From ac108e454f4afe93d518addbe9f411a318b8ee9f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 10:58:07 -0700 Subject: [PATCH 001/103] checkpoint --- .../copilot/request/tools/executor.test.ts | 58 ++++++++- .../sim/lib/copilot/request/tools/executor.ts | 7 +- .../server/knowledge/knowledge-base.test.ts | 42 ++++++ .../tools/server/knowledge/knowledge-base.ts | 61 ++++++++- .../knowledge/application/documents.test.ts | 122 ++++++++++++++++++ .../lib/knowledge/application/documents.ts | 84 +++++++++++- 6 files changed, 366 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 06c86e224fa..58b62d95f8e 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -73,7 +73,12 @@ vi.mock('@/lib/copilot/request/tools/workflow-context', () => ({ })) import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import { + MothershipStreamV1EventType, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { GenerateApiKey } from '@/lib/copilot/generated/tool-catalog-v1' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolExecutionContext, @@ -330,6 +335,57 @@ describe('executeToolAndReport provenance isolation', () => { expect(registry.getActiveMatches()).toEqual([]) expect(JSON.stringify([completion, onEvent.mock.calls])).not.toContain('secret-value') }) + + it('reveals a generated API key only in the live client event', async () => { + const generatedKey = 'sk-sim-one-time-secret' + const statusMessage = 'API key "streaming-test" created.' + executeTool.mockResolvedValueOnce({ + success: true, + output: { + id: 'key-1', + name: 'streaming-test', + key: generatedKey, + workspaceId: 'workspace-1', + message: statusMessage, + }, + }) + const toolCall: ToolCallState = { + id: 'generate-key-call', + name: GenerateApiKey.id, + status: 'pending', + params: { name: 'streaming-test' }, + } + + const completion = await executeToolAndReport( + toolCall.id, + buildStreamingContext(toolCall), + { userId: 'user-1', workflowId: 'workflow-1' }, + { onEvent } + ) + + expect(completion).toEqual({ + status: MothershipStreamV1ToolOutcome.success, + message: 'Tool completed', + data: statusMessage, + }) + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ result: statusMessage }) + ) + expect(JSON.stringify([completion, completeAsyncToolCall.mock.calls])).not.toContain( + generatedKey + ) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + toolName: GenerateApiKey.id, + phase: MothershipStreamV1ToolPhase.result, + success: true, + output: expect.objectContaining({ key: generatedKey }), + }), + }) + ) + }) }) describe('executeToolAndReport metrics', () => { diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 693a8c325d2..abab3329518 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -32,6 +32,7 @@ import { EditContent, Ffmpeg, FunctionExecute, + GenerateApiKey, GenerateAudio, GenerateImage, GenerateVideo, @@ -809,6 +810,10 @@ async function executeToolAndReportInner( // Fire-and-forget: notify the copilot backend that the tool completed. // IMPORTANT: We must NOT await this — the Go backend may block on the + const clientEventOutput = + toolCall.name === GenerateApiKey.id && hasOutputValue(copilotResult) + ? copilotResult.output + : terminalData const resultEvent: StreamEvent = { type: MothershipStreamV1EventType.tool, payload: { @@ -818,7 +823,7 @@ async function executeToolAndReportInner( mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, success: modelSucceeded, - output: terminalData, + output: clientEventOutput, ...(modelSucceeded ? { status: MothershipStreamV1ToolOutcome.success } : { status: MothershipStreamV1ToolOutcome.error, error: terminalMessage }), diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index f4eff85a65f..8d1d5ba941f 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -565,6 +565,48 @@ describe('knowledge_base trusted application delegation', () => { }) }) + it('delegates per-document typed tag values by tag definition ID', async () => { + mockUpdateKnowledgeDocument.mockResolvedValueOnce({ + document: {}, + updatedFields: ['tag1', 'number1'], + }) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'update_document', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + documentId: 'document-1', + tagValues: [ + { tagDefinitionId: 'category-tag', value: 'support' }, + { tagDefinitionId: 'priority-tag', value: 2 }, + ], + }, + }, + CONTEXT + ) + + expect(result).toMatchObject({ + success: true, + data: { + documentId: 'document-1', + tagDefinitionIds: ['category-tag', 'priority-tag'], + }, + }) + const call = mockUpdateKnowledgeDocument.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toEqual({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + documentId: 'document-1', + assertedWorkspaceId: 'workspace-paid', + tagValues: [ + { tagDefinitionId: 'category-tag', value: 'support' }, + { tagDefinitionId: 'priority-tag', value: 2 }, + ], + source: 'agent', + }) + }) + it('does not expose connector infrastructure errors to the model', async () => { mockUpdateKnowledgeConnector.mockRejectedValueOnce(new Error('sql host=private-db')) 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 2e97eaf547d..1f5aa48d4c5 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -30,6 +30,7 @@ import { } from '@/lib/knowledge/application/connectors' import { bulkDeleteKnowledgeDocuments, + type KnowledgeDocumentTagValueAssignment, updateKnowledgeDocument, } from '@/lib/knowledge/application/documents' import { @@ -46,7 +47,7 @@ import { readKnowledgeTagUsage, updateKnowledgeTag, } from '@/lib/knowledge/application/tags' -import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' +import { ALL_TAG_SLOTS, KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' import { captureServerEvent } from '@/lib/posthog/server' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' @@ -230,6 +231,23 @@ type KnowledgeBaseResult = { data?: any } +function isKnowledgeDocumentTagValueAssignment( + value: unknown +): value is KnowledgeDocumentTagValueAssignment { + if (typeof value !== 'object' || value === null) return false + const assignment = value as Record + if (typeof assignment.tagDefinitionId !== 'string' || !assignment.tagDefinitionId.trim()) { + return false + } + if (!Object.hasOwn(assignment, 'value')) return false + return ( + assignment.value === null || + typeof assignment.value === 'string' || + typeof assignment.value === 'number' || + typeof assignment.value === 'boolean' + ) +} + /** * Knowledge base tool for copilot to create, list, and get knowledge bases */ @@ -613,17 +631,42 @@ export const knowledgeBaseServerTool: BaseServerTool ALL_TAG_SLOTS.length) { + return { + success: false, + message: `Too many tag values (${args.tagValues.length}). Maximum is ${ALL_TAG_SLOTS.length}.`, + } + } + updateData.tagValues = args.tagValues + } if (Object.keys(updateData).length === 0) { return { success: false, - message: 'At least one of filename or enabled is required for update_document', + message: + 'At least one of filename, enabled, or tagValues is required for update_document', } } assertNotAborted() @@ -641,7 +684,17 @@ export const knowledgeBaseServerTool: BaseServerTool assignment.tagDefinitionId + ), + }), }, } } diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index 8df17daf2ee..41190fff76f 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ recordKnowledgeBaseFileOwnership: vi.fn(), recordAudit: vi.fn(), captureServerEvent: vi.fn(), + getDocumentTagDefinitions: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -71,6 +72,10 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ getProcessingConfig: mocks.getProcessingConfig, })) +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: mocks.getDocumentTagDefinitions, +})) + vi.mock('@/lib/knowledge/orchestration/documents', () => ({ performUploadKnowledgeDocument: mocks.performSingleUpload, performUploadKnowledgeDocuments: mocks.performBulkUpload, @@ -524,6 +529,123 @@ describe('knowledge document application use cases', () => { ) }) + it('resolves typed tag-definition assignments into document tag slots', async () => { + mocks.getDocumentTagDefinitions.mockResolvedValueOnce([ + { + id: 'category-tag', + knowledgeBaseId: 'knowledge-1', + tagSlot: 'tag1', + displayName: 'Category', + fieldType: 'text', + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'priority-tag', + knowledgeBaseId: 'knowledge-1', + tagSlot: 'number1', + displayName: 'Priority', + fieldType: 'number', + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'reviewed-tag', + knowledgeBaseId: 'knowledge-1', + tagSlot: 'boolean1', + displayName: 'Reviewed', + fieldType: 'boolean', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await updateKnowledgeDocument.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: {}, + }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-1', + assertedWorkspaceId: 'workspace-1', + tagValues: [ + { tagDefinitionId: 'category-tag', value: 'support' }, + { tagDefinitionId: 'priority-tag', value: 2 }, + { tagDefinitionId: 'reviewed-tag', value: false }, + ], + source: 'agent', + }, + }) + + expect(mocks.updateDocument).toHaveBeenCalledWith( + 'document-1', + { + filename: undefined, + enabled: undefined, + tag1: 'support', + number1: '2', + boolean1: 'false', + }, + expect.any(String) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + tagDefinitionIds: ['category-tag', 'priority-tag', 'reviewed-tag'], + }), + }) + ) + }) + + it('rejects a tag value that does not match its definition type', async () => { + mocks.getDocumentTagDefinitions.mockResolvedValueOnce([ + { + id: 'priority-tag', + knowledgeBaseId: 'knowledge-1', + tagSlot: 'number1', + displayName: 'Priority', + fieldType: 'number', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await expect( + updateKnowledgeDocument.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: {}, + }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-1', + assertedWorkspaceId: 'workspace-1', + tagValues: [{ tagDefinitionId: 'priority-tag', value: 'urgent' }], + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Tag "Priority" expects a number value, but received "urgent"', + }) + + expect(mocks.updateDocument).not.toHaveBeenCalled() + }) + it('propagates document infrastructure failures without audit', async () => { const failure = new Error('storage ledger unavailable') mocks.createDocument.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 11b1add79b2..cf5d1f987e4 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -33,7 +33,11 @@ import { resolveCanonicalActiveKnowledgeDocumentContext, } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE } from '@/lib/knowledge/constants' +import { + ALL_TAG_SLOTS, + type AllTagSlot, + MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE, +} from '@/lib/knowledge/constants' import { bulkDocumentOperation, bulkDocumentOperationByFilter, @@ -56,6 +60,8 @@ import { performUploadKnowledgeDocuments, } from '@/lib/knowledge/orchestration/documents' import type { KnowledgeDocumentWriteSecretProvenance } from '@/lib/knowledge/secret-provenance' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { validateTagValue } from '@/lib/knowledge/tags/utils' import { StorageService } from '@/lib/uploads' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' @@ -170,6 +176,7 @@ type BulkDeleteKnowledgeDocumentsContext = ActiveKnowledgeResourceBaseContext & export interface UpdateKnowledgeDocumentInput extends ReadKnowledgeDocumentInput { filename?: string enabled?: boolean + tagValues?: KnowledgeDocumentTagValueAssignment[] updates?: Parameters[1] markFailedDueToTimeout?: boolean retryProcessing?: boolean @@ -177,6 +184,68 @@ export interface UpdateKnowledgeDocumentInput extends ReadKnowledgeDocumentInput source?: string } +export interface KnowledgeDocumentTagValueAssignment { + tagDefinitionId: string + value: string | number | boolean | null +} + +type KnowledgeDocumentUpdates = Parameters[1] + +function isAllTagSlot(tagSlot: string): tagSlot is AllTagSlot { + return (ALL_TAG_SLOTS as readonly string[]).includes(tagSlot) +} + +async function resolveKnowledgeDocumentTagValueUpdates( + knowledgeBaseId: string, + tagValues: readonly KnowledgeDocumentTagValueAssignment[] +): Promise { + const definitions = await getDocumentTagDefinitions(knowledgeBaseId) + const definitionsById = new Map(definitions.map((definition) => [definition.id, definition])) + const seenDefinitionIds = new Set() + const updates: KnowledgeDocumentUpdates = {} + + for (const assignment of tagValues) { + if (seenDefinitionIds.has(assignment.tagDefinitionId)) { + throw new OrchestrationError( + 'validation', + `Duplicate tag definition ID: ${assignment.tagDefinitionId}` + ) + } + seenDefinitionIds.add(assignment.tagDefinitionId) + + const definition = definitionsById.get(assignment.tagDefinitionId) + if (!definition) { + throw new OrchestrationError( + 'validation', + `Tag definition ${assignment.tagDefinitionId} does not belong to this knowledge base` + ) + } + if (!isAllTagSlot(definition.tagSlot)) { + throw new Error(`Tag definition ${definition.id} has an unsupported slot`) + } + + if (assignment.value === null) { + updates[definition.tagSlot] = '' + continue + } + + const value = String(assignment.value).trim() + if (!value) { + throw new OrchestrationError( + 'validation', + `Tag "${definition.displayName}" requires a value; use null to clear it` + ) + } + const validationError = validateTagValue(definition.displayName, value, definition.fieldType) + if (validationError) { + throw new OrchestrationError('validation', validationError) + } + updates[definition.tagSlot] = value + } + + return updates +} + export interface BulkKnowledgeDocumentsInput extends UploadKnowledgeDocumentAdmissionInput { operation: 'enable' | 'disable' | 'delete' documentIds?: string[] @@ -790,7 +859,15 @@ export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ message: outcome.message, } } - const updates = input.updates ?? { filename: input.filename, enabled: input.enabled } + const updates: KnowledgeDocumentUpdates = input.updates + ? { ...input.updates } + : { filename: input.filename, enabled: input.enabled } + if (input.tagValues !== undefined) { + Object.assign( + updates, + await resolveKnowledgeDocumentTagValueUpdates(context.knowledgeBaseId, input.tagValues) + ) + } const updatedFields = Object.keys(updates).filter( (key) => updates[key as keyof typeof updates] !== undefined ) @@ -818,6 +895,9 @@ export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ fileName: result.document.filename, updatedFields: result.updatedFields, ...(input.enabled !== undefined && { enabled: input.enabled }), + ...(input.tagValues !== undefined && { + tagDefinitionIds: input.tagValues.map((assignment) => assignment.tagDefinitionId), + }), }, } }, From 797076c1ea465aad13defc604b83bceecec98949 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 11:06:46 -0700 Subject: [PATCH 002/103] Checkpoint --- .../lib/copilot/generated/tool-catalog-v1.ts | 50 ++++++++++++++----- .../lib/copilot/generated/tool-schemas-v1.ts | 46 ++++++++++++----- .../copilot/request/tools/executor.test.ts | 6 ++- .../sim/lib/copilot/request/tools/executor.ts | 6 +-- bun.lock | 4 ++ 5 files changed, 84 insertions(+), 28 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 2248179ea8e..805bdf29f80 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -295,13 +295,25 @@ export const Browser: ToolCatalogEntry = { mode: 'async', parameters: { properties: { + sessionId: { + description: + 'Reusable session ID returned by an earlier browser call in this chat. Supply it only on a later user message that continues the same browsing objective, and at most once per user message.', + type: 'string', + }, task: { description: - 'The web task to complete, in plain language (include the target site/URL if known).', + "Optional brief scoping instruction that the conversation does not already convey. Do not restate the user's request.", + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this Browser Agent session's stable objective. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, - required: ['task'], + required: ['title'], type: 'object', }, subagentId: 'browser', @@ -1248,16 +1260,14 @@ export const Cp: ToolCatalogEntry = { properties: { destination: { type: 'string', - maxLength: 4096, description: 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', - maxItems: 100, description: 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string', maxLength: 4096 }, + items: { type: 'string' }, }, toolTitle: { type: 'string', @@ -3268,6 +3278,26 @@ export const KnowledgeBase: ToolCatalogEntry = { 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', enum: ['text', 'number', 'date', 'boolean'], }, + tagValues: { + type: 'array', + description: + 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', + items: { + type: 'object', + properties: { + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID returned by list_tags.', + }, + value: { + type: ['string', 'number', 'boolean', 'null'], + description: + "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", + }, + }, + required: ['tagDefinitionId', 'value'], + }, + }, topK: { type: 'number', description: 'Number of results to return (1-50, default: 5)', @@ -3716,10 +3746,9 @@ export const Mkdir: ToolCatalogEntry = { properties: { paths: { type: 'array', - maxItems: 100, description: 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', - items: { type: 'string', maxLength: 4096 }, + items: { type: 'string' }, }, toolTitle: { type: 'string', @@ -3742,16 +3771,14 @@ export const Mv: ToolCatalogEntry = { properties: { destination: { type: 'string', - maxLength: 4096, description: 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', - maxItems: 100, description: 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string', maxLength: 4096 }, + items: { type: 'string' }, }, toolTitle: { type: 'string', @@ -4184,10 +4211,9 @@ export const Rm: ToolCatalogEntry = { properties: { paths: { type: 'array', - maxItems: 100, description: 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', - items: { type: 'string', maxLength: 4096 }, + items: { type: 'string' }, }, toolTitle: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 7248ec6dc7e..a7d92662bf8 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -39,13 +39,25 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { browser: { parameters: { properties: { + sessionId: { + description: + 'Reusable session ID returned by an earlier browser call in this chat. Supply it only on a later user message that continues the same browsing objective, and at most once per user message.', + type: 'string', + }, task: { description: - 'The web task to complete, in plain language (include the target site/URL if known).', + "Optional brief scoping instruction that the conversation does not already convey. Do not restate the user's request.", + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this Browser Agent session's stable objective. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, - required: ['task'], + required: ['title'], type: 'object', }, resultSchema: undefined, @@ -1112,18 +1124,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { destination: { type: 'string', - maxLength: 4096, description: 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', - maxItems: 100, description: 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', - maxLength: 4096, }, }, toolTitle: { @@ -3164,6 +3173,26 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', enum: ['text', 'number', 'date', 'boolean'], }, + tagValues: { + type: 'array', + description: + 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', + items: { + type: 'object', + properties: { + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID returned by list_tags.', + }, + value: { + type: ['string', 'number', 'boolean', 'null'], + description: + "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", + }, + }, + required: ['tagDefinitionId', 'value'], + }, + }, topK: { type: 'number', description: 'Number of results to return (1-50, default: 5)', @@ -3596,12 +3625,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { paths: { type: 'array', - maxItems: 100, description: 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string', - maxLength: 4096, }, }, toolTitle: { @@ -3620,18 +3647,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { destination: { type: 'string', - maxLength: 4096, description: 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', - maxItems: 100, description: 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', - maxLength: 4096, }, }, toolTitle: { @@ -4067,12 +4091,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { paths: { type: 'array', - maxItems: 100, description: 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', items: { type: 'string', - maxLength: 4096, }, }, toolTitle: { diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 58b62d95f8e..b40964ab425 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -359,7 +359,11 @@ describe('executeToolAndReport provenance isolation', () => { const completion = await executeToolAndReport( toolCall.id, buildStreamingContext(toolCall), - { userId: 'user-1', workflowId: 'workflow-1' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), + }, { onEvent } ) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index abab3329518..01845830153 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -808,10 +808,10 @@ async function executeToolAndReportInner( return cancelledCompletion('Request aborted before tool result delivery') } - // Fire-and-forget: notify the copilot backend that the tool completed. - // IMPORTANT: We must NOT await this — the Go backend may block on the + // A newly generated API key is intentionally included only in this + // live/replay client event. Model-facing results and long-term chat records stay redacted. const clientEventOutput = - toolCall.name === GenerateApiKey.id && hasOutputValue(copilotResult) + toolCall.name === GenerateApiKey.id && modelSucceeded && hasOutputValue(copilotResult) ? copilotResult.output : terminalData const resultEvent: StreamEvent = { diff --git a/bun.lock b/bun.lock index efb1d9c2298..a6a5f598185 100644 --- a/bun.lock +++ b/bun.lock @@ -588,10 +588,14 @@ "packages/terminal-protocol": { "name": "@sim/terminal-protocol", "version": "0.1.0", + "dependencies": { + "@sim/utils": "workspace:*", + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", "typescript": "^7.0.2", + "vitest": "^4.1.0", }, }, "packages/testing": { From a63c8fa35af1c42286e807576b312a2102aea51b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 11:31:31 -0700 Subject: [PATCH 003/103] dot fixes --- .../lib/copilot/async-runs/repository.test.ts | 24 +++ apps/sim/lib/copilot/constants.ts | 13 ++ .../copilot/request/handlers/handlers.test.ts | 123 ++++++++++++- apps/sim/lib/copilot/request/handlers/tool.ts | 88 ++++++--- .../tools/workflow-client-fallback.test.ts | 169 ++++++++++++++++++ .../request/tools/workflow-client-fallback.ts | 140 +++++++++++++++ apps/sim/lib/copilot/tool-executor/types.ts | 8 + .../tools/client/run-tool-execution.test.ts | 26 +++ .../tools/client/run-tool-execution.ts | 19 ++ .../tools/handlers/workflow/mutations.ts | 10 ++ .../run-workflow-from-copilot.test.ts | 65 +++++++ .../application/run-workflow-from-copilot.ts | 34 +++- 12 files changed, 690 insertions(+), 29 deletions(-) create mode 100644 apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts create mode 100644 apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index fcd9c01a4e7..e6098a8b851 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, @@ -162,6 +163,29 @@ describe('async tool repository single-row semantics', () => { await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull() }) + it('overwrites a workflow execution claim once the sim path starts running it', async () => { + // The server-side fallback claims `workflow:` and then immediately runs + // the tool, whose executor re-marks the row as running under 'sim-stream'. + // The claim value is therefore NOT durable identity — only its + // `claimedBy IS NULL` precondition is load-bearing, since that is what keeps + // a late browser locked out. Pinning this so nobody builds on reading it back. + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'running', + claimedBy: 'sim-stream', + }, + ]) + + const result = await markAsyncToolRunning('workflow-tool', 'sim-stream') + + expect(result).toMatchObject({ claimedBy: 'sim-stream' }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ claimedBy: 'sim-stream' }) + ) + expect(getClaimedWorkflowExecutionId('sim-stream')).toBeUndefined() + }) + it('releases a matching pre-start workflow claim without changing its lifecycle status', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index 5102f753d51..1df1be6c40e 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -36,6 +36,19 @@ export const TOOL_WATCHDOG_RESUME_GRACE_MS = 30_000 /** Timeout for the client-side streaming response handler (60 min). */ export const STREAM_TIMEOUT_MS = 3_600_000 +/** + * How long a workflow tool call waits for a browser to pick it up before the + * server runs it itself. + * + * Workflow tools are client-routed, but the only thing that starts one is the + * mounted chat view — a call frame that arrives while the user is on a + * different chat is never dispatched by anyone, and the turn used to park for + * the full STREAM_TIMEOUT_MS. The real pickup path (stream frame -> execute + * POST -> claim) lands in ~1-3s, so 30s is an order of magnitude of headroom + * and cannot steal work from a live tab. + */ +export const COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS = 30_000 + /** SessionStorage key for persisting active stream metadata across page reloads. */ export const STREAM_STORAGE_KEY = 'copilot_active_stream' diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 9bcf12d9d7c..a6ace4a3acf 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -15,10 +15,16 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ +const { + upsertAsyncToolCall, + markAsyncToolRunning, + completeAsyncToolCall, + claimWorkflowToolExecution, +} = vi.hoisted(() => ({ upsertAsyncToolCall: vi.fn(), markAsyncToolRunning: vi.fn(), completeAsyncToolCall: vi.fn(), + claimWorkflowToolExecution: vi.fn().mockResolvedValue(null), })) const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = @@ -56,6 +62,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, + claimWorkflowToolExecution, })) vi.mock('@/lib/copilot/request/tools/client', () => ({ @@ -578,11 +585,13 @@ describe('sse-handlers tool lifecycle', () => { await sleep(0) await Promise.allSettled(context.pendingToolPromises.values()) + // The waiter always receives a signal now: the server fallback needs a + // handle to cancel its own wait if it ends up running the tool itself. expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith({ toolCallId: 'tool-background', workflowId: 'workflow-1', timeoutMs: 1000, - abortSignal: undefined, + abortSignal: expect.any(AbortSignal), registry: execContext.resolvedSecretTraceRegistry, }) expect(onEvent).toHaveBeenCalledWith( @@ -644,6 +653,116 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('runs a workflow tool server-side when no browser picks it up', async () => { + // Nobody claims it, the wait expires, and the server wins the claim. + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockResolvedValueOnce({ toolCallId: 'tool-unclaimed' }) + executeTool.mockResolvedValueOnce({ success: true, output: { ran: 'on-server' } }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-unclaimed', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: true, timeout: 1 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + // Regression guard: the wait sets the call to 'executing' before parking, + // and executeToolAndReport short-circuits anything already 'executing'. If + // the handoff stops resetting the status, executeTool is never reached and + // the workflow silently does not run. + expect(executeTool).toHaveBeenCalled() + expect(executeTool.mock.calls.at(-1)?.[0]).toBe('run_workflow') + // The claimed execution id must reach the handler so the run is attributable. + expect(executeTool.mock.calls.at(-1)?.[2]?.boundWorkflowExecutionId).toBeTruthy() + + const workflowResults = onEvent.mock.calls + .map(([event]) => event) + .filter( + (event) => + event?.type === MothershipStreamV1EventType.tool && + event.payload?.toolCallId === 'tool-unclaimed' && + event.payload?.phase === MothershipStreamV1ToolPhase.result + ) + // Exactly one result, from the sim path — no client-flavored duplicate on top. + expect(workflowResults).toHaveLength(1) + expect(workflowResults[0].payload.executor).toBe(MothershipStreamV1ToolExecutor.sim) + }) + + it('claims and runs the same workflow when the call omits an explicit workflowId', async () => { + // The waiter resolves the target via resolveWorkflowToolTargetId(args, ctx) + // while the handler resolves it as params.workflowId || context.workflowId. + // If those two ever diverge, the fallback would claim one workflow and run + // another. + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockResolvedValueOnce({ toolCallId: 'tool-implicit-workflow' }) + executeTool.mockResolvedValueOnce({ success: true, output: {} }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-implicit-workflow', + toolName: 'run_workflow', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: true, timeout: 1 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-1' }) + ) + expect(executeTool.mock.calls.at(-1)?.[2]?.workflowId).toBe('workflow-1') + }) + + it('does not run a workflow tool server-side when a browser holds the claim', async () => { + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockResolvedValueOnce(null) + executeTool.mockClear() + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-claimed-elsewhere', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: true, timeout: 1 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(executeTool).not.toHaveBeenCalled() + }) + it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { waitForClientToolCompletion.mockResolvedValueOnce({ status: 'success', diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 415e7475eed..d08f04b486d 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -2,9 +2,12 @@ import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import type { + AsyncCompletionSignal, + AsyncTerminalCompletionSnapshot, +} from '@/lib/copilot/async-runs/lifecycle' import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' -import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' +import { COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, type MothershipStreamV1ToolCallDescriptor, @@ -24,10 +27,7 @@ import { } from '@/lib/copilot/request/session' import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' -import { - waitForClientToolCompletion, - waitForWorkflowToolCompletion, -} from '@/lib/copilot/request/tools/client' +import { waitForClientToolCompletion } from '@/lib/copilot/request/tools/client' import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' import { executeToolAndReport } from '@/lib/copilot/request/tools/executor' import { @@ -35,6 +35,7 @@ import { TOOL_AWAITING_APPROVAL_STATUS, toolCallNeedsApproval, } from '@/lib/copilot/request/tools/permission' +import { raceWorkflowToolClientPickup } from '@/lib/copilot/request/tools/workflow-client-fallback' import type { ExecutionContext, OrchestratorOptions, @@ -671,9 +672,11 @@ async function dispatchToolExecution( ): Promise { const scopeLabel = scope === 'subagent' ? 'subagent ' : '' - const fireToolExecution = (): Promise => { + const fireToolExecution = ( + execContextOverride?: ExecutionContext + ): Promise => { return (async () => { - return executeToolAndReport(toolCallId, context, execContext, options) + return executeToolAndReport(toolCallId, context, execContextOverride ?? execContext, options) })().catch((err) => { logger.error(`Parallel ${scopeLabel}tool execution failed`, { toolCallId, @@ -754,23 +757,58 @@ async function dispatchToolExecution( ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), }, async (span) => { - const completion = isWorkflowToolName(toolName) - ? await waitForWorkflowToolCompletion({ - toolCallId, - workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), - timeoutMs: timeoutMs ?? STREAM_TIMEOUT_MS, - abortSignal: options.abortSignal, - registry: execContext.resolvedSecretTraceRegistry, - }) - : await waitForClientToolCompletion({ - toolCallId, - runId: context.runId, - userId: execContext.userId, - timeoutMs, - abortSignal: options.abortSignal, - registry: execContext.resolvedSecretTraceRegistry, - }) - span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) + let completion: AsyncTerminalCompletionSnapshot | null + if (isWorkflowToolName(toolName)) { + const race = await raceWorkflowToolClientPickup({ + toolCallId, + workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), + timeoutMs: timeoutMs ?? STREAM_TIMEOUT_MS, + graceMs: COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + runOnServer: (boundExecutionId) => { + // `executeToolAndReportInner` short-circuits a call that is + // already 'executing' — which is exactly what this wait set it to + // before parking. Hand it back the state it dispatches from. + toolCall.status = 'pending' + return fireToolExecution({ + ...execContext, + boundWorkflowExecutionId: boundExecutionId, + }) + }, + }) + + if (race.winner === 'sim') { + // `executeToolAndReport` already emitted its own `executor: sim` + // result and marked it seen, so the client-completion bookkeeping + // below must not run again on top of it. + span.setAttribute(TraceAttr.ToolExecutor, MothershipStreamV1ToolExecutor.sim) + if (race.signal) { + span.setAttribute(TraceAttr.ToolOutcome, race.signal.status) + } + return ( + race.signal ?? { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool completion missing', + data: { error: 'Tool completion missing' }, + } + ) + } + completion = race.completion ?? null + } else { + completion = await waitForClientToolCompletion({ + toolCallId, + runId: context.runId, + userId: execContext.userId, + timeoutMs, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) + } + span.setAttribute(TraceAttr.ToolExecutor, MothershipStreamV1ToolExecutor.client) + // Both waiters resolve `T | null`, never undefined — comparing against + // undefined made this a constant `true` and hid every timeout. + span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== null) if (completion) { span.setAttribute(TraceAttr.ToolOutcome, completion.status) } diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts new file mode 100644 index 00000000000..328d62c6d39 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { waitForWorkflowToolCompletion, claimWorkflowToolExecution } = vi.hoisted(() => ({ + waitForWorkflowToolCompletion: vi.fn(), + claimWorkflowToolExecution: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/tools/client', () => ({ + waitForWorkflowToolCompletion, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + claimWorkflowToolExecution, +})) + +import { raceWorkflowToolClientPickup } from '@/lib/copilot/request/tools/workflow-client-fallback' + +const GRACE_MS = 30_000 +const TIMEOUT_MS = 3_600_000 + +/** Captures the abort signal the waiter was handed so tests can assert teardown. */ +let waiterSignals: (AbortSignal | undefined)[] = [] + +/** Models the real waiter: pends until aborted, then resolves null. */ +function pendingUntilAborted() { + waitForWorkflowToolCompletion.mockImplementation(({ abortSignal }) => { + waiterSignals.push(abortSignal) + return new Promise((resolve) => { + if (abortSignal?.aborted) { + resolve(null) + return + } + abortSignal?.addEventListener('abort', () => resolve(null), { once: true }) + }) + }) +} + +function baseParams(overrides: Record = {}) { + return { + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: TIMEOUT_MS, + graceMs: GRACE_MS, + runOnServer: vi.fn().mockResolvedValue({ status: 'success' }), + ...overrides, + } +} + +describe('raceWorkflowToolClientPickup', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + waiterSignals = [] + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('lets the client win without ever attempting a claim', async () => { + waitForWorkflowToolCompletion.mockResolvedValue({ status: 'success', data: { ok: true } }) + const params = baseParams() + + const outcome = await raceWorkflowToolClientPickup(params as never) + + expect(outcome.winner).toBe('client') + expect(outcome.completion).toEqual({ status: 'success', data: { ok: true } }) + expect(claimWorkflowToolExecution).not.toHaveBeenCalled() + expect(params.runOnServer).not.toHaveBeenCalled() + }) + + it('runs the tool server-side when the grace elapses and the claim is won', async () => { + pendingUntilAborted() + claimWorkflowToolExecution.mockResolvedValue({ toolCallId: 'tool-1' }) + const params = baseParams() + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(GRACE_MS) + const outcome = await promise + + expect(outcome.winner).toBe('sim') + expect(outcome.signal).toEqual({ status: 'success' }) + expect(claimWorkflowToolExecution).toHaveBeenCalledTimes(1) + expect(params.runOnServer).toHaveBeenCalledTimes(1) + // The claimed id is what the server run must bind to. + expect(params.runOnServer).toHaveBeenCalledWith(outcome.boundExecutionId) + expect(outcome.boundExecutionId).toBeTruthy() + // The client waiter must be torn down before the server runs, or the sim + // path's own confirmation would wake it and emit a duplicate result. + expect(waiterSignals.at(0)?.aborted).toBe(true) + }) + + it('keeps waiting on the browser when the claim is lost', async () => { + // The waiter stays pending until the "browser" reports, so we can assert the + // helper went back to waiting on the same promise rather than running. + let reportFromBrowser!: (value: unknown) => void + waitForWorkflowToolCompletion.mockImplementation(({ abortSignal }) => { + waiterSignals.push(abortSignal) + return new Promise((resolve) => { + reportFromBrowser = resolve + }) + }) + claimWorkflowToolExecution.mockResolvedValue(null) + const params = baseParams() + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(GRACE_MS) + + expect(claimWorkflowToolExecution).toHaveBeenCalledTimes(1) + expect(params.runOnServer).not.toHaveBeenCalled() + // The waiter must NOT have been torn down — a browser owns this call. + expect(waiterSignals.at(0)?.aborted).toBe(false) + + reportFromBrowser({ status: 'success', data: { ranInBrowser: true } }) + const outcome = await promise + + expect(outcome.winner).toBe('client') + expect(outcome.completion).toEqual({ status: 'success', data: { ranInBrowser: true } }) + expect(waitForWorkflowToolCompletion).toHaveBeenCalledTimes(1) + }) + + it('never claims work on a turn the user already stopped', async () => { + pendingUntilAborted() + const abortController = new AbortController() + const params = baseParams({ abortSignal: abortController.signal }) + + const promise = raceWorkflowToolClientPickup(params as never) + abortController.abort() + await vi.advanceTimersByTimeAsync(GRACE_MS) + const outcome = await promise + + expect(outcome.winner).toBe('client') + expect(claimWorkflowToolExecution).not.toHaveBeenCalled() + expect(params.runOnServer).not.toHaveBeenCalled() + }) + + it('still falls back when the wait expires before the grace window', async () => { + // A caller-supplied timeout shorter than the grace makes the waiter resolve + // null first; that is an expired wait, not a missing completion. + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockResolvedValue({ toolCallId: 'tool-1' }) + const params = baseParams({ timeoutMs: 1_000 }) + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(1_000) + const outcome = await promise + + expect(outcome.winner).toBe('sim') + expect(params.runOnServer).toHaveBeenCalledTimes(1) + }) + + it('keeps waiting on the browser when the claim itself errors', async () => { + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockRejectedValue(new Error('db down')) + const params = baseParams({ timeoutMs: 1_000 }) + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(1_000) + const outcome = await promise + + // Losing the claim to an error must never become a second execution. + expect(outcome.winner).toBe('client') + expect(params.runOnServer).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts new file mode 100644 index 00000000000..5e63209ef6d --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts @@ -0,0 +1,140 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import type { + AsyncCompletionSignal, + AsyncTerminalCompletionSnapshot, +} from '@/lib/copilot/async-runs/lifecycle' +import { claimWorkflowToolExecution } from '@/lib/copilot/async-runs/repository' +import { waitForWorkflowToolCompletion } from '@/lib/copilot/request/tools/client' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const logger = createLogger('CopilotWorkflowClientFallback') + +/** Which side actually ran the workflow for this tool call. */ +export type WorkflowToolWinner = 'client' | 'sim' + +export interface WorkflowToolRaceOutcome { + winner: WorkflowToolWinner + /** Set when `winner === 'client'`; null means the client wait timed out. */ + completion?: AsyncTerminalCompletionSnapshot | null + /** Set when `winner === 'sim'`. */ + signal?: AsyncCompletionSignal + /** The execution id the server claimed, when it won. */ + boundExecutionId?: string +} + +interface RaceWorkflowToolClientPickupParams { + toolCallId: string + workflowId?: string + timeoutMs: number + graceMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry + /** Runs the tool in-process; only invoked after the execution claim is won. */ + runOnServer: (boundExecutionId: string) => Promise +} + +/** + * Wait for a browser to run a workflow tool call, and run it here if none does. + * + * Workflow tools are client-routed, but the only thing that dispatches one is + * the mounted chat view. A call frame that arrives while the user sits on a + * different chat is picked up by nobody, and the turn used to park for the full + * `timeoutMs` (an hour) before failing. + * + * After `graceMs` with no result, this competes for the same single-winner + * execution claim that `/api/workflows/[id]/execute` takes on the browser's + * behalf. Losing the claim means a browser really is running it, so we go back + * to waiting; winning it means nobody was there, so we run it in-process. + * Because both sides contend on `claimedBy IS NULL`, the workflow can never run + * twice — a browser arriving late gets a 409 it already treats as benign. + */ +export async function raceWorkflowToolClientPickup( + params: RaceWorkflowToolClientPickupParams +): Promise { + const { toolCallId, workflowId, timeoutMs, graceMs, abortSignal, registry, runOnServer } = params + + // Cancels only OUR client waiter once the server takes over, without + // disturbing the caller's turn-level abort signal. + const cancelClientWait = new AbortController() + const clientWaitSignal = abortSignal + ? AbortSignal.any([abortSignal, cancelClientWait.signal]) + : cancelClientWait.signal + + // Exactly one waiter for the whole race — a second one would double-consume + // the confirmation and emit a duplicate tool result. + const clientWait = waitForWorkflowToolCompletion({ + toolCallId, + workflowId, + timeoutMs, + abortSignal: clientWaitSignal, + registry, + }) + + // A caller-supplied timeout shorter than the grace must win, or the grace + // would outlive the wait it is supposed to bound. + const effectiveGraceMs = Math.min(graceMs, timeoutMs) + + const first = await Promise.race([ + clientWait.then((completion) => ({ kind: 'client' as const, completion })), + sleep(effectiveGraceMs).then(() => ({ kind: 'grace' as const })), + ]) + + // A non-null client result inside the grace window is the normal path. + // A null one means the wait itself already expired (timeoutMs <= graceMs), so + // fall through and try the claim rather than reporting a missing completion. + if (first.kind === 'client' && first.completion !== null) { + return { winner: 'client', completion: first.completion } + } + + // Never claim work on a turn the user already stopped. + if (abortSignal?.aborted) { + return { winner: 'client', completion: await clientWait } + } + + const boundExecutionId = generateId() + // The repository returns `row ?? null`, but with no `noUncheckedIndexedAccess` + // the destructured row types as non-optional and the null collapses away. + // It is genuinely null when the claim is lost, so widen it back — the same + // reality `/api/workflows/[id]/execute` leans on for its `if (!boundToolCall)`. + let claimed: Awaited> | null = null + try { + claimed = await claimWorkflowToolExecution(toolCallId, boundExecutionId) + } catch (error) { + // Losing the claim to an error is not a reason to run the workflow twice; + // fall back to waiting on the browser exactly as before. + logger.warn('Failed to claim workflow tool execution for server fallback', { + toolCallId, + workflowId, + error: toError(error).message, + }) + return { winner: 'client', completion: await clientWait } + } + + if (!claimed) { + logger.info('Workflow tool already claimed by a client; continuing to wait', { + toolCallId, + workflowId, + }) + return { winner: 'client', completion: await clientWait } + } + + logger.info('No client picked up workflow tool within grace; running it server-side', { + toolCallId, + workflowId, + boundExecutionId, + graceMs: effectiveGraceMs, + }) + + // Tear the waiter down BEFORE running in-process. The server path publishes + // its own terminal confirmation on the same channel this waiter subscribes + // to, so a live waiter would resolve with our own result and emit a second, + // client-flavored tool result on top of it. Awaiting is what guarantees the + // subscription is gone, not just signalled. + cancelClientWait.abort() + await clientWait + + return { winner: 'sim', signal: await runOnServer(boundExecutionId), boundExecutionId } +} diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 17c233e2550..789d48df5f0 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -13,6 +13,14 @@ export interface ToolExecutionContext { runId?: string /** Stable identity of the individual tool call being executed. */ toolCallId?: string + /** + * Workflow execution id this tool call is already bound to, set only by the + * copilot request handler when it wins the workflow-tool execution claim and + * runs the tool server-side instead of waiting for a browser. Distinct from + * `executionId`, which is the copilot run's own identity and is re-emitted + * into the principal by `requireTrustedCopilotExecutionContext`. + */ + boundWorkflowExecutionId?: string billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean /** Server-owned base image selected from the fixed Go route for this turn. */ diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index d0299ac419c..7b4817d0b89 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -451,6 +451,32 @@ describe('run tool execution cancellation', () => { ) }) + it('drops a duplicate async launch without confirming or surfacing an error', async () => { + // The server fallback (or another tab) already claimed this tool call. + // Reporting an error here would overwrite a run that is in flight. + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 409, + json: vi.fn().mockResolvedValue({ + error: 'Copilot workflow tool is already bound to another execution', + code: 'COPILOT_WORKFLOW_EXECUTION_CONFLICT', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + executeRunToolOnClient('tool-async-duplicate', 'run_workflow', { + workflowId: 'wf-1', + async: true, + }) + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + // Only the execute attempt — never a /api/copilot/confirm report. + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][0]).toBe('/api/workflows/wf-1/execute') + expect(saveExecutionPointer).not.toHaveBeenCalled() + }) + it('drops a duplicate client runner without confirming or surfacing an error', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index 4e7e6e2340d..6ccb62f1f96 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -69,6 +69,13 @@ function resolveTriggerBlockId(params: Record): string | undefi : undefined } +/** The execute endpoint's "this tool call is already bound to another run" body. */ +function isWorkflowExecutionConflict(responseBody: unknown): boolean { + return ( + isPlainRecord(responseBody) && responseBody.code === COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE + ) +} + async function enqueueAsyncWorkflowRun( toolCallId: string, workflowId: string, @@ -120,6 +127,18 @@ async function enqueueAsyncWorkflowRun( acceptanceIsAmbiguous = isPlainRecord(responseBody) && responseBody.code === 'ASYNC_ENQUEUE_AMBIGUOUS' + // Someone else — another tab, or the server's own fallback — already owns + // this tool call. Stay silent so the winner reports the result; reporting + // an error here would overwrite a run that is happily in flight. Mirrors + // the streamed path's handling of the same conflict. + if (response.status === 409 && isWorkflowExecutionConflict(responseBody)) { + logger.info('[RunTool] Ignoring duplicate async workflow launch', { + toolCallId, + workflowId, + }) + return + } + if (!response.ok && !acceptanceIsAmbiguous) { const responseError = deploymentError?.message ?? diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 0769954cad9..ee407450105 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -102,6 +102,16 @@ function copilotRunLifecycle(context: ExecutionContext) { billingAttribution: context.billingAttribution, resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, abortSignal: context.abortSignal, + // Present only when the request handler already claimed an execution id for + // this tool call because it is running the workflow server-side. + ...(context.boundWorkflowExecutionId && context.toolCallId + ? { + boundExecution: { + executionId: context.boundWorkflowExecutionId, + copilotToolCallId: context.toolCallId, + }, + } + : {}), } } diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts index dcec0fdb8a8..90d3bf6a3bf 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts @@ -148,6 +148,71 @@ describe('Copilot workflow run application commands', () => { ) }) + it('runs under a caller-claimed execution id and stamps its copilot correlation', async () => { + // Set when the request handler wins the workflow-tool claim and runs the + // tool server-side. The claimed id must BE the child execution id, and the + // log row must carry the tool-call correlation, or a server-run tool call + // is unattributable where a browser-run one is not. + await runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + useDraftState: true, + lifecycle: { + ...lifecycle, + boundExecution: { + executionId: 'claimed-execution-1', + copilotToolCallId: 'tool-call-1', + }, + }, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + + expect(mocks.admission).toHaveBeenCalledWith( + { userId: 'user-1', billingAttribution: undefined }, + 'workspace-1', + 'claimed-execution-1' + ) + expect(mocks.executeWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ id: 'workflow-1' }), + 'request-1', + { source: 'mock' }, + 'user-1', + expect.objectContaining({ + trustedExecutionCorrelation: { + executionId: 'claimed-execution-1', + requestId: 'request-1', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'tool-call-1', + }, + }), + 'claimed-execution-1' + ) + }) + + it('does not stamp a correlation for an ordinary browser-routed run', async () => { + await runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + + expect(mocks.executeWorkflow.mock.calls.at(-1)?.[4]).not.toHaveProperty( + 'trustedExecutionCorrelation' + ) + }) + it('rechecks current permission before loading execution state', async () => { mocks.permission.mockResolvedValueOnce(null) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 6a8b3527c4e..705999129cd 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -34,6 +34,20 @@ export interface CopilotWorkflowRunLifecycle { billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry abortSignal?: AbortSignal + /** + * Execution identity the caller already claimed for this Copilot tool call. + * + * Set only when the copilot request handler runs a workflow tool server-side + * because no browser picked it up. Using the claimed id as the child + * execution id — and stamping the matching trusted correlation — keeps a + * server-run tool call as attributable in `workflow_execution_logs` as a + * browser-routed one, which `/api/workflows/[id]/execute` does for its own + * claim at the equivalent point. + */ + boundExecution?: { + executionId: string + copilotToolCallId: string + } } interface BaseCopilotRunInput { @@ -207,7 +221,11 @@ async function executeCopilotRun(params: { } }): Promise { const actorUserId = requirePrincipalSubjectUserId(params.principal) - const childExecutionId = generateId() + const boundExecution = params.input.lifecycle.boundExecution + // Reuse the caller's already-claimed execution id so the claim and the log + // row describe the same run; otherwise mint our own as before. + const childExecutionId = boundExecution?.executionId ?? generateId() + const requestId = generateRequestId() const admission = await prepareWorkflowExecutionAdmission( { userId: actorUserId, @@ -229,7 +247,7 @@ async function executeCopilotRun(params: { workspaceId: params.context.workspaceId, variables: params.context.workflow.variables || {}, }, - generateRequestId(), + requestId, params.executionInput, actorUserId, { @@ -244,6 +262,18 @@ async function executeCopilotRun(params: { ...(trustedInitialResolvedSecretTraceProvenance ? { trustedInitialResolvedSecretTraceProvenance } : {}), + ...(boundExecution + ? { + trustedExecutionCorrelation: { + executionId: childExecutionId, + requestId, + source: 'workflow' as const, + workflowId: params.context.workflowId, + triggerType: 'copilot', + copilotToolCallId: boundExecution.copilotToolCallId, + }, + } + : {}), }, childExecutionId ) From b5bae34edf2b5e4272c44e1fa761c8e78019ddda Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 15:45:09 -0700 Subject: [PATCH 004/103] Make async tool resume delivery recoverable --- .../[id]/execute/route.async.test.ts | 38 +++- .../app/api/workflows/[id]/execute/route.ts | 60 ++--- .../utils/workflow-execution-utils.ts | 5 +- apps/sim/lib/copilot/generated/metrics-v1.ts | 2 + .../lib/copilot/generated/tool-catalog-v1.ts | 15 +- .../lib/copilot/generated/tool-schemas-v1.ts | 15 +- .../generated/trace-attribute-values-v1.ts | 10 + .../copilot/generated/trace-attributes-v1.ts | 2 + .../copilot/request/handlers/handlers.test.ts | 132 +++++++++++ apps/sim/lib/copilot/request/handlers/tool.ts | 55 +++++ .../sim/lib/copilot/request/handlers/types.ts | 32 ++- .../lifecycle/resume-leg-context.test.ts | 35 +++ .../lib/copilot/request/lifecycle/run.test.ts | 154 +++++++++++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 215 +++++++++++++----- apps/sim/lib/copilot/request/metrics.ts | 15 ++ .../tools/workflow-client-fallback.test.ts | 16 +- .../request/tools/workflow-client-fallback.ts | 3 + .../tools/client/run-tool-execution.test.ts | 32 +++ .../tools/client/run-tool-execution.ts | 13 +- .../lib/copilot/tools/workflow-tools.test.ts | 86 +++++++ apps/sim/lib/copilot/tools/workflow-tools.ts | 101 ++++++++ 21 files changed, 910 insertions(+), 126 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/workflow-tools.test.ts diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 4adac12ff2a..20bf4ff7e5d 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -852,8 +852,13 @@ describe('workflow execute async route', () => { status: 'pending', }, { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + 403, + 'COPILOT_WORKFLOW_TOOL_BINDING_AWAITING_APPROVAL', ], [ + // A finished call is a benign duplicate, not a defect: some other runner + // already owns this tool call, so it reports the same conflict the + // execution claim does and the client stays silent. 'terminal tool row', { toolCallId: 'copilot-tool-1', @@ -863,6 +868,8 @@ describe('workflow execute async route', () => { status: 'completed', }, { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + 409, + 'COPILOT_WORKFLOW_EXECUTION_CONFLICT', ], [ 'different workflow target', @@ -874,6 +881,8 @@ describe('workflow execute async route', () => { status: 'running', }, { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + 403, + 'COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH', ], [ 'different execution actor', @@ -885,19 +894,28 @@ describe('workflow execute async route', () => { status: 'running', }, { id: 'copilot-run-1', userId: 'other-user', workflowId: 'workflow-1' }, + 403, + 'COPILOT_WORKFLOW_TOOL_BINDING_FOREIGN_OWNER', ], - ])('rejects a Copilot binding owned by a %s', async (_caseName, toolCall, run) => { - mockGetAsyncToolCall.mockResolvedValueOnce(toolCall) - mockGetRunSegment.mockResolvedValueOnce(run) + ['missing tool row', null, null, 404, 'COPILOT_WORKFLOW_TOOL_BINDING_UNKNOWN'], + ])( + 'rejects a Copilot binding owned by a %s', + async (_caseName, toolCall, run, expectedStatus, expectedCode) => { + mockGetAsyncToolCall.mockResolvedValueOnce(toolCall) + mockGetRunSegment.mockResolvedValueOnce(run) - const response = await POST(createBoundCopilotExecutionRequest(), { - params: Promise.resolve({ id: 'workflow-1' }), - }) + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) - expect(response.status).toBe(403) - expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() - expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() - }) + expect(response.status).toBe(expectedStatus) + // The reason must be machine-readable — an opaque 403 is what stopped the + // client telling a benign duplicate from a real failure. + await expect(response.json()).resolves.toMatchObject({ code: expectedCode }) + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() + } + ) it('rejects Copilot workflow bindings outside the interactive SSE surface', async () => { const response = await POST(createBoundCopilotExecutionRequest({ stream: false }), { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 25704a10308..ba44c86f0c7 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -21,7 +21,6 @@ import { type BillingAttributionSnapshot, requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' -import { isWorkflowToolExecutionClaimable } from '@/lib/copilot/async-runs/lifecycle' import { claimWorkflowToolExecution, getAsyncToolCall, @@ -29,10 +28,12 @@ import { releaseWorkflowToolExecutionClaim, } from '@/lib/copilot/async-runs/repository' import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants' +import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/copilot/request/metrics' import { ASYNC_WORKFLOW_DEPLOYMENT_ERRORS, - isWorkflowToolName, - resolveWorkflowToolTargetId, + type CopilotWorkflowToolBindingResult, + classifyWorkflowToolBinding, } from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { @@ -168,25 +169,19 @@ const SERVER_EXECUTION_ID_CLAIM_ATTEMPTS = 3 export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -async function isValidCopilotWorkflowToolBinding(params: { +async function resolveCopilotWorkflowToolBinding(params: { toolCallId: string userId: string workflowId: string -}): Promise { +}): Promise { const toolCall = await getAsyncToolCall(params.toolCallId) - if ( - !toolCall || - !isWorkflowToolName(toolCall.toolName) || - !isWorkflowToolExecutionClaimable(toolCall.status, toolCall.permissionDecision) - ) { - return false - } - - const run = await getRunSegment(toolCall.runId) - return ( - run?.userId === params.userId && - resolveWorkflowToolTargetId(toolCall.args, run.workflowId) === params.workflowId - ) + const run = toolCall ? await getRunSegment(toolCall.runId) : null + return classifyWorkflowToolBinding({ + toolCall, + run, + userId: params.userId, + workflowId: params.workflowId, + }) } function createExecutionJsonResponse( @@ -1019,18 +1014,29 @@ async function handleExecutePost( ) } - if ( - copilotToolCallId && - !(await isValidCopilotWorkflowToolBinding({ + if (copilotToolCallId) { + const binding = await resolveCopilotWorkflowToolBinding({ toolCallId: copilotToolCallId, userId, workflowId, - })) - ) { - return NextResponse.json( - { error: 'Copilot workflow tool binding was not found' }, - { status: 403 } - ) + }) + if (!binding.ok) { + // This rejection happens before any LoggingSession exists, so it leaves + // no execution log and no workflow span — log the reason or it is + // invisible everywhere except the browser console. + // This rejection happens before a LoggingSession or any workflow span + // exists, so the counter is the only place it becomes visible. + recordDegraded(CopilotDegradedReason.BindingRejected) + reqLogger.warn('Rejected Copilot workflow tool execution', { + copilotToolCallId, + workflowId, + reason: binding.rejection.code, + }) + return NextResponse.json( + { error: binding.rejection.message, code: binding.rejection.code }, + { status: binding.rejection.statusCode } + ) + } } if (inputFromExecutionId) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 3f888b2e19c..4dc59961db6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -1070,7 +1070,10 @@ export async function executeWorkflowWithFullLogging( error: errorMessage, httpStatus: response.status, }) - throw new Error(errorMessage) + // Keep the status and code on the thrown error. Downgrading to a bare Error + // discarded both, so callers could not tell a Copilot binding rejection from + // any other 4xx — and the reason never reached the agent that could fix it. + throw new ExecutionStreamHttpError(errorMessage, response.status, errorCode) } if (!response.body) { diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/copilot/generated/metrics-v1.ts index 06c74839c47..b379803f21f 100644 --- a/apps/sim/lib/copilot/generated/metrics-v1.ts +++ b/apps/sim/lib/copilot/generated/metrics-v1.ts @@ -19,6 +19,7 @@ export const Metric = { CopilotCacheWrite: 'copilot.cache.write', CopilotChatBlobBytes: 'copilot.chat.blob.bytes', CopilotChatBlobCount: 'copilot.chat.blob.count', + CopilotDegradedCount: 'copilot.degraded.count', CopilotFileReadDuration: 'copilot.file.read.duration', CopilotFileReadSize: 'copilot.file.read.size', CopilotMessagesSerializeDuration: 'copilot.messages.serialize.duration', @@ -48,6 +49,7 @@ export const MetricValues: readonly MetricValue[] = [ 'copilot.cache.write', 'copilot.chat.blob.bytes', 'copilot.chat.blob.count', + 'copilot.degraded.count', 'copilot.file.read.duration', 'copilot.file.read.size', 'copilot.messages.serialize.duration', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 805bdf29f80..91fe4c4c2e9 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -4268,14 +4268,14 @@ export const RunBlock: ToolCatalogEntry = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['blockId'], + required: ['workflowId', 'blockId'], }, clientExecutable: true, } @@ -4396,14 +4396,14 @@ export const RunFromBlock: ToolCatalogEntry = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['startBlockId'], + required: ['workflowId', 'startBlockId'], }, clientExecutable: true, } @@ -4444,7 +4444,7 @@ export const RunWorkflow: ToolCatalogEntry = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', @@ -4452,6 +4452,7 @@ export const RunWorkflow: ToolCatalogEntry = { "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, + required: ['workflowId'], }, clientExecutable: true, requiresApproval: true, @@ -4492,7 +4493,7 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', @@ -4500,7 +4501,7 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, - required: ['stopAfterBlockId'], + required: ['workflowId', 'stopAfterBlockId'], }, clientExecutable: true, requiresApproval: true, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a7d92662bf8..ccc7a598c03 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -4145,14 +4145,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['blockId'], + required: ['workflowId', 'blockId'], }, resultSchema: undefined, }, @@ -4270,14 +4270,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['startBlockId'], + required: ['workflowId', 'startBlockId'], }, resultSchema: undefined, }, @@ -4313,7 +4313,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', @@ -4321,6 +4321,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, + required: ['workflowId'], }, resultSchema: undefined, }, @@ -4355,7 +4356,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', @@ -4363,7 +4364,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, - required: ['stopAfterBlockId'], + required: ['workflowId', 'stopAfterBlockId'], }, resultSchema: undefined, }, diff --git a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts index 1fad4c11323..916ecc88569 100644 --- a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts @@ -117,6 +117,16 @@ export const CopilotConfirmOutcome = { export type CopilotConfirmOutcomeKey = keyof typeof CopilotConfirmOutcome export type CopilotConfirmOutcomeValue = (typeof CopilotConfirmOutcome)[CopilotConfirmOutcomeKey] +export const CopilotDegradedReason = { + BindingRejected: 'binding_rejected', + ClientPickupTimeout: 'client_pickup_timeout', + MissingToolResult: 'missing_tool_result', + StreamDeadBeforeDispatch: 'stream_dead_before_dispatch', +} as const + +export type CopilotDegradedReasonKey = keyof typeof CopilotDegradedReason +export type CopilotDegradedReasonValue = (typeof CopilotDegradedReason)[CopilotDegradedReasonKey] + export const CopilotFinalizeOutcome = { Aborted: 'aborted', Error: 'error', diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 6db17a6329d..5a026a33c3a 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -189,6 +189,7 @@ export const TraceAttr = { CopilotCommandsCount: 'copilot.commands.count', CopilotConfirmOutcome: 'copilot.confirm.outcome', CopilotContextsCount: 'copilot.contexts.count', + CopilotDegradedReason: 'copilot.degraded.reason', CopilotExecutionId: 'copilot.execution.id', CopilotFileAttachmentsCount: 'copilot.file_attachments.count', CopilotFinalizeOutcome: 'copilot.finalize.outcome', @@ -833,6 +834,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.commands.count', 'copilot.confirm.outcome', 'copilot.contexts.count', + 'copilot.degraded.reason', 'copilot.execution.id', 'copilot.file_attachments.count', 'copilot.finalize.outcome', diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index a6ace4a3acf..32562b8293b 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -143,6 +143,57 @@ describe('sse-handlers tool lifecycle', () => { } }) + it('pins the workflow target into the args it persists and forwards', async () => { + // The browser resolved its own target from the open tab while the server + // resolved the run's workflow; in a workspace chat those disagreed and every + // omitted-argument call was rejected. One stamped field ends that. + isSimExecuted.mockReturnValue(false) + context.runId = 'run-1' + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'run-workflow-1', + toolName: 'run_workflow', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}, execContext) + + // Forwarded frame — this is what the browser POSTs back with. + expect((event.payload as { arguments?: Record }).arguments).toEqual({ + workflowId: 'workflow-1', + }) + expect(upsertAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'run-workflow-1', args: { workflowId: 'workflow-1' } }) + ) + }) + + it('leaves an explicit workflow target untouched', async () => { + isSimExecuted.mockReturnValue(false) + context.runId = 'run-1' + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'run-workflow-2', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-explicit' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}, execContext) + + expect(upsertAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ args: { workflowId: 'workflow-explicit' } }) + ) + }) + it('pre-persists browser tools as pending for the desktop authorization claim', async () => { isSimExecuted.mockReturnValue(false) context.runId = 'run-1' @@ -735,6 +786,53 @@ describe('sse-handlers tool lifecycle', () => { expect(executeTool.mock.calls.at(-1)?.[2]?.workflowId).toBe('workflow-1') }) + it('refuses a workflow tool call with no resolvable workflow target', async () => { + // A workspace chat has no run-scoped workflow, so an omitted workflowId + // cannot be resolved by anyone on this side. Dispatching would only buy a + // rejection the model cannot read, so fail with something it can act on. + const workspaceExecContext = { ...execContext, workflowId: '' } + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-unbound-workflow', + toolName: 'run_workflow', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + workspaceExecContext, + { onEvent, interactive: true, timeout: 1000 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + // Never handed to a browser, and never claimed server-side. + expect(waitForWorkflowToolCompletion).not.toHaveBeenCalled() + expect(claimWorkflowToolExecution).not.toHaveBeenCalled() + + const results = onEvent.mock.calls + .map(([event]) => event) + .filter( + (event) => + event?.type === MothershipStreamV1EventType.tool && + event.payload?.toolCallId === 'tool-unbound-workflow' && + event.payload?.phase === MothershipStreamV1ToolPhase.result + ) + expect(results).toHaveLength(1) + expect(results[0].payload.status).toBe(MothershipStreamV1ToolOutcome.error) + // The message has to name the fix, or the model just retries identically. + expect(results[0].payload.output?.error).toContain('workflowId') + expect(context.toolCalls.get('tool-unbound-workflow')?.status).toBe( + MothershipStreamV1ToolOutcome.error + ) + }) + it('does not run a workflow tool server-side when a browser holds the claim', async () => { waitForWorkflowToolCompletion.mockResolvedValue(null) claimWorkflowToolExecution.mockResolvedValueOnce(null) @@ -1488,6 +1586,40 @@ describe('sse-handlers tool lifecycle', () => { expect(context.pendingToolPromises.has('tool-inflight')).toBe(false) }) + it('leaves a complete terminal state when a tool is cancelled before dispatch', async () => { + // A tool cancelled because its stream was already aborted used to get a + // status but no `result`. The subagent join requires one, so that single + // half-finished tool call was turned into a thrown "missing result" that + // killed the entire turn and blamed an unrelated tool. + context.wasAborted = true + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-stream-dead', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: false, timeout: 1000 } + ) + + await sleep(0) + + const toolCall = context.toolCalls.get('tool-stream-dead') + expect(executeTool).not.toHaveBeenCalled() + expect(toolCall?.status).toBe(MothershipStreamV1ToolOutcome.cancelled) + // The part that was missing: a terminal tool must also be complete. + expect(toolCall?.result).toEqual({ success: false }) + expect(toolCall?.error).toBeTruthy() + }) + it('still executes the tool when async row upsert fails', async () => { upsertAsyncToolCall.mockRejectedValueOnce(new Error('db down')) executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index d08f04b486d..c7e340b0133 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -226,6 +226,22 @@ export async function prePersistClientExecutableToolCall( if (!context.runId) return + // Pin the workflow target into the arguments before they are sealed, persisted, + // and forwarded, so the row, the browser, and the completion waiter all read one + // explicit field. + // + // They used to disagree: the server resolved `args.workflowId ?? run.workflowId` + // while the browser resolved `args.workflowId ?? activeWorkflowId`. In a + // workspace chat `copilot_runs.workflow_id` is NULL, so every call that omitted + // the (optional) argument resolved to nothing server-side and to the open tab + // client-side — a guaranteed rejection at the execute endpoint. + if (isWorkflowToolName(data.toolName)) { + const targetWorkflowId = resolveWorkflowToolTargetId(data.arguments, execContext?.workflowId) + if (targetWorkflowId) { + data.arguments = { ...(data.arguments ?? {}), workflowId: targetWorkflowId } + } + } + let sealedContext: Awaited> | undefined if (execContext?.resolvedSecretTraceRegistry) { try { @@ -691,6 +707,39 @@ async function dispatchToolExecution( }) } + /** + * Refuse a workflow tool call whose target this side cannot name. + * + * The execute endpoint validates the run against the tool call's bound + * workflow, so dispatching an unbound call only buys a rejection the model + * cannot interpret. Failing here instead tells it exactly what to send back, + * and never guesses a workflow on the user's behalf. + */ + const refuseUnboundWorkflowTool = async (): Promise => { + const error = `${toolName} requires an explicit workflowId. This chat is not scoped to a workflow, so there is no current workflow to fall back to — pass the id of the workflow to run.` + logger.warn('Refusing workflow tool call with no resolvable workflow target', { + toolCallId, + toolName, + }) + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.error, + output: { error }, + error, + }) + markToolResultSeen(toolCallId) + await emitSyntheticToolResult( + toolCallId, + toolCall.name, + { + status: MothershipStreamV1ToolOutcome.error, + message: error, + data: { error }, + }, + options + ) + return { status: MothershipStreamV1ToolOutcome.error, message: error, data: { error } } + } + // Returns the promise instead of registering it, so the permission gate can // wrap the whole thing in one pending promise that stays unsettled until the // tool has actually run. Null means nothing was dispatched. @@ -708,6 +757,12 @@ async function dispatchToolExecution( if (abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) return null return fireToolExecution() } + if ( + delegateWorkflowRunToClient && + !resolveWorkflowToolTargetId(args, execContext.workflowId) + ) { + return refuseUnboundWorkflowTool() + } return waitForClientExecution() } diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts index 3e383e2c6fc..a7f9d819466 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -16,6 +16,8 @@ import { MothershipStreamV1ToolPhase, type MothershipStreamV1ToolResultPayload, } from '@/lib/copilot/generated/mothership-stream-v1' +import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/copilot/request/metrics' import { asRecord, markToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' import type { @@ -149,17 +151,37 @@ export function abortPendingToolIfStreamDead( if (!options.abortSignal?.aborted && !context.wasAborted) { return false } - toolCall.status = MothershipStreamV1ToolOutcome.cancelled - toolCall.endTime = Date.now() + const abortReason = options.abortSignal?.aborted + ? String(options.abortSignal.reason ?? 'unknown') + : undefined + // Go through the canonical terminal helper rather than stamping status by + // hand: it also writes `result`, and everything downstream that reads a + // finished tool call requires one. Leaving it unset made this call look + // terminal-but-incomplete, which the subagent join turned into a thrown + // "missing result" error that killed the whole turn. + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.cancelled, + error: 'Tool was not dispatched because its stream had already been aborted', + }) markToolResultSeen(toolCallId) + // Sim's logs do not reach Loki and the trace span below is collected + // in-process but never exported, so the counter is the only signal that + // survives to somewhere queryable. + recordDegraded(CopilotDegradedReason.StreamDeadBeforeDispatch) + logger.warn('Cancelled tool call before dispatch: stream already aborted', { + toolCallId, + toolName: toolCall.name, + reason: 'stream_dead_before_dispatch', + abortSignalAborted: options.abortSignal?.aborted ?? false, + ...(abortReason ? { abortReason } : {}), + wasAborted: context.wasAborted ?? false, + }) const toolSpan = context.trace.startSpan(toolCall.name || 'unknown_tool', 'tool.execute', { toolCallId, toolName: toolCall.name, cancelReason: 'stream_dead_before_dispatch', abortSignalAborted: options.abortSignal?.aborted ?? false, - abortReason: options.abortSignal?.aborted - ? String(options.abortSignal.reason ?? 'unknown') - : undefined, + abortReason, wasAborted: context.wasAborted ?? false, }) context.trace.endSpan(toolSpan, 'cancelled') 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..37fff6f90c7 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 @@ -9,6 +9,10 @@ import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/copilot/reque // all concurrent legs build one chat. This is the regression the inline comment // warns about — without per-leg isolation the orchestrator's pre-fanout content // gets multiplied by the leg count on merge. +// +// `wasAborted` is the one deliberate exception to "reset AND folded back": it is +// reset per leg but folded back only for a turn-level abort, because a fanout +// cancelling its own lanes must not mark the shared turn aborted. describe('resume leg context isolate/merge contract', () => { it('isolates the per-leg scalars while sharing the heavy accumulators by reference', () => { const base = createStreamingContext({ @@ -31,6 +35,9 @@ describe('resume leg context isolate/merge contract', () => { expect(leg.streamComplete).toBe(false) expect(leg.awaitingAsyncContinuation).toBeUndefined() expect(leg.completionStatus).toBeUndefined() + // A leg must never be born aborted — that is what let one cancelled lane + // cancel every tool dispatched on every lane created after it. + expect(leg.wasAborted).toBe(false) // A leg's own errors array is a fresh array (not the shared one) so a leg's // retry rollback can't truncate a sibling's errors. @@ -65,6 +72,34 @@ describe('resume leg context isolate/merge contract', () => { expect(base.completionStatus).toBe(MothershipStreamV1CompletionStatus.complete) }) + it('does not fold a fanout-induced abort onto the shared turn', () => { + // A lane that fails cancels its siblings by design, and each cancelled + // sibling returns normally with wasAborted set. Folding that marked the + // SHARED context aborted, so every leg created afterwards was born aborted + // and every tool it dispatched was cancelled before dispatch — which the + // subagent join then reported as a fatal "missing result". + const base = createStreamingContext({}) + const leg = makeResumeLegContext(base) + leg.wasAborted = true + + mergeResumeLegOutputs(base, leg, false) + + expect(base.wasAborted).toBe(false) + }) + + it('folds a turn-level abort onto the shared turn', () => { + // The other half: a real Stop (or an observed abort marker) must reach the + // shared context, because that is what classifies the request as cancelled + // rather than successful. + const base = createStreamingContext({}) + const leg = makeResumeLegContext(base) + leg.wasAborted = true + + mergeResumeLegOutputs(base, leg, true) + + expect(base.wasAborted).toBe(true) + }) + it('leaves the turn unfinished when only child legs fold back', () => { const base = createStreamingContext() diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 5f535532f55..4b86ccb104f 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -1759,6 +1759,61 @@ describe('runCopilotLifecycle', () => { } }) + it('retries an initial stream that ended before its checkpoint pause with one request identity', async () => { + const headers: Array> = [] + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + headers.push(fetchOptions.headers as Record) + context.errors.push(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + throw new StreamEndedWithoutTerminalError('/api/mothership') + } + ) + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + headers.push(fetchOptions.headers as Record) + context.streamComplete = true + context.completionStatus = MothershipStreamV1CompletionStatus.complete + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-initial-retry' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + simRequestId: 'request-initial-retry', + executionContext, + } + ) + + expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(headers.map((value) => value['X-Sim-Request-ID'])).toEqual([ + 'request-initial-retry', + 'request-initial-retry', + ]) + expect(result).toEqual( + expect.objectContaining({ success: true, cancelled: false, errors: undefined }) + ) + }) + it('does not retry a resume leg the backend already claimed and ended early', async () => { const executionContext: ExecutionContext = { userId: 'user-1', @@ -2121,4 +2176,103 @@ describe('runCopilotLifecycle', () => { vi.useRealTimers() } }) + it('completes the turn when a pending subagent tool has no result', async () => { + // A tool that never reached a terminal state used to throw + // "Cannot resume subagent chain ...: missing result for tool call ...", + // which inside a fanout cancelled every sibling lane and reported the whole + // request as an error blaming an unrelated tool. Synthesize the failure Go + // already writes for itself instead, and let the turn finish. + const bodies: Array> = [] + mockRunStreamLoop.mockImplementation( + async ( + fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + if (!fetchUrl.includes('/api/tools/resume')) { + context.awaitingAsyncContinuation = { + checkpointId: 'cp-root', + pendingToolCallIds: [], + frames: [ + { + parentToolCallId: 'subagent-file', + parentToolName: 'file', + pendingToolIds: ['tool-never-dispatched'], + checkpointId: 'cp-file', + }, + ], + } + return + } + bodies.push(JSON.parse(String(fetchOptions.body))) + context.streamComplete = true + context.completionStatus = MothershipStreamV1CompletionStatus.complete + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-missing-subagent-result' }, + { userId: 'user-1', workspaceId: 'ws-1' } + ) + + expect(bodies).toHaveLength(1) + expect(bodies[0].checkpointId).toBe('cp-file') + expect(bodies[0].results).toEqual([ + expect.objectContaining({ + callId: 'tool-never-dispatched', + success: false, + data: { error: expect.stringContaining('no result was returned') }, + }), + ]) + expect(result.success).toBe(true) + }) + + it('classifies a Stop landing during a subagent fanout as cancelled', async () => { + // Guards the trap in the fanout fix: `wasAborted` is now isolated per leg, so + // a user Stop must still reach the turn — via the abort signal or the folded + // turn-level abort — or a cancelled turn would be reported as a success. + const controller = new AbortController() + mockRunStreamLoop.mockImplementation( + async ( + fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + if (!fetchUrl.includes('/api/tools/resume')) { + context.toolCalls.set('tool-done', { + id: 'tool-done', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true }, + endTime: Date.now(), + }) + context.awaitingAsyncContinuation = { + checkpointId: 'cp-root', + pendingToolCallIds: [], + frames: [ + { + parentToolCallId: 'subagent-file', + parentToolName: 'file', + pendingToolIds: ['tool-done'], + checkpointId: 'cp-file', + }, + ], + } + return + } + // The user hits Stop mid-fanout. `wasAborted` is isolated per leg now, so + // the turn's own signal is what has to carry the cancellation into the + // classification — reading only `context.wasAborted` reports success. + controller.abort('user_stop') + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-stop-during-fanout' }, + { userId: 'user-1', workspaceId: 'ws-1', abortSignal: controller.signal } + ) + + expect(result.cancelled).toBe(true) + expect(result.success).toBe(false) + }) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 389f31564a2..f8288661998 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -28,6 +28,7 @@ import { MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' +import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' import { getAutoAllowedTools } from '@/lib/copilot/persistence/tool-permission/auto-allow' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' @@ -37,6 +38,8 @@ import { runStreamLoop, StreamEndedWithoutTerminalError, } from '@/lib/copilot/request/go/stream' +import { recordDegraded } from '@/lib/copilot/request/metrics' +import { AbortReason } from '@/lib/copilot/request/session/abort-reason' import { getToolCallTerminalData, requireToolCallStateResult, @@ -346,7 +349,13 @@ export async function runCopilotLifecycle( // the work the user watched succeed. const backendFinishedTurn = context.completionStatus === MothershipStreamV1CompletionStatus.complete - const succeeded = !context.wasAborted && (backendFinishedTurn || context.errors.length === 0) + // Consult the lifecycle signal as well as the flag. `context.wasAborted` is + // only reached from a fanout leg through the (deliberately asymmetric) merge + // in `mergeResumeLegOutputs`, so a Stop landing mid-fanout could otherwise + // classify the turn as a success. Mirrors the check already used below on + // the throw path. + const turnWasAborted = context.wasAborted || (lifecycleOptions.abortSignal?.aborted ?? false) + const succeeded = !turnWasAborted && (backendFinishedTurn || context.errors.length === 0) const result: OrchestratorResult = { success: succeeded, @@ -359,7 +368,7 @@ export async function runCopilotLifecycle( // path, but practically that doesn't happen in the success // branch here — if there are errors we never reach a // wasAborted-without-errors state. - cancelled: context.wasAborted && context.errors.length === 0, + cancelled: turnWasAborted && context.errors.length === 0, content: resultContent(context, lifecycleOptions), contentBlocks: context.contentBlocks, toolCalls: buildToolCallSummaries(context), @@ -463,10 +472,12 @@ function isPerSubagentContinuation(c: AsyncContinuation): boolean { // every resume leg), so the auth/source/version headers can't drift between the // sequential path and the concurrent per-subagent resume legs. function mothershipRequestHeaders( - hostedBillingRequest?: AttributedBillingRequestEnvelope + hostedBillingRequest?: AttributedBillingRequestEnvelope, + simRequestId?: string ): Record { return { 'Content-Type': 'application/json', + ...(simRequestId ? { 'X-Sim-Request-ID': simRequestId } : {}), ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), ...getMothershipSourceEnvHeaders(), 'X-Client-Version': SIM_AGENT_VERSION, @@ -498,6 +509,12 @@ function mothershipRequestHeaders( // - completionStatus: the backend's terminal verdict, set only on the leg that // carries the turn to its end; a stale one from a sibling would speak for a // turn that leg never finished. +// - wasAborted: the ONE field with an asymmetric fold. Cancelling a fanout +// cancels its siblings by design, and each cancelled sibling returns +// normally with wasAborted set — folding that unconditionally marked the +// SHARED context aborted, so every later leg was born aborted and every tool +// it dispatched was cancelled before dispatch. Reset per leg, and fold back +// only for a turn-level abort (see mergeResumeLegOutputs). // When adding a per-leg field, update BOTH functions (and the contract test in // resume-leg-context.test.ts). Exported only for that test. export function makeResumeLegContext(base: StreamingContext): StreamingContext { @@ -511,19 +528,30 @@ export function makeResumeLegContext(base: StreamingContext): StreamingContext { cost: undefined, errors: [], completionStatus: undefined, + wasAborted: false, } } // mergeResumeLegOutputs folds a finished leg's isolated scalars back into the // shared context. Child (subagent-lane) legs leave the join scalars empty; only // the join-carrying leg (which streams the orchestrator continuation) sets them. -export function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingContext): void { +// +// `turnWasAborted` is the caller's answer to "was this abort the turn's, or just +// this fanout cancelling its own lanes?". Only a turn-level abort belongs on the +// shared context: it is what `runCopilotLifecycle` reads to classify the request +// as cancelled, and on the headless path (which never wires `onAbortObserved`) +// it is the only record that the abort marker was ever observed. +export function mergeResumeLegOutputs( + context: StreamingContext, + leg: StreamingContext, + turnWasAborted = true +): void { if (leg.accumulatedContent) context.accumulatedContent += leg.accumulatedContent if (leg.finalAssistantContent) context.finalAssistantContent += leg.finalAssistantContent if (leg.usage) context.usage = leg.usage if (leg.cost) context.cost = leg.cost if (leg.sawMainToolCall) context.sawMainToolCall = true - if (leg.wasAborted) context.wasAborted = true + if (leg.wasAborted && turnWasAborted) context.wasAborted = true if (leg.errors.length > 0) context.errors.push(...leg.errors) if (leg.completionStatus) context.completionStatus = leg.completionStatus } @@ -537,26 +565,61 @@ async function waitForToolIds(context: StreamingContext, toolIds: string[]): Pro if (promises.length > 0) await Promise.allSettled(promises) } -function collectResultsForToolIds( +interface ResumeToolResult { + callId: string + name: string + data: unknown + success: boolean +} + +/** + * Build the resume payload entry for one pending tool call. + * + * A tool that never reached a terminal state has no result to send. That used to + * throw — which turned one incomplete tool into a dead turn, and inside a + * subagent fanout cancelled every sibling lane and reported the whole request as + * an error blaming an unrelated tool. Synthesize the same failure Go already + * writes for itself when Sim posts nothing for a pending call, so the model sees + * one failed tool and the turn carries on. Still logged at error level: getting + * here is a bug, it just must not be fatal. + */ +function buildResumeToolResult( context: StreamingContext, - toolIds: string[], - checkpointId: string -): Array<{ callId: string; name: string; data: unknown; success: boolean }> { - return toolIds.map((toolCallId) => { - const tool = context.toolCalls.get(toolCallId) - if (!tool || !tool.result) { - throw new Error( - `Cannot resume subagent chain ${checkpointId}: missing result for tool call ${toolCallId}` - ) - } - const name = tool.name || '' + toolCallId: string, + checkpointId: string | undefined +): ResumeToolResult { + const tool = context.toolCalls.get(toolCallId) + if (!tool || !tool.result) { + recordDegraded(CopilotDegradedReason.MissingToolResult) + logger.error('Missing tool result for pending tool call; synthesizing a failure', { + toolCallId, + checkpointId, + hasToolEntry: !!tool, + toolName: tool?.name, + toolStatus: tool?.status, + hasPendingPromise: context.pendingToolPromises.has(toolCallId), + }) return { callId: toolCallId, - name, - data: getToolCallTerminalData(tool), - success: requireToolCallStateResult(tool).success, + name: tool?.name || '', + data: { error: `no result was returned for tool call ${toolCallId}` }, + success: false, } - }) + } + return { + callId: toolCallId, + name: tool.name || '', + data: getToolCallTerminalData(tool), + success: requireToolCallStateResult(tool).success, + } +} + +function collectResultsForToolIds( + context: StreamingContext, + toolIds: string[], + checkpointId: string +): ResumeToolResult[] { + return toolIds.map((toolCallId) => buildResumeToolResult(context, toolCallId, checkpointId)) } // runResumeLegWithRetry runs ONE resume POST with the same retryable-error + @@ -583,7 +646,7 @@ async function runResumeLegWithRetry( url, { method: 'POST', - headers: mothershipRequestHeaders(hostedBillingRequest), + headers: mothershipRequestHeaders(hostedBillingRequest, options.simRequestId), body: JSON.stringify(body), }, leg, @@ -622,6 +685,12 @@ async function driveOneChildChain( execContext: ExecutionContext, options: CopilotLifecycleOptions, baseURL: string, + /** + * The turn's own abort signal, NOT the fanout controller in `options`. Used to + * tell "the user stopped the turn" from "a lane failed and cancelled its + * siblings" when deciding whether a leg's abort belongs on the shared context. + */ + turnAbortSignal: AbortSignal | undefined, workspaceId?: string, hostedBillingRequest?: AttributedBillingRequestEnvelope ): Promise { @@ -642,6 +711,18 @@ async function driveOneChildChain( const results = collectResultsForToolIds(context, toolIds, checkpointId) const leg = makeResumeLegContext(context) + // The abort marker is turn-scoped (keyed on the shared messageId), so a leg + // that observes it at body close IS a turn-level abort — and on the headless + // path, where `onAbortObserved` is never wired to the turn controller, this + // is the only record of it. + let markerObserved = false + const legOptions: CopilotLifecycleOptions = { + ...options, + onAbortObserved: (reason) => { + if (reason === AbortReason.MarkerObservedAtBodyClose) markerObserved = true + options.onAbortObserved?.(reason) + }, + } await runResumeLegWithRetry( `${baseURL}/api/tools/resume`, { @@ -653,10 +734,10 @@ async function driveOneChildChain( }, leg, execContext, - options, + legOptions, hostedBillingRequest ) - mergeResumeLegOutputs(context, leg) + mergeResumeLegOutputs(context, leg, markerObserved || (turnAbortSignal?.aborted ?? false)) const cont = leg.awaitingAsyncContinuation if (!cont) { @@ -735,6 +816,7 @@ async function driveSubagentChains( execContext, legOptions, baseURL, + parentSignal, workspaceId, hostedBillingRequest ).catch((error) => { @@ -769,9 +851,14 @@ async function runCheckpointLoop( let route = initialRoute let payload: Record = initialPayload let resumeAttempt = 0 + let initialAttempt = 0 const callerOnEvent = options.onEvent const mothershipBaseURL = await getMothershipBaseURL({ userId: options.userId }) const lifecycleWorkspaceId = nonBlankString(options.workspaceId) + const mothershipRequestId = nonBlankString(options.simRequestId) ?? generateId() + if (!options.simRequestId) { + options = { ...options, simRequestId: mothershipRequestId } + } const systemPromptOverride = env.MSHIP_SYSPROMPT_OVERRIDE if (typeof systemPromptOverride === 'string' && systemPromptOverride.trim() !== '') { @@ -855,7 +942,7 @@ async function runCheckpointLoop( `${mothershipBaseURL}${route}`, { method: 'POST', - headers: mothershipRequestHeaders(hostedBillingRequest), + headers: mothershipRequestHeaders(hostedBillingRequest, mothershipRequestId), body: JSON.stringify(payload), }, context, @@ -870,6 +957,7 @@ async function runCheckpointLoop( context.trace.endSpan(streamSpan, streamStatus) context.trace.setActiveSpan(undefined) resumeAttempt = 0 + initialAttempt = 0 } catch (streamError) { context.trace.endSpan(streamSpan, RequestTraceV1SpanStatus.error) context.trace.setActiveSpan(undefined) @@ -877,22 +965,27 @@ async function runCheckpointLoop( await handleBillingLimitResponse(streamError.userId, context, execContext, options) break } - if ( - isResume && - isRetryableStreamError(streamError) && - resumeAttempt < MAX_RESUME_ATTEMPTS - 1 - ) { + const attempt = isResume ? resumeAttempt : initialAttempt + const retryable = isResume + ? isRetryableStreamError(streamError) + : isRetryableInitialStreamError(streamError) + if (retryable && attempt < MAX_RESUME_ATTEMPTS - 1) { // Discard errors recorded during this failed attempt; we're about to // redo this leg and a clean retry must not finalize as `error`. context.errors.length = errorsBeforeAttempt - resumeAttempt++ - const backoff = RESUME_BACKOFF_MS[resumeAttempt - 1] ?? 1000 - logger.warn('Resume stream failed, retrying', { - attempt: resumeAttempt + 1, - maxAttempts: MAX_RESUME_ATTEMPTS, - backoffMs: backoff, - error: toError(streamError).message, - }) + if (isResume) resumeAttempt++ + else initialAttempt++ + const nextAttempt = isResume ? resumeAttempt : initialAttempt + const backoff = RESUME_BACKOFF_MS[nextAttempt - 1] ?? 1000 + logger.warn( + isResume ? 'Resume stream failed, retrying' : 'Initial stream failed, retrying', + { + attempt: nextAttempt + 1, + maxAttempts: MAX_RESUME_ATTEMPTS, + backoffMs: backoff, + error: toError(streamError).message, + } + ) await sleepWithAbort(backoff, options.abortSignal) continue } @@ -1047,37 +1140,14 @@ async function runCheckpointLoop( break } - const results: Array<{ - callId: string - name: string - data: unknown - success: boolean - }> = [] + const results: ResumeToolResult[] = [] for (const toolCallId of continuation.pendingToolCallIds) { if (isAborted(options, context)) { cancelPendingTools(context) context.awaitingAsyncContinuation = undefined break } - const tool = context.toolCalls.get(toolCallId) - if (!tool || !tool.result) { - logger.error('Missing tool result for pending tool call', { - toolCallId, - checkpointId: continuation.checkpointId, - hasToolEntry: !!tool, - toolName: tool?.name, - toolStatus: tool?.status, - hasPendingPromise: context.pendingToolPromises.has(toolCallId), - }) - throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`) - } - const name = tool.name || '' - results.push({ - callId: toolCallId, - name, - data: getToolCallTerminalData(tool), - success: requireToolCallStateResult(tool).success, - }) + results.push(buildResumeToolResult(context, toolCallId, continuation.checkpointId)) } if (isAborted(options, context)) { @@ -1325,6 +1395,25 @@ function isRetryableStreamError(error: unknown): boolean { return false } +/** + * Initial requests use a durable request identity and the backend's checkpoint + * delivery reservation. Reposting a transport-ambiguous initial leg is safe: + * Go redelivers an untouched committed pause, starts a request that never + * anchored, or fails closed when the accepted leg has no recoverable pause. + */ +function isRetryableInitialStreamError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'AbortError') { + return false + } + if (error instanceof StreamEndedWithoutTerminalError) { + return true + } + if (error instanceof CopilotBackendError) { + return error.status !== undefined && error.status >= 500 + } + return error instanceof TypeError +} + function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise { if (!abortSignal) { return sleep(ms) diff --git a/apps/sim/lib/copilot/request/metrics.ts b/apps/sim/lib/copilot/request/metrics.ts index d3bfb804382..7ad1ed582ae 100644 --- a/apps/sim/lib/copilot/request/metrics.ts +++ b/apps/sim/lib/copilot/request/metrics.ts @@ -11,6 +11,7 @@ import { type Counter, type Histogram, metrics } from '@opentelemetry/api' import { Metric } from '@/lib/copilot/generated/metrics-v1' import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import type { CopilotDegradedReasonValue } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' // MUST match Go's copilot/internal/telemetry/metrics.go LatencyBucketsMs @@ -26,6 +27,7 @@ const BYTE_BUCKETS = [1024, 8192, 65536, 262144, 1048576, 4194304, 16777216, 671 interface CopilotMeterInstruments { toolDuration: Histogram toolCalls: Counter + degradedCount: Counter vfsMaterializeDuration: Histogram fileReadDuration: Histogram fileReadBytes: Histogram @@ -45,6 +47,7 @@ function instruments(): CopilotMeterInstruments { advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS }, }), toolCalls: meter.createCounter(Metric.CopilotToolCalls), + degradedCount: meter.createCounter(Metric.CopilotDegradedCount), vfsMaterializeDuration: meter.createHistogram(Metric.CopilotVfsMaterializeDuration, { unit: 'ms', advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS }, @@ -97,6 +100,18 @@ export function recordSimToolMetric( if (durationMs >= 0) toolDuration.record(durationMs, attrs) } +// recordDegraded counts one non-fatal fallback, labelled by the bounded reason +// it took. Every degradation path reports here so "are we degrading, and why" is +// one query instead of a per-incident investigation — and so a path that should +// be impossible can be alerted on at > 0. Sim's own logs do not reach Loki and +// the in-process TraceCollector is not exported, so without this a fallback is +// invisible. +export function recordDegraded(reason: CopilotDegradedReasonValue): void { + instruments().degradedCount.add(1, { + [TraceAttr.CopilotDegradedReason]: reason, + }) +} + // recordVfsMaterialize records VFS materialization time. Call once per phase // with that phase's duration and once with phase="total" for the whole op, so // the dashboard can show total + per-phase. phase must be a bounded value. diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts index 328d62c6d39..d786190a33a 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts @@ -4,10 +4,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { waitForWorkflowToolCompletion, claimWorkflowToolExecution } = vi.hoisted(() => ({ - waitForWorkflowToolCompletion: vi.fn(), - claimWorkflowToolExecution: vi.fn(), -})) +const { waitForWorkflowToolCompletion, claimWorkflowToolExecution, recordDegraded } = vi.hoisted( + () => ({ + waitForWorkflowToolCompletion: vi.fn(), + claimWorkflowToolExecution: vi.fn(), + recordDegraded: vi.fn(), + }) +) + +vi.mock('@/lib/copilot/request/metrics', () => ({ recordDegraded })) vi.mock('@/lib/copilot/request/tools/client', () => ({ waitForWorkflowToolCompletion, @@ -84,6 +89,9 @@ describe('raceWorkflowToolClientPickup', () => { expect(outcome.winner).toBe('sim') expect(outcome.signal).toEqual({ status: 'success' }) + // Falling back is non-fatal, so it has to be countable — Sim logs do not + // reach Loki and this path emits no exported span. + expect(recordDegraded).toHaveBeenCalledWith('client_pickup_timeout') expect(claimWorkflowToolExecution).toHaveBeenCalledTimes(1) expect(params.runOnServer).toHaveBeenCalledTimes(1) // The claimed id is what the server run must bind to. diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts index 5e63209ef6d..6a5a2df4c0b 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts @@ -7,6 +7,8 @@ import type { AsyncTerminalCompletionSnapshot, } from '@/lib/copilot/async-runs/lifecycle' import { claimWorkflowToolExecution } from '@/lib/copilot/async-runs/repository' +import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/copilot/request/metrics' import { waitForWorkflowToolCompletion } from '@/lib/copilot/request/tools/client' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -121,6 +123,7 @@ export async function raceWorkflowToolClientPickup( return { winner: 'client', completion: await clientWait } } + recordDegraded(CopilotDegradedReason.ClientPickupTimeout) logger.info('No client picked up workflow tool within grace; running it server-side', { toolCallId, workflowId, diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index 7b4817d0b89..ccf8c55de2d 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -451,6 +451,38 @@ describe('run tool execution cancellation', () => { ) }) + it('reports the real failure reason so the agent can correct its arguments', async () => { + // A generic "Workflow execution failed." told the model nothing, so it could + // not fix a rejected binding or an undeployed workflow on retry. + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + executeWorkflowWithFullLogging.mockRejectedValueOnce( + new MockExecutionStreamHttpError( + 'This Copilot workflow tool call is bound to a different workflow', + 403, + 'COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH' + ) + ) + + executeRunToolOnClient('tool-binding-rejected', 'run_workflow', { workflowId: 'wf-1' }) + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining('COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH'), + }) + ) + }) + const confirmBody = JSON.parse( + fetchMock.mock.calls.find(([url]) => url === '/api/copilot/confirm')?.[1]?.body as string + ) + expect(confirmBody.message).toBe( + 'This Copilot workflow tool call is bound to a different workflow' + ) + expect(confirmBody.status).toBe('error') + }) + it('drops a duplicate async launch without confirming or surfacing an error', async () => { // The server fallback (or another tab) already claimed this tool call. // Reporting an error here would overwrite a run that is in flight. diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index 6ccb62f1f96..ac2ead76706 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -681,11 +681,20 @@ async function doExecuteRunTool( logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) const failedExecutionId = useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + // Carry the real failure through instead of the generic "Workflow execution + // failed." — the agent can only correct a bad request (a rejected binding, + // an undeployed workflow) if it is told what was wrong. + const failureCode = isExecutionStreamHttpError(err) ? err.code : undefined await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.error, - getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.error), - undefined, + msg, + { + success: false, + workflowId: targetWorkflowId, + error: msg, + ...(failureCode ? { code: failureCode } : {}), + }, failedExecutionId ) } diff --git a/apps/sim/lib/copilot/tools/workflow-tools.test.ts b/apps/sim/lib/copilot/tools/workflow-tools.test.ts new file mode 100644 index 00000000000..b461634bad3 --- /dev/null +++ b/apps/sim/lib/copilot/tools/workflow-tools.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS, + classifyWorkflowToolBinding, + resolveWorkflowToolTargetId, +} from './workflow-tools' + +const runningToolCall = { + toolName: 'run_workflow', + status: 'running' as const, + permissionDecision: null, + args: { workflowId: 'workflow-1' }, +} + +const run = { userId: 'user-1', workflowId: 'workflow-1' } + +function classify(overrides: Partial[0]> = {}) { + return classifyWorkflowToolBinding({ + toolCall: runningToolCall, + run, + userId: 'user-1', + workflowId: 'workflow-1', + ...overrides, + }) +} + +describe('classifyWorkflowToolBinding', () => { + it('accepts a live call bound to the requested workflow', () => { + expect(classify()).toEqual({ ok: true }) + }) + + it.each([ + ['missing row', { toolCall: null }, COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.unknown], + [ + 'non-workflow tool', + { toolCall: { ...runningToolCall, toolName: 'read' } }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.notWorkflowTool, + ], + [ + 'finished call', + { toolCall: { ...runningToolCall, status: 'completed' as const } }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.alreadySettled, + ], + [ + 'unapproved call', + { toolCall: { ...runningToolCall, status: 'pending' as const } }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.awaitingPermission, + ], + [ + 'another user', + { run: { ...run, userId: 'someone-else' } }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.foreignOwner, + ], + [ + 'another workflow', + { workflowId: 'workflow-2' }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.workflowMismatch, + ], + ])('rejects %s with its own reason', (_name, overrides, expected) => { + expect(classify(overrides)).toEqual({ ok: false, rejection: expected }) + }) + + it('reports a finished call as the same conflict the execution claim uses', () => { + // The client already treats this status/code pair as benign on both the sync + // and async paths, so a duplicate stops rendering as a workflow failure. + expect(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.alreadySettled).toMatchObject({ + statusCode: 409, + code: 'COPILOT_WORKFLOW_EXECUTION_CONFLICT', + }) + }) + + it('falls back to the run workflow only for rows persisted without a stamped target', () => { + // Kept for the deploy window: in-flight tool calls created before the target + // was stamped into args still resolve through the run. + expect(resolveWorkflowToolTargetId({}, 'workflow-1')).toBe('workflow-1') + expect(classify({ toolCall: { ...runningToolCall, args: {} } })).toEqual({ ok: true }) + // A workspace chat has no run workflow, so nothing can rescue it. + expect( + classify({ toolCall: { ...runningToolCall, args: {} }, run: { ...run, workflowId: null } }) + ).toEqual({ ok: false, rejection: COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.workflowMismatch }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts index b493f55c193..a922924b7e0 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -1,8 +1,12 @@ +import type { CopilotAsyncToolStatus, CopilotToolPermissionDecision } from '@sim/db/schema' import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncConfirmationStatus, + isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from '@/lib/copilot/async-runs/lifecycle' +import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants' const WORKFLOW_TOOL_NAMES = [ 'run_workflow', @@ -31,6 +35,103 @@ const ASYNC_WORKFLOW_DEPLOYMENT_ERROR_BY_CODE = new Map [error.code, error]) ) +/** + * Why a workflow-tool execution request is not bound to the tool call it claims. + * + * These used to collapse into one opaque 403, which cost the caller any chance of + * telling "someone already ran this" (benign) from "this can never run" (a real + * defect), and told the model nothing it could act on. + * + * `alreadySettled` deliberately reuses `COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE`: + * it IS the same conflict as losing the execution claim, and both client paths + * already treat that status/code pair as benign and silent. + */ +export const COPILOT_WORKFLOW_TOOL_BINDING_ERRORS = { + unknown: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_UNKNOWN', + message: 'No Copilot workflow tool call matches this execution request', + statusCode: 404, + }, + notWorkflowTool: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_NOT_WORKFLOW_TOOL', + message: 'This Copilot tool call does not run a workflow', + statusCode: 403, + }, + alreadySettled: { + code: COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE, + message: 'This Copilot workflow tool call has already completed', + statusCode: 409, + }, + awaitingPermission: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_AWAITING_APPROVAL', + message: 'This Copilot workflow tool call has not been approved yet', + statusCode: 403, + }, + foreignOwner: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_FOREIGN_OWNER', + message: 'This Copilot workflow tool call belongs to a different user', + statusCode: 403, + }, + workflowMismatch: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH', + message: 'This Copilot workflow tool call is bound to a different workflow', + statusCode: 403, + }, +} as const + +export type CopilotWorkflowToolBindingError = + (typeof COPILOT_WORKFLOW_TOOL_BINDING_ERRORS)[keyof typeof COPILOT_WORKFLOW_TOOL_BINDING_ERRORS] + +export type CopilotWorkflowToolBindingResult = + | { ok: true } + | { ok: false; rejection: CopilotWorkflowToolBindingError } + +interface WorkflowToolBindingCandidate { + toolName: string + status: CopilotAsyncToolStatus + permissionDecision: CopilotToolPermissionDecision | null + args: unknown +} + +/** + * Decides whether an execution request may run under a Copilot workflow tool call. + * + * Non-authoritative on its own — the single-winner claim in + * `claimWorkflowToolExecution` is what actually prevents a double run. This exists + * so the request fails fast, and with a distinguishable reason, before spending + * admission and billing work on something that cannot legally run. + */ +export function classifyWorkflowToolBinding(params: { + toolCall: WorkflowToolBindingCandidate | null | undefined + run: { userId: string; workflowId: string | null } | null | undefined + userId: string + workflowId: string +}): CopilotWorkflowToolBindingResult { + const { toolCall, run, userId, workflowId } = params + const reject = (rejection: CopilotWorkflowToolBindingError) => ({ ok: false as const, rejection }) + + if (!toolCall) return reject(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.unknown) + if (!isWorkflowToolName(toolCall.toolName)) { + return reject(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.notWorkflowTool) + } + if (!isWorkflowToolExecutionClaimable(toolCall.status, toolCall.permissionDecision)) { + // Split the one unclaimable bucket: a finished call is a benign duplicate, + // an unapproved one is a real refusal. + return reject( + isTerminalAsyncStatus(toolCall.status) + ? COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.alreadySettled + : COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.awaitingPermission + ) + } + if (!run || run.userId !== userId) { + return reject(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.foreignOwner) + } + if (resolveWorkflowToolTargetId(toolCall.args, run.workflowId) !== workflowId) { + return reject(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.workflowMismatch) + } + return { ok: true } +} + export function isWorkflowToolName(name: string): boolean { return WORKFLOW_TOOL_NAME_SET.has(name) } From eaee669c85e8c51d9e536632039229d3c270c662 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 19:21:42 -0700 Subject: [PATCH 005/103] Support split table tools and option recovery --- .../special-tags/special-tags.test.tsx | 30 + .../components/special-tags/special-tags.tsx | 43 ++ .../lib/copilot/generated/tool-catalog-v1.ts | 611 +++++++++++++++++- .../lib/copilot/generated/tool-schemas-v1.ts | 575 +++++++++++++++- apps/sim/lib/copilot/tools/server/router.ts | 10 + .../tools/server/table/table-automations.ts | 49 ++ .../tools/server/table/table-columns.ts | 42 ++ .../tools/server/table/table-enrichments.ts | 40 ++ .../tools/server/table/table-manage.ts | 37 ++ .../copilot/tools/server/table/table-rows.ts | 46 ++ .../tools/server/table/table-split.test.ts | 60 ++ apps/sim/lib/copilot/tools/tool-display.ts | 10 + 12 files changed, 1551 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/table/table-automations.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-columns.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-enrichments.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-manage.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-rows.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-split.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 80798ad2b09..6270761fc44 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -1330,3 +1330,33 @@ describe('parseSpecialTags sim_key placeholder', () => { } }) }) + +describe('recoverTrailingBareOptions', () => { + const bareOptions = + '{"1": {"title": "Fix the tracker", "description": "debug"}, "2": {"title": "Inspect the miss", "description": "look"}}' + + it('renders a trailing bare-JSON options payload as an options card', () => { + const { segments } = parseSpecialTags(`Here they are.\n${bareOptions}`, false) + const last = segments[segments.length - 1] + expect(last.type).toBe('options') + if (last.type === 'options') { + expect(last.data['1']?.title).toBe('Fix the tracker') + } + expect(segments[0]).toEqual({ type: 'text', content: 'Here they are.' }) + }) + + it('never recovers mid-stream — a partial JSON tail must not flicker into a card', () => { + const { segments } = parseSpecialTags(`Here they are.\n${bareOptions}`, true) + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + + it('leaves ordinary JSON prose alone', () => { + const { segments } = parseSpecialTags('The config is {"retries": 3, "mode": "fast"}', false) + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + + it('does not double-render when a real options tag already parsed', () => { + const { segments } = parseSpecialTags(`Pick one ${bareOptions}`, false) + expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0562ce7c616..21596a05ea1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1442,8 +1442,51 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS segments.push({ type: 'text', content }) } + if (!isStreaming) { + recoverTrailingBareOptions(segments) + } + return { segments, hasPendingTag } } +/** + * Recovers a trailing bare-JSON options payload the model emitted WITHOUT the + * `` wrapper (observed when an automation prompt asks the model to + * "(re)send suggested actions" and it answers with the JSON as content). The + * shape check is strict — a non-empty object whose every value is + * { title, description } with numeric-string keys — so ordinary JSON in prose + * cannot false-positive. Only a message's FINAL text segment is considered, + * mirroring the tag contract (options go last), and only when no options tag + * already parsed. Never applied mid-stream: a partial JSON tail must not + * flicker between prose and a card. + */ +function recoverTrailingBareOptions(segments: ContentSegment[]): void { + const last = segments[segments.length - 1] + if (!last || last.type !== 'text') return + if (segments.some((segment) => segment.type === 'options')) return + const text = last.content + if (!text.trimEnd().endsWith('}')) return + // The payload nests objects, so the START brace is the first one from which + // the remainder parses — probe brace positions left to right (bounded). + let start = -1 + let parsed: unknown + let probe = text.indexOf('{') + for (let attempts = 0; probe !== -1 && attempts < 20; attempts++) { + try { + parsed = JSON.parse(text.slice(probe).trim()) + start = probe + break + } catch { + probe = text.indexOf('{', probe + 1) + } + } + if (start === -1) return + if (!isOptionsTagData(parsed) || Object.keys(parsed as object).length === 0) return + if (!Object.keys(parsed as object).every((key) => /^\d+$/.test(key))) return + const prefix = text.slice(0, start).replace(/\s+$/, '') + segments.pop() + if (prefix) segments.push({ type: 'text', content: prefix }) + segments.push({ type: 'options', data: parsed }) +} interface SpecialTagsProps { segment: Exclude diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 91fe4c4c2e9..3d522d70351 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -113,6 +113,11 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'table_automations' + | 'table_columns' + | 'table_enrichments' + | 'table_manage' + | 'table_rows' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' @@ -229,6 +234,11 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'table_automations' + | 'table_columns' + | 'table_enrichments' + | 'table_manage' + | 'table_rows' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' @@ -2067,7 +2077,7 @@ export const EnrichmentRun: ToolCatalogEntry = { enrichmentId: { type: 'string', description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via user_table.list_enrichments.", + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", enum: [ 'work-email', 'phone-number', @@ -4900,6 +4910,505 @@ export const Table: ToolCatalogEntry = { internal: true, } +export const TableAutomations: ToolCatalogEntry = { + id: 'table_automations', + name: 'table_automations', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + "On add: true fires dep-satisfied rows immediately (only when the user explicitly asked); default false stages silently. On update: toggles the group's auto-fire on dep satisfaction.", + }, + blockId: { + type: 'string', + description: 'Source block ID inside the workflow (add_workflow_group_output)', + }, + columnName: { + type: 'string', + description: + 'Target column name: required for delete_workflow_group_output (the bound column to drop); optional for add_workflow_group_output (auto-derived from path)', + }, + dependencies: { + type: 'object', + description: + 'Dependencies before a row runs: { columns?: string[] } of input column names that must be filled. Output columns of upstream groups are valid; a group cannot depend on its own outputs.', + properties: { + columns: { + type: 'array', + description: 'Input column names that must be filled before the group runs a row.', + items: { type: 'string' }, + }, + }, + }, + deploymentMode: { + type: 'string', + description: + 'Which workflow version rows execute: "live" (default, editable draft — edits take effect immediately) or "deployed" (latest active deployment; fails if the workflow was never deployed).', + enum: ['live', 'deployed'], + }, + groupId: { + type: 'string', + description: + 'Workflow group ID (required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output)', + }, + groupIds: { + type: 'array', + description: 'Workflow group IDs to fire (required for run_column, non-empty)', + items: { type: 'string' }, + }, + mappingUpdates: { + type: 'array', + description: + 'Surgical per-output remap for update_workflow_group: each entry repoints ONE existing output column to a new (blockId, path) without touching the rest. Stale cells clear and backfill from saved execution logs where possible. Discover valid pairs via list_workflow_outputs first.', + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'New source block ID for this column.' }, + columnName: { + type: 'string', + description: 'The existing output column to remap (must be bound to this group).', + }, + path: { type: 'string', description: 'New dotted output path on the new block.' }, + }, + required: ['columnName', 'blockId', 'path'], + }, + }, + name: { + type: 'string', + description: 'Display name for the group (optional on add/update)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + outputs: { + type: 'array', + description: + 'Outputs to surface as columns for add_workflow_group: each { blockId, path, columnName?, columnType? }; columnName auto-derives from path, columnType from the leaf type. Validated against list_workflow_outputs — invalid picks return the valid options. For update_workflow_group prefer add/delete_workflow_group_output and mappingUpdates; pass outputs only to restructure the whole set.', + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'Source block ID inside the workflow.' }, + columnName: { + type: 'string', + description: + 'Optional target column name; auto-derived from the path when omitted.', + }, + columnType: { + type: 'string', + description: 'Optional column type; defaults from the leaf type when omitted.', + enum: ['string', 'number', 'boolean', 'date', 'json'], + }, + path: { type: 'string', description: 'Dotted output path on the block.' }, + }, + required: ['blockId', 'path'], + }, + }, + path: { + type: 'string', + description: 'Dotted output path on the block (add_workflow_group_output)', + }, + rowId: { type: 'string', description: 'Row ID for cancel_table_runs with scope "row".' }, + rowIds: { + type: 'array', + description: + 'Optional row scope for run_column: only these rows are candidates (server eligibility still applies); omit for the whole table.', + items: { type: 'string' }, + }, + runMode: { + type: 'string', + description: + 'Run mode for run_column: "incomplete" (default) re-runs only rows with no output or a last failure; "all" re-runs every dep-satisfied row.', + enum: ['incomplete', 'all'], + }, + scope: { + type: 'string', + description: + 'Cancellation scope for cancel_table_runs: "all" (whole table) or "row" (requires rowId).', + enum: ['all', 'row'], + }, + tableId: { + type: 'string', + description: 'Table ID (required for everything except list_workflow_outputs)', + }, + workflowId: { + type: 'string', + description: 'Workflow ID (required for add_workflow_group and list_workflow_outputs)', + }, + }, + }, + operation: { + type: 'string', + description: 'The automation operation to perform', + enum: [ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableColumns: ToolCatalogEntry = { + id: 'table_columns', + name: 'table_columns', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + column: { + type: 'object', + description: + 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.', + }, + columnName: { + type: 'string', + description: + 'Column name (required for rename_column and update_column; single-column delete_column)', + }, + columnNames: { + type: 'array', + description: + 'Array of column names to delete at once (preferred for multi-column delete_column)', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + newName: { type: 'string', description: 'New column name (required for rename_column)' }, + newType: { + type: 'string', + description: + 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { type: 'string' }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { type: 'string', description: 'Table ID (required for every operation)' }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The column operation to perform', + enum: ['add_column', 'rename_column', 'delete_column', 'update_column'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableEnrichments: ToolCatalogEntry = { + id: 'table_enrichments', + name: 'table_enrichments', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + 'true fires dep-satisfied rows immediately on add (only when the user explicitly asked); default false stages silently — fire later via table_automations run_column.', + }, + dependencies: { + type: 'object', + description: + 'Optional dependency override: { columns?: string[] }; omit to default to the mapped input columns.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the enrichment runs a row.', + items: { type: 'string' }, + }, + }, + }, + enrichmentId: { + type: 'string', + description: + 'Enrichment registry ID for add_enrichment — discover via list_enrichments (e.g. work-email, phone-number, company-domain, company-info).', + }, + inputMappings: { + type: 'array', + description: + 'For add_enrichment: binds each enrichment input to an existing table column, as { inputName, columnName } where inputName is the enrichment input id from list_enrichments. Provide one for every required input.', + items: { + type: 'object', + properties: { + columnName: { + type: 'string', + description: 'Existing table column that supplies this input.', + }, + inputName: { + type: 'string', + description: 'Enrichment input id to bind (from list_enrichments).', + }, + }, + required: ['inputName', 'columnName'], + }, + }, + name: { + type: 'string', + description: + "Optional display name for the enrichment column group; defaults to the enrichment's registry name.", + }, + outputColumnNames: { + type: 'object', + description: + 'Optional output column name overrides, as { "": "" }; omit for defaults.', + additionalProperties: { + type: 'string', + description: 'Target column name for this enrichment output id.', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { type: 'string', description: 'Table ID (required for add_enrichment)' }, + }, + }, + operation: { + type: 'string', + description: 'The enrichment operation to perform', + enum: ['list_enrichments', 'add_enrichment'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableManage: ToolCatalogEntry = { + id: 'table_manage', + name: 'table_manage', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + description: { type: 'string', description: 'Table description (optional for create)' }, + filePath: { + type: 'string', + description: + 'Canonical workspace file VFS path for create_from_file / import_file, e.g. files/{path}/{name}', + }, + mapping: { + type: 'object', + description: + 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. null skips that header; omit a header to auto-map by sanitized name.', + additionalProperties: { + type: ['string', 'null'], + description: 'Target column name on the table; null skips that CSV header.', + }, + }, + mode: { + type: 'string', + description: + 'Import mode for import_file: append (default) adds rows; replace truncates existing rows in a transaction first.', + enum: ['append', 'replace'], + }, + name: { type: 'string', description: 'Table name (required for create)' }, + newName: { type: 'string', description: 'New table name (required for rename)' }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + schema: { + type: 'object', + description: + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for import_file and rename)', + }, + }, + }, + operation: { + type: 'string', + description: 'The lifecycle operation to perform', + enum: ['create', 'create_from_file', 'import_file', 'rename'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableRows: ToolCatalogEntry = { + id: 'table_rows', + name: 'table_rows', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + columnName: { + type: 'string', + description: 'Column to set when using the values map format of batch_update_rows', + }, + data: { + type: 'object', + description: + 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.', + }, + filter: { + type: 'object', + description: + 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.', + }, + limit: { + type: 'number', + description: + 'Optional cap on affected rows for the by-filter operations; omit to act on every match.', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + position: { + type: 'integer', + description: + 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below shift down; omit to append.', + }, + rowId: { type: 'string', description: 'Row ID (required for update_row, delete_row)' }, + rowIds: { + type: 'array', + description: 'Array of row IDs to delete (required for batch_delete_rows)', + items: { type: 'string' }, + }, + rows: { + type: 'array', + description: 'Array of row data objects (required for batch_insert_rows)', + }, + tableId: { type: 'string', description: 'Table ID (required for every operation)' }, + updates: { + type: 'array', + description: + 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', + }, + values: { + type: 'object', + description: + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The row operation to perform', + enum: [ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Terminal: ToolCatalogEntry = { id: 'terminal', name: 'terminal', @@ -5788,6 +6297,101 @@ export const SearchKnowledgeBaseOperationValues = [ SearchKnowledgeBaseOperation.listTags, ] as const +export const TableAutomationsOperation = { + listWorkflowOutputs: 'list_workflow_outputs', + addWorkflowGroup: 'add_workflow_group', + updateWorkflowGroup: 'update_workflow_group', + deleteWorkflowGroup: 'delete_workflow_group', + addWorkflowGroupOutput: 'add_workflow_group_output', + deleteWorkflowGroupOutput: 'delete_workflow_group_output', + runColumn: 'run_column', + cancelTableRuns: 'cancel_table_runs', +} as const + +export type TableAutomationsOperation = + (typeof TableAutomationsOperation)[keyof typeof TableAutomationsOperation] + +export const TableAutomationsOperationValues = [ + TableAutomationsOperation.listWorkflowOutputs, + TableAutomationsOperation.addWorkflowGroup, + TableAutomationsOperation.updateWorkflowGroup, + TableAutomationsOperation.deleteWorkflowGroup, + TableAutomationsOperation.addWorkflowGroupOutput, + TableAutomationsOperation.deleteWorkflowGroupOutput, + TableAutomationsOperation.runColumn, + TableAutomationsOperation.cancelTableRuns, +] as const + +export const TableColumnsOperation = { + addColumn: 'add_column', + renameColumn: 'rename_column', + deleteColumn: 'delete_column', + updateColumn: 'update_column', +} as const + +export type TableColumnsOperation = + (typeof TableColumnsOperation)[keyof typeof TableColumnsOperation] + +export const TableColumnsOperationValues = [ + TableColumnsOperation.addColumn, + TableColumnsOperation.renameColumn, + TableColumnsOperation.deleteColumn, + TableColumnsOperation.updateColumn, +] as const + +export const TableEnrichmentsOperation = { + listEnrichments: 'list_enrichments', + addEnrichment: 'add_enrichment', +} as const + +export type TableEnrichmentsOperation = + (typeof TableEnrichmentsOperation)[keyof typeof TableEnrichmentsOperation] + +export const TableEnrichmentsOperationValues = [ + TableEnrichmentsOperation.listEnrichments, + TableEnrichmentsOperation.addEnrichment, +] as const + +export const TableManageOperation = { + create: 'create', + createFromFile: 'create_from_file', + importFile: 'import_file', + rename: 'rename', +} as const + +export type TableManageOperation = (typeof TableManageOperation)[keyof typeof TableManageOperation] + +export const TableManageOperationValues = [ + TableManageOperation.create, + TableManageOperation.createFromFile, + TableManageOperation.importFile, + TableManageOperation.rename, +] as const + +export const TableRowsOperation = { + insertRow: 'insert_row', + batchInsertRows: 'batch_insert_rows', + updateRow: 'update_row', + batchUpdateRows: 'batch_update_rows', + deleteRow: 'delete_row', + batchDeleteRows: 'batch_delete_rows', + updateRowsByFilter: 'update_rows_by_filter', + deleteRowsByFilter: 'delete_rows_by_filter', +} as const + +export type TableRowsOperation = (typeof TableRowsOperation)[keyof typeof TableRowsOperation] + +export const TableRowsOperationValues = [ + TableRowsOperation.insertRow, + TableRowsOperation.batchInsertRows, + TableRowsOperation.updateRow, + TableRowsOperation.batchUpdateRows, + TableRowsOperation.deleteRow, + TableRowsOperation.batchDeleteRows, + TableRowsOperation.updateRowsByFilter, + TableRowsOperation.deleteRowsByFilter, +] as const + export const TerminalOperation = { run: 'run', read: 'read', @@ -6008,6 +6612,11 @@ export const TOOL_CATALOG: Record = { [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, [Table.id]: Table, + [TableAutomations.id]: TableAutomations, + [TableColumns.id]: TableColumns, + [TableEnrichments.id]: TableEnrichments, + [TableManage.id]: TableManage, + [TableRows.id]: TableRows, [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index ccc7a598c03..a3fd31e1c85 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1971,7 +1971,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { enrichmentId: { type: 'string', description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via user_table.list_enrichments.", + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", enum: [ 'work-email', 'phone-number', @@ -4749,6 +4749,579 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + table_automations: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + "On add: true fires dep-satisfied rows immediately (only when the user explicitly asked); default false stages silently. On update: toggles the group's auto-fire on dep satisfaction.", + }, + blockId: { + type: 'string', + description: 'Source block ID inside the workflow (add_workflow_group_output)', + }, + columnName: { + type: 'string', + description: + 'Target column name: required for delete_workflow_group_output (the bound column to drop); optional for add_workflow_group_output (auto-derived from path)', + }, + dependencies: { + type: 'object', + description: + 'Dependencies before a row runs: { columns?: string[] } of input column names that must be filled. Output columns of upstream groups are valid; a group cannot depend on its own outputs.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the group runs a row.', + items: { + type: 'string', + }, + }, + }, + }, + deploymentMode: { + type: 'string', + description: + 'Which workflow version rows execute: "live" (default, editable draft — edits take effect immediately) or "deployed" (latest active deployment; fails if the workflow was never deployed).', + enum: ['live', 'deployed'], + }, + groupId: { + type: 'string', + description: + 'Workflow group ID (required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output)', + }, + groupIds: { + type: 'array', + description: 'Workflow group IDs to fire (required for run_column, non-empty)', + items: { + type: 'string', + }, + }, + mappingUpdates: { + type: 'array', + description: + 'Surgical per-output remap for update_workflow_group: each entry repoints ONE existing output column to a new (blockId, path) without touching the rest. Stale cells clear and backfill from saved execution logs where possible. Discover valid pairs via list_workflow_outputs first.', + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'New source block ID for this column.', + }, + columnName: { + type: 'string', + description: + 'The existing output column to remap (must be bound to this group).', + }, + path: { + type: 'string', + description: 'New dotted output path on the new block.', + }, + }, + required: ['columnName', 'blockId', 'path'], + }, + }, + name: { + type: 'string', + description: 'Display name for the group (optional on add/update)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + outputs: { + type: 'array', + description: + 'Outputs to surface as columns for add_workflow_group: each { blockId, path, columnName?, columnType? }; columnName auto-derives from path, columnType from the leaf type. Validated against list_workflow_outputs — invalid picks return the valid options. For update_workflow_group prefer add/delete_workflow_group_output and mappingUpdates; pass outputs only to restructure the whole set.', + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'Source block ID inside the workflow.', + }, + columnName: { + type: 'string', + description: + 'Optional target column name; auto-derived from the path when omitted.', + }, + columnType: { + type: 'string', + description: 'Optional column type; defaults from the leaf type when omitted.', + enum: ['string', 'number', 'boolean', 'date', 'json'], + }, + path: { + type: 'string', + description: 'Dotted output path on the block.', + }, + }, + required: ['blockId', 'path'], + }, + }, + path: { + type: 'string', + description: 'Dotted output path on the block (add_workflow_group_output)', + }, + rowId: { + type: 'string', + description: 'Row ID for cancel_table_runs with scope "row".', + }, + rowIds: { + type: 'array', + description: + 'Optional row scope for run_column: only these rows are candidates (server eligibility still applies); omit for the whole table.', + items: { + type: 'string', + }, + }, + runMode: { + type: 'string', + description: + 'Run mode for run_column: "incomplete" (default) re-runs only rows with no output or a last failure; "all" re-runs every dep-satisfied row.', + enum: ['incomplete', 'all'], + }, + scope: { + type: 'string', + description: + 'Cancellation scope for cancel_table_runs: "all" (whole table) or "row" (requires rowId).', + enum: ['all', 'row'], + }, + tableId: { + type: 'string', + description: 'Table ID (required for everything except list_workflow_outputs)', + }, + workflowId: { + type: 'string', + description: + 'Workflow ID (required for add_workflow_group and list_workflow_outputs)', + }, + }, + }, + operation: { + type: 'string', + description: 'The automation operation to perform', + enum: [ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_columns: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + column: { + type: 'object', + description: + 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.', + }, + columnName: { + type: 'string', + description: + 'Column name (required for rename_column and update_column; single-column delete_column)', + }, + columnNames: { + type: 'array', + description: + 'Array of column names to delete at once (preferred for multi-column delete_column)', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + newName: { + type: 'string', + description: 'New column name (required for rename_column)', + }, + newType: { + type: 'string', + description: + 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { + type: 'string', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The column operation to perform', + enum: ['add_column', 'rename_column', 'delete_column', 'update_column'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_enrichments: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + 'true fires dep-satisfied rows immediately on add (only when the user explicitly asked); default false stages silently — fire later via table_automations run_column.', + }, + dependencies: { + type: 'object', + description: + 'Optional dependency override: { columns?: string[] }; omit to default to the mapped input columns.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the enrichment runs a row.', + items: { + type: 'string', + }, + }, + }, + }, + enrichmentId: { + type: 'string', + description: + 'Enrichment registry ID for add_enrichment — discover via list_enrichments (e.g. work-email, phone-number, company-domain, company-info).', + }, + inputMappings: { + type: 'array', + description: + 'For add_enrichment: binds each enrichment input to an existing table column, as { inputName, columnName } where inputName is the enrichment input id from list_enrichments. Provide one for every required input.', + items: { + type: 'object', + properties: { + columnName: { + type: 'string', + description: 'Existing table column that supplies this input.', + }, + inputName: { + type: 'string', + description: 'Enrichment input id to bind (from list_enrichments).', + }, + }, + required: ['inputName', 'columnName'], + }, + }, + name: { + type: 'string', + description: + "Optional display name for the enrichment column group; defaults to the enrichment's registry name.", + }, + outputColumnNames: { + type: 'object', + description: + 'Optional output column name overrides, as { "": "" }; omit for defaults.', + additionalProperties: { + type: 'string', + description: 'Target column name for this enrichment output id.', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for add_enrichment)', + }, + }, + }, + operation: { + type: 'string', + description: 'The enrichment operation to perform', + enum: ['list_enrichments', 'add_enrichment'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_manage: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + description: { + type: 'string', + description: 'Table description (optional for create)', + }, + filePath: { + type: 'string', + description: + 'Canonical workspace file VFS path for create_from_file / import_file, e.g. files/{path}/{name}', + }, + mapping: { + type: 'object', + description: + 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. null skips that header; omit a header to auto-map by sanitized name.', + additionalProperties: { + type: ['string', 'null'], + description: 'Target column name on the table; null skips that CSV header.', + }, + }, + mode: { + type: 'string', + description: + 'Import mode for import_file: append (default) adds rows; replace truncates existing rows in a transaction first.', + enum: ['append', 'replace'], + }, + name: { + type: 'string', + description: 'Table name (required for create)', + }, + newName: { + type: 'string', + description: 'New table name (required for rename)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + schema: { + type: 'object', + description: + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for import_file and rename)', + }, + }, + }, + operation: { + type: 'string', + description: 'The lifecycle operation to perform', + enum: ['create', 'create_from_file', 'import_file', 'rename'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_rows: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + columnName: { + type: 'string', + description: 'Column to set when using the values map format of batch_update_rows', + }, + data: { + type: 'object', + description: + 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.', + }, + filter: { + type: 'object', + description: + 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.', + }, + limit: { + type: 'number', + description: + 'Optional cap on affected rows for the by-filter operations; omit to act on every match.', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + position: { + type: 'integer', + description: + 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below shift down; omit to append.', + }, + rowId: { + type: 'string', + description: 'Row ID (required for update_row, delete_row)', + }, + rowIds: { + type: 'array', + description: 'Array of row IDs to delete (required for batch_delete_rows)', + items: { + type: 'string', + }, + }, + rows: { + type: 'array', + description: 'Array of row data objects (required for batch_insert_rows)', + }, + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + updates: { + type: 'array', + description: + 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', + }, + values: { + type: 'object', + description: + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The row operation to perform', + enum: [ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, terminal: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 2eb7b1d681a..7d87b432218 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -48,6 +48,11 @@ import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/genera import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' +import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' +import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' +import { tableEnrichmentsServerTool } from '@/lib/copilot/tools/server/table/table-enrichments' +import { tableManageServerTool } from '@/lib/copilot/tools/server/table/table-manage' +import { tableRowsServerTool } from '@/lib/copilot/tools/server/table/table-rows' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' @@ -172,6 +177,11 @@ const baseServerToolRegistry: Record = { [enrichmentRunServerTool.name]: enrichmentRunServerTool, [userTableServerTool.name]: userTableServerTool, [queryUserTableServerTool.name]: queryUserTableServerTool, + [tableManageServerTool.name]: tableManageServerTool, + [tableRowsServerTool.name]: tableRowsServerTool, + [tableColumnsServerTool.name]: tableColumnsServerTool, + [tableAutomationsServerTool.name]: tableAutomationsServerTool, + [tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/table-automations.ts b/apps/sim/lib/copilot/tools/server/table/table-automations.ts new file mode 100644 index 00000000000..e2b94929404 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-automations.ts @@ -0,0 +1,49 @@ +import { TableAutomations } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableAutomationsArgs = { + operation: string + args?: Record +} + +type TableAutomationsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', +]) + +/** + * per-row workflow automations slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableAutomationsServerTool: BaseServerTool< + TableAutomationsArgs, + TableAutomationsResult +> = { + name: TableAutomations.id, + async execute(params: TableAutomationsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_automations does not support operation '${operation}' (allowed: list_workflow_outputs, add_workflow_group, update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output, run_column, cancel_table_runs); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-columns.ts b/apps/sim/lib/copilot/tools/server/table/table-columns.ts new file mode 100644 index 00000000000..9e4ebc2f499 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-columns.ts @@ -0,0 +1,42 @@ +import { TableColumns } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableColumnsArgs = { + operation: string + args?: Record +} + +type TableColumnsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'add_column', + 'rename_column', + 'delete_column', + 'update_column', +]) + +/** + * column DDL (add/rename/retype/delete) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableColumnsServerTool: BaseServerTool = { + name: TableColumns.id, + async execute(params: TableColumnsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_columns does not support operation '${operation}' (allowed: add_column, rename_column, delete_column, update_column); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts b/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts new file mode 100644 index 00000000000..caa317ebdfa --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts @@ -0,0 +1,40 @@ +import { TableEnrichments } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableEnrichmentsArgs = { + operation: string + args?: Record +} + +type TableEnrichmentsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set(['list_enrichments', 'add_enrichment']) + +/** + * prebuilt per-row enrichments slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableEnrichmentsServerTool: BaseServerTool< + TableEnrichmentsArgs, + TableEnrichmentsResult +> = { + name: TableEnrichments.id, + async execute(params: TableEnrichmentsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_enrichments does not support operation '${operation}' (allowed: list_enrichments, add_enrichment); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-manage.ts b/apps/sim/lib/copilot/tools/server/table/table-manage.ts new file mode 100644 index 00000000000..b8030fc5f43 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-manage.ts @@ -0,0 +1,37 @@ +import { TableManage } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableManageArgs = { + operation: string + args?: Record +} + +type TableManageResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set(['create', 'create_from_file', 'import_file', 'rename']) + +/** + * table lifecycle (create, create_from_file, import_file, rename) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableManageServerTool: BaseServerTool = { + name: TableManage.id, + async execute(params: TableManageArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_manage does not support operation '${operation}' (allowed: create, create_from_file, import_file, rename); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-rows.ts b/apps/sim/lib/copilot/tools/server/table/table-rows.ts new file mode 100644 index 00000000000..aec5ee08211 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-rows.ts @@ -0,0 +1,46 @@ +import { TableRows } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableRowsArgs = { + operation: string + args?: Record +} + +type TableRowsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', +]) + +/** + * row data (insert/update/delete, batch and by-filter) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableRowsServerTool: BaseServerTool = { + name: TableRows.id, + async execute(params: TableRowsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_rows does not support operation '${operation}' (allowed: insert_row, batch_insert_rows, update_row, batch_update_rows, delete_row, batch_delete_rows, update_rows_by_filter, delete_rows_by_filter); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-split.test.ts b/apps/sim/lib/copilot/tools/server/table/table-split.test.ts new file mode 100644 index 00000000000..00233836d39 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-split.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const executeUserTable = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/table/user-table', () => ({ + userTableServerTool: { execute: executeUserTable }, +})) + +import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' +import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' +import { tableEnrichmentsServerTool } from '@/lib/copilot/tools/server/table/table-enrichments' +import { tableManageServerTool } from '@/lib/copilot/tools/server/table/table-manage' +import { tableRowsServerTool } from '@/lib/copilot/tools/server/table/table-rows' + +/** + * Every split tool delegates its own operations to the shared user_table + * executor untouched, and rejects operations that belong to a sibling slice + * without ever invoking it — the per-slice allowlist is the access contract. + */ +describe('split table tools', () => { + beforeEach(() => { + vi.clearAllMocks() + executeUserTable.mockResolvedValue({ success: true, message: 'ok' }) + }) + + const cases = [ + { tool: tableManageServerTool, own: 'create', foreign: 'insert_row' }, + { tool: tableRowsServerTool, own: 'batch_update_rows', foreign: 'add_column' }, + { tool: tableColumnsServerTool, own: 'update_column', foreign: 'create' }, + { tool: tableAutomationsServerTool, own: 'run_column', foreign: 'add_enrichment' }, + { tool: tableEnrichmentsServerTool, own: 'add_enrichment', foreign: 'run_column' }, + ] as const + + it.each(cases)( + '$tool.name delegates $own and rejects $foreign', + async ({ tool, own, foreign }) => { + const context = { userId: 'user-1', workspaceId: 'workspace-1', copilotToolExecution: true } + const params = { operation: own, args: { tableId: 'table-1' } } + + await expect(tool.execute(params as never, context as never)).resolves.toEqual({ + success: true, + message: 'ok', + }) + expect(executeUserTable).toHaveBeenCalledWith(params, context) + + executeUserTable.mockClear() + await expect( + tool.execute({ operation: foreign, args: { tableId: 'table-1' } } as never) + ).resolves.toMatchObject({ + success: false, + message: expect.stringContaining(foreign), + }) + expect(executeUserTable).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 9d3e30c37c6..f15c80c5224 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -444,6 +444,11 @@ const TOOL_TITLES: Record = { user_table: 'Managing table', run_code: 'Running code', query_user_table: 'Querying table', + table_manage: 'Managing table', + table_rows: 'Editing table rows', + table_columns: 'Editing table columns', + table_automations: 'Managing table automations', + table_enrichments: 'Managing table enrichments', workspace_file: 'Editing file', edit_content: 'Applying file content', create_workflow: 'Creating workflow', @@ -682,6 +687,11 @@ export function getToolDisplayTitle(name: string, args?: Record case 'knowledge_base': return knowledgeBaseTitle(args) case 'query_user_table': + case 'table_manage': + case 'table_rows': + case 'table_columns': + case 'table_automations': + case 'table_enrichments': return queryUserTableTitle(args) case 'search_knowledge_base': return searchKnowledgeBaseTitle(args) From 3738347b05f5115c123ecd604ec2e07b4e5875d9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 19:41:48 -0700 Subject: [PATCH 006/103] Harden VFS mutation handling --- .../copilot/tools/handlers/vfs-mutate.test.ts | 196 ++++++++++- .../lib/copilot/tools/handlers/vfs-mutate.ts | 309 ++++++++++++------ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 40 ++- .../lib/folders/application/resource-vfs.ts | Bin 0 -> 14692 bytes .../knowledge/application/knowledge-vfs.ts | 154 ++++++++- .../knowledge/application/operations.test.ts | 2 + .../lib/knowledge/application/operations.ts | 12 + apps/sim/lib/table/application/operations.ts | 6 + apps/sim/lib/table/application/table-vfs.ts | 157 ++++++++- 9 files changed, 752 insertions(+), 124 deletions(-) create mode 100644 apps/sim/lib/folders/application/resource-vfs.ts 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 e19935bb9a4..3245a6b8977 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -42,8 +42,14 @@ const mocks = vi.hoisted(() => ({ deleteFileVfsItems: vi.fn(), renameTableVfs: vi.fn(), deleteTableVfs: vi.fn(), + transferTableVfs: vi.fn(), + createTableFolders: vi.fn(), + deleteTableFolders: vi.fn(), renameKnowledgeVfs: vi.fn(), deleteKnowledgeVfs: vi.fn(), + transferKnowledgeVfs: vi.fn(), + createKnowledgeFolders: vi.fn(), + deleteKnowledgeFolders: vi.fn(), })) vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) @@ -163,6 +169,18 @@ vi.mock('@/lib/table/application/table-vfs', () => ({ operation: tableOperations.deleteByVfsPath, execute: mocks.deleteTableVfs, }, + transferTableVfsItems: { + operation: tableOperations.moveByVfsPath, + execute: mocks.transferTableVfs, + }, + createTableVfsFolders: { + operation: tableOperations.createFolder, + execute: mocks.createTableFolders, + }, + deleteTableVfsFolders: { + operation: tableOperations.deleteFolder, + execute: mocks.deleteTableFolders, + }, })) vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ @@ -174,6 +192,18 @@ vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ operation: knowledgeOperations.deleteByVfsPath, execute: mocks.deleteKnowledgeVfs, }, + transferKnowledgeVfsItems: { + operation: knowledgeOperations.moveByVfsPath, + execute: mocks.transferKnowledgeVfs, + }, + createKnowledgeVfsFolders: { + operation: knowledgeOperations.manageVfsFolders, + execute: mocks.createKnowledgeFolders, + }, + deleteKnowledgeVfsFolders: { + operation: knowledgeOperations.manageVfsFolders, + execute: mocks.deleteKnowledgeFolders, + }, })) vi.mock('@/lib/table/service', () => ({ @@ -776,15 +806,41 @@ describe('vfs mv/cp', () => { }) }) - it('rejects flat namespaces', async () => { + it('creates table folders through the table application operation', async () => { + mocks.createTableFolders.mockResolvedValue({ + outcomes: [ + { source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1', targetSegments: ['CRM'] }, + ], + }) + const result = await executeVfsMkdir({ paths: ['tables/CRM'] }, context) - expect(result.success).toBe(false) + + expect(mocks.createTableFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + paths: [{ source: 'tables/CRM', segments: ['CRM'] }], + }, + }) + ) + expect(result.success).toBe(true) expect(result.output).toMatchObject({ - results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }], + results: [{ from: 'tables/CRM', to: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }], }) expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) + it('rejects the reserved knowledgebases/connectors folder path', async () => { + const result = await executeVfsMkdir({ paths: ['knowledgebases/connectors/sub'] }, context) + expect(result.success).toBe(false) + expect(result.output).toMatchObject({ + results: [ + { from: 'knowledgebases/connectors/sub', error: expect.stringContaining('reserved') }, + ], + }) + expect(mocks.createKnowledgeFolders).not.toHaveBeenCalled() + }) + it('rejects creation inside a locked workflow folder', async () => { mocks.createWorkflowVfsFolders.mockResolvedValue({ outcomes: [ @@ -804,30 +860,65 @@ describe('vfs mv/cp', () => { }) }) - describe('tables and knowledge bases (flat namespaces)', () => { - it('renames a table', async () => { + describe('tables and knowledge bases (foldered)', () => { + it('renames a table through the transfer application operation', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/Leads', + kind: 'resource', + resourceId: 'tbl-1', + targetSegments: ['Customers'], + }, + ], + }) + const result = await executeVfsMv( { sources: ['tables/Leads'], destination: 'tables/Customers' }, context ) - expect(mocks.renameTableVfs).toHaveBeenCalledWith( + expect(mocks.transferTableVfs).toHaveBeenCalledWith( expect.objectContaining({ - input: { workspaceId: 'ws-1', sourceName: 'Leads', newName: 'Customers' }, + input: { + workspaceId: 'ws-1', + sources: [{ source: 'tables/Leads', segments: ['Leads'] }], + destination: { segments: ['Customers'], trailingSlash: false }, + }, }) ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] }) }) - it('rejects nested table destinations as flat-namespace violations', async () => { + it('moves a table into a folder, folders auto-created server-side', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/Leads', + kind: 'resource', + resourceId: 'tbl-1', + targetSegments: ['CRM', 'Leads'], + }, + ], + }) + const result = await executeVfsMv( - { sources: ['tables/Leads'], destination: 'tables/CRM/Leads' }, + { sources: ['tables/Leads'], destination: 'tables/CRM/' }, context ) - expect(result.success).toBe(false) - expect(result.error).toContain('flat namespace') - expect(mocks.renameTable).not.toHaveBeenCalled() + + expect(mocks.transferTableVfs).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + destination: { segments: ['CRM'], trailingSlash: true }, + }), + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ to: 'tables/CRM/Leads', kind: 'table' }], + }) }) it('rejects copying tables', async () => { @@ -840,12 +931,23 @@ describe('vfs mv/cp', () => { }) it('renames a knowledge base through trusted application operations', async () => { + mocks.transferKnowledgeVfs.mockResolvedValue({ + outcomes: [ + { + source: 'knowledgebases/Docs', + kind: 'resource', + resourceId: 'kb-1', + targetSegments: ['Product Docs'], + }, + ], + }) + const result = await executeVfsMv( { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, context ) - expect(mocks.renameKnowledgeVfs).toHaveBeenCalledWith( + expect(mocks.transferKnowledgeVfs).toHaveBeenCalledWith( expect.objectContaining({ principal: expect.objectContaining({ kind: 'delegated', @@ -855,8 +957,8 @@ describe('vfs mv/cp', () => { }), input: { workspaceId: 'ws-1', - sourceName: 'Docs', - newName: 'Product Docs', + sources: [{ source: 'knowledgebases/Docs', segments: ['Docs'] }], + destination: { segments: ['Product Docs'], trailingSlash: false }, }, }) ) @@ -864,7 +966,7 @@ describe('vfs mv/cp', () => { }) it('propagates knowledge application infrastructure failures', async () => { - mocks.renameKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) + mocks.transferKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) await expect( executeVfsMv( @@ -875,7 +977,7 @@ describe('vfs mv/cp', () => { }) it('preserves an actionable knowledge rename conflict', async () => { - mocks.renameKnowledgeVfs.mockRejectedValue( + mocks.transferKnowledgeVfs.mockRejectedValue( new OrchestrationError('conflict', 'A knowledge base named Product Docs already exists') ) @@ -912,11 +1014,71 @@ describe('vfs mv/cp', () => { input: { workspaceId: 'ws-1', sourceName: 'Docs', + sourceSegments: ['Docs'], }, }) ) }) + it('moves a whole table folder through the transfer operation', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/CRM', + kind: 'folder', + resourceId: 'fld-1', + targetSegments: ['Archive', 'CRM'], + }, + ], + }) + + const result = await executeVfsMv( + { sources: ['tables/CRM'], destination: 'tables/Archive/' }, + context + ) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ to: 'tables/Archive/CRM', kind: 'table_folder' }], + }) + }) + + it('rm retargets to the folder cascade when the path is a folder', async () => { + mocks.deleteTableVfs.mockRejectedValue( + new OrchestrationError('invalid', 'tables/CRM is a folder; this operation takes a table.') + ) + mocks.deleteTableFolders.mockResolvedValue({ + outcomes: [{ source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1' }], + }) + + const result = await executeVfsRm({ paths: ['tables/CRM'] }, context) + + expect(mocks.deleteTableFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: 'ws-1', paths: [{ source: 'tables/CRM', segments: ['CRM'] }] }, + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ from: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }], + }) + }) + + it('deletes a nested knowledge base by its folder path', async () => { + const result = await executeVfsRm({ paths: ['knowledgebases/Legal/Contracts'] }, context) + + expect(mocks.deleteKnowledgeVfs).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + sourceName: 'Contracts', + sourceSegments: ['Legal', 'Contracts'], + }, + }) + ) + expect(result.success).toBe(true) + }) + it('preserves an actionable knowledge delete failure', async () => { mocks.deleteKnowledgeVfs.mockRejectedValue( new OrchestrationError('not_found', 'Knowledge base no longer exists') diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 6516fe35f15..8fa50ce6d6f 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -12,16 +12,23 @@ import { import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/files/file-folder-application' -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' +import type { ResourceVfsOutcome } from '@/lib/folders/application/resource-vfs' import { + createKnowledgeVfsFolders, deleteKnowledgeBaseByVfsPath, - renameKnowledgeBaseByVfsPath, + deleteKnowledgeVfsFolders, + transferKnowledgeVfsItems, } from '@/lib/knowledge/application/knowledge-vfs' import { captureServerEvent } from '@/lib/posthog/server' -import { deleteTableByVfsPath, renameTableByVfsPath } from '@/lib/table/application/table-vfs' +import { + createTableVfsFolders, + deleteTableByVfsPath, + deleteTableVfsFolders, + transferTableVfsItems, +} from '@/lib/table/application/table-vfs' import { VfsPathLimitError, validateVfsPathBatch } from '@/lib/vfs/limits' import { copyWorkflowVfsItems, @@ -66,7 +73,15 @@ const RM_CATEGORY_REJECTIONS: Record = { interface VfsMutateOutcome { from: string to?: string - kind: 'file' | 'file_folder' | 'workflow' | 'workflow_folder' | 'table' | 'knowledge_base' + kind: + | 'file' + | 'file_folder' + | 'workflow' + | 'workflow_folder' + | 'table' + | 'table_folder' + | 'knowledge_base' + | 'knowledge_base_folder' id?: string error?: string } @@ -217,18 +232,72 @@ export async function executeVfsMkdir( } } + const folderedOutcomes = new Map() + for (const category of ['tables', 'knowledgebases'] as const) { + const categoryPaths = paths.filter((path) => topLevelSegment(path) === category) + if (categoryPaths.length === 0) continue + const folderKind = category === 'tables' ? 'table_folder' : 'knowledge_base_folder' + const reserved = categoryPaths.filter((path) => + isReservedKnowledgePath(category, decodeVfsPathSegments(path).slice(1)) + ) + for (const path of reserved) { + folderedOutcomes.set(path, { + from: path, + kind: folderKind, + error: '"knowledgebases/connectors" is a reserved path.', + }) + } + const eligible = categoryPaths.filter((path) => !folderedOutcomes.has(path)) + if (eligible.length === 0) continue + const input = { + workspaceId, + paths: eligible.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + } + try { + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, createTableVfsFolders, input, {}) + : await executeCopilotKnowledgeUseCase(context, createKnowledgeVfsFolders, input) + for (const outcome of result.outcomes) { + folderedOutcomes.set(outcome.source, presentResourceVfsOutcome(category, outcome)) + } + } catch (error) { + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, 'Write access required to create folders') + for (const path of eligible) { + folderedOutcomes.set(path, { from: path, kind: folderKind, error: message }) + } + } + } + const outcomes: VfsMutateOutcome[] = [] for (const path of paths) { const top = topLevelSegment(path) const segments = decodeVfsPathSegments(path).slice(1) - const kind = top === 'workflows' ? 'workflow_folder' : 'file_folder' - + const kind = + top === 'workflows' + ? 'workflow_folder' + : top === 'tables' + ? 'table_folder' + : top === 'knowledgebases' + ? 'knowledge_base_folder' + : 'file_folder' + + if (top === 'tables' || top === 'knowledgebases') { + outcomes.push( + folderedOutcomes.get(path) ?? { from: path, kind, error: 'Folder creation failed' } + ) + continue + } if (top !== 'files' && top !== 'workflows') { const rejection = - top === 'tables' || top === 'knowledgebases' - ? `${top}/ is a flat namespace with no folders.` - : (CATEGORY_REJECTIONS[top] ?? - `"${path}" is not a folder target. mkdir supports files/ and workflows/ paths.`) + CATEGORY_REJECTIONS[top] ?? + `"${path}" is not a folder target. mkdir supports files/, workflows/, tables/, and knowledgebases/ paths.` outcomes.push({ from: path, kind, error: rejection }) continue } @@ -319,7 +388,14 @@ async function executeVfsMutate( case 'workflows': return await mutateWorkflows(verb, sources, destination, context, workspaceId) default: - return await renameFlatResource(verb, category, sources, destination, context, workspaceId) + return await transferFolderedResource( + verb, + category, + sources, + destination, + context, + workspaceId + ) } } catch (error) { if (error instanceof KnowledgeVfsInfrastructureError) { @@ -333,6 +409,28 @@ async function executeVfsMutate( } } +function presentResourceVfsOutcome( + category: 'tables' | 'knowledgebases', + outcome: ResourceVfsOutcome +): VfsMutateOutcome { + const resourceKind = category === 'tables' ? 'table' : 'knowledge_base' + const folderKind = category === 'tables' ? 'table_folder' : 'knowledge_base_folder' + return { + from: outcome.source, + ...(outcome.targetSegments + ? { to: `${category}/${encodeVfsPathSegments(outcome.targetSegments)}` } + : {}), + kind: outcome.kind === 'folder' ? folderKind : resourceKind, + id: outcome.resourceId, + error: outcome.error, + } +} + +/** knowledgebases/connectors is a virtual tree, not a knowledge base or folder. */ +function isReservedKnowledgePath(category: string, segments: readonly string[]): boolean { + return category === 'knowledgebases' && segments[0]?.toLowerCase() === 'connectors' +} + async function mutateWorkspaceFiles( verb: MutateVerb, sources: string[], @@ -419,7 +517,7 @@ async function mutateWorkflows( } } -async function renameFlatResource( +async function transferFolderedResource( verb: MutateVerb, category: 'tables' | 'knowledgebases', sources: string[], @@ -428,77 +526,49 @@ async function renameFlatResource( workspaceId: string ): Promise { const label = category === 'tables' ? 'Tables' : 'Knowledge bases' - const kind = category === 'tables' ? 'table' : 'knowledge_base' - if (verb === 'cp') { return { success: false, error: `${label} cannot be copied — duplication is not supported.` } } - if (sources.length > 1) { - return { success: false, error: `${label} are renamed one at a time.` } - } - const sourceSegments = decodeVfsPathSegments(sources[0]).slice(1) - const destSegments = decodeVfsPathSegments(destination).slice(1) - if (sourceSegments.length !== 1 || destSegments.length !== 1 || hasTrailingSlash(destination)) { - return { - success: false, - error: `${label} have a flat namespace with no folders — mv only renames them, e.g. mv({sources: ["${category}/Old Name"], destination: "${category}/New Name"}).`, + const sourceRefs = sources.map((source) => ({ + source, + segments: decodeVfsPathSegments(source).slice(1), + })) + const destinationSegments = decodeVfsPathSegments(destination).slice(1) + for (const ref of sourceRefs) { + if (isReservedKnowledgePath(category, ref.segments)) { + return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } } - - const sourceName = sourceSegments[0] - const newName = destSegments[0] - - if (category === 'tables') { - try { - const renamed = await executeCopilotTableUseCase( - context, - renameTableByVfsPath, - { workspaceId, sourceName, newName }, - {} - ) - return buildResult(verb, [ - { - from: sources[0], - to: `tables/${normalizeVfsSegment(renamed.name)}`, - kind, - id: renamed.id, - }, - ]) - } catch (error) { - return { success: false, error: messageForExpectedTableVfsError(error) } - } + if (isReservedKnowledgePath(category, destinationSegments)) { + return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } - if (newName.toLowerCase() === 'connectors') { - return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } + const input = { + workspaceId, + sources: sourceRefs, + destination: { + segments: destinationSegments, + trailingSlash: hasTrailingSlash(destination), + }, } + assertMutationNotAborted(context) try { - const renamed = await executeCopilotKnowledgeUseCase(context, renameKnowledgeBaseByVfsPath, { - workspaceId, - sourceName, - newName, - }) - logger.info('Renamed knowledge base via mv', { - knowledgeBaseId: renamed.id, - workspaceId, - }) - return buildResult(verb, [ - { - from: sources[0], - to: `knowledgebases/${normalizeVfsSegment(renamed.name)}`, - kind, - id: renamed.id, - }, - ]) + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, transferTableVfsItems, input, {}) + : await executeCopilotKnowledgeUseCase(context, transferKnowledgeVfsItems, input) + return buildResult( + verb, + result.outcomes.map((outcome) => presentResourceVfsOutcome(category, outcome)) + ) } catch (error) { - return { - success: false, - error: messageForKnowledgeVfsError( - error, - `Write access required to rename knowledge base "${sourceName}"` - ), - } + if (context.abortSignal?.aborted) throw error + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, `Write access required to move ${label.toLowerCase()}`) + return { success: false, error: message } } } @@ -641,31 +711,25 @@ function removeOne( } } -/** Resolves a flat tables/{name} or knowledgebases/{name} path to its single segment. */ -function flatResourceName(path: string): string | null { - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length !== 1) return null - return segments[0] -} - async function removeTablePath( path: string, context: ExecutionContext, workspaceId: string ): Promise { - const sourceName = flatResourceName(path) - if (!sourceName) { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { return { from: path, kind: 'table', - error: 'tables/ is a flat namespace — rm takes a single name, e.g. rm(["tables/Leads"]).', + error: 'rm takes a table or folder path, e.g. rm(["tables/Leads"]) or rm(["tables/CRM"]).', } } + const sourceName = segments[segments.length - 1] try { const deleted = await executeCopilotTableUseCase( context, deleteTableByVfsPath, - { workspaceId, sourceName }, + { workspaceId, sourceName, sourceSegments: segments }, {} ) captureServerEvent( @@ -677,7 +741,51 @@ async function removeTablePath( logger.info('Archived table via rm', { tableId: deleted.id, workspaceId }) return { from: path, kind: 'table', id: deleted.id } } catch (error) { - return { from: path, kind: 'table', error: messageForExpectedTableVfsError(error) } + const message = messageForExpectedTableVfsError(error) + const folderOutcome = await removeResourceFolderFallback( + 'tables', + path, + segments, + message, + context, + workspaceId + ) + if (folderOutcome) return folderOutcome + return { from: path, kind: 'table', error: message } + } +} + +/** + * rm resolution is resource-first (matching mv); when the resource resolver + * reports the path IS a folder, the delete retargets to the folder cascade. + */ +async function removeResourceFolderFallback( + category: 'tables' | 'knowledgebases', + path: string, + segments: string[], + resourceError: string, + context: ExecutionContext, + workspaceId: string +): Promise { + if (!resourceError.includes('is a folder')) return null + const input = { workspaceId, paths: [{ source: path, segments }] } + try { + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, deleteTableVfsFolders, input, {}) + : await executeCopilotKnowledgeUseCase(context, deleteKnowledgeVfsFolders, input) + const outcome = result.outcomes[0] + return outcome ? presentResourceVfsOutcome(category, outcome) : null + } catch (error) { + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, 'Write access required to delete folders') + return { + from: path, + kind: category === 'tables' ? 'table_folder' : 'knowledge_base_folder', + error: message, + } } } @@ -686,16 +794,16 @@ async function removeKnowledgeBasePath( context: ExecutionContext, workspaceId: string ): Promise { - const sourceName = flatResourceName(path) - if (!sourceName) { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { return { from: path, kind: 'knowledge_base', - error: - 'knowledgebases/ is a flat namespace — rm takes a single name, e.g. rm(["knowledgebases/support-docs"]).', + error: 'rm takes a knowledge base or folder path, e.g. rm(["knowledgebases/support-docs"]).', } } - if (sourceName.toLowerCase() === 'connectors') { + const sourceName = segments[segments.length - 1] + if (isReservedKnowledgePath('knowledgebases', segments)) { return { from: path, kind: 'knowledge_base', @@ -706,6 +814,7 @@ async function removeKnowledgeBasePath( const deleted = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseByVfsPath, { workspaceId, sourceName, + sourceSegments: segments, }) PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: deleted.id }) logger.info('Deleted knowledge base via rm', { @@ -714,13 +823,19 @@ async function removeKnowledgeBasePath( }) return { from: path, kind: 'knowledge_base', id: deleted.id } } catch (error) { - return { - from: path, - kind: 'knowledge_base', - error: messageForKnowledgeVfsError( - error, - `Write access required to delete knowledge base "${sourceName}"` - ), - } + const message = messageForKnowledgeVfsError( + error, + `Write access required to delete knowledge base "${sourceName}"` + ) + const folderOutcome = await removeResourceFolderFallback( + 'knowledgebases', + path, + segments, + message, + context, + workspaceId + ) + if (folderOutcome) return folderOutcome + return { from: path, kind: 'knowledge_base', error: message } } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 1cdb3dd34b2..96ca9331725 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -109,6 +109,7 @@ import { listWorkspaceSandboxes, } from '@/lib/execution/remote-sandbox/workspace-sandboxes' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { listFoldersForWorkspace } from '@/lib/folders/queries' import { isIntegrationDeploymentAvailableForVisibility, isOAuthServiceDeploymentAvailable, @@ -1612,6 +1613,26 @@ export class WorkspaceVFS { return buildVfsFolderPathMap(folders) } + /** + * Folder paths for a non-workflow resource tree (tables, knowledge bases), + * plus `.folder` markers so empty folders are discoverable via glob — the + * same contract workflows/ has. Returns folderId → encoded folder path. + */ + private async registerResourceFolders( + workspaceId: string, + resourceType: 'table' | 'knowledge_base', + rootSegment: 'tables' | 'knowledgebases' + ): Promise> { + const folders = await listFoldersForWorkspace(workspaceId, 'active', resourceType) + const paths = buildVfsFolderPathMap( + folders.map((f) => ({ folderId: f.id, folderName: f.name, parentId: f.parentId })) + ) + for (const folderPath of paths.values()) { + this.files.set(`${rootSegment}/${folderPath}/.folder`, '') + } + return paths + } + /** * Resolve the set of folder IDs that are effectively locked — locked directly * or via a locked ancestor folder. A workflow inside any of these folders is @@ -1814,10 +1835,18 @@ export class WorkspaceVFS { input: { workspaceId }, }) const kbs = knowledgeBases.map(({ knowledgeBase }) => knowledgeBase) + const folderPaths = await this.registerResourceFolders( + workspaceId, + 'knowledge_base', + 'knowledgebases' + ) for (const { knowledgeBase: kb, tagDefinitions } of knowledgeBases) { const safeName = sanitizeName(kb.name) - const prefix = `knowledgebases/${safeName}/` + const folderPath = kb.folderId ? folderPaths.get(kb.folderId) : undefined + const prefix = folderPath + ? `knowledgebases/${folderPath}/${safeName}/` + : `knowledgebases/${safeName}/` this.files.set( `${prefix}meta.json`, @@ -1912,12 +1941,17 @@ export class WorkspaceVFS { */ private async materializeTables(workspaceId: string): Promise { try { - const tables = await listTables(workspaceId) + const [tables, folderPaths] = await Promise.all([ + listTables(workspaceId), + this.registerResourceFolders(workspaceId, 'table', 'tables'), + ]) for (const table of tables) { const safeName = sanitizeName(table.name) + const folderPath = table.folderId ? folderPaths.get(table.folderId) : undefined + const prefix = folderPath ? `tables/${folderPath}/${safeName}` : `tables/${safeName}` this.files.set( - `tables/${safeName}/meta.json`, + `${prefix}/meta.json`, serializeTableMeta({ id: table.id, name: table.name, diff --git a/apps/sim/lib/folders/application/resource-vfs.ts b/apps/sim/lib/folders/application/resource-vfs.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c997fa7f19d5505692b5757a935e207dd50d1a4 GIT binary patch literal 14692 zcmcIr>uwy!mCkQHMM*F+Gd9^{|D{Z6>0J>af=B|XO%M=**_`g-^s;A~>Fy!L&;<6^ zKEPt1Fi*1IcTSz^>ba1k#1>#ntgbqB?$=^5zp3ihwf8r#AM>FsbT% zT*bS_ygP#N!nsMEr){pt9JL?QcD9GrEH86g{psFT>%6QcVYjXKvG#cO?~Sm&Toz@f z*AeUr%69q&pCUY6Sv0L4YL2V=FI9cr+@zCLj@jwIE%Uk{oiE1Ue*40G>&|9rooDXP z$7fFWxF(;ci?*0FSg75!Oxyjsx^uM`N8^TVdRgYpp1WRDcV(Vk81h3jU&w=9P8vn+fgr=J0CTTNCv%GQt`0xJ%>7Z#|WlQj_T9o&~_VN~Am13ex z%o`yn0T@C$TDG$a6bshm3P2Z^IrKjVi1!~pxmjN42QCML?;RM1|KC)_qUB!j4nkD> zN(t>IulG&bF7=?XW7*_HHunm!e>-j5xKjfDJOZJ!+%;e)A(iPp1OZOmK`p0cbth*4 zry=I!7x@=4dmxh6Q!4GTm~x((?Vmz&4=}nSJ8-aPfWi+HR`jFklP$Sl++talFCJe! z5C14jZ*aHVVI z{k>u;18jv2x<4#oZs62qUW&ox;;!a-)1+54vOKv;+$*{971;eM+$a|JDj^@8o;MEg zXJ|Q?*9^&daR?`Yg)q{OaHd6*d+K`pKL1Q2sAD)kgxMTylFFX<79CX2cG$s_H{Mo7 zCR`Gm;}y7#Oxtpgd|l*s?<1kp4ZBVEU_B!4AyS*yJ*{z%Z#JrjiTTgd=EJg`RC7cl zz|=StahGxv7u~3w8@F5_Vu|jX8i^jrO3~(X2nahem$q)`9f!JZ0|PDEq)@0uwtSL>Q&<%}>K0%Pt7i7T%9OsRtPB1Wio7OfvTqgM z2`Ky`47o_!!9h(&m0K zapBgoN__$$vY=^gutu7X>5MwNbtm^CvLpQ}5Z?$dl)t9Nhc3NKi*{Qg5_=%HPGoGw zZ9drRVK4$l3Y3}yNx|T^sn0Op+|!2*=e$zL8D=xX7#}L z<3)R0H1LEb-Lt&)&f$$n2iX%+o}r!p{qNmNe;}EqjjfMNQ1&g&vhYn9tpZ-qwH+b> z1F?w+6~48*OhSCAdF3lvdRMUXqf|$ME!(WVMdNIRaBv%uQxxQi2oOfwc;{@K9+A2b4D5i;Ki-MVf0=1$}E!gF}t|)xrT& zW$K`*cT(nyD@gkA@X&p48=SNIlbdBT8`6^WkK(FHELVJXrygO@txE=nwC^LeXud>B zQ!FOs5}plNBvMB#R10bnyCo)oaNY+V@xJglOB7`ILrZWdM(@1>m~&Pj5_^^X`J!3Y z-m?$^P1AYUGrhxegJc4&{+sGkcSJz75rlwOEinxDu?eT@=aaHRDdk%S}*^N}4Xi}*LiECkMJWUac zvy0IxrVvl$NsXTy^3YAn*I*F?N? zKV|}U^X3>wM|D#nlOdyE?ytL_ehdhBhY)@H?$G^UvehUUG66*Ul`yW>(LrmyY7|M=mim5y4i*i>ejBv&vRl|-u=43NM>`lPcv?2Ot+q3?7ETt^8t$@QPLM62 zxr2ZC4c-VPqfnZYW;sD%Z`?1xxR-uO8Q3wD+x;i8+JBdH6U-lgds9fO9dw(Y^J6hv z%(<>bv>+}xecaJyIAnC30)CC|5GYBB0B+GB?u@jkJK*(*-?7-~5*IYqbbpH|q_kV_ z&v)TvGhY#I`#^?&i6V{C$SP`m1tok)AA^Y&8lJwsZHhtHnzsJ@Ujw!FNaz4r--yG>zm|LLG z^N>+WW53=!$;gGs-y0+7?_17fPF3}?Gs(T&A-4a5YI(I;^?GY_PoPIf^oGIfplZPt zYbC|(+F#m8>Z6&Iq}ecPG@jW|V^900&Zm$*8*b?Oi-To;NK6nr8Vn7%o$$Z`+E2zL zkAgvjB8ry^b4-?2>(1PwV34P;6JdiE6`8w6bE`HeJpSWKoUkkUq&lbB{vs1H2_X7szVJR-y&jiK+rOLH?FFNv_N8n8EAx{7jsORXfGx1FBrn3aAWj@l)gnj5)FCGh}lT+cR@smgc6%@BVH>=Ov%`#B=;kv2Mk6M zHNPV)@d!MaxIYz~VrsBte#_Z!GnN#QZ#H_<-<1Xzc(iB`ycr*Si|YgfwBNqLrPm>% zeQk@2Z$g3~(OC5YZ`Ar?FFuOdZBUJkiL?jVIlOE@AlJ4r| z%0YN}3)t&W9eGB?EHp1AI}V=Ou{r5bfAFc=cBLNz zIQJy%^CvJfdHK|6i;Q0yB34~LDbp^^bAyN*E3H&X><59{ySIbArUg{ida6D@PYod^bB+Vr5;=StGpaxq(PnR7rN#Jp!kjJrT? zO}KbPu@{{p4(bN`IvPG2QR{JJxRj1AAwpocI!rGSOS-7WFVBtk7u5s)hKaIJwkmEdL=SeX_~TR)@S zVwWk%$YX7R8x(0n7$9m42An)>qBgv#d>$SMG4mZ-%Zv$;=$Fx&h?RKtDAQ%w0 zm^Zzk4Nd0N2P*>3?Z8<2pvCXfjtUGaTo4cA)i9hKzNbwvfP`k;mY_;^L$y#Ki;9-8<5uo86HNr(7 z4%{w7z*wYTG5O;I7e3v#r&MBBD5B~dGl~XJy^uH~60&lytL4>9(qJj=Gkf_gZ(96Q z;FV%f&z^Rrpr!!7eKAPy@K2P2JHB>M4{v(8&*3)k8@>LNEbcl1Ks3pDtwqH9C3Z{>sI zH)mjsm|Uv% zMUP>7Z%cmN=wNpRT)!eVcq>^YJE(|Z3fMFO-_AGUeJ|9}jl)Cq26Dp%`@dn@Hi9MU zGkUYPeZ+mVfi`9zqh2n0Hy7@^&wubvli6UD~q-vP}hY~>1x(=fLO)f zzFDAH^aEhijP4CB!xh;i)`bR7)24VQiu7px%|n=b$=<|Yx9$8p`Mn^t33|J?!tgd| Ih{6W{AA}y(lmGw# literal 0 HcmV?d00001 diff --git a/apps/sim/lib/knowledge/application/knowledge-vfs.ts b/apps/sim/lib/knowledge/application/knowledge-vfs.ts index f7fbff3e8d7..4c4e5243da9 100644 --- a/apps/sim/lib/knowledge/application/knowledge-vfs.ts +++ b/apps/sim/lib/knowledge/application/knowledge-vfs.ts @@ -1,6 +1,14 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { + createResourceVfsFolders, + deleteResourceVfsFolders, + type FolderedResourceAdapter, + resolveResourceRowBySegments, + transferResourceVfsItems, +} from '@/lib/folders/application/resource-vfs' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { type KnowledgeWorkspaceContext, @@ -17,6 +25,29 @@ import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' interface KnowledgeVfsReferenceInput { workspaceId: string sourceName: string + /** Folder segments + leaf name; when present the nested-aware resolver is used. */ + sourceSegments?: string[] +} + +const knowledgeVfsAdapter: FolderedResourceAdapter = { + resourceType: 'knowledge_base', + rootSegment: 'knowledgebases', + label: 'knowledge base', + async listRows(workspaceId) { + const { data: rows } = await getWorkspaceKnowledgeBases(workspaceId, 'active', {}) + return rows.map((kb) => ({ id: kb.id, name: kb.name, folderId: kb.folderId ?? null })) + }, + async moveRow(row, folderId, workspaceId) { + await updateKnowledgeBase(row.id, { folderId }, generateRequestId(), { + assertedWorkspaceId: workspaceId, + }) + }, + async renameRow(row, newName, workspaceId) { + const updated = await updateKnowledgeBase(row.id, { name: newName }, generateRequestId(), { + assertedWorkspaceId: workspaceId, + }) + return { id: updated.id, name: updated.name } + }, } export interface RenameKnowledgeBaseByVfsPathInput extends KnowledgeVfsReferenceInput { @@ -27,8 +58,27 @@ export type DeleteKnowledgeBaseByVfsPathInput = KnowledgeVfsReferenceInput async function resolveKnowledgeBaseByVfsName( context: KnowledgeWorkspaceContext, - sourceName: string + sourceName: string, + sourceSegments?: string[] ): Promise { + if (sourceSegments && sourceSegments.length > 1) { + const row = await resolveResourceRowBySegments( + knowledgeVfsAdapter, + context.workspaceId, + sourceSegments + ) + const { data: rows } = await getWorkspaceKnowledgeBases(context.workspaceId, 'active', { + search: row.name, + }) + const match = rows.find((kb) => kb.id === row.id) + if (!match) { + throw new OrchestrationError( + 'not_found', + `Knowledge base not found at knowledgebases/${sourceSegments.join('/')}` + ) + } + return match + } const { data: rows } = await getWorkspaceKnowledgeBases(context.workspaceId, 'active', { search: sourceName, }) @@ -54,7 +104,11 @@ export const renameKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: RenameKnowledgeBaseByVfsPathInput }) => resolveKnowledgeWorkspaceContext(input), async execute({ input, context }) { - const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + const knowledgeBase = await resolveKnowledgeBaseByVfsName( + context, + input.sourceName, + input.sourceSegments + ) const updated = await updateKnowledgeBase( knowledgeBase.id, { name: input.newName }, @@ -83,7 +137,11 @@ export const deleteKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: DeleteKnowledgeBaseByVfsPathInput }) => resolveKnowledgeWorkspaceContext(input), async execute({ input, context }) { - const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + const knowledgeBase = await resolveKnowledgeBaseByVfsName( + context, + input.sourceName, + input.sourceSegments + ) await deleteKnowledgeBase(knowledgeBase.id, generateRequestId(), { assertedWorkspaceId: context.workspaceId, }) @@ -103,3 +161,93 @@ export const deleteKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ metadata: { source: 'copilot_vfs', knowledgeBaseName: result.name }, }), }) + +export interface KnowledgeVfsPathsInput { + workspaceId: string + paths: Array<{ source: string; segments: string[] }> +} + +export interface TransferKnowledgeVfsItemsInput { + workspaceId: string + sources: Array<{ source: string; segments: string[] }> + destination: { segments: string[]; trailingSlash: boolean } +} + +/** mkdir -p under knowledgebases/ — folder invariants live in lib/folders. */ +export const createKnowledgeVfsFolders = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.manageVfsFolders, + resolveContext: ({ input }: { input: KnowledgeVfsPathsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await createResourceVfsFolders(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Created knowledge base folders', + metadata: { op: 'vfs_mkdir', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) + +/** mv under knowledgebases/: rows into folders, folder moves/renames, leaf renames. */ +export const transferKnowledgeVfsItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.moveByVfsPath, + resolveContext: ({ input }: { input: TransferKnowledgeVfsItemsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await transferResourceVfsItems(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + sources: input.sources, + destination: input.destination, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Moved knowledge base VFS items', + metadata: { op: 'vfs_mv', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) + +/** rm of knowledgebases/ folder paths — recursive via the shared cascade. */ +export const deleteKnowledgeVfsFolders = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.manageVfsFolders, + resolveContext: ({ input }: { input: KnowledgeVfsPathsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await deleteResourceVfsFolders(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Deleted knowledge base folders', + metadata: { op: 'vfs_rm_folder', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 32847a90a73..cc54bcbd650 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -18,6 +18,8 @@ describe('knowledge operation registry', () => { 'knowledge.delete', 'knowledge.bulk_delete', 'knowledge.vfs.rename', + 'knowledge.vfs.move', + 'knowledge.vfs.folders.manage', 'knowledge.vfs.delete', 'knowledge.search', 'knowledge.folders.list', diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index c0181d2f8e9..318aecdbac1 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -77,6 +77,18 @@ export const knowledgeOperations = { workspaceApiKey: 'deny', ...COPILOT_PRINCIPAL_POLICY, }), + moveByVfsPath: defineWorkspaceOperation({ + id: 'knowledge.vfs.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), + manageVfsFolders: defineWorkspaceOperation({ + id: 'knowledge.vfs.folders.manage', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), deleteByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.delete', minimumRole: 'write', diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index dd590edd4f7..18633f87554 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -86,6 +86,12 @@ export const tableOperations = { workspaceApiKey: 'deny', ...COPILOT_PRINCIPAL_POLICY, }), + moveByVfsPath: defineWorkspaceOperation({ + id: 'tables.vfs.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), deleteByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.delete', minimumRole: 'write', diff --git a/apps/sim/lib/table/application/table-vfs.ts b/apps/sim/lib/table/application/table-vfs.ts index cba5c91866c..2e61a3b0578 100644 --- a/apps/sim/lib/table/application/table-vfs.ts +++ b/apps/sim/lib/table/application/table-vfs.ts @@ -1,16 +1,52 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { + createResourceVfsFolders, + deleteResourceVfsFolders, + type FolderedResourceAdapter, + resolveResourceRowBySegments, + transferResourceVfsItems, +} from '@/lib/folders/application/resource-vfs' import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveTableWorkspaceContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' -import { deleteTable, findActiveTablesByExactName, renameTable } from '@/lib/table/service' +import { + deleteTable, + findActiveTablesByExactName, + listTables, + moveTableToFolder, + renameTable, +} from '@/lib/table/service' import type { TableDefinition } from '@/lib/table/types' interface TableVfsReferenceInput { workspaceId: string sourceName: string + /** Folder segments + leaf name; when present the nested-aware resolver is used. */ + sourceSegments?: string[] +} + +const tableVfsAdapter: FolderedResourceAdapter = { + resourceType: 'table', + rootSegment: 'tables', + label: 'table', + async listRows(workspaceId) { + const tables = await listTables(workspaceId) + return tables.map((t) => ({ id: t.id, name: t.name, folderId: t.folderId ?? null })) + }, + async moveRow(row, folderId, workspaceId) { + await moveTableToFolder(row.id, workspaceId, folderId, generateRequestId()) + }, + async renameRow(row, newName, workspaceId) { + const renamed = await renameTable(row.id, newName, generateRequestId(), { + expectedWorkspaceId: workspaceId, + skipNotify: true, + }) + return { id: renamed.id, name: renamed.name } + }, } export interface RenameTableByVfsPathInput extends TableVfsReferenceInput { @@ -21,8 +57,20 @@ export type DeleteTableByVfsPathInput = TableVfsReferenceInput async function resolveTableByVfsName( workspaceId: string, - sourceName: string + sourceName: string, + sourceSegments?: string[] ): Promise { + if (sourceSegments && sourceSegments.length > 1) { + const row = await resolveResourceRowBySegments(tableVfsAdapter, workspaceId, sourceSegments) + const matches = await findActiveTablesByExactName(workspaceId, row.name) + const table = matches.find((t) => t.id === row.id) + if (!table) + throw new OrchestrationError( + 'not_found', + `Table not found at tables/${sourceSegments.join('/')}` + ) + return table + } const matches = await findActiveTablesByExactName(workspaceId, sourceName) if (matches.length > 1) { throw new OrchestrationError('conflict', `Table path is ambiguous: tables/${sourceName}`) @@ -37,7 +85,11 @@ export const renameTableByVfsPath = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: RenameTableByVfsPathInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const table = await resolveTableByVfsName( + context.workspaceId, + input.sourceName, + input.sourceSegments + ) const renamed = await renameTable(table.id, input.newName, generateRequestId(), { expectedWorkspaceId: context.workspaceId, skipNotify: true, @@ -65,7 +117,11 @@ export const deleteTableByVfsPath = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: DeleteTableByVfsPathInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const table = await resolveTableByVfsName( + context.workspaceId, + input.sourceName, + input.sourceSegments + ) const { archived } = await deleteTable(table.id, generateRequestId(), { expectedWorkspaceId: context.workspaceId, skipNotify: true, @@ -89,3 +145,96 @@ export const deleteTableByVfsPath = defineAuthorizedTableUseCase({ }), afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), }) + +export interface TableVfsPathsInput { + workspaceId: string + paths: Array<{ source: string; segments: string[] }> +} + +export interface TransferTableVfsItemsInput { + workspaceId: string + sources: Array<{ source: string; segments: string[] }> + destination: { segments: string[]; trailingSlash: boolean } +} + +/** mkdir -p under tables/ — folder invariants live in lib/folders. */ +export const createTableVfsFolders = defineAuthorizedTableUseCase({ + operation: tableOperations.createFolder, + resolveContext: ({ input }: { input: TableVfsPathsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await createResourceVfsFolders(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Created table folders', + metadata: { op: 'vfs_mkdir', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) + +/** mv under tables/: rows into folders, folder moves/renames, leaf renames. */ +export const transferTableVfsItems = defineAuthorizedTableUseCase({ + operation: tableOperations.moveByVfsPath, + resolveContext: ({ input }: { input: TransferTableVfsItemsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await transferResourceVfsItems(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + sources: input.sources, + destination: input.destination, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Moved table VFS items', + metadata: { op: 'vfs_mv', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) + +/** rm of tables/ folder paths — recursive via the shared cascade. */ +export const deleteTableVfsFolders = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteFolder, + resolveContext: ({ input }: { input: TableVfsPathsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await deleteResourceVfsFolders(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Deleted table folders', + metadata: { op: 'vfs_rm_folder', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) From cc6dd5b57d857251c36260bc0f3a2cd64f64d51c Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:51:08 -0700 Subject: [PATCH 007/103] feat(copilot): identify current workspace in workspace list --- .../tools/handlers/workflow/queries.test.ts | 52 ++++++++++++++++++- .../tools/handlers/workflow/queries.ts | 5 +- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts index f8f33c8289e..801372da6f1 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts @@ -2,8 +2,9 @@ import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' -const { executeWorkflowUseCaseMock } = vi.hoisted(() => ({ +const { executeWorkflowUseCaseMock, listUserWorkspacesMock } = vi.hoisted(() => ({ executeWorkflowUseCaseMock: vi.fn(), + listUserWorkspacesMock: vi.fn(), })) vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ @@ -12,7 +13,54 @@ vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ getErrorMessage(error, 'Workflow operation failed'), })) -import { executeGetBlockOutputs } from './queries' +vi.mock('@/lib/workspaces/utils', () => ({ + listUserWorkspaces: listUserWorkspacesMock, +})) + +import { + executeGetBlockOutputs, + executeListUserWorkspaces, +} from '@/lib/copilot/tools/handlers/workflow/queries' + +describe('executeListUserWorkspaces', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('marks the current workspace in the accessible workspace list', async () => { + listUserWorkspacesMock.mockResolvedValue([ + { workspaceId: 'workspace-1', workspaceName: 'One', role: 'owner' }, + { workspaceId: 'workspace-2', workspaceName: 'Two', role: 'read' }, + ]) + + const result = await executeListUserWorkspaces({ + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-2', + }) + + expect(listUserWorkspacesMock).toHaveBeenCalledWith('user-1') + expect(result).toEqual({ + success: true, + output: { + workspaces: [ + { + workspaceId: 'workspace-1', + workspaceName: 'One', + role: 'owner', + isCurrent: false, + }, + { + workspaceId: 'workspace-2', + workspaceName: 'Two', + role: 'read', + isCurrent: true, + }, + ], + }, + }) + }) +}) describe('executeGetBlockOutputs', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 8861dbc98d6..0291fdee8fc 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -34,7 +34,10 @@ export async function executeListUserWorkspaces( context: ExecutionContext ): Promise { try { - const workspaces = await listUserWorkspaces(context.userId) + const workspaces = (await listUserWorkspaces(context.userId)).map((workspace) => ({ + ...workspace, + isCurrent: workspace.workspaceId === context.workspaceId, + })) return { success: true, output: { workspaces } } } catch (error) { From ea295e2b9fe15c92ceb60802f4b581992632d49d Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:03:22 -0700 Subject: [PATCH 008/103] feat(copilot): replace search_documentation with path-scoped search_docs; serve openapi.json publicly - search_docs server tool: same vector search over docs_embeddings plus an optional docs/documentation/... VFS path prefix mapped onto a source_document scope (covers both .mdx and /... layouts); unscoped searches exclude academy/ and api-reference/ rows so the scope is exactly the Documentation tab - @docs chat context repointed to the new tool; display label updated - apps/docs now serves /openapi.json so the mothership can build its docs/api-reference/.json VFS views from the deployed spec - generated tool catalog/schemas regenerated from the mothership contract Companion: simstudioai/mothership feat/enhance-search-agent Co-Authored-By: Claude Fable 5 --- apps/docs/app/openapi.json/route.ts | 23 ++++ apps/sim/lib/copilot/chat/process-contents.ts | 6 +- .../tools/server/docs/search-docs.test.ts | 42 +++++++ .../copilot/tools/server/docs/search-docs.ts | 110 ++++++++++++++++++ .../tools/server/docs/search-documentation.ts | 60 ---------- apps/sim/lib/copilot/tools/server/router.ts | 4 +- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 7 files changed, 181 insertions(+), 66 deletions(-) create mode 100644 apps/docs/app/openapi.json/route.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.ts delete mode 100644 apps/sim/lib/copilot/tools/server/docs/search-documentation.ts diff --git a/apps/docs/app/openapi.json/route.ts b/apps/docs/app/openapi.json/route.ts new file mode 100644 index 00000000000..a7d07ae3fa8 --- /dev/null +++ b/apps/docs/app/openapi.json/route.ts @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +export const revalidate = false + +/** + * Serves the raw OpenAPI spec (apps/docs/openapi.json) publicly so external + * consumers — notably the Mothership search agent's docs/api-reference/ VFS — + * can build per-tag views from the same spec that renders the API Reference. + */ +export async function GET() { + try { + const spec = await readFile(join(process.cwd(), 'openapi.json'), 'utf-8') + return new Response(spec, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }) + } catch (error) { + console.error('Error serving openapi.json:', error) + return new Response('OpenAPI spec unavailable', { status: 500 }) + } +} diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 689eaed8a02..2cfc17a68c0 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -314,12 +314,12 @@ export async function processContextsServer( } if (ctx.kind === 'docs') { try { - const { searchDocumentationServerTool } = await import( - '@/lib/copilot/tools/server/docs/search-documentation' + const { searchDocsServerTool } = await import( + '@/lib/copilot/tools/server/docs/search-docs' ) const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' const query = sanitizeMessageForDocs(rawQuery, contexts) - const res = await searchDocumentationServerTool.execute({ query, topK: 10 }) + const res = await searchDocsServerTool.execute({ query, topK: 10 }) const content = JSON.stringify(res?.results || []) return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } } catch (e) { diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts new file mode 100644 index 00000000000..4d0077f5540 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateSearchEmbedding: vi.fn(), +})) + +import { docsScopeTail } from '@/lib/copilot/tools/server/docs/search-docs' + +describe('docsScopeTail', () => { + it('returns undefined for an unscoped search', () => { + expect(docsScopeTail(undefined)).toBeUndefined() + expect(docsScopeTail('')).toBeUndefined() + expect(docsScopeTail(' ')).toBeUndefined() + }) + + it('treats the bare docs/documentation prefix as unscoped', () => { + expect(docsScopeTail('docs/documentation')).toBeUndefined() + expect(docsScopeTail('docs/documentation/')).toBeUndefined() + expect(docsScopeTail('/docs/documentation/')).toBeUndefined() + }) + + it('maps directory scopes to their source_document tail', () => { + expect(docsScopeTail('docs/documentation/workflows')).toBe('workflows') + expect(docsScopeTail('/docs/documentation/workflows/')).toBe('workflows') + expect(docsScopeTail('docs/documentation/integrations/gmail')).toBe('integrations/gmail') + }) + + it('maps file scopes by stripping the mdx extension', () => { + expect(docsScopeTail('docs/documentation/agents/choosing.mdx')).toBe('agents/choosing') + expect(docsScopeTail('docs/documentation/workflows/index.mdx')).toBe('workflows') + }) + + it('rejects paths outside docs/documentation/', () => { + expect(() => docsScopeTail('docs/academy/agents')).toThrow(/must start with/) + expect(() => docsScopeTail('docs/api-reference/workflows.json')).toThrow(/must start with/) + expect(() => docsScopeTail('workflows')).toThrow(/must start with/) + expect(() => docsScopeTail('docs/documentation-extra/foo')).toThrow(/must start with/) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts new file mode 100644 index 00000000000..dcc3b6d6b67 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -0,0 +1,110 @@ +import { db } from '@sim/db' +import { docsEmbeddings } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, like, notLike, or, sql } from 'drizzle-orm' +import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' + +interface SearchDocsParams { + query: string + topK?: number + path?: string +} + +const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 +const DEFAULT_TOP_K = 10 +const MAX_TOP_K = 25 +const DOCS_DOCUMENTATION_PREFIX = 'docs/documentation' + +/** + * Maps a docs/documentation/... VFS path onto a docs_embeddings source_document + * scope tail. VFS paths mirror docs.sim.ai URLs while source_document stores + * the en-relative mdx path, so a scope tail must cover both layouts a page can + * have on disk: `.mdx` and `/...` (including `/index.mdx`). + * Returns undefined for an unscoped search; throws when the path does not + * address docs/documentation/. + */ +export function docsScopeTail(path?: string): string | undefined { + if (!path || path.trim() === '') return undefined + const normalized = path.trim().replace(/^\.?\//, '') + if ( + normalized !== DOCS_DOCUMENTATION_PREFIX && + !normalized.startsWith(`${DOCS_DOCUMENTATION_PREFIX}/`) + ) { + throw new Error(`path must start with ${DOCS_DOCUMENTATION_PREFIX}/ (got "${path}")`) + } + const tail = normalized + .slice(DOCS_DOCUMENTATION_PREFIX.length) + .replace(/^\/+|\/+$/g, '') + .replace(/\/index\.mdx$/, '') + .replace(/\.mdx$/, '') + return tail === '' ? undefined : tail +} + +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`) +} + +/** + * Unscoped searches cover exactly the Documentation tab (everything under the + * docs/documentation/ VFS tree), so Academy and API-reference rows are + * excluded; a scope tail narrows to one page or directory subtree. + */ +function scopeCondition(tail?: string) { + if (!tail) { + return and( + notLike(docsEmbeddings.sourceDocument, 'academy/%'), + notLike(docsEmbeddings.sourceDocument, 'api-reference/%') + ) + } + return or( + eq(docsEmbeddings.sourceDocument, `${tail}.mdx`), + like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`) + ) +} + +export const searchDocsServerTool: BaseServerTool = { + name: SearchDocs.id, + async execute(params: SearchDocsParams): Promise { + const logger = createLogger('SearchDocsServerTool') + const { query, path } = params + if (!query || typeof query !== 'string') throw new Error('query is required') + const topK = Math.min(Math.max(Math.trunc(params.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) + const scopeTail = docsScopeTail(path) + + logger.info('Executing docs search', { query, topK, path: path ?? null }) + + const { embedding: queryEmbedding } = await generateSearchEmbedding(query) + if (!queryEmbedding || queryEmbedding.length === 0) { + return { results: [], query, totalResults: 0 } + } + + const results = await db + .select({ + chunkId: docsEmbeddings.chunkId, + chunkText: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, + sourceLink: docsEmbeddings.sourceLink, + headerText: docsEmbeddings.headerText, + headerLevel: docsEmbeddings.headerLevel, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, + }) + .from(docsEmbeddings) + .where(scopeCondition(scopeTail)) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) + .limit(topK) + + const filteredResults = results.filter((r) => r.similarity >= DEFAULT_DOCS_SIMILARITY_THRESHOLD) + const documentationResults = filteredResults.map((r, idx) => ({ + id: idx + 1, + title: String(r.headerText || 'Untitled Section'), + url: String(r.sourceLink || '#'), + content: String(r.chunkText || ''), + similarity: r.similarity, + })) + + logger.info('Docs search complete', { count: documentationResults.length }) + return { results: documentationResults, query, totalResults: documentationResults.length } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts deleted file mode 100644 index ad14c3937a6..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { db } from '@sim/db' -import { docsEmbeddings } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { sql } from 'drizzle-orm' -import { SearchDocumentation } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' - -interface DocsSearchParams { - query: string - topK?: number - threshold?: number -} - -const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 - -export const searchDocumentationServerTool: BaseServerTool = { - name: SearchDocumentation.id, - async execute(params: DocsSearchParams): Promise { - const logger = createLogger('SearchDocumentationServerTool') - const { query, topK = 10, threshold } = params - if (!query || typeof query !== 'string') throw new Error('query is required') - - logger.info('Executing docs search', { queryLength: query.length, topK }) - - const similarityThreshold = threshold ?? DEFAULT_DOCS_SIMILARITY_THRESHOLD - - const modelQuery = query - const { embedding: queryEmbedding } = await generateSearchEmbedding(modelQuery) - if (!queryEmbedding || queryEmbedding.length === 0) { - return { results: [], query, totalResults: 0 } - } - - const results = await db - .select({ - chunkId: docsEmbeddings.chunkId, - chunkText: docsEmbeddings.chunkText, - sourceDocument: docsEmbeddings.sourceDocument, - sourceLink: docsEmbeddings.sourceLink, - headerText: docsEmbeddings.headerText, - headerLevel: docsEmbeddings.headerLevel, - similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, - }) - .from(docsEmbeddings) - .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) - .limit(topK) - - const filteredResults = results.filter((r) => r.similarity >= similarityThreshold) - const documentationResults = filteredResults.map((r, idx) => ({ - id: idx + 1, - title: String(r.headerText || 'Untitled Section'), - url: String(r.sourceLink || '#'), - content: String(r.chunkText || ''), - similarity: r.similarity, - })) - - logger.info('Docs search complete', { count: documentationResults.length }) - return { results: documentationResults, query, totalResults: documentationResults.length } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 7d87b432218..ab6a882b30e 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -24,7 +24,7 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' -import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' +import { searchDocsServerTool } from '@/lib/copilot/tools/server/docs/search-docs' import { enrichmentRunServerTool } from '@/lib/copilot/tools/server/enrichment/enrichment-run' import { createFileServerTool } from '@/lib/copilot/tools/server/files/create-file' import { downloadToWorkspaceFileServerTool } from '@/lib/copilot/tools/server/files/download-to-workspace-file' @@ -168,7 +168,7 @@ const baseServerToolRegistry: Record = { [getTriggerBlocksServerTool.name]: getTriggerBlocksServerTool, [editWorkflowServerTool.name]: editWorkflowServerTool, [queryLogsServerTool.name]: queryLogsServerTool, - [searchDocumentationServerTool.name]: searchDocumentationServerTool, + [searchDocsServerTool.name]: searchDocsServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index f15c80c5224..91c2a00ebdc 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -503,7 +503,7 @@ const TOOL_TITLES: Record = { restore_resource: 'Restoring resource', run_block: 'Running block', scheduled_task: 'Managing scheduled task', - search_documentation: 'Searching documentation', + search_docs: 'Searching docs', search_patterns: 'Searching patterns', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', From 010f0a650ea0540c541526c97c23e56d0bd6850b Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:28:41 -0700 Subject: [PATCH 009/103] improvement(copilot): label docs corpus reads as Section/filename in tool chips read("docs/documentation/workflows/index.mdx") now renders "Read Workflows/index" instead of the leaf-only fallback ("Read Index"). Co-Authored-By: Claude Fable 5 --- .../copilot/tools/client/store-utils.test.ts | 26 ++++++++++++++++++ .../lib/copilot/tools/client/store-utils.ts | 27 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 7a849821895..6f973c23f8e 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -49,6 +49,32 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) + it('formats docs corpus reads as Section/filename', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'docs/documentation/workflows/index.mdx', + })?.text + ).toBe('Read Workflows/index') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { + path: 'docs/academy/agents/block.mdx', + })?.text + ).toBe('Reading Agents/block') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'docs/api-reference/workflows.json', + })?.text + ).toBe('Read Workflows') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { + path: 'docs/documentation/getting-started.mdx', + })?.text + ).toBe('Attempted to read Getting-started') + }) + it('decodes percent-encoded VFS path segments for display', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 343c9e2712d..5ca68ff5597 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -97,6 +97,10 @@ function describeReadTarget(path: string | undefined): string | undefined { if (segments.length === 0) return undefined + if (segments[0] === 'docs') { + return describeDocsReadTarget(segments) + } + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] if (!resourceType) { return humanizeDisplayIdentifier(stripExtension(segments[segments.length - 1]), 'sentence') @@ -140,6 +144,29 @@ function describeFileReadTarget(segments: string[]): string { return lastSegment } +const DOCS_TAB_SEGMENTS = new Set(['documentation', 'academy', 'api-reference']) + +/** + * Labels a docs/ corpus read as `
/` (e.g. `Workflows/index` + * for docs/documentation/workflows/index.mdx). The tab segment is dropped and + * single-level pages show just their capitalized name (e.g. `Getting-started`, + * or `Workflows` for the api-reference tag file workflows.json). + */ +function describeDocsReadTarget(segments: string[]): string { + let rest = segments.slice(1) + if (rest.length > 0 && DOCS_TAB_SEGMENTS.has(rest[0])) { + rest = rest.slice(1) + } + if (rest.length === 0) return 'docs' + const leaf = stripExtension(rest[rest.length - 1]) + if (rest.length === 1) return capitalizeFirst(leaf) + return `${capitalizeFirst(rest[0])}/${leaf}` +} + +function capitalizeFirst(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + function getLeafResourceSegment(segments: string[]): string { const lastSegment = segments[segments.length - 1] || '' if (hasFileExtension(lastSegment) && segments.length > 1) { From c5f355e712c0564228eee26f9bb787a06fac4dba Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:51:00 -0700 Subject: [PATCH 010/103] improvement(copilot): show the query in search_docs tool chips "Searched docs" becomes 'Searched docs for ""' (toolTitle/title preferred, query fallback, truncated at 60 chars). Also adds the missing browser_list_sessions display title the catalog regen surfaced. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/tools/tool-display.test.ts | 13 +++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 027ce68d915..948ffa2adcc 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -78,6 +78,19 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) + it('includes the query in search_docs titles', () => { + expect(getToolDisplayTitle('search_docs')).toBe('Searching docs') + expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( + 'Searching docs for "loop blocks iteration"' + ) + expect( + getToolDisplayTitle('search_docs', { + query: + 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', + })?.length + ).toBeLessThanOrEqual('Searching docs for ""'.length + 60 + '...'.length) + }) + it('falls back to running code for function_execute without a title', () => { expect(getToolDisplayTitle('function_execute')).toBe('Running code') expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 91c2a00ebdc..58f5de96f23 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,4 @@ -import { stripVersionSuffix } from '@sim/utils/string' +import { stripVersionSuffix, truncate } from '@sim/utils/string' /** * Single source of truth for copilot tool-call display titles. @@ -803,6 +803,10 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' } + case 'search_docs': { + const target = firstStringArg(args, 'toolTitle', 'title', 'query') + return target ? `Searching docs for "${truncate(target, 60)}"` : 'Searching docs' + } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching for ${target}` : 'Searching' From 9aa29aa7994f429efbd0e37460009779d03814e2 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:07:42 -0700 Subject: [PATCH 011/103] feat(copilot): build the docs vfs from a generated manifest, rescope search_docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the mothership's runtime docs corpus (llms.txt + llms-full.txt + openapi.json behind a 15m TTL cache) with a static manifest generated from the docs source, plus live per-page fetches. ~1,000 fewer lines of hand- written code and one repo instead of two. - scripts/sync-docs-manifest.ts walks apps/docs/content/docs/en and emits lib/copilot/generated/docs-manifest.ts. Each entry is simultaneously the docs/ VFS path and the docs.sim.ai URL path, so a read is a plain fetch. Section index pages fold onto their parent (fumadocs serves /workflows, not /workflows/index); academy/ and api-reference/ are excluded — they stay unmounted and unsearchable, reachable only via scrape_page. - docs-manifest:generate / :check, with a CI step so a page added, renamed, or deleted without regenerating fails the build. Content edits don't. - lib/copilot/docs/docs-corpus.ts + tools/handlers/vfs.ts: glob matches the manifest with no network, read fetches the page live, grep takes exactly ONE page (each is a fetch, so there is no corpus-wide grep). Opt-in like uploads/ — only an explicit docs/ prefix ever matches. - search_docs now scopes to the docs/ tree instead of docs/documentation/, validates its path against the manifest (a bad path errors instead of silently returning nothing), and returns the docs/ path with every chunk so search chains into read. Unscoped searches drop rows the agent could not then read: unmounted sections, and pages gone since the last index rebuild. - @docs tagging disabled: its query was the raw user message, a poor embedding query, and the mention UI it fed was already dead code. - Reverts the apps/docs /openapi.json route, added only for the old api-reference VFS views. Companion: simstudioai/mothership feat/enhance-search-agent Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-build.yml | 3 + apps/docs/app/openapi.json/route.ts | 23 -- apps/sim/lib/copilot/chat/process-contents.ts | 69 +--- apps/sim/lib/copilot/docs/docs-corpus.test.ts | 134 +++++++ apps/sim/lib/copilot/docs/docs-corpus.ts | 174 +++++++++ apps/sim/lib/copilot/docs/docs-search.test.ts | 171 ++++++++ apps/sim/lib/copilot/docs/docs-search.ts | 143 +++++++ .../lib/copilot/generated/docs-manifest.ts | 365 ++++++++++++++++++ .../copilot/tools/client/store-utils.test.ts | 18 +- .../lib/copilot/tools/client/store-utils.ts | 14 +- apps/sim/lib/copilot/tools/handlers/vfs.ts | 53 ++- .../tools/server/docs/search-docs.test.ts | 42 -- .../copilot/tools/server/docs/search-docs.ts | 106 +---- .../lib/copilot/tools/tool-display.test.ts | 13 - apps/sim/lib/copilot/tools/tool-display.ts | 6 +- package.json | 2 + scripts/sync-docs-manifest.ts | 108 ++++++ 17 files changed, 1175 insertions(+), 269 deletions(-) delete mode 100644 apps/docs/app/openapi.json/route.ts create mode 100644 apps/sim/lib/copilot/docs/docs-corpus.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-corpus.ts create mode 100644 apps/sim/lib/copilot/docs/docs-search.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-search.ts create mode 100644 apps/sim/lib/copilot/generated/docs-manifest.ts delete mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts create mode 100644 scripts/sync-docs-manifest.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 09cdf7dbb48..d5aa22c1ac2 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -123,6 +123,9 @@ jobs: - name: Repo audits run: bun run check:audits + - name: Verify docs manifest is in sync + run: bun run docs-manifest:check + - name: Migration safety (zero-downtime) audit run: | if [ "${{ github.event_name }}" = "pull_request" ]; then diff --git a/apps/docs/app/openapi.json/route.ts b/apps/docs/app/openapi.json/route.ts deleted file mode 100644 index a7d07ae3fa8..00000000000 --- a/apps/docs/app/openapi.json/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' - -export const revalidate = false - -/** - * Serves the raw OpenAPI spec (apps/docs/openapi.json) publicly so external - * consumers — notably the Mothership search agent's docs/api-reference/ VFS — - * can build per-tag views from the same spec that renders the API Reference. - */ -export async function GET() { - try { - const spec = await readFile(join(process.cwd(), 'openapi.json'), 'utf-8') - return new Response(spec, { - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - }) - } catch (error) { - console.error('Error serving openapi.json:', error) - return new Response('OpenAPI spec unavailable', { status: 500 }) - } -} diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 2cfc17a68c0..54b44e7afff 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -48,7 +48,6 @@ import { listFolders } from '@/lib/workflows/utils' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' -import { escapeRegExp } from '@/executor/constants' import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel' type AgentContextType = @@ -122,7 +121,8 @@ function formatTerminalSelection(selection: TerminalTextSelection): string { export async function processContextsServer( contexts: ChatContext[] | undefined, userId: string, - userMessage?: string, + /** Retained for call-site compatibility; unused while @docs tagging is disabled. */ + _userMessage: string | undefined, currentWorkspaceId?: string, chatId?: string ): Promise { @@ -312,21 +312,9 @@ export async function processContextsServer( path: result.path, } } - if (ctx.kind === 'docs') { - try { - const { searchDocsServerTool } = await import( - '@/lib/copilot/tools/server/docs/search-docs' - ) - const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' - const query = sanitizeMessageForDocs(rawQuery, contexts) - const res = await searchDocsServerTool.execute({ query, topK: 10 }) - const content = JSON.stringify(res?.results || []) - return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } - } catch (e) { - logger.error('Failed to process docs context', e) - return null - } - } + // `docs` contexts are intentionally inert: @docs tagging is disabled while + // the docs corpus moves to the `docs/` VFS tree. A tagged context resolves + // to nothing and is filtered out below. return null } catch (error) { logger.error('Failed processing context (server)', { ctx, error }) @@ -348,53 +336,6 @@ export async function processContextsServer( return filtered } -function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string { - if (!rawMessage) return '' - if (!Array.isArray(contexts) || contexts.length === 0) { - // No context mapping; conservatively strip all @mentions-like tokens - const stripped = rawMessage - .replace(/(^|\s)@([^\s]+)/g, ' ') - .replace(/\s{2,}/g, ' ') - .trim() - return stripped - } - - // Gather labels by kind - const blockLabels = new Set( - contexts - .filter((c) => c.kind === 'blocks') - .map((c) => c.label) - .filter((l): l is string => typeof l === 'string' && l.length > 0) - ) - const nonBlockLabels = new Set( - contexts - .filter((c) => c.kind !== 'blocks') - .map((c) => c.label) - .filter((l): l is string => typeof l === 'string' && l.length > 0) - ) - - let result = rawMessage - - // 1) Remove all non-block mentions entirely - for (const label of nonBlockLabels) { - const pattern = new RegExp(`(^|\\s)@${escapeRegExp(label)}(?!\\S)`, 'g') - result = result.replace(pattern, ' ') - } - - // 2) For block mentions, strip the '@' but keep the block name - for (const label of blockLabels) { - const pattern = new RegExp(`@${escapeRegExp(label)}(?!\\S)`, 'g') - result = result.replace(pattern, label) - } - - // 3) Remove any remaining @mentions (unknown or not in contexts) - result = result.replace(/(^|\s)@([^\s]+)/g, ' ') - - // Normalize whitespace - result = result.replace(/\s{2,}/g, ' ').trim() - return result -} - async function processSkillFromDb( skillId: string, workspaceId: string, diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts new file mode 100644 index 00000000000..0142ee4f169 --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + couldMatchDocsScope, + DocsCorpusError, + globDocs, + grepDocsPage, + isDocsPath, + readDocsPage, +} from '@/lib/copilot/docs/docs-corpus' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' + +const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx') + +describe('docs corpus scoping', () => { + it('recognizes docs paths', () => { + expect(isDocsPath('docs/workflows.mdx')).toBe(true) + expect(isDocsPath('docs')).toBe(true) + expect(isDocsPath('/docs/workflows.mdx')).toBe(true) + expect(isDocsPath('workflows.mdx')).toBe(false) + expect(isDocsPath('files/report.pdf')).toBe(false) + expect(isDocsPath('docsomething/x')).toBe(false) + expect(isDocsPath(undefined)).toBe(false) + }) + + it('is opt-in: only an explicit docs/ pattern can match', () => { + expect(couldMatchDocsScope('docs/**')).toBe(true) + expect(couldMatchDocsScope('docs/workflows/**')).toBe(true) + expect(couldMatchDocsScope('**')).toBe(false) + expect(couldMatchDocsScope('**/*.mdx')).toBe(false) + expect(couldMatchDocsScope('*')).toBe(false) + expect(couldMatchDocsScope(undefined)).toBe(false) + }) +}) + +describe('globDocs', () => { + it('lists the whole corpus under docs/**', () => { + const files = globDocs('docs/**') + expect(files.length).toBeGreaterThan(DOCS_MANIFEST.length) + expect(files).toContain('docs/workflows/blocks/agent.mdx') + expect(files).toContain('docs/workflows/blocks') + }) + + it('scopes to a section', () => { + const files = globDocs('docs/integrations/*.mdx') + expect(files).toContain('docs/integrations/gmail.mdx') + expect(files.every((path) => path.startsWith('docs/integrations/'))).toBe(true) + }) + + it('excludes academy and api-reference', () => { + expect(globDocs('docs/academy/**')).toEqual([]) + expect(globDocs('docs/api-reference/**')).toEqual([]) + }) + + it('maps section index pages onto their parent URL path', () => { + expect(globDocs('docs/workflows.mdx')).toEqual(['docs/workflows.mdx']) + expect(globDocs('docs/workflows/index.mdx')).toEqual([]) + }) +}) + +describe('readDocsPage', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetches the manifest path verbatim from the docs site', async () => { + expect(SAMPLE_PAGE).toBeDefined() + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '# Agent\n\nbody' }) + + const page = await readDocsPage(`docs/${SAMPLE_PAGE}`) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls[0][0]).toBe(`https://docs.sim.ai/${SAMPLE_PAGE}`) + expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 }) + }) + + it('rejects an unknown page without fetching', async () => { + await expect(readDocsPage('docs/not-a-real-page.mdx')).rejects.toThrow(DocsCorpusError) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('points a directory read at glob', async () => { + await expect(readDocsPage('docs/workflows/blocks')).rejects.toThrow(/is a directory/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('surfaces a docs-site failure as a retryable error', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' }) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + }) +}) + +describe('grepDocsPage', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('greps exactly one page', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'intro line\nsystemPrompt matters\ntail', + }) + + const matches = await grepDocsPage(`docs/${SAMPLE_PAGE}`, 'systemPrompt') + + expect(fetchMock).toHaveBeenCalledOnce() + expect(matches).toEqual([ + { path: `docs/${SAMPLE_PAGE}`, line: 2, content: 'systemPrompt matters' }, + ]) + }) + + it('refuses a multi-page scope so one grep is never hundreds of fetches', async () => { + await expect(grepDocsPage('docs/', 'cron')).rejects.toThrow(/single page/) + await expect(grepDocsPage('docs/workflows', 'cron')).rejects.toThrow(/single page/) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts new file mode 100644 index 00000000000..5a5c3f1d7ee --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -0,0 +1,174 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' +import { glob as globPaths, grep as grepFiles } from '@/lib/copilot/vfs/operations' + +const logger = createLogger('DocsCorpus') + +/** The public docs site the `docs/` tree is a lazy view of. */ +const DOCS_BASE_URL = 'https://docs.sim.ai' + +/** VFS prefix the docs corpus is mounted at. */ +const DOCS_PREFIX = 'docs/' + +const FETCH_TIMEOUT_MS = 10_000 + +/** + * Thrown for expected, user-facing docs-corpus conditions (unknown page, + * directory path, site unreachable). The VFS handlers return the message as the + * tool error instead of logging an internal failure. + */ +export class DocsCorpusError extends Error { + readonly code = 'DOCS_CORPUS' as const + constructor(message: string) { + super(message) + this.name = 'DocsCorpusError' + } +} + +/** + * Keys-only view of the corpus for glob: every manifest path under `docs/`, + * mapped to empty content. `ops.glob` matches keys and derives the virtual + * directories from them, so this never touches the network. + */ +const docsKeyView: Map = new Map( + DOCS_MANIFEST.map((path) => [`${DOCS_PREFIX}${path}`, '']) +) + +function normalize(path: string): string { + return path.trim().replace(/^\/+/, '') +} + +/** + * True when a read/grep `path` addresses the docs corpus. Deliberately not a + * `path is string` type predicate: the callers chain it ahead of the other + * namespace checks, and a predicate would narrow `path` to `never` in every + * later branch. + */ +export function isDocsPath(path: string | undefined): boolean { + if (!path) return false + const normalized = normalize(path) + return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX) +} + +/** + * True when a glob `pattern` could match the docs corpus. Like `uploads/` and + * `recently-deleted/`, the corpus is opt-in: only a pattern that explicitly + * starts with `docs/` (or is exactly `docs`) sees it, so a broad `**` glob never + * drags 300+ doc pages into the result. + */ +export function couldMatchDocsScope(pattern: string | undefined): boolean { + if (!pattern) return false + const normalized = normalize(pattern) + return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX) +} + +/** Manifest paths (and their virtual directories) matching an explicit `docs/` pattern. */ +export function globDocs(pattern: string): string[] { + return globPaths(docsKeyView, normalize(pattern)) +} + +/** True when `path` is a page in the docs tree. */ +export function isDocsPage(path: string): boolean { + return docsKeyView.has(normalize(path)) +} + +/** + * Map a `docs_embeddings.source_document` (the en-relative mdx file path) back to + * its `docs/` VFS path, applying the same index-page fold as the manifest + * generator. Returns null when the source has no live VFS path — an unmounted + * section (academy, api-reference) or a page deleted since the index was built. + */ +export function docsPathForSourceDocument(sourceDocument: string | null): string | null { + if (!sourceDocument) return null + const path = `${DOCS_PREFIX}${sourceDocument.replace(/^\/+/, '').replace(/\/index\.mdx$/, '.mdx')}` + return docsKeyView.has(path) ? path : null +} + +/** True when `path` is a directory in the docs tree rather than a page. */ +export function isDocsDir(path: string): boolean { + const dir = `${normalize(path).replace(/\/+$/, '')}/` + if (dir === DOCS_PREFIX) return true + for (const key of docsKeyView.keys()) { + if (key.startsWith(dir)) return true + } + return false +} + +export interface DocsPage { + content: string + totalLines: number +} + +/** + * Fetch one docs page's raw markdown from the live site. The manifest path IS + * the URL path (`docs/workflows/blocks/agent.mdx` → + * `https://docs.sim.ai/workflows/blocks/agent.mdx`, which the docs app rewrites + * to its raw-markdown route), so no mapping table is needed. Returns null when + * the page is not in the manifest or the site does not serve it. + */ +async function fetchDocsPage(path: string): Promise { + const key = normalize(path) + if (!docsKeyView.has(key)) return null + const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { Accept: 'text/markdown, text/plain' }, + }) + if (!response.ok) { + logger.warn('Docs page fetch returned a non-OK status', { url, status: response.status }) + return null + } + return await response.text() + } catch (err) { + logger.warn('Docs page fetch failed', { url, error: toError(err).message }) + return null + } +} + +/** + * Read one docs page. Throws {@link DocsCorpusError} for the expected user-facing + * conditions (directory path, unknown page, site unreachable) so the handler can + * surface the message verbatim. + */ +export async function readDocsPage(path: string): Promise { + const key = normalize(path) + if (!docsKeyView.has(key)) { + if (isDocsDir(key)) { + const dir = key.replace(/\/+$/, '') + throw new DocsCorpusError(`${dir} is a directory — glob "${dir}/**" to list its pages.`) + } + throw new DocsCorpusError( + `Docs page not found: ${path}. Use glob("docs/**") to list the docs corpus.` + ) + } + const content = await fetchDocsPage(key) + if (content === null) { + throw new DocsCorpusError( + `Could not load ${key} from ${DOCS_BASE_URL} — the docs site is temporarily unavailable. Retry shortly.` + ) + } + return { content, totalLines: content.split('\n').length } +} + +/** + * Grep ONE docs page, mirroring how grep over `files/` works: each page is a + * separate fetch from the docs site, so a multi-page grep would mean hundreds of + * requests. A path that is not a single page throws. + */ +export async function grepDocsPage( + path: string, + pattern: string, + options?: GrepOptions +): Promise { + const key = normalize(path) + if (!docsKeyView.has(key)) { + throw new DocsCorpusError( + `Grep over the docs corpus must target a single page (e.g. path: "docs/workflows/blocks/agent.mdx"). "${path}" is not a docs page. Use glob("docs/**") to find the exact path, then grep that one page.` + ) + } + const page = await readDocsPage(key) + return grepFiles(new Map([[key, page.content]]), pattern, undefined, options) +} diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts new file mode 100644 index 00000000000..8407f2e336f --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateSearchEmbedding, capturedWhere, mockRows } = vi.hoisted(() => ({ + mockGenerateSearchEmbedding: vi.fn(), + capturedWhere: { value: undefined as unknown }, + mockRows: { value: [] as unknown[] }, +})) + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateSearchEmbedding: mockGenerateSearchEmbedding, +})) + +/** + * Override the global drizzle mock with operators that record their arguments, + * so a test can assert on the `source_document` filter the scope produced. + */ +vi.mock('drizzle-orm', () => { + const op = + (name: string) => + (...args: unknown[]) => ({ op: name, args }) + return { + and: op('and'), + or: op('or'), + eq: op('eq'), + like: op('like'), + notLike: op('notLike'), + sql: (strings: TemplateStringsArray) => ({ op: 'sql', text: strings.join('?') }), + } +}) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: (condition: unknown) => { + capturedWhere.value = condition + return { + orderBy: () => ({ limit: async () => mockRows.value }), + } + }, + }), + }), + }, +})) + +import { DocsSearchScopeError, searchDocs } from '@/lib/copilot/docs/docs-search' + +/** Render a drizzle condition to comparable SQL-ish text for assertions. */ +function whereText(): string { + return JSON.stringify(capturedWhere.value) +} + +describe('searchDocs path scoping', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockRows.value = [] + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('excludes unmounted sections when unscoped', async () => { + await searchDocs('cron') + expect(whereText()).toContain('academy/%') + expect(whereText()).toContain('api-reference/%') + }) + + it('treats a bare docs prefix as unscoped', async () => { + await searchDocs('cron', { path: 'docs/' }) + expect(whereText()).toContain('academy/%') + }) + + it('scopes a page to both on-disk layouts', async () => { + await searchDocs('cron', { path: 'docs/workflows/blocks/agent.mdx' }) + const text = whereText() + expect(text).toContain('workflows/blocks/agent.mdx') + expect(text).toContain('workflows/blocks/agent/index.mdx') + }) + + it('maps a section overview page onto its index file', async () => { + await searchDocs('cron', { path: 'docs/workflows.mdx' }) + const text = whereText() + expect(text).toContain('workflows/index.mdx') + }) + + it('scopes a directory to its subtree', async () => { + await searchDocs('cron', { path: 'docs/workflows' }) + expect(whereText()).toContain('workflows/%') + }) + + it('rejects a path outside the docs corpus', async () => { + await expect(searchDocs('cron', { path: 'files/report.pdf' })).rejects.toThrow( + DocsSearchScopeError + ) + }) + + it('rejects a docs path that is neither a page nor a section', async () => { + await expect(searchDocs('cron', { path: 'docs/not-a-real-section' })).rejects.toThrow( + /not a page or section/ + ) + }) + + it('rejects unmounted sections that exist on the site but not in the VFS', async () => { + await expect(searchDocs('cron', { path: 'docs/academy' })).rejects.toThrow( + /not a page or section/ + ) + }) +}) + +describe('searchDocs results', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('returns the docs/ path to read next, folding index pages', async () => { + mockRows.value = [ + { + chunkText: 'body', + sourceDocument: 'workflows/index.mdx', + sourceLink: 'https://docs.sim.ai/workflows', + headerText: 'Overview', + similarity: 0.8, + }, + ] + const results = await searchDocs('cron') + expect(results).toEqual([ + { + path: 'docs/workflows.mdx', + url: 'https://docs.sim.ai/workflows', + title: 'Overview', + content: 'body', + similarity: 0.8, + }, + ]) + }) + + it('drops chunks whose source has no live docs/ path', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'academy/lesson-1.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.9, + }, + { + chunkText: 'b', + sourceDocument: 'deleted-page.mdx', + sourceLink: 'y', + headerText: 'h', + similarity: 0.9, + }, + ] + expect(await searchDocs('cron')).toEqual([]) + }) + + it('drops chunks below the similarity threshold', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.1, + }, + ] + expect(await searchDocs('cron')).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts new file mode 100644 index 00000000000..0f5553a4858 --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -0,0 +1,143 @@ +import { db } from '@sim/db' +import { docsEmbeddings } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, like, notLike, or, sql } from 'drizzle-orm' +import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' +import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' + +const logger = createLogger('DocsSearch') + +const SIMILARITY_THRESHOLD = 0.3 +const DEFAULT_TOP_K = 10 +const MAX_TOP_K = 25 + +export interface DocsSearchResult { + /** The `docs/` VFS path this chunk came from — pass it to `read` for the full page. */ + path: string + /** Public docs.sim.ai URL for the section, for citation. */ + url: string + title: string + content: string + similarity: number +} + +/** + * Thrown when the caller scopes a search to a `path` that is not a real page or + * section in the docs corpus. Surfaced verbatim so the model can correct itself + * rather than reading an empty result as "the docs say nothing about this". + */ +export class DocsSearchScopeError extends Error { + readonly code = 'DOCS_SEARCH_SCOPE' as const + constructor(message: string) { + super(message) + this.name = 'DocsSearchScopeError' + } +} + +/** + * Translate an optional `docs/` VFS path into a `source_document` filter. + * + * `source_document` stores the en-relative mdx file path, while VFS paths mirror + * the public URL — so a section overview is `docs/workflows.mdx` in the VFS but + * `workflows/index.mdx` (or `workflows.mdx`) on disk. A directory scope covers + * the whole subtree, including that overview page. + * + * Returns undefined for an unscoped search, which excludes `academy/` and + * `api-reference/`: both are indexed but neither is mounted in the VFS, so a hit + * there would be a chunk the agent cannot then read. + */ +function scopeCondition(path?: string) { + const normalized = (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '') + if (normalized === '' || normalized === 'docs') { + return and( + notLike(docsEmbeddings.sourceDocument, 'academy/%'), + notLike(docsEmbeddings.sourceDocument, 'api-reference/%') + ) + } + + if (!normalized.startsWith('docs/')) { + throw new DocsSearchScopeError( + `path must be a docs/ VFS path (got "${path}"). Use glob("docs/**") to find one, or omit path to search everything.` + ) + } + + const tail = normalized.slice('docs/'.length) + + if (isDocsPage(normalized)) { + // One page: on disk it is either `.mdx` or `/index.mdx`. + const stem = tail.replace(/\.mdx$/, '') + return or( + eq(docsEmbeddings.sourceDocument, `${stem}.mdx`), + eq(docsEmbeddings.sourceDocument, `${stem}/index.mdx`) + ) + } + + if (isDocsDir(normalized)) { + return like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`) + } + + throw new DocsSearchScopeError( + `"${path}" is not a page or section in the docs corpus. Use glob("docs/**") to find a valid path, or omit path to search everything.` + ) +} + +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`) +} + +/** + * Semantic search over the indexed docs corpus (`docs_embeddings`, rebuilt by + * `scripts/process-docs.ts` on release). Every result carries the `docs/` path + * it came from so the caller can `read` the full page next. + * + * The index lags the VFS: a page added since the last index rebuild is readable + * but not searchable, and a deleted one can still return chunks. Results whose + * source no longer maps to a live `docs/` path are dropped. + */ +export async function searchDocs( + query: string, + options?: { path?: string; topK?: number } +): Promise { + if (!query || typeof query !== 'string') throw new Error('query is required') + + const topK = Math.min(Math.max(Math.trunc(options?.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) + const where = scopeCondition(options?.path) + + logger.info('Executing docs search', { query, topK, path: options?.path ?? null }) + + const { embedding: queryEmbedding } = await generateSearchEmbedding(query) + if (!queryEmbedding || queryEmbedding.length === 0) return [] + + const rows = await db + .select({ + chunkText: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, + sourceLink: docsEmbeddings.sourceLink, + headerText: docsEmbeddings.headerText, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, + }) + .from(docsEmbeddings) + .where(where) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) + .limit(topK) + + const results: DocsSearchResult[] = [] + for (const row of rows) { + if (row.similarity < SIMILARITY_THRESHOLD) continue + const path = docsPathForSourceDocument(row.sourceDocument) + if (!path) continue + results.push({ + path, + url: String(row.sourceLink || '#'), + title: String(row.headerText || 'Untitled Section'), + content: String(row.chunkText || ''), + similarity: row.similarity, + }) + } + + logger.info('Docs search complete', { + count: results.length, + dropped: rows.length - results.length, + }) + return results +} diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts new file mode 100644 index 00000000000..720d5371947 --- /dev/null +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -0,0 +1,365 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts +// Run: bun run docs-manifest:generate +// + +/** + * Every page in the copilot's read-only `docs/` VFS tree, as a path that is + * simultaneously the `docs/`-relative VFS path and the docs.sim.ai URL path + * (so `docs/workflows/blocks/agent.mdx` reads + * `https://docs.sim.ai/workflows/blocks/agent.mdx`). Sorted. + */ +export const DOCS_MANIFEST: readonly string[] = [ + 'agents.mdx', + 'agents/choosing.mdx', + 'agents/custom-tools.mdx', + 'agents/mcp.mdx', + 'agents/skills.mdx', + 'files.mdx', + 'files/editor.mdx', + 'files/generating.mdx', + 'files/passing-files.mdx', + 'files/using-in-workflows.mdx', + 'getting-started.mdx', + 'integrations.mdx', + 'integrations/a2a.mdx', + 'integrations/agentmail.mdx', + 'integrations/agentphone.mdx', + 'integrations/agiloft.mdx', + 'integrations/ahrefs.mdx', + 'integrations/airtable-service-account.mdx', + 'integrations/airtable.mdx', + 'integrations/airweave.mdx', + 'integrations/algolia.mdx', + 'integrations/amplitude.mdx', + 'integrations/apify.mdx', + 'integrations/apollo.mdx', + 'integrations/appconfig.mdx', + 'integrations/arxiv.mdx', + 'integrations/asana-service-account.mdx', + 'integrations/asana.mdx', + 'integrations/ashby.mdx', + 'integrations/athena.mdx', + 'integrations/atlassian-service-account.mdx', + 'integrations/attio-service-account.mdx', + 'integrations/attio.mdx', + 'integrations/azure_devops.mdx', + 'integrations/box-service-account.mdx', + 'integrations/box.mdx', + 'integrations/brandfetch.mdx', + 'integrations/brex.mdx', + 'integrations/brightdata.mdx', + 'integrations/browser_use.mdx', + 'integrations/buffer.mdx', + 'integrations/calcom-service-account.mdx', + 'integrations/calcom.mdx', + 'integrations/calendly.mdx', + 'integrations/circleback.mdx', + 'integrations/clay.mdx', + 'integrations/clerk.mdx', + 'integrations/clickhouse.mdx', + 'integrations/clickup-service-account.mdx', + 'integrations/clickup.mdx', + 'integrations/cloudflare.mdx', + 'integrations/cloudformation.mdx', + 'integrations/cloudwatch.mdx', + 'integrations/codepipeline.mdx', + 'integrations/confluence.mdx', + 'integrations/context_dev.mdx', + 'integrations/convex.mdx', + 'integrations/crowdstrike.mdx', + 'integrations/cursor.mdx', + 'integrations/dagster.mdx', + 'integrations/databricks.mdx', + 'integrations/datadog.mdx', + 'integrations/datagma.mdx', + 'integrations/daytona.mdx', + 'integrations/deployments.mdx', + 'integrations/devin.mdx', + 'integrations/discord.mdx', + 'integrations/docusign.mdx', + 'integrations/downdetector.mdx', + 'integrations/dropbox.mdx', + 'integrations/dropcontact.mdx', + 'integrations/dspy.mdx', + 'integrations/dub.mdx', + 'integrations/duckduckgo.mdx', + 'integrations/dynamodb.mdx', + 'integrations/elasticsearch.mdx', + 'integrations/elevenlabs.mdx', + 'integrations/emailbison.mdx', + 'integrations/enrich.mdx', + 'integrations/enrichment.mdx', + 'integrations/enrow.mdx', + 'integrations/evernote.mdx', + 'integrations/exa.mdx', + 'integrations/extend.mdx', + 'integrations/fathom.mdx', + 'integrations/file.mdx', + 'integrations/findymail.mdx', + 'integrations/firecrawl.mdx', + 'integrations/fireflies.mdx', + 'integrations/flint.mdx', + 'integrations/gamma.mdx', + 'integrations/github.mdx', + 'integrations/gitlab.mdx', + 'integrations/gmail.mdx', + 'integrations/gong.mdx', + 'integrations/google-service-account.mdx', + 'integrations/google_ads.mdx', + 'integrations/google_appsheet.mdx', + 'integrations/google_bigquery.mdx', + 'integrations/google_books.mdx', + 'integrations/google_calendar.mdx', + 'integrations/google_contacts.mdx', + 'integrations/google_docs.mdx', + 'integrations/google_drive.mdx', + 'integrations/google_forms.mdx', + 'integrations/google_groups.mdx', + 'integrations/google_maps.mdx', + 'integrations/google_meet.mdx', + 'integrations/google_pagespeed.mdx', + 'integrations/google_search.mdx', + 'integrations/google_sheets.mdx', + 'integrations/google_slides.mdx', + 'integrations/google_tasks.mdx', + 'integrations/google_translate.mdx', + 'integrations/google_vault.mdx', + 'integrations/grafana.mdx', + 'integrations/grain.mdx', + 'integrations/granola.mdx', + 'integrations/greenhouse.mdx', + 'integrations/greptile.mdx', + 'integrations/hex.mdx', + 'integrations/hubspot-service-account.mdx', + 'integrations/hubspot-setup.mdx', + 'integrations/hubspot.mdx', + 'integrations/huggingface.mdx', + 'integrations/hunter.mdx', + 'integrations/iam.mdx', + 'integrations/icypeas.mdx', + 'integrations/identity_center.mdx', + 'integrations/imap.mdx', + 'integrations/incidentio.mdx', + 'integrations/infisical.mdx', + 'integrations/instantly.mdx', + 'integrations/intercom.mdx', + 'integrations/jina.mdx', + 'integrations/jira.mdx', + 'integrations/jira_service_management.mdx', + 'integrations/jupyter.mdx', + 'integrations/kalshi.mdx', + 'integrations/ketch.mdx', + 'integrations/knowledge.mdx', + 'integrations/langsmith.mdx', + 'integrations/latex.mdx', + 'integrations/launchdarkly.mdx', + 'integrations/leadmagic.mdx', + 'integrations/lemlist.mdx', + 'integrations/linear-service-account.mdx', + 'integrations/linear.mdx', + 'integrations/linkedin.mdx', + 'integrations/linkup.mdx', + 'integrations/linq.mdx', + 'integrations/logs.mdx', + 'integrations/loops.mdx', + 'integrations/luma.mdx', + 'integrations/mailchimp.mdx', + 'integrations/mailgun.mdx', + 'integrations/mem0.mdx', + 'integrations/memory.mdx', + 'integrations/microsoft_ad.mdx', + 'integrations/microsoft_dataverse.mdx', + 'integrations/microsoft_excel.mdx', + 'integrations/microsoft_planner.mdx', + 'integrations/microsoft_teams.mdx', + 'integrations/millionverifier.mdx', + 'integrations/mistral_parse.mdx', + 'integrations/monday-service-account.mdx', + 'integrations/monday.mdx', + 'integrations/mongodb.mdx', + 'integrations/mysql.mdx', + 'integrations/neo4j.mdx', + 'integrations/neverbounce.mdx', + 'integrations/new_relic.mdx', + 'integrations/notion-service-account.mdx', + 'integrations/notion.mdx', + 'integrations/obsidian.mdx', + 'integrations/okta.mdx', + 'integrations/onedrive.mdx', + 'integrations/onepassword.mdx', + 'integrations/openai.mdx', + 'integrations/outlook.mdx', + 'integrations/pagerduty.mdx', + 'integrations/parallel_ai.mdx', + 'integrations/peopledatalabs.mdx', + 'integrations/perplexity.mdx', + 'integrations/persona.mdx', + 'integrations/pinecone.mdx', + 'integrations/pipedrive-service-account.mdx', + 'integrations/pipedrive.mdx', + 'integrations/polymarket.mdx', + 'integrations/postgresql.mdx', + 'integrations/posthog.mdx', + 'integrations/profound.mdx', + 'integrations/prospeo.mdx', + 'integrations/pulse.mdx', + 'integrations/qdrant.mdx', + 'integrations/quartr.mdx', + 'integrations/quiver.mdx', + 'integrations/railway.mdx', + 'integrations/rb2b.mdx', + 'integrations/rds.mdx', + 'integrations/reddit.mdx', + 'integrations/redis.mdx', + 'integrations/reducto.mdx', + 'integrations/resend.mdx', + 'integrations/revenuecat.mdx', + 'integrations/rippling.mdx', + 'integrations/rocketlane.mdx', + 'integrations/rootly.mdx', + 'integrations/s3.mdx', + 'integrations/salesforce-service-account.mdx', + 'integrations/salesforce.mdx', + 'integrations/sap_concur.mdx', + 'integrations/sap_s4hana.mdx', + 'integrations/secrets_manager.mdx', + 'integrations/sendblue.mdx', + 'integrations/sendgrid.mdx', + 'integrations/sentry.mdx', + 'integrations/serper.mdx', + 'integrations/servicenow.mdx', + 'integrations/ses.mdx', + 'integrations/sftp.mdx', + 'integrations/sharepoint.mdx', + 'integrations/shopify-service-account.mdx', + 'integrations/shopify.mdx', + 'integrations/similarweb.mdx', + 'integrations/sixtyfour.mdx', + 'integrations/slack.mdx', + 'integrations/smtp.mdx', + 'integrations/sportmonks.mdx', + 'integrations/sqs.mdx', + 'integrations/square.mdx', + 'integrations/ssh.mdx', + 'integrations/stagehand.mdx', + 'integrations/stripe.mdx', + 'integrations/sts.mdx', + 'integrations/supabase.mdx', + 'integrations/table.mdx', + 'integrations/tailscale.mdx', + 'integrations/tavily.mdx', + 'integrations/telegram.mdx', + 'integrations/temporal.mdx', + 'integrations/textract.mdx', + 'integrations/thrive.mdx', + 'integrations/tinybird.mdx', + 'integrations/trello-service-account.mdx', + 'integrations/trello.mdx', + 'integrations/trigger_dev.mdx', + 'integrations/twilio.mdx', + 'integrations/twilio_sms.mdx', + 'integrations/twilio_voice.mdx', + 'integrations/typeform.mdx', + 'integrations/upstash.mdx', + 'integrations/uptimerobot.mdx', + 'integrations/vanta.mdx', + 'integrations/vercel.mdx', + 'integrations/wealthbox-service-account.mdx', + 'integrations/wealthbox.mdx', + 'integrations/webflow-service-account.mdx', + 'integrations/webflow.mdx', + 'integrations/whatsapp.mdx', + 'integrations/wikipedia.mdx', + 'integrations/wiza.mdx', + 'integrations/wordpress.mdx', + 'integrations/workday.mdx', + 'integrations/x.mdx', + 'integrations/youtube.mdx', + 'integrations/zendesk.mdx', + 'integrations/zep.mdx', + 'integrations/zerobounce.mdx', + 'integrations/zoom-service-account.mdx', + 'integrations/zoom.mdx', + 'integrations/zoominfo.mdx', + 'introduction.mdx', + 'keyboard-shortcuts.mdx', + 'knowledgebase.mdx', + 'knowledgebase/chunking-strategies.mdx', + 'knowledgebase/connectors.mdx', + 'knowledgebase/debugging-retrieval.mdx', + 'knowledgebase/tags.mdx', + 'knowledgebase/using-in-workflows.mdx', + 'logs-debugging.mdx', + 'logs-debugging/alerts.mdx', + 'logs-debugging/logging.mdx', + 'mothership.mdx', + 'mothership/files.mdx', + 'mothership/knowledge.mdx', + 'mothership/mailer.mdx', + 'mothership/research.mdx', + 'mothership/tables.mdx', + 'mothership/tasks.mdx', + 'mothership/workflows.mdx', + 'platform/costs.mdx', + 'platform/credentials.mdx', + 'platform/enterprise.mdx', + 'platform/enterprise/access-control.mdx', + 'platform/enterprise/audit-logs.mdx', + 'platform/enterprise/custom-blocks.mdx', + 'platform/enterprise/data-drains.mdx', + 'platform/enterprise/data-retention.mdx', + 'platform/enterprise/forks.mdx', + 'platform/enterprise/session-policies.mdx', + 'platform/enterprise/sso.mdx', + 'platform/enterprise/verified-domains.mdx', + 'platform/enterprise/whitelabeling.mdx', + 'platform/organization.mdx', + 'platform/permissions.mdx', + 'platform/self-hosting.mdx', + 'platform/self-hosting/docker.mdx', + 'platform/self-hosting/environment-variables.mdx', + 'platform/self-hosting/kubernetes.mdx', + 'platform/self-hosting/object-storage.mdx', + 'platform/self-hosting/platforms.mdx', + 'platform/self-hosting/troubleshooting.mdx', + 'platform/workspaces.mdx', + 'quick-reference.mdx', + 'tables.mdx', + 'tables/using-in-workflows.mdx', + 'tables/workflow-columns.mdx', + 'workflows.mdx', + 'workflows/blocks/agent.mdx', + 'workflows/blocks/api.mdx', + 'workflows/blocks/condition.mdx', + 'workflows/blocks/credential.mdx', + 'workflows/blocks/evaluator.mdx', + 'workflows/blocks/function.mdx', + 'workflows/blocks/guardrails.mdx', + 'workflows/blocks/human-in-the-loop.mdx', + 'workflows/blocks/logs.mdx', + 'workflows/blocks/loop.mdx', + 'workflows/blocks/parallel.mdx', + 'workflows/blocks/pi.mdx', + 'workflows/blocks/response.mdx', + 'workflows/blocks/router.mdx', + 'workflows/blocks/variables.mdx', + 'workflows/blocks/wait.mdx', + 'workflows/blocks/webhook.mdx', + 'workflows/blocks/workflow.mdx', + 'workflows/connections.mdx', + 'workflows/data-flow.mdx', + 'workflows/deployment.mdx', + 'workflows/deployment/agent-events.mdx', + 'workflows/deployment/api.mdx', + 'workflows/deployment/chat.mdx', + 'workflows/deployment/mcp.mdx', + 'workflows/how-it-runs.mdx', + 'workflows/triggers/rss.mdx', + 'workflows/triggers/schedule.mdx', + 'workflows/triggers/sim.mdx', + 'workflows/triggers/start.mdx', + 'workflows/triggers/table.mdx', + 'workflows/triggers/webhook.mdx', + 'workflows/variables.mdx', +] diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 6f973c23f8e..fa6de4c0b14 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -49,28 +49,22 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) - it('formats docs corpus reads as Section/filename', () => { + it('formats docs corpus reads as Section/page', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { - path: 'docs/documentation/workflows/index.mdx', + path: 'docs/workflows/blocks/agent.mdx', })?.text - ).toBe('Read Workflows/index') + ).toBe('Read Workflows/agent') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { - path: 'docs/academy/agents/block.mdx', + path: 'docs/integrations/gmail.mdx', })?.text - ).toBe('Reading Agents/block') - - expect( - resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { - path: 'docs/api-reference/workflows.json', - })?.text - ).toBe('Read Workflows') + ).toBe('Reading Integrations/gmail') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { - path: 'docs/documentation/getting-started.mdx', + path: 'docs/getting-started.mdx', })?.text ).toBe('Attempted to read Getting-started') }) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 5ca68ff5597..1a448d75e5d 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -144,19 +144,13 @@ function describeFileReadTarget(segments: string[]): string { return lastSegment } -const DOCS_TAB_SEGMENTS = new Set(['documentation', 'academy', 'api-reference']) - /** - * Labels a docs/ corpus read as `
/` (e.g. `Workflows/index` - * for docs/documentation/workflows/index.mdx). The tab segment is dropped and - * single-level pages show just their capitalized name (e.g. `Getting-started`, - * or `Workflows` for the api-reference tag file workflows.json). + * Labels a docs/ corpus read as `
/` (e.g. `Workflows/agent` for + * docs/workflows/blocks/agent.mdx). Top-level pages show just their capitalized + * name (e.g. `Getting-started` for docs/getting-started.mdx). */ function describeDocsReadTarget(segments: string[]): string { - let rest = segments.slice(1) - if (rest.length > 0 && DOCS_TAB_SEGMENTS.has(rest[0])) { - rest = rest.slice(1) - } + const rest = segments.slice(1) if (rest.length === 0) return 'docs' const leaf = stripExtension(rest[rest.length - 1]) if (rest.length === 1) return capitalizeFirst(leaf) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index ba8bd734bca..e2c7f13c3aa 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -4,6 +4,14 @@ import { resolveCopilotKnowledgePrincipal } from '@/lib/copilot/application/exec import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' +import { + couldMatchDocsScope, + DocsCorpusError, + globDocs, + grepDocsPage, + isDocsPath, + readDocsPage, +} from '@/lib/copilot/docs/docs-corpus' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' @@ -154,14 +162,17 @@ export async function executeVfsGrep( // Routing mirrors read/glob: // - uploads/ -> grep one chat upload's content (chat-scoped) + // - docs/ -> grep one docs.sim.ai page (one page only — each is a fetch) // - files/ -> grep one workspace file's content (one file only) // - everything else -> grep the in-memory VFS map (workflow JSON, metadata) - // Chat uploads are opt-in like recently-deleted/: they are never in the VFS - // map, so an unscoped grep can't touch them — only an explicit uploads/ - // path does, and only one upload at a time. + // Chat uploads and the docs corpus are opt-in like recently-deleted/: they are + // never in the VFS map, so an unscoped grep can't touch them — only an explicit + // uploads/ or docs/ path does, and only one at a time. let result: GrepMatch[] | string[] | GrepCountEntry[] let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined - if (isChatUploadGrepPath(rawPath)) { + if (rawPath !== undefined && isDocsPath(rawPath)) { + result = await grepDocsPage(rawPath, pattern, grepOptions) + } else if (isChatUploadGrepPath(rawPath)) { if (!context.chatId) { return { success: false, error: 'No chat context available for uploads/' } } @@ -223,8 +234,8 @@ export async function executeVfsGrep( } catch (err) { // Expected single-file scoping / no-text / too-large conditions: surface the // message verbatim instead of logging an internal failure. - if (err instanceof WorkspaceFileGrepError) { - logger.debug('vfs_grep workspace file rejected', { + if (err instanceof WorkspaceFileGrepError || err instanceof DocsCorpusError) { + logger.debug('vfs_grep single-file scope rejected', { pattern, path: rawPath, error: err.message, @@ -255,6 +266,15 @@ export async function executeVfsGlob( } try { + // The docs corpus is a lazy view of docs.sim.ai built from the generated + // manifest, not part of the workspace VFS — an explicit docs/ pattern is the + // only way to see it. + if (couldMatchDocsScope(pattern)) { + const files = globDocs(pattern) + logger.debug('vfs_glob docs result', { pattern, fileCount: files.length }) + return { success: true, output: { files } } + } + const vfs = await getGatedVFS(context) let files = vfs.glob(pattern) @@ -323,6 +343,21 @@ export async function executeVfsRead( } } + // Docs pages are fetched from the live docs site on demand — the manifest + // path is the URL path, so there is nothing workspace-scoped to resolve. + if (isDocsPath(path)) { + const page = await readDocsPage(path) + const windowed = applyWindow(page) + if (serializedResultSize(windowed) > TOOL_RESULT_MAX_INLINE_CHARS) { + return { + success: false, + error: `${path} is too large to return inline. Grep that one page for the relevant section, then retry read with offset/limit.`, + } + } + logger.debug('vfs_read resolved docs page', { path, totalLines: page.totalLines }) + return { success: true, output: windowed } + } + // Handle chat-scoped uploads via the uploads/ virtual prefix. // Uploads are flat and have no metadata/content split like files/ — the upload // IS the first path segment after uploads/. Any trailing segment (e.g. a @@ -482,6 +517,12 @@ export async function executeVfsRead( output: result, } } catch (err) { + // Expected docs-corpus conditions (unknown page, directory path, site + // unreachable): surface the message verbatim. + if (err instanceof DocsCorpusError) { + logger.debug('vfs_read docs page rejected', { path, error: err.message }) + return { success: false, error: err.message } + } logger.error('vfs_read failed', { path, error: toError(err).message, diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts deleted file mode 100644 index 4d0077f5540..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/knowledge/embeddings', () => ({ - generateSearchEmbedding: vi.fn(), -})) - -import { docsScopeTail } from '@/lib/copilot/tools/server/docs/search-docs' - -describe('docsScopeTail', () => { - it('returns undefined for an unscoped search', () => { - expect(docsScopeTail(undefined)).toBeUndefined() - expect(docsScopeTail('')).toBeUndefined() - expect(docsScopeTail(' ')).toBeUndefined() - }) - - it('treats the bare docs/documentation prefix as unscoped', () => { - expect(docsScopeTail('docs/documentation')).toBeUndefined() - expect(docsScopeTail('docs/documentation/')).toBeUndefined() - expect(docsScopeTail('/docs/documentation/')).toBeUndefined() - }) - - it('maps directory scopes to their source_document tail', () => { - expect(docsScopeTail('docs/documentation/workflows')).toBe('workflows') - expect(docsScopeTail('/docs/documentation/workflows/')).toBe('workflows') - expect(docsScopeTail('docs/documentation/integrations/gmail')).toBe('integrations/gmail') - }) - - it('maps file scopes by stripping the mdx extension', () => { - expect(docsScopeTail('docs/documentation/agents/choosing.mdx')).toBe('agents/choosing') - expect(docsScopeTail('docs/documentation/workflows/index.mdx')).toBe('workflows') - }) - - it('rejects paths outside docs/documentation/', () => { - expect(() => docsScopeTail('docs/academy/agents')).toThrow(/must start with/) - expect(() => docsScopeTail('docs/api-reference/workflows.json')).toThrow(/must start with/) - expect(() => docsScopeTail('workflows')).toThrow(/must start with/) - expect(() => docsScopeTail('docs/documentation-extra/foo')).toThrow(/must start with/) - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts index dcc3b6d6b67..cf88c0bfa1b 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -1,10 +1,6 @@ -import { db } from '@sim/db' -import { docsEmbeddings } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, like, notLike, or, sql } from 'drizzle-orm' +import { searchDocs } from '@/lib/copilot/docs/docs-search' import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' interface SearchDocsParams { query: string @@ -12,99 +8,21 @@ interface SearchDocsParams { path?: string } -const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 -const DEFAULT_TOP_K = 10 -const MAX_TOP_K = 25 -const DOCS_DOCUMENTATION_PREFIX = 'docs/documentation' - -/** - * Maps a docs/documentation/... VFS path onto a docs_embeddings source_document - * scope tail. VFS paths mirror docs.sim.ai URLs while source_document stores - * the en-relative mdx path, so a scope tail must cover both layouts a page can - * have on disk: `.mdx` and `/...` (including `/index.mdx`). - * Returns undefined for an unscoped search; throws when the path does not - * address docs/documentation/. - */ -export function docsScopeTail(path?: string): string | undefined { - if (!path || path.trim() === '') return undefined - const normalized = path.trim().replace(/^\.?\//, '') - if ( - normalized !== DOCS_DOCUMENTATION_PREFIX && - !normalized.startsWith(`${DOCS_DOCUMENTATION_PREFIX}/`) - ) { - throw new Error(`path must start with ${DOCS_DOCUMENTATION_PREFIX}/ (got "${path}")`) - } - const tail = normalized - .slice(DOCS_DOCUMENTATION_PREFIX.length) - .replace(/^\/+|\/+$/g, '') - .replace(/\/index\.mdx$/, '') - .replace(/\.mdx$/, '') - return tail === '' ? undefined : tail -} - -function escapeLikePattern(value: string): string { - return value.replace(/[\\%_]/g, (char) => `\\${char}`) +interface SearchDocsOutput { + results: Awaited> + query: string + totalResults: number } /** - * Unscoped searches cover exactly the Documentation tab (everything under the - * docs/documentation/ VFS tree), so Academy and API-reference rows are - * excluded; a scope tail narrows to one page or directory subtree. + * Vector search over Sim's product documentation, scoped to the same pages the + * agent can `read` from the `docs/` VFS tree. Search-agent only; the corpus + * logic lives in `@/lib/copilot/docs/docs-search`. */ -function scopeCondition(tail?: string) { - if (!tail) { - return and( - notLike(docsEmbeddings.sourceDocument, 'academy/%'), - notLike(docsEmbeddings.sourceDocument, 'api-reference/%') - ) - } - return or( - eq(docsEmbeddings.sourceDocument, `${tail}.mdx`), - like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`) - ) -} - -export const searchDocsServerTool: BaseServerTool = { +export const searchDocsServerTool: BaseServerTool = { name: SearchDocs.id, - async execute(params: SearchDocsParams): Promise { - const logger = createLogger('SearchDocsServerTool') - const { query, path } = params - if (!query || typeof query !== 'string') throw new Error('query is required') - const topK = Math.min(Math.max(Math.trunc(params.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) - const scopeTail = docsScopeTail(path) - - logger.info('Executing docs search', { query, topK, path: path ?? null }) - - const { embedding: queryEmbedding } = await generateSearchEmbedding(query) - if (!queryEmbedding || queryEmbedding.length === 0) { - return { results: [], query, totalResults: 0 } - } - - const results = await db - .select({ - chunkId: docsEmbeddings.chunkId, - chunkText: docsEmbeddings.chunkText, - sourceDocument: docsEmbeddings.sourceDocument, - sourceLink: docsEmbeddings.sourceLink, - headerText: docsEmbeddings.headerText, - headerLevel: docsEmbeddings.headerLevel, - similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, - }) - .from(docsEmbeddings) - .where(scopeCondition(scopeTail)) - .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) - .limit(topK) - - const filteredResults = results.filter((r) => r.similarity >= DEFAULT_DOCS_SIMILARITY_THRESHOLD) - const documentationResults = filteredResults.map((r, idx) => ({ - id: idx + 1, - title: String(r.headerText || 'Untitled Section'), - url: String(r.sourceLink || '#'), - content: String(r.chunkText || ''), - similarity: r.similarity, - })) - - logger.info('Docs search complete', { count: documentationResults.length }) - return { results: documentationResults, query, totalResults: documentationResults.length } + async execute(params: SearchDocsParams): Promise { + const results = await searchDocs(params.query, { path: params.path, topK: params.topK }) + return { results, query: params.query, totalResults: results.length } }, } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 948ffa2adcc..027ce68d915 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -78,19 +78,6 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) - it('includes the query in search_docs titles', () => { - expect(getToolDisplayTitle('search_docs')).toBe('Searching docs') - expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( - 'Searching docs for "loop blocks iteration"' - ) - expect( - getToolDisplayTitle('search_docs', { - query: - 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', - })?.length - ).toBeLessThanOrEqual('Searching docs for ""'.length + 60 + '...'.length) - }) - it('falls back to running code for function_execute without a title', () => { expect(getToolDisplayTitle('function_execute')).toBe('Running code') expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 58f5de96f23..91c2a00ebdc 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,4 @@ -import { stripVersionSuffix, truncate } from '@sim/utils/string' +import { stripVersionSuffix } from '@sim/utils/string' /** * Single source of truth for copilot tool-call display titles. @@ -803,10 +803,6 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' } - case 'search_docs': { - const target = firstStringArg(args, 'toolTitle', 'title', 'query') - return target ? `Searching docs for "${truncate(target, 60)}"` : 'Searching docs' - } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching for ${target}` : 'Searching' diff --git a/package.json b/package.json index 81f5d23783b..2826be7b11c 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,8 @@ "metrics-contract:check": "bun run scripts/sync-metrics-contract.ts --check", "vfs-snapshot-contract:generate": "bun run scripts/sync-vfs-snapshot-contract.ts", "vfs-snapshot-contract:check": "bun run scripts/sync-vfs-snapshot-contract.ts --check", + "docs-manifest:generate": "bun run scripts/sync-docs-manifest.ts", + "docs-manifest:check": "bun run scripts/sync-docs-manifest.ts --check", "mship:generate": "bun run scripts/generate-mship-contracts.ts", "mship:check": "bun run scripts/generate-mship-contracts.ts --check", "library:covers": "bun run scripts/generate-library-covers.tsx", diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts new file mode 100644 index 00000000000..2d81f277331 --- /dev/null +++ b/scripts/sync-docs-manifest.ts @@ -0,0 +1,108 @@ +/** + * Generate the static docs manifest the copilot's `docs/` VFS tree is built from. + * + * Source of truth: `apps/docs/content/docs/en/**\/*.mdx` — the English docs + * corpus, whose folder structure mirrors the public docs.sim.ai URL structure. + * The copilot never reads those files from disk (they are not deployed with + * `apps/sim`); it globs this manifest for structure and fetches page content + * from the live site on demand. That makes the manifest the one thing that can + * drift, hence `--check` in CI. + * + * Path derivation (each entry is BOTH the `docs/`-relative VFS path and the + * docs.sim.ai URL path, so a read is a plain fetch of `https://docs.sim.ai/`): + * - `workflows/blocks/agent.mdx` → `workflows/blocks/agent.mdx` + * - `workflows/index.mdx` → `workflows.mdx` (fumadocs folds index pages + * into their parent URL; `/workflows/index.mdx` + * is a 404 on the site) + * + * Excluded, and intentionally absent from the VFS: `academy/` and + * `api-reference/` (fetch those with the scrape tool if ever needed), the root + * `index.mdx` (its URL is `/`, which redirects), and every non-`en` locale. + * + * Usage: + * bun run docs-manifest:generate # write the manifest + * bun run docs-manifest:check # fail (exit 1) if the manifest is stale + */ +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const DOCS_CONTENT_DIR = resolve(ROOT, 'apps/docs/content/docs/en') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/docs-manifest.ts') + +/** Top-level docs sections deliberately left out of the copilot's `docs/` tree. */ +const EXCLUDED_SECTIONS = new Set(['academy', 'api-reference']) + +/** Collect every `.mdx` file under `dir`, as paths relative to {@link DOCS_CONTENT_DIR}. */ +async function collectMdxPaths(dir: string, prefix = ''): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths: string[] = [] + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + if (prefix === '' && EXCLUDED_SECTIONS.has(entry.name)) continue + paths.push(...(await collectMdxPaths(resolve(dir, entry.name), relative))) + continue + } + if (entry.isFile() && entry.name.endsWith('.mdx')) paths.push(relative) + } + return paths +} + +/** Map an `en`-relative mdx file path to its docs.sim.ai URL path, or null to drop it. */ +function toDocsPath(mdxPath: string): string | null { + if (mdxPath === 'index.mdx') return null + return mdxPath.replace(/\/index\.mdx$/, '.mdx') +} + +function render(paths: string[]): string { + const entries = paths.map((path) => ` '${path}',`).join('\n') + return `// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts +// Run: bun run docs-manifest:generate +// + +/** + * Every page in the copilot's read-only \`docs/\` VFS tree, as a path that is + * simultaneously the \`docs/\`-relative VFS path and the docs.sim.ai URL path + * (so \`docs/workflows/blocks/agent.mdx\` reads + * \`https://docs.sim.ai/workflows/blocks/agent.mdx\`). Sorted. + */ +export const DOCS_MANIFEST: readonly string[] = [ +${entries} +] +` +} + +async function main() { + const checkOnly = process.argv.includes('--check') + + const mdxPaths = await collectMdxPaths(DOCS_CONTENT_DIR) + const docsPaths = mdxPaths + .map(toDocsPath) + .filter((path): path is string => path !== null) + .sort() + + if (docsPaths.length === 0) { + throw new Error(`No docs pages found under ${DOCS_CONTENT_DIR}`) + } + + const rendered = formatGeneratedSource(render(docsPaths), OUTPUT_PATH, ROOT) + + if (checkOnly) { + const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) + if (existing !== rendered) { + throw new Error( + 'Generated docs manifest is stale — the docs tree changed (page added, removed, or renamed). Run: bun run docs-manifest:generate' + ) + } + return + } + + await writeFile(OUTPUT_PATH, rendered, 'utf8') +} + +await main() From c6b8eecc807da8ede4ebef84cbc66e7df8b84f91 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:13:29 -0700 Subject: [PATCH 012/103] fix(review): act on docs-vfs review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent review of the docs/ VFS change. Applied the behavior-preserving fixes plus two agent-facing bugs that made real pages unreadable. - docs read no longer hard-fails on oversized pages. Six+ live integration references exceed the inline cap (github.mdx is 354KB, sportmonks 513KB), so a plain read of them ALWAYS failed and cost a second fetch to recover. Truncate to the largest whole-line prefix that fits, keep the true totalLines, and tell the model how to page. An explicit offset/limit that still overflows is still an error — that one is a caller mistake. - classify docs fetch failures. Everything collapsed to null, so a permanent 404 was reported to the agent as "temporarily unavailable, retry shortly", inviting a retry loop on a page that will never exist. 4xx (except 429) is now permanent and says so; 5xx/429/network/timeout keep the retry wording. - register search_documentation as a transitional alias for search_docs. sim and mothership deploy independently and the rename deleted the old id on both sides, so BOTH deploy orders broke docs lookup for the window between them. Old params are a subset of the new. Remove once both ship. - extract the index-page fold (X/index.mdx <-> X.mdx) into docs-path.ts. It was re-derived in three places — the manifest generator, the source_document reverse mapping, and the search scope filter — which is the hand-synced-duplicate shape that has drifted in this repo before. - grepDocsPage now goes through grepReadResult, the primitive files/ and uploads/ grep already use, instead of calling grep directly. - couldMatchDocsScope delegates to isDocsPath; the bodies were identical. - drop the dead 'docs' member from AgentContextType. Tests: 404-vs-5xx-vs-429 classification, network failure, and a docs-path round-trip asserting one source candidate reproduces every manifest entry. Verified: tsc clean, 932 copilot tests, biome clean, docs-manifest:check, check:utils and check:api-validation:strict both pass. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/chat/process-contents.ts | 1 - apps/sim/lib/copilot/docs/docs-corpus.test.ts | 21 ++++++++- apps/sim/lib/copilot/docs/docs-corpus.ts | 43 +++++++++++------ apps/sim/lib/copilot/docs/docs-path.test.ts | 35 ++++++++++++++ apps/sim/lib/copilot/docs/docs-path.ts | 36 ++++++++++++++ apps/sim/lib/copilot/docs/docs-search.ts | 7 +-- apps/sim/lib/copilot/tools/handlers/vfs.ts | 47 +++++++++++++++++-- apps/sim/lib/copilot/tools/server/router.ts | 5 ++ scripts/sync-docs-manifest.ts | 3 +- 9 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 apps/sim/lib/copilot/docs/docs-path.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-path.ts diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 54b44e7afff..c5a49d8a787 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -62,7 +62,6 @@ type AgentContextType = | 'file' | 'file_selection' | 'workflow_block' - | 'docs' | 'folder' | 'filefolder' | 'active_resource' diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts index 0142ee4f169..31117855a08 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -93,10 +93,29 @@ describe('readDocsPage', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('surfaces a docs-site failure as a retryable error', async () => { + it('surfaces a docs-site outage as a retryable error', async () => { fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' }) await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) }) + + it('treats a network failure as retryable', async () => { + fetchMock.mockRejectedValue(new Error('socket hang up')) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + }) + + it('reports a page the site no longer serves as permanent, not retryable', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404, text: async () => '' }) + const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e) + expect(error).toBeInstanceOf(DocsCorpusError) + expect(error.message).toMatch(/does not serve it/) + expect(error.message).toMatch(/retrying will not help/) + expect(error.message).not.toMatch(/temporarily unavailable/) + }) + + it('still treats 429 as retryable rather than permanent', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 429, text: async () => '' }) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + }) }) describe('grepDocsPage', () => { diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index 5a5c3f1d7ee..16a202a4f84 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -1,8 +1,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' -import { glob as globPaths, grep as grepFiles } from '@/lib/copilot/vfs/operations' +import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations' const logger = createLogger('DocsCorpus') @@ -56,12 +57,11 @@ export function isDocsPath(path: string | undefined): boolean { * True when a glob `pattern` could match the docs corpus. Like `uploads/` and * `recently-deleted/`, the corpus is opt-in: only a pattern that explicitly * starts with `docs/` (or is exactly `docs`) sees it, so a broad `**` glob never - * drags 300+ doc pages into the result. + * drags 300+ doc pages into the result. Same rule as {@link isDocsPath}; the + * separate name reads correctly at the glob call site. */ export function couldMatchDocsScope(pattern: string | undefined): boolean { - if (!pattern) return false - const normalized = normalize(pattern) - return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX) + return isDocsPath(pattern) } /** Manifest paths (and their virtual directories) matching an explicit `docs/` pattern. */ @@ -82,7 +82,7 @@ export function isDocsPage(path: string): boolean { */ export function docsPathForSourceDocument(sourceDocument: string | null): string | null { if (!sourceDocument) return null - const path = `${DOCS_PREFIX}${sourceDocument.replace(/^\/+/, '').replace(/\/index\.mdx$/, '.mdx')}` + const path = `${DOCS_PREFIX}${foldDocsIndexPath(sourceDocument.replace(/^\/+/, ''))}` return docsKeyView.has(path) ? path : null } @@ -108,9 +108,16 @@ export interface DocsPage { * to its raw-markdown route), so no mapping table is needed. Returns null when * the page is not in the manifest or the site does not serve it. */ -async function fetchDocsPage(path: string): Promise { +type DocsFetchResult = + | { outcome: 'ok'; content: string } + /** The site will not serve this path however many times we ask. */ + | { outcome: 'missing' } + /** Transient: 5xx, 429, network error, or timeout. */ + | { outcome: 'unavailable' } + +async function fetchDocsPage(path: string): Promise { const key = normalize(path) - if (!docsKeyView.has(key)) return null + if (!docsKeyView.has(key)) return { outcome: 'missing' } const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` try { const response = await fetch(url, { @@ -119,12 +126,13 @@ async function fetchDocsPage(path: string): Promise { }) if (!response.ok) { logger.warn('Docs page fetch returned a non-OK status', { url, status: response.status }) - return null + const permanent = response.status >= 400 && response.status < 500 && response.status !== 429 + return { outcome: permanent ? 'missing' : 'unavailable' } } - return await response.text() + return { outcome: 'ok', content: await response.text() } } catch (err) { logger.warn('Docs page fetch failed', { url, error: toError(err).message }) - return null + return { outcome: 'unavailable' } } } @@ -144,13 +152,18 @@ export async function readDocsPage(path: string): Promise { `Docs page not found: ${path}. Use glob("docs/**") to list the docs corpus.` ) } - const content = await fetchDocsPage(key) - if (content === null) { + const result = await fetchDocsPage(key) + if (result.outcome === 'missing') { + throw new DocsCorpusError( + `${key} is in the docs index but ${DOCS_BASE_URL} does not serve it — the page was likely moved or removed. Use glob("docs/**") to find the current path; retrying will not help.` + ) + } + if (result.outcome === 'unavailable') { throw new DocsCorpusError( `Could not load ${key} from ${DOCS_BASE_URL} — the docs site is temporarily unavailable. Retry shortly.` ) } - return { content, totalLines: content.split('\n').length } + return { content: result.content, totalLines: result.content.split('\n').length } } /** @@ -170,5 +183,5 @@ export async function grepDocsPage( ) } const page = await readDocsPage(key) - return grepFiles(new Map([[key, page.content]]), pattern, undefined, options) + return grepReadResult(key, page, pattern, key, options) } diff --git a/apps/sim/lib/copilot/docs/docs-path.test.ts b/apps/sim/lib/copilot/docs/docs-path.test.ts new file mode 100644 index 00000000000..c40ba0a7abb --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-path.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { docsSourceCandidates, foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' + +describe('foldDocsIndexPath', () => { + it('folds a section overview onto the section path', () => { + expect(foldDocsIndexPath('workflows/index.mdx')).toBe('workflows.mdx') + expect(foldDocsIndexPath('platform/enterprise/index.mdx')).toBe('platform/enterprise.mdx') + }) + + it('leaves a plain page untouched', () => { + expect(foldDocsIndexPath('workflows/blocks/agent.mdx')).toBe('workflows/blocks/agent.mdx') + expect(foldDocsIndexPath('agents.mdx')).toBe('agents.mdx') + }) + + it('does not fold a page merely named index', () => { + expect(foldDocsIndexPath('index.mdx')).toBe('index.mdx') + }) +}) + +describe('docsSourceCandidates', () => { + it('is the inverse of the fold — one candidate always reproduces the input', () => { + for (const publicPath of DOCS_MANIFEST) { + const candidates = docsSourceCandidates(publicPath) + expect(candidates.map(foldDocsIndexPath)).toContain(publicPath) + } + }) + + it('offers both on-disk layouts for a section path', () => { + expect(docsSourceCandidates('workflows.mdx')).toEqual(['workflows.mdx', 'workflows/index.mdx']) + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-path.ts b/apps/sim/lib/copilot/docs/docs-path.ts new file mode 100644 index 00000000000..650fd2977ac --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-path.ts @@ -0,0 +1,36 @@ +/** + * The single definition of how a docs source file maps onto its public path. + * + * Fumadocs folds a section's `index.mdx` into the section URL itself, so + * `workflows/index.mdx` on disk is `/workflows` on the site (and + * `/workflows/index.mdx` is a 404). Three places need that rule — the manifest + * generator, the `source_document` -> VFS reverse mapping, and the vector + * search's scope filter — and hand-syncing it has bitten this repo before, so + * it lives here. + * + * Deliberately dependency-free: `scripts/sync-docs-manifest.ts` imports this by + * relative path, and it must not pull in the manifest it generates. + */ + +/** Suffix that marks a section overview page on disk. */ +export const DOCS_INDEX_SUFFIX = '/index.mdx' + +/** + * Fold an `en`-relative mdx file path onto its public path — the value used as + * both the `docs/`-relative VFS path and the docs.sim.ai URL path. + */ +export function foldDocsIndexPath(mdxPath: string): string { + return mdxPath.endsWith(DOCS_INDEX_SUFFIX) + ? `${mdxPath.slice(0, -DOCS_INDEX_SUFFIX.length)}.mdx` + : mdxPath +} + +/** + * The inverse of {@link foldDocsIndexPath}: the on-disk file names a public + * path could have come from. A page is stored either as `.mdx` or, when + * it is a section overview, as `/index.mdx`. + */ +export function docsSourceCandidates(publicPath: string): [string, string] { + const stem = publicPath.replace(/\.mdx$/, '') + return [`${stem}.mdx`, `${stem}${DOCS_INDEX_SUFFIX}`] +} diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 0f5553a4858..37679cc867b 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -3,6 +3,7 @@ import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, notLike, or, sql } from 'drizzle-orm' import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' +import { docsSourceCandidates } from '@/lib/copilot/docs/docs-path' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' const logger = createLogger('DocsSearch') @@ -65,10 +66,10 @@ function scopeCondition(path?: string) { if (isDocsPage(normalized)) { // One page: on disk it is either `.mdx` or `/index.mdx`. - const stem = tail.replace(/\.mdx$/, '') + const [pageFile, indexFile] = docsSourceCandidates(tail) return or( - eq(docsEmbeddings.sourceDocument, `${stem}.mdx`), - eq(docsEmbeddings.sourceDocument, `${stem}/index.mdx`) + eq(docsEmbeddings.sourceDocument, pageFile), + eq(docsEmbeddings.sourceDocument, indexFile) ) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index e2c7f13c3aa..e364cefd0cc 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -134,6 +134,33 @@ async function canReturnWorkspaceFileValue( return true } +/** + * Trim an oversized docs page to the largest whole-line prefix that fits the + * inline budget, preserving the true `totalLines` so the model can page through + * the rest with offset/limit. + */ +function truncateDocsPageToInlineCap(page: { content: string; totalLines: number }): { + output: { content: string; totalLines: number } + returnedLines: number +} { + const lines = page.content.split('\n') + const notice = (shown: number) => + `\n\n[Page truncated: showing lines 1-${shown} of ${page.totalLines}. Grep this path for the section you need, then read with offset/limit.]` + + let kept = lines.length + let content = page.content + while (kept > 0) { + content = `${lines.slice(0, kept).join('\n')}${notice(kept)}` + if ( + serializedResultSize({ content, totalLines: page.totalLines }) <= TOOL_RESULT_MAX_INLINE_CHARS + ) { + break + } + kept = Math.floor(kept / 2) + } + return { output: { content, totalLines: page.totalLines }, returnedLines: kept } +} + export async function executeVfsGrep( params: Record, context: ExecutionContext @@ -349,10 +376,24 @@ export async function executeVfsRead( const page = await readDocsPage(path) const windowed = applyWindow(page) if (serializedResultSize(windowed) > TOOL_RESULT_MAX_INLINE_CHARS) { - return { - success: false, - error: `${path} is too large to return inline. Grep that one page for the relevant section, then retry read with offset/limit.`, + // Several real docs pages (the largest integration references) exceed the + // inline cap, so failing here would make a plain read of them always fail + // and cost a second fetch to recover. Truncate to what fits instead and + // tell the model how to page — but only when it did not ask for a window, + // since an explicit offset/limit that still overflows is a caller error. + if (offset !== undefined || limit !== undefined) { + return { + success: false, + error: `${path} is still too large over the requested window. Narrow offset/limit, or grep this page for the section you need.`, + } } + const truncated = truncateDocsPageToInlineCap(page) + logger.debug('vfs_read truncated oversized docs page', { + path, + totalLines: page.totalLines, + returnedLines: truncated.returnedLines, + }) + return { success: true, output: truncated.output } } logger.debug('vfs_read resolved docs page', { path, totalLines: page.totalLines }) return { success: true, output: windowed } diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index ab6a882b30e..1410c37941f 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -169,6 +169,11 @@ const baseServerToolRegistry: Record = { [editWorkflowServerTool.name]: editWorkflowServerTool, [queryLogsServerTool.name]: queryLogsServerTool, [searchDocsServerTool.name]: searchDocsServerTool, + // Transitional alias: sim and mothership deploy independently, so during the + // rollout of the search_documentation -> search_docs rename one side is still + // emitting the old id. The old params are a subset of the new, so routing them + // here is safe. Remove once both repos have shipped the rename. + search_documentation: searchDocsServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts index 2d81f277331..fa1f0ddcd81 100644 --- a/scripts/sync-docs-manifest.ts +++ b/scripts/sync-docs-manifest.ts @@ -26,6 +26,7 @@ import { readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { foldDocsIndexPath } from '../apps/sim/lib/copilot/docs/docs-path' import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) @@ -55,7 +56,7 @@ async function collectMdxPaths(dir: string, prefix = ''): Promise { /** Map an `en`-relative mdx file path to its docs.sim.ai URL path, or null to drop it. */ function toDocsPath(mdxPath: string): string | null { if (mdxPath === 'index.mdx') return null - return mdxPath.replace(/\/index\.mdx$/, '.mdx') + return foldDocsIndexPath(mdxPath) } function render(paths: string[]): string { From da102c7e5157b6e792666c3ba33c0fe54cd25fbc Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:13:40 -0700 Subject: [PATCH 013/103] fix(copilot): explain a short or empty search_docs result set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQL LIMIT is applied before the similarity-threshold and liveness filters, so search_docs can return fewer hits than topK — or none, when every candidate was filtered. An empty array is indistinguishable from "the documentation does not cover this", which sends the agent off to guess instead of rephrasing or falling back to glob. searchDocs now returns the drop counts alongside the results, and the tool attaches a note when anything was dropped: how many candidates the index returned, why they went, and what to try next. Silent on the common path. This does not change which rows are returned or how many — the ordering issue behind the shortfall is a pre-existing bug the deleted search-documentation.ts had too, and pushing the threshold into SQL is its own change. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/docs/docs-search.test.ts | 55 ++++++++++++++++++- apps/sim/lib/copilot/docs/docs-search.ts | 48 ++++++++++++++-- .../copilot/tools/server/docs/search-docs.ts | 43 ++++++++++++++- 3 files changed, 134 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index 8407f2e336f..c0981a22a82 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -124,7 +124,7 @@ describe('searchDocs results', () => { similarity: 0.8, }, ] - const results = await searchDocs('cron') + const { results } = await searchDocs('cron') expect(results).toEqual([ { path: 'docs/workflows.mdx', @@ -153,7 +153,7 @@ describe('searchDocs results', () => { similarity: 0.9, }, ] - expect(await searchDocs('cron')).toEqual([]) + expect((await searchDocs('cron')).results).toEqual([]) }) it('drops chunks below the similarity threshold', async () => { @@ -166,6 +166,55 @@ describe('searchDocs results', () => { similarity: 0.1, }, ] - expect(await searchDocs('cron')).toEqual([]) + expect((await searchDocs('cron')).results).toEqual([]) + }) +}) + +describe('searchDocs shortfall reporting', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('counts why candidates were dropped so an empty set is explainable', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.1, + }, + { + chunkText: 'b', + sourceDocument: 'deleted-page.mdx', + sourceLink: 'y', + headerText: 'h', + similarity: 0.9, + }, + ] + const outcome = await searchDocs('cron') + expect(outcome).toEqual({ + results: [], + candidatesConsidered: 2, + droppedBelowThreshold: 1, + droppedStale: 1, + }) + }) + + it('reports no drops when every candidate survives', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.9, + }, + ] + const outcome = await searchDocs('cron') + expect(outcome.droppedBelowThreshold).toBe(0) + expect(outcome.droppedStale).toBe(0) + expect(outcome.results).toHaveLength(1) }) }) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 37679cc867b..e30145d3a3e 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -27,6 +27,21 @@ export interface DocsSearchResult { * section in the docs corpus. Surfaced verbatim so the model can correct itself * rather than reading an empty result as "the docs say nothing about this". */ +/** + * A search result set plus why it may be shorter than `topK`. The SQL LIMIT is + * applied before the threshold and liveness filters, so these counts are what + * distinguishes "nothing matched" from "matches were filtered out". + */ +export interface DocsSearchOutcome { + results: DocsSearchResult[] + /** Rows the vector search returned before filtering. */ + candidatesConsidered: number + /** Candidates dropped for scoring below the similarity threshold. */ + droppedBelowThreshold: number + /** Candidates dropped because their page is no longer in the docs manifest. */ + droppedStale: number +} + export class DocsSearchScopeError extends Error { readonly code = 'DOCS_SEARCH_SCOPE' as const constructor(message: string) { @@ -94,11 +109,16 @@ function escapeLikePattern(value: string): string { * The index lags the VFS: a page added since the last index rebuild is readable * but not searchable, and a deleted one can still return chunks. Results whose * source no longer maps to a live `docs/` path are dropped. + * + * Because those drops happen after the SQL LIMIT, a caller can get fewer hits + * than it asked for — or none at all when every candidate was filtered. The + * returned {@link DocsSearchOutcome} reports that explicitly so an empty result + * is never mistaken for "the documentation does not cover this". */ export async function searchDocs( query: string, options?: { path?: string; topK?: number } -): Promise { +): Promise { if (!query || typeof query !== 'string') throw new Error('query is required') const topK = Math.min(Math.max(Math.trunc(options?.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) @@ -107,7 +127,9 @@ export async function searchDocs( logger.info('Executing docs search', { query, topK, path: options?.path ?? null }) const { embedding: queryEmbedding } = await generateSearchEmbedding(query) - if (!queryEmbedding || queryEmbedding.length === 0) return [] + if (!queryEmbedding || queryEmbedding.length === 0) { + return { results: [], candidatesConsidered: 0, droppedBelowThreshold: 0, droppedStale: 0 } + } const rows = await db .select({ @@ -123,10 +145,18 @@ export async function searchDocs( .limit(topK) const results: DocsSearchResult[] = [] + let droppedBelowThreshold = 0 + let droppedStale = 0 for (const row of rows) { - if (row.similarity < SIMILARITY_THRESHOLD) continue + if (row.similarity < SIMILARITY_THRESHOLD) { + droppedBelowThreshold++ + continue + } const path = docsPathForSourceDocument(row.sourceDocument) - if (!path) continue + if (!path) { + droppedStale++ + continue + } results.push({ path, url: String(row.sourceLink || '#'), @@ -138,7 +168,13 @@ export async function searchDocs( logger.info('Docs search complete', { count: results.length, - dropped: rows.length - results.length, + droppedBelowThreshold, + droppedStale, }) - return results + return { + results, + candidatesConsidered: rows.length, + droppedBelowThreshold, + droppedStale, + } } diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts index cf88c0bfa1b..96bdc922e2c 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -1,3 +1,4 @@ +import type { DocsSearchResult } from '@/lib/copilot/docs/docs-search' import { searchDocs } from '@/lib/copilot/docs/docs-search' import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' @@ -9,9 +10,39 @@ interface SearchDocsParams { } interface SearchDocsOutput { - results: Awaited> + results: DocsSearchResult[] query: string totalResults: number + /** + * Present only when the vector search matched chunks that were then filtered + * out. Without it an empty result set reads as "the docs do not cover this", + * which sends the caller off to guess instead of rephrasing or falling back + * to glob. + */ + note?: string +} + +/** + * Explain a short or empty result set in terms the caller can act on. Returns + * undefined when nothing was dropped — the common case needs no commentary. + */ +function shortfallNote(outcome: Awaited>): string | undefined { + const { results, candidatesConsidered, droppedBelowThreshold, droppedStale } = outcome + if (droppedBelowThreshold === 0 && droppedStale === 0) return undefined + + const reasons: string[] = [] + if (droppedBelowThreshold > 0) + reasons.push(`${droppedBelowThreshold} scored too low to be relevant`) + if (droppedStale > 0) { + reasons.push( + `${droppedStale} point at pages no longer in the docs (the search index lags the site)` + ) + } + const dropped = reasons.join(' and ') + + return results.length === 0 + ? `No relevant matches. The search index returned ${candidatesConsidered} candidate(s), but ${dropped} — this does NOT mean the docs lack this topic. Rephrase the query, widen it by dropping the path scope, or browse with glob("docs/**").` + : `Returned ${results.length} of ${candidatesConsidered} candidate(s); ${dropped}. Rephrase or widen the query if these look off-topic.` } /** @@ -22,7 +53,13 @@ interface SearchDocsOutput { export const searchDocsServerTool: BaseServerTool = { name: SearchDocs.id, async execute(params: SearchDocsParams): Promise { - const results = await searchDocs(params.query, { path: params.path, topK: params.topK }) - return { results, query: params.query, totalResults: results.length } + const outcome = await searchDocs(params.query, { path: params.path, topK: params.topK }) + const note = shortfallNote(outcome) + return { + results: outcome.results, + query: params.query, + totalResults: outcome.results.length, + ...(note ? { note } : {}), + } }, } From 32e07e52c6408b77e0ebbe30cdaa340ba62e47cd Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:02:29 -0700 Subject: [PATCH 014/103] fix(copilot): include a section overview in either layout when scoping search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A directory scope matched only `
/%`, which covers an overview stored as `
/index.mdx` but not one stored as a sibling `
.mdx`. Fumadocs accepts both layouts and page scope already handles both via docsSourceCandidates, so a scoped section search could silently omit the overview chunks — and the doc comment claimed it did not. Every section in the tree currently uses the index.mdx layout, so nothing is broken today; this closes the gap before someone adds a sibling overview and gets quietly incomplete results. --- apps/sim/lib/copilot/docs/docs-search.test.ts | 9 +++++++++ apps/sim/lib/copilot/docs/docs-search.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index c0981a22a82..5c1a288487e 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -89,6 +89,15 @@ describe('searchDocs path scoping', () => { expect(whereText()).toContain('workflows/%') }) + it('includes a section overview stored in either on-disk layout', async () => { + await searchDocs('cron', { path: 'docs/workflows' }) + const text = whereText() + // `workflows/index.mdx` is inside the subtree; a sibling `workflows.mdx` is not, + // and fumadocs accepts either, so the scope must name it explicitly. + expect(text).toContain('workflows/%') + expect(text).toContain('workflows.mdx') + }) + it('rejects a path outside the docs corpus', async () => { await expect(searchDocs('cron', { path: 'files/report.pdf' })).rejects.toThrow( DocsSearchScopeError diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index e30145d3a3e..9c545b40d60 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -56,7 +56,7 @@ export class DocsSearchScopeError extends Error { * `source_document` stores the en-relative mdx file path, while VFS paths mirror * the public URL — so a section overview is `docs/workflows.mdx` in the VFS but * `workflows/index.mdx` (or `workflows.mdx`) on disk. A directory scope covers - * the whole subtree, including that overview page. + * the whole subtree plus the overview in either layout. * * Returns undefined for an unscoped search, which excludes `academy/` and * `api-reference/`: both are indexed but neither is mounted in the VFS, so a hit @@ -89,7 +89,14 @@ function scopeCondition(path?: string) { } if (isDocsDir(normalized)) { - return like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`) + // Everything under the directory, PLUS a sibling `.mdx`. Fumadocs + // accepts either layout for a section overview and only `/index.mdx` + // is inside the subtree, so matching the prefix alone would silently omit + // the overview for the sibling layout — page scope already covers both. + return or( + like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`), + eq(docsEmbeddings.sourceDocument, `${tail}.mdx`) + ) } throw new DocsSearchScopeError( From 7fdce7134818122407aa4729c17263bd327c96aa Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:27:39 -0700 Subject: [PATCH 015/103] fix(copilot): make the search_docs topK clamp type-safe and test it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clamp guarded magnitude but not type: Math.min/Math.max propagate NaN, so a non-numeric topK reached the query as `.limit(NaN)`. The `?? DEFAULT` only caught undefined. Nothing enforced this but the generated Ajv schema, and searchDocs is also called directly, so it should not depend on that. Extract clampTopK, which falls back to the default for anything non-finite (NaN, Infinity, a string that slipped through) and clamps the rest to [1, 25]. The clamp was completely untested because the db mock's .limit() stub discarded its argument — the mock now records it. Covers default, cap, floor, truncation, and the non-finite fallback. Worth pinning: staging's search_documentation documented "max 10" and enforced nothing, so this bound is new behavior, not just a bigger number. --- apps/sim/lib/copilot/docs/docs-search.test.ts | 50 ++++++++++++++++++- apps/sim/lib/copilot/docs/docs-search.ts | 15 +++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index 5c1a288487e..a78672d52d6 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -3,9 +3,10 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGenerateSearchEmbedding, capturedWhere, mockRows } = vi.hoisted(() => ({ +const { mockGenerateSearchEmbedding, capturedWhere, capturedLimit, mockRows } = vi.hoisted(() => ({ mockGenerateSearchEmbedding: vi.fn(), capturedWhere: { value: undefined as unknown }, + capturedLimit: { value: undefined as number | undefined }, mockRows: { value: [] as unknown[] }, })) @@ -38,7 +39,12 @@ vi.mock('@sim/db', () => ({ where: (condition: unknown) => { capturedWhere.value = condition return { - orderBy: () => ({ limit: async () => mockRows.value }), + orderBy: () => ({ + limit: async (n: number) => { + capturedLimit.value = n + return mockRows.value + }, + }), } }, }), @@ -227,3 +233,43 @@ describe('searchDocs shortfall reporting', () => { expect(outcome.results).toHaveLength(1) }) }) + +describe('searchDocs topK clamping', () => { + beforeEach(() => { + capturedLimit.value = undefined + mockRows.value = [] + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('defaults to 10 when unspecified', async () => { + await searchDocs('cron') + expect(capturedLimit.value).toBe(10) + }) + + it('caps at 25 — the documented max, which the old tool never enforced', async () => { + await searchDocs('cron', { topK: 500 }) + expect(capturedLimit.value).toBe(25) + }) + + it('floors at 1', async () => { + await searchDocs('cron', { topK: 0 }) + expect(capturedLimit.value).toBe(1) + await searchDocs('cron', { topK: -8 }) + expect(capturedLimit.value).toBe(1) + }) + + it('truncates a fractional count', async () => { + await searchDocs('cron', { topK: 7.9 }) + expect(capturedLimit.value).toBe(7) + }) + + it('falls back to the default rather than passing NaN to the query', async () => { + // Math.min/Math.max propagate NaN, so a bare clamp would reach `.limit(NaN)`. + await searchDocs('cron', { topK: Number.NaN }) + expect(capturedLimit.value).toBe(10) + await searchDocs('cron', { topK: 'twelve' as unknown as number }) + expect(capturedLimit.value).toBe(10) + await searchDocs('cron', { topK: Number.POSITIVE_INFINITY }) + expect(capturedLimit.value).toBe(10) + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 9c545b40d60..2b77e0f0f67 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -108,6 +108,19 @@ function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, (char) => `\\${char}`) } +/** + * Clamp a caller-supplied result count into [1, {@link MAX_TOP_K}]. + * + * Guards magnitude AND type: `Math.min`/`Math.max` propagate NaN, so a + * non-numeric value would otherwise reach the query as `.limit(NaN)`. The + * generated tool schema rejects a non-number upstream today, but this function + * is also called directly, so it does not rely on that. + */ +function clampTopK(requested: number | undefined): number { + if (requested === undefined || !Number.isFinite(requested)) return DEFAULT_TOP_K + return Math.min(Math.max(Math.trunc(requested), 1), MAX_TOP_K) +} + /** * Semantic search over the indexed docs corpus (`docs_embeddings`, rebuilt by * `scripts/process-docs.ts` on release). Every result carries the `docs/` path @@ -128,7 +141,7 @@ export async function searchDocs( ): Promise { if (!query || typeof query !== 'string') throw new Error('query is required') - const topK = Math.min(Math.max(Math.trunc(options?.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) + const topK = clampTopK(options?.topK) const where = scopeCondition(options?.path) logger.info('Executing docs search', { query, topK, path: options?.path ?? null }) From b175d0a172118951570665ddc44a7dd00f0a4406 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:08:16 -0700 Subject: [PATCH 016/103] fix(copilot): restore the query in search_docs tool chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chips read "Searched docs" with no indication of what was searched. The query-aware title existed earlier on this branch and this commit's own predecessor dropped it: removing search_docs from the catalog deleted the display case and its test, and putting the tool back only restored the static map entry. The generic "every visible catalog tool has a title" assertion still passed, because it checks that a title exists, not that it is the useful one. Chips now read: Searching docs for "how to read workflow logs and view executions" -> Searched docs for "...". The gerund flip already preserves the suffix, so the completed state needs no extra handling — the test now pins that too, since it was the part most likely to regress silently. --- .../lib/copilot/tools/tool-display.test.ts | 20 +++++++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 027ce68d915..24b7be3681f 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -78,6 +78,26 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) + it('includes the query in search_docs titles', () => { + expect(getToolDisplayTitle('search_docs')).toBe('Searching docs') + expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( + 'Searching docs for "loop blocks iteration"' + ) + // The completed-state flip must keep the suffix, not drop back to the bare label. + expect( + getToolCompletedTitle( + getToolDisplayTitle('search_docs', { query: 'how to read workflow logs' }) + ) + ).toBe('Searched docs for "how to read workflow logs"') + // A long agent-written query is truncated rather than blowing out the chip. + expect( + getToolDisplayTitle('search_docs', { + query: + 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', + })?.length + ).toBeLessThanOrEqual('Searching docs for ""'.length + 60 + '...'.length) + }) + it('falls back to running code for function_execute without a title', () => { expect(getToolDisplayTitle('function_execute')).toBe('Running code') expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 91c2a00ebdc..58f5de96f23 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,4 @@ -import { stripVersionSuffix } from '@sim/utils/string' +import { stripVersionSuffix, truncate } from '@sim/utils/string' /** * Single source of truth for copilot tool-call display titles. @@ -803,6 +803,10 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' } + case 'search_docs': { + const target = firstStringArg(args, 'toolTitle', 'title', 'query') + return target ? `Searching docs for "${truncate(target, 60)}"` : 'Searching docs' + } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching for ${target}` : 'Searching' From bb65387bdbb78d5573814a3b08bc604549d311c4 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:33:39 -0700 Subject: [PATCH 017/103] improvement(copilot): share the unmounted-docs list, shrink the search default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places decide what the docs/ corpus is: the manifest generator (what is readable) and the vector search's unscoped filter (what is findable). They each carried their own copy of the excluded-section list. If they drift, a hit in a section that is indexed but not mounted comes back as a chunk the agent cannot then read — dropped as stale, silently shrinking the result set. UNMOUNTED_DOCS_SECTIONS is now the one list both import. search_docs returns 5 chunks by default instead of 10; raise topK when a pass genuinely comes back thin. A truncated docs page now routes to one more fetch instead of two. grep and read cost the same single uncached fetch of the page, so grep is an alternative to a read here, never a step after one. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/docs/docs-path.ts | 22 +++++++++++++++++++ apps/sim/lib/copilot/docs/docs-search.test.ts | 10 ++++----- apps/sim/lib/copilot/docs/docs-search.ts | 15 +++++++------ apps/sim/lib/copilot/tools/handlers/vfs.ts | 6 ++++- scripts/sync-docs-manifest.ts | 17 +++++++++----- 5 files changed, 51 insertions(+), 19 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-path.ts b/apps/sim/lib/copilot/docs/docs-path.ts index 650fd2977ac..b4e5ff66bc1 100644 --- a/apps/sim/lib/copilot/docs/docs-path.ts +++ b/apps/sim/lib/copilot/docs/docs-path.ts @@ -15,6 +15,28 @@ /** Suffix that marks a section overview page on disk. */ export const DOCS_INDEX_SUFFIX = '/index.mdx' +/** + * Top-level docs sections deliberately left out of the copilot's `docs/` tree. + * + * Two places must agree on this list or the corpus goes subtly wrong: the + * manifest generator (which decides what is readable) and the vector search's + * unscoped filter (which decides what is findable). If search still matched an + * unmounted section, every hit there would be a chunk the agent cannot then + * `read` — dropped as stale, silently shrinking the result set. + * + * Mounting a section later is not uniform work, so plan per section: + * - `academy` is plain mdx under `apps/docs/content/docs/en/academy` and is + * already indexed in `docs_embeddings` — removing it here and regenerating + * the manifest is the whole change. + * - `api-reference` is mostly generated from `apps/docs/openapi.json` at build + * time, so its pages have no source mdx for the generator to walk (only the + * four handwritten ones: authentication, getting-started, python, typescript). + * Mounting it properly needs the spec served publicly again — the + * `apps/docs/app/openapi.json` route existed for exactly this and was + * reverted — plus a generator branch that walks the spec's tags. + */ +export const UNMOUNTED_DOCS_SECTIONS = ['academy', 'api-reference'] as const + /** * Fold an `en`-relative mdx file path onto its public path — the value used as * both the `docs/`-relative VFS path and the docs.sim.ai URL path. diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index a78672d52d6..5b2bf75f23d 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -241,9 +241,9 @@ describe('searchDocs topK clamping', () => { mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) }) - it('defaults to 10 when unspecified', async () => { + it('defaults to 5 when unspecified', async () => { await searchDocs('cron') - expect(capturedLimit.value).toBe(10) + expect(capturedLimit.value).toBe(5) }) it('caps at 25 — the documented max, which the old tool never enforced', async () => { @@ -266,10 +266,10 @@ describe('searchDocs topK clamping', () => { it('falls back to the default rather than passing NaN to the query', async () => { // Math.min/Math.max propagate NaN, so a bare clamp would reach `.limit(NaN)`. await searchDocs('cron', { topK: Number.NaN }) - expect(capturedLimit.value).toBe(10) + expect(capturedLimit.value).toBe(5) await searchDocs('cron', { topK: 'twelve' as unknown as number }) - expect(capturedLimit.value).toBe(10) + expect(capturedLimit.value).toBe(5) await searchDocs('cron', { topK: Number.POSITIVE_INFINITY }) - expect(capturedLimit.value).toBe(10) + expect(capturedLimit.value).toBe(5) }) }) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 2b77e0f0f67..93e5b2f208f 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -3,13 +3,13 @@ import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, notLike, or, sql } from 'drizzle-orm' import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' -import { docsSourceCandidates } from '@/lib/copilot/docs/docs-path' +import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' const logger = createLogger('DocsSearch') const SIMILARITY_THRESHOLD = 0.3 -const DEFAULT_TOP_K = 10 +const DEFAULT_TOP_K = 5 const MAX_TOP_K = 25 export interface DocsSearchResult { @@ -58,16 +58,17 @@ export class DocsSearchScopeError extends Error { * `workflows/index.mdx` (or `workflows.mdx`) on disk. A directory scope covers * the whole subtree plus the overview in either layout. * - * Returns undefined for an unscoped search, which excludes `academy/` and - * `api-reference/`: both are indexed but neither is mounted in the VFS, so a hit - * there would be a chunk the agent cannot then read. + * An unscoped search excludes every {@link UNMOUNTED_DOCS_SECTIONS} section: + * they are indexed but not mounted in the VFS, so a hit there would be a chunk + * the agent cannot then read. */ function scopeCondition(path?: string) { const normalized = (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '') if (normalized === '' || normalized === 'docs') { return and( - notLike(docsEmbeddings.sourceDocument, 'academy/%'), - notLike(docsEmbeddings.sourceDocument, 'api-reference/%') + ...UNMOUNTED_DOCS_SECTIONS.map((section) => + notLike(docsEmbeddings.sourceDocument, `${section}/%`) + ) ) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index e364cefd0cc..ee2559b02c0 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -144,8 +144,12 @@ function truncateDocsPageToInlineCap(page: { content: string; totalLines: number returnedLines: number } { const lines = page.content.split('\n') + // Route to ONE more fetch, not two. Telling the model to grep and then read + // costs two more uncached fetches of a page it already partly has; grep and + // read cost the same single fetch, so grep is an alternative to a read here, + // never a step before one. const notice = (shown: number) => - `\n\n[Page truncated: showing lines 1-${shown} of ${page.totalLines}. Grep this path for the section you need, then read with offset/limit.]` + `\n\n[Page truncated: returned lines 1-${shown} of ${page.totalLines}. To continue, read this path with offset: ${shown}. To jump straight to a section, grep this path INSTEAD of reading it — grep is the same single fetch and returns only matching lines with their numbers.]` let kept = lines.length let content = page.content diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts index fa1f0ddcd81..7373a72e26c 100644 --- a/scripts/sync-docs-manifest.ts +++ b/scripts/sync-docs-manifest.ts @@ -15,9 +15,10 @@ * into their parent URL; `/workflows/index.mdx` * is a 404 on the site) * - * Excluded, and intentionally absent from the VFS: `academy/` and - * `api-reference/` (fetch those with the scrape tool if ever needed), the root - * `index.mdx` (its URL is `/`, which redirects), and every non-`en` locale. + * Excluded, and intentionally absent from the VFS: every section in + * `UNMOUNTED_DOCS_SECTIONS` (fetch those with the scrape tool if ever needed), + * the root `index.mdx` (its URL is `/`, which redirects), and every non-`en` + * locale. * * Usage: * bun run docs-manifest:generate # write the manifest @@ -26,7 +27,7 @@ import { readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { foldDocsIndexPath } from '../apps/sim/lib/copilot/docs/docs-path' +import { foldDocsIndexPath, UNMOUNTED_DOCS_SECTIONS } from '../apps/sim/lib/copilot/docs/docs-path' import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) @@ -34,8 +35,12 @@ const ROOT = resolve(SCRIPT_DIR, '..') const DOCS_CONTENT_DIR = resolve(ROOT, 'apps/docs/content/docs/en') const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/docs-manifest.ts') -/** Top-level docs sections deliberately left out of the copilot's `docs/` tree. */ -const EXCLUDED_SECTIONS = new Set(['academy', 'api-reference']) +/** + * Top-level docs sections deliberately left out of the copilot's `docs/` tree. + * Shared with the vector search's unscoped filter so readability and + * findability cannot drift apart — see `UNMOUNTED_DOCS_SECTIONS`. + */ +const EXCLUDED_SECTIONS = new Set(UNMOUNTED_DOCS_SECTIONS) /** Collect every `.mdx` file under `dir`, as paths relative to {@link DOCS_CONTENT_DIR}. */ async function collectMdxPaths(dir: string, prefix = ''): Promise { From 8dd7bf6a5e27428daa6b695187a223202104caa0 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:53:43 -0700 Subject: [PATCH 018/103] chore(copilot): regenerate the tool catalog for the retired quick-reference tool Picks up get_platform_actions' hidden/retired description from mothership. The id stays in the catalog so isKnownTool keeps routing calls from an older build during a mixed deploy; the handler is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 3d522d70351..54bb070eeca 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3032,6 +3032,7 @@ export const GetPlatformActions: ToolCatalogEntry = { route: 'sim', mode: 'async', parameters: { type: 'object', properties: {} }, + hidden: true, } export const GetWorkflowData: ToolCatalogEntry = { From 2bb974c2f59a0aa1b460b7a270718a856c250088 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:50:53 -0700 Subject: [PATCH 019/103] fix(review): attach the scope-error TSDoc to the class it documents Two TSDoc blocks sat back to back above DocsSearchOutcome; the first describes DocsSearchScopeError, which had no doc comment of its own. Moved it to the class. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/docs/docs-search.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 93e5b2f208f..db45bb66990 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -22,11 +22,6 @@ export interface DocsSearchResult { similarity: number } -/** - * Thrown when the caller scopes a search to a `path` that is not a real page or - * section in the docs corpus. Surfaced verbatim so the model can correct itself - * rather than reading an empty result as "the docs say nothing about this". - */ /** * A search result set plus why it may be shorter than `topK`. The SQL LIMIT is * applied before the threshold and liveness filters, so these counts are what @@ -42,6 +37,11 @@ export interface DocsSearchOutcome { droppedStale: number } +/** + * Thrown when the caller scopes a search to a `path` that is not a real page or + * section in the docs corpus. Surfaced verbatim so the model can correct itself + * rather than reading an empty result as "the docs say nothing about this". + */ export class DocsSearchScopeError extends Error { readonly code = 'DOCS_SEARCH_SCOPE' as const constructor(message: string) { From ae25efdc61a5da35c27dfd3f4fd62cef864fbd90 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:53:23 -0700 Subject: [PATCH 020/103] =?UTF-8?q?fix(review):=20harden=20docs=20corpus?= =?UTF-8?q?=20edges=20=E2=80=94=20trailing-slash=20glob,=20root-index=20dr?= =?UTF-8?q?ops,=20oversized-line=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings applied from the multi-agent pass on this branch: - glob("docs/") matched no key and silently returned empty; normalize now strips trailing slashes so it resolves like "docs" - unscoped search_docs no longer returns root-homepage chunks that would only be counted against topK and then dropped as stale (the manifest deliberately omits index.mdx) - a docs page whose single line exceeds the inline cap now fails with grep guidance instead of returning an over-cap payload as success - test coverage for the vfs docs routing (glob/read/grep dispatch, DocsCorpusError surfacing, truncation paths), the search_docs server tool's shortfall notes, the empty-embedding outcome, and the inert @docs context Co-Authored-By: Claude Fable 5 --- .../lib/copilot/chat/process-contents.test.ts | 17 +++ apps/sim/lib/copilot/docs/docs-corpus.test.ts | 5 + apps/sim/lib/copilot/docs/docs-corpus.ts | 5 +- apps/sim/lib/copilot/docs/docs-search.test.ts | 18 +++ apps/sim/lib/copilot/docs/docs-search.ts | 8 +- .../lib/copilot/tools/handlers/vfs.test.ts | 108 +++++++++++++++++- apps/sim/lib/copilot/tools/handlers/vfs.ts | 19 ++- .../tools/server/docs/search-docs.test.ts | 102 +++++++++++++++++ 8 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 6570628e3bb..f36f755f587 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -282,6 +282,23 @@ describe('processContextsServer - skill contexts', () => { }) }) +describe('processContextsServer - docs contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a tagged docs context to nothing while @docs tagging is disabled', async () => { + const result = await processContextsServer( + [{ kind: 'docs', label: 'Docs' } as ChatContext], + 'user-1', + 'how do loops work @Docs', + 'ws-1' + ) + + expect(result).toEqual([]) + }) +}) + describe('processContextsServer - MCP contexts', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts index 31117855a08..f3dd5a8f4ad 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -58,6 +58,11 @@ describe('globDocs', () => { expect(globDocs('docs/workflows.mdx')).toEqual(['docs/workflows.mdx']) expect(globDocs('docs/workflows/index.mdx')).toEqual([]) }) + + it('treats a trailing-slash pattern like the bare directory instead of matching nothing', () => { + expect(globDocs('docs/')).toEqual(['docs']) + expect(globDocs('docs/integrations/')).toEqual(['docs/integrations']) + }) }) describe('readDocsPage', () => { diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index 16a202a4f84..5b8bcbc3213 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -38,7 +38,10 @@ const docsKeyView: Map = new Map( ) function normalize(path: string): string { - return path.trim().replace(/^\/+/, '') + // Trailing slashes are stripped so `docs/` addresses the corpus the same way + // `docs` does — otherwise a trailing-slash glob pattern matches no key and + // silently returns an empty result instead of the corpus listing. + return path.trim().replace(/^\/+/, '').replace(/\/+$/, '') } /** diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index 5b2bf75f23d..5920c0d9fef 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -26,6 +26,7 @@ vi.mock('drizzle-orm', () => { and: op('and'), or: op('or'), eq: op('eq'), + ne: op('ne'), like: op('like'), notLike: op('notLike'), sql: (strings: TemplateStringsArray) => ({ op: 'sql', text: strings.join('?') }), @@ -77,6 +78,12 @@ describe('searchDocs path scoping', () => { expect(whereText()).toContain('academy/%') }) + it('excludes the root homepage when unscoped — its chunks have no live docs/ path', async () => { + await searchDocs('cron') + expect(whereText()).toContain('"op":"ne"') + expect(whereText()).toContain('index.mdx') + }) + it('scopes a page to both on-disk layouts', async () => { await searchDocs('cron', { path: 'docs/workflows/blocks/agent.mdx' }) const text = whereText() @@ -171,6 +178,17 @@ describe('searchDocs results', () => { expect((await searchDocs('cron')).results).toEqual([]) }) + it('returns the zero-candidate outcome without querying when the embedding is empty', async () => { + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [] }) + const outcome = await searchDocs('cron') + expect(outcome).toEqual({ + results: [], + candidatesConsidered: 0, + droppedBelowThreshold: 0, + droppedStale: 0, + }) + }) + it('drops chunks below the similarity threshold', async () => { mockRows.value = [ { diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index db45bb66990..0b70d840608 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, like, notLike, or, sql } from 'drizzle-orm' +import { and, eq, like, ne, notLike, or, sql } from 'drizzle-orm' import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' @@ -60,12 +60,16 @@ export class DocsSearchScopeError extends Error { * * An unscoped search excludes every {@link UNMOUNTED_DOCS_SECTIONS} section: * they are indexed but not mounted in the VFS, so a hit there would be a chunk - * the agent cannot then read. + * the agent cannot then read. The root homepage (`index.mdx`) is excluded for + * the same reason — the manifest generator drops it (its URL is `/`, which + * redirects), so its chunks would only ever be counted against topK and then + * discarded as stale. */ function scopeCondition(path?: string) { const normalized = (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '') if (normalized === '' || normalized === 'docs') { return and( + ne(docsEmbeddings.sourceDocument, 'index.mdx'), ...UNMOUNTED_DOCS_SECTIONS.map((section) => notLike(docsEmbeddings.sourceDocument, `${section}/%`) ) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 283f63c0709..348bea6fb1a 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' const { getOrMaterializeVFS } = vi.hoisted(() => ({ @@ -702,3 +702,109 @@ describe('vfs uploads are opt-in (like recently-deleted/)', () => { expect(grepChatUpload).toHaveBeenCalledWith('report.json', 'chat-1', 'x', expect.any(Object)) }) }) + +describe('vfs handlers docs corpus routing', () => { + const fetchMock = vi.fn() + const DOCS_PAGE = 'docs/workflows/blocks/agent.mdx' + + beforeEach(() => { + vi.clearAllMocks() + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('globs the docs corpus without materializing the workspace VFS', async () => { + const result = await executeVfsGlob({ pattern: 'docs/**' }, GREP_CTX) + + expect(result.success).toBe(true) + expect((result.output as { files: string[] }).files).toContain(DOCS_PAGE) + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('reads a docs page via the live-site fetch, not the workspace VFS', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => 'line one\nline two' }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(true) + expect(result.output).toEqual({ content: 'line one\nline two', totalLines: 2 }) + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('surfaces DocsCorpusError messages verbatim from read, without fetching', async () => { + const unknown = await executeVfsRead({ path: 'docs/not-a-real-page.mdx' }, GREP_CTX) + expect(unknown.success).toBe(false) + expect(unknown.error).toContain('Docs page not found') + + const dir = await executeVfsRead({ path: 'docs/workflows/blocks' }, GREP_CTX) + expect(dir.success).toBe(false) + expect(dir.error).toContain('is a directory') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('greps exactly one docs page and rejects multi-page scopes verbatim', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'alpha\ncron beta\ngamma', + }) + + const single = await executeVfsGrep({ pattern: 'cron', path: DOCS_PAGE }, GREP_CTX) + expect(single.success).toBe(true) + + const multi = await executeVfsGrep({ pattern: 'cron', path: 'docs/workflows' }, GREP_CTX) + expect(multi.success).toBe(false) + expect(multi.error).toContain('single page') + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('truncates an oversized multi-line docs page to fit the inline cap', async () => { + const line = 'y'.repeat(200) + const totalLines = Math.ceil((TOOL_RESULT_MAX_INLINE_CHARS * 2) / (line.length + 1)) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => Array.from({ length: totalLines }, () => line).join('\n'), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(true) + const output = result.output as { content: string; totalLines: number } + expect(output.totalLines).toBe(totalLines) + expect(output.content).toContain('[Page truncated: returned lines 1-') + expect(JSON.stringify(output).length).toBeLessThanOrEqual(TOOL_RESULT_MAX_INLINE_CHARS) + }) + + it('fails a docs page whose single line cannot fit inline instead of returning it oversized', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'z'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1000), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('Grep this page') + }) + + it('rejects an explicit window that still overflows instead of truncating it', async () => { + const line = 'y'.repeat(200) + const totalLines = Math.ceil((TOOL_RESULT_MAX_INLINE_CHARS * 2) / (line.length + 1)) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => Array.from({ length: totalLines }, () => line).join('\n'), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE, offset: 0, limit: totalLines }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('still too large over the requested window') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index ee2559b02c0..c8bac616ed6 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -137,12 +137,14 @@ async function canReturnWorkspaceFileValue( /** * Trim an oversized docs page to the largest whole-line prefix that fits the * inline budget, preserving the true `totalLines` so the model can page through - * the rest with offset/limit. + * the rest with offset/limit. Returns null when not even one line fits — a + * single line longer than the cap — so the caller can fail instead of returning + * an over-cap payload as success. */ function truncateDocsPageToInlineCap(page: { content: string; totalLines: number }): { output: { content: string; totalLines: number } returnedLines: number -} { +} | null { const lines = page.content.split('\n') // Route to ONE more fetch, not two. Telling the model to grep and then read // costs two more uncached fetches of a page it already partly has; grep and @@ -152,17 +154,16 @@ function truncateDocsPageToInlineCap(page: { content: string; totalLines: number `\n\n[Page truncated: returned lines 1-${shown} of ${page.totalLines}. To continue, read this path with offset: ${shown}. To jump straight to a section, grep this path INSTEAD of reading it — grep is the same single fetch and returns only matching lines with their numbers.]` let kept = lines.length - let content = page.content while (kept > 0) { - content = `${lines.slice(0, kept).join('\n')}${notice(kept)}` + const content = `${lines.slice(0, kept).join('\n')}${notice(kept)}` if ( serializedResultSize({ content, totalLines: page.totalLines }) <= TOOL_RESULT_MAX_INLINE_CHARS ) { - break + return { output: { content, totalLines: page.totalLines }, returnedLines: kept } } kept = Math.floor(kept / 2) } - return { output: { content, totalLines: page.totalLines }, returnedLines: kept } + return null } export async function executeVfsGrep( @@ -392,6 +393,12 @@ export async function executeVfsRead( } } const truncated = truncateDocsPageToInlineCap(page) + if (!truncated) { + return { + success: false, + error: `${path} is too large to return inline even truncated. Grep this page for the section you need.`, + } + } logger.debug('vfs_read truncated oversized docs page', { path, totalLines: page.totalLines, diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts new file mode 100644 index 00000000000..37318f5fc53 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DocsSearchOutcome } from '@/lib/copilot/docs/docs-search' + +const { mockSearchDocs } = vi.hoisted(() => ({ + mockSearchDocs: vi.fn(), +})) + +vi.mock('@/lib/copilot/docs/docs-search', () => ({ + searchDocs: mockSearchDocs, +})) + +import { searchDocsServerTool } from '@/lib/copilot/tools/server/docs/search-docs' + +function outcome(overrides: Partial): DocsSearchOutcome { + return { + results: [], + candidatesConsidered: 0, + droppedBelowThreshold: 0, + droppedStale: 0, + ...overrides, + } +} + +const RESULT = { + path: 'docs/agents.mdx', + url: 'https://docs.sim.ai/agents', + title: 'Agents', + content: 'body', + similarity: 0.9, +} + +describe('searchDocsServerTool', () => { + beforeEach(() => { + mockSearchDocs.mockReset() + }) + + it('forwards query, path, and topK to the search layer', async () => { + mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) + + const output = await searchDocsServerTool.execute({ + query: 'how do agents work', + path: 'docs/agents.mdx', + topK: 7, + }) + + expect(mockSearchDocs).toHaveBeenCalledWith('how do agents work', { + path: 'docs/agents.mdx', + topK: 7, + }) + expect(output).toEqual({ + results: [RESULT], + query: 'how do agents work', + totalResults: 1, + }) + }) + + it('omits the note when nothing was dropped', async () => { + mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) + + const output = await searchDocsServerTool.execute({ query: 'q' }) + + expect(output.note).toBeUndefined() + }) + + it('explains an empty result set caused by filtering, so it does not read as missing docs', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ candidatesConsidered: 2, droppedBelowThreshold: 1, droppedStale: 1 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }) + + expect(output.note).toContain('does NOT mean the docs lack this topic') + expect(output.note).toContain('1 scored too low') + expect(output.note).toContain('1 point at pages no longer in the docs') + }) + + it('notes threshold-only drops on a partial result set', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ results: [RESULT], candidatesConsidered: 3, droppedBelowThreshold: 2 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }) + + expect(output.note).toContain('Returned 1 of 3 candidate(s)') + expect(output.note).toContain('2 scored too low') + expect(output.note).not.toContain('no longer in the docs') + }) + + it('notes stale-only drops on a partial result set', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ results: [RESULT], candidatesConsidered: 2, droppedStale: 1 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }) + + expect(output.note).toContain('1 point at pages no longer in the docs') + expect(output.note).not.toContain('scored too low') + }) +}) From 5e66306901d8e33ac0ca2c825baaa7b88d408372 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:39:04 -0700 Subject: [PATCH 021/103] chore(copilot): regenerate the tool catalog for the lean search agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search subagent's task description now tells callers to pass a fully self-contained task — it no longer inherits the conversation (see the companion mothership change). Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 2 +- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 54bb070eeca..e3f1fc548b3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -4549,7 +4549,7 @@ export const Search: ToolCatalogEntry = { properties: { task: { description: - "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + "A fully self-contained task — the search agent sees none of this conversation, so include the question plus every name, id, constraint, and prior finding it needs. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", type: 'string', }, }, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a3fd31e1c85..593ccc62c0f 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -4394,7 +4394,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { task: { description: - "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + "A fully self-contained task — the search agent sees none of this conversation, so include the question plus every name, id, constraint, and prior finding it needs. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", type: 'string', }, }, From d28b9564f3ef150373f18529c4407c4afae43861 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:25:09 -0700 Subject: [PATCH 022/103] improvement(copilot): retire search_documentation and get_platform_actions outright, no shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transitional apparatus is gone: no search_documentation registry alias, no get_platform_actions handler, and the ids are out of the regenerated catalog/schemas. During the deploy window an old Mothership build calling either id gets the recoverable tool-not-found result. The two ids stay in HIDDEN_TOOL_NAMES forever — like load_agent_skill, historical persisted chats contain their tool calls and must replay without rendering chips for retired tools. The alias test is replaced by a dispatch test pinning search_docs's own catalog -> route -> handler chain and the retired ids' gone-but-chip-hidden state. Co-Authored-By: Claude Fable 5 --- .../tool-executor/register-handlers.ts | 3 - .../tools/handlers/platform-actions.ts | 118 ------------------ .../lib/copilot/tools/handlers/platform.ts | 9 -- .../server/docs/search-docs-dispatch.test.ts | 45 +++++++ apps/sim/lib/copilot/tools/server/router.ts | 5 - 5 files changed, 45 insertions(+), 135 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/handlers/platform-actions.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/platform.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index f2f9eb6d304..b5b9768ac14 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -16,7 +16,6 @@ import { GetBlockUpstreamReferences, GetDeployedWorkflowState, GetDeploymentLog, - GetPlatformActions, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, @@ -81,7 +80,6 @@ import { executeManageSandbox } from '../tools/handlers/management/manage-sandbo import { executeManageSkill } from '../tools/handlers/management/manage-skill' import { executeMaterializeFile } from '../tools/handlers/materialize-file' import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/handlers/oauth' -import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeRunCode } from '../tools/handlers/run-code' @@ -192,7 +190,6 @@ function buildHandlerMap(): Record { [OauthRequestAccess.id]: h(executeOAuthRequestAccess), [OpenResource.id]: h(executeOpenResource), [RestoreResource.id]: h(executeRestoreResource), - [GetPlatformActions.id]: h(executeGetPlatformActions), [ListIntegrationTools.id]: h(executeListIntegrationTools), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts deleted file mode 100644 index c3c3ac14384..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Static content for the get_platform_actions tool. - * Contains the Sim platform quick reference and keyboard shortcuts. - */ -export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboard Shortcuts - -## Keyboard Shortcuts -**Mod** = Cmd (macOS) / Ctrl (Windows/Linux). Shortcuts work when canvas is focused. - -### Workflow Actions -| Shortcut | Action | -|----------|--------| -| Mod+Enter | Run workflow (or cancel if running) | -| Mod+Z | Undo | -| Mod+Shift+Z | Redo | -| Mod+C | Copy selected blocks | -| Mod+X | Cut selected blocks | -| Mod+V | Paste blocks | -| Delete/Backspace | Delete selected blocks or edges | -| Shift+L | Auto-layout canvas | -| Mod+Shift+F | Fit to view | -| Mod+Shift+Enter | Accept Copilot changes | - -### Panel Navigation -| Shortcut | Action | -|----------|--------| -| Mod+F | Open workflow search and replace | -| Mod+Alt+F | Focus Toolbar search | - -### Global Navigation -| Shortcut | Action | -|----------|--------| -| Mod+K | Open search | -| Mod+Shift+A | Add new agent workflow | -| Mod+Shift+P | Create workflow | -| Mod+B | Toggle sidebar | -| Mod+L | Go to logs | - -### Utility -| Shortcut | Action | -|----------|--------| -| Mod+D | Clear terminal console | - -### Mouse Controls -| Action | Control | -|--------|---------| -| Pan/move canvas | Left-drag on empty space (hand mode, the default), middle-drag, scroll, or trackpad | -| Select multiple blocks | Shift+drag to draw a selection box. In cursor mode, left-drag on empty space draws it instead | -| Drag block | Left-drag on block header | -| Add to selection | Mod+Click or Shift+Click on blocks | - -## Quick Reference — Workspaces -| Action | How | -|--------|-----| -| Create workspace | Click workspace dropdown → New Workspace | -| Switch workspaces | Click workspace dropdown → Select workspace | -| Invite teammates | Sidebar → Invite | -| Rename/Duplicate/Export/Delete workspace | Right-click workspace → action | - -## Quick Reference — Workflows -| Action | How | -|--------|-----| -| Create workflow | Click + button in sidebar | -| Reorder/move workflows | Drag workflow up/down or onto a folder | -| Import workflow | Click import button in sidebar → Select file | -| Multi-select workflows | Mod+Click or Shift+Click workflows in sidebar | -| Open in new tab | Right-click workflow → Open in New Tab | -| Rename/Duplicate/Export/Delete | Right-click workflow → action | - -## Quick Reference — Blocks -| Action | How | -|--------|-----| -| Add a block | Drag from Toolbar panel, or right-click canvas → Add Block | -| Multi-select blocks | Mod+Click or Shift+Click additional blocks, or Shift+drag a selection box | -| Copy/Paste blocks | Mod+C / Mod+V | -| Duplicate/Delete blocks | Right-click → action | -| Rename a block | Click block name in header | -| Enable/Disable block | Right-click → Enable/Disable | -| Lock/Unlock block | Hover block → Click lock icon (Admin only) | -| Toggle handle orientation | Right-click → Toggle Handles | -| Open a block in the Editor panel | Right-click → Open Editor | -| Move a block out of a loop/parallel | Right-click → Remove from Subflow | -| Configure a block | Select block → use Editor panel on right | - -## Quick Reference — Connections -| Action | How | -|--------|-----| -| Create connection | Drag from output handle to input handle | -| Delete connection | Click edge to select → Delete key | -| Use output in another block | Drag connection tag into input field | - -## Quick Reference — Running & Testing -| Action | How | -|--------|-----| -| Run workflow | Click Run Workflow button or Mod+Enter | -| Stop workflow | Click Stop button or Mod+Enter while running | -| Test with chat | Use Chat panel on the right side | -| Run from block | Hover block → Click play button, or right-click → Run from block | -| Run until block | Right-click block → Run until block | -| View execution logs | Open terminal panel at bottom, or Mod+L | -| Filter/Search/Copy/Clear logs | Terminal panel controls | - -## Quick Reference — Deployment -| Action | How | -|--------|-----| -| Deploy workflow | Click Deploy button in panel | -| Update deployment | Click Update when changes are detected | -| Revert deployment | Previous versions in Deploy tab → Promote to live | -| Copy API endpoint | Deploy tab → API → Copy API cURL | - -## Quick Reference — Variables -| Action | How | -|--------|-----| -| Add/Edit/Delete workflow variable | Panel → Variables → Add Variable | -| Add environment variable | Settings → Environment Variables → Add | -| Reference workflow variable | Use syntax | -| Reference environment variable | Use {{ENV_VAR}} syntax | -` diff --git a/apps/sim/lib/copilot/tools/handlers/platform.ts b/apps/sim/lib/copilot/tools/handlers/platform.ts deleted file mode 100644 index f5cc43f910b..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/platform.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { PLATFORM_ACTIONS_CONTENT } from './platform-actions' - -export async function executeGetPlatformActions( - _rawParams: Record, - _context: ExecutionContext -): Promise { - return { success: true, output: { content: PLATFORM_ACTIONS_CONTENT } } -} diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts new file mode 100644 index 00000000000..c3f406c56b9 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import { isKnownTool, isSimExecuted } from '@/lib/copilot/tool-executor/router' +import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' +import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' + +/** + * `executeTool` gates on `isKnownTool` (catalog membership) before it ever + * consults the handler registry, so a sim-routed tool needs every link of this + * chain or dispatch rejects it before the handler is reached. These assertions + * pin that chain for search_docs. + */ +describe('search_docs dispatch chain', () => { + it('is in the catalog, so dispatch does not reject it as unknown', () => { + expect(isKnownTool('search_docs')).toBe(true) + }) + + it('routes to sim, so dispatch reaches the server tool registry', () => { + expect(isSimExecuted('search_docs')).toBe(true) + }) + + it('has a registered server handler', () => { + expect(getRegisteredServerToolNames()).toContain('search_docs') + }) +}) + +/** + * The retired ids are fully unregistered server-side — no catalog entry, no + * handler, no alias. Only the client-side chip suppression survives, forever, + * so historical persisted chats replay without rendering chips for tools that + * no longer exist (the load_agent_skill precedent). + */ +describe('retired docs-tool ids', () => { + for (const retired of ['search_documentation', 'get_platform_actions']) { + it(`${retired} is gone from the catalog and server registry but stays chip-hidden`, () => { + expect(TOOL_CATALOG[retired]).toBeUndefined() + expect(isKnownTool(retired)).toBe(false) + expect(getRegisteredServerToolNames()).not.toContain(retired) + expect(getHiddenToolNames().has(retired)).toBe(true) + }) + } +}) diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 1410c37941f..ab6a882b30e 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -169,11 +169,6 @@ const baseServerToolRegistry: Record = { [editWorkflowServerTool.name]: editWorkflowServerTool, [queryLogsServerTool.name]: queryLogsServerTool, [searchDocsServerTool.name]: searchDocsServerTool, - // Transitional alias: sim and mothership deploy independently, so during the - // rollout of the search_documentation -> search_docs rename one side is still - // emitting the old id. The old params are a subset of the new, so routing them - // here is safe. Remove once both repos have shipped the rename. - search_documentation: searchDocsServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, From df7295904531d833a73c00c36a215914ed92ed08 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:43:40 -0700 Subject: [PATCH 023/103] changed search_docs tool title to Searching Sim docs --- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 58f5de96f23..cae077aceab 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -503,7 +503,7 @@ const TOOL_TITLES: Record = { restore_resource: 'Restoring resource', run_block: 'Running block', scheduled_task: 'Managing scheduled task', - search_docs: 'Searching docs', + search_docs: 'Searching Sim docs', search_patterns: 'Searching patterns', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', From d651d6ffbdb25c866cded3fb1c6a4414a18ba415 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:45:45 -0700 Subject: [PATCH 024/103] fix(copilot): apply the Searching Sim docs rename to the dynamic title case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static TOOL_TITLES entry is unreachable for search_docs — the dynamic switch case returns first so it can include the query — so the rename only takes effect there. Tests updated to the new wording. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/tools/tool-display.test.ts | 8 ++++---- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 24b7be3681f..681e555f92d 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -79,23 +79,23 @@ describe('getToolDisplayTitle natural-language coverage', () => { }) it('includes the query in search_docs titles', () => { - expect(getToolDisplayTitle('search_docs')).toBe('Searching docs') + expect(getToolDisplayTitle('search_docs')).toBe('Searching Sim docs') expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( - 'Searching docs for "loop blocks iteration"' + 'Searching Sim docs for "loop blocks iteration"' ) // The completed-state flip must keep the suffix, not drop back to the bare label. expect( getToolCompletedTitle( getToolDisplayTitle('search_docs', { query: 'how to read workflow logs' }) ) - ).toBe('Searched docs for "how to read workflow logs"') + ).toBe('Searched Sim docs for "how to read workflow logs"') // A long agent-written query is truncated rather than blowing out the chip. expect( getToolDisplayTitle('search_docs', { query: 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', })?.length - ).toBeLessThanOrEqual('Searching docs for ""'.length + 60 + '...'.length) + ).toBeLessThanOrEqual('Searching Sim docs for ""'.length + 60 + '...'.length) }) it('falls back to running code for function_execute without a title', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index cae077aceab..8f77f4cd856 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -805,7 +805,7 @@ export function getToolDisplayTitle(name: string, args?: Record } case 'search_docs': { const target = firstStringArg(args, 'toolTitle', 'title', 'query') - return target ? `Searching docs for "${truncate(target, 60)}"` : 'Searching docs' + return target ? `Searching Sim docs for "${truncate(target, 60)}"` : 'Searching Sim docs' } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') From ae89151692e55e97a158a64c63e4babcf6eb4186 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:26:59 -0700 Subject: [PATCH 025/103] improvement(copilot): retry docs fetches and grep docs directories in parallel Two robustness upgrades to the docs corpus. Page fetches from docs.sim.ai now retry transient failures (5xx, 429, network, timeout) with jittered backoff over three 3s attempts instead of a single 10s attempt, so a momentary stall recovers in seconds instead of failing the tool call. And grep now accepts a docs directory path: it fans out to every manifest page under the directory with bounded concurrency and runs one multi-file grep, replacing the single-page restriction that forced agents into per-page call sweeps. Pages the site no longer serves are skipped; an unreachable page fails the whole grep so a partial result is never mistaken for "not documented". Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/docs/docs-corpus.test.ts | 93 ++++++++++++++++--- apps/sim/lib/copilot/docs/docs-corpus.ts | 80 ++++++++++++---- apps/sim/lib/copilot/tools/handlers/vfs.ts | 4 +- .../server/docs/search-docs-dispatch.test.ts | 20 ++-- 4 files changed, 151 insertions(+), 46 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts index f3dd5a8f4ad..33211bb7b9b 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -2,15 +2,21 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/utils/helpers', () => ({ + sleep: vi.fn(() => Promise.resolve()), +})) + import { couldMatchDocsScope, DocsCorpusError, globDocs, - grepDocsPage, + grepDocs, isDocsPath, readDocsPage, } from '@/lib/copilot/docs/docs-corpus' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +import type { GrepMatch } from '@/lib/copilot/vfs/operations' const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx') @@ -98,33 +104,52 @@ describe('readDocsPage', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('surfaces a docs-site outage as a retryable error', async () => { + it('surfaces a docs-site outage as a retryable error after exhausting retries', async () => { fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' }) - await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) }) it('treats a network failure as retryable', async () => { fetchMock.mockRejectedValue(new Error('socket hang up')) - await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('recovers when a transient failure clears on retry', async () => { + fetchMock + .mockRejectedValueOnce(new Error('socket hang up')) + .mockResolvedValue({ ok: true, status: 200, text: async () => '# Agent\n\nbody' }) + + const page = await readDocsPage(`docs/${SAMPLE_PAGE}`) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 }) }) - it('reports a page the site no longer serves as permanent, not retryable', async () => { + it('reports a page the site no longer serves as permanent, without retrying', async () => { fetchMock.mockResolvedValue({ ok: false, status: 404, text: async () => '' }) const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e) expect(error).toBeInstanceOf(DocsCorpusError) expect(error.message).toMatch(/does not serve it/) expect(error.message).toMatch(/retrying will not help/) - expect(error.message).not.toMatch(/temporarily unavailable/) + expect(error.message).not.toMatch(/could not be reached/) + expect(fetchMock).toHaveBeenCalledOnce() }) it('still treats 429 as retryable rather than permanent', async () => { fetchMock.mockResolvedValue({ ok: false, status: 429, text: async () => '' }) - await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) }) }) -describe('grepDocsPage', () => { +describe('grepDocs', () => { const fetchMock = vi.fn() + const SECTION_DIR = 'docs/workflows/blocks' + const SECTION_PAGES = DOCS_MANIFEST.filter((path) => path.startsWith('workflows/blocks/')).map( + (path) => `docs/${path}` + ) beforeEach(() => { fetchMock.mockReset() @@ -135,14 +160,14 @@ describe('grepDocsPage', () => { vi.unstubAllGlobals() }) - it('greps exactly one page', async () => { + it('greps exactly one page for a page path', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => 'intro line\nsystemPrompt matters\ntail', }) - const matches = await grepDocsPage(`docs/${SAMPLE_PAGE}`, 'systemPrompt') + const matches = await grepDocs(`docs/${SAMPLE_PAGE}`, 'systemPrompt') expect(fetchMock).toHaveBeenCalledOnce() expect(matches).toEqual([ @@ -150,9 +175,51 @@ describe('grepDocsPage', () => { ]) }) - it('refuses a multi-page scope so one grep is never hundreds of fetches', async () => { - await expect(grepDocsPage('docs/', 'cron')).rejects.toThrow(/single page/) - await expect(grepDocsPage('docs/workflows', 'cron')).rejects.toThrow(/single page/) + it('greps a directory by fetching every page under it', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'intro\ncron marker line\ntail', + }) + expect(SECTION_PAGES.length).toBeGreaterThan(1) + + const matches = (await grepDocs(SECTION_DIR, 'cron marker', { + maxResults: 10_000, + })) as GrepMatch[] + + expect(fetchMock).toHaveBeenCalledTimes(SECTION_PAGES.length) + expect(matches.map((match) => match.path)).toEqual(SECTION_PAGES) + }) + + it('skips pages the site no longer serves instead of failing the directory grep', async () => { + const missingUrl = `https://docs.sim.ai/${SECTION_PAGES[0].slice('docs/'.length)}` + fetchMock.mockImplementation(async (url: string) => + url === missingUrl + ? { ok: false, status: 404, text: async () => '' } + : { ok: true, status: 200, text: async () => 'cron marker line' } + ) + + const matches = (await grepDocs(SECTION_DIR, 'cron marker', { + maxResults: 10_000, + })) as GrepMatch[] + + expect(matches.map((match) => match.path)).toEqual(SECTION_PAGES.slice(1)) + }) + + it('fails the whole directory grep when a page cannot be reached', async () => { + fetchMock.mockImplementation(async (url: string) => + url.endsWith(`/${SAMPLE_PAGE}`) + ? { ok: false, status: 502, text: async () => '' } + : { ok: true, status: 200, text: async () => 'cron marker line' } + ) + + await expect(grepDocs(SECTION_DIR, 'cron marker')).rejects.toThrow(/Retry shortly/) + }) + + it('rejects a path that is neither a page nor a directory without fetching', async () => { + await expect(grepDocs('docs/not-a-real-page.mdx', 'cron')).rejects.toThrow( + /not a docs page or directory/ + ) expect(fetchMock).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index 5b8bcbc3213..d02d07aa7ce 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -1,9 +1,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' -import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations' +import { glob as globPaths, grep, grepReadResult } from '@/lib/copilot/vfs/operations' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' const logger = createLogger('DocsCorpus') @@ -13,7 +16,12 @@ const DOCS_BASE_URL = 'https://docs.sim.ai' /** VFS prefix the docs corpus is mounted at. */ const DOCS_PREFIX = 'docs/' -const FETCH_TIMEOUT_MS = 10_000 +/** Per-attempt budget — the site is CDN-cached and normally answers in well under a second. */ +const FETCH_ATTEMPT_TIMEOUT_MS = 3_000 +const FETCH_MAX_ATTEMPTS = 3 + +/** Parallel page fetches for a directory-scoped grep. */ +const GREP_FETCH_CONCURRENCY = 8 /** * Thrown for expected, user-facing docs-corpus conditions (unknown page, @@ -108,8 +116,9 @@ export interface DocsPage { * Fetch one docs page's raw markdown from the live site. The manifest path IS * the URL path (`docs/workflows/blocks/agent.mdx` → * `https://docs.sim.ai/workflows/blocks/agent.mdx`, which the docs app rewrites - * to its raw-markdown route), so no mapping table is needed. Returns null when - * the page is not in the manifest or the site does not serve it. + * to its raw-markdown route), so no mapping table is needed. Transient failures + * (5xx, 429, network error, timeout) are retried with jittered backoff before + * being reported as unavailable. */ type DocsFetchResult = | { outcome: 'ok'; content: string } @@ -118,13 +127,10 @@ type DocsFetchResult = /** Transient: 5xx, 429, network error, or timeout. */ | { outcome: 'unavailable' } -async function fetchDocsPage(path: string): Promise { - const key = normalize(path) - if (!docsKeyView.has(key)) return { outcome: 'missing' } - const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` +async function fetchDocsPageOnce(url: string): Promise { try { const response = await fetch(url, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + signal: AbortSignal.timeout(FETCH_ATTEMPT_TIMEOUT_MS), headers: { Accept: 'text/markdown, text/plain' }, }) if (!response.ok) { @@ -139,6 +145,17 @@ async function fetchDocsPage(path: string): Promise { } } +async function fetchDocsPage(path: string): Promise { + const key = normalize(path) + if (!docsKeyView.has(key)) return { outcome: 'missing' } + const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` + for (let attempt = 1; ; attempt++) { + const result = await fetchDocsPageOnce(url) + if (result.outcome !== 'unavailable' || attempt >= FETCH_MAX_ATTEMPTS) return result + await sleep(backoffWithJitter(attempt, null)) + } +} + /** * Read one docs page. Throws {@link DocsCorpusError} for the expected user-facing * conditions (directory path, unknown page, site unreachable) so the handler can @@ -163,28 +180,55 @@ export async function readDocsPage(path: string): Promise { } if (result.outcome === 'unavailable') { throw new DocsCorpusError( - `Could not load ${key} from ${DOCS_BASE_URL} — the docs site is temporarily unavailable. Retry shortly.` + `Could not load ${key} from ${DOCS_BASE_URL} — the docs site could not be reached. Retry shortly.` ) } return { content: result.content, totalLines: result.content.split('\n').length } } /** - * Grep ONE docs page, mirroring how grep over `files/` works: each page is a - * separate fetch from the docs site, so a multi-page grep would mean hundreds of - * requests. A path that is not a single page throws. + * Grep the docs corpus. A single page greps just that page. A directory path + * (`docs`, `docs/files`) fans out to every manifest page under it: pages are + * fetched in parallel and searched as one multi-file grep, so results follow + * manifest order and `maxResults` applies across pages. Pages the site no + * longer serves are skipped; a page that cannot be reached after retries fails + * the whole grep, because a silent partial result would misread as "not + * documented". */ -export async function grepDocsPage( +export async function grepDocs( path: string, pattern: string, options?: GrepOptions ): Promise { const key = normalize(path) - if (!docsKeyView.has(key)) { + if (docsKeyView.has(key)) { + const page = await readDocsPage(key) + return grepReadResult(key, page, pattern, key, options) + } + if (!isDocsDir(key)) { + throw new DocsCorpusError( + `"${path}" is not a docs page or directory. Use glob("docs/**") to list the docs corpus.` + ) + } + const dir = `${key}/` + const pages = [...docsKeyView.keys()].filter((pageKey) => pageKey.startsWith(dir)) + let unreachable = 0 + const results = await mapWithConcurrency(pages, GREP_FETCH_CONCURRENCY, async (pageKey) => { + // Once any page is unreachable the grep is going to fail — skip the + // remaining fetches instead of hammering a site that is not answering. + if (unreachable > 0) return null + const result = await fetchDocsPage(pageKey) + if (result.outcome === 'unavailable') unreachable++ + return result + }) + if (unreachable > 0) { throw new DocsCorpusError( - `Grep over the docs corpus must target a single page (e.g. path: "docs/workflows/blocks/agent.mdx"). "${path}" is not a docs page. Use glob("docs/**") to find the exact path, then grep that one page.` + `Could not load every page under ${dir} from ${DOCS_BASE_URL} — a partial grep could misread as "not documented". Retry shortly.` ) } - const page = await readDocsPage(key) - return grepReadResult(key, page, pattern, key, options) + const contents = new Map() + results.forEach((result, index) => { + if (result?.outcome === 'ok') contents.set(pages[index], result.content) + }) + return grep(contents, pattern, undefined, options) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index c8bac616ed6..a7b93399238 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -8,7 +8,7 @@ import { couldMatchDocsScope, DocsCorpusError, globDocs, - grepDocsPage, + grepDocs, isDocsPath, readDocsPage, } from '@/lib/copilot/docs/docs-corpus' @@ -203,7 +203,7 @@ export async function executeVfsGrep( let result: GrepMatch[] | string[] | GrepCountEntry[] let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined if (rawPath !== undefined && isDocsPath(rawPath)) { - result = await grepDocsPage(rawPath, pattern, grepOptions) + result = await grepDocs(rawPath, pattern, grepOptions) } else if (isChatUploadGrepPath(rawPath)) { if (!context.chatId) { return { success: false, error: 'No chat context available for uploads/' } diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts index c3f406c56b9..3634648b5b8 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts @@ -27,19 +27,13 @@ describe('search_docs dispatch chain', () => { }) }) -/** - * The retired ids are fully unregistered server-side — no catalog entry, no - * handler, no alias. Only the client-side chip suppression survives, forever, - * so historical persisted chats replay without rendering chips for tools that - * no longer exist (the load_agent_skill precedent). - */ -describe('retired docs-tool ids', () => { - for (const retired of ['search_documentation', 'get_platform_actions']) { - it(`${retired} is gone from the catalog and server registry but stays chip-hidden`, () => { - expect(TOOL_CATALOG[retired]).toBeUndefined() - expect(isKnownTool(retired)).toBe(false) - expect(getRegisteredServerToolNames()).not.toContain(retired) - expect(getHiddenToolNames().has(retired)).toBe(true) +describe('removed docs-tool ids', () => { + for (const removed of ['search_documentation', 'get_platform_actions']) { + it(`${removed} is absent from the catalog, registries, and hidden-tool set`, () => { + expect(TOOL_CATALOG[removed]).toBeUndefined() + expect(isKnownTool(removed)).toBe(false) + expect(getRegisteredServerToolNames()).not.toContain(removed) + expect(getHiddenToolNames().has(removed)).toBe(false) }) } }) From b272cf1a95c8878285c69824d79a0b7e3d3ea611 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:27:36 -0700 Subject: [PATCH 026/103] chore(copilot): drop the retired search_documentation test The tool was retired outright with the search_docs replacement; its test outlived the module on staging and no longer resolves. Co-Authored-By: Claude Fable 5 --- .../server/docs/search-documentation.test.ts | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts 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 deleted file mode 100644 index 14693f75913..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @vitest-environment node - */ -import { loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGenerateSearchEmbedding } = vi.hoisted(() => ({ - mockGenerateSearchEmbedding: vi.fn(), -})) - -vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - SearchDocumentation: { id: 'search_documentation' }, -})) -vi.mock('@/lib/knowledge/embeddings', () => ({ - generateSearchEmbedding: mockGenerateSearchEmbedding, -})) - -import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -describe('documentation search model boundary', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [], isBYOK: false }) - }) - - it('preserves a query that merely collides with ambient secret plaintext', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { - name: 'DOCS_QUERY', - plaintext: 'private documentation query', - encryptedValue: 'encrypted-query', - }, - ]) - registry.recordResolved('DOCS_QUERY', 'private documentation query') - - const result = await searchDocumentationServerTool.execute( - { query: 'private documentation query' }, - { userId: 'user-1', resolvedSecretTraceRegistry: registry } - ) - - expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith('private documentation query') - expect(result).toEqual({ - results: [], - query: 'private documentation query', - totalResults: 0, - }) - - const logger = loggerMock.createLogger.mock.results.at(-1)?.value - expect(logger?.info).toHaveBeenCalledWith('Executing docs search', { - queryLength: 'private documentation query'.length, - topK: 10, - }) - expect(JSON.stringify(logger?.info.mock.calls)).not.toContain('private documentation query') - }) -}) From 407c7c7d6b28961488e3514b4af42710f8a1958e Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:28:59 -0700 Subject: [PATCH 027/103] test(copilot): cover directory-scoped docs grep at the handler level The vfs handler test still pinned the retired single-page restriction; directory grep now succeeds with a parallel page fan-out, and an invalid path (neither page nor directory) is the remaining rejection. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/tools/handlers/vfs.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 348bea6fb1a..464bb2ccc6b 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -746,7 +746,7 @@ describe('vfs handlers docs corpus routing', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('greps exactly one docs page and rejects multi-page scopes verbatim', async () => { + it('greps one docs page or a docs directory without touching the workspace VFS', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, @@ -756,9 +756,16 @@ describe('vfs handlers docs corpus routing', () => { const single = await executeVfsGrep({ pattern: 'cron', path: DOCS_PAGE }, GREP_CTX) expect(single.success).toBe(true) - const multi = await executeVfsGrep({ pattern: 'cron', path: 'docs/workflows' }, GREP_CTX) - expect(multi.success).toBe(false) - expect(multi.error).toContain('single page') + const multi = await executeVfsGrep( + { pattern: 'cron', path: 'docs/workflows', maxResults: 10_000 }, + GREP_CTX + ) + expect(multi.success).toBe(true) + expect(fetchMock.mock.calls.length).toBeGreaterThan(1) + + const invalid = await executeVfsGrep({ pattern: 'cron', path: 'docs/not-a-page.mdx' }, GREP_CTX) + expect(invalid.success).toBe(false) + expect(invalid.error).toContain('not a docs page or directory') expect(getOrMaterializeVFS).not.toHaveBeenCalled() }) From a807e2eb1d4a9c7bb2e7e76b51290cce31ac18a6 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:36:33 -0700 Subject: [PATCH 028/103] chore(copilot): regenerate the docs manifest for staging docs content Staging added docs pages since the manifest was generated; the CI freshness check (docs-manifest:check) catches exactly this drift. Co-Authored-By: Claude Fable 5 --- .../lib/copilot/generated/docs-manifest.ts | 36 +- .../2026-08-03-platform-agent-ideation.html | 511 ++++++++++++++++++ 2 files changed, 539 insertions(+), 8 deletions(-) create mode 100644 docs/ideation/2026-08-03-platform-agent-ideation.html diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 720d5371947..6c747f97593 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -15,6 +15,14 @@ export const DOCS_MANIFEST: readonly string[] = [ 'agents/custom-tools.mdx', 'agents/mcp.mdx', 'agents/skills.mdx', + 'chat.mdx', + 'chat/files.mdx', + 'chat/knowledge.mdx', + 'chat/mailer.mdx', + 'chat/research.mdx', + 'chat/tables.mdx', + 'chat/tasks.mdx', + 'chat/workflows.mdx', 'files.mdx', 'files/editor.mdx', 'files/generating.mdx', @@ -88,6 +96,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/elasticsearch.mdx', 'integrations/elevenlabs.mdx', 'integrations/emailbison.mdx', + 'integrations/embeddings.mdx', 'integrations/enrich.mdx', 'integrations/enrichment.mdx', 'integrations/enrow.mdx', @@ -161,11 +170,13 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/linkedin.mdx', 'integrations/linkup.mdx', 'integrations/linq.mdx', + 'integrations/logfire.mdx', 'integrations/logs.mdx', 'integrations/loops.mdx', 'integrations/luma.mdx', 'integrations/mailchimp.mdx', 'integrations/mailgun.mdx', + 'integrations/managed_agent.mdx', 'integrations/mem0.mdx', 'integrations/memory.mdx', 'integrations/microsoft_ad.mdx', @@ -237,6 +248,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/similarweb.mdx', 'integrations/sixtyfour.mdx', 'integrations/slack.mdx', + 'integrations/smartlead.mdx', 'integrations/smtp.mdx', 'integrations/sportmonks.mdx', 'integrations/sqs.mdx', @@ -253,6 +265,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/temporal.mdx', 'integrations/textract.mdx', 'integrations/thrive.mdx', + 'integrations/tiktok.mdx', 'integrations/tinybird.mdx', 'integrations/trello-service-account.mdx', 'integrations/trello.mdx', @@ -279,6 +292,8 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/zendesk.mdx', 'integrations/zep.mdx', 'integrations/zerobounce.mdx', + 'integrations/zoho-desk-service-account.mdx', + 'integrations/zoho_desk.mdx', 'integrations/zoom-service-account.mdx', 'integrations/zoom.mdx', 'integrations/zoominfo.mdx', @@ -293,14 +308,6 @@ export const DOCS_MANIFEST: readonly string[] = [ 'logs-debugging.mdx', 'logs-debugging/alerts.mdx', 'logs-debugging/logging.mdx', - 'mothership.mdx', - 'mothership/files.mdx', - 'mothership/knowledge.mdx', - 'mothership/mailer.mdx', - 'mothership/research.mdx', - 'mothership/tables.mdx', - 'mothership/tasks.mdx', - 'mothership/workflows.mdx', 'platform/costs.mdx', 'platform/credentials.mdx', 'platform/enterprise.mdx', @@ -310,6 +317,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'platform/enterprise/data-drains.mdx', 'platform/enterprise/data-retention.mdx', 'platform/enterprise/forks.mdx', + 'platform/enterprise/self-hosted.mdx', 'platform/enterprise/session-policies.mdx', 'platform/enterprise/sso.mdx', 'platform/enterprise/verified-domains.mdx', @@ -317,12 +325,24 @@ export const DOCS_MANIFEST: readonly string[] = [ 'platform/organization.mdx', 'platform/permissions.mdx', 'platform/self-hosting.mdx', + 'platform/self-hosting/architecture.mdx', + 'platform/self-hosting/authentication.mdx', + 'platform/self-hosting/background-jobs.mdx', 'platform/self-hosting/docker.mdx', + 'platform/self-hosting/email.mdx', 'platform/self-hosting/environment-variables.mdx', + 'platform/self-hosting/integrations-oauth.mdx', 'platform/self-hosting/kubernetes.mdx', + 'platform/self-hosting/networking.mdx', 'platform/self-hosting/object-storage.mdx', + 'platform/self-hosting/observability.mdx', 'platform/self-hosting/platforms.mdx', + 'platform/self-hosting/redis.mdx', + 'platform/self-hosting/scaling.mdx', + 'platform/self-hosting/security.mdx', 'platform/self-hosting/troubleshooting.mdx', + 'platform/self-hosting/upgrades.mdx', + 'platform/self-hosting/verify.mdx', 'platform/workspaces.mdx', 'quick-reference.mdx', 'tables.mdx', diff --git a/docs/ideation/2026-08-03-platform-agent-ideation.html b/docs/ideation/2026-08-03-platform-agent-ideation.html new file mode 100644 index 00000000000..cfa784716f5 --- /dev/null +++ b/docs/ideation/2026-08-03-platform-agent-ideation.html @@ -0,0 +1,511 @@ + + + + + + Platform agent — ideation + + + +
+
+

Ideation · Platform intelligence

+

Turn the docs agent into a trusted platform operator

+

The strongest direction is not an omniscient agent. It is a source-aware agent that knows the user’s operating context, fetches private state only when needed, explains access and billing in product language, and leaves evidence behind whenever it reads sensitive data.

+ + + +
+
30raw candidates
+
12deduped directions
+
6ranked survivors
+
4topic axes covered
+
+ + +
+ +
+

What the codebase already gives us

+

Grounding Context

+

The branch introduces a dedicated platform child that is intentionally isolated from parent conversation and restricted to documentation search, VFS reads, and response. That isolation is useful, but the runtime already has stronger seams than the prompt admits.

+ +
+
+

Trusted request context already exists

+

Child execution carries trusted user/workspace IDs, effective permission, entitlements, timezone, and workspace/session/workflow bootstrap. Human-readable UserMetadata is the notable omission.

+
+
+

Central handlers are the security seam

+

Sim-side tool handlers receive authenticated actor/workspace context and can enforce permission before returning data. Model-supplied IDs do not need to become authority.

+
+
+

Most live data services already exist

+

Billing, permission groups, audit events, execution logs and metrics, and metadata-only subagent invocation records already expose the underlying facts with distinct gates.

+
+
+

Prior art converges on the same split

+

Microsoft, AWS, and Intercom separate ambient identity from permission-trimmed retrieval and persona-specific behavior.

+
+
+ +
+ + Four source-of-truth layers feeding the platform agent + Injected context, live tools, public docs, and component schemas each answer a different class of question. The platform agent synthesizes them into a scoped answer with provenance. + + + + + + + Injected context + who · where · current role + + + Live Sim tools + private · mutable · scoped + + + Product docs + behavior · limits · UI + + + Component schemas + fields · enums · tool IDs + + + + + + + + Platform agent + chooses authority by question + + + scoped + cited + fresh + + Answer with provenance + +
Directional overview: each source is authoritative for a different kind of fact. The model chooses among them; authorization remains in Sim.
+
+
+ +
+

Surface map

+

Topic Axes

+
+

1. Identity and current context

Who is asking, where they are operating, and what request-local context is safe to carry ambiently.

+

2. Access and resource visibility

What the viewer may discover or do, why something is unavailable, and how to avoid resource-existence leaks.

+

3. Plan, billing, and usage

Personal plan, effective coverage, exact workspace payer, usage gates, limits, credits, and management authority.

+

4. Activity, audit, and operational health

What changed, what failed, which evidence source applies, and how private reads become inspectable.

+
+
+ +
+

Qualified directions

+

Ranked Ideas

+ + +
+
+
1

Idea 1. Context passport + source hierarchy

+
Confidence · 94%Complexity · Low
+
+

Description: Inject a small trusted Current Platform Context block into the child: display name, timezone, workspace name/ID, current workflow or selected resource, effective read|write|admin, broad entitlements, and an asOf value. Rewrite the prompt around four authorities: this passport for orientation, live tools for private or mutable facts, docs for product behavior, and component schemas for exact configuration.

+
+
Axis
Identity and current context
+
Basis
direct: The request already threads trusted workspace, permission, entitlement, timezone, session/workflow bootstrap, and VFS inventory to the child, but not human-readable UserMetadata. The current prompt already distinguishes docs behavior from schema truth, so this adds the missing live-data tier rather than replacing the model.
+
Rationale
It removes repeated disambiguation while creating a crisp rule for stale, conflicting, or private facts. This is the smallest change that makes every later tool safer and easier to use.
+
Downsides
The passport becomes a compatibility contract and must stay deliberately small. Current page/resource context needs careful selection so it does not leak browser state to children unnecessarily.
+
+
+ +
+
+
2

Idea 2. Capability/access explainer

+
Confidence · 92%Complexity · Medium
+
+

Description: Add explain_capability(action, resourceType?). It returns available, needs_write, needs_admin, blocked_by_policy, not_entitled, or not_configured, identifies the controlling layer, and gives a safe next step. It never returns names, counts, or existence signals for hidden resources.

+
+
Axis
Access and resource visibility
+
Basis
direct: Sim already combines workspace permission, organization role, permission-group restrictions, integration/model/tool allowlists, and per-viewer feature visibility. Handler-side enforcement and trusted execution context are already the normal boundary.
+
Rationale
This turns “the docs say I can” into “here is whether you can, why, and what legitimate path exists.” It can absorb the useful part of a buildability map without exposing a broad hidden-feature manifest.
+
Downsides
A stable causal vocabulary is product work, not just plumbing. Incorrect denial explanations are worse than a generic denial, so the tool must reuse the same policy decisions as execution rather than reimplementing them.
+
+
+ +
+
+
3

Idea 3. Three-lens billing snapshot + run preflight

+
Confidence · 91%Complexity · Medium
+
+

Description: Add one billing tool with explicit lenses: personal_subscription, effective_user_coverage, and current_workspace_payer. Return only decision-ready fields—plan/status, usable/block state, usage and limit, credits, period, management authority, freshness—and an optional operation preflight that reports the first live gate and user-appropriate remediation.

+
+
Personal

What the user personally owns or pays for.

+
Effective

What coverage the user currently receives.

+
Workspace payer

Which billing pool governs work here.

+
+
+
Axis
Plan, billing, and usage
+
Basis
direct: Those three meanings deliberately differ in the billing code. Billing status also differs from product-usable access, and enforcement-grade reads have stronger freshness requirements than display reads.
+
Rationale
A naïve get_plan would encode the wrong product semantics. A lens-based projection answers “what plan am I on?”, “who pays for this?”, and “why is this run blocked?” without exposing raw subscriptions, Stripe identifiers, invoices, or other members’ usage.
+
Downsides
Organizations and personal accounts need different redaction and management guidance. Live preflight may cost more than a replica-backed informational answer, so freshness must be explicit.
+
+
+ +
+
+
4

Idea 4. Evidence-routed activity investigator

+
Confidence · 88%Complexity · High
+
+

Description: Add investigate_activity(question, timeRange). It classifies the symptom and queries only the authorized evidence family: execution percentiles for latency, workflow logs for failures, organization audit events for “who changed this?”, and metadata-only subagent invocation records for delegation health. It returns a bounded timeline, saved filters or deep links, truncation/freshness notices, and facts clearly separated from hypotheses.

+
+
Axis
Activity, audit, and operational health
+
Basis
direct: Sim already has each source with separate authorization, filter, pagination, and payload semantics. external: Azure copilots use reviewable queries and deep links rather than becoming a parallel source of truth.
+
Rationale
This is the step-function move: the platform agent becomes a credible first responder for “what changed?” and “why did this fail?” while preserving the authority of existing observability surfaces.
+
Downsides
Joining evidence can create false causality. The first version should route and summarize rather than claim root cause, and enterprise audit access must stay independently gated.
+
+
+ +
+
+
5

Idea 5. Sensitive-read receipts

+
Confidence · 87%Complexity · Medium
+
+

Description: Treat read-only billing, audit, member, and execution-data access as sensitive. Every lookup emits a metadata-only receipt containing actor, scope, tool, authorization result, reason or query hash, timestamp, and trace linkage—never the returned private body. The prompt briefly discloses when private records were inspected and offers an inspectable activity link.

+
+
Axis
Activity, audit, and operational health
+
Basis
direct: Sim already records audit metadata and durable subagent-invocation metadata without conversational content. external: AWS and Google log agent-mediated or admin data reads, including dry-run permission checks.
+
Rationale
This is the trust foundation for every private-data tool. It makes agent access governable and answers the security question “what did the agent look at?” without storing sensitive outputs twice.
+
Downsides
Receipts create volume, retention, and user-experience questions. Query hashes and reason fields must avoid becoming a new content-leak channel.
+
+
+ +
+
+
6

Idea 6. Persona/access evaluation matrix

+
Confidence · 85%Complexity · Medium
+
+

Description: Evaluate the same platform questions as free/paid, member/admin/owner, billing-manager/non-manager, policy-restricted/unrestricted, and resource-access/no-access personas. Assert the answer, visible tools, denial wording, non-disclosure, citations, freshness labels, and sensitive-read receipts—not only whether a handler returns 200 or 403.

+
+
Axis
Access and resource visibility
+
Basis
external: Intercom tests Fin as real or synthetic users, plans, audiences, and brands while inspecting triggered behavior. direct: Sim’s access semantics span enough independent layers that isolated handler tests cannot validate what the model ultimately says.
+
Rationale
This converts permission awareness from an architectural claim into product behavior that can be regression-tested. It is especially valuable for “must not reveal” cases where a function-level authorization test can pass while the answer leaks context.
+
Downsides
Model-evaluation stability and fixture maintenance are real costs. Start with a small invariant suite around identity, capability denials, billing lenses, and audit authorization.
+
+
Useful invariantThe same question should produce different, correct answers for a member and an admin—without either answer mentioning what the other persona can see.
+
+
+ +
+

What did not survive intact

+

Rejection Summary

+ + + + + + + + + + + +
#IdeaReason rejected or merged
1Viewer-specific buildability mapThe proposed breadth outran current evidence; its supported capability categories were folded into Idea 2.
2Standalone run-capability preflightStrong but duplicate; merged into the exact-payer billing semantics in Idea 3.
3Usage-driver narrativeReduced public logs do not support detailed workflow attribution without crossing payer-sensitive boundaries.
4Standalone source hierarchyStrong but inseparable from ambient context design; merged into Idea 1.
5Intent-gated private-tool revealRequest-time permission filtering already exists; extra progressive revelation lacked demonstrated value.
6Standalone deep-link behaviorValuable response behavior rather than a product direction; merged into Idea 4.
7Repeated context, access, billing, and incident variantsFive independent lenses converged; duplicates were combined into the strongest source-aware forms above.
+
+ +
Composed by ce-ideate from the platform-agent enhancement prompt and the active Sim/Mothership worktrees.
+
+ + From 8b5a8d8d821270987b125305a36248d8fe412eef Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:48:30 -0700 Subject: [PATCH 029/103] chore(copilot): resync the grep tool description from mothership contracts Mirrors the schema fix documenting the docs corpus grep mode. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 4 ++-- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index e3f1fc548b3..3903492b7ec 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3128,12 +3128,12 @@ export const Grep: ToolCatalogEntry = { path: { type: 'string', description: - "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees there are rejected for content search. A docs/ page or directory searches live page text — a directory fans out to every docs page under it.", }, pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf, and live docs page text when path is a docs/ page or directory.", }, toolTitle: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 593ccc62c0f..94e8452296e 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3007,12 +3007,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees there are rejected for content search. A docs/ page or directory searches live page text — a directory fans out to every docs page under it.", }, pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf, and live docs page text when path is a docs/ page or directory.", }, toolTitle: { type: 'string', From 60cccd2e650801f2f673ebab7d05c50fcfd74892 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:57:49 -0700 Subject: [PATCH 030/103] improvement(copilot): stamp docs grep fan-out size on the grep span A directory-scoped docs grep now records copilot.vfs.grep.docs_page_count (pages fetched from the live site) on the active tool span, mirroring the new contract attribute. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/docs/docs-corpus.ts | 3 +++ apps/sim/lib/copilot/generated/trace-attributes-v1.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index d02d07aa7ce..11462895bb8 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -1,9 +1,11 @@ +import { trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' import { glob as globPaths, grep, grepReadResult } from '@/lib/copilot/vfs/operations' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' @@ -212,6 +214,7 @@ export async function grepDocs( } const dir = `${key}/` const pages = [...docsKeyView.keys()].filter((pageKey) => pageKey.startsWith(dir)) + trace.getActiveSpan()?.setAttribute(TraceAttr.CopilotVfsGrepDocsPageCount, pages.length) let unreachable = 0 const results = await mapWithConcurrency(pages, GREP_FETCH_CONCURRENCY, async (pageKey) => { // Once any page is unreachable the grep is going to fail — skip the diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 5a026a33c3a..37a9c72387b 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -279,6 +279,7 @@ export const TraceAttr = { CopilotVfsFileMediaType: 'copilot.vfs.file.media_type', CopilotVfsFileName: 'copilot.vfs.file.name', CopilotVfsFileSizeBytes: 'copilot.vfs.file.size_bytes', + CopilotVfsGrepDocsPageCount: 'copilot.vfs.grep.docs_page_count', CopilotVfsHasAlpha: 'copilot.vfs.has_alpha', CopilotVfsInputBytes: 'copilot.vfs.input.bytes', CopilotVfsInputHeight: 'copilot.vfs.input.height', @@ -924,6 +925,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.vfs.file.media_type', 'copilot.vfs.file.name', 'copilot.vfs.file.size_bytes', + 'copilot.vfs.grep.docs_page_count', 'copilot.vfs.has_alpha', 'copilot.vfs.input.bytes', 'copilot.vfs.input.height', From 4107177d296d70d8e22fe8fc28e483440ad9a749 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:02:47 -0700 Subject: [PATCH 031/103] chore(copilot): resync docs and mothership contracts after restack --- .../lib/copilot/generated/docs-manifest.ts | 4 +++ .../lib/copilot/generated/tool-catalog-v1.ts | 34 ++++++------------- .../lib/copilot/generated/tool-schemas-v1.ts | 18 ++++------ .../lib/copilot/generated/vfs-snapshot-v1.ts | 14 -------- 4 files changed, 22 insertions(+), 48 deletions(-) diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 6c747f97593..13e896c2bfc 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -93,6 +93,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/dub.mdx', 'integrations/duckduckgo.mdx', 'integrations/dynamodb.mdx', + 'integrations/dynatrace.mdx', 'integrations/elasticsearch.mdx', 'integrations/elevenlabs.mdx', 'integrations/emailbison.mdx', @@ -185,6 +186,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/microsoft_planner.mdx', 'integrations/microsoft_teams.mdx', 'integrations/millionverifier.mdx', + 'integrations/mintlify.mdx', 'integrations/mistral_parse.mdx', 'integrations/monday-service-account.mdx', 'integrations/monday.mdx', @@ -250,6 +252,8 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/slack.mdx', 'integrations/smartlead.mdx', 'integrations/smtp.mdx', + 'integrations/snowflake-service-account.mdx', + 'integrations/snowflake.mdx', 'integrations/sportmonks.mdx', 'integrations/sqs.mdx', 'integrations/square.mdx', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 3903492b7ec..4d0a4f13977 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -61,7 +61,6 @@ export interface ToolCatalogEntry { | 'get_deployed_workflow_state' | 'get_deployment_log' | 'get_page_contents' - | 'get_platform_actions' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -102,7 +101,7 @@ export interface ToolCatalogEntry { | 'run_workflow_until_block' | 'scrape_page' | 'search' - | 'search_documentation' + | 'search_docs' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -182,7 +181,6 @@ export interface ToolCatalogEntry { | 'get_deployed_workflow_state' | 'get_deployment_log' | 'get_page_contents' - | 'get_platform_actions' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -223,7 +221,7 @@ export interface ToolCatalogEntry { | 'run_workflow_until_block' | 'scrape_page' | 'search' - | 'search_documentation' + | 'search_docs' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -3026,15 +3024,6 @@ export const GetPageContents: ToolCatalogEntry = { }, } -export const GetPlatformActions: ToolCatalogEntry = { - id: 'get_platform_actions', - name: 'get_platform_actions', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, - hidden: true, -} - export const GetWorkflowData: ToolCatalogEntry = { id: 'get_workflow_data', name: 'get_workflow_data', @@ -4560,21 +4549,21 @@ export const Search: ToolCatalogEntry = { internal: true, } -export const SearchDocumentation: ToolCatalogEntry = { - id: 'search_documentation', - name: 'search_documentation', +export const SearchDocs: ToolCatalogEntry = { + id: 'search_docs', + name: 'search_docs', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - query: { type: 'string', description: 'The search query' }, - topK: { - type: 'number', + path: { + type: 'string', description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, + 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope', }, + query: { type: 'string', description: 'The search query' }, + topK: { type: 'number', description: 'Number of results (default 5, max 25)' }, }, required: ['query'], }, @@ -6561,7 +6550,6 @@ export const TOOL_CATALOG: Record = { [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentLog.id]: GetDeploymentLog, [GetPageContents.id]: GetPageContents, - [GetPlatformActions.id]: GetPlatformActions, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, @@ -6602,7 +6590,7 @@ export const TOOL_CATALOG: Record = { [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScrapePage.id]: ScrapePage, [Search.id]: Search, - [SearchDocumentation.id]: SearchDocumentation, + [SearchDocs.id]: SearchDocs, [SearchIntegrationTools.id]: SearchIntegrationTools, [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 94e8452296e..c36a3d2bc8b 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2918,13 +2918,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, get_workflow_data: { parameters: { type: 'object', @@ -4403,19 +4396,22 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + search_docs: { parameters: { type: 'object', properties: { + path: { + type: 'string', + description: + 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope', + }, query: { type: 'string', description: 'The search query', }, topK: { type: 'number', - description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, + description: 'Number of results (default 5, max 25)', }, }, required: ['query'], diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts index ee8a2dc4f62..6559a690ca7 100644 --- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts +++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts @@ -10,7 +10,6 @@ export interface VfsSnapshotV1 { envVars?: string[] files?: VfsSnapshotV1File[] integrations?: VfsSnapshotV1Integration[] - jobs?: VfsSnapshotV1Job[] knowledgeBases?: VfsSnapshotV1KnowledgeBase[] mcpServers?: VfsSnapshotV1McpServer[] members?: VfsSnapshotV1Member[] @@ -59,19 +58,6 @@ export interface VfsSnapshotV1Integration { providerId: string role?: string } -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Job". - */ -export interface VfsSnapshotV1Job { - cronExpression?: string - id: string - lifecycle?: string - prompt?: string - sourceTaskName?: string - status?: string - title?: string -} /** * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema * via the `definition` "VfsSnapshotV1KnowledgeBase". From be702bc0c6182a3278ed5d30dd8b4ba3692996a7 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:08:44 -0700 Subject: [PATCH 032/103] improvement(copilot): narrow the docs search rollout --- .../app/api/mothership/execute/route.test.ts | 4 +- apps/sim/app/api/mothership/execute/route.ts | 21 +- .../chat-context-kind-registry.tsx | 1 - .../components/chip-clipboard-codec.ts | 4 +- .../user-input/components/constants.ts | 6 +- .../prompt-editor/use-prompt-editor.ts | 4 +- .../components/resource-context.test.ts | 4 + .../[workspaceId]/home/hooks/use-chat.ts | 2 - .../components/user-input/constants.ts | 17 +- .../hooks/use-mention-insert-handlers.ts | 34 +- .../user-input/hooks/use-mention-keyboard.ts | 33 +- .../user-input/hooks/use-mention-menu.ts | 2 +- .../copilot/components/user-input/utils.ts | 2 - .../lib/copilot/chat/display-message.test.ts | 6 +- .../copilot/chat/persisted-message.test.ts | 6 +- apps/sim/lib/copilot/chat/post.ts | 6 +- .../lib/copilot/chat/process-contents.test.ts | 45 +- apps/sim/lib/copilot/chat/process-contents.ts | 5 - apps/sim/lib/copilot/docs/docs-corpus.test.ts | 56 +- apps/sim/lib/copilot/docs/docs-corpus.ts | 85 +-- apps/sim/lib/copilot/docs/docs-path.ts | 18 +- apps/sim/lib/copilot/docs/docs-search.test.ts | 11 +- apps/sim/lib/copilot/docs/docs-search.ts | 42 +- .../lib/copilot/generated/docs-manifest.ts | 9 +- .../lib/copilot/generated/tool-catalog-v1.ts | 6 +- .../lib/copilot/generated/tool-schemas-v1.ts | 5 +- .../copilot/generated/trace-attributes-v1.ts | 2 - .../lib/copilot/tools/handlers/vfs.test.ts | 13 +- apps/sim/lib/copilot/tools/handlers/vfs.ts | 31 +- .../tools/server/docs/search-docs.test.ts | 68 ++- .../copilot/tools/server/docs/search-docs.ts | 21 +- .../lib/copilot/tools/tool-display.test.ts | 2 - apps/sim/stores/panel/types.ts | 1 - .../2026-08-03-platform-agent-ideation.html | 511 ------------------ scripts/sync-docs-manifest.ts | 11 +- 35 files changed, 211 insertions(+), 883 deletions(-) delete mode 100644 docs/ideation/2026-08-03-platform-agent-ideation.html diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 007f9424f1b..513c4ea3de4 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -224,7 +224,7 @@ describe('mothership private trace provenance transport', () => { { ...requestBody, messages: [{ role: 'user', content: 'secret-value __var_FOREIGN' }], - contexts: [{ kind: 'docs', label: 'Docs' }], + contexts: [{ kind: 'knowledge', knowledgeId: 'knowledge-1', label: 'Docs' }], }, { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, 'http://localhost:3000/api/mothership/execute' @@ -235,7 +235,6 @@ describe('mothership private trace provenance transport', () => { expect(mockProcessContextsServer).toHaveBeenCalledWith( expect.any(Array), 'user-1', - 'secret-value __var_FOREIGN', 'workspace-1', 'chat-1' ) @@ -288,7 +287,6 @@ describe('mothership private trace provenance transport', () => { }, ], 'user-1', - 'hello', 'workspace-1', 'chat-1' ) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 39f061a92d3..fc583885765 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -211,7 +211,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workflowId, executionId, }) - const lastUserMessage = messages.filter((message) => message.role === 'user').at(-1)?.content // double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime const agentMentions = contexts as unknown as ChatContext[] | undefined const taggedMcpServerIds = (agentMentions ?? []).flatMap((context) => @@ -239,18 +238,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), mothershipToolsPromise, computeWorkspaceEntitlements(workspaceId, userId), - processContextsServer( - nonMcpAgentMentions, - userId, - lastUserMessage, - workspaceId, - effectiveChatId - ).catch((error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - }), + processContextsServer(nonMcpAgentMentions, userId, workspaceId, effectiveChatId).catch( + (error) => { + reqLogger.warn('Failed to resolve agent contexts for execution', { + error: toError(error).message, + }) + return [] + } + ), ]) const requestPayload: Record = { messages, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index 1a251e0bc4a..1d6d2ad210f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -110,7 +110,6 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, - docs: { label: 'Docs', renderIcon: () => null }, slash_command: { label: 'Command', renderIcon: () => null }, integration: { label: 'Integration', renderIcon: renderIntegrationTile }, skill: { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 73eb6eff658..378092fac2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -19,8 +19,8 @@ const CHIP_LINK_SCHEME = 'sim' * string>>` keeps it union-synced: rename a kind's id field and this stops * type-checking. * - * Excluded kinds (`current_workflow`, `blocks`, `workflow_block`, `docs`) carry - * no single portable id (an array / two ids / none) and degrade to plain text. + * Kinds absent from this map have no portable single-id representation and + * degrade to plain text. */ const PORTABLE_KIND_TO_ID_FIELD = { table: 'tableId', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index f24b1890ee7..a0ffd98df46 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -113,7 +113,7 @@ export const SPEECH_RECOGNITION_LANG = 'en-US' // inner tab. The singleton ids ask the agent to inspect the whole resource; // every other id is a precise live-tab pointer. const RESOURCE_TO_CONTEXT: Record< - MothershipResourceType, + Exclude, (resource: MothershipResource) => ChatContext > = { browser: (r) => ({ kind: 'browser_tab', tabId: r.id, label: r.title }), @@ -127,9 +127,9 @@ const RESOURCE_TO_CONTEXT: Record< task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }), log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }), integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }), - generic: (r) => ({ kind: 'docs', label: r.title }), } -export function mapResourceToContext(resource: MothershipResource): ChatContext { +export function mapResourceToContext(resource: MothershipResource): ChatContext | null { + if (resource.type === 'generic') return null return RESOURCE_TO_CONTEXT[resource.type](resource) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index f86f69f24a2..74f25c7f237 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -407,6 +407,9 @@ export function usePromptEditor({ const insertResource = useCallback( (resource: MothershipResource) => { + const context = mapResourceToContext(resource) + if (!context) return + const textarea = textareaRef.current if (textarea) { const currentValue = valueRef.current @@ -442,7 +445,6 @@ export function usePromptEditor({ setValueState(newValue) } - const context = mapResourceToContext(resource) addContextNotified(context) }, [textareaRef, addContextNotified] diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts index 47e0216319f..3441bd0ed61 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts @@ -47,4 +47,8 @@ describe('mapResourceToContext', () => { label: 'Leads', }) }) + + it('does not turn a synthetic panel into a chat context', () => { + expect(mapResourceToContext(resource({ type: 'generic', title: 'Results' }))).toBeNull() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index acbea0bd61f..26cc3ca9fb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -458,8 +458,6 @@ function isChatContext(value: unknown): value is ChatContext { return typeof value.folderId === 'string' case 'filefolder': return typeof value.fileFolderId === 'string' - case 'docs': - return true case 'slash_command': return typeof value.command === 'string' case 'integration': diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index d9cdf9702ac..7860b326ae7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -12,11 +12,6 @@ export type MentionFolderId = | 'logs' | 'integrations' -/** - * Menu item category types for mention menu (includes folders + docs item) - */ -export type MentionCategory = MentionFolderId | 'docs' - /** * Configuration interface for folder types */ @@ -184,17 +179,9 @@ export const FOLDER_ORDER: MentionFolderId[] = [ ] /** - * Docs item configuration (special case - not a folder) - */ -export const DOCS_CONFIG = { - getLabel: () => 'Docs', - buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }), -} as const - -/** - * Total number of items in root menu (folders + docs) + * Total number of items in the root menu. */ -export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1 +export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length /** * Slash command configuration diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts index 75eb4f7ec50..8a67a524458 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo } from 'react' import { - DOCS_CONFIG, FOLDER_CONFIGS, type FolderConfig, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' @@ -89,36 +88,6 @@ export function useMentionInsertHandlers({ ] ) - /** - * Special handler for Docs (no item parameter, uses DOCS_CONFIG) - */ - const insertDocsMention = useCallback(() => { - const label = DOCS_CONFIG.getLabel() - const context = DOCS_CONFIG.buildContext() - - // Prevent duplicate insertion - if (isContextAlreadySelected(context, selectedContexts)) { - resetActiveMentionQuery() - closeMenus() - return - } - - // Docs uses fallback insertion - if (!replaceActiveMentionWith(label)) { - insertAtCursor(` @${label} `) - } - - onContextAdd(context) - closeMenus() - }, [ - selectedContexts, - replaceActiveMentionWith, - insertAtCursor, - onContextAdd, - resetActiveMentionQuery, - closeMenus, - ]) - const handlers = useMemo( () => ({ insertPastChatMention: createInsertHandler(FOLDER_CONFIGS.chats), @@ -128,9 +97,8 @@ export function useMentionInsertHandlers({ insertWorkflowBlockMention: createInsertHandler(FOLDER_CONFIGS['workflow-blocks']), insertLogMention: createInsertHandler(FOLDER_CONFIGS.logs), insertIntegrationMention: createInsertHandler(FOLDER_CONFIGS.integrations), - insertDocsMention, }), - [createInsertHandler, insertDocsMention] + [createInsertHandler] ) return handlers diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts index 8ab898483ff..1c7c5d9a5d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts @@ -29,7 +29,6 @@ interface UseMentionKeyboardProps { insertWorkflowBlockMention: (blk: any) => void insertLogMention: (log: any) => void insertIntegrationMention: (integration: any) => void - insertDocsMention: () => void } /** Folder navigation state exposed from MentionMenu via callback */ mentionFolderNav: MentionFolderNav | null @@ -114,9 +113,9 @@ export function useMentionKeyboard({ * Build aggregated list matching the portal's ordering */ const buildAggregatedList = useCallback( - (query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => { + (query: string): Array<{ type: MentionFolderId; value: any }> => { const q = query.toLowerCase() - const result: Array<{ type: MentionFolderId | 'docs'; value: any }> = [] + const result: Array<{ type: MentionFolderId; value: any }> = [] for (const folderId of FOLDER_ORDER) { const filtered = filterFolderItems(folderId, q) @@ -125,10 +124,6 @@ export function useMentionKeyboard({ }) } - if ('docs'.includes(q)) { - result.push({ type: 'docs', value: null }) - } - return result }, [filterFolderItems] @@ -215,13 +210,6 @@ export function useMentionKeyboard({ e.preventDefault() - const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length - if (isDocsSelected) { - resetActiveMentionQuery() - insertHandlers.insertDocsMention() - return true - } - const selectedFolderId = FOLDER_ORDER[mentionActiveIndex] if (selectedFolderId) { const config = FOLDER_CONFIGS[selectedFolderId] @@ -242,7 +230,6 @@ export function useMentionKeyboard({ resetActiveMentionQuery, setSubmenuQueryStart, ensureFolderLoaded, - insertHandlers, ] ) @@ -283,12 +270,8 @@ export function useMentionKeyboard({ const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1)) const chosen = aggregated[idx] if (chosen) { - if (chosen.type === 'docs') { - insertHandlers.insertDocsMention() - } else { - const handler = insertHandlerMap[chosen.type] - handler(chosen.value) - } + const handler = insertHandlerMap[chosen.type] + handler(chosen.value) } return true } @@ -306,13 +289,6 @@ export function useMentionKeyboard({ return true } - const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length - if (isDocsSelected) { - resetActiveMentionQuery() - insertHandlers.insertDocsMention() - return true - } - const selectedFolderId = FOLDER_ORDER[mentionActiveIndex] if (selectedFolderId && mentionFolderNav) { const config = FOLDER_CONFIGS[selectedFolderId] @@ -342,7 +318,6 @@ export function useMentionKeyboard({ setSubmenuActiveIndex, setSubmenuQueryStart, ensureFolderLoaded, - insertHandlers, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts index 3e9a390f5ac..fd9d826c6cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts @@ -229,7 +229,7 @@ export function useMentionMenu({ /** * Inserts text at the current cursor position * - * @param text - Text to insert (e.g., " @Docs ") + * @param text - Text to insert at the current cursor position */ const insertAtCursor = useCallback( (text: string) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 3e8c4d8be5d..c1e87d5a5ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -305,8 +305,6 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean const ctx = context as IntegrationContext return c.blockType === ctx.blockType } - case 'docs': - return true // Only one docs context allowed case 'slash_command': { const ctx = context as SlashCommandContext return c.command === ctx.command diff --git a/apps/sim/lib/copilot/chat/display-message.test.ts b/apps/sim/lib/copilot/chat/display-message.test.ts index e70a32314a4..907dd2e650e 100644 --- a/apps/sim/lib/copilot/chat/display-message.test.ts +++ b/apps/sim/lib/copilot/chat/display-message.test.ts @@ -150,12 +150,12 @@ describe('display-message', () => { const display = toDisplayMessage({ id: 'msg-selection', role: 'user', - content: '@Docs @Terminal', + content: '@Guide @Terminal', timestamp: '2024-01-01T00:00:00.000Z', contexts: [ { kind: 'browser_tab', - label: 'Docs', + label: 'Guide', tabId: 'tab-1', selection: { text: 'Selected browser text', @@ -179,7 +179,7 @@ describe('display-message', () => { expect(display.contexts).toEqual([ { kind: 'browser_tab', - label: 'Docs', + label: 'Guide', tabId: 'tab-1', selection: { text: 'Selected browser text', diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index 304a9dcfce7..fe2ffff91c7 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -280,11 +280,11 @@ describe('persisted-message', () => { it('round-trips browser and terminal selection snapshots', () => { const persisted = buildPersistedUserMessage({ id: 'user-selection', - content: '@Docs @Terminal', + content: '@Guide @Terminal', contexts: [ { kind: 'browser_tab', - label: 'Docs', + label: 'Guide', tabId: 'tab-1', selection: { text: 'Selected browser text', @@ -310,7 +310,7 @@ describe('persisted-message', () => { expect(normalized.contexts).toEqual([ { kind: 'browser_tab', - label: 'Docs', + label: 'Guide', tabId: 'tab-1', selection: { text: 'Selected browser text', diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 0bde82a6c45..d696cae7133 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -205,7 +205,6 @@ const ChatContextSchema = z 'logs', 'workflow_block', 'knowledge', - 'docs', 'table', 'table_selection', 'file', @@ -458,12 +457,11 @@ async function resolveAgentContexts(params: { contexts?: UnifiedChatRequest['contexts'] resourceAttachments?: UnifiedChatRequest['resourceAttachments'] userId: string - message: string workspaceId?: string chatId?: string requestId: string }): Promise> { - const { contexts, resourceAttachments, userId, message, workspaceId, chatId, requestId } = params + const { contexts, resourceAttachments, userId, workspaceId, chatId, requestId } = params let agentContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = [] @@ -472,7 +470,6 @@ async function resolveAgentContexts(params: { agentContexts = await processContextsServer( contexts as ChatContext[], userId, - message, workspaceId, chatId ) @@ -1279,7 +1276,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { contexts: normalizedContexts, resourceAttachments: body.resourceAttachments, userId: authenticatedUserId, - message: body.message, workspaceId, chatId: actualChatId, requestId, diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index f36f755f587..03d0b0d4706 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -75,9 +75,8 @@ describe('processContextsServer - knowledge contexts', () => { it('reads through the fixed application query with a trusted chat principal', async () => { const result = await processContextsServer( - [{ kind: 'knowledge', knowledgeId: 'knowledge-1', label: 'Docs' } as ChatContext], + [{ kind: 'knowledge', knowledgeId: 'knowledge-1', label: 'Product KB' } as ChatContext], 'dual-workspace-user', - 'hello', 'workspace-a', 'chat-1' ) @@ -98,7 +97,7 @@ describe('processContextsServer - knowledge contexts', () => { expect(result).toEqual([ { type: 'knowledge', - tag: '@Docs', + tag: '@Product KB', content: '', path: 'knowledgebases/Product%20docs/meta.json', }, @@ -112,7 +111,6 @@ describe('processContextsServer - knowledge contexts', () => { processContextsServer( [{ kind: 'knowledge', knowledgeId: 'knowledge-b', label: 'Hidden' } as ChatContext], 'dual-workspace-user', - 'hello', 'workspace-a', 'chat-1' ) @@ -126,7 +124,6 @@ describe('processContextsServer - knowledge contexts', () => { processContextsServer( [{ kind: 'knowledge', knowledgeId: 'knowledge-b', label: 'Hidden' } as ChatContext], 'dual-workspace-user', - 'hello', 'workspace-a', 'chat-1' ) @@ -159,7 +156,6 @@ describe('processContextsServer - block contexts', () => { { kind: 'blocks', blockIds: ['notion'], label: 'Notion' } as ChatContext, ], 'user-1', - 'hello', 'workspace-1' ) @@ -190,7 +186,6 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId: 'sk-1', label: 'My Skill — PostHog' } as ChatContext], 'user-1', - 'hello', 'ws-1' ) @@ -217,7 +212,6 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId, label: 'Skill' } as ChatContext], 'user-1', - 'hello', 'ws-1' ) @@ -240,7 +234,6 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId: 'missing', label: 'x' } as ChatContext], 'user-1', - 'hello', 'ws-1' ) @@ -251,7 +244,6 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId: 'sk-1', label: 'x' } as ChatContext], 'user-1', - 'hello', undefined ) @@ -266,7 +258,6 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId, label: 'Skill 1' } as ChatContext], 'user-1', - 'hello', 'ws-1' ) @@ -282,23 +273,6 @@ describe('processContextsServer - skill contexts', () => { }) }) -describe('processContextsServer - docs contexts', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('resolves a tagged docs context to nothing while @docs tagging is disabled', async () => { - const result = await processContextsServer( - [{ kind: 'docs', label: 'Docs' } as ChatContext], - 'user-1', - 'how do loops work @Docs', - 'ws-1' - ) - - expect(result).toEqual([]) - }) -}) - describe('processContextsServer - MCP contexts', () => { beforeEach(() => { vi.clearAllMocks() @@ -318,7 +292,6 @@ describe('processContextsServer - MCP contexts', () => { const result = await processContextsServer( [{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' }], 'user-1', - '/Docs find auth docs', 'ws-1' ) @@ -479,7 +452,6 @@ describe('processContextsServer - logs contexts', () => { const result = await processContextsServer( [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], 'user-1', - 'hello', 'ws-1' ) @@ -547,7 +519,6 @@ describe('processContextsServer - logs contexts', () => { const result = await processContextsServer( [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], 'user-1', - 'hello', 'ws-1' ) @@ -581,7 +552,6 @@ describe('processContextsServer - logs contexts', () => { const result = await processContextsServer( [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], 'user-1', - 'hello', 'ws-1' ) @@ -611,7 +581,6 @@ describe('processContextsServer - logs contexts', () => { const result = await processContextsServer( [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], 'user-1', - 'hello', 'ws-1' ) @@ -646,7 +615,6 @@ describe('processContextsServer - file_selection contexts', () => { } as ChatContext, ], 'user-1', - 'explain this', 'ws-1' ) @@ -673,7 +641,6 @@ describe('processContextsServer - file_selection contexts', () => { } as ChatContext, ], 'user-1', - 'hello', 'ws-1' ) @@ -696,7 +663,6 @@ describe('processContextsServer - file_selection contexts', () => { } as ChatContext, ], 'user-1', - 'explain', 'ws-1' ) @@ -743,7 +709,6 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', - 'summarize', 'ws-1' ) @@ -776,7 +741,6 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', - 'hello', 'ws-1' ) @@ -804,7 +768,6 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', - 'summarize', 'ws-1' ) @@ -839,7 +802,6 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', - 'summarize', 'ws-1' ) @@ -880,7 +842,6 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', - 'summarize', 'ws-1' ) @@ -918,7 +879,6 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', - 'summarize', 'ws-1' ) @@ -947,7 +907,6 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', - 'summarize', 'ws-1' ) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index c5a49d8a787..2d7a47d63ab 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -120,8 +120,6 @@ function formatTerminalSelection(selection: TerminalTextSelection): string { export async function processContextsServer( contexts: ChatContext[] | undefined, userId: string, - /** Retained for call-site compatibility; unused while @docs tagging is disabled. */ - _userMessage: string | undefined, currentWorkspaceId?: string, chatId?: string ): Promise { @@ -311,9 +309,6 @@ export async function processContextsServer( path: result.path, } } - // `docs` contexts are intentionally inert: @docs tagging is disabled while - // the docs corpus moves to the `docs/` VFS tree. A tagged context resolves - // to nothing and is filtered out below. return null } catch (error) { logger.error('Failed processing context (server)', { ctx, error }) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts index 33211bb7b9b..e1222c9ab36 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -16,7 +16,6 @@ import { readDocsPage, } from '@/lib/copilot/docs/docs-corpus' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' -import type { GrepMatch } from '@/lib/copilot/vfs/operations' const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx') @@ -142,14 +141,17 @@ describe('readDocsPage', () => { await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) expect(fetchMock).toHaveBeenCalledTimes(3) }) + + it('treats 408 as retryable rather than a missing page', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 408, text: async () => '' }) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) }) describe('grepDocs', () => { const fetchMock = vi.fn() const SECTION_DIR = 'docs/workflows/blocks' - const SECTION_PAGES = DOCS_MANIFEST.filter((path) => path.startsWith('workflows/blocks/')).map( - (path) => `docs/${path}` - ) beforeEach(() => { fetchMock.mockReset() @@ -175,51 +177,15 @@ describe('grepDocs', () => { ]) }) - it('greps a directory by fetching every page under it', async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - text: async () => 'intro\ncron marker line\ntail', - }) - expect(SECTION_PAGES.length).toBeGreaterThan(1) - - const matches = (await grepDocs(SECTION_DIR, 'cron marker', { - maxResults: 10_000, - })) as GrepMatch[] - - expect(fetchMock).toHaveBeenCalledTimes(SECTION_PAGES.length) - expect(matches.map((match) => match.path)).toEqual(SECTION_PAGES) - }) - - it('skips pages the site no longer serves instead of failing the directory grep', async () => { - const missingUrl = `https://docs.sim.ai/${SECTION_PAGES[0].slice('docs/'.length)}` - fetchMock.mockImplementation(async (url: string) => - url === missingUrl - ? { ok: false, status: 404, text: async () => '' } - : { ok: true, status: 200, text: async () => 'cron marker line' } - ) - - const matches = (await grepDocs(SECTION_DIR, 'cron marker', { - maxResults: 10_000, - })) as GrepMatch[] - - expect(matches.map((match) => match.path)).toEqual(SECTION_PAGES.slice(1)) - }) - - it('fails the whole directory grep when a page cannot be reached', async () => { - fetchMock.mockImplementation(async (url: string) => - url.endsWith(`/${SAMPLE_PAGE}`) - ? { ok: false, status: 502, text: async () => '' } - : { ok: true, status: 200, text: async () => 'cron marker line' } + it('rejects a directory without fetching any pages', async () => { + await expect(grepDocs(SECTION_DIR, 'cron marker')).rejects.toThrow( + /grep must target one docs page/ ) - - await expect(grepDocs(SECTION_DIR, 'cron marker')).rejects.toThrow(/Retry shortly/) + expect(fetchMock).not.toHaveBeenCalled() }) it('rejects a path that is neither a page nor a directory without fetching', async () => { - await expect(grepDocs('docs/not-a-real-page.mdx', 'cron')).rejects.toThrow( - /not a docs page or directory/ - ) + await expect(grepDocs('docs/not-a-real-page.mdx', 'cron')).rejects.toThrow(/not a docs page/) expect(fetchMock).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index 11462895bb8..27e249b1ac7 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -1,14 +1,11 @@ -import { trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' -import { glob as globPaths, grep, grepReadResult } from '@/lib/copilot/vfs/operations' -import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations' const logger = createLogger('DocsCorpus') @@ -22,16 +19,12 @@ const DOCS_PREFIX = 'docs/' const FETCH_ATTEMPT_TIMEOUT_MS = 3_000 const FETCH_MAX_ATTEMPTS = 3 -/** Parallel page fetches for a directory-scoped grep. */ -const GREP_FETCH_CONCURRENCY = 8 - /** * Thrown for expected, user-facing docs-corpus conditions (unknown page, * directory path, site unreachable). The VFS handlers return the message as the * tool error instead of logging an internal failure. */ export class DocsCorpusError extends Error { - readonly code = 'DOCS_CORPUS' as const constructor(message: string) { super(message) this.name = 'DocsCorpusError' @@ -47,10 +40,11 @@ const docsKeyView: Map = new Map( DOCS_MANIFEST.map((path) => [`${DOCS_PREFIX}${path}`, '']) ) -function normalize(path: string): string { - // Trailing slashes are stripped so `docs/` addresses the corpus the same way - // `docs` does — otherwise a trailing-slash glob pattern matches no key and - // silently returns an empty result instead of the corpus listing. +/** + * Normalize a docs path and make `docs/` equivalent to `docs`, avoiding empty + * glob results caused only by a trailing slash. + */ +export function normalizeDocsPath(path: string): string { return path.trim().replace(/^\/+/, '').replace(/\/+$/, '') } @@ -62,7 +56,7 @@ function normalize(path: string): string { */ export function isDocsPath(path: string | undefined): boolean { if (!path) return false - const normalized = normalize(path) + const normalized = normalizeDocsPath(path) return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX) } @@ -79,12 +73,12 @@ export function couldMatchDocsScope(pattern: string | undefined): boolean { /** Manifest paths (and their virtual directories) matching an explicit `docs/` pattern. */ export function globDocs(pattern: string): string[] { - return globPaths(docsKeyView, normalize(pattern)) + return globPaths(docsKeyView, normalizeDocsPath(pattern)) } /** True when `path` is a page in the docs tree. */ export function isDocsPage(path: string): boolean { - return docsKeyView.has(normalize(path)) + return docsKeyView.has(normalizeDocsPath(path)) } /** @@ -101,7 +95,7 @@ export function docsPathForSourceDocument(sourceDocument: string | null): string /** True when `path` is a directory in the docs tree rather than a page. */ export function isDocsDir(path: string): boolean { - const dir = `${normalize(path).replace(/\/+$/, '')}/` + const dir = `${normalizeDocsPath(path).replace(/\/+$/, '')}/` if (dir === DOCS_PREFIX) return true for (const key of docsKeyView.keys()) { if (key.startsWith(dir)) return true @@ -137,7 +131,11 @@ async function fetchDocsPageOnce(url: string): Promise { }) if (!response.ok) { logger.warn('Docs page fetch returned a non-OK status', { url, status: response.status }) - const permanent = response.status >= 400 && response.status < 500 && response.status !== 429 + const permanent = + response.status >= 400 && + response.status < 500 && + response.status !== 408 && + response.status !== 429 return { outcome: permanent ? 'missing' : 'unavailable' } } return { outcome: 'ok', content: await response.text() } @@ -148,7 +146,7 @@ async function fetchDocsPageOnce(url: string): Promise { } async function fetchDocsPage(path: string): Promise { - const key = normalize(path) + const key = normalizeDocsPath(path) if (!docsKeyView.has(key)) return { outcome: 'missing' } const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` for (let attempt = 1; ; attempt++) { @@ -164,7 +162,7 @@ async function fetchDocsPage(path: string): Promise { * surface the message verbatim. */ export async function readDocsPage(path: string): Promise { - const key = normalize(path) + const key = normalizeDocsPath(path) if (!docsKeyView.has(key)) { if (isDocsDir(key)) { const dir = key.replace(/\/+$/, '') @@ -189,49 +187,26 @@ export async function readDocsPage(path: string): Promise { } /** - * Grep the docs corpus. A single page greps just that page. A directory path - * (`docs`, `docs/files`) fans out to every manifest page under it: pages are - * fetched in parallel and searched as one multi-file grep, so results follow - * manifest order and `maxResults` applies across pages. Pages the site no - * longer serves are skipped; a page that cannot be reached after retries fails - * the whole grep, because a silent partial result would misread as "not - * documented". + * Grep one docs page. Directory-wide grep is deliberately unsupported because + * each page is a separate network fetch; use `search_docs` for corpus search or + * `glob("docs/**")` to find a page first. */ export async function grepDocs( path: string, pattern: string, options?: GrepOptions ): Promise { - const key = normalize(path) - if (docsKeyView.has(key)) { - const page = await readDocsPage(key) - return grepReadResult(key, page, pattern, key, options) - } - if (!isDocsDir(key)) { - throw new DocsCorpusError( - `"${path}" is not a docs page or directory. Use glob("docs/**") to list the docs corpus.` - ) - } - const dir = `${key}/` - const pages = [...docsKeyView.keys()].filter((pageKey) => pageKey.startsWith(dir)) - trace.getActiveSpan()?.setAttribute(TraceAttr.CopilotVfsGrepDocsPageCount, pages.length) - let unreachable = 0 - const results = await mapWithConcurrency(pages, GREP_FETCH_CONCURRENCY, async (pageKey) => { - // Once any page is unreachable the grep is going to fail — skip the - // remaining fetches instead of hammering a site that is not answering. - if (unreachable > 0) return null - const result = await fetchDocsPage(pageKey) - if (result.outcome === 'unavailable') unreachable++ - return result - }) - if (unreachable > 0) { + const key = normalizeDocsPath(path) + if (!docsKeyView.has(key)) { + if (isDocsDir(key)) { + throw new DocsCorpusError( + `"${path}" is a docs directory; grep must target one docs page. Use search_docs to search the corpus or glob("${key}/**") to list its pages.` + ) + } throw new DocsCorpusError( - `Could not load every page under ${dir} from ${DOCS_BASE_URL} — a partial grep could misread as "not documented". Retry shortly.` + `"${path}" is not a docs page. Use glob("docs/**") to list the docs corpus.` ) } - const contents = new Map() - results.forEach((result, index) => { - if (result?.outcome === 'ok') contents.set(pages[index], result.content) - }) - return grep(contents, pattern, undefined, options) + const page = await readDocsPage(key) + return grepReadResult(key, page, pattern, key, options) } diff --git a/apps/sim/lib/copilot/docs/docs-path.ts b/apps/sim/lib/copilot/docs/docs-path.ts index b4e5ff66bc1..61785418035 100644 --- a/apps/sim/lib/copilot/docs/docs-path.ts +++ b/apps/sim/lib/copilot/docs/docs-path.ts @@ -18,22 +18,8 @@ export const DOCS_INDEX_SUFFIX = '/index.mdx' /** * Top-level docs sections deliberately left out of the copilot's `docs/` tree. * - * Two places must agree on this list or the corpus goes subtly wrong: the - * manifest generator (which decides what is readable) and the vector search's - * unscoped filter (which decides what is findable). If search still matched an - * unmounted section, every hit there would be a chunk the agent cannot then - * `read` — dropped as stale, silently shrinking the result set. - * - * Mounting a section later is not uniform work, so plan per section: - * - `academy` is plain mdx under `apps/docs/content/docs/en/academy` and is - * already indexed in `docs_embeddings` — removing it here and regenerating - * the manifest is the whole change. - * - `api-reference` is mostly generated from `apps/docs/openapi.json` at build - * time, so its pages have no source mdx for the generator to walk (only the - * four handwritten ones: authentication, getting-started, python, typescript). - * Mounting it properly needs the spec served publicly again — the - * `apps/docs/app/openapi.json` route existed for exactly this and was - * reverted — plus a generator branch that walks the spec's tags. + * The manifest generator and vector search share this list so search cannot + * return pages that the VFS cannot read. */ export const UNMOUNTED_DOCS_SECTIONS = ['academy', 'api-reference'] as const diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index 5920c0d9fef..16d19141f43 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -54,6 +54,7 @@ vi.mock('@sim/db', () => ({ })) import { DocsSearchScopeError, searchDocs } from '@/lib/copilot/docs/docs-search' +import { OrchestrationError } from '@/lib/core/orchestration/types' /** Render a drizzle condition to comparable SQL-ish text for assertions. */ function whereText(): string { @@ -105,16 +106,15 @@ describe('searchDocs path scoping', () => { it('includes a section overview stored in either on-disk layout', async () => { await searchDocs('cron', { path: 'docs/workflows' }) const text = whereText() - // `workflows/index.mdx` is inside the subtree; a sibling `workflows.mdx` is not, - // and fumadocs accepts either, so the scope must name it explicitly. expect(text).toContain('workflows/%') expect(text).toContain('workflows.mdx') }) it('rejects a path outside the docs corpus', async () => { - await expect(searchDocs('cron', { path: 'files/report.pdf' })).rejects.toThrow( - DocsSearchScopeError - ) + const error = await searchDocs('cron', { path: 'files/report.pdf' }).catch((cause) => cause) + expect(error).toBeInstanceOf(DocsSearchScopeError) + expect(error).toBeInstanceOf(OrchestrationError) + expect(error).toMatchObject({ code: 'validation' }) }) it('rejects a docs path that is neither a page nor a section', async () => { @@ -282,7 +282,6 @@ describe('searchDocs topK clamping', () => { }) it('falls back to the default rather than passing NaN to the query', async () => { - // Math.min/Math.max propagate NaN, so a bare clamp would reach `.limit(NaN)`. await searchDocs('cron', { topK: Number.NaN }) expect(capturedLimit.value).toBe(5) await searchDocs('cron', { topK: 'twelve' as unknown as number }) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 0b70d840608..e506dff48ef 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -2,8 +2,15 @@ import { db } from '@sim/db' import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, ne, notLike, or, sql } from 'drizzle-orm' -import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' +import { escapeLikePattern } from '@/lib/api/list-query' +import { + docsPathForSourceDocument, + isDocsDir, + isDocsPage, + normalizeDocsPath, +} from '@/lib/copilot/docs/docs-corpus' import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' const logger = createLogger('DocsSearch') @@ -42,10 +49,9 @@ export interface DocsSearchOutcome { * section in the docs corpus. Surfaced verbatim so the model can correct itself * rather than reading an empty result as "the docs say nothing about this". */ -export class DocsSearchScopeError extends Error { - readonly code = 'DOCS_SEARCH_SCOPE' as const +export class DocsSearchScopeError extends OrchestrationError { constructor(message: string) { - super(message) + super('validation', message) this.name = 'DocsSearchScopeError' } } @@ -66,7 +72,7 @@ export class DocsSearchScopeError extends Error { * discarded as stale. */ function scopeCondition(path?: string) { - const normalized = (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '') + const normalized = normalizeDocsPath(path ?? '') if (normalized === '' || normalized === 'docs') { return and( ne(docsEmbeddings.sourceDocument, 'index.mdx'), @@ -85,7 +91,6 @@ function scopeCondition(path?: string) { const tail = normalized.slice('docs/'.length) if (isDocsPage(normalized)) { - // One page: on disk it is either `.mdx` or `/index.mdx`. const [pageFile, indexFile] = docsSourceCandidates(tail) return or( eq(docsEmbeddings.sourceDocument, pageFile), @@ -94,10 +99,6 @@ function scopeCondition(path?: string) { } if (isDocsDir(normalized)) { - // Everything under the directory, PLUS a sibling `.mdx`. Fumadocs - // accepts either layout for a section overview and only `/index.mdx` - // is inside the subtree, so matching the prefix alone would silently omit - // the overview for the sibling layout — page scope already covers both. return or( like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`), eq(docsEmbeddings.sourceDocument, `${tail}.mdx`) @@ -109,10 +110,6 @@ function scopeCondition(path?: string) { ) } -function escapeLikePattern(value: string): string { - return value.replace(/[\\%_]/g, (char) => `\\${char}`) -} - /** * Clamp a caller-supplied result count into [1, {@link MAX_TOP_K}]. * @@ -149,12 +146,17 @@ export async function searchDocs( const topK = clampTopK(options?.topK) const where = scopeCondition(options?.path) - logger.info('Executing docs search', { query, topK, path: options?.path ?? null }) + logger.info('Executing docs search', { + queryLength: query.length, + topK, + path: options?.path ?? null, + }) const { embedding: queryEmbedding } = await generateSearchEmbedding(query) if (!queryEmbedding || queryEmbedding.length === 0) { return { results: [], candidatesConsidered: 0, droppedBelowThreshold: 0, droppedStale: 0 } } + const queryVector = JSON.stringify(queryEmbedding) const rows = await db .select({ @@ -162,11 +164,11 @@ export async function searchDocs( sourceDocument: docsEmbeddings.sourceDocument, sourceLink: docsEmbeddings.sourceLink, headerText: docsEmbeddings.headerText, - similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${queryVector}::vector)`, }) .from(docsEmbeddings) .where(where) - .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${queryVector}::vector`) .limit(topK) const results: DocsSearchResult[] = [] @@ -184,9 +186,9 @@ export async function searchDocs( } results.push({ path, - url: String(row.sourceLink || '#'), - title: String(row.headerText || 'Untitled Section'), - content: String(row.chunkText || ''), + url: row.sourceLink, + title: row.headerText, + content: row.chunkText, similarity: row.similarity, }) } diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 13e896c2bfc..c068a936569 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -1,9 +1,8 @@ -// AUTO-GENERATED FILE. DO NOT EDIT. -// Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts -// Run: bun run docs-manifest:generate -// - /** + * AUTO-GENERATED FILE. DO NOT EDIT. + * Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts. + * Run: bun run docs-manifest:generate. + * * Every page in the copilot's read-only `docs/` VFS tree, as a path that is * simultaneously the `docs/`-relative VFS path and the docs.sim.ai URL path * (so `docs/workflows/blocks/agent.mdx` reads diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4d0a4f13977..2930c2ca0a3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3117,12 +3117,12 @@ export const Grep: ToolCatalogEntry = { path: { type: 'string', description: - "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees there are rejected for content search. A docs/ page or directory searches live page text — a directory fans out to every docs page under it.", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact supported single-file path searches that file's content; folders and multi-file trees are rejected for content search.", }, pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf, and live docs page text when path is a docs/ page or directory.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default, or an exact supported file leaf's content when path selects one.", }, toolTitle: { type: 'string', @@ -4563,7 +4563,7 @@ export const SearchDocs: ToolCatalogEntry = { 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope', }, query: { type: 'string', description: 'The search query' }, - topK: { type: 'number', description: 'Number of results (default 5, max 25)' }, + topK: { type: 'number', description: 'Number of results (default 5, max 25)', default: 5 }, }, required: ['query'], }, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index c36a3d2bc8b..6ef9d8f2e09 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3000,12 +3000,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees there are rejected for content search. A docs/ page or directory searches live page text — a directory fans out to every docs page under it.", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact supported single-file path searches that file's content; folders and multi-file trees are rejected for content search.", }, pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf, and live docs page text when path is a docs/ page or directory.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default, or an exact supported file leaf's content when path selects one.", }, toolTitle: { type: 'string', @@ -4412,6 +4412,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { topK: { type: 'number', description: 'Number of results (default 5, max 25)', + default: 5, }, }, required: ['query'], diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 37a9c72387b..5a026a33c3a 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -279,7 +279,6 @@ export const TraceAttr = { CopilotVfsFileMediaType: 'copilot.vfs.file.media_type', CopilotVfsFileName: 'copilot.vfs.file.name', CopilotVfsFileSizeBytes: 'copilot.vfs.file.size_bytes', - CopilotVfsGrepDocsPageCount: 'copilot.vfs.grep.docs_page_count', CopilotVfsHasAlpha: 'copilot.vfs.has_alpha', CopilotVfsInputBytes: 'copilot.vfs.input.bytes', CopilotVfsInputHeight: 'copilot.vfs.input.height', @@ -925,7 +924,6 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.vfs.file.media_type', 'copilot.vfs.file.name', 'copilot.vfs.file.size_bytes', - 'copilot.vfs.grep.docs_page_count', 'copilot.vfs.has_alpha', 'copilot.vfs.input.bytes', 'copilot.vfs.input.height', diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 464bb2ccc6b..f858cc57436 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -746,7 +746,7 @@ describe('vfs handlers docs corpus routing', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('greps one docs page or a docs directory without touching the workspace VFS', async () => { + it('greps one docs page and rejects directory scope without touching the workspace VFS', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, @@ -756,16 +756,17 @@ describe('vfs handlers docs corpus routing', () => { const single = await executeVfsGrep({ pattern: 'cron', path: DOCS_PAGE }, GREP_CTX) expect(single.success).toBe(true) - const multi = await executeVfsGrep( + const directory = await executeVfsGrep( { pattern: 'cron', path: 'docs/workflows', maxResults: 10_000 }, GREP_CTX ) - expect(multi.success).toBe(true) - expect(fetchMock.mock.calls.length).toBeGreaterThan(1) + expect(directory.success).toBe(false) + expect(directory.error).toContain('grep must target one docs page') + expect(fetchMock).toHaveBeenCalledOnce() const invalid = await executeVfsGrep({ pattern: 'cron', path: 'docs/not-a-page.mdx' }, GREP_CTX) expect(invalid.success).toBe(false) - expect(invalid.error).toContain('not a docs page or directory') + expect(invalid.error).toContain('not a docs page') expect(getOrMaterializeVFS).not.toHaveBeenCalled() }) @@ -784,6 +785,8 @@ describe('vfs handlers docs corpus routing', () => { const output = result.output as { content: string; totalLines: number } expect(output.totalLines).toBe(totalLines) expect(output.content).toContain('[Page truncated: returned lines 1-') + expect(output.content).toMatch(/offset: \d+ and limit: \d+/) + expect(output.content).toContain('reduce the limit if that window is still too large') expect(JSON.stringify(output).length).toBeLessThanOrEqual(TOOL_RESULT_MAX_INLINE_CHARS) }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index a7b93399238..e46d1f15e31 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -135,23 +135,20 @@ async function canReturnWorkspaceFileValue( } /** - * Trim an oversized docs page to the largest whole-line prefix that fits the + * Trim an oversized docs page to a whole-line prefix that fits the * inline budget, preserving the true `totalLines` so the model can page through * the rest with offset/limit. Returns null when not even one line fits — a * single line longer than the cap — so the caller can fail instead of returning - * an over-cap payload as success. + * an over-cap payload as success. The notice offers grep as an alternative to + * another read because either operation fetches the page once. */ function truncateDocsPageToInlineCap(page: { content: string; totalLines: number }): { output: { content: string; totalLines: number } returnedLines: number } | null { const lines = page.content.split('\n') - // Route to ONE more fetch, not two. Telling the model to grep and then read - // costs two more uncached fetches of a page it already partly has; grep and - // read cost the same single fetch, so grep is an alternative to a read here, - // never a step before one. const notice = (shown: number) => - `\n\n[Page truncated: returned lines 1-${shown} of ${page.totalLines}. To continue, read this path with offset: ${shown}. To jump straight to a section, grep this path INSTEAD of reading it — grep is the same single fetch and returns only matching lines with their numbers.]` + `\n\n[Page truncated: returned lines 1-${shown} of ${page.totalLines}. To continue, read this path with offset: ${shown} and limit: ${shown}; reduce the limit if that window is still too large. To jump straight to a section, grep this path INSTEAD of reading it — grep is the same single fetch and returns only matching lines with their numbers.]` let kept = lines.length while (kept > 0) { @@ -192,14 +189,6 @@ export async function executeVfsGrep( context: (params.context as number) ?? 0, } - // Routing mirrors read/glob: - // - uploads/ -> grep one chat upload's content (chat-scoped) - // - docs/ -> grep one docs.sim.ai page (one page only — each is a fetch) - // - files/ -> grep one workspace file's content (one file only) - // - everything else -> grep the in-memory VFS map (workflow JSON, metadata) - // Chat uploads and the docs corpus are opt-in like recently-deleted/: they are - // never in the VFS map, so an unscoped grep can't touch them — only an explicit - // uploads/ or docs/ path does, and only one at a time. let result: GrepMatch[] | string[] | GrepCountEntry[] let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined if (rawPath !== undefined && isDocsPath(rawPath)) { @@ -298,9 +287,6 @@ export async function executeVfsGlob( } try { - // The docs corpus is a lazy view of docs.sim.ai built from the generated - // manifest, not part of the workspace VFS — an explicit docs/ pattern is the - // only way to see it. if (couldMatchDocsScope(pattern)) { const files = globDocs(pattern) logger.debug('vfs_glob docs result', { pattern, fileCount: files.length }) @@ -375,17 +361,10 @@ export async function executeVfsRead( } } - // Docs pages are fetched from the live docs site on demand — the manifest - // path is the URL path, so there is nothing workspace-scoped to resolve. if (isDocsPath(path)) { const page = await readDocsPage(path) const windowed = applyWindow(page) if (serializedResultSize(windowed) > TOOL_RESULT_MAX_INLINE_CHARS) { - // Several real docs pages (the largest integration references) exceed the - // inline cap, so failing here would make a plain read of them always fail - // and cost a second fetch to recover. Truncate to what fits instead and - // tell the model how to page — but only when it did not ask for a window, - // since an explicit offset/limit that still overflows is a caller error. if (offset !== undefined || limit !== undefined) { return { success: false, @@ -569,8 +548,6 @@ export async function executeVfsRead( output: result, } } catch (err) { - // Expected docs-corpus conditions (unknown page, directory path, site - // unreachable): surface the message verbatim. if (err instanceof DocsCorpusError) { logger.debug('vfs_read docs page rejected', { path, error: err.message }) return { success: false, error: err.message } diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts index 37318f5fc53..1b01c4c8dd5 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DocsSearchOutcome } from '@/lib/copilot/docs/docs-search' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const { mockSearchDocs } = vi.hoisted(() => ({ mockSearchDocs: vi.fn(), @@ -32,6 +33,11 @@ const RESULT = { similarity: 0.9, } +const CONTEXT = { + userId: 'user-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), +} + describe('searchDocsServerTool', () => { beforeEach(() => { mockSearchDocs.mockReset() @@ -40,11 +46,14 @@ describe('searchDocsServerTool', () => { it('forwards query, path, and topK to the search layer', async () => { mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) - const output = await searchDocsServerTool.execute({ - query: 'how do agents work', - path: 'docs/agents.mdx', - topK: 7, - }) + const output = await searchDocsServerTool.execute( + { + query: 'how do agents work', + path: 'docs/agents.mdx', + topK: 7, + }, + CONTEXT + ) expect(mockSearchDocs).toHaveBeenCalledWith('how do agents work', { path: 'docs/agents.mdx', @@ -60,17 +69,27 @@ describe('searchDocsServerTool', () => { it('omits the note when nothing was dropped', async () => { mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) - const output = await searchDocsServerTool.execute({ query: 'q' }) + const output = await searchDocsServerTool.execute({ query: 'q' }, CONTEXT) expect(output.note).toBeUndefined() }) + it('explains when the index returns no candidates', async () => { + mockSearchDocs.mockResolvedValue(outcome({})) + + const output = await searchDocsServerTool.execute({ query: 'brand new feature' }, CONTEXT) + + expect(output.note).toContain('search index may lag') + expect(output.note).toContain('read it directly') + expect(output.note).toContain('glob("docs/**")') + }) + it('explains an empty result set caused by filtering, so it does not read as missing docs', async () => { mockSearchDocs.mockResolvedValue( outcome({ candidatesConsidered: 2, droppedBelowThreshold: 1, droppedStale: 1 }) ) - const output = await searchDocsServerTool.execute({ query: 'q' }) + const output = await searchDocsServerTool.execute({ query: 'q' }, CONTEXT) expect(output.note).toContain('does NOT mean the docs lack this topic') expect(output.note).toContain('1 scored too low') @@ -82,7 +101,7 @@ describe('searchDocsServerTool', () => { outcome({ results: [RESULT], candidatesConsidered: 3, droppedBelowThreshold: 2 }) ) - const output = await searchDocsServerTool.execute({ query: 'q' }) + const output = await searchDocsServerTool.execute({ query: 'q' }, CONTEXT) expect(output.note).toContain('Returned 1 of 3 candidate(s)') expect(output.note).toContain('2 scored too low') @@ -94,9 +113,40 @@ describe('searchDocsServerTool', () => { outcome({ results: [RESULT], candidatesConsidered: 2, droppedStale: 1 }) ) - const output = await searchDocsServerTool.execute({ query: 'q' }) + const output = await searchDocsServerTool.execute({ query: 'q' }, CONTEXT) expect(output.note).toContain('1 point at pages no longer in the docs') expect(output.note).not.toContain('scored too low') }) + + it('projects resolved secrets before embedding or returning the query', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'DOCS_QUERY', + plaintext: 'private docs query', + encryptedValue: 'ciphertext', + }, + ]) + registry.recordResolved('DOCS_QUERY', 'private docs query') + mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) + + const output = await searchDocsServerTool.execute( + { query: 'private docs query' }, + { userId: 'user-1', resolvedSecretTraceRegistry: registry } + ) + + expect(mockSearchDocs).toHaveBeenCalledWith('{{DOCS_QUERY}}', { + path: undefined, + topK: undefined, + }) + expect(output.query).toBe('{{DOCS_QUERY}}') + expect(JSON.stringify(output)).not.toContain('private docs query') + }) + + it('fails closed when secret provenance is unavailable', async () => { + await expect(searchDocsServerTool.execute({ query: 'query' })).rejects.toThrow( + 'Docs search query could not be processed safely' + ) + expect(mockSearchDocs).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts index 96bdc922e2c..a95e3be3a79 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -1,7 +1,9 @@ import type { DocsSearchResult } from '@/lib/copilot/docs/docs-search' import { searchDocs } from '@/lib/copilot/docs/docs-search' import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { ServerToolModelInputError } from '@/lib/copilot/tools/server/model-input' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' interface SearchDocsParams { query: string @@ -28,6 +30,9 @@ interface SearchDocsOutput { */ function shortfallNote(outcome: Awaited>): string | undefined { const { results, candidatesConsidered, droppedBelowThreshold, droppedStale } = outcome + if (results.length === 0 && candidatesConsidered === 0) { + return 'No indexed candidates were returned. The search index may lag the live docs. If you know the page, read it directly; otherwise use glob("docs/**") to find the current path.' + } if (droppedBelowThreshold === 0 && droppedStale === 0) return undefined const reasons: string[] = [] @@ -52,12 +57,20 @@ function shortfallNote(outcome: Awaited>): string */ export const searchDocsServerTool: BaseServerTool = { name: SearchDocs.id, - async execute(params: SearchDocsParams): Promise { - const outcome = await searchDocs(params.query, { path: params.path, topK: params.topK }) + async execute(params: SearchDocsParams, context?: ServerToolContext): Promise { + const queryProjection = projectResolvedSecretModelContent( + params.query, + context?.resolvedSecretTraceRegistry + ) + if (!queryProjection.safe || typeof queryProjection.value !== 'string') { + throw new ServerToolModelInputError('Docs search query could not be processed safely') + } + const query = queryProjection.value + const outcome = await searchDocs(query, { path: params.path, topK: params.topK }) const note = shortfallNote(outcome) return { results: outcome.results, - query: params.query, + query, totalResults: outcome.results.length, ...(note ? { note } : {}), } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 681e555f92d..94cfedd34af 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -83,13 +83,11 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( 'Searching Sim docs for "loop blocks iteration"' ) - // The completed-state flip must keep the suffix, not drop back to the bare label. expect( getToolCompletedTitle( getToolDisplayTitle('search_docs', { query: 'how to read workflow logs' }) ) ).toBe('Searched Sim docs for "how to read workflow logs"') - // A long agent-written query is truncated rather than blowing out the chip. expect( getToolDisplayTitle('search_docs', { query: diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 42c4a8cfd54..f5ff7aaa54c 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -77,7 +77,6 @@ export type ChatContext = } | { kind: 'folder'; folderId: string; label: string } | { kind: 'filefolder'; fileFolderId: string; label: string } - | { kind: 'docs'; label: string } /** * A tab in the desktop browser or terminal panel, dragged into the input to * say "this one". Resource tags remain live pointers; tags created from an diff --git a/docs/ideation/2026-08-03-platform-agent-ideation.html b/docs/ideation/2026-08-03-platform-agent-ideation.html deleted file mode 100644 index cfa784716f5..00000000000 --- a/docs/ideation/2026-08-03-platform-agent-ideation.html +++ /dev/null @@ -1,511 +0,0 @@ - - - - - - Platform agent — ideation - - - -
-
-

Ideation · Platform intelligence

-

Turn the docs agent into a trusted platform operator

-

The strongest direction is not an omniscient agent. It is a source-aware agent that knows the user’s operating context, fetches private state only when needed, explains access and billing in product language, and leaves evidence behind whenever it reads sensitive data.

- - - -
-
30raw candidates
-
12deduped directions
-
6ranked survivors
-
4topic axes covered
-
- - -
- -
-

What the codebase already gives us

-

Grounding Context

-

The branch introduces a dedicated platform child that is intentionally isolated from parent conversation and restricted to documentation search, VFS reads, and response. That isolation is useful, but the runtime already has stronger seams than the prompt admits.

- -
-
-

Trusted request context already exists

-

Child execution carries trusted user/workspace IDs, effective permission, entitlements, timezone, and workspace/session/workflow bootstrap. Human-readable UserMetadata is the notable omission.

-
-
-

Central handlers are the security seam

-

Sim-side tool handlers receive authenticated actor/workspace context and can enforce permission before returning data. Model-supplied IDs do not need to become authority.

-
-
-

Most live data services already exist

-

Billing, permission groups, audit events, execution logs and metrics, and metadata-only subagent invocation records already expose the underlying facts with distinct gates.

-
-
-

Prior art converges on the same split

-

Microsoft, AWS, and Intercom separate ambient identity from permission-trimmed retrieval and persona-specific behavior.

-
-
- -
- - Four source-of-truth layers feeding the platform agent - Injected context, live tools, public docs, and component schemas each answer a different class of question. The platform agent synthesizes them into a scoped answer with provenance. - - - - - - - Injected context - who · where · current role - - - Live Sim tools - private · mutable · scoped - - - Product docs - behavior · limits · UI - - - Component schemas - fields · enums · tool IDs - - - - - - - - Platform agent - chooses authority by question - - - scoped + cited + fresh - - Answer with provenance - -
Directional overview: each source is authoritative for a different kind of fact. The model chooses among them; authorization remains in Sim.
-
-
- -
-

Surface map

-

Topic Axes

-
-

1. Identity and current context

Who is asking, where they are operating, and what request-local context is safe to carry ambiently.

-

2. Access and resource visibility

What the viewer may discover or do, why something is unavailable, and how to avoid resource-existence leaks.

-

3. Plan, billing, and usage

Personal plan, effective coverage, exact workspace payer, usage gates, limits, credits, and management authority.

-

4. Activity, audit, and operational health

What changed, what failed, which evidence source applies, and how private reads become inspectable.

-
-
- -
-

Qualified directions

-

Ranked Ideas

- - -
-
-
1

Idea 1. Context passport + source hierarchy

-
Confidence · 94%Complexity · Low
-
-

Description: Inject a small trusted Current Platform Context block into the child: display name, timezone, workspace name/ID, current workflow or selected resource, effective read|write|admin, broad entitlements, and an asOf value. Rewrite the prompt around four authorities: this passport for orientation, live tools for private or mutable facts, docs for product behavior, and component schemas for exact configuration.

-
-
Axis
Identity and current context
-
Basis
direct: The request already threads trusted workspace, permission, entitlement, timezone, session/workflow bootstrap, and VFS inventory to the child, but not human-readable UserMetadata. The current prompt already distinguishes docs behavior from schema truth, so this adds the missing live-data tier rather than replacing the model.
-
Rationale
It removes repeated disambiguation while creating a crisp rule for stale, conflicting, or private facts. This is the smallest change that makes every later tool safer and easier to use.
-
Downsides
The passport becomes a compatibility contract and must stay deliberately small. Current page/resource context needs careful selection so it does not leak browser state to children unnecessarily.
-
-
- -
-
-
2

Idea 2. Capability/access explainer

-
Confidence · 92%Complexity · Medium
-
-

Description: Add explain_capability(action, resourceType?). It returns available, needs_write, needs_admin, blocked_by_policy, not_entitled, or not_configured, identifies the controlling layer, and gives a safe next step. It never returns names, counts, or existence signals for hidden resources.

-
-
Axis
Access and resource visibility
-
Basis
direct: Sim already combines workspace permission, organization role, permission-group restrictions, integration/model/tool allowlists, and per-viewer feature visibility. Handler-side enforcement and trusted execution context are already the normal boundary.
-
Rationale
This turns “the docs say I can” into “here is whether you can, why, and what legitimate path exists.” It can absorb the useful part of a buildability map without exposing a broad hidden-feature manifest.
-
Downsides
A stable causal vocabulary is product work, not just plumbing. Incorrect denial explanations are worse than a generic denial, so the tool must reuse the same policy decisions as execution rather than reimplementing them.
-
-
- -
-
-
3

Idea 3. Three-lens billing snapshot + run preflight

-
Confidence · 91%Complexity · Medium
-
-

Description: Add one billing tool with explicit lenses: personal_subscription, effective_user_coverage, and current_workspace_payer. Return only decision-ready fields—plan/status, usable/block state, usage and limit, credits, period, management authority, freshness—and an optional operation preflight that reports the first live gate and user-appropriate remediation.

-
-
Personal

What the user personally owns or pays for.

-
Effective

What coverage the user currently receives.

-
Workspace payer

Which billing pool governs work here.

-
-
-
Axis
Plan, billing, and usage
-
Basis
direct: Those three meanings deliberately differ in the billing code. Billing status also differs from product-usable access, and enforcement-grade reads have stronger freshness requirements than display reads.
-
Rationale
A naïve get_plan would encode the wrong product semantics. A lens-based projection answers “what plan am I on?”, “who pays for this?”, and “why is this run blocked?” without exposing raw subscriptions, Stripe identifiers, invoices, or other members’ usage.
-
Downsides
Organizations and personal accounts need different redaction and management guidance. Live preflight may cost more than a replica-backed informational answer, so freshness must be explicit.
-
-
- -
-
-
4

Idea 4. Evidence-routed activity investigator

-
Confidence · 88%Complexity · High
-
-

Description: Add investigate_activity(question, timeRange). It classifies the symptom and queries only the authorized evidence family: execution percentiles for latency, workflow logs for failures, organization audit events for “who changed this?”, and metadata-only subagent invocation records for delegation health. It returns a bounded timeline, saved filters or deep links, truncation/freshness notices, and facts clearly separated from hypotheses.

-
-
Axis
Activity, audit, and operational health
-
Basis
direct: Sim already has each source with separate authorization, filter, pagination, and payload semantics. external: Azure copilots use reviewable queries and deep links rather than becoming a parallel source of truth.
-
Rationale
This is the step-function move: the platform agent becomes a credible first responder for “what changed?” and “why did this fail?” while preserving the authority of existing observability surfaces.
-
Downsides
Joining evidence can create false causality. The first version should route and summarize rather than claim root cause, and enterprise audit access must stay independently gated.
-
-
- -
-
-
5

Idea 5. Sensitive-read receipts

-
Confidence · 87%Complexity · Medium
-
-

Description: Treat read-only billing, audit, member, and execution-data access as sensitive. Every lookup emits a metadata-only receipt containing actor, scope, tool, authorization result, reason or query hash, timestamp, and trace linkage—never the returned private body. The prompt briefly discloses when private records were inspected and offers an inspectable activity link.

-
-
Axis
Activity, audit, and operational health
-
Basis
direct: Sim already records audit metadata and durable subagent-invocation metadata without conversational content. external: AWS and Google log agent-mediated or admin data reads, including dry-run permission checks.
-
Rationale
This is the trust foundation for every private-data tool. It makes agent access governable and answers the security question “what did the agent look at?” without storing sensitive outputs twice.
-
Downsides
Receipts create volume, retention, and user-experience questions. Query hashes and reason fields must avoid becoming a new content-leak channel.
-
-
- -
-
-
6

Idea 6. Persona/access evaluation matrix

-
Confidence · 85%Complexity · Medium
-
-

Description: Evaluate the same platform questions as free/paid, member/admin/owner, billing-manager/non-manager, policy-restricted/unrestricted, and resource-access/no-access personas. Assert the answer, visible tools, denial wording, non-disclosure, citations, freshness labels, and sensitive-read receipts—not only whether a handler returns 200 or 403.

-
-
Axis
Access and resource visibility
-
Basis
external: Intercom tests Fin as real or synthetic users, plans, audiences, and brands while inspecting triggered behavior. direct: Sim’s access semantics span enough independent layers that isolated handler tests cannot validate what the model ultimately says.
-
Rationale
This converts permission awareness from an architectural claim into product behavior that can be regression-tested. It is especially valuable for “must not reveal” cases where a function-level authorization test can pass while the answer leaks context.
-
Downsides
Model-evaluation stability and fixture maintenance are real costs. Start with a small invariant suite around identity, capability denials, billing lenses, and audit authorization.
-
-
Useful invariantThe same question should produce different, correct answers for a member and an admin—without either answer mentioning what the other persona can see.
-
-
- -
-

What did not survive intact

-

Rejection Summary

- - - - - - - - - - - -
#IdeaReason rejected or merged
1Viewer-specific buildability mapThe proposed breadth outran current evidence; its supported capability categories were folded into Idea 2.
2Standalone run-capability preflightStrong but duplicate; merged into the exact-payer billing semantics in Idea 3.
3Usage-driver narrativeReduced public logs do not support detailed workflow attribution without crossing payer-sensitive boundaries.
4Standalone source hierarchyStrong but inseparable from ambient context design; merged into Idea 1.
5Intent-gated private-tool revealRequest-time permission filtering already exists; extra progressive revelation lacked demonstrated value.
6Standalone deep-link behaviorValuable response behavior rather than a product direction; merged into Idea 4.
7Repeated context, access, billing, and incident variantsFive independent lenses converged; duplicates were combined into the strongest source-aware forms above.
-
- -
Composed by ce-ideate from the platform-agent enhancement prompt and the active Sim/Mothership worktrees.
-
- - diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts index 7373a72e26c..35a240bd836 100644 --- a/scripts/sync-docs-manifest.ts +++ b/scripts/sync-docs-manifest.ts @@ -66,12 +66,11 @@ function toDocsPath(mdxPath: string): string | null { function render(paths: string[]): string { const entries = paths.map((path) => ` '${path}',`).join('\n') - return `// AUTO-GENERATED FILE. DO NOT EDIT. -// Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts -// Run: bun run docs-manifest:generate -// - -/** + return `/** + * AUTO-GENERATED FILE. DO NOT EDIT. + * Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts. + * Run: bun run docs-manifest:generate. + * * Every page in the copilot's read-only \`docs/\` VFS tree, as a path that is * simultaneously the \`docs/\`-relative VFS path and the docs.sim.ai URL path * (so \`docs/workflows/blocks/agent.mdx\` reads From 4805cf3684fc4eb663155416fb29049305fab410 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:18:46 -0700 Subject: [PATCH 033/103] improvement(copilot): preserve docs path casing in read labels --- .../lib/copilot/tools/client/store-utils.test.ts | 8 ++++---- apps/sim/lib/copilot/tools/client/store-utils.ts | 14 +++++--------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index fa6de4c0b14..de756b58182 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -49,24 +49,24 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) - it('formats docs corpus reads as Section/page', () => { + it('formats docs corpus reads as section/page', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'docs/workflows/blocks/agent.mdx', })?.text - ).toBe('Read Workflows/agent') + ).toBe('Read workflows/agent') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { path: 'docs/integrations/gmail.mdx', })?.text - ).toBe('Reading Integrations/gmail') + ).toBe('Reading integrations/gmail') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { path: 'docs/getting-started.mdx', })?.text - ).toBe('Attempted to read Getting-started') + ).toBe('Attempted to read getting-started') }) it('decodes percent-encoded VFS path segments for display', () => { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 1a448d75e5d..5240d0629e6 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -145,20 +145,16 @@ function describeFileReadTarget(segments: string[]): string { } /** - * Labels a docs/ corpus read as `
/` (e.g. `Workflows/agent` for - * docs/workflows/blocks/agent.mdx). Top-level pages show just their capitalized - * name (e.g. `Getting-started` for docs/getting-started.mdx). + * Labels a docs/ corpus read as `
/` (e.g. `workflows/agent` for + * docs/workflows/blocks/agent.mdx). Top-level pages show just their name (e.g. + * `getting-started` for docs/getting-started.mdx). */ function describeDocsReadTarget(segments: string[]): string { const rest = segments.slice(1) if (rest.length === 0) return 'docs' const leaf = stripExtension(rest[rest.length - 1]) - if (rest.length === 1) return capitalizeFirst(leaf) - return `${capitalizeFirst(rest[0])}/${leaf}` -} - -function capitalizeFirst(value: string): string { - return value.charAt(0).toUpperCase() + value.slice(1) + if (rest.length === 1) return leaf + return `${rest[0]}/${leaf}` } function getLeafResourceSegment(segments: string[]): string { From a16a5933ecd807beb8a105fd68cd0819be769ffb Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:43:43 -0700 Subject: [PATCH 034/103] improvement(copilot): keep @Docs on search_docs --- .../app/api/mothership/execute/route.test.ts | 14 ++- apps/sim/app/api/mothership/execute/route.ts | 22 +++-- .../chat-context-kind-registry.tsx | 1 + .../components/chip-clipboard-codec.ts | 4 +- .../user-input/components/constants.ts | 6 +- .../prompt-editor/use-prompt-editor.ts | 4 +- .../components/resource-context.test.ts | 4 - .../[workspaceId]/home/hooks/use-chat.ts | 2 + .../components/user-input/constants.ts | 17 +++- .../hooks/use-mention-insert-handlers.ts | 34 ++++++- .../user-input/hooks/use-mention-keyboard.ts | 33 ++++++- .../user-input/hooks/use-mention-menu.ts | 2 +- .../copilot/components/user-input/utils.ts | 2 + .../lib/copilot/chat/display-message.test.ts | 6 +- .../copilot/chat/persisted-message.test.ts | 6 +- apps/sim/lib/copilot/chat/post.ts | 22 ++++- .../lib/copilot/chat/process-contents.test.ts | 98 ++++++++++++++++++- apps/sim/lib/copilot/chat/process-contents.ts | 78 ++++++++++++++- apps/sim/stores/panel/types.ts | 1 + 19 files changed, 313 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 513c4ea3de4..43893921a35 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -224,7 +224,7 @@ describe('mothership private trace provenance transport', () => { { ...requestBody, messages: [{ role: 'user', content: 'secret-value __var_FOREIGN' }], - contexts: [{ kind: 'knowledge', knowledgeId: 'knowledge-1', label: 'Docs' }], + contexts: [{ kind: 'docs', label: 'Docs' }], }, { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, 'http://localhost:3000/api/mothership/execute' @@ -235,9 +235,15 @@ describe('mothership private trace provenance transport', () => { expect(mockProcessContextsServer).toHaveBeenCalledWith( expect.any(Array), 'user-1', + 'secret-value __var_FOREIGN', 'workspace-1', - 'chat-1' + 'chat-1', + expect.any(Object) ) + + const contextRegistry = mockProcessContextsServer.mock.calls.at(-1)?.[5] + const lifecycleOptions = mockRunHeadlessCopilotLifecycle.mock.calls.at(-1)?.[1] + expect(contextRegistry).toBe(lifecycleOptions.environmentContext?.resolvedSecretTraceRegistry) }) it('keeps context routing and display inputs raw until the lifecycle boundary', async () => { @@ -287,8 +293,10 @@ describe('mothership private trace provenance transport', () => { }, ], 'user-1', + 'hello', 'workspace-1', - 'chat-1' + 'chat-1', + expect.any(Object) ) }) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index fc583885765..e01f727f591 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -211,6 +211,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workflowId, executionId, }) + const lastUserMessage = messages.filter((message) => message.role === 'user').at(-1)?.content // double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime const agentMentions = contexts as unknown as ChatContext[] | undefined const taggedMcpServerIds = (agentMentions ?? []).flatMap((context) => @@ -238,14 +239,19 @@ export const POST = withRouteHandler(async (req: NextRequest) => { buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), mothershipToolsPromise, computeWorkspaceEntitlements(workspaceId, userId), - processContextsServer(nonMcpAgentMentions, userId, workspaceId, effectiveChatId).catch( - (error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - } - ), + processContextsServer( + nonMcpAgentMentions, + userId, + lastUserMessage, + workspaceId, + effectiveChatId, + activeResolvedSecretTraceRegistry + ).catch((error) => { + reqLogger.warn('Failed to resolve agent contexts for execution', { + error: toError(error).message, + }) + return [] + }), ]) const requestPayload: Record = { messages, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index 1d6d2ad210f..1a251e0bc4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -110,6 +110,7 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, + docs: { label: 'Docs', renderIcon: () => null }, slash_command: { label: 'Command', renderIcon: () => null }, integration: { label: 'Integration', renderIcon: renderIntegrationTile }, skill: { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 378092fac2e..73eb6eff658 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -19,8 +19,8 @@ const CHIP_LINK_SCHEME = 'sim' * string>>` keeps it union-synced: rename a kind's id field and this stops * type-checking. * - * Kinds absent from this map have no portable single-id representation and - * degrade to plain text. + * Excluded kinds (`current_workflow`, `blocks`, `workflow_block`, `docs`) carry + * no single portable id (an array / two ids / none) and degrade to plain text. */ const PORTABLE_KIND_TO_ID_FIELD = { table: 'tableId', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index a0ffd98df46..f24b1890ee7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -113,7 +113,7 @@ export const SPEECH_RECOGNITION_LANG = 'en-US' // inner tab. The singleton ids ask the agent to inspect the whole resource; // every other id is a precise live-tab pointer. const RESOURCE_TO_CONTEXT: Record< - Exclude, + MothershipResourceType, (resource: MothershipResource) => ChatContext > = { browser: (r) => ({ kind: 'browser_tab', tabId: r.id, label: r.title }), @@ -127,9 +127,9 @@ const RESOURCE_TO_CONTEXT: Record< task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }), log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }), integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }), + generic: (r) => ({ kind: 'docs', label: r.title }), } -export function mapResourceToContext(resource: MothershipResource): ChatContext | null { - if (resource.type === 'generic') return null +export function mapResourceToContext(resource: MothershipResource): ChatContext { return RESOURCE_TO_CONTEXT[resource.type](resource) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 74f25c7f237..f86f69f24a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -407,9 +407,6 @@ export function usePromptEditor({ const insertResource = useCallback( (resource: MothershipResource) => { - const context = mapResourceToContext(resource) - if (!context) return - const textarea = textareaRef.current if (textarea) { const currentValue = valueRef.current @@ -445,6 +442,7 @@ export function usePromptEditor({ setValueState(newValue) } + const context = mapResourceToContext(resource) addContextNotified(context) }, [textareaRef, addContextNotified] diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts index 3441bd0ed61..47e0216319f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts @@ -47,8 +47,4 @@ describe('mapResourceToContext', () => { label: 'Leads', }) }) - - it('does not turn a synthetic panel into a chat context', () => { - expect(mapResourceToContext(resource({ type: 'generic', title: 'Results' }))).toBeNull() - }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 26cc3ca9fb3..acbea0bd61f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -458,6 +458,8 @@ function isChatContext(value: unknown): value is ChatContext { return typeof value.folderId === 'string' case 'filefolder': return typeof value.fileFolderId === 'string' + case 'docs': + return true case 'slash_command': return typeof value.command === 'string' case 'integration': diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index 7860b326ae7..d9cdf9702ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -12,6 +12,11 @@ export type MentionFolderId = | 'logs' | 'integrations' +/** + * Menu item category types for mention menu (includes folders + docs item) + */ +export type MentionCategory = MentionFolderId | 'docs' + /** * Configuration interface for folder types */ @@ -179,9 +184,17 @@ export const FOLDER_ORDER: MentionFolderId[] = [ ] /** - * Total number of items in the root menu. + * Docs item configuration (special case - not a folder) + */ +export const DOCS_CONFIG = { + getLabel: () => 'Docs', + buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }), +} as const + +/** + * Total number of items in root menu (folders + docs) */ -export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length +export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1 /** * Slash command configuration diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts index 8a67a524458..75eb4f7ec50 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from 'react' import { + DOCS_CONFIG, FOLDER_CONFIGS, type FolderConfig, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' @@ -88,6 +89,36 @@ export function useMentionInsertHandlers({ ] ) + /** + * Special handler for Docs (no item parameter, uses DOCS_CONFIG) + */ + const insertDocsMention = useCallback(() => { + const label = DOCS_CONFIG.getLabel() + const context = DOCS_CONFIG.buildContext() + + // Prevent duplicate insertion + if (isContextAlreadySelected(context, selectedContexts)) { + resetActiveMentionQuery() + closeMenus() + return + } + + // Docs uses fallback insertion + if (!replaceActiveMentionWith(label)) { + insertAtCursor(` @${label} `) + } + + onContextAdd(context) + closeMenus() + }, [ + selectedContexts, + replaceActiveMentionWith, + insertAtCursor, + onContextAdd, + resetActiveMentionQuery, + closeMenus, + ]) + const handlers = useMemo( () => ({ insertPastChatMention: createInsertHandler(FOLDER_CONFIGS.chats), @@ -97,8 +128,9 @@ export function useMentionInsertHandlers({ insertWorkflowBlockMention: createInsertHandler(FOLDER_CONFIGS['workflow-blocks']), insertLogMention: createInsertHandler(FOLDER_CONFIGS.logs), insertIntegrationMention: createInsertHandler(FOLDER_CONFIGS.integrations), + insertDocsMention, }), - [createInsertHandler] + [createInsertHandler, insertDocsMention] ) return handlers diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts index 1c7c5d9a5d7..8ab898483ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts @@ -29,6 +29,7 @@ interface UseMentionKeyboardProps { insertWorkflowBlockMention: (blk: any) => void insertLogMention: (log: any) => void insertIntegrationMention: (integration: any) => void + insertDocsMention: () => void } /** Folder navigation state exposed from MentionMenu via callback */ mentionFolderNav: MentionFolderNav | null @@ -113,9 +114,9 @@ export function useMentionKeyboard({ * Build aggregated list matching the portal's ordering */ const buildAggregatedList = useCallback( - (query: string): Array<{ type: MentionFolderId; value: any }> => { + (query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => { const q = query.toLowerCase() - const result: Array<{ type: MentionFolderId; value: any }> = [] + const result: Array<{ type: MentionFolderId | 'docs'; value: any }> = [] for (const folderId of FOLDER_ORDER) { const filtered = filterFolderItems(folderId, q) @@ -124,6 +125,10 @@ export function useMentionKeyboard({ }) } + if ('docs'.includes(q)) { + result.push({ type: 'docs', value: null }) + } + return result }, [filterFolderItems] @@ -210,6 +215,13 @@ export function useMentionKeyboard({ e.preventDefault() + const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length + if (isDocsSelected) { + resetActiveMentionQuery() + insertHandlers.insertDocsMention() + return true + } + const selectedFolderId = FOLDER_ORDER[mentionActiveIndex] if (selectedFolderId) { const config = FOLDER_CONFIGS[selectedFolderId] @@ -230,6 +242,7 @@ export function useMentionKeyboard({ resetActiveMentionQuery, setSubmenuQueryStart, ensureFolderLoaded, + insertHandlers, ] ) @@ -270,8 +283,12 @@ export function useMentionKeyboard({ const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1)) const chosen = aggregated[idx] if (chosen) { - const handler = insertHandlerMap[chosen.type] - handler(chosen.value) + if (chosen.type === 'docs') { + insertHandlers.insertDocsMention() + } else { + const handler = insertHandlerMap[chosen.type] + handler(chosen.value) + } } return true } @@ -289,6 +306,13 @@ export function useMentionKeyboard({ return true } + const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length + if (isDocsSelected) { + resetActiveMentionQuery() + insertHandlers.insertDocsMention() + return true + } + const selectedFolderId = FOLDER_ORDER[mentionActiveIndex] if (selectedFolderId && mentionFolderNav) { const config = FOLDER_CONFIGS[selectedFolderId] @@ -318,6 +342,7 @@ export function useMentionKeyboard({ setSubmenuActiveIndex, setSubmenuQueryStart, ensureFolderLoaded, + insertHandlers, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts index fd9d826c6cf..3e9a390f5ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu.ts @@ -229,7 +229,7 @@ export function useMentionMenu({ /** * Inserts text at the current cursor position * - * @param text - Text to insert at the current cursor position + * @param text - Text to insert (e.g., " @Docs ") */ const insertAtCursor = useCallback( (text: string) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index c1e87d5a5ab..3e8c4d8be5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -305,6 +305,8 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean const ctx = context as IntegrationContext return c.blockType === ctx.blockType } + case 'docs': + return true // Only one docs context allowed case 'slash_command': { const ctx = context as SlashCommandContext return c.command === ctx.command diff --git a/apps/sim/lib/copilot/chat/display-message.test.ts b/apps/sim/lib/copilot/chat/display-message.test.ts index 907dd2e650e..e70a32314a4 100644 --- a/apps/sim/lib/copilot/chat/display-message.test.ts +++ b/apps/sim/lib/copilot/chat/display-message.test.ts @@ -150,12 +150,12 @@ describe('display-message', () => { const display = toDisplayMessage({ id: 'msg-selection', role: 'user', - content: '@Guide @Terminal', + content: '@Docs @Terminal', timestamp: '2024-01-01T00:00:00.000Z', contexts: [ { kind: 'browser_tab', - label: 'Guide', + label: 'Docs', tabId: 'tab-1', selection: { text: 'Selected browser text', @@ -179,7 +179,7 @@ describe('display-message', () => { expect(display.contexts).toEqual([ { kind: 'browser_tab', - label: 'Guide', + label: 'Docs', tabId: 'tab-1', selection: { text: 'Selected browser text', diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index fe2ffff91c7..304a9dcfce7 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -280,11 +280,11 @@ describe('persisted-message', () => { it('round-trips browser and terminal selection snapshots', () => { const persisted = buildPersistedUserMessage({ id: 'user-selection', - content: '@Guide @Terminal', + content: '@Docs @Terminal', contexts: [ { kind: 'browser_tab', - label: 'Guide', + label: 'Docs', tabId: 'tab-1', selection: { text: 'Selected browser text', @@ -310,7 +310,7 @@ describe('persisted-message', () => { expect(normalized.contexts).toEqual([ { kind: 'browser_tab', - label: 'Guide', + label: 'Docs', tabId: 'tab-1', selection: { text: 'Selected browser text', diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index d696cae7133..710ea9ad544 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -205,6 +205,7 @@ const ChatContextSchema = z 'logs', 'workflow_block', 'knowledge', + 'docs', 'table', 'table_selection', 'file', @@ -457,11 +458,22 @@ async function resolveAgentContexts(params: { contexts?: UnifiedChatRequest['contexts'] resourceAttachments?: UnifiedChatRequest['resourceAttachments'] userId: string + message: string workspaceId?: string chatId?: string + resolvedSecretTraceRegistry?: ExecutionContext['resolvedSecretTraceRegistry'] requestId: string }): Promise> { - const { contexts, resourceAttachments, userId, workspaceId, chatId, requestId } = params + const { + contexts, + resourceAttachments, + userId, + message, + workspaceId, + chatId, + resolvedSecretTraceRegistry, + requestId, + } = params let agentContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = [] @@ -470,8 +482,10 @@ async function resolveAgentContexts(params: { agentContexts = await processContextsServer( contexts as ChatContext[], userId, + message, workspaceId, - chatId + chatId, + resolvedSecretTraceRegistry ) } catch (error) { logger.error(`[${requestId}] Failed to process contexts`, error) @@ -1264,7 +1278,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { }), activeOtelRoot.context ) - const agentContextsPromise = executionContextPromise.then(() => { + const agentContextsPromise = executionContextPromise.then((executionContext) => { return withCopilotSpan( TraceSpan.CopilotChatResolveAgentContexts, { @@ -1276,8 +1290,10 @@ export async function handleUnifiedChatPost(req: NextRequest) { contexts: normalizedContexts, resourceAttachments: body.resourceAttachments, userId: authenticatedUserId, + message: body.message, workspaceId, chatId: actualChatId, + resolvedSecretTraceRegistry: executionContext.resolvedSecretTraceRegistry, requestId, }), activeOtelRoot.context diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 03d0b0d4706..73bc3692ea6 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -10,6 +10,7 @@ import { MAX_TABLE_SELECTION_ROWS, } from '@/lib/copilot/chat/selection-context' import { DelegatedWorkspaceAuthorizationError } from '@/lib/core/application' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { ChatContext } from '@/stores/panel' const { @@ -25,6 +26,7 @@ const { readKnowledgeBase, getBlockVisibilityForCopilot, isIntegrationDeploymentAvailable, + searchDocsExecute, } = vi.hoisted(() => ({ discoverServerTools: vi.fn(), getBlock: vi.fn(), @@ -38,6 +40,7 @@ const { readKnowledgeBase: vi.fn(), getBlockVisibilityForCopilot: vi.fn(async () => null), isIntegrationDeploymentAvailable: vi.fn(() => true), + searchDocsExecute: vi.fn(), })) vi.mock('@/blocks/registry', () => ({ getBlock, getBlockRegistry })) @@ -57,6 +60,9 @@ vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ readKnowledgeBase: { execute: readKnowledgeBase }, })) +vi.mock('@/lib/copilot/tools/server/docs/search-docs', () => ({ + searchDocsServerTool: { execute: searchDocsExecute }, +})) /** * Overrides the global `@sim/db` mock: the logs-context tests below need @@ -75,8 +81,9 @@ describe('processContextsServer - knowledge contexts', () => { it('reads through the fixed application query with a trusted chat principal', async () => { const result = await processContextsServer( - [{ kind: 'knowledge', knowledgeId: 'knowledge-1', label: 'Product KB' } as ChatContext], + [{ kind: 'knowledge', knowledgeId: 'knowledge-1', label: 'Docs' } as ChatContext], 'dual-workspace-user', + 'hello', 'workspace-a', 'chat-1' ) @@ -97,7 +104,7 @@ describe('processContextsServer - knowledge contexts', () => { expect(result).toEqual([ { type: 'knowledge', - tag: '@Product KB', + tag: '@Docs', content: '', path: 'knowledgebases/Product%20docs/meta.json', }, @@ -111,6 +118,7 @@ describe('processContextsServer - knowledge contexts', () => { processContextsServer( [{ kind: 'knowledge', knowledgeId: 'knowledge-b', label: 'Hidden' } as ChatContext], 'dual-workspace-user', + 'hello', 'workspace-a', 'chat-1' ) @@ -124,6 +132,7 @@ describe('processContextsServer - knowledge contexts', () => { processContextsServer( [{ kind: 'knowledge', knowledgeId: 'knowledge-b', label: 'Hidden' } as ChatContext], 'dual-workspace-user', + 'hello', 'workspace-a', 'chat-1' ) @@ -156,6 +165,7 @@ describe('processContextsServer - block contexts', () => { { kind: 'blocks', blockIds: ['notion'], label: 'Notion' } as ChatContext, ], 'user-1', + 'hello', 'workspace-1' ) @@ -186,6 +196,7 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId: 'sk-1', label: 'My Skill — PostHog' } as ChatContext], 'user-1', + 'hello', 'ws-1' ) @@ -212,6 +223,7 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId, label: 'Skill' } as ChatContext], 'user-1', + 'hello', 'ws-1' ) @@ -234,6 +246,7 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId: 'missing', label: 'x' } as ChatContext], 'user-1', + 'hello', 'ws-1' ) @@ -244,6 +257,7 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId: 'sk-1', label: 'x' } as ChatContext], 'user-1', + 'hello', undefined ) @@ -258,6 +272,7 @@ describe('processContextsServer - skill contexts', () => { const result = await processContextsServer( [{ kind: 'skill', skillId, label: 'Skill 1' } as ChatContext], 'user-1', + 'hello', 'ws-1' ) @@ -273,6 +288,70 @@ describe('processContextsServer - skill contexts', () => { }) }) +describe('processContextsServer - docs contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes @Docs to an unscoped search_docs query', async () => { + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() + const results = [ + { + path: 'docs/workflows/loops.mdx', + url: 'https://docs.sim.ai/workflows/loops', + title: 'Loops', + content: 'Use a loop block to iterate.', + similarity: 0.9, + }, + ] + searchDocsExecute.mockResolvedValue({ results, query: 'how do loops work?', totalResults: 1 }) + + const result = await processContextsServer( + [{ kind: 'docs', label: 'Docs' }], + 'user-1', + '@Docs how do loops work?', + 'ws-1', + undefined, + resolvedSecretTraceRegistry + ) + + expect(searchDocsExecute).toHaveBeenCalledWith( + { query: 'how do loops work?' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: undefined, + resolvedSecretTraceRegistry, + } + ) + expect(result).toEqual([ + { + type: 'docs', + tag: '@Docs', + content: JSON.stringify(results), + }, + ]) + }) + + it('uses the Docs label when the message only contains the mention', async () => { + searchDocsExecute.mockResolvedValue({ results: [], query: 'Docs', totalResults: 0 }) + + await processContextsServer( + [{ kind: 'docs', label: 'Docs' }], + 'user-1', + '@Docs', + 'ws-1', + 'chat-1', + new ResolvedSecretTraceRegistry() + ) + + expect(searchDocsExecute).toHaveBeenCalledWith( + { query: 'Docs' }, + expect.objectContaining({ workspaceId: 'ws-1', chatId: 'chat-1' }) + ) + }) +}) + describe('processContextsServer - MCP contexts', () => { beforeEach(() => { vi.clearAllMocks() @@ -292,6 +371,7 @@ describe('processContextsServer - MCP contexts', () => { const result = await processContextsServer( [{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' }], 'user-1', + '/Docs find auth docs', 'ws-1' ) @@ -452,6 +532,7 @@ describe('processContextsServer - logs contexts', () => { const result = await processContextsServer( [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], 'user-1', + 'hello', 'ws-1' ) @@ -519,6 +600,7 @@ describe('processContextsServer - logs contexts', () => { const result = await processContextsServer( [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], 'user-1', + 'hello', 'ws-1' ) @@ -552,6 +634,7 @@ describe('processContextsServer - logs contexts', () => { const result = await processContextsServer( [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], 'user-1', + 'hello', 'ws-1' ) @@ -581,6 +664,7 @@ describe('processContextsServer - logs contexts', () => { const result = await processContextsServer( [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], 'user-1', + 'hello', 'ws-1' ) @@ -615,6 +699,7 @@ describe('processContextsServer - file_selection contexts', () => { } as ChatContext, ], 'user-1', + 'explain this', 'ws-1' ) @@ -641,6 +726,7 @@ describe('processContextsServer - file_selection contexts', () => { } as ChatContext, ], 'user-1', + 'hello', 'ws-1' ) @@ -663,6 +749,7 @@ describe('processContextsServer - file_selection contexts', () => { } as ChatContext, ], 'user-1', + 'explain', 'ws-1' ) @@ -709,6 +796,7 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', + 'summarize', 'ws-1' ) @@ -741,6 +829,7 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', + 'hello', 'ws-1' ) @@ -768,6 +857,7 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', + 'summarize', 'ws-1' ) @@ -802,6 +892,7 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', + 'summarize', 'ws-1' ) @@ -842,6 +933,7 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', + 'summarize', 'ws-1' ) @@ -879,6 +971,7 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', + 'summarize', 'ws-1' ) @@ -907,6 +1000,7 @@ describe('processContextsServer - table_selection contexts', () => { } as ChatContext, ], 'user-1', + 'summarize', 'ws-1' ) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 2d7a47d63ab..0f6dcf00ecc 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -48,6 +48,8 @@ import { listFolders } from '@/lib/workflows/utils' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { escapeRegExp } from '@/executor/constants' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel' type AgentContextType = @@ -62,6 +64,7 @@ type AgentContextType = | 'file' | 'file_selection' | 'workflow_block' + | 'docs' | 'folder' | 'filefolder' | 'active_resource' @@ -120,8 +123,10 @@ function formatTerminalSelection(selection: TerminalTextSelection): string { export async function processContextsServer( contexts: ChatContext[] | undefined, userId: string, + userMessage?: string, currentWorkspaceId?: string, - chatId?: string + chatId?: string, + resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry ): Promise { if (!Array.isArray(contexts) || contexts.length === 0) return [] const tasks = contexts.map(async (ctx) => { @@ -309,6 +314,30 @@ export async function processContextsServer( path: result.path, } } + if (ctx.kind === 'docs') { + try { + const { searchDocsServerTool } = await import( + '@/lib/copilot/tools/server/docs/search-docs' + ) + const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' + const query = + sanitizeMessageForDocs(rawQuery, contexts) || ctx.label || 'Sim documentation' + const res = await searchDocsServerTool.execute( + { query }, + { + userId, + workspaceId: currentWorkspaceId, + chatId, + resolvedSecretTraceRegistry, + } + ) + const content = JSON.stringify(res?.results || []) + return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } + } catch (e) { + logger.error('Failed to process docs context', e) + return null + } + } return null } catch (error) { logger.error('Failed processing context (server)', { ctx, error }) @@ -330,6 +359,53 @@ export async function processContextsServer( return filtered } +function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string { + if (!rawMessage) return '' + if (!Array.isArray(contexts) || contexts.length === 0) { + // No context mapping; conservatively strip all @mentions-like tokens + const stripped = rawMessage + .replace(/(^|\s)@([^\s]+)/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim() + return stripped + } + + // Gather labels by kind + const blockLabels = new Set( + contexts + .filter((c) => c.kind === 'blocks') + .map((c) => c.label) + .filter((l): l is string => typeof l === 'string' && l.length > 0) + ) + const nonBlockLabels = new Set( + contexts + .filter((c) => c.kind !== 'blocks') + .map((c) => c.label) + .filter((l): l is string => typeof l === 'string' && l.length > 0) + ) + + let result = rawMessage + + // 1) Remove all non-block mentions entirely + for (const label of nonBlockLabels) { + const pattern = new RegExp(`(^|\\s)@${escapeRegExp(label)}(?!\\S)`, 'g') + result = result.replace(pattern, ' ') + } + + // 2) For block mentions, strip the '@' but keep the block name + for (const label of blockLabels) { + const pattern = new RegExp(`@${escapeRegExp(label)}(?!\\S)`, 'g') + result = result.replace(pattern, label) + } + + // 3) Remove any remaining @mentions (unknown or not in contexts) + result = result.replace(/(^|\s)@([^\s]+)/g, ' ') + + // Normalize whitespace + result = result.replace(/\s{2,}/g, ' ').trim() + return result +} + async function processSkillFromDb( skillId: string, workspaceId: string, diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index f5ff7aaa54c..42c4a8cfd54 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -77,6 +77,7 @@ export type ChatContext = } | { kind: 'folder'; folderId: string; label: string } | { kind: 'filefolder'; fileFolderId: string; label: string } + | { kind: 'docs'; label: string } /** * A tab in the desktop browser or terminal panel, dragged into the input to * say "this one". Resource tags remain live pointers; tags created from an From 2d020432b797a09851fcabf4e5a4e0195bdee15d Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:51:35 -0700 Subject: [PATCH 035/103] fix(copilot): preserve docs search guidance (#6389) --- .../lib/copilot/chat/process-contents.test.ts | 25 ++++++++++++++++++- apps/sim/lib/copilot/chat/process-contents.ts | 5 +++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 73bc3692ea6..0f0c7593d0e 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -328,7 +328,30 @@ describe('processContextsServer - docs contexts', () => { { type: 'docs', tag: '@Docs', - content: JSON.stringify(results), + content: JSON.stringify({ results }), + }, + ]) + }) + + it('preserves the search note when @Docs has no relevant matches', async () => { + const note = + 'No relevant matches. This does NOT mean the docs lack this topic. Rephrase the query.' + searchDocsExecute.mockResolvedValue({ results: [], query: 'new topic', totalResults: 0, note }) + + const result = await processContextsServer( + [{ kind: 'docs', label: 'Docs' }], + 'user-1', + '@Docs new topic', + 'ws-1', + undefined, + new ResolvedSecretTraceRegistry() + ) + + expect(result).toEqual([ + { + type: 'docs', + tag: '@Docs', + content: JSON.stringify({ results: [], note }), }, ]) }) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 0f6dcf00ecc..73db8db8db0 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -331,7 +331,10 @@ export async function processContextsServer( resolvedSecretTraceRegistry, } ) - const content = JSON.stringify(res?.results || []) + const content = JSON.stringify({ + results: res?.results || [], + ...(res?.note ? { note: res.note } : {}), + }) return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } } catch (e) { logger.error('Failed to process docs context', e) From 935edc4c2af72adb584dca379e099fc8d51ae973 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:58:14 -0700 Subject: [PATCH 036/103] docs(copilot): document VFS grep routing --- apps/sim/lib/copilot/tools/handlers/vfs.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index e46d1f15e31..af6568e25bd 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -163,6 +163,13 @@ function truncateDocsPageToInlineCap(page: { content: string; totalLines: number return null } +/** + * Routes grep by content source. `docs/` uses one network-backed docs + * page; `uploads/` uses one chat-scoped upload; workspace file paths use + * one authorized file; all remaining paths use the materialized in-memory VFS. + * External and dynamic file contents are therefore opt-in and single-target, + * while an unscoped grep searches only static VFS resources and metadata. + */ export async function executeVfsGrep( params: Record, context: ExecutionContext From a82622f66593cce0ad59a0962817b31aa9d51596 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:54:46 -0700 Subject: [PATCH 037/103] fix(copilot): harden docs context retrieval --- apps/sim/lib/copilot/chat/post.test.ts | 6 +- .../lib/copilot/chat/process-contents.test.ts | 28 ++++++++ apps/sim/lib/copilot/chat/process-contents.ts | 9 ++- apps/sim/lib/copilot/docs/docs-corpus.test.ts | 70 +++++++++++++++---- apps/sim/lib/copilot/docs/docs-corpus.ts | 65 +++++++++++++---- .../lib/copilot/tools/handlers/vfs.test.ts | 24 ++++++- apps/sim/lib/copilot/tools/handlers/vfs.ts | 4 +- .../copilot/tools/server/docs/search-docs.ts | 5 +- 8 files changed, 177 insertions(+), 34 deletions(-) diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 4f8efd52029..f7f71a24733 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -384,7 +384,8 @@ describe('handleUnifiedChatPost', () => { 'user-1', 'Hello', 'ws-1', - expect.anything() + expect.anything(), + expect.any(ResolvedSecretTraceRegistry) ) }) @@ -448,7 +449,8 @@ describe('handleUnifiedChatPost', () => { 'user-1', 'Explain these selections', 'ws-1', - 'chat-1' + 'chat-1', + expect.any(ResolvedSecretTraceRegistry) ) }) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 0f0c7593d0e..3e29c8b4046 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -373,6 +373,34 @@ describe('processContextsServer - docs contexts', () => { expect.objectContaining({ workspaceId: 'ws-1', chatId: 'chat-1' }) ) }) + + it('preserves an explicit unavailable note when docs search fails', async () => { + searchDocsExecute.mockRejectedValue(new Error('embedding service unavailable')) + + const result = await processContextsServer( + [{ kind: 'docs', label: 'Docs' }], + 'user-1', + '@Docs explain schedules', + 'ws-1', + 'chat-1', + new ResolvedSecretTraceRegistry() + ) + + expect(result).toEqual([ + { + type: 'docs', + tag: '@Docs', + content: JSON.stringify({ + results: [], + note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry search_docs or browse docs/** later.', + }), + }, + ]) + expect(mockProcessContentsLogger.error).toHaveBeenCalledWith( + 'Failed to process docs context', + expect.any(Error) + ) + }) }) describe('processContextsServer - MCP contexts', () => { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 73db8db8db0..3ee1367159b 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -338,7 +338,14 @@ export async function processContextsServer( return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } } catch (e) { logger.error('Failed to process docs context', e) - return null + return { + type: 'docs', + tag: ctx.label ? `@${ctx.label}` : '@', + content: JSON.stringify({ + results: [], + note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry search_docs or browse docs/** later.', + }), + } } } return null diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts index e1222c9ab36..54764c35625 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -3,8 +3,12 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { mockSleep } = vi.hoisted(() => ({ + mockSleep: vi.fn(() => Promise.resolve()), +})) + vi.mock('@sim/utils/helpers', () => ({ - sleep: vi.fn(() => Promise.resolve()), + sleep: mockSleep, })) import { @@ -19,6 +23,15 @@ import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx') +function fetchResponse(status: number, content = '', headers: HeadersInit = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + text: async () => content, + } +} + describe('docs corpus scoping', () => { it('recognizes docs paths', () => { expect(isDocsPath('docs/workflows.mdx')).toBe(true) @@ -75,6 +88,8 @@ describe('readDocsPage', () => { beforeEach(() => { fetchMock.mockReset() + mockSleep.mockReset() + mockSleep.mockResolvedValue(undefined) vi.stubGlobal('fetch', fetchMock) }) @@ -84,7 +99,7 @@ describe('readDocsPage', () => { it('fetches the manifest path verbatim from the docs site', async () => { expect(SAMPLE_PAGE).toBeDefined() - fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '# Agent\n\nbody' }) + fetchMock.mockResolvedValue(fetchResponse(200, '# Agent\n\nbody')) const page = await readDocsPage(`docs/${SAMPLE_PAGE}`) @@ -104,7 +119,7 @@ describe('readDocsPage', () => { }) it('surfaces a docs-site outage as a retryable error after exhausting retries', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' }) + fetchMock.mockResolvedValue(fetchResponse(502)) await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) expect(fetchMock).toHaveBeenCalledTimes(3) }) @@ -118,7 +133,7 @@ describe('readDocsPage', () => { it('recovers when a transient failure clears on retry', async () => { fetchMock .mockRejectedValueOnce(new Error('socket hang up')) - .mockResolvedValue({ ok: true, status: 200, text: async () => '# Agent\n\nbody' }) + .mockResolvedValue(fetchResponse(200, '# Agent\n\nbody')) const page = await readDocsPage(`docs/${SAMPLE_PAGE}`) @@ -127,7 +142,7 @@ describe('readDocsPage', () => { }) it('reports a page the site no longer serves as permanent, without retrying', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 404, text: async () => '' }) + fetchMock.mockResolvedValue(fetchResponse(404)) const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e) expect(error).toBeInstanceOf(DocsCorpusError) expect(error.message).toMatch(/does not serve it/) @@ -136,17 +151,50 @@ describe('readDocsPage', () => { expect(fetchMock).toHaveBeenCalledOnce() }) - it('still treats 429 as retryable rather than permanent', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 429, text: async () => '' }) + it('honors Retry-After while retrying a 429 response', async () => { + fetchMock.mockResolvedValue(fetchResponse(429, '', { 'Retry-After': '7' })) await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) expect(fetchMock).toHaveBeenCalledTimes(3) + expect(mockSleep).toHaveBeenNthCalledWith(1, 7_000) + expect(mockSleep).toHaveBeenNthCalledWith(2, 7_000) }) it('treats 408 as retryable rather than a missing page', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 408, text: async () => '' }) + fetchMock.mockResolvedValue(fetchResponse(408)) await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) expect(fetchMock).toHaveBeenCalledTimes(3) }) + + it('aborts an in-flight fetch without retrying', async () => { + const controller = new AbortController() + fetchMock.mockImplementation((_url: string, init: RequestInit) => { + const signal = init.signal as AbortSignal + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + + const request = readDocsPage(`docs/${SAMPLE_PAGE}`, controller.signal) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + controller.abort(new Error('user stopped docs read')) + + await expect(request).rejects.toThrow('user stopped docs read') + expect(fetchMock).toHaveBeenCalledOnce() + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('aborts retry backoff before starting another fetch', async () => { + const controller = new AbortController() + fetchMock.mockResolvedValue(fetchResponse(502)) + mockSleep.mockImplementationOnce(() => new Promise(() => {})) + + const request = readDocsPage(`docs/${SAMPLE_PAGE}`, controller.signal) + await vi.waitFor(() => expect(mockSleep).toHaveBeenCalledOnce()) + controller.abort(new Error('user stopped docs retry')) + + await expect(request).rejects.toThrow('user stopped docs retry') + expect(fetchMock).toHaveBeenCalledOnce() + }) }) describe('grepDocs', () => { @@ -163,11 +211,7 @@ describe('grepDocs', () => { }) it('greps exactly one page for a page path', async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - text: async () => 'intro line\nsystemPrompt matters\ntail', - }) + fetchMock.mockResolvedValue(fetchResponse(200, 'intro line\nsystemPrompt matters\ntail')) const matches = await grepDocs(`docs/${SAMPLE_PAGE}`, 'systemPrompt') diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index 27e249b1ac7..e8eca75761d 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { backoffWithJitter } from '@sim/utils/retry' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' @@ -121,12 +121,43 @@ type DocsFetchResult = /** The site will not serve this path however many times we ask. */ | { outcome: 'missing' } /** Transient: 5xx, 429, network error, or timeout. */ - | { outcome: 'unavailable' } + | { outcome: 'unavailable'; retryAfterMs: number | null } + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw toError(signal.reason ?? 'Docs request aborted') + } +} + +async function sleepForRetry(delayMs: number, signal?: AbortSignal): Promise { + if (!signal) { + await sleep(delayMs) + return + } + + throwIfAborted(signal) + let abortListener: (() => void) | undefined + const aborted = new Promise((_resolve, reject) => { + abortListener = () => reject(toError(signal.reason ?? 'Docs request aborted')) + signal.addEventListener('abort', abortListener, { once: true }) + if (signal.aborted) abortListener() + }) + + try { + await Promise.race([sleep(delayMs), aborted]) + } finally { + if (abortListener) signal.removeEventListener('abort', abortListener) + } +} + +async function fetchDocsPageOnce(url: string, signal?: AbortSignal): Promise { + throwIfAborted(signal) + const timeoutSignal = AbortSignal.timeout(FETCH_ATTEMPT_TIMEOUT_MS) + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal -async function fetchDocsPageOnce(url: string): Promise { try { const response = await fetch(url, { - signal: AbortSignal.timeout(FETCH_ATTEMPT_TIMEOUT_MS), + signal: requestSignal, headers: { Accept: 'text/markdown, text/plain' }, }) if (!response.ok) { @@ -136,23 +167,30 @@ async function fetchDocsPageOnce(url: string): Promise { response.status < 500 && response.status !== 408 && response.status !== 429 - return { outcome: permanent ? 'missing' : 'unavailable' } + if (permanent) return { outcome: 'missing' } + return { + outcome: 'unavailable', + retryAfterMs: parseRetryAfter(response.headers.get('retry-after')), + } } return { outcome: 'ok', content: await response.text() } } catch (err) { + throwIfAborted(signal) logger.warn('Docs page fetch failed', { url, error: toError(err).message }) - return { outcome: 'unavailable' } + return { outcome: 'unavailable', retryAfterMs: null } } } -async function fetchDocsPage(path: string): Promise { +async function fetchDocsPage(path: string, signal?: AbortSignal): Promise { const key = normalizeDocsPath(path) if (!docsKeyView.has(key)) return { outcome: 'missing' } const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` for (let attempt = 1; ; attempt++) { - const result = await fetchDocsPageOnce(url) + throwIfAborted(signal) + const result = await fetchDocsPageOnce(url, signal) + throwIfAborted(signal) if (result.outcome !== 'unavailable' || attempt >= FETCH_MAX_ATTEMPTS) return result - await sleep(backoffWithJitter(attempt, null)) + await sleepForRetry(backoffWithJitter(attempt, result.retryAfterMs), signal) } } @@ -161,7 +199,7 @@ async function fetchDocsPage(path: string): Promise { * conditions (directory path, unknown page, site unreachable) so the handler can * surface the message verbatim. */ -export async function readDocsPage(path: string): Promise { +export async function readDocsPage(path: string, signal?: AbortSignal): Promise { const key = normalizeDocsPath(path) if (!docsKeyView.has(key)) { if (isDocsDir(key)) { @@ -172,7 +210,7 @@ export async function readDocsPage(path: string): Promise { `Docs page not found: ${path}. Use glob("docs/**") to list the docs corpus.` ) } - const result = await fetchDocsPage(key) + const result = await fetchDocsPage(key, signal) if (result.outcome === 'missing') { throw new DocsCorpusError( `${key} is in the docs index but ${DOCS_BASE_URL} does not serve it — the page was likely moved or removed. Use glob("docs/**") to find the current path; retrying will not help.` @@ -194,7 +232,8 @@ export async function readDocsPage(path: string): Promise { export async function grepDocs( path: string, pattern: string, - options?: GrepOptions + options?: GrepOptions, + signal?: AbortSignal ): Promise { const key = normalizeDocsPath(path) if (!docsKeyView.has(key)) { @@ -207,6 +246,6 @@ export async function grepDocs( `"${path}" is not a docs page. Use glob("docs/**") to list the docs corpus.` ) } - const page = await readDocsPage(key) + const page = await readDocsPage(key, signal) return grepReadResult(key, page, pattern, key, options) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index f858cc57436..b360fd5b2af 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -726,7 +726,12 @@ describe('vfs handlers docs corpus routing', () => { }) it('reads a docs page via the live-site fetch, not the workspace VFS', async () => { - fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => 'line one\nline two' }) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + text: async () => 'line one\nline two', + }) const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) @@ -750,6 +755,7 @@ describe('vfs handlers docs corpus routing', () => { fetchMock.mockResolvedValue({ ok: true, status: 200, + headers: new Headers(), text: async () => 'alpha\ncron beta\ngamma', }) @@ -776,6 +782,7 @@ describe('vfs handlers docs corpus routing', () => { fetchMock.mockResolvedValue({ ok: true, status: 200, + headers: new Headers(), text: async () => Array.from({ length: totalLines }, () => line).join('\n'), }) @@ -794,6 +801,7 @@ describe('vfs handlers docs corpus routing', () => { fetchMock.mockResolvedValue({ ok: true, status: 200, + headers: new Headers(), text: async () => 'z'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1000), }) @@ -809,6 +817,7 @@ describe('vfs handlers docs corpus routing', () => { fetchMock.mockResolvedValue({ ok: true, status: 200, + headers: new Headers(), text: async () => Array.from({ length: totalLines }, () => line).join('\n'), }) @@ -817,4 +826,17 @@ describe('vfs handlers docs corpus routing', () => { expect(result.success).toBe(false) expect(result.error).toContain('still too large over the requested window') }) + + it('forwards caller cancellation to docs read and grep without fetching', async () => { + const controller = new AbortController() + controller.abort(new Error('user stopped docs tool')) + const context = { ...GREP_CTX, abortSignal: controller.signal } + + const read = await executeVfsRead({ path: DOCS_PAGE }, context) + const grep = await executeVfsGrep({ pattern: 'agent', path: DOCS_PAGE }, context) + + expect(read).toEqual({ success: false, error: 'user stopped docs tool' }) + expect(grep).toEqual({ success: false, error: 'user stopped docs tool' }) + expect(fetchMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index af6568e25bd..1d07c8aa751 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -199,7 +199,7 @@ export async function executeVfsGrep( let result: GrepMatch[] | string[] | GrepCountEntry[] let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined if (rawPath !== undefined && isDocsPath(rawPath)) { - result = await grepDocs(rawPath, pattern, grepOptions) + result = await grepDocs(rawPath, pattern, grepOptions, context.abortSignal) } else if (isChatUploadGrepPath(rawPath)) { if (!context.chatId) { return { success: false, error: 'No chat context available for uploads/' } @@ -369,7 +369,7 @@ export async function executeVfsRead( } if (isDocsPath(path)) { - const page = await readDocsPage(path) + const page = await readDocsPage(path, context.abortSignal) const windowed = applyWindow(page) if (serializedResultSize(windowed) > TOOL_RESULT_MAX_INLINE_CHARS) { if (offset !== undefined || limit !== undefined) { diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts index a95e3be3a79..7603bc9e6b2 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -52,8 +52,9 @@ function shortfallNote(outcome: Awaited>): string /** * Vector search over Sim's product documentation, scoped to the same pages the - * agent can `read` from the `docs/` VFS tree. Search-agent only; the corpus - * logic lives in `@/lib/copilot/docs/docs-search`. + * agent can `read` from the `docs/` VFS tree. Normal delegation exposes it to + * the platform agent; the `@Docs` compatibility path also invokes it directly. + * Corpus logic lives in `@/lib/copilot/docs/docs-search`. */ export const searchDocsServerTool: BaseServerTool = { name: SearchDocs.id, From eb2867f1081c0704225638190c7f53fef5ce477b Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:05:16 -0700 Subject: [PATCH 038/103] chore(copilot): sync search context contract --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 2 +- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 2930c2ca0a3..d4d2df7141d 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -4538,7 +4538,7 @@ export const Search: ToolCatalogEntry = { properties: { task: { description: - "A fully self-contained task — the search agent sees none of this conversation, so include the question plus every name, id, constraint, and prior finding it needs. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", type: 'string', }, }, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 6ef9d8f2e09..6654dc277b5 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -4387,7 +4387,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { task: { description: - "A fully self-contained task — the search agent sees none of this conversation, so include the question plus every name, id, constraint, and prior finding it needs. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", type: 'string', }, }, From ea246042c01c74798cf128ba9759b7e769813e73 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:31:48 -0700 Subject: [PATCH 039/103] feat(copilot): register blank platform subagent --- .../home/components/message-content/utils.ts | 1 + .../app/workspace/[workspaceId]/home/types.ts | 1 + .../lib/copilot/generated/tool-catalog-v1.ts | 23 +++++++++++++++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 13 +++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 1 + 5 files changed, 39 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 8079fc40de6..4ccbf00f5a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -64,6 +64,7 @@ const TOOL_ICONS: Record = { research: Search, scout: Search, search: Search, + platform: Library, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index e6d21c27765..cf2a46b1b7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -189,6 +189,7 @@ export const SUBAGENT_LABELS: Record = { custom_tool: 'Custom Tool Agent', scout: 'Scout Agent', search: 'Search Agent', + platform: 'Platform Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index d4d2df7141d..0c9caa14c33 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -85,6 +85,7 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'platform' | 'promote_to_live' | 'query_logs' | 'query_user_table' @@ -205,6 +206,7 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'platform' | 'promote_to_live' | 'query_logs' | 'query_user_table' @@ -257,6 +259,7 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' + | 'platform' | 'run' | 'search' | 'table' @@ -3867,6 +3870,25 @@ export const OpenResource: ToolCatalogEntry = { }, } +export const Platform: ToolCatalogEntry = { + id: 'platform', + name: 'platform', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: 'A task for the Platform agent.', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'platform', + internal: true, +} + export const PromoteToLive: ToolCatalogEntry = { id: 'promote_to_live', name: 'promote_to_live', @@ -6574,6 +6596,7 @@ export const TOOL_CATALOG: Record = { [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, + [Platform.id]: Platform, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, [QueryUserTable.id]: QueryUserTable, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 6654dc277b5..63e0ca211c6 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3728,6 +3728,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + platform: { + parameters: { + properties: { + task: { + description: 'A task for the Platform agent.', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, promote_to_live: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 8f77f4cd856..01520732c0b 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -536,6 +536,7 @@ const TOOL_TITLES: Record = { research: 'Research Agent', scout: 'Scout Agent', search: 'Search Agent', + platform: 'Platform Agent', file: 'File Agent', media: 'Media Agent', browser: 'Browser Agent', From 86fd659734720e73fa77c96b135074d0ddf09cee Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:33:01 -0700 Subject: [PATCH 040/103] feat(copilot): describe platform agent delegation --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 3 ++- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 0c9caa14c33..a4312707e3d 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3878,7 +3878,8 @@ export const Platform: ToolCatalogEntry = { parameters: { properties: { task: { - description: 'A task for the Platform agent.', + description: + "A fully self-contained question about Sim — the platform agent sees none of this conversation, so include every name, id, constraint, and prior finding it needs. Example: 'what is the minimum schedule-trigger interval, and does it differ by plan?' or 'does the agent block persist memory across runs?'.", type: 'string', }, }, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 63e0ca211c6..b75d04a7f64 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3732,7 +3732,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { properties: { task: { - description: 'A task for the Platform agent.', + description: + "A fully self-contained question about Sim — the platform agent sees none of this conversation, so include every name, id, constraint, and prior finding it needs. Example: 'what is the minimum schedule-trigger interval, and does it differ by plan?' or 'does the agent block persist memory across runs?'.", type: 'string', }, }, From 952c7373f111d2254d22322a0db26241d1599c60 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:47:26 -0700 Subject: [PATCH 041/103] feat(copilot): answer account billing questions from a live snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the no-argument get_account_billing tool: the handler combines the org-aware billing lookups (usage data, credit balance, usage-limit info) into one snapshot — plan, current-period usage vs limit with remaining, and purchased credit balance — always scoped to the requesting user. Catalog and schema mirrors regenerated from the mothership definitions. Co-Authored-By: Claude Fable 5 --- .../lib/copilot/generated/tool-catalog-v1.ts | 11 ++ .../lib/copilot/generated/tool-schemas-v1.ts | 7 + .../tool-executor/register-handlers.ts | 3 + .../copilot/tools/handlers/account.test.ts | 121 ++++++++++++++++++ .../sim/lib/copilot/tools/handlers/account.ts | 43 +++++++ apps/sim/lib/copilot/tools/tool-display.ts | 1 + 6 files changed, 186 insertions(+) create mode 100644 apps/sim/lib/copilot/tools/handlers/account.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/account.ts diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index a4312707e3d..4068fa6d8e7 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -56,6 +56,7 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' + | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' @@ -177,6 +178,7 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' + | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' @@ -2923,6 +2925,14 @@ export const GenerateVideo: ToolCatalogEntry = { capabilities: ['file_input', 'file_output', 'generated_media'], } +export const GetAccountBilling: ToolCatalogEntry = { + id: 'get_account_billing', + name: 'get_account_billing', + route: 'sim', + mode: 'async', + parameters: { type: 'object', properties: {} }, +} + export const GetBlockOutputs: ToolCatalogEntry = { id: 'get_block_outputs', name: 'get_block_outputs', @@ -6568,6 +6578,7 @@ export const TOOL_CATALOG: Record = { [GenerateAudio.id]: GenerateAudio, [GenerateImage.id]: GenerateImage, [GenerateVideo.id]: GenerateVideo, + [GetAccountBilling.id]: GetAccountBilling, [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index b75d04a7f64..205c8565129 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2821,6 +2821,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + get_account_billing: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, get_block_outputs: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index b5b9768ac14..324931bc75a 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -12,6 +12,7 @@ import { DiffWorkflows, FunctionExecute, GenerateApiKey, + GetAccountBilling, GetBlockOutputs, GetBlockUpstreamReferences, GetDeployedWorkflowState, @@ -52,6 +53,7 @@ import { } from '@/lib/copilot/generated/tool-catalog-v1' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' +import { executeGetAccountBilling } from '../tools/handlers/account' import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' import { executeDeployApi, @@ -134,6 +136,7 @@ function h(fn: (params: any, context: any) => Promise): ToolHandler { function buildHandlerMap(): Record { return { [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), + [GetAccountBilling.id]: h((_p, c) => executeGetAccountBilling(c)), [GetWorkflowData.id]: h(executeGetWorkflowData), [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), [GetBlockOutputs.id]: h(executeGetBlockOutputs), diff --git a/apps/sim/lib/copilot/tools/handlers/account.test.ts b/apps/sim/lib/copilot/tools/handlers/account.test.ts new file mode 100644 index 00000000000..e9a75f603f2 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/account.test.ts @@ -0,0 +1,121 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserUsageData, mockGetCreditBalance, mockGetUserUsageLimitInfo } = vi.hoisted( + () => ({ + mockGetUserUsageData: vi.fn(), + mockGetCreditBalance: vi.fn(), + mockGetUserUsageLimitInfo: vi.fn(), + }) +) + +vi.mock('@/lib/billing', () => ({ + getUserUsageData: mockGetUserUsageData, + getCreditBalance: mockGetCreditBalance, + getUserUsageLimitInfo: mockGetUserUsageLimitInfo, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' + +const context = { userId: 'user-1' } as ExecutionContext + +describe('executeGetAccountBilling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the org-aware plan, usage, and credit snapshot', async () => { + const periodEnd = new Date('2026-09-01T00:00:00Z') + mockGetUserUsageData.mockResolvedValue({ + currentUsage: 18.5, + limit: 40, + percentUsed: 46.25, + isWarning: false, + isExceeded: false, + billingPeriodStart: new Date('2026-08-01T00:00:00Z'), + billingPeriodEnd: periodEnd, + lastPeriodCost: 31, + }) + mockGetCreditBalance.mockResolvedValue({ + balance: 25, + entityType: 'organization', + entityId: 'org-1', + }) + mockGetUserUsageLimitInfo.mockResolvedValue({ + currentLimit: 40, + canEdit: false, + minimumLimit: 0, + plan: 'team', + updatedAt: null, + scope: 'organization', + organizationId: 'org-1', + }) + + const result = await executeGetAccountBilling(context) + + expect(mockGetUserUsageData).toHaveBeenCalledWith('user-1') + expect(mockGetCreditBalance).toHaveBeenCalledWith('user-1') + expect(mockGetUserUsageLimitInfo).toHaveBeenCalledWith('user-1') + expect(result).toEqual({ + success: true, + output: { + plan: 'team', + billingScope: 'organization', + organizationId: 'org-1', + usage: { + currentPeriodCost: 18.5, + limit: 40, + remaining: 21.5, + percentUsed: 46.25, + isExceeded: false, + billingPeriodEnd: periodEnd, + }, + credits: { balance: 25, scope: 'organization' }, + }, + }) + }) + + it('clamps remaining to zero when usage exceeds the limit', async () => { + mockGetUserUsageData.mockResolvedValue({ + currentUsage: 45, + limit: 40, + percentUsed: 112.5, + isWarning: false, + isExceeded: true, + billingPeriodStart: null, + billingPeriodEnd: null, + lastPeriodCost: 0, + }) + mockGetCreditBalance.mockResolvedValue({ balance: 0, entityType: 'user', entityId: 'user-1' }) + mockGetUserUsageLimitInfo.mockResolvedValue({ + currentLimit: 40, + canEdit: true, + minimumLimit: 0, + plan: 'pro', + updatedAt: null, + scope: 'user', + organizationId: null, + }) + + const result = await executeGetAccountBilling(context) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + plan: 'pro', + usage: { remaining: 0, isExceeded: true }, + }) + }) + + it('surfaces a billing lookup failure as a tool error', async () => { + mockGetUserUsageData.mockRejectedValue(new Error('stats row missing')) + mockGetCreditBalance.mockResolvedValue({ balance: 0, entityType: 'user', entityId: 'user-1' }) + mockGetUserUsageLimitInfo.mockResolvedValue({}) + + const result = await executeGetAccountBilling(context) + + expect(result).toEqual({ success: false, error: 'stats row missing' }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/account.ts b/apps/sim/lib/copilot/tools/handlers/account.ts new file mode 100644 index 00000000000..ec3b3440f5a --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/account.ts @@ -0,0 +1,43 @@ +import { toError } from '@sim/utils/errors' +import { getCreditBalance, getUserUsageData, getUserUsageLimitInfo } from '@/lib/billing' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' + +/** + * Live billing snapshot for the requesting user: plan, current-period usage + * against its limit, and purchased credit balance. All three sources are + * org-aware — a member whose subscription lives on an organization gets the + * org's plan, limit, and credit pool, with `billingScope`/`organizationId` + * saying which applied. + */ +export async function executeGetAccountBilling(context: ExecutionContext): Promise { + try { + const [usage, credits, limitInfo] = await Promise.all([ + getUserUsageData(context.userId), + getCreditBalance(context.userId), + getUserUsageLimitInfo(context.userId), + ]) + + return { + success: true, + output: { + plan: limitInfo.plan, + billingScope: limitInfo.scope, + organizationId: limitInfo.organizationId, + usage: { + currentPeriodCost: usage.currentUsage, + limit: usage.limit, + remaining: Math.max(0, usage.limit - usage.currentUsage), + percentUsed: usage.percentUsed, + isExceeded: usage.isExceeded, + billingPeriodEnd: usage.billingPeriodEnd, + }, + credits: { + balance: credits.balance, + scope: credits.entityType, + }, + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 01520732c0b..7759e17d676 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -474,6 +474,7 @@ const TOOL_TITLES: Record = { function_execute: 'Running code', complete_scheduled_task: 'Completing scheduled task', generate_api_key: 'Generating API key', + get_account_billing: 'Checking plan and usage', get_block_outputs: 'Getting block outputs', get_block_upstream_references: 'Getting block references', get_deployed_workflow_state: 'Getting deployed workflow', From f9f7fb83e44c356fdc44f305b11eb61710719cfd Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:51:08 -0700 Subject: [PATCH 042/103] feat(copilot): expose effective enterprise context --- .../content/docs/en/platform/permissions.mdx | 5 +- .../components/group-detail.tsx | 148 +-------- .../utils/permission-check.test.ts | 122 ++++++++ .../access-control/utils/permission-check.ts | 96 +++++- apps/sim/lib/api/contracts/workspaces.ts | 2 + .../lib/copilot/generated/tool-catalog-v1.ts | 11 + .../lib/copilot/generated/tool-schemas-v1.ts | 7 + .../tool-executor/register-handlers.ts | 3 + .../tools/handlers/enterprise-context.test.ts | 285 ++++++++++++++++++ .../tools/handlers/enterprise-context.ts | 94 ++++++ .../lib/copilot/tools/tool-display.test.ts | 1 + apps/sim/lib/copilot/tools/tool-display.ts | 1 + .../lib/permission-groups/features.test.ts | 94 ++++++ apps/sim/lib/permission-groups/features.ts | 228 ++++++++++++++ apps/sim/lib/workspaces/host-context.test.ts | 3 + apps/sim/lib/workspaces/host-context.ts | 3 +- 16 files changed, 940 insertions(+), 163 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.ts create mode 100644 apps/sim/lib/permission-groups/features.test.ts create mode 100644 apps/sim/lib/permission-groups/features.ts diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx index b5f1969c240..2f45f5c1a42 100644 --- a/apps/docs/content/docs/en/platform/permissions.mdx +++ b/apps/docs/content/docs/en/platform/permissions.mdx @@ -126,7 +126,7 @@ Here's a detailed breakdown of what users can do with each permission level: **What they can do:** - Everything Read users can do, plus: - Create, edit, and delete workflows -- Run and deploy workflows +- Run workflows - Add, edit, and delete workspace environment variables - Use all available tools and integrations - Collaborate in real-time on workflow editing @@ -140,6 +140,7 @@ Here's a detailed breakdown of what users can do with each permission level: **What they can do:** - Everything Write users can do, plus: +- Deploy workflows - Invite new users to the workspace with any permission level - Remove users from the workspace - Manage workspace settings and integrations @@ -254,4 +255,4 @@ import { FAQ } from '@/components/ui/faq' { question: "Who can manage a workspace's credentials and secrets?", answer: "Workspace Admins are automatically Credential Admins of the workspace's shared credentials — OAuth connections, service accounts, and workspace environment variables — so they can use, edit, delete, and share them, and run workflows that rely on them. Organization Owners and Admins get this too because they are workspace Admins everywhere. Read and Write members get use-only access to shared credentials unless they are explicitly made a Credential Admin. Personal environment variables are never shared; they stay private to their owner." }, { question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. The organization's default group is org-wide; every other group targets specific workspaces and, by default, governs all members of those workspaces (including external members) — add members to restrict it to specific people. A user is governed by one group per workspace: a group they're an explicit member of takes precedence over an all-members group (one with no members) on that workspace, which takes precedence over the organization's default group. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Only one group per organization can be the default; it ignores members and governs everyone not covered by a workspace group, including external members. Restrictions are enforced based on the organization that owns the workflow's workspace, not on which workspace you're currently viewing." }, { question: "How should I set up permissions for a new team member?", answer: "Start with the lowest permission level they need. Invite them with Read workspace access if they only need visibility, Write if they need to create and run workflows, or Admin if they need to manage the workspace and its users, and leave Membership on Member. For clients, partners, and contractors, choose External so they collaborate without joining your organization or using a seat — this requires them to already be on a paid Sim plan, either their own Pro or Max subscription or another organization that seats them." }, -]} /> \ No newline at end of file +]} /> diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index f51c843ff60..a7961ba8949 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -30,6 +30,7 @@ import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { @@ -174,151 +175,6 @@ function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFi ) } -/** Render order for the platform-feature category sections; unlisted ones follow. */ -const PLATFORM_CATEGORY_ORDER = [ - 'Sidebar', - 'Deploy Tabs', - 'Chat', - 'Collaboration', - 'Workflow Panel', - 'Tools', - 'Features', - 'Settings Tabs', - 'Logs', - 'Files', -] - -const PLATFORM_FEATURES = [ - { - id: 'hide-knowledge-base', - label: 'Knowledge Base', - category: 'Sidebar', - configKey: 'hideKnowledgeBaseTab' as const, - hint: 'Hide the Knowledge Base module from the sidebar.', - }, - { - id: 'hide-tables', - label: 'Tables', - category: 'Sidebar', - configKey: 'hideTablesTab' as const, - hint: 'Hide the Tables module from the sidebar.', - }, - { - id: 'hide-copilot', - label: 'Chat', - category: 'Workflow Panel', - configKey: 'hideCopilot' as const, - hint: 'Hide the Chat panel so users cannot build or edit with natural language.', - }, - { - id: 'hide-integrations', - label: 'Integrations', - category: 'Settings Tabs', - configKey: 'hideIntegrationsTab' as const, - hint: 'Hide the Integrations settings tab (OAuth connections).', - }, - { - id: 'hide-secrets', - label: 'Secrets', - category: 'Settings Tabs', - configKey: 'hideSecretsTab' as const, - hint: 'Hide the Secrets (environment variables) settings tab.', - }, - { - id: 'hide-api-keys', - label: 'API Keys', - category: 'Settings Tabs', - configKey: 'hideApiKeysTab' as const, - hint: 'Hide the API Keys settings tab.', - }, - { - id: 'hide-files', - label: 'Files', - category: 'Settings Tabs', - configKey: 'hideFilesTab' as const, - hint: 'Hide the Files settings tab.', - }, - { - id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - configKey: 'hideDeployApi' as const, - hint: 'Hide the API deployment option.', - }, - { - id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - configKey: 'hideDeployMcp' as const, - hint: 'Hide the MCP server deployment option.', - }, - { - id: 'disable-mcp', - label: 'MCP Tools', - category: 'Tools', - configKey: 'disableMcpTools' as const, - hint: 'Block agents from calling MCP tools.', - }, - { - id: 'disable-custom-tools', - label: 'Custom Tools', - category: 'Tools', - configKey: 'disableCustomTools' as const, - hint: 'Block agents from calling user-defined custom tools.', - }, - { - id: 'disable-skills', - label: 'Skills', - category: 'Tools', - configKey: 'disableSkills' as const, - hint: 'Block agents from loading skills.', - }, - { - id: 'hide-trace-spans', - label: 'Trace Spans', - category: 'Logs', - configKey: 'hideTraceSpans' as const, - hint: 'Hide per-block trace spans in logs.', - }, - { - id: 'disable-invitations', - label: 'Invitations', - category: 'Collaboration', - configKey: 'disableInvitations' as const, - hint: 'Prevent users from inviting others to workspaces.', - }, - { - id: 'hide-inbox', - label: 'Sim Mailer', - category: 'Features', - configKey: 'hideInboxTab' as const, - hint: 'Hide the Sim Mailer inbox.', - }, - { - id: 'disable-public-api', - label: 'Public API', - category: 'Features', - configKey: 'disablePublicApi' as const, - hint: 'Disable public API access to deployed workflows.', - }, - // Chat and Files get a category of their own so their nested auth-mode - // dropdown (see `featureExtras`) reads as part of the toggle it qualifies. - { - id: 'hide-deploy-chatbot', - label: 'Deployment', - category: 'Chat', - configKey: 'hideDeployChatbot' as const, - hint: 'Hide the chat deployment option.', - }, - { - id: 'disable-public-file-sharing', - label: 'Public Sharing', - category: 'Files', - configKey: 'disablePublicFileSharing' as const, - hint: 'Disable public file-share links.', - }, -] - interface OrganizationMemberOption { userId: string user: { @@ -954,7 +810,7 @@ export function GroupDetail({ }, [searchedPlatformFeatures, statusFilter, editingConfig]) const platformCategories = useMemo(() => { - const categories: Record = {} + const categories: Record = {} for (const feature of filteredPlatformFeatures) { if (!categories[feature.category]) { categories[feature.category] = [] diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 8a9d502c80c..b5eaa610496 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -84,6 +84,8 @@ import { ModelNotAllowedError, ProviderNotAllowedError, PublicFileSharingNotAllowedError, + resolveUserAccessControlContext, + resolveVerifiedUserAccessControlContext, SkillsNotAllowedError, ToolNotAllowedError, validateBlockType, @@ -229,6 +231,126 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { }) }) +describe('resolveUserAccessControlContext', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) + }) + + it('describes a personal workspace without changing the config-only result', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null }) + + await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ + organizationId: null, + entitled: false, + permissionGroup: null, + config: null, + }) + await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toBeNull() + }) + + it('returns the explicit governing group and its effective config', async () => { + setEnterpriseOrgWorkspace() + queueGroupResolution([ + { + id: 'group-explicit', + name: 'Engineering', + config: { disableMcpTools: true }, + isMember: true, + hasMembers: true, + }, + ]) + + await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ + organizationId: 'org-1', + entitled: true, + permissionGroup: { + id: 'group-explicit', + name: 'Engineering', + resolution: 'explicit-member', + }, + config: expect.objectContaining({ disableMcpTools: true }), + }) + }) + + it('identifies an all-members governing group', async () => { + setEnterpriseOrgWorkspace() + queueGroupResolution([ + { + id: 'group-all-members', + name: 'All workspace members', + config: { disableCustomTools: true }, + isMember: false, + hasMembers: false, + }, + ]) + + const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + + expect(context.permissionGroup).toEqual({ + id: 'group-all-members', + name: 'All workspace members', + resolution: 'all-members', + }) + }) + + it('uses a verified workspace organization without loading the workspace again', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + queueGroupResolution([ + { + id: 'group-verified', + name: 'Verified group', + config: { disableSkills: true }, + isMember: true, + hasMembers: true, + }, + ]) + + const context = await resolveVerifiedUserAccessControlContext( + 'user-123', + 'workspace-1', + 'org-verified' + ) + + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified') + expect(context).toMatchObject({ + organizationId: 'org-verified', + entitled: true, + permissionGroup: { + id: 'group-verified', + resolution: 'explicit-member', + }, + config: { disableSkills: true }, + }) + }) + + it('identifies the default group and preserves the environment allowlist', async () => { + setEnterpriseOrgWorkspace() + mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) + queueGroupResolution( + [], + [ + { + id: 'group-default', + name: 'Organization default', + config: { allowedIntegrations: ['slack', 'github'] }, + }, + ] + ) + + const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + + expect(context.permissionGroup).toEqual({ + id: 'group-default', + name: 'Organization default', + resolution: 'default', + }) + expect(context.config?.allowedIntegrations).toEqual(['slack']) + }) +}) + describe('getUserPermissionConfig (workspace-group precedence)', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index d83b16e8f62..0afd24ed462 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -141,9 +141,30 @@ function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGrou export interface ResolvedPermissionGroup { permissionGroupId: string groupName: string + resolution: 'explicit-member' | 'all-members' | 'default' config: PermissionGroupConfig } +export interface UserAccessControlContext { + organizationId: string | null + entitled: boolean + permissionGroup: { + id: string + name: string + resolution: ResolvedPermissionGroup['resolution'] + } | null + config: PermissionGroupConfig | null +} + +function inactiveUserAccessControlContext(organizationId: string | null): UserAccessControlContext { + return { + organizationId, + entitled: false, + permissionGroup: null, + config: mergeEnvAllowlist(null), + } +} + /** The organization's single default group (`isDefault`), or `null`. */ async function resolveDefaultGroup( organizationId: string @@ -167,6 +188,7 @@ async function resolveDefaultGroup( return { permissionGroupId: defaultGroup.id, groupName: defaultGroup.name, + resolution: 'default', config: parsePermissionGroupConfig(defaultGroup.config), } } @@ -222,12 +244,14 @@ export async function resolveWorkspaceGroup( ) .orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id)) - const winner = rows.find((row) => row.isMember) ?? rows.find((row) => !row.hasMembers) + const explicitMemberGroup = rows.find((row) => row.isMember) + const winner = explicitMemberGroup ?? rows.find((row) => !row.hasMembers) if (winner) { return { permissionGroupId: winner.id, groupName: winner.name, + resolution: explicitMemberGroup ? 'explicit-member' : 'all-members', config: parsePermissionGroupConfig(winner.config), } } @@ -246,26 +270,70 @@ export async function resolveWorkspaceGroup( * The env-level integration allowlist is always merged last so self-hosted * deployments can constrain integrations without touching the DB. */ -export async function getUserPermissionConfig( +async function resolveUserAccessControlContextForOrganization( userId: string, - workspaceId: string -): Promise { - if (!isHosted && !isAccessControlEnabled) { - return mergeEnvAllowlist(null) + workspaceId: string, + organizationId: string | null +): Promise { + if (!organizationId) return inactiveUserAccessControlContext(null) + + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!isEnterprise) { + return inactiveUserAccessControlContext(organizationId) } - const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) - if (!ws?.organizationId) { - return mergeEnvAllowlist(null) + const resolved = await resolveWorkspaceGroup(userId, organizationId, workspaceId) + return { + organizationId, + entitled: true, + permissionGroup: resolved + ? { + id: resolved.permissionGroupId, + name: resolved.groupName, + resolution: resolved.resolution, + } + : null, + config: mergeEnvAllowlist(resolved?.config ?? null), } +} - const isEnterprise = await isOrganizationOnEnterprisePlan(ws.organizationId) - if (!isEnterprise) { - return mergeEnvAllowlist(null) +/** + * Resolves Access Control from an organization ID obtained from an already + * access-checked workspace. This function does not independently authorize the + * user for the workspace; callers must establish that boundary first. + */ +export async function resolveVerifiedUserAccessControlContext( + userId: string, + workspaceId: string, + organizationId: string | null +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return inactiveUserAccessControlContext(null) } + return resolveUserAccessControlContextForOrganization(userId, workspaceId, organizationId) +} - const resolved = await resolveWorkspaceGroup(userId, ws.organizationId, workspaceId) - return mergeEnvAllowlist(resolved?.config ?? null) +export async function resolveUserAccessControlContext( + userId: string, + workspaceId: string +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return inactiveUserAccessControlContext(null) + } + + const workspace = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + return resolveUserAccessControlContextForOrganization( + userId, + workspaceId, + workspace?.organizationId ?? null + ) +} + +export async function getUserPermissionConfig( + userId: string, + workspaceId: string +): Promise { + return (await resolveUserAccessControlContext(userId, workspaceId)).config } /** diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 5210e774c75..aaa1b836caa 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -263,6 +263,8 @@ export const workspaceHostContextSchema = z.object({ permission: workspacePermissionSchema, isHostOrganizationMember: z.boolean(), isHostOrganizationAdmin: z.boolean(), + /** Optional for rolling compatibility with app versions that predate organization-role projection. */ + organizationRole: z.string().nullable().optional(), }), }) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4068fa6d8e7..d753c42437d 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -61,6 +61,7 @@ export interface ToolCatalogEntry { | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' + | 'get_enterprise_context' | 'get_page_contents' | 'get_workflow_data' | 'get_workflow_run_options' @@ -183,6 +184,7 @@ export interface ToolCatalogEntry { | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' + | 'get_enterprise_context' | 'get_page_contents' | 'get_workflow_data' | 'get_workflow_run_options' @@ -3010,6 +3012,14 @@ export const GetDeploymentLog: ToolCatalogEntry = { }, } +export const GetEnterpriseContext: ToolCatalogEntry = { + id: 'get_enterprise_context', + name: 'get_enterprise_context', + route: 'sim', + mode: 'async', + parameters: { type: 'object', properties: {} }, +} + export const GetPageContents: ToolCatalogEntry = { id: 'get_page_contents', name: 'get_page_contents', @@ -6583,6 +6593,7 @@ export const TOOL_CATALOG: Record = { [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentLog.id]: GetDeploymentLog, + [GetEnterpriseContext.id]: GetEnterpriseContext, [GetPageContents.id]: GetPageContents, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 205c8565129..f4a8ce82175 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2897,6 +2897,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + get_enterprise_context: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, get_page_contents: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 324931bc75a..91c74be519b 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -17,6 +17,7 @@ import { GetBlockUpstreamReferences, GetDeployedWorkflowState, GetDeploymentLog, + GetEnterpriseContext, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, @@ -51,6 +52,7 @@ import { UpdateDeploymentVersion, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' +import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' import { executeGetAccountBilling } from '../tools/handlers/account' @@ -137,6 +139,7 @@ function buildHandlerMap(): Record { return { [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), [GetAccountBilling.id]: h((_p, c) => executeGetAccountBilling(c)), + [GetEnterpriseContext.id]: h((_p, c) => executeGetEnterpriseContext(c)), [GetWorkflowData.id]: h(executeGetWorkflowData), [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), [GetBlockOutputs.id]: h(executeGetBlockOutputs), diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts new file mode 100644 index 00000000000..a6d257e2326 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts @@ -0,0 +1,285 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkspaceHostContextForViewer, mockResolveVerifiedUserAccessControlContext } = + vi.hoisted(() => ({ + mockGetWorkspaceHostContextForViewer: vi.fn(), + mockResolveVerifiedUserAccessControlContext: vi.fn(), + })) + +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + resolveVerifiedUserAccessControlContext: mockResolveVerifiedUserAccessControlContext, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' + +const context = { + userId: 'user-1', + workspaceId: 'workspace-1', +} as ExecutionContext + +function enterpriseHost(permission: 'read' | 'write' | 'admin') { + return { + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + billedAccountUserId: 'owner-1', + }, + hostOrganizationId: 'org-1', + ownerBilling: { + plan: 'enterprise', + status: 'active', + isPaid: true, + isPro: true, + isTeam: true, + isEnterprise: true, + isOrgScoped: true, + organizationId: 'org-1', + billingInterval: 'year', + billingBlocked: false, + billingBlockedReason: null, + }, + viewer: { + permission, + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, + organizationRole: null, + }, + } +} + +describe('executeGetEnterpriseContext', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('requires a current workspace', async () => { + const result = await executeGetEnterpriseContext({ userId: 'user-1' } as ExecutionContext) + + expect(result).toEqual({ + success: false, + error: 'A current workspace is required to resolve enterprise access.', + }) + expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() + }) + + it('keeps external workspace administration separate from organization authority', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: { + id: 'group-1', + name: 'Contractors', + resolution: 'all-members', + }, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack'], + deniedTools: ['slack_delete_message'], + disableMcpTools: true, + disableInvitations: true, + }, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'org-1' + ) + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + id: 'workspace-1', + permission: 'admin', + capabilities: { + canRead: true, + canEdit: true, + canRun: true, + canDeploy: true, + canManageWorkspace: true, + }, + }, + organization: { + id: 'org-1', + relationship: 'external', + role: null, + canManageOrganization: false, + canManageBilling: false, + plan: 'enterprise', + isEnterprise: true, + }, + accessControl: { + entitled: true, + governingPermissionGroup: { + id: 'group-1', + name: 'Contractors', + resolution: 'all-members', + }, + effectiveConfig: expect.objectContaining({ disableMcpTools: true }), + activeRestrictions: expect.arrayContaining([ + expect.objectContaining({ key: 'allowedIntegrations' }), + expect.objectContaining({ key: 'deniedTools' }), + expect.objectContaining({ key: 'disableMcpTools' }), + expect.objectContaining({ key: 'disableInvitations' }), + ]), + }, + }, + }) + }) + + it('reports an internal member role without granting organization administration', async () => { + const host = enterpriseHost('write') + mockGetWorkspaceHostContextForViewer.mockResolvedValue({ + ...host, + viewer: { + ...host.viewer, + isHostOrganizationMember: true, + organizationRole: 'member', + }, + }) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + permission: 'write', + capabilities: { + canRead: true, + canEdit: true, + canRun: true, + canDeploy: false, + canManageWorkspace: false, + }, + }, + organization: { + relationship: 'internal', + role: 'member', + canManageOrganization: false, + canManageBilling: false, + }, + }, + }) + }) + + it('reports read access without write, run, deployment, or administration capabilities', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('read')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + permission: 'read', + capabilities: { + canRead: true, + canEdit: false, + canRun: false, + canDeploy: false, + canManageWorkspace: false, + }, + }, + }, + }) + }) + + it('returns a personal-workspace context without looking up organization membership', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue({ + ...enterpriseHost('write'), + hostOrganizationId: null, + ownerBilling: { + ...enterpriseHost('write').ownerBilling, + plan: 'pro', + isEnterprise: false, + isOrgScoped: false, + organizationId: null, + }, + }) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: null, + entitled: false, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { permission: 'write' }, + organization: null, + accessControl: { + entitled: false, + governingPermissionGroup: null, + effectiveConfig: null, + activeRestrictions: [], + }, + }, + }) + expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + null + ) + }) + + it('does not expose enterprise context when workspace access cannot be resolved', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(null) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ + success: false, + error: 'Workspace not found or you do not have access.', + }) + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('returns a failure when workspace context resolution fails', async () => { + mockGetWorkspaceHostContextForViewer.mockRejectedValue(new Error('workspace lookup failed')) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ success: false, error: 'workspace lookup failed' }) + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('returns a failure when access-control resolution fails', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('write')) + mockResolveVerifiedUserAccessControlContext.mockRejectedValue( + new Error('access-control lookup failed') + ) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ success: false, error: 'access-control lookup failed' }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts new file mode 100644 index 00000000000..ece309369d7 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts @@ -0,0 +1,94 @@ +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { toError } from '@sim/utils/errors' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' + +const ENTERPRISE_PERMISSION_DOCUMENTATION = [ + { + title: 'Roles and permissions', + path: 'docs/platform/permissions.mdx', + url: 'https://docs.sim.ai/platform/permissions', + }, + { + title: 'Enterprise Access Control', + path: 'docs/platform/enterprise/access-control.mdx', + url: 'https://docs.sim.ai/platform/enterprise/access-control', + }, +] as const + +/** + * Resolves the authenticated user's effective Enterprise access in the current + * workspace. This is an explanatory snapshot; every later mutation must still + * perform its normal server-side authorization at execution time. + */ +export async function executeGetEnterpriseContext( + context: ExecutionContext +): Promise { + if (!context.workspaceId) { + return { + success: false, + error: 'A current workspace is required to resolve enterprise access.', + } + } + + try { + const hostContext = await getWorkspaceHostContextForViewer(context.workspaceId, context.userId) + if (!hostContext) { + return { + success: false, + error: 'Workspace not found or you do not have access.', + } + } + + const accessControl = await resolveVerifiedUserAccessControlContext( + context.userId, + context.workspaceId, + hostContext.hostOrganizationId + ) + + const canWrite = permissionSatisfies(hostContext.viewer.permission, 'write') + const canAdmin = permissionSatisfies(hostContext.viewer.permission, 'admin') + + return { + success: true, + output: { + workspace: { + id: hostContext.workspace.id, + name: hostContext.workspace.name, + mode: hostContext.workspace.workspaceMode, + permission: hostContext.viewer.permission, + capabilities: { + canRead: true, + canEdit: canWrite, + canRun: canWrite, + canDeploy: canAdmin, + canManageWorkspace: canAdmin, + }, + }, + organization: hostContext.hostOrganizationId + ? { + id: hostContext.hostOrganizationId, + relationship: hostContext.viewer.isHostOrganizationMember ? 'internal' : 'external', + role: hostContext.viewer.organizationRole ?? null, + canManageOrganization: hostContext.viewer.isHostOrganizationAdmin, + canManageBilling: hostContext.viewer.isHostOrganizationAdmin, + plan: hostContext.ownerBilling.plan, + isEnterprise: hostContext.ownerBilling.isEnterprise, + } + : null, + accessControl: { + entitled: accessControl.entitled, + governingPermissionGroup: accessControl.permissionGroup, + effectiveConfig: accessControl.config, + activeRestrictions: getActivePermissionGroupRestrictions(accessControl.config), + }, + documentation: ENTERPRISE_PERMISSION_DOCUMENTATION, + resolvedAt: new Date().toISOString(), + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 94cfedd34af..cff4b8ef479 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -76,6 +76,7 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link') expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') + expect(getToolDisplayTitle('get_enterprise_context')).toBe('Checking enterprise access') }) it('includes the query in search_docs titles', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 7759e17d676..9b1efb92534 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -479,6 +479,7 @@ const TOOL_TITLES: Record = { get_block_upstream_references: 'Getting block references', get_deployed_workflow_state: 'Getting deployed workflow', get_deployment_log: 'Getting deployment logs', + get_enterprise_context: 'Checking enterprise access', get_platform_actions: 'Getting platform actions', get_scheduled_task_logs: 'Reading scheduled task logs', get_workflow_data: 'Getting workflow data', diff --git a/apps/sim/lib/permission-groups/features.test.ts b/apps/sim/lib/permission-groups/features.test.ts new file mode 100644 index 00000000000..a22b5474004 --- /dev/null +++ b/apps/sim/lib/permission-groups/features.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + getActivePermissionGroupRestrictions, + PLATFORM_FEATURES, +} from '@/lib/permission-groups/features' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, +} from '@/lib/permission-groups/types' + +describe('getActivePermissionGroupRestrictions', () => { + it('returns no restrictions for an absent or unrestricted config', () => { + expect(getActivePermissionGroupRestrictions(null)).toEqual([]) + expect(getActivePermissionGroupRestrictions(DEFAULT_PERMISSION_GROUP_CONFIG)).toEqual([]) + }) + + it.each([ + { + key: 'allowedIntegrations', + emptyValue: [], + limitedValue: ['slack'], + emptyDescription: 'No non-exempt integrations or blocks are allowed.', + limitedDescription: + 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.', + }, + { + key: 'allowedModelProviders', + emptyValue: [], + limitedValue: ['openai'], + emptyDescription: 'No model providers are allowed.', + limitedDescription: 'Model providers are limited to effectiveConfig.allowedModelProviders.', + }, + { + key: 'allowedFileShareAuthTypes', + emptyValue: [], + limitedValue: ['password'], + emptyDescription: 'No public file-share authentication modes are allowed.', + limitedDescription: + 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.', + }, + { + key: 'allowedChatDeployAuthTypes', + emptyValue: [], + limitedValue: ['sso'], + emptyDescription: 'No chat deployment authentication modes are allowed.', + limitedDescription: + 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.', + }, + ] as const)( + 'describes empty and limited $key allowlists', + ({ key, emptyValue, limitedValue, emptyDescription, limitedDescription }) => { + const emptyConfig = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: emptyValue } + const limitedConfig = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: limitedValue } + + expect(getActivePermissionGroupRestrictions(emptyConfig)).toEqual([ + { key, description: emptyDescription }, + ]) + expect(getActivePermissionGroupRestrictions(limitedConfig)).toEqual([ + { key, description: limitedDescription }, + ]) + } + ) + + it.each([ + { + key: 'deniedModels', + value: ['gpt-4o'], + description: 'Models listed in effectiveConfig.deniedModels are blocked.', + }, + { + key: 'deniedTools', + value: ['slack_delete_message'], + description: 'Integration tools listed in effectiveConfig.deniedTools are blocked.', + }, + ] as const)('describes a populated $key denylist', ({ key, value, description }) => { + const config = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: value } + + expect(getActivePermissionGroupRestrictions(config)).toEqual([{ key, description }]) + }) + + it.each(PLATFORM_FEATURES)( + 'uses the shared prose for $configKey when enabled', + ({ configKey, hint }) => { + const config: PermissionGroupConfig = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + [configKey]: true, + } + + expect(getActivePermissionGroupRestrictions(config)).toEqual([ + { key: configKey, description: hint }, + ]) + } + ) +}) diff --git a/apps/sim/lib/permission-groups/features.ts b/apps/sim/lib/permission-groups/features.ts new file mode 100644 index 00000000000..78aa4f79545 --- /dev/null +++ b/apps/sim/lib/permission-groups/features.ts @@ -0,0 +1,228 @@ +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' + +type BooleanPermissionGroupConfigKey = { + [Key in keyof PermissionGroupConfig]: PermissionGroupConfig[Key] extends boolean ? Key : never +}[keyof PermissionGroupConfig] + +export interface PermissionGroupPlatformFeature { + id: string + label: string + category: string + configKey: BooleanPermissionGroupConfigKey + hint: string +} + +export interface ActivePermissionGroupRestriction { + key: keyof PermissionGroupConfig + description: string +} + +/** Render order for the platform-feature category sections; unlisted ones follow. */ +export const PLATFORM_CATEGORY_ORDER: readonly string[] = [ + 'Sidebar', + 'Deploy Tabs', + 'Chat', + 'Collaboration', + 'Workflow Panel', + 'Tools', + 'Features', + 'Settings Tabs', + 'Logs', + 'Files', +] as const + +/** User-facing descriptions shared by the Access Control editor and live permission context. */ +export const PLATFORM_FEATURES = [ + { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + configKey: 'hideKnowledgeBaseTab', + hint: 'Hide the Knowledge Base module from the sidebar.', + }, + { + id: 'hide-tables', + label: 'Tables', + category: 'Sidebar', + configKey: 'hideTablesTab', + hint: 'Hide the Tables module from the sidebar.', + }, + { + id: 'hide-copilot', + label: 'Chat', + category: 'Workflow Panel', + configKey: 'hideCopilot', + hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + }, + { + id: 'hide-integrations', + label: 'Integrations', + category: 'Settings Tabs', + configKey: 'hideIntegrationsTab', + hint: 'Hide the Integrations settings tab (OAuth connections).', + }, + { + id: 'hide-secrets', + label: 'Secrets', + category: 'Settings Tabs', + configKey: 'hideSecretsTab', + hint: 'Hide the Secrets (environment variables) settings tab.', + }, + { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + configKey: 'hideApiKeysTab', + hint: 'Hide the API Keys settings tab.', + }, + { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + configKey: 'hideFilesTab', + hint: 'Hide the Files settings tab.', + }, + { + id: 'hide-deploy-api', + label: 'API', + category: 'Deploy Tabs', + configKey: 'hideDeployApi', + hint: 'Hide the API deployment option.', + }, + { + id: 'hide-deploy-mcp', + label: 'MCP', + category: 'Deploy Tabs', + configKey: 'hideDeployMcp', + hint: 'Hide the MCP server deployment option.', + }, + { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + configKey: 'disableMcpTools', + hint: 'Block agents from calling MCP tools.', + }, + { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + configKey: 'disableCustomTools', + hint: 'Block agents from calling user-defined custom tools.', + }, + { + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + configKey: 'disableSkills', + hint: 'Block agents from loading skills.', + }, + { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + configKey: 'hideTraceSpans', + hint: 'Hide per-block trace spans in logs.', + }, + { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + configKey: 'disableInvitations', + hint: 'Prevent users from inviting others to workspaces.', + }, + { + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Features', + configKey: 'hideInboxTab', + hint: 'Hide the Sim Mailer inbox.', + }, + { + id: 'disable-public-api', + label: 'Public API', + category: 'Features', + configKey: 'disablePublicApi', + hint: 'Disable public API access to deployed workflows.', + }, + { + id: 'hide-deploy-chatbot', + label: 'Deployment', + category: 'Chat', + configKey: 'hideDeployChatbot', + hint: 'Hide the chat deployment option.', + }, + { + id: 'disable-public-file-sharing', + label: 'Public Sharing', + category: 'Files', + configKey: 'disablePublicFileSharing', + hint: 'Disable public file-share links.', + }, +] as const satisfies readonly PermissionGroupPlatformFeature[] + +/** Returns only restrictions that actively constrain the current user. */ +export function getActivePermissionGroupRestrictions( + config: PermissionGroupConfig | null +): ActivePermissionGroupRestriction[] { + if (!config) return [] + + const restrictions: ActivePermissionGroupRestriction[] = [] + + if (config.allowedIntegrations !== null) { + restrictions.push({ + key: 'allowedIntegrations', + description: + config.allowedIntegrations.length > 0 + ? 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.' + : 'No non-exempt integrations or blocks are allowed.', + }) + } + if (config.allowedModelProviders !== null) { + restrictions.push({ + key: 'allowedModelProviders', + description: + config.allowedModelProviders.length > 0 + ? 'Model providers are limited to effectiveConfig.allowedModelProviders.' + : 'No model providers are allowed.', + }) + } + if (config.deniedModels.length > 0) { + restrictions.push({ + key: 'deniedModels', + description: 'Models listed in effectiveConfig.deniedModels are blocked.', + }) + } + if (config.deniedTools.length > 0) { + restrictions.push({ + key: 'deniedTools', + description: 'Integration tools listed in effectiveConfig.deniedTools are blocked.', + }) + } + if (config.allowedFileShareAuthTypes !== null) { + restrictions.push({ + key: 'allowedFileShareAuthTypes', + description: + config.allowedFileShareAuthTypes.length > 0 + ? 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.' + : 'No public file-share authentication modes are allowed.', + }) + } + if (config.allowedChatDeployAuthTypes !== null) { + restrictions.push({ + key: 'allowedChatDeployAuthTypes', + description: + config.allowedChatDeployAuthTypes.length > 0 + ? 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.' + : 'No chat deployment authentication modes are allowed.', + }) + } + + for (const feature of PLATFORM_FEATURES) { + if (config[feature.configKey]) { + restrictions.push({ key: feature.configKey, description: feature.hint }) + } + } + + return restrictions +} diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index c08bb7d7d39..6c83a34867d 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -85,6 +85,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'write', isHostOrganizationMember: true, isHostOrganizationAdmin: false, + organizationRole: 'member', }, }) ) @@ -104,6 +105,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'read', isHostOrganizationMember: false, isHostOrganizationAdmin: false, + organizationRole: null, }) expect(context?.hostOrganizationId).toBe('org-host') }) @@ -125,6 +127,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'admin', isHostOrganizationMember: false, isHostOrganizationAdmin: false, + organizationRole: null, }, }) ) diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index d350c5a1763..5df7df76461 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -25,7 +25,7 @@ async function resolveWorkspaceHostContextForViewer( getWorkspaceOwnerSubscriptionAccess(workspaceId), hostOrganizationId ? getOrganizationSettingsAccess(hostOrganizationId, userId) - : Promise.resolve({ isMember: false, isAdmin: false }), + : Promise.resolve({ role: null, isMember: false, isAdmin: false }), ]) return { @@ -41,6 +41,7 @@ async function resolveWorkspaceHostContextForViewer( permission: access.permission, isHostOrganizationMember: hostOrganizationAccess.isMember, isHostOrganizationAdmin: hostOrganizationAccess.isAdmin, + organizationRole: hostOrganizationAccess.role, }, } } From 51e8300b732ac301c380ab48e96f7a4195429d2a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:53:59 -0700 Subject: [PATCH 043/103] fix(copilot): secure live platform context --- apps/sim/lib/api/contracts/organization.ts | 4 +- apps/sim/lib/api/contracts/primitives.test.ts | 11 + apps/sim/lib/api/contracts/primitives.ts | 6 + apps/sim/lib/api/contracts/workspaces.test.ts | 32 +++ apps/sim/lib/api/contracts/workspaces.ts | 8 +- .../core/account-billing-snapshot.test.ts | 100 ++++++++ .../billing/core/account-billing-snapshot.ts | 58 +++++ apps/sim/lib/billing/core/usage.ts | 48 ++-- .../execute-platform-context-use-case.ts | 40 ++++ .../auth/application-delegation.test.ts | 22 ++ .../copilot/auth/application-delegation.ts | 26 +++ .../request/lifecycle/headless.test.ts | 18 ++ .../lib/copilot/request/lifecycle/headless.ts | 1 + .../lib/copilot/request/lifecycle/run.test.ts | 42 ++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 2 + .../tool-executor/register-handlers.ts | 2 +- apps/sim/lib/copilot/tool-executor/types.ts | 2 + .../copilot/tools/handlers/account.test.ts | 176 +++++++------- .../sim/lib/copilot/tools/handlers/account.ts | 36 +-- .../tools/handlers/enterprise-context.test.ts | 100 +++++++- .../tools/handlers/enterprise-context.ts | 80 +------ .../lib/organizations/settings-access.test.ts | 8 + apps/sim/lib/organizations/settings-access.ts | 5 +- .../lib/permission-groups/features.test.ts | 3 + .../application/authorization.ts | 12 + .../platform-context/application/context.ts | 14 ++ .../application/operations.ts | 24 ++ .../platform-context-use-cases.test.ts | 221 ++++++++++++++++++ .../application/read-account-billing.ts | 22 ++ .../application/read-enterprise-context.ts | 88 +++++++ 30 files changed, 989 insertions(+), 222 deletions(-) create mode 100644 apps/sim/lib/api/contracts/workspaces.test.ts create mode 100644 apps/sim/lib/billing/core/account-billing-snapshot.test.ts create mode 100644 apps/sim/lib/billing/core/account-billing-snapshot.ts create mode 100644 apps/sim/lib/copilot/application/execute-platform-context-use-case.ts create mode 100644 apps/sim/lib/platform-context/application/authorization.ts create mode 100644 apps/sim/lib/platform-context/application/context.ts create mode 100644 apps/sim/lib/platform-context/application/operations.ts create mode 100644 apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts create mode 100644 apps/sim/lib/platform-context/application/read-account-billing.ts create mode 100644 apps/sim/lib/platform-context/application/read-enterprise-context.ts diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index bbd1fcf69ce..bf3415aae92 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { + organizationRoleSchema, type PiiRedactionSettings, piiRedactionSettingsSchema, retentionOverridesSchema, @@ -15,9 +16,6 @@ const numericResponseSchema = z.preprocess((value) => { return Number.isFinite(parsed) ? parsed : value }, z.number()) -export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], { - error: 'Invalid role', -}) export const organizationParamsSchema = z.object({ id: z.string().min(1), }) diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 4e8a605a98f..7670830de49 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -6,6 +6,7 @@ import { customPatternSchema, isCanonicalBase64, organizationIdSchema, + organizationRoleSchema, piiStagePolicySchema, piiStagesSchema, privateSecretProvenanceBundleSchema, @@ -16,6 +17,16 @@ import { workspaceIdSchema, } from '@/lib/api/contracts/primitives' +describe('organizationRoleSchema', () => { + it.each(['owner', 'admin', 'member'] as const)('accepts canonical role %s', (role) => { + expect(organizationRoleSchema.parse(role)).toBe(role) + }) + + it.each(['billing-owner', 'viewer', '', null, undefined])('rejects invalid role %j', (role) => { + expect(organizationRoleSchema.safeParse(role).success).toBe(false) + }) +}) + describe('workspaceFileNameSchema', () => { it('trims and accepts one bounded file name', () => { expect(workspaceFileNameSchema.parse(' report.pdf ')).toBe('report.pdf') diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index c05dc4679fa..7de554762ba 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -231,6 +231,12 @@ export const workspaceFileNameSchema = z /** Non-empty `organizationId` field with a stable, human-readable message. */ export const organizationIdSchema = requiredFieldSchema('Organization ID is required') +/** Canonical organization membership role shared across API resource families. */ +export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], { + error: 'Invalid role', +}) +export type OrganizationRole = z.output + /** Non-empty `workflowId` field with a stable, human-readable message. */ export const workflowIdSchema = requiredFieldSchema('Workflow ID is required') diff --git a/apps/sim/lib/api/contracts/workspaces.test.ts b/apps/sim/lib/api/contracts/workspaces.test.ts new file mode 100644 index 00000000000..8adfeed25e4 --- /dev/null +++ b/apps/sim/lib/api/contracts/workspaces.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { workspaceHostContextSchema } from '@/lib/api/contracts/workspaces' + +const viewerSchema = workspaceHostContextSchema.shape.viewer +const viewer = { + permission: 'read' as const, + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, +} + +describe('workspaceHostContextSchema organizationRole', () => { + it.each(['owner', 'admin', 'member'] as const)( + 'accepts canonical role %s', + (organizationRole) => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole }).success).toBe(true) + } + ) + + it('retains null and omission for rolling response compatibility', () => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole: null }).success).toBe(true) + expect(viewerSchema.safeParse(viewer).success).toBe(true) + }) + + it('rejects non-canonical organization roles', () => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole: 'billing-owner' }).success).toBe( + false + ) + }) +}) diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index aaa1b836caa..9c32dc4d23e 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { nonEmptyIdSchema, requiredFieldSchema } from '@/lib/api/contracts/primitives' +import { + nonEmptyIdSchema, + organizationRoleSchema, + requiredFieldSchema, +} from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' export const workspaceScopeSchema = z.enum(['active', 'archived', 'all']) @@ -264,7 +268,7 @@ export const workspaceHostContextSchema = z.object({ isHostOrganizationMember: z.boolean(), isHostOrganizationAdmin: z.boolean(), /** Optional for rolling compatibility with app versions that predate organization-role projection. */ - organizationRole: z.string().nullable().optional(), + organizationRole: organizationRoleSchema.nullable().optional(), }), }) diff --git a/apps/sim/lib/billing/core/account-billing-snapshot.test.ts b/apps/sim/lib/billing/core/account-billing-snapshot.test.ts new file mode 100644 index 00000000000..503d01834af --- /dev/null +++ b/apps/sim/lib/billing/core/account-billing-snapshot.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + events: [] as string[], + getResolvedUserUsageData: vi.fn(), + getCreditBalanceForEntity: vi.fn(), + isOrgScopedSubscription: vi.fn(), +})) + +vi.mock('@/lib/billing/core/usage', () => ({ + getResolvedUserUsageData: mocks.getResolvedUserUsageData, +})) + +vi.mock('@/lib/billing/credits/balance', () => ({ + getCreditBalanceForEntity: mocks.getCreditBalanceForEntity, +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + isOrgScopedSubscription: mocks.isOrgScopedSubscription, +})) + +import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot' + +const usage = { + currentUsage: 18.5, + limit: 40, + percentUsed: 46.25, + isWarning: false, + isExceeded: false, + billingPeriodStart: new Date('2026-08-01T00:00:00Z'), + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + lastPeriodCost: 31, +} + +describe('getAccountBillingSnapshot', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.events.length = 0 + }) + + it('reuses one resolved subscription for org scope, usage, limits, and credits', async () => { + const subscription = { + plan: 'team', + referenceId: 'org-1', + } + mocks.getResolvedUserUsageData.mockImplementation(async () => { + mocks.events.push('usage-and-subscription') + return { usage, subscription, personalCreditBalance: 4 } + }) + mocks.isOrgScopedSubscription.mockReturnValue(true) + mocks.getCreditBalanceForEntity.mockImplementation(async () => { + mocks.events.push('credits') + return 25 + }) + + await expect(getAccountBillingSnapshot('user-1')).resolves.toEqual({ + plan: 'team', + billingScope: 'organization', + organizationId: 'org-1', + usage: { + currentPeriodCost: 18.5, + limit: 40, + remaining: 21.5, + percentUsed: 46.25, + isExceeded: false, + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + }, + credits: { balance: 25, scope: 'organization' }, + }) + expect(mocks.getResolvedUserUsageData).toHaveBeenCalledOnce() + expect(mocks.getCreditBalanceForEntity).toHaveBeenCalledWith( + 'organization', + 'org-1', + expect.anything() + ) + expect(mocks.events).toEqual(['usage-and-subscription', 'credits']) + }) + + it('preserves personal scope and clamps negative remaining usage to zero', async () => { + mocks.getResolvedUserUsageData.mockResolvedValue({ + usage: { ...usage, currentUsage: 45, isExceeded: true }, + subscription: { plan: 'pro', referenceId: 'user-1' }, + personalCreditBalance: 0, + }) + mocks.isOrgScopedSubscription.mockReturnValue(false) + mocks.getCreditBalanceForEntity.mockResolvedValue(0) + + await expect(getAccountBillingSnapshot('user-1')).resolves.toMatchObject({ + plan: 'pro', + billingScope: 'user', + organizationId: null, + usage: { remaining: 0, isExceeded: true }, + credits: { balance: 0, scope: 'user' }, + }) + expect(mocks.getCreditBalanceForEntity).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/account-billing-snapshot.ts b/apps/sim/lib/billing/core/account-billing-snapshot.ts new file mode 100644 index 00000000000..a7591357aca --- /dev/null +++ b/apps/sim/lib/billing/core/account-billing-snapshot.ts @@ -0,0 +1,58 @@ +import { db } from '@sim/db' +import { getResolvedUserUsageData } from '@/lib/billing/core/usage' +import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import type { DbClient } from '@/lib/db/types' + +export interface AccountBillingSnapshot { + plan: string + billingScope: 'user' | 'organization' + organizationId: string | null + usage: { + currentPeriodCost: number + limit: number + remaining: number + percentUsed: number + isExceeded: boolean + billingPeriodEnd: Date | null + } + credits: { + balance: number + scope: 'user' | 'organization' + } +} + +/** Resolves one coherent subscription, usage, limit, and credit snapshot for an account. */ +export async function getAccountBillingSnapshot( + userId: string, + executor: DbClient = db +): Promise { + const { usage, subscription, personalCreditBalance } = await getResolvedUserUsageData( + userId, + executor + ) + const organizationScoped = isOrgScopedSubscription(subscription, userId) && subscription !== null + const billingScope = organizationScoped ? 'organization' : 'user' + const billingEntityId = organizationScoped ? subscription.referenceId : userId + const creditBalance = organizationScoped + ? await getCreditBalanceForEntity('organization', billingEntityId, executor) + : personalCreditBalance + + return { + plan: subscription?.plan || 'free', + billingScope, + organizationId: organizationScoped ? subscription.referenceId : null, + usage: { + currentPeriodCost: usage.currentUsage, + limit: usage.limit, + remaining: Math.max(0, usage.limit - usage.currentUsage), + percentUsed: usage.percentUsed, + isExceeded: usage.isExceeded, + billingPeriodEnd: usage.billingPeriodEnd, + }, + credits: { + balance: creditBalance, + scope: billingScope, + }, + } +} diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index b68d1982623..0fe0e91009d 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -14,7 +14,10 @@ import { } from '@/components/emails' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' -import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' +import { + getHighestPrioritySubscription, + type HighestPrioritySubscription, +} from '@/lib/billing/core/plan' import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' import { computeDailyRefreshConsumed, @@ -190,13 +193,18 @@ export async function ensureUserStatsExists(userId: string): Promise { .onConflictDoNothing({ target: userStats.userId }) } -/** - * Get comprehensive usage data for a user - */ -export async function getUserUsageData( +export interface ResolvedUserUsageData { + usage: UsageData + subscription: HighestPrioritySubscription + /** The personal balance from the same user-stats row used to calculate usage. */ + personalCreditBalance: number +} + +/** Resolves comprehensive usage and the subscription that determined its billing scope. */ +export async function getResolvedUserUsageData( userId: string, executor: DbClient = db -): Promise { +): Promise { try { // Write — always on the primary regardless of executor routing. await ensureUserStatsExists(userId) @@ -332,14 +340,18 @@ export async function getUserUsageData( const isExceeded = effectiveUsage >= limit return { - currentUsage: effectiveUsage, - limit, - percentUsed, - isWarning, - isExceeded, - billingPeriodStart, - billingPeriodEnd, - lastPeriodCost, + usage: { + currentUsage: effectiveUsage, + limit, + percentUsed, + isWarning, + isExceeded, + billingPeriodStart, + billingPeriodEnd, + lastPeriodCost, + }, + subscription, + personalCreditBalance: toNumber(toDecimal(stats.creditBalance)), } } catch (error) { logger.error('Failed to get user usage data', { userId, error }) @@ -347,6 +359,14 @@ export async function getUserUsageData( } } +/** Get comprehensive usage data for a user. */ +export async function getUserUsageData( + userId: string, + executor: DbClient = db +): Promise { + return (await getResolvedUserUsageData(userId, executor)).usage +} + /** * Get usage limit information for a user */ diff --git a/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts b/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts new file mode 100644 index 00000000000..b891ea294ce --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts @@ -0,0 +1,40 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + InteractiveCopilotExecutionRequiredError, + requireInteractiveCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { + type PlatformContextOperation, + platformContextOperations, +} from '@/lib/platform-context/application/operations' + +const executePlatformContextUseCase = createCopilotApplicationAdapter({ + domain: 'platform context', + delegation: { + audience: platformContextDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: platformContextOperations, +}) + +/** Enters a live platform-context operation only from a trusted interactive Copilot lifecycle. */ +export function executeCopilotPlatformContextUseCase( + context: CopilotExecutionContext | undefined, + useCase: OperationUseCase, + input: I +): Promise { + const trustedContext = requireInteractiveCopilotExecutionContext(context) + return executePlatformContextUseCase(trustedContext, useCase, input) +} + +/** Projects only actionable authorization failures into live platform-context tool output. */ +export function messageForCopilotPlatformContextError(error: unknown): string { + if (error instanceof InteractiveCopilotExecutionRequiredError) return error.message + return messageForCopilotApplicationError(error) +} diff --git a/apps/sim/lib/copilot/auth/application-delegation.test.ts b/apps/sim/lib/copilot/auth/application-delegation.test.ts index 1e0bdc5037c..655f685fc64 100644 --- a/apps/sim/lib/copilot/auth/application-delegation.test.ts +++ b/apps/sim/lib/copilot/auth/application-delegation.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createCopilotApplicationPrincipal, + requireInteractiveCopilotExecutionContext, requireTrustedCopilotExecutionContext, } from '@/lib/copilot/auth/application-delegation' @@ -61,4 +62,25 @@ describe('Copilot application delegation', () => { }, }) }) + + it.each([undefined, 'headless' as const])( + 'rejects non-interactive live platform context (%s)', + (copilotInteractionMode) => { + expect(() => + requireInteractiveCopilotExecutionContext({ + ...trustedContext, + copilotInteractionMode, + }) + ).toThrow('only in an interactive Copilot session') + } + ) + + it('accepts a server-classified interactive lifecycle', () => { + expect( + requireInteractiveCopilotExecutionContext({ + ...trustedContext, + copilotInteractionMode: 'interactive', + }) + ).toMatchObject({ copilotInteractionMode: 'interactive' }) + }) }) diff --git a/apps/sim/lib/copilot/auth/application-delegation.ts b/apps/sim/lib/copilot/auth/application-delegation.ts index 969bf37b325..7b095d8c7ba 100644 --- a/apps/sim/lib/copilot/auth/application-delegation.ts +++ b/apps/sim/lib/copilot/auth/application-delegation.ts @@ -9,6 +9,7 @@ export interface CopilotExecutionContext { executionId?: string toolCallId?: string copilotToolExecution?: boolean + copilotInteractionMode?: 'interactive' | 'headless' } export interface TrustedCopilotExecutionContext extends CopilotExecutionContext { @@ -18,6 +19,17 @@ export interface TrustedCopilotExecutionContext extends CopilotExecutionContext copilotToolExecution: true } +export interface TrustedInteractiveCopilotExecutionContext extends TrustedCopilotExecutionContext { + copilotInteractionMode: 'interactive' +} + +export class InteractiveCopilotExecutionRequiredError extends Error { + constructor() { + super('Live platform context is available only in an interactive Copilot session.') + this.name = 'InteractiveCopilotExecutionRequiredError' + } +} + export type CopilotResourceScope = Pick< NonNullable, 'fileId' | 'tableId' @@ -74,9 +86,23 @@ export function requireTrustedCopilotExecutionContext( ...(context.executionId ? { executionId: context.executionId } : {}), toolCallId: context.toolCallId, copilotToolExecution: true, + ...(context.copilotInteractionMode + ? { copilotInteractionMode: context.copilotInteractionMode } + : {}), }) } +/** Restricts sensitive live platform reads to a server-classified interactive lifecycle. */ +export function requireInteractiveCopilotExecutionContext( + context: CopilotExecutionContext | undefined +): TrustedInteractiveCopilotExecutionContext { + const trustedContext = requireTrustedCopilotExecutionContext(context) + if (trustedContext.copilotInteractionMode !== 'interactive') { + throw new InteractiveCopilotExecutionRequiredError() + } + return trustedContext as TrustedInteractiveCopilotExecutionContext +} + /** Creates a bounded Copilot principal from an explicitly trusted server lifecycle. */ export function createTrustedCopilotPrincipal( input: CreateTrustedCopilotPrincipalInput, diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts b/apps/sim/lib/copilot/request/lifecycle/headless.test.ts index d31751c4ad2..42f2fbd9eb0 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.test.ts @@ -98,6 +98,24 @@ describe('runHeadlessCopilotLifecycle', () => { expect(result.success).toBe(false) }) + it('forces the server-owned headless classification', async () => { + runCopilotLifecycle.mockResolvedValueOnce(createLifecycleResult()) + + await runHeadlessCopilotLifecycle( + { message: 'hello', messageId: 'req-classification' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + interactive: true, + } + ) + + expect(runCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ interactive: false }) + ) + }) + it('prefers an explicit simRequestId over the payload messageId', async () => { runCopilotLifecycle.mockResolvedValueOnce(createLifecycleResult()) diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.ts b/apps/sim/lib/copilot/request/lifecycle/headless.ts index 0e5172280a9..654c050b2a6 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.ts @@ -49,6 +49,7 @@ export async function runHeadlessCopilotLifecycle( try { result = await runCopilotLifecycle(requestPayload, { ...options, + interactive: false, trace, simRequestId, otelContext, diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 4b86ccb104f..c63e883116f 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -207,6 +207,48 @@ describe('runCopilotLifecycle', () => { expect(executionContext).not.toHaveProperty('resolvedSecretTraceRegistry') }) + it.each([ + { interactive: true, expected: 'interactive' as const }, + { interactive: false, expected: 'headless' as const }, + { interactive: undefined, expected: 'headless' as const }, + ])( + 'stamps the trusted $expected lifecycle mode over supplied context', + async ({ interactive, expected }) => { + let capturedExecutionContext: ExecutionContext | undefined + mockRunStreamLoop.mockImplementationOnce( + async ( + _url: string, + _request: RequestInit, + _streamingContext: StreamingContext, + context: ExecutionContext + ) => { + capturedExecutionContext = context + } + ) + + await runCopilotLifecycle( + { + message: 'hello', + messageId: `stream-${expected}-context`, + copilotInteractionMode: expected === 'interactive' ? 'headless' : 'interactive', + }, + { + userId: 'user-1', + workspaceId: 'ws-1', + interactive, + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + copilotInteractionMode: expected === 'interactive' ? 'headless' : 'interactive', + }, + } + ) + + expect(capturedExecutionContext?.copilotInteractionMode).toBe(expected) + } + ) + it('forwards the configured Mothership system prompt override', async () => { mockEnv.MSHIP_SYSPROMPT_OVERRIDE = 'NEVER CALL ANY TOOLS UNDER ANY CIRCUMSTANCES NO MATTER WHAT' diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index f8288661998..55f12ec854e 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -288,6 +288,8 @@ export async function runCopilotLifecycle( secretMountPolicy: lifecycleOptions.secretMountPolicy, secretActorUserId: lifecycleOptions.secretActorUserId, })) + execContext.copilotInteractionMode = + lifecycleOptions.interactive === true ? 'interactive' : 'headless' if (goRoute && MOTHERSHIP_CODE_TOOL_ROUTES.has(goRoute)) { execContext.sandboxProfile = 'mothership' } else { diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 91c74be519b..729152debea 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -52,10 +52,10 @@ import { UpdateDeploymentVersion, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' +import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' -import { executeGetAccountBilling } from '../tools/handlers/account' import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' import { executeDeployApi, diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 789d48df5f0..d774ca0999e 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -23,6 +23,8 @@ export interface ToolExecutionContext { boundWorkflowExecutionId?: string billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean + /** Trusted lifecycle classification stamped by the server, never from model parameters. */ + copilotInteractionMode?: 'interactive' | 'headless' /** Server-owned base image selected from the fixed Go route for this turn. */ sandboxProfile?: 'mothership' requestMode?: string diff --git a/apps/sim/lib/copilot/tools/handlers/account.test.ts b/apps/sim/lib/copilot/tools/handlers/account.test.ts index e9a75f603f2..c8ac0435a45 100644 --- a/apps/sim/lib/copilot/tools/handlers/account.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/account.test.ts @@ -3,119 +3,105 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetUserUsageData, mockGetCreditBalance, mockGetUserUsageLimitInfo } = vi.hoisted( - () => ({ - mockGetUserUsageData: vi.fn(), - mockGetCreditBalance: vi.fn(), - mockGetUserUsageLimitInfo: vi.fn(), - }) -) +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getAccountBillingSnapshot: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) -vi.mock('@/lib/billing', () => ({ - getUserUsageData: mockGetUserUsageData, - getCreditBalance: mockGetCreditBalance, - getUserUsageLimitInfo: mockGetUserUsageLimitInfo, +vi.mock('@/lib/billing/core/account-billing-snapshot', () => ({ + getAccountBillingSnapshot: mocks.getAccountBillingSnapshot, })) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' -const context = { userId: 'user-1' } as ExecutionContext +const context = { + userId: 'user-1', + workflowId: '', + workspaceId: 'workspace-1', + chatId: 'chat-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + copilotInteractionMode: 'interactive', +} as const satisfies ExecutionContext + +const snapshot = { + plan: 'team', + billingScope: 'organization' as const, + organizationId: 'org-1', + usage: { + currentPeriodCost: 18.5, + limit: 40, + remaining: 21.5, + percentUsed: 46.25, + isExceeded: false, + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + }, + credits: { balance: 25, scope: 'organization' as const }, +} describe('executeGetAccountBilling', () => { beforeEach(() => { vi.clearAllMocks() - }) - - it('returns the org-aware plan, usage, and credit snapshot', async () => { - const periodEnd = new Date('2026-09-01T00:00:00Z') - mockGetUserUsageData.mockResolvedValue({ - currentUsage: 18.5, - limit: 40, - percentUsed: 46.25, - isWarning: false, - isExceeded: false, - billingPeriodStart: new Date('2026-08-01T00:00:00Z'), - billingPeriodEnd: periodEnd, - lastPeriodCost: 31, - }) - mockGetCreditBalance.mockResolvedValue({ - balance: 25, - entityType: 'organization', - entityId: 'org-1', + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', }) - mockGetUserUsageLimitInfo.mockResolvedValue({ - currentLimit: 40, - canEdit: false, - minimumLimit: 0, - plan: 'team', - updatedAt: null, - scope: 'organization', - organizationId: 'org-1', - }) - - const result = await executeGetAccountBilling(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getAccountBillingSnapshot.mockResolvedValue(snapshot) + }) - expect(mockGetUserUsageData).toHaveBeenCalledWith('user-1') - expect(mockGetCreditBalance).toHaveBeenCalledWith('user-1') - expect(mockGetUserUsageLimitInfo).toHaveBeenCalledWith('user-1') - expect(result).toEqual({ + it('returns the existing account billing tool result shape after authorization', async () => { + await expect(executeGetAccountBilling(context)).resolves.toEqual({ success: true, - output: { - plan: 'team', - billingScope: 'organization', - organizationId: 'org-1', - usage: { - currentPeriodCost: 18.5, - limit: 40, - remaining: 21.5, - percentUsed: 46.25, - isExceeded: false, - billingPeriodEnd: periodEnd, - }, - credits: { balance: 25, scope: 'organization' }, - }, + output: snapshot, }) + expect(mocks.getAccountBillingSnapshot).toHaveBeenCalledWith('user-1') }) - it('clamps remaining to zero when usage exceeds the limit', async () => { - mockGetUserUsageData.mockResolvedValue({ - currentUsage: 45, - limit: 40, - percentUsed: 112.5, - isWarning: false, - isExceeded: true, - billingPeriodStart: null, - billingPeriodEnd: null, - lastPeriodCost: 0, - }) - mockGetCreditBalance.mockResolvedValue({ balance: 0, entityType: 'user', entityId: 'user-1' }) - mockGetUserUsageLimitInfo.mockResolvedValue({ - currentLimit: 40, - canEdit: true, - minimumLimit: 0, - plan: 'pro', - updatedAt: null, - scope: 'user', - organizationId: null, - }) - - const result = await executeGetAccountBilling(context) + it.each(['headless' as const, undefined])( + 'fails closed for a non-interactive lifecycle (%s) before protected lookup', + async (copilotInteractionMode) => { + const result = await executeGetAccountBilling({ + ...context, + copilotInteractionMode, + }) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - plan: 'pro', - usage: { remaining: 0, isExceeded: true }, - }) - }) + expect(result).toEqual({ + success: false, + error: 'Live platform context is available only in an interactive Copilot session.', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getAccountBillingSnapshot).not.toHaveBeenCalled() + } + ) - it('surfaces a billing lookup failure as a tool error', async () => { - mockGetUserUsageData.mockRejectedValue(new Error('stats row missing')) - mockGetCreditBalance.mockResolvedValue({ balance: 0, entityType: 'user', entityId: 'user-1' }) - mockGetUserUsageLimitInfo.mockResolvedValue({}) + it('does not expose an underlying billing failure', async () => { + mocks.getAccountBillingSnapshot.mockRejectedValue( + new Error('connection secret from billing database') + ) - const result = await executeGetAccountBilling(context) - - expect(result).toEqual({ success: false, error: 'stats row missing' }) + await expect(executeGetAccountBilling(context)).resolves.toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/account.ts b/apps/sim/lib/copilot/tools/handlers/account.ts index ec3b3440f5a..051b46f931f 100644 --- a/apps/sim/lib/copilot/tools/handlers/account.ts +++ b/apps/sim/lib/copilot/tools/handlers/account.ts @@ -1,6 +1,9 @@ -import { toError } from '@sim/utils/errors' -import { getCreditBalance, getUserUsageData, getUserUsageLimitInfo } from '@/lib/billing' +import { + executeCopilotPlatformContextUseCase, + messageForCopilotPlatformContextError, +} from '@/lib/copilot/application/execute-platform-context-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' /** * Live billing snapshot for the requesting user: plan, current-period usage @@ -11,33 +14,14 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ */ export async function executeGetAccountBilling(context: ExecutionContext): Promise { try { - const [usage, credits, limitInfo] = await Promise.all([ - getUserUsageData(context.userId), - getCreditBalance(context.userId), - getUserUsageLimitInfo(context.userId), - ]) - + const output = await executeCopilotPlatformContextUseCase(context, readAccountBilling, { + workspaceId: context.workspaceId ?? '', + }) return { success: true, - output: { - plan: limitInfo.plan, - billingScope: limitInfo.scope, - organizationId: limitInfo.organizationId, - usage: { - currentPeriodCost: usage.currentUsage, - limit: usage.limit, - remaining: Math.max(0, usage.limit - usage.currentUsage), - percentUsed: usage.percentUsed, - isExceeded: usage.isExceeded, - billingPeriodEnd: usage.billingPeriodEnd, - }, - credits: { - balance: credits.balance, - scope: credits.entityType, - }, - }, + output, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotPlatformContextError(error) } } } diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts index a6d257e2326..231692c6a76 100644 --- a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts @@ -3,11 +3,31 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceHostContextForViewer, mockResolveVerifiedUserAccessControlContext } = - vi.hoisted(() => ({ - mockGetWorkspaceHostContextForViewer: vi.fn(), - mockResolveVerifiedUserAccessControlContext: vi.fn(), - })) +const { + mockGetWorkspaceHostContextForViewer, + mockResolveVerifiedUserAccessControlContext, + mockLoadWorkspace, + mockResolvePermission, +} = vi.hoisted(() => ({ + mockGetWorkspaceHostContextForViewer: vi.fn(), + mockResolveVerifiedUserAccessControlContext: vi.fn(), + mockLoadWorkspace: vi.fn(), + mockResolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mockLoadWorkspace, +})) vi.mock('@/lib/workspaces/host-context', () => ({ getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, @@ -23,8 +43,13 @@ import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' const context = { userId: 'user-1', + workflowId: '', workspaceId: 'workspace-1', -} as ExecutionContext + chatId: 'chat-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + copilotInteractionMode: 'interactive', +} as const satisfies ExecutionContext function enterpriseHost(permission: 'read' | 'write' | 'admin') { return { @@ -60,6 +85,13 @@ function enterpriseHost(permission: 'read' | 'write' | 'admin') { describe('executeGetEnterpriseContext', () => { beforeEach(() => { vi.clearAllMocks() + mockLoadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mockResolvePermission.mockResolvedValue('read') }) it('requires a current workspace', async () => { @@ -72,6 +104,21 @@ describe('executeGetEnterpriseContext', () => { expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() }) + it('rejects headless execution before loading workspace or enterprise context', async () => { + const result = await executeGetEnterpriseContext({ + ...context, + copilotInteractionMode: 'headless', + }) + + expect(result).toEqual({ + success: false, + error: 'Live platform context is available only in an interactive Copilot session.', + }) + expect(mockLoadWorkspace).not.toHaveBeenCalled() + expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + it('keeps external workspace administration separate from organization authority', async () => { mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ @@ -201,7 +248,7 @@ describe('executeGetEnterpriseContext', () => { capabilities: { canRead: true, canEdit: false, - canRun: false, + canRun: true, canDeploy: false, canManageWorkspace: false, }, @@ -210,6 +257,35 @@ describe('executeGetEnterpriseContext', () => { }) }) + it('does not advertise deployment when every deployment surface is hidden', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + }, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + capabilities: { + canRun: true, + canDeploy: false, + }, + }, + }, + }) + }) + it('returns a personal-workspace context without looking up organization membership', async () => { mockGetWorkspaceHostContextForViewer.mockResolvedValue({ ...enterpriseHost('write'), @@ -268,7 +344,10 @@ describe('executeGetEnterpriseContext', () => { const result = await executeGetEnterpriseContext(context) - expect(result).toEqual({ success: false, error: 'workspace lookup failed' }) + expect(result).toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() }) @@ -280,6 +359,9 @@ describe('executeGetEnterpriseContext', () => { const result = await executeGetEnterpriseContext(context) - expect(result).toEqual({ success: false, error: 'access-control lookup failed' }) + expect(result).toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts index ece309369d7..d72f7ae1db0 100644 --- a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts @@ -1,22 +1,9 @@ -import { permissionSatisfies } from '@sim/platform-authz/workspace' -import { toError } from '@sim/utils/errors' +import { + executeCopilotPlatformContextUseCase, + messageForCopilotPlatformContextError, +} from '@/lib/copilot/application/execute-platform-context-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' -import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' -import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' - -const ENTERPRISE_PERMISSION_DOCUMENTATION = [ - { - title: 'Roles and permissions', - path: 'docs/platform/permissions.mdx', - url: 'https://docs.sim.ai/platform/permissions', - }, - { - title: 'Enterprise Access Control', - path: 'docs/platform/enterprise/access-control.mdx', - url: 'https://docs.sim.ai/platform/enterprise/access-control', - }, -] as const +import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' /** * Resolves the authenticated user's effective Enterprise access in the current @@ -34,61 +21,14 @@ export async function executeGetEnterpriseContext( } try { - const hostContext = await getWorkspaceHostContextForViewer(context.workspaceId, context.userId) - if (!hostContext) { - return { - success: false, - error: 'Workspace not found or you do not have access.', - } - } - - const accessControl = await resolveVerifiedUserAccessControlContext( - context.userId, - context.workspaceId, - hostContext.hostOrganizationId - ) - - const canWrite = permissionSatisfies(hostContext.viewer.permission, 'write') - const canAdmin = permissionSatisfies(hostContext.viewer.permission, 'admin') - + const output = await executeCopilotPlatformContextUseCase(context, readEnterpriseContext, { + workspaceId: context.workspaceId, + }) return { success: true, - output: { - workspace: { - id: hostContext.workspace.id, - name: hostContext.workspace.name, - mode: hostContext.workspace.workspaceMode, - permission: hostContext.viewer.permission, - capabilities: { - canRead: true, - canEdit: canWrite, - canRun: canWrite, - canDeploy: canAdmin, - canManageWorkspace: canAdmin, - }, - }, - organization: hostContext.hostOrganizationId - ? { - id: hostContext.hostOrganizationId, - relationship: hostContext.viewer.isHostOrganizationMember ? 'internal' : 'external', - role: hostContext.viewer.organizationRole ?? null, - canManageOrganization: hostContext.viewer.isHostOrganizationAdmin, - canManageBilling: hostContext.viewer.isHostOrganizationAdmin, - plan: hostContext.ownerBilling.plan, - isEnterprise: hostContext.ownerBilling.isEnterprise, - } - : null, - accessControl: { - entitled: accessControl.entitled, - governingPermissionGroup: accessControl.permissionGroup, - effectiveConfig: accessControl.config, - activeRestrictions: getActivePermissionGroupRestrictions(accessControl.config), - }, - documentation: ENTERPRISE_PERMISSION_DOCUMENTATION, - resolvedAt: new Date().toISOString(), - }, + output, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotPlatformContextError(error) } } } diff --git a/apps/sim/lib/organizations/settings-access.test.ts b/apps/sim/lib/organizations/settings-access.test.ts index 3ac741ff69d..f598c09fd85 100644 --- a/apps/sim/lib/organizations/settings-access.test.ts +++ b/apps/sim/lib/organizations/settings-access.test.ts @@ -46,6 +46,14 @@ describe('organization settings access', () => { }) }) + it('fails closed when a stored membership has a non-canonical role', async () => { + queueTableRows(member, [{ role: 'billing-owner' }]) + + await expect(getOrganizationSettingsAccess('organization-route', 'viewer')).rejects.toThrow( + 'Invalid role' + ) + }) + it('allows members to view the roster but reserves control-plane sections for admins', async () => { queueTableRows(member, [{ role: 'member' }]) await expect( diff --git a/apps/sim/lib/organizations/settings-access.ts b/apps/sim/lib/organizations/settings-access.ts index 3281a11a930..db374c716db 100644 --- a/apps/sim/lib/organizations/settings-access.ts +++ b/apps/sim/lib/organizations/settings-access.ts @@ -7,11 +7,12 @@ import { type OrganizationSettingsSection, resolveOrganizationSectionAccess, } from '@/components/settings/navigation' +import { type OrganizationRole, organizationRoleSchema } from '@/lib/api/contracts/primitives' interface OrganizationSettingsAccess { isAdmin: boolean isMember: boolean - role: string | null + role: OrganizationRole | null } /** @@ -28,7 +29,7 @@ async function resolveOrganizationSettingsAccess( .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) .limit(1) - const role = membership?.role ?? null + const role = membership ? organizationRoleSchema.parse(membership.role) : null return { role, isMember: role !== null, diff --git a/apps/sim/lib/permission-groups/features.test.ts b/apps/sim/lib/permission-groups/features.test.ts index a22b5474004..c7496830cd3 100644 --- a/apps/sim/lib/permission-groups/features.test.ts +++ b/apps/sim/lib/permission-groups/features.test.ts @@ -1,3 +1,6 @@ +/** + * @vitest-environment node + */ import { describe, expect, it } from 'vitest' import { getActivePermissionGroupRestrictions, diff --git a/apps/sim/lib/platform-context/application/authorization.ts b/apps/sim/lib/platform-context/application/authorization.ts new file mode 100644 index 00000000000..11fe1a00bf5 --- /dev/null +++ b/apps/sim/lib/platform-context/application/authorization.ts @@ -0,0 +1,12 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export const PLATFORM_CONTEXT_DELEGATION_AUDIENCE = 'sim:platform-context' + +export const platformContextDelegationPolicy = { + audience: PLATFORM_CONTEXT_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: DelegatedPrincipal, + context: ActiveWorkspaceApplicationContext + ): boolean => principal.workspaceId === context.workspaceId, +} as const diff --git a/apps/sim/lib/platform-context/application/context.ts b/apps/sim/lib/platform-context/application/context.ts new file mode 100644 index 00000000000..f25cc85b882 --- /dev/null +++ b/apps/sim/lib/platform-context/application/context.ts @@ -0,0 +1,14 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +/** Loads canonical active workspace state before authorizing a live platform-context read. */ +export async function resolvePlatformContextWorkspace( + workspaceId: string +): Promise { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} diff --git a/apps/sim/lib/platform-context/application/operations.ts b/apps/sim/lib/platform-context/application/operations.ts new file mode 100644 index 00000000000..36c650a064f --- /dev/null +++ b/apps/sim/lib/platform-context/application/operations.ts @@ -0,0 +1,24 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const + +export const platformContextOperations = { + readAccountBilling: defineWorkspaceOperation({ + id: 'platform_context.account_billing.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, + }), + readEnterpriseContext: defineWorkspaceOperation({ + id: 'platform_context.enterprise.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, + }), +} as const + +export type PlatformContextOperation = + (typeof platformContextOperations)[keyof typeof platformContextOperations] diff --git a/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts b/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts new file mode 100644 index 00000000000..64319782d72 --- /dev/null +++ b/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts @@ -0,0 +1,221 @@ +/** + * @vitest-environment node + */ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getAccountBillingSnapshot: vi.fn(), + getWorkspaceHostContextForViewer: vi.fn(), + resolveVerifiedUserAccessControlContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/billing/core/account-billing-snapshot', () => ({ + getAccountBillingSnapshot: mocks.getAccountBillingSnapshot, +})) + +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mocks.getWorkspaceHostContextForViewer, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + resolveVerifiedUserAccessControlContext: mocks.resolveVerifiedUserAccessControlContext, +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' +import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' +import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} + +function copilotPrincipal(): DelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:platform-context', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + } +} + +describe('platform context application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + }) + + it('authorizes a current Copilot subject before reading account billing', async () => { + const snapshot = { + plan: 'pro', + billingScope: 'user', + organizationId: null, + usage: {}, + credits: {}, + } + mocks.getAccountBillingSnapshot.mockResolvedValue(snapshot) + + await expect( + readAccountBilling.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toBe(snapshot) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'org-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.getAccountBillingSnapshot).toHaveBeenCalledWith('user-1') + }) + + it.each([ + { + name: 'workspace API key', + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + }, + { + name: 'executor delegation', + principal: { ...copilotPrincipal(), serviceId: 'executor' as const }, + }, + ])('rejects a $name before loading protected account context', async ({ principal }) => { + await expect( + readAccountBilling.execute({ principal, input: { workspaceId: 'workspace-1' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getAccountBillingSnapshot).not.toHaveBeenCalled() + }) + + it('does not load enterprise context when current workspace access is absent', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.getWorkspaceHostContextForViewer).not.toHaveBeenCalled() + expect(mocks.resolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('projects enterprise context only after authorization', async () => { + mocks.getWorkspaceHostContextForViewer.mockResolvedValue({ + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + }, + hostOrganizationId: 'org-1', + ownerBilling: { plan: 'enterprise', isEnterprise: true }, + viewer: { + permission: 'admin', + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, + organizationRole: null, + }, + }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + entitled: true, + permissionGroup: null, + config: DEFAULT_PERMISSION_GROUP_CONFIG, + }) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + workspace: { + id: 'workspace-1', + capabilities: { canRead: true, canEdit: true, canDeploy: true }, + }, + organization: { + id: 'org-1', + relationship: 'external', + canManageOrganization: false, + }, + accessControl: { entitled: true }, + }) + expect(mocks.getWorkspaceHostContextForViewer).toHaveBeenCalledWith('workspace-1', 'user-1') + }) + + it('allows read-role execution but hides deployment when every deploy surface is hidden', async () => { + mocks.getWorkspaceHostContextForViewer.mockResolvedValue({ + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + }, + hostOrganizationId: 'org-1', + ownerBilling: { plan: 'enterprise', isEnterprise: true }, + viewer: { + permission: 'read', + isHostOrganizationMember: true, + isHostOrganizationAdmin: false, + organizationRole: 'member', + }, + }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + entitled: true, + permissionGroup: null, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + }, + }) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + workspace: { + capabilities: { + canRead: true, + canEdit: false, + canRun: true, + canDeploy: false, + canManageWorkspace: false, + }, + }, + }) + }) +}) diff --git a/apps/sim/lib/platform-context/application/read-account-billing.ts b/apps/sim/lib/platform-context/application/read-account-billing.ts new file mode 100644 index 00000000000..ee3bd7ed698 --- /dev/null +++ b/apps/sim/lib/platform-context/application/read-account-billing.ts @@ -0,0 +1,22 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type AccountBillingSnapshot, + getAccountBillingSnapshot, +} from '@/lib/billing/core/account-billing-snapshot' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { resolvePlatformContextWorkspace } from '@/lib/platform-context/application/context' +import { platformContextOperations } from '@/lib/platform-context/application/operations' + +export interface ReadAccountBillingInput { + workspaceId: string +} + +export const readAccountBilling = defineAuthorizedWorkspaceUseCase({ + operation: platformContextOperations.readAccountBilling, + resolveContext: ({ input }: { input: ReadAccountBillingInput }) => + resolvePlatformContextWorkspace(input.workspaceId), + authorizationOptions: { delegation: platformContextDelegationPolicy }, + execute: async ({ principal }): Promise => + getAccountBillingSnapshot(requirePrincipalSubjectUserId(principal)), +}) diff --git a/apps/sim/lib/platform-context/application/read-enterprise-context.ts b/apps/sim/lib/platform-context/application/read-enterprise-context.ts new file mode 100644 index 00000000000..dab02b17e5d --- /dev/null +++ b/apps/sim/lib/platform-context/application/read-enterprise-context.ts @@ -0,0 +1,88 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { resolvePlatformContextWorkspace } from '@/lib/platform-context/application/context' +import { platformContextOperations } from '@/lib/platform-context/application/operations' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' + +const ENTERPRISE_PERMISSION_DOCUMENTATION = [ + { + title: 'Roles and permissions', + path: 'docs/platform/permissions.mdx', + url: 'https://docs.sim.ai/platform/permissions', + }, + { + title: 'Enterprise Access Control', + path: 'docs/platform/enterprise/access-control.mdx', + url: 'https://docs.sim.ai/platform/enterprise/access-control', + }, +] as const + +export interface ReadEnterpriseContextInput { + workspaceId: string +} + +export const readEnterpriseContext = defineAuthorizedWorkspaceUseCase({ + operation: platformContextOperations.readEnterpriseContext, + resolveContext: ({ input }: { input: ReadEnterpriseContextInput }) => + resolvePlatformContextWorkspace(input.workspaceId), + authorizationOptions: { delegation: platformContextDelegationPolicy }, + async execute({ principal, context }) { + const userId = requirePrincipalSubjectUserId(principal) + const hostContext = await getWorkspaceHostContextForViewer(context.workspaceId, userId) + if (!hostContext) { + throw new OrchestrationError('not_found', 'Workspace not found or you do not have access.') + } + + const accessControl = await resolveVerifiedUserAccessControlContext( + userId, + context.workspaceId, + hostContext.hostOrganizationId + ) + const canWrite = permissionSatisfies(hostContext.viewer.permission, 'write') + const canAdmin = permissionSatisfies(hostContext.viewer.permission, 'admin') + const allDeploymentSurfacesHidden = + accessControl.config?.hideDeployApi === true && + accessControl.config.hideDeployMcp === true && + accessControl.config.hideDeployChatbot === true + + return { + workspace: { + id: hostContext.workspace.id, + name: hostContext.workspace.name, + mode: hostContext.workspace.workspaceMode, + permission: hostContext.viewer.permission, + capabilities: { + canRead: true, + canEdit: canWrite, + canRun: true, + canDeploy: canAdmin && !allDeploymentSurfacesHidden, + canManageWorkspace: canAdmin, + }, + }, + organization: hostContext.hostOrganizationId + ? { + id: hostContext.hostOrganizationId, + relationship: hostContext.viewer.isHostOrganizationMember ? 'internal' : 'external', + role: hostContext.viewer.organizationRole ?? null, + canManageOrganization: hostContext.viewer.isHostOrganizationAdmin, + canManageBilling: hostContext.viewer.isHostOrganizationAdmin, + plan: hostContext.ownerBilling.plan, + isEnterprise: hostContext.ownerBilling.isEnterprise, + } + : null, + accessControl: { + entitled: accessControl.entitled, + governingPermissionGroup: accessControl.permissionGroup, + effectiveConfig: accessControl.config, + activeRestrictions: getActivePermissionGroupRestrictions(accessControl.config), + }, + documentation: ENTERPRISE_PERMISSION_DOCUMENTATION, + resolvedAt: new Date().toISOString(), + } + }, +}) From 73ff4b7568daaced98c51e9bbb003245fdea1aa7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 13:03:23 -0700 Subject: [PATCH 044/103] Align Copilot tools and resource handling --- .../components/agent-group/tool-call-item.tsx | 4 +- .../message-content/message-content.test.ts | 18 +- .../message-content/message-content.tsx | 4 +- .../home/components/message-content/utils.ts | 14 +- .../resource-content/resource-content.tsx | 1 + .../home/hooks/stream/handle-tool-event.ts | 17 +- .../home/hooks/stream/stream-helpers.ts | 48 +- .../hooks/stream/turn-model-serialize.test.ts | 20 +- .../home/hooks/stream/turn-model.test.ts | 54 +- .../home/hooks/stream/turn-model.ts | 30 +- .../app/workspace/[workspaceId]/home/types.ts | 7 +- .../[workspaceId]/tables/[tableId]/table.tsx | 12 +- apps/sim/lib/api/contracts/custom-blocks.ts | 2 +- apps/sim/lib/billing/core/subscription.ts | 2 +- .../application/table-commands.test.ts | 2 +- .../lib/copilot/async-runs/repository.test.ts | 4 +- apps/sim/lib/copilot/chat/display-message.ts | 10 +- .../copilot/chat/effective-transcript.test.ts | 4 +- apps/sim/lib/copilot/chat/payload.test.ts | 4 +- apps/sim/lib/copilot/chat/payload.ts | 6 +- .../lib/copilot/generated/tool-catalog-v1.ts | 2244 +++++++++-------- .../lib/copilot/generated/tool-schemas-v1.ts | 1986 ++++++++------- .../copilot/request/context/result.test.ts | 10 +- .../request/go/file-preview-adapter.test.ts | 8 +- .../request/go/file-preview-adapter.ts | 41 +- .../sim/lib/copilot/request/go/stream.test.ts | 22 +- .../copilot/request/handlers/handlers.test.ts | 18 +- apps/sim/lib/copilot/request/handlers/tool.ts | 4 +- .../copilot/request/session/contract.test.ts | 2 +- .../lib/copilot/request/session/contract.ts | 12 +- .../lib/copilot/request/session/event.test.ts | 4 +- .../copilot/request/session/writer.test.ts | 2 +- .../sim/lib/copilot/request/sse-utils.test.ts | 2 +- .../copilot/request/tools/executor.test.ts | 2 +- .../sim/lib/copilot/request/tools/executor.ts | 48 +- .../lib/copilot/request/tools/files.test.ts | 44 +- apps/sim/lib/copilot/request/tools/files.ts | 10 +- .../copilot/request/tools/permission.test.ts | 20 +- .../lib/copilot/request/tools/permissions.ts | 4 +- .../tools/resolved-secret-result.test.ts | 4 +- .../lib/copilot/request/tools/tables.test.ts | 16 +- apps/sim/lib/copilot/request/tools/tables.ts | 4 +- apps/sim/lib/copilot/request/types.ts | 2 +- .../lib/copilot/resources/extraction.test.ts | 18 +- apps/sim/lib/copilot/resources/extraction.ts | 40 +- apps/sim/lib/copilot/resources/types.ts | 2 + .../copilot/tool-executor/executor.test.ts | 46 +- .../sim/lib/copilot/tool-executor/executor.ts | 2 +- .../tool-executor/register-handlers.ts | 40 +- .../copilot/tools/client/store-utils.test.ts | 4 +- .../tools/handlers/deployment/context.test.ts | 2 +- .../handlers/deployment/custom-block.test.ts | 2 +- .../tools/handlers/deployment/custom-block.ts | 2 +- .../tools/handlers/deployment/deploy.test.ts | 4 +- .../tools/handlers/deployment/deploy.ts | 6 +- .../tools/handlers/deployment/manage.ts | 3 +- .../tools/handlers/function-execute.test.ts | 20 +- .../tools/handlers/function-execute.ts | 8 +- .../handlers/management/manage-custom-tool.ts | 2 +- .../handlers/management/manage-mcp-tool.ts | 9 +- .../tools/handlers/materialize-file.test.ts | 8 +- .../tools/handlers/materialize-file.ts | 14 +- .../lib/copilot/tools/handlers/param-types.ts | 3 + .../tools/handlers/platform-actions.ts | 2 +- .../copilot/tools/handlers/resources.test.ts | 55 + .../lib/copilot/tools/handlers/resources.ts | 22 +- .../lib/copilot/tools/handlers/run-code.ts | 2 +- .../tools/handlers/upload-file-reader.test.ts | 6 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 4 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 2 +- .../sim/lib/copilot/tools/permissions.test.ts | 4 +- .../registry/server-tool-adapter.test.ts | 10 +- .../sim/lib/copilot/tools/server/base-tool.ts | 2 +- .../server/docs/search-documentation.test.ts | 2 +- .../tools/server/docs/search-documentation.ts | 4 +- .../tools/server/enrichment/enrichment-run.ts | 4 +- .../copilot/tools/server/files/create-file.ts | 9 +- .../copilot/tools/server/files/doc-compile.ts | 2 +- .../files/download-to-workspace-file.ts | 4 +- .../tools/server/files/edit-content.ts | 14 +- .../server/files/file-intent-store.test.ts | 4 +- .../tools/server/files/file-intent-store.ts | 2 +- .../tools/server/files/file-preview.ts | 6 +- .../tools/server/files/workspace-file.ts | 14 +- .../server/knowledge/knowledge-base.test.ts | 6 +- .../tools/server/knowledge/knowledge-base.ts | 6 +- .../server/knowledge/search-knowledge-base.ts | 4 +- .../tools/server/other/search-online.test.ts | 2 +- .../tools/server/other/search-online.ts | 4 +- apps/sim/lib/copilot/tools/server/router.ts | 22 +- .../tools/server/table/table-views.test.ts | 123 + .../copilot/tools/server/table/table-views.ts | 197 ++ .../tools/server/table/user-table.test.ts | 4 +- .../copilot/tools/server/table/user-table.ts | 54 +- .../lib/copilot/tools/tool-display.test.ts | 60 +- apps/sim/lib/copilot/tools/tool-display.ts | 66 +- apps/sim/lib/copilot/vfs/resource-writer.ts | 2 +- apps/sim/lib/copilot/vfs/serializers.ts | 35 + apps/sim/lib/copilot/vfs/workspace-vfs.ts | 37 +- .../lib/folders/application/resource-vfs.ts | Bin 14692 -> 14713 bytes .../mcp/application/workflow-deployments.ts | 2 +- apps/sim/lib/mcp/workflow-mcp-sync.ts | 2 +- apps/sim/lib/table/application/views.ts | 4 +- .../application/workspace-file-imports.ts | 2 +- apps/sim/lib/table/views/service.test.ts | 49 + apps/sim/lib/table/views/service.ts | 82 + apps/sim/lib/uploads/archive.test.ts | 2 +- apps/sim/lib/uploads/archive.ts | 2 +- .../workspace/track-chat-upload.test.ts | 2 +- .../workspace/workspace-file-manager.ts | 2 +- apps/sim/lib/uploads/utils/file-utils.ts | 2 +- apps/sim/lib/uploads/utils/validation.ts | 4 +- .../lib/workflows/custom-blocks/operations.ts | 2 +- .../orchestration/chat-deploy.test.ts | 2 +- .../workflows/orchestration/chat-deploy.ts | 8 +- 115 files changed, 3396 insertions(+), 2574 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/table/table-views.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-views.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index b00b5bc043a..2a06b33e197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -4,10 +4,10 @@ import { ShimmerText } from '@/components/ui' import { BrowserRequestTakeover, CallIntegrationTool, + PrepareFileEdit, Read as ReadTool, Terminal as TerminalTool, Wait as WaitTool, - WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' @@ -138,7 +138,7 @@ export function ToolCallItem({ }, [toolName, params, streamingArgs]) const liveWorkspaceFileTitle = useMemo(() => { - if (toolName !== WorkspaceFile.id || !streamingArgs) return null + if (toolName !== PrepareFileEdit.id || !streamingArgs) return null const titleMatch = streamingArgs.match(/"title"\s*:\s*"([^"]+)"/) if (!titleMatch?.[1]) return null const opMatch = streamingArgs.match(/"operation"\s*:\s*"(\w+)"/) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 8226865967a..a777c7006a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -135,7 +135,7 @@ describe('parseBlocks span-identity tree', () => { subagentStart('workflow', 'S1', 'main'), subagentToolCall('t1', 'create_workflow', 'S1', 'workflow'), subagentStart('deploy', 'S2', 'S1'), - subagentToolCall('t2', 'check_deployment_status', 'S2', 'deploy'), + subagentToolCall('t2', 'get_deployment_status', 'S2', 'deploy'), ] const segments = parseBlocks(blocks) @@ -185,9 +185,9 @@ describe('parseBlocks span-identity tree', () => { it('creates distinct groups for repeated deploy invocations (no collision)', () => { const blocks: ContentBlock[] = [ subagentStart('deploy', 'S2', 'main'), - subagentToolCall('t1', 'deploy_api', 'S2', 'deploy'), + subagentToolCall('t1', 'deploy_as_api', 'S2', 'deploy'), subagentStart('deploy', 'S4', 'main'), - subagentToolCall('t2', 'deploy_api', 'S4', 'deploy'), + subagentToolCall('t2', 'deploy_as_api', 'S4', 'deploy'), ] const segments = parseBlocks(blocks) @@ -311,7 +311,7 @@ describe('parseBlocks span-identity tree', () => { it('absorbs the dispatch tool of a nested file subagent from its parent span group', () => { const blocks: ContentBlock[] = [ subagentStart('workflow', 'S1', 'main'), - subagentToolCall('t1', 'workspace_file', 'S1', 'workflow'), + subagentToolCall('t1', 'prepare_file_edit', 'S1', 'workflow'), { type: 'subagent', content: 'file', spanId: 'S2', parentSpanId: 'S1', timestamp: 2 }, { type: 'subagent_text', content: 'writing', spanId: 'S2', timestamp: 3 }, ] @@ -321,7 +321,7 @@ describe('parseBlocks span-identity tree', () => { const workflow = segments[0] if (workflow.type !== 'agent_group') throw new Error('expected workflow group') - // The workspace_file dispatch tool is absorbed (not shown as a sibling tool); + // The prepare_file_edit dispatch tool is absorbed (not shown as a sibling tool); // only the nested file subagent remains under workflow. expect(workflow.items.some((item) => item.type === 'tool')).toBe(false) const nested = workflow.items.find((item) => item.type === 'agent_group') @@ -476,7 +476,7 @@ describe('completed tool titles', () => { type: 'tool_call', toolCall: { id: 'undeploy-api', - name: 'deploy_api', + name: 'deploy_as_api', status: 'success', params: { action: 'undeploy' }, }, @@ -485,7 +485,7 @@ describe('completed tool titles', () => { ]) ).toBe('Undeployed API') - expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_mcp')])).toBe('Deployed MCP tool') + expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_as_mcp')])).toBe('Deployed MCP tool') }) it('renders Compared after the full diff_workflows wire lifecycle succeeds', () => { @@ -755,7 +755,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => { const blocks: ContentBlock[] = [ { type: 'tool_call', - toolCall: { id: 'dispatch-1', name: 'workspace_file', status: 'executing' }, + toolCall: { id: 'dispatch-1', name: 'prepare_file_edit', status: 'executing' }, timestamp: 1, }, { @@ -785,7 +785,7 @@ describe('deriveThinkingLabel', () => { it('shows Dispatching for the dispatch call, then yields to the opened lane', () => { expect(deriveThinkingLabel([mainToolCall('t1', 'workflow')])).toBe('Dispatching…') - expect(deriveThinkingLabel([mainToolCall('t1', 'workspace_file')])).toBe('Dispatching…') + expect(deriveThinkingLabel([mainToolCall('t1', 'prepare_file_edit')])).toBe('Dispatching…') expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking…') expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBeNull() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 4b230fb4fd3..24dfc3fd842 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -11,7 +11,7 @@ import { useState, } from 'react' import { cn } from '@sim/emcn' -import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' +import { PrepareFileEdit, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' @@ -127,7 +127,7 @@ const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS)) * group is absorbed so it doesn't render as a separate Mothership entry. */ const SUBAGENT_DISPATCH_TOOLS: Record = { - [FILE_SUBAGENT_ID]: WorkspaceFile.id, + [FILE_SUBAGENT_ID]: PrepareFileEdit.id, } function isToolResultRead(params?: Record): boolean { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 8079fc40de6..16179983449 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -33,19 +33,19 @@ const TOOL_ICONS: Record = { mv: FolderCode, cp: Layout, mkdir: FolderCode, - search_online: Search, - scrape_page: Search, - get_page_contents: Search, + web_search: Search, + web_scrape: Search, + web_fetch: Search, search_library_docs: Library, - manage_mcp_tool: Settings, + manage_mcp_connection: Settings, manage_skill: Asterisk, user_memory: Database, - function_execute: TerminalWindow, + run_function: TerminalWindow, run_code: TerminalWindow, superagent: Blimp, user_table: TableIcon, - workspace_file: File, - edit_content: File, + prepare_file_edit: File, + apply_file_edit: File, create_workflow: Layout, edit_workflow: Pencil, workflow: Hammer, 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 4378ba9a3cf..48f3769b17b 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 @@ -256,6 +256,7 @@ export const ResourceContent = memo(function ResourceContent({ tableId={resource.id} embedded viewsEnabled={tableViewsEnabled} + initialViewId={resource.viewId} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts index e00e851626d..2f23df785b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -4,7 +4,7 @@ import { MothershipStreamV1ToolPhase, MothershipStreamV1ToolStatus, } from '@/lib/copilot/generated/mothership-stream-v1' -import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' +import { ApplyFileEdit, PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import { extractResourcesFromToolResult, @@ -77,7 +77,7 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void invalidateResourceQueries(deps.queryClient, deps.workspaceId, resource.type, resource.id) } - if ((name === 'edit_content' || name === WorkspaceFile.id) && isSuccess) { + if ((name === ApplyFileEdit.id || name === PrepareFileEdit.id) && isSuccess) { const out = output as Record | undefined const editData = out && typeof out.data === 'object' && out.data !== null @@ -100,17 +100,20 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void deps.onToolResultRef.current?.(name, isSuccess, output) const workspaceFileOperation = - name === WorkspaceFile.id && typeof params?.operation === 'string' + name === PrepareFileEdit.id && typeof params?.operation === 'string' ? params.operation : undefined const shouldKeepWorkspacePreviewOpen = - name === WorkspaceFile.id && + name === PrepareFileEdit.id && (workspaceFileOperation === 'append' || workspaceFileOperation === 'update' || workspaceFileOperation === 'patch') - if ((name === WorkspaceFile.id || name === 'edit_content') && !shouldKeepWorkspacePreviewOpen) { - if (name === WorkspaceFile.id) { + if ( + (name === PrepareFileEdit.id || name === ApplyFileEdit.id) && + !shouldKeepWorkspacePreviewOpen + ) { + if (name === PrepareFileEdit.id) { deps.removePreviewSessionImmediate(node.id) } const fileResource = extractedResources.find((r) => r.type === 'file') @@ -126,7 +129,7 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void /** * Side effects for tool events. State (the tool node, its status, args, and the - * edit_content row merge) is owned by `reduceEvent`; this handler routes preview + * apply_file_edit row merge) is owned by `reduceEvent`; this handler routes preview * phases, fires client workflow tools, and runs result side effects, then * flushes the model-derived snapshot. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 90e8d0f9cac..2abb9ef6a7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -2,30 +2,30 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' import { CallIntegrationTool, - CrawlWebsite, - CreateFile, + CreateEmptyFile, CreateWorkflow, - DeployApi, - DeployChat, - DeployMcp, + DeployAsApi, + DeployAsChat, + DeployAsMcp, EditWorkflow, - FunctionExecute, Glob, Grep, ManageCredential, ManageCustomTool, - ManageMcpTool, + ManageMcpConnection, ManageSkill, + PrepareFileEdit, + PrepareFileEditOperation, QueryLogs, Redeploy, Rm, RunFromBlock, + RunFunction, RunWorkflow, RunWorkflowUntilBlock, - ScrapePage, - SearchOnline, - WorkspaceFile, - WorkspaceFileOperation, + WebCrawl, + WebScrape, + WebSearch, } from '@/lib/copilot/generated/tool-catalog-v1' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display' @@ -40,9 +40,9 @@ const logger = createLogger('StreamHelpers') export const FILE_SUBAGENT_ID = 'file' export const DEPLOY_TOOL_NAMES: Set = new Set([ - DeployApi.id, - DeployChat.id, - DeployMcp.id, + DeployAsApi.id, + DeployAsChat.id, + DeployAsMcp.id, Redeploy.id, ]) @@ -139,13 +139,13 @@ function resolveWorkspaceFileDisplayTitle( let verb = 'Writing' switch (operation) { - case WorkspaceFileOperation.append: + case PrepareFileEditOperation.append: verb = 'Adding' break - case WorkspaceFileOperation.patch: + case PrepareFileEditOperation.patch: verb = 'Editing' break - case WorkspaceFileOperation.update: + case PrepareFileEditOperation.update: verb = 'Writing' break } @@ -270,11 +270,11 @@ export function resolveStreamingToolDisplayTitle( name: string, streamingArgs: string ): string | undefined { - if (name === FunctionExecute.id) { + if (name === RunFunction.id) { return functionExecuteTitle(matchStreamingStringArg(streamingArgs, 'title')) } - if (name === WorkspaceFile.id) { + if (name === PrepareFileEdit.id) { return resolveWorkspaceFileDisplayTitle( matchStreamingStringArg(streamingArgs, 'operation'), matchStreamingStringArg(streamingArgs, 'title'), @@ -282,7 +282,7 @@ export function resolveStreamingToolDisplayTitle( ) } - if (name === CreateFile.id) { + if (name === CreateEmptyFile.id) { const target = matchStreamingStringArg(streamingArgs, 'path') ?? matchStreamingStringArg(streamingArgs, 'fileName') @@ -299,7 +299,7 @@ export function resolveStreamingToolDisplayTitle( return workflowId ? resolveToolDisplayTitle(name, { workflowId }) : undefined } - if (name === SearchOnline.id) { + if (name === WebSearch.id) { const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') return toolTitle ? `Searching online for ${toolTitle}` : undefined } @@ -349,12 +349,12 @@ export function resolveStreamingToolDisplayTitle( return toolTitle ? `Deleting ${toolTitle}` : undefined } - if (name === ScrapePage.id) { + if (name === WebScrape.id) { const url = matchStreamingStringArg(streamingArgs, 'url') return url ? `Scraping ${url}` : undefined } - if (name === CrawlWebsite.id) { + if (name === WebCrawl.id) { const url = matchStreamingStringArg(streamingArgs, 'url') return url ? `Crawling ${url}` : undefined } @@ -363,7 +363,7 @@ export function resolveStreamingToolDisplayTitle( return resolveStreamingManagedResourceTitle(name, streamingArgs, ['toolTitle', 'title', 'name']) } - if (name === ManageMcpTool.id) { + if (name === ManageMcpConnection.id) { return resolveStreamingManagedResourceTitle(name, streamingArgs, [ 'serverName', 'name', diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts index d4a5b69e3aa..a764b32fa9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts @@ -68,7 +68,7 @@ describe('streaming resource titles', () => { }) // A main-agent file delegation: trigger tool (main lane), subagent span, inner -// workspace_file, span end, delegation result. +// prepare_file_edit, span end, delegation result. function fileDelegationEvents(): PersistedStreamEventEnvelope[] { const sub: Scope = { lane: 'subagent', @@ -89,13 +89,13 @@ function fileDelegationEvents(): PersistedStreamEventEnvelope[] { env( 4, 'tool', - { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' }, + { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' }, { lane: 'subagent', spanId: 'S1' } ), env( 5, 'tool', - { phase: 'result', toolCallId: 'wf-1', toolName: 'workspace_file', success: true }, + { phase: 'result', toolCallId: 'wf-1', toolName: 'prepare_file_edit', success: true }, { lane: 'subagent', spanId: 'S1' } ), env( @@ -124,7 +124,7 @@ describe('modelToContentBlocks', () => { expect(trigger?.toolCall?.status).toBe('success') const innerTool = blocksByType(blocks, 'tool_call').find( - (b) => b.toolCall?.name === 'workspace_file' + (b) => b.toolCall?.name === 'prepare_file_edit' ) expect(innerTool?.spanId).toBe('S1') expect(innerTool?.toolCall?.calledBy).toBe('file') @@ -220,7 +220,7 @@ describe('modelToContentBlocks', () => { env( 4, 'tool', - { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' }, + { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' }, { lane: 'subagent', spanId: 'S1' } ), env( @@ -233,7 +233,7 @@ describe('modelToContentBlocks', () => { ]) ) const types = blocks.map((b) => b.type) - const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'workspace_file') + const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'prepare_file_edit') const endIdx = types.indexOf('subagent_end') const afterIdx = blocks.findIndex((b) => b.type === 'text' && b.content === 'after') // subagent_end sits after the inner work and before the trailing main text — no sibling jumps. @@ -275,7 +275,7 @@ describe('modelToContentBlocks', () => { env( 3, 'tool', - { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' }, + { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' }, { lane: 'subagent', spanId: 'S1' } ), ]) @@ -309,7 +309,7 @@ describe('modelToContentBlocks', () => { env(1, 'tool', { phase: 'call', toolCallId: 'wf', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', arguments: { operation: 'create', title: 'My Doc' }, }), ]) @@ -322,7 +322,7 @@ describe('modelToContentBlocks', () => { const blocks = modelToContentBlocks(build(fileDelegationEvents())) const startIdx = blocks.findIndex((b) => b.type === 'subagent') const innerIdx = blocks.findIndex( - (b) => b.type === 'tool_call' && b.toolCall?.name === 'workspace_file' + (b) => b.type === 'tool_call' && b.toolCall?.name === 'prepare_file_edit' ) const endIdx = blocks.findIndex((b) => b.type === 'subagent_end') expect(startIdx).toBeGreaterThanOrEqual(0) @@ -425,7 +425,7 @@ describe('contentBlocksToModel round-trip', () => { env( 3, 'tool', - { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' }, + { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' }, { lane: 'subagent', spanId: 'S1' } ), ]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 4681ae8e402..971ecd57b85 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -144,17 +144,17 @@ describe('reduceEvent — tool lifecycle', () => { it('accumulates streaming args across deltas', () => { const m = apply([ - toolCall(1, 'tc-1', 'workspace_file'), + toolCall(1, 'tc-1', 'prepare_file_edit'), envelope(2, 'tool', { phase: 'args_delta', toolCallId: 'tc-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', argumentsDelta: '{"a":', }), envelope(3, 'tool', { phase: 'args_delta', toolCallId: 'tc-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', argumentsDelta: '1}', }), ]) @@ -164,11 +164,11 @@ describe('reduceEvent — tool lifecycle', () => { it('clears streamingArgs once the result settles the tool', () => { const m = apply([ - toolCall(1, 'tc-1', 'workspace_file'), + toolCall(1, 'tc-1', 'prepare_file_edit'), envelope(2, 'tool', { phase: 'args_delta', toolCallId: 'tc-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', argumentsDelta: '{"operation":"create"', }), toolResult(3, 'tc-1', true), @@ -213,11 +213,11 @@ describe('reduceEvent — tool lifecycle', () => { it('ignores preview phases (decoupled from tool status)', () => { const m = apply([ - toolCall(1, 'tc-1', 'workspace_file'), + toolCall(1, 'tc-1', 'prepare_file_edit'), envelope(2, 'tool', { previewPhase: 'file_preview_content', toolCallId: 'tc-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', content: 'x', contentMode: 'delta', fileName: 'f', @@ -270,8 +270,8 @@ describe('reduceEvent — subagent lifecycle', () => { const m = apply([ spanStart(1, 'S1', 'file', 'tc-a'), spanStart(2, 'S2', 'file', 'tc-b'), - toolCall(3, 'wf-a', 'workspace_file', { lane: 'subagent', spanId: 'S1' }), - toolCall(4, 'wf-b', 'workspace_file', { lane: 'subagent', spanId: 'S2' }), + toolCall(3, 'wf-a', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }), + toolCall(4, 'wf-b', 'prepare_file_edit', { lane: 'subagent', spanId: 'S2' }), toolResult(5, 'wf-a', true), spanEnd(6, 'S1', 'file'), toolResult(7, 'wf-b', true), @@ -327,7 +327,7 @@ describe('reduceEvent — idempotency', () => { it('rebuilds the identical model when replayed into a fresh model', () => { const events = [ spanStart(1, 'S1', 'file', 'tc-file'), - toolCall(2, 'wf', 'workspace_file', { lane: 'subagent', spanId: 'S1' }), + toolCall(2, 'wf', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }), toolResult(3, 'wf', true), spanEnd(4, 'S1', 'file'), complete(5), @@ -340,42 +340,42 @@ describe('reduceEvent — idempotency', () => { }) }) -describe('reduceEvent — edit_content row merge', () => { - it('folds an edit_content write into its span workspace_file row', () => { +describe('reduceEvent — apply_file_edit row merge', () => { + it('folds an apply_file_edit write into its span prepare_file_edit row', () => { const sub: Scope = { lane: 'subagent', spanId: 'S1' } const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), - toolCall(2, 'wf-1', 'workspace_file', sub), + toolCall(2, 'wf-1', 'prepare_file_edit', sub), toolResult(3, 'wf-1', true, undefined, sub), - toolCall(4, 'ec-1', 'edit_content', sub), + toolCall(4, 'ec-1', 'apply_file_edit', sub), ]) - // No separate edit_content node; the workspace_file row reopened for the edit. + // No separate apply_file_edit node; the prepare_file_edit row reopened for the edit. expect(m.nodes.has('ec-1')).toBe(false) expect(tool(m, 'wf-1').status).toBe('running') expect(m.toolAlias.get('ec-1')).toBe('wf-1') }) - it('settles the merged row on the edit_content result', () => { + it('settles the merged row on the apply_file_edit result', () => { const sub: Scope = { lane: 'subagent', spanId: 'S1' } const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), - toolCall(2, 'wf-1', 'workspace_file', sub), - toolCall(3, 'ec-1', 'edit_content', sub), + toolCall(2, 'wf-1', 'prepare_file_edit', sub), + toolCall(3, 'ec-1', 'apply_file_edit', sub), toolResult(4, 'ec-1', true, undefined, sub), ]) expect(tool(m, 'wf-1').status).toBe('success') expect(m.nodes.has('ec-1')).toBe(false) }) - it('folds an edit_content result that raced ahead of its call into the merged row', () => { + it('folds an apply_file_edit result that raced ahead of its call into the merged row', () => { const sub: Scope = { lane: 'subagent', spanId: 'S1' } const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), - toolCall(2, 'wf-1', 'workspace_file', sub), - // Result for edit_content arrives BEFORE its call (buffered under ec-1)... + toolCall(2, 'wf-1', 'prepare_file_edit', sub), + // Result for apply_file_edit arrives BEFORE its call (buffered under ec-1)... toolResult(3, 'ec-1', true, undefined, sub), // ...then the call lands and aliases ec-1 -> wf-1, draining the buffer. - toolCall(4, 'ec-1', 'edit_content', sub), + toolCall(4, 'ec-1', 'apply_file_edit', sub), ]) expect(tool(m, 'wf-1').status).toBe('success') expect(tool(m, 'wf-1').result?.success).toBe(true) @@ -386,13 +386,13 @@ describe('reduceEvent — edit_content row merge', () => { const sub: Scope = { lane: 'subagent', spanId: 'S1' } const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), - // Section 1: the workspace_file row is reopened by its edit_content, but the + // Section 1: the prepare_file_edit row is reopened by its apply_file_edit, but the // edit's closing result is reordered/dropped — wf-1 is left running. - toolCall(2, 'wf-1', 'workspace_file', sub), + toolCall(2, 'wf-1', 'prepare_file_edit', sub), toolResult(3, 'wf-1', true, undefined, sub), - toolCall(4, 'ec-1', 'edit_content', sub), + toolCall(4, 'ec-1', 'apply_file_edit', sub), // Section 2 opens before section 1's edit result lands. - toolCall(5, 'wf-2', 'workspace_file', sub), + toolCall(5, 'wf-2', 'prepare_file_edit', sub), ]) // The previous section settles instead of spinning until the turn terminal... expect(tool(m, 'wf-1').status).toBe('success') @@ -498,7 +498,7 @@ describe('turn-terminal propagation', () => { // A file subagent opened but no span end arrived (mid-stream error/disconnect). const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), - toolCall(2, 'wf-1', 'workspace_file', { lane: 'subagent', spanId: 'S1' }), + toolCall(2, 'wf-1', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }), ]) expect(agent(m, 'S1').endSeq).toBeUndefined() applyTurnTerminal(m, 'error') diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 6ac6b32e3f3..d636bf8b470 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -121,7 +121,7 @@ export interface TurnModel { > /** * Maps a tool call id to another tool node it folds into. Used for the - * `edit_content` -> `workspace_file` row merge so the write streams into the + * `apply_file_edit` -> `prepare_file_edit` row merge so the write streams into the * single "writing" row rather than a second row. */ toolAlias: Map @@ -142,16 +142,16 @@ export function createTurnModel(): TurnModel { } } -const WORKSPACE_FILE_TOOL = 'workspace_file' -const EDIT_CONTENT_TOOL = 'edit_content' +const WORKSPACE_FILE_TOOL = 'prepare_file_edit' +const EDIT_CONTENT_TOOL = 'apply_file_edit' -/** Resolves a tool call id through the alias map (e.g. edit_content -> its workspace_file row). */ +/** Resolves a tool call id through the alias map (e.g. apply_file_edit -> its prepare_file_edit row). */ export function resolveToolId(model: TurnModel, id: string): string { return model.toolAlias.get(id) ?? id } /** - * Finds the most recent `workspace_file` tool node in a span so an `edit_content` + * Finds the most recent `prepare_file_edit` tool node in a span so an `apply_file_edit` * write folds into it (the single "writing" row). Co-location in the file * subagent's span is the link — no coupling to preview phases. The caller * reopens whatever this returns, including an already-settled row (an edit after @@ -169,11 +169,11 @@ function findWorkspaceFileNodeInSpan(model: TurnModel, spanId: string): ToolNode } /** - * The file agent writes a file as strictly sequential `workspace_file` + - * `edit_content` section pairs, waiting for each to finish before the next. So - * when a new section's `workspace_file` opens, any earlier `workspace_file` row + * The file agent writes a file as strictly sequential `prepare_file_edit` + + * `apply_file_edit` section pairs, waiting for each to finish before the next. So + * when a new section's `prepare_file_edit` opens, any earlier `prepare_file_edit` row * still `running` in the same span is a completed section whose closing - * `edit_content` result was reordered or dropped — finalize it as success so its + * `apply_file_edit` result was reordered or dropped — finalize it as success so its * "writing" spinner resolves when the next section starts, instead of lingering * until the turn-terminal sweep. A no-op on the happy path (prior rows already * settled on their own result). @@ -331,8 +331,8 @@ function appendText( /** * Applies a result that raced ahead of its tool `call` (buffered under `fromId`) * onto `node`, then clears the buffer. Used by the normal call path and by the - * edit_content -> workspace_file merge, where the buffer is keyed by the - * edit_content id but folds into the workspace_file row. + * apply_file_edit -> prepare_file_edit merge, where the buffer is keyed by the + * apply_file_edit id but folds into the prepare_file_edit row. */ function drainBufferedResult(model: TurnModel, fromId: string, node: ToolNode): void { const buffered = model.bufferedResults.get(fromId) @@ -473,12 +473,12 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve ensureSubagentLane(model, spanId, scope, seq, tsMs) const phase = payload.phase if (phase === MothershipStreamV1ToolPhase.call) { - // edit_content folds into its span's workspace_file row (the write + // apply_file_edit folds into its span's prepare_file_edit row (the write // continues in the single "writing" row), reopening it for the edit. if (toolName === EDIT_CONTENT_TOOL) { - // A re-emitted edit_content call (same tool call id — duplicate/replay) + // A re-emitted apply_file_edit call (same tool call id — duplicate/replay) // must keep its ORIGINAL target row. Re-running the span lookup can - // return a newer workspace_file, and folding into that would leave the + // return a newer prepare_file_edit, and folding into that would leave the // first (already reopened) row running with no result ever closing it — // a spinner stuck until the turn-terminal sweep. So once aliased, reuse. const aliasedId = model.toolAlias.get(rawToolCallId) @@ -492,7 +492,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve parent.status = 'running' parent.result = undefined // A result that raced ahead of this call was buffered under the - // edit_content id; fold it into the reopened workspace_file row. + // apply_file_edit id; fold it into the reopened prepare_file_edit row. drainBufferedResult(model, rawToolCallId, parent) break } diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index e6d21c27765..f29c7b86125 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -1,7 +1,7 @@ import type { ChatContext } from '@/stores/panel' import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types' -const EDIT_CONTENT_TOOL_ID = 'edit_content' +const EDIT_CONTENT_TOOL_ID = 'apply_file_edit' const RUN_SUBAGENT_ID = 'run' export type { @@ -191,7 +191,10 @@ export const SUBAGENT_LABELS: Record = { search: 'Search Agent', superagent: 'Superagent', run: 'Run Agent', - agent: 'Tools Agent', + // The extensions subagent's wire/scope AgentID stays `agent` (pre-rename); + // `extensions` is its current model-facing trigger tool name. + agent: 'Extensions Agent', + extensions: 'Extensions Agent', // `job` retained as a backward-compat alias so historical transcripts still render a label. job: 'Job Agent', file: 'File Agent', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 8db321108dc..d9f04de5a17 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -115,6 +115,13 @@ interface TableProps { * context to resolve it — stays on today's Filter/Sort bar. */ viewsEnabled?: boolean + /** + * Saved view to adopt on first seed instead of the table's default — + * embedded mode only, set when the agent opened this table pinned to a + * view. Participates only in the one-time adoption branch, so it never + * fights a later user switch. + */ + initialViewId?: string } /** @@ -213,6 +220,7 @@ function isSameViewConfig(a: TableViewConfig, b: TableViewConfig): boolean { */ export function Table({ embedded, + initialViewId, workspaceId: propWorkspaceId, tableId: propTableId, tableLocksEnabled = false, @@ -546,7 +554,9 @@ export function Table({ !views.some((view) => view.id === activeViewId) if (activeViewId === null || inheritedParams) { - const defaultView = views.find((view) => view.isDefault) + const pinnedView = + embedded && initialViewId ? views.find((view) => view.id === initialViewId) : undefined + const defaultView = pinnedView ?? views.find((view) => view.isDefault) // `sort` rides the same host URL, so when the view id is inherited the // sort beside it is too — not local work, and it must not suppress the // default view's own sort. diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index 70f40a1132f..99ec47a5f00 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -83,7 +83,7 @@ export const listCustomBlocksQuerySchema = z.object({ * Icon URLs are rendered as org-wide `` sources, so only https URLs and * internal file-serve paths (what the icon upload UI stores) are accepted — * never data:/blob:/other schemes an admin could smuggle into shared metadata. - * Shared with the copilot deploy_custom_block handler's pass-through branch. + * Shared with the copilot publish_custom_block handler's pass-through branch. */ export function isAllowedCustomBlockIconUrl(value: string): boolean { return value.startsWith('https://') || value.startsWith('/api/files/serve/') diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 44345553e4d..50c11d0ab20 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -702,7 +702,7 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise { try { diff --git a/apps/sim/lib/copilot/application/table-commands.test.ts b/apps/sim/lib/copilot/application/table-commands.test.ts index 900f8b76dfe..f1d734dec1e 100644 --- a/apps/sim/lib/copilot/application/table-commands.test.ts +++ b/apps/sim/lib/copilot/application/table-commands.test.ts @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ createFromFile: { operation: { id: 'tables.imports.create_from_workspace_file' } }, createWorkflowGroup: { operation: { id: 'tables.groups.create' } }, deleteTables: { operation: { id: 'tables.delete' } }, - importFile: { operation: { id: 'tables.imports.workspace_file' } }, + importFile: { operation: { id: 'tables.imports.prepare_file_edit' } }, replaceProjectedRows: { operation: { id: 'tables.rows.replace' } }, updateWorkflowGroup: { operation: { id: 'tables.groups.update' } }, }, diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index e6098a8b851..81bdd061986 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -282,7 +282,7 @@ describe('async tool repository single-row semantics', () => { const existingRow = { runId: 'run-1', toolCallId: 'tool-1', - toolName: 'function_execute', + toolName: 'run_function', args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' }, status, } @@ -291,7 +291,7 @@ describe('async tool repository single-row semantics', () => { const result = await upsertAsyncToolCall({ runId: 'run-1', toolCallId: 'tool-1', - toolName: 'function_execute', + toolName: 'run_function', args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' }, status: 'pending', }) diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 24df088f595..443d517a7bb 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -138,17 +138,17 @@ function toDisplayContexts( })) } -const WORKSPACE_FILE_TOOL = 'workspace_file' -const EDIT_CONTENT_TOOL = 'edit_content' +const WORKSPACE_FILE_TOOL = 'prepare_file_edit' +const EDIT_CONTENT_TOOL = 'apply_file_edit' const MAIN_SPAN = 'main' /** - * Collapses an `edit_content` write into the most-recent `workspace_file` row in + * Collapses an `apply_file_edit` write into the most-recent `prepare_file_edit` row in * the same subagent span, mirroring the live turn-model fold. The live view * folds these in `reduceEvent`, but the persisted transcript stores them as two * separate tool blocks; without this a reloaded chat splits the file write into - * "workspace_file" + "edit_content" rows (and a refresh mid-write leaves the - * second row spinning). The reopened row inherits the edit_content's final + * "prepare_file_edit" + "apply_file_edit" rows (and a refresh mid-write leaves the + * second row spinning). The reopened row inherits the apply_file_edit's final * status/result, exactly as the live single "writing" row resolves. Every other * block is passed through untouched, so this only affects file writes. */ diff --git a/apps/sim/lib/copilot/chat/effective-transcript.test.ts b/apps/sim/lib/copilot/chat/effective-transcript.test.ts index 10c74da0545..23910ed66ce 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.test.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.test.ts @@ -242,7 +242,7 @@ describe('buildEffectiveChatTranscript', () => { payload: { phase: 'result', toolCallId: 'tool-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', executor: 'go', mode: 'sync', success: false, @@ -262,7 +262,7 @@ describe('buildEffectiveChatTranscript', () => { type: MothershipStreamV1EventType.tool, toolCall: expect.objectContaining({ id: 'tool-1', - name: 'workspace_file', + name: 'prepare_file_edit', state: MothershipStreamV1CompletionStatus.cancelled, }), }), diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 2f061cf8592..4ec333268bd 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -368,7 +368,7 @@ describe('buildCopilotRequestPayload', () => { content: [ 'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded.', 'Read with: read("uploads/payroll.xlsx")', - 'To save permanently: materialize_file(fileName: "payroll.xlsx")', + 'To save permanently: save_upload(fileName: "payroll.xlsx")', ].join('\n'), }, ]) @@ -410,7 +410,7 @@ describe('buildCopilotRequestPayload', () => { content: [ 'File "photo.png" (image/png, 10 bytes) uploaded.', 'Read with: read("uploads/photo.png")', - 'To save permanently: materialize_file(fileName: "photo.png")', + 'To save permanently: save_upload(fileName: "photo.png")', ].join('\n'), }, ]) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index ef1ea48d159..5f185b683f3 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -362,7 +362,7 @@ export async function buildCopilotRequestPayload( userMessageId ) // Encode the read path per the percent-encoded VFS convention (matches - // files/ and the uploads glob output). The materialize_file `fileName` + // files/ and the uploads glob output). The save_upload `fileName` // arg stays the raw display name — the upload resolver accepts both. let encodedUploadName = displayName try { @@ -382,11 +382,11 @@ export async function buildCopilotRequestPayload( lines = [ `File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`, `Read with: read("uploads/${encodedUploadName}")`, - `To save permanently: materialize_file(fileName: "${displayName}")`, + `To save permanently: save_upload(fileName: "${displayName}")`, ] if (displayName.endsWith('.json')) { lines.push( - `To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")` + `To import as a workflow: save_upload(fileName: "${displayName}", operation: "import")` ) } } diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 3d522d70351..82ec12ead2a 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -7,7 +7,7 @@ export interface ToolCatalogEntry { clientExecutable?: boolean hidden?: boolean id: - | 'agent' + | 'apply_file_edit' | 'auth' | 'browser' | 'browser_click' @@ -32,26 +32,21 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' - | 'check_deployment_status' | 'cp' - | 'crawl_website' - | 'create_file' + | 'create_empty_file' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_workspace_mcp_server' | 'deploy' - | 'deploy_api' - | 'deploy_chat' - | 'deploy_custom_block' - | 'deploy_mcp' + | 'deploy_as_api' + | 'deploy_as_chat' + | 'deploy_as_mcp' | 'diff_workflows' - | 'download_to_workspace_file' - | 'edit_content' + | 'download_file' | 'edit_workflow' - | 'enrichment_run' + | 'extensions' | 'ffmpeg' | 'file' - | 'function_execute' | 'generate_api_key' | 'generate_audio' | 'generate_image' @@ -59,15 +54,14 @@ export interface ToolCatalogEntry { | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' - | 'get_deployment_log' - | 'get_page_contents' - | 'get_platform_actions' + | 'get_deployment_status' + | 'get_ui_reference' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' | 'grep' | 'knowledge' - | 'knowledge_base' + | 'list_deployment_versions' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -76,17 +70,19 @@ export interface ToolCatalogEntry { | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_mcp_tool' + | 'manage_knowledge_base' + | 'manage_mcp_connection' | 'manage_sandbox' | 'manage_skill' - | 'materialize_file' | 'media' | 'mkdir' | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'prepare_file_edit' | 'promote_to_live' + | 'publish_custom_block' | 'query_logs' | 'query_user_table' | 'read' @@ -97,17 +93,17 @@ export interface ToolCatalogEntry { | 'run' | 'run_block' | 'run_code' + | 'run_enrichment' | 'run_from_block' + | 'run_function' | 'run_workflow' | 'run_workflow_until_block' - | 'scrape_page' + | 'save_upload' | 'search' - | 'search_documentation' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' - | 'search_online' - | 'search_patterns' + | 'search_sim_docs' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' @@ -118,17 +114,21 @@ export interface ToolCatalogEntry { | 'table_enrichments' | 'table_manage' | 'table_rows' + | 'table_views' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' | 'wait' + | 'web_crawl' + | 'web_fetch' + | 'web_scrape' + | 'web_search' | 'workflow' - | 'workspace_file' internal?: boolean mode: 'async' | 'sync' name: - | 'agent' + | 'apply_file_edit' | 'auth' | 'browser' | 'browser_click' @@ -153,26 +153,21 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' - | 'check_deployment_status' | 'cp' - | 'crawl_website' - | 'create_file' + | 'create_empty_file' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_workspace_mcp_server' | 'deploy' - | 'deploy_api' - | 'deploy_chat' - | 'deploy_custom_block' - | 'deploy_mcp' + | 'deploy_as_api' + | 'deploy_as_chat' + | 'deploy_as_mcp' | 'diff_workflows' - | 'download_to_workspace_file' - | 'edit_content' + | 'download_file' | 'edit_workflow' - | 'enrichment_run' + | 'extensions' | 'ffmpeg' | 'file' - | 'function_execute' | 'generate_api_key' | 'generate_audio' | 'generate_image' @@ -180,15 +175,14 @@ export interface ToolCatalogEntry { | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' - | 'get_deployment_log' - | 'get_page_contents' - | 'get_platform_actions' + | 'get_deployment_status' + | 'get_ui_reference' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' | 'grep' | 'knowledge' - | 'knowledge_base' + | 'list_deployment_versions' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -197,17 +191,19 @@ export interface ToolCatalogEntry { | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_mcp_tool' + | 'manage_knowledge_base' + | 'manage_mcp_connection' | 'manage_sandbox' | 'manage_skill' - | 'materialize_file' | 'media' | 'mkdir' | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'prepare_file_edit' | 'promote_to_live' + | 'publish_custom_block' | 'query_logs' | 'query_user_table' | 'read' @@ -218,17 +214,17 @@ export interface ToolCatalogEntry { | 'run' | 'run_block' | 'run_code' + | 'run_enrichment' | 'run_from_block' + | 'run_function' | 'run_workflow' | 'run_workflow_until_block' - | 'scrape_page' + | 'save_upload' | 'search' - | 'search_documentation' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' - | 'search_online' - | 'search_patterns' + | 'search_sim_docs' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' @@ -239,13 +235,17 @@ export interface ToolCatalogEntry { | 'table_enrichments' | 'table_manage' | 'table_rows' + | 'table_views' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' | 'wait' + | 'web_crawl' + | 'web_fetch' + | 'web_scrape' + | 'web_search' | 'workflow' - | 'workspace_file' parameters: unknown requiredPermission?: 'admin' | 'write' requiresApproval?: boolean @@ -265,20 +265,35 @@ export interface ToolCatalogEntry { | 'workflow' } -export const Agent: ToolCatalogEntry = { - id: 'agent', - name: 'agent', - route: 'subagent', +export const ApplyFileEdit: ToolCatalogEntry = { + id: 'apply_file_edit', + name: 'apply_file_edit', + route: 'sim', mode: 'async', parameters: { + type: 'object', properties: { - request: { description: 'What tool/skill/MCP action is needed.', type: 'string' }, + content: { + type: 'string', + description: + 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.', + }, }, - required: ['request'], + required: ['content'], + }, + resultSchema: { type: 'object', + properties: { + data: { + type: 'object', + description: + 'Optional operation metadata such as file id, file name, size, and content type.', + }, + message: { type: 'string', description: 'Human-readable summary of the outcome.' }, + success: { type: 'boolean', description: 'Whether the content was applied successfully.' }, + }, + required: ['success', 'message'], }, - subagentId: 'agent', - internal: true, requiredPermission: 'write', } @@ -1244,22 +1259,6 @@ export const CallIntegrationTool: ToolCatalogEntry = { requiresApproval: true, } -export const CheckDeploymentStatus: ToolCatalogEntry = { - id: 'check_deployment_status', - name: 'check_deployment_status', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workflowId: { - type: 'string', - description: 'Workflow ID to check (defaults to current workflow)', - }, - }, - }, -} - export const Cp: ToolCatalogEntry = { id: 'cp', name: 'cp', @@ -1290,35 +1289,9 @@ export const Cp: ToolCatalogEntry = { requiredPermission: 'write', } -export const CrawlWebsite: ToolCatalogEntry = { - id: 'crawl_website', - name: 'crawl_website', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - exclude_paths: { - type: 'array', - description: 'Skip URLs matching these patterns', - items: { type: 'string' }, - }, - include_paths: { - type: 'array', - description: 'Only crawl URLs matching these patterns', - items: { type: 'string' }, - }, - limit: { type: 'number', description: 'Maximum pages to crawl (default 10, max 50)' }, - max_depth: { type: 'number', description: 'How deep to follow links (default 2)' }, - url: { type: 'string', description: 'Starting URL to crawl from' }, - }, - required: ['url'], - }, -} - -export const CreateFile: ToolCatalogEntry = { - id: 'create_file', - name: 'create_file', +export const CreateEmptyFile: ToolCatalogEntry = { + id: 'create_empty_file', + name: 'create_empty_file', route: 'sim', mode: 'async', parameters: { @@ -1470,9 +1443,9 @@ export const Deploy: ToolCatalogEntry = { internal: true, } -export const DeployApi: ToolCatalogEntry = { - id: 'deploy_api', - name: 'deploy_api', +export const DeployAsApi: ToolCatalogEntry = { + id: 'deploy_as_api', + name: 'deploy_as_api', route: 'sim', mode: 'async', parameters: { @@ -1521,7 +1494,7 @@ export const DeployApi: ToolCatalogEntry = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".', + 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".', }, examples: { type: 'object', @@ -1551,9 +1524,9 @@ export const DeployApi: ToolCatalogEntry = { requiresApproval: true, } -export const DeployChat: ToolCatalogEntry = { - id: 'deploy_chat', - name: 'deploy_chat', +export const DeployAsChat: ToolCatalogEntry = { + id: 'deploy_as_chat', + name: 'deploy_as_chat', route: 'sim', mode: 'async', parameters: { @@ -1652,7 +1625,7 @@ export const DeployChat: ToolCatalogEntry = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_chat this is always "chat".', + 'Deployment surface this result describes. For deploy_as_chat this is always "chat".', }, examples: { type: 'object', @@ -1670,7 +1643,7 @@ export const DeployChat: ToolCatalogEntry = { }, success: { type: 'boolean', - description: 'Whether the deploy_chat action completed successfully.', + description: 'Whether the deploy_as_chat action completed successfully.', }, version: { type: 'number', @@ -1697,121 +1670,9 @@ export const DeployChat: ToolCatalogEntry = { requiresApproval: true, } -export const DeployCustomBlock: ToolCatalogEntry = { - id: 'deploy_custom_block', - name: 'deploy_custom_block', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', - enum: ['deploy', 'undeploy'], - default: 'deploy', - }, - description: { - type: 'string', - description: 'Short description shown in the block picker, max 280 characters', - }, - exposedOutputs: { - type: 'array', - description: - "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", - items: { - type: 'object', - properties: { - blockId: { type: 'string', description: 'Block UUID inside the workflow' }, - name: { type: 'string', description: 'Friendly output name shown on the block' }, - path: { - type: 'string', - description: - "Dot-path into that block's output (from get_block_outputs relativeOutputs)", - }, - }, - required: ['blockId', 'path', 'name'], - }, - }, - iconUrl: { - type: 'string', - description: - 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', - }, - inputs: { - type: 'array', - description: - "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", - items: { - type: 'object', - properties: { - id: { type: 'string', description: 'Stable id of the input trigger field' }, - placeholder: { - type: 'string', - description: "Placeholder text shown in the block's input field", - }, - }, - required: ['id'], - }, - }, - name: { - type: 'string', - description: - 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', - }, - workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, - }, - }, - resultSchema: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Action performed by the tool, such as "deploy" or "undeploy".', - }, - blockId: { type: 'string', description: 'Custom block record ID.' }, - blockType: { - type: 'string', - description: 'Stable block type slug (custom_block_*) used in workflow state.', - }, - deploymentConfig: { - type: 'object', - description: - "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", - }, - deploymentStatus: { - type: 'object', - description: - 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', - }, - deploymentType: { - type: 'string', - description: - 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".', - }, - isDeployed: { - type: 'boolean', - description: 'Whether the custom block is published after this tool call.', - }, - name: { type: 'string', description: 'Display name of the custom block.' }, - removed: { - type: 'boolean', - description: 'Whether the custom block was unpublished during an undeploy action.', - }, - updated: { - type: 'boolean', - description: 'Whether an existing custom block was updated instead of created.', - }, - workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' }, - }, - required: ['deploymentType', 'deploymentStatus'], - }, - requiredPermission: 'admin', -} - -export const DeployMcp: ToolCatalogEntry = { - id: 'deploy_mcp', - name: 'deploy_mcp', +export const DeployAsMcp: ToolCatalogEntry = { + id: 'deploy_as_mcp', + name: 'deploy_as_mcp', route: 'sim', mode: 'async', parameters: { @@ -1873,7 +1734,7 @@ export const DeployMcp: ToolCatalogEntry = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_mcp this is always "mcp".', + 'Deployment surface this result describes. For deploy_as_mcp this is always "mcp".', }, examples: { type: 'object', @@ -1935,9 +1796,9 @@ export const DiffWorkflows: ToolCatalogEntry = { }, } -export const DownloadToWorkspaceFile: ToolCatalogEntry = { - id: 'download_to_workspace_file', - name: 'download_to_workspace_file', +export const DownloadFile: ToolCatalogEntry = { + id: 'download_file', + name: 'download_file', route: 'sim', mode: 'async', parameters: { @@ -1990,38 +1851,6 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { capabilities: ['file_output'], } -export const EditContent: ToolCatalogEntry = { - id: 'edit_content', - name: 'edit_content', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - content: { - type: 'string', - description: - 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.', - }, - }, - required: ['content'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - 'Optional operation metadata such as file id, file name, size, and content type.', - }, - message: { type: 'string', description: 'Human-readable summary of the outcome.' }, - success: { type: 'boolean', description: 'Whether the content was applied successfully.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - export const EditWorkflow: ToolCatalogEntry = { id: 'edit_workflow', name: 'edit_workflow', @@ -2066,53 +1895,20 @@ export const EditWorkflow: ToolCatalogEntry = { requiredPermission: 'write', } -export const EnrichmentRun: ToolCatalogEntry = { - id: 'enrichment_run', - name: 'enrichment_run', - route: 'sim', +export const Extensions: ToolCatalogEntry = { + id: 'extensions', + name: 'extensions', + route: 'subagent', mode: 'async', parameters: { - type: 'object', properties: { - enrichmentId: { - type: 'string', - description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", - enum: [ - 'work-email', - 'phone-number', - 'company-domain', - 'company-info', - 'email-verification', - ], - }, - inputs: { - type: 'object', - description: - 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.', - }, + request: { description: 'What tool/skill/MCP action is needed.', type: 'string' }, }, - required: ['enrichmentId', 'inputs'], - }, - resultSchema: { + required: ['request'], type: 'object', - properties: { - matched: { - type: 'boolean', - description: 'True when a provider returned a non-empty result.', - }, - provider: { - type: ['string', 'null'], - description: - 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', - }, - result: { - type: 'object', - description: 'Mapped output values from the winning provider (empty object on no match).', - }, - }, - required: ['matched', 'result'], }, + subagentId: 'agent', + internal: true, requiredPermission: 'write', } @@ -2302,168 +2098,17 @@ export const File: ToolCatalogEntry = { internal: true, } -export const FunctionExecute: ToolCatalogEntry = { - id: 'function_execute', - name: 'function_execute', +export const GenerateApiKey: ToolCatalogEntry = { + id: 'generate_api_key', + name: 'generate_api_key', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - code: { + name: { type: 'string', - description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', - }, - inputs: { - type: 'object', - description: - 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', - properties: { - directories: { - type: 'array', - description: - 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', - }, - sandboxPath: { - type: 'string', - description: - 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', - }, - }, - required: ['path'], - }, - }, - files: { - type: 'array', - description: 'Workspace files to mount into the sandbox.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', - }, - sandboxPath: { - type: 'string', - description: - 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', - }, - }, - required: ['path'], - }, - }, - tables: { - type: 'array', - description: 'Workspace tables to mount as CSV files.', - items: { - type: 'object', - properties: { - path: { type: 'string', description: 'Canonical VFS table path when available.' }, - sandboxPath: { - type: 'string', - description: 'Optional full sandbox path for the mounted CSV.', - }, - tableId: { type: 'string', description: 'Workspace table ID.' }, - }, - }, - }, - }, - }, - language: { - type: 'string', - description: 'Execution language.', - enum: ['javascript', 'python', 'shell'], - }, - outputTable: { - type: 'string', - description: - 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', - }, - outputs: { - type: 'object', - description: - 'Workspace files to create or overwrite from returned code results or sandbox-created files.', - properties: { - files: { - type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', - items: { - type: 'object', - properties: { - format: { - type: 'string', - description: 'Optional serialization format for returned values.', - enum: ['json', 'csv', 'txt', 'md', 'html'], - }, - mimeType: { - type: 'string', - description: 'Optional MIME type override when inference is not enough.', - }, - mode: { - type: 'string', - description: 'Create a new file or overwrite an existing file at path.', - enum: ['create', 'overwrite'], - }, - path: { - type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', - }, - sandboxPath: { - type: 'string', - description: - 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', - }, - }, - required: ['path', 'mode'], - }, - }, - }, - }, - sandboxId: { - type: 'string', - description: - 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', - }, - timeout: { - type: 'number', - description: - 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', - default: 10, - }, - title: { - type: 'string', - description: - 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', - }, - }, - required: ['code'], - }, - requiredPermission: 'write', - requiresApproval: true, - capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], -} - -export const GenerateApiKey: ToolCatalogEntry = { - id: 'generate_api_key', - name: 'generate_api_key', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: "A descriptive name for the API key (e.g., 'production-key', 'dev-testing').", + description: "A descriptive name for the API key (e.g., 'production-key', 'dev-testing').", }, workspaceId: { type: 'string', @@ -2983,9 +2628,9 @@ export const GetDeployedWorkflowState: ToolCatalogEntry = { }, } -export const GetDeploymentLog: ToolCatalogEntry = { - id: 'get_deployment_log', - name: 'get_deployment_log', +export const GetDeploymentStatus: ToolCatalogEntry = { + id: 'get_deployment_status', + name: 'get_deployment_status', route: 'sim', mode: 'async', parameters: { @@ -2993,42 +2638,15 @@ export const GetDeploymentLog: ToolCatalogEntry = { properties: { workflowId: { type: 'string', - description: 'Optional workflow ID. If not provided, uses the current workflow in context.', - }, - }, - }, -} - -export const GetPageContents: ToolCatalogEntry = { - id: 'get_page_contents', - name: 'get_page_contents', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - include_highlights: { - type: 'boolean', - description: 'Include key highlights (default false)', - }, - include_summary: { - type: 'boolean', - description: 'Include AI-generated summary (default false)', - }, - include_text: { type: 'boolean', description: 'Include full page text (default true)' }, - urls: { - type: 'array', - description: 'URLs to get content from (max 10)', - items: { type: 'string' }, + description: 'Workflow ID to check (defaults to current workflow)', }, }, - required: ['urls'], }, } -export const GetPlatformActions: ToolCatalogEntry = { - id: 'get_platform_actions', - name: 'get_platform_actions', +export const GetUiReference: ToolCatalogEntry = { + id: 'get_ui_reference', + name: 'get_ui_reference', route: 'sim', mode: 'async', parameters: { type: 'object', properties: {} }, @@ -3160,203 +2778,19 @@ export const Knowledge: ToolCatalogEntry = { internal: true, } -export const KnowledgeBase: ToolCatalogEntry = { - id: 'knowledge_base', - name: 'knowledge_base', +export const ListDeploymentVersions: ToolCatalogEntry = { + id: 'list_deployment_versions', + name: 'list_deployment_versions', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - args: { - type: 'object', - description: 'Arguments for the operation', - properties: { - apiKey: { - type: 'string', - description: - 'API key for API-key-based connectors (required when connector auth mode is apiKey)', - }, - chunkingConfig: { - type: 'object', - description: "Chunking configuration (optional for 'create')", - properties: { - maxSize: { - type: 'number', - description: 'Maximum chunk size (100-4000, default: 1024)', - default: 1024, - }, - minSize: { - type: 'number', - description: 'Minimum chunk size (1-2000, default: 1)', - default: 1, - }, - overlap: { - type: 'number', - description: 'Overlap between chunks (0-500, default: 200)', - default: 200, - }, - }, - }, - connectorId: { - type: 'string', - description: - 'Connector ID (required for update_connector, delete_connector, sync_connector)', - }, - connectorStatus: { - type: 'string', - description: 'Connector status (optional for update_connector)', - enum: ['active', 'paused'], - }, - connectorType: { - type: 'string', - description: - "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.", - }, - credentialId: { - type: 'string', - description: - 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)', - }, - description: { - type: 'string', - description: "Description of the knowledge base (optional for 'create')", - }, - disabledTagIds: { - type: 'array', - description: - 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', - }, - documentId: { type: 'string', description: 'Document ID (required for update_document)' }, - documentIds: { - type: 'array', - description: 'Document IDs (for batch delete_document)', - items: { type: 'string' }, - }, - enabled: { - type: 'boolean', - description: 'Enable/disable a document (optional for update_document)', - }, - filePaths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', - items: { type: 'string' }, - }, - filename: { - type: 'string', - description: 'New filename for a document (optional for update_document)', - }, - knowledgeBaseId: { - type: 'string', - description: - 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', - }, - knowledgeBaseIds: { - type: 'array', - description: 'Knowledge base IDs (for batch delete)', - items: { type: 'string' }, - }, - name: { - type: 'string', - description: "Name of the knowledge base (required for 'create')", - }, - query: { type: 'string', description: "Search query text (required for 'query')" }, - sourceConfig: { - type: 'object', - description: - 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json', - }, - syncIntervalMinutes: { - type: 'number', - description: - 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', - default: 1440, - }, - tagDefinitionId: { - type: 'string', - description: 'Tag definition ID (required for update_tag, delete_tag)', - }, - tagDisplayName: { - type: 'string', - description: - 'Display name for the tag (required for create_tag, optional for update_tag)', - }, - tagFieldType: { - type: 'string', - description: - 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', - enum: ['text', 'number', 'date', 'boolean'], - }, - tagValues: { - type: 'array', - description: - 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', - items: { - type: 'object', - properties: { - tagDefinitionId: { - type: 'string', - description: 'Tag definition ID returned by list_tags.', - }, - value: { - type: ['string', 'number', 'boolean', 'null'], - description: - "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", - }, - }, - required: ['tagDefinitionId', 'value'], - }, - }, - topK: { - type: 'number', - description: 'Number of results to return (1-50, default: 5)', - default: 5, - }, - workspaceId: { - type: 'string', - description: - "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", - }, - }, - }, - operation: { + workflowId: { type: 'string', - description: 'The operation to perform', - enum: [ - 'create', - 'get', - 'query', - 'add_file', - 'update', - 'delete_document', - 'update_document', - 'list_tags', - 'create_tag', - 'update_tag', - 'delete_tag', - 'get_tag_usage', - 'add_connector', - 'update_connector', - 'delete_connector', - 'sync_connector', - ], - }, - }, - required: ['operation'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: ['object', 'array'], - description: - 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', }, - message: { type: 'string', description: 'Human-readable outcome summary.' }, - success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, - required: ['success', 'message'], }, } @@ -3542,25 +2976,225 @@ export const ManageCustomTool: ToolCatalogEntry = { }, required: ['type', 'function'], }, - toolId: { - type: 'string', + toolId: { + type: 'string', + description: + "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", + }, + toolIds: { + type: 'array', + description: 'Array of custom tool IDs (for batch delete)', + items: { type: 'string' }, + }, + }, + required: ['operation'], + }, + requiredPermission: 'write', +} + +export const ManageKnowledgeBase: ToolCatalogEntry = { + id: 'manage_knowledge_base', + name: 'manage_knowledge_base', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + apiKey: { + type: 'string', + description: + 'API key for API-key-based connectors (required when connector auth mode is apiKey)', + }, + chunkingConfig: { + type: 'object', + description: "Chunking configuration (optional for 'create')", + properties: { + maxSize: { + type: 'number', + description: 'Maximum chunk size (100-4000, default: 1024)', + default: 1024, + }, + minSize: { + type: 'number', + description: 'Minimum chunk size (1-2000, default: 1)', + default: 1, + }, + overlap: { + type: 'number', + description: 'Overlap between chunks (0-500, default: 200)', + default: 200, + }, + }, + }, + connectorId: { + type: 'string', + description: + 'Connector ID (required for update_connector, delete_connector, sync_connector)', + }, + connectorStatus: { + type: 'string', + description: 'Connector status (optional for update_connector)', + enum: ['active', 'paused'], + }, + connectorType: { + type: 'string', + description: + "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.", + }, + credentialId: { + type: 'string', + description: + 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)', + }, + description: { + type: 'string', + description: "Description of the knowledge base (optional for 'create')", + }, + disabledTagIds: { + type: 'array', + description: + 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', + }, + documentId: { type: 'string', description: 'Document ID (required for update_document)' }, + documentIds: { + type: 'array', + description: 'Document IDs (for batch delete_document)', + items: { type: 'string' }, + }, + enabled: { + type: 'boolean', + description: 'Enable/disable a document (optional for update_document)', + }, + filePaths: { + type: 'array', + description: + 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', + items: { type: 'string' }, + }, + filename: { + type: 'string', + description: 'New filename for a document (optional for update_document)', + }, + knowledgeBaseId: { + type: 'string', + description: + 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', + }, + knowledgeBaseIds: { + type: 'array', + description: 'Knowledge base IDs (for batch delete)', + items: { type: 'string' }, + }, + name: { + type: 'string', + description: "Name of the knowledge base (required for 'create')", + }, + query: { type: 'string', description: "Search query text (required for 'query')" }, + sourceConfig: { + type: 'object', + description: + 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json', + }, + syncIntervalMinutes: { + type: 'number', + description: + 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', + default: 1440, + }, + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID (required for update_tag, delete_tag)', + }, + tagDisplayName: { + type: 'string', + description: + 'Display name for the tag (required for create_tag, optional for update_tag)', + }, + tagFieldType: { + type: 'string', + description: + 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', + enum: ['text', 'number', 'date', 'boolean'], + }, + tagValues: { + type: 'array', + description: + 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', + items: { + type: 'object', + properties: { + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID returned by list_tags.', + }, + value: { + type: ['string', 'number', 'boolean', 'null'], + description: + "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", + }, + }, + required: ['tagDefinitionId', 'value'], + }, + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + workspaceId: { + type: 'string', + description: + "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", + }, + }, + }, + operation: { + type: 'string', + description: 'The operation to perform', + enum: [ + 'create', + 'get', + 'query', + 'add_file', + 'update', + 'delete_document', + 'update_document', + 'list_tags', + 'create_tag', + 'update_tag', + 'delete_tag', + 'get_tag_usage', + 'add_connector', + 'update_connector', + 'delete_connector', + 'sync_connector', + ], + }, + }, + required: ['operation'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: ['object', 'array'], description: - "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", - }, - toolIds: { - type: 'array', - description: 'Array of custom tool IDs (for batch delete)', - items: { type: 'string' }, + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, - required: ['operation'], + required: ['success', 'message'], }, - requiredPermission: 'write', } -export const ManageMcpTool: ToolCatalogEntry = { - id: 'manage_mcp_tool', - name: 'manage_mcp_tool', +export const ManageMcpConnection: ToolCatalogEntry = { + id: 'manage_mcp_connection', + name: 'manage_mcp_connection', route: 'sim', mode: 'async', parameters: { @@ -3700,33 +3334,6 @@ export const ManageSkill: ToolCatalogEntry = { requiredPermission: 'write', } -export const MaterializeFile: ToolCatalogEntry = { - id: 'materialize_file', - name: 'materialize_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - fileNames: { - type: 'array', - description: - 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', - items: { type: 'string' }, - }, - operation: { - type: 'string', - description: - 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".', - enum: ['save', 'import', 'extract'], - default: 'save', - }, - }, - required: ['fileNames'], - }, - requiredPermission: 'write', -} - export const Media: ToolCatalogEntry = { id: 'media', name: 'media', @@ -3868,6 +3475,11 @@ export const OpenResource: ToolCatalogEntry = { description: 'The resource type.', enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, + view: { + type: 'string', + description: + 'Saved table view to open pinned (type "table" only): a view id or exact view name from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', + }, }, required: ['type'], }, @@ -3877,6 +3489,129 @@ export const OpenResource: ToolCatalogEntry = { }, } +export const PrepareFileEdit: ToolCatalogEntry = { + id: 'prepare_file_edit', + name: 'prepare_file_edit', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + operation: { + type: 'string', + description: 'The file operation to perform.', + enum: ['append', 'update', 'patch'], + }, + target: { + type: 'object', + description: 'Explicit file target. Use kind=path + path for existing files.', + properties: { + kind: { + type: 'string', + description: 'How the file target is identified.', + enum: ['path'], + }, + path: { + type: 'string', + description: + 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', + }, + }, + required: ['kind'], + }, + title: { + type: 'string', + description: + 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".', + }, + contentType: { + type: 'string', + description: + 'Optional MIME type override. Usually omit and let the system infer from the target file extension.', + enum: [ + 'text/markdown', + 'text/html', + 'text/plain', + 'application/json', + 'text/csv', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/pdf', + ], + }, + edit: { + type: 'object', + description: + 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired apply_file_edit tool call.', + properties: { + after_anchor: { + type: 'string', + description: + 'Boundary line kept after inserted replacement content. Required for mode=replace_between.', + }, + anchor: { + type: 'string', + description: + 'Anchor line after which new content is inserted. Required for mode=insert_after.', + }, + before_anchor: { + type: 'string', + description: + 'Boundary line kept before inserted replacement content. Required for mode=replace_between.', + }, + end_anchor: { + type: 'string', + description: 'First line to keep after deletion. Required for mode=delete_between.', + }, + mode: { + type: 'string', + description: 'Anchored edit mode when strategy=anchored.', + enum: ['replace_between', 'insert_after', 'delete_between'], + }, + occurrence: { + type: 'number', + description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.', + }, + replaceAll: { + type: 'boolean', + description: + 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.', + }, + search: { + type: 'string', + description: + 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.', + }, + start_anchor: { + type: 'string', + description: 'First line to delete. Required for mode=delete_between.', + }, + strategy: { + type: 'string', + description: 'Patch strategy.', + enum: ['search_replace', 'anchored'], + }, + }, + }, + }, + required: ['operation', 'target', 'title'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Optional operation metadata such as file id, file name, size, and content type.', + }, + message: { type: 'string', description: 'Human-readable summary of the outcome.' }, + success: { type: 'boolean', description: 'Whether the file operation succeeded.' }, + }, + required: ['success', 'message'], + }, + requiredPermission: 'write', +} + export const PromoteToLive: ToolCatalogEntry = { id: 'promote_to_live', name: 'promote_to_live', @@ -3888,17 +3623,129 @@ export const PromoteToLive: ToolCatalogEntry = { version: { type: 'number', description: - 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + }, + workflowId: { + type: 'string', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], + }, + requiredPermission: 'admin', + requiresApproval: true, +} + +export const PublishCustomBlock: ToolCatalogEntry = { + id: 'publish_custom_block', + name: 'publish_custom_block', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', + enum: ['deploy', 'undeploy'], + default: 'deploy', + }, + description: { + type: 'string', + description: 'Short description shown in the block picker, max 280 characters', + }, + exposedOutputs: { + type: 'array', + description: + "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'Block UUID inside the workflow' }, + name: { type: 'string', description: 'Friendly output name shown on the block' }, + path: { + type: 'string', + description: + "Dot-path into that block's output (from get_block_outputs relativeOutputs)", + }, + }, + required: ['blockId', 'path', 'name'], + }, + }, + iconUrl: { + type: 'string', + description: + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', + }, + inputs: { + type: 'array', + description: + "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Stable id of the input trigger field' }, + placeholder: { + type: 'string', + description: "Placeholder text shown in the block's input field", + }, + }, + required: ['id'], + }, + }, + name: { + type: 'string', + description: + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', + }, + workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, + }, + }, + resultSchema: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Action performed by the tool, such as "deploy" or "undeploy".', + }, + blockId: { type: 'string', description: 'Custom block record ID.' }, + blockType: { + type: 'string', + description: 'Stable block type slug (custom_block_*) used in workflow state.', + }, + deploymentConfig: { + type: 'object', + description: + "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", }, - workflowId: { + deploymentStatus: { + type: 'object', + description: + 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', + }, + deploymentType: { type: 'string', - description: 'Optional workflow ID. If not provided, uses the current workflow in context.', + description: + 'Deployment surface this result describes. For publish_custom_block this is always "custom_block".', + }, + isDeployed: { + type: 'boolean', + description: 'Whether the custom block is published after this tool call.', + }, + name: { type: 'string', description: 'Display name of the custom block.' }, + removed: { + type: 'boolean', + description: 'Whether the custom block was unpublished during an undeploy action.', + }, + updated: { + type: 'boolean', + description: 'Whether an existing custom block was updated instead of created.', }, + workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' }, }, - required: ['version'], + required: ['deploymentType', 'deploymentStatus'], }, requiredPermission: 'admin', - requiresApproval: true, } export const QueryLogs: ToolCatalogEntry = { @@ -4040,6 +3887,11 @@ export const QueryUserTable: ToolCatalogEntry = { }, rowId: { type: 'string', description: 'Row ID (required for get_row)' }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, + view: { + type: 'string', + description: + "Saved view to query through (query_rows only): a view id or exact view name from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", + }, }, }, operation: { @@ -4132,7 +3984,7 @@ export const Redeploy: ToolCatalogEntry = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".', + 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".', }, examples: { type: 'object', @@ -4358,64 +4210,265 @@ export const RunCode: ToolCatalogEntry = { path: { type: 'string', description: 'Canonical VFS table path when available.' }, sandboxPath: { type: 'string', - description: 'Optional full sandbox path for the mounted CSV.', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + requiredPermission: 'write', + requiresApproval: true, + capabilities: ['file_input', 'directory_input', 'table_input'], +} + +export const RunEnrichment: ToolCatalogEntry = { + id: 'run_enrichment', + name: 'run_enrichment', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + enrichmentId: { + type: 'string', + description: + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", + enum: [ + 'work-email', + 'phone-number', + 'company-domain', + 'company-info', + 'email-verification', + ], + }, + inputs: { + type: 'object', + description: + 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.', + }, + }, + required: ['enrichmentId', 'inputs'], + }, + resultSchema: { + type: 'object', + properties: { + matched: { + type: 'boolean', + description: 'True when a provider returned a non-empty result.', + }, + provider: { + type: ['string', 'null'], + description: + 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', + }, + result: { + type: 'object', + description: 'Mapped output values from the winning provider (empty object on no match).', + }, + }, + required: ['matched', 'result'], + }, + requiredPermission: 'write', +} + +export const RunFromBlock: ToolCatalogEntry = { + id: 'run_from_block', + name: 'run_from_block', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + executionId: { + type: 'string', + description: + 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', + }, + startBlockId: { type: 'string', description: 'The block ID to start execution from.' }, + useDeployedState: { + type: 'boolean', + description: + 'When true, runs the deployed version instead of the live draft. Default: false (draft).', + }, + workflowId: { + type: 'string', + description: + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', + }, + workflow_input: { + type: 'object', + description: 'JSON object with key-value mappings where each key is an input field name', + }, + }, + required: ['workflowId', 'startBlockId'], + }, + clientExecutable: true, +} + +export const RunFunction: ToolCatalogEntry = { + id: 'run_function', + name: 'run_function', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + outputTable: { + type: 'string', + description: + 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', + }, + outputs: { + type: 'object', + description: + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', }, - tableId: { type: 'string', description: 'Workspace table ID.' }, }, + required: ['path', 'mode'], }, }, }, }, - language: { + sandboxId: { type: 'string', - description: 'Execution language.', - enum: ['javascript', 'python', 'shell'], + description: + 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default run_function environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', + }, + timeout: { + type: 'number', + description: + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', + default: 10, }, title: { type: 'string', description: - 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', }, }, required: ['code'], }, requiredPermission: 'write', requiresApproval: true, - capabilities: ['file_input', 'directory_input', 'table_input'], -} - -export const RunFromBlock: ToolCatalogEntry = { - id: 'run_from_block', - name: 'run_from_block', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - executionId: { - type: 'string', - description: - 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', - }, - startBlockId: { type: 'string', description: 'The block ID to start execution from.' }, - useDeployedState: { - type: 'boolean', - description: - 'When true, runs the deployed version instead of the live draft. Default: false (draft).', - }, - workflowId: { - type: 'string', - description: - 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', - }, - workflow_input: { - type: 'object', - description: 'JSON object with key-value mappings where each key is an input field name', - }, - }, - required: ['workflowId', 'startBlockId'], - }, - clientExecutable: true, + capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], } export const RunWorkflow: ToolCatalogEntry = { @@ -4517,26 +4570,31 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { requiresApproval: true, } -export const ScrapePage: ToolCatalogEntry = { - id: 'scrape_page', - name: 'scrape_page', - route: 'go', - mode: 'sync', +export const SaveUpload: ToolCatalogEntry = { + id: 'save_upload', + name: 'save_upload', + route: 'sim', + mode: 'async', parameters: { type: 'object', properties: { - include_links: { - type: 'boolean', - description: 'Extract all links from the page (default false)', + fileNames: { + type: 'array', + description: + 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', + items: { type: 'string' }, }, - url: { type: 'string', description: 'The URL to scrape (must include https://)' }, - wait_for: { + operation: { type: 'string', - description: 'CSS selector to wait for before scraping (for JS-heavy pages)', + description: + 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".', + enum: ['save', 'import', 'extract'], + default: 'save', }, }, - required: ['url'], + required: ['fileNames'], }, + requiredPermission: 'write', } export const Search: ToolCatalogEntry = { @@ -4559,26 +4617,6 @@ export const Search: ToolCatalogEntry = { internal: true, } -export const SearchDocumentation: ToolCatalogEntry = { - id: 'search_documentation', - name: 'search_documentation', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'The search query' }, - topK: { - type: 'number', - description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, - }, - }, - required: ['query'], - }, -} - export const SearchIntegrationTools: ToolCatalogEntry = { id: 'search_integration_tools', name: 'search_integration_tools', @@ -4680,64 +4718,23 @@ export const SearchLibraryDocs: ToolCatalogEntry = { }, } -export const SearchOnline: ToolCatalogEntry = { - id: 'search_online', - name: 'search_online', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - category: { - type: 'string', - description: 'Filter by category', - enum: [ - 'news', - 'tweet', - 'github', - 'company', - 'research paper', - 'linkedin profile', - 'pdf', - 'personal site', - ], - }, - include_text: { type: 'boolean', description: 'Include page text content (default true)' }, - num_results: { type: 'number', description: 'Number of results (default 10, max 25)' }, - query: { type: 'string', description: 'Natural language search query' }, - toolTitle: { - type: 'string', - description: - "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", - }, - }, - required: ['query', 'toolTitle'], - }, -} - -export const SearchPatterns: ToolCatalogEntry = { - id: 'search_patterns', - name: 'search_patterns', - route: 'go', - mode: 'sync', +export const SearchSimDocs: ToolCatalogEntry = { + id: 'search_sim_docs', + name: 'search_sim_docs', + route: 'sim', + mode: 'async', parameters: { type: 'object', properties: { - limit: { - type: 'integer', - description: 'Maximum number of pattern examples to return per query (defaults to 3).', - }, - queries: { - type: 'array', + query: { type: 'string', description: 'The search query' }, + topK: { + type: 'number', description: - 'Up to 3 descriptive strings explaining the workflow pattern(s) you need. Focus on intent and desired outcomes.', - items: { - type: 'string', - description: 'Example: "how to automate wealthbox meeting notes into follow-up tasks"', - }, + 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', + default: 10, }, }, - required: ['queries'], + required: ['query'], }, } @@ -5409,6 +5406,79 @@ export const TableRows: ToolCatalogEntry = { }, } +export const TableViews: ToolCatalogEntry = { + id: 'table_views', + name: 'table_views', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: + 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.', + }, + hiddenColumns: { + type: 'array', + description: + 'Column names to hide in the UI when this view is active. Display-only — queries through the view still return every column.', + items: { type: 'string' }, + }, + isDefault: { + type: 'boolean', + description: + "Make this view the table's default (at most one per table; setting it clears the previous default).", + }, + name: { + type: 'string', + description: + "View display name (required for create_view; optional rename on update_view). Free-form label, need not be unique — prefer distinct names so query_user_table's view argument can use them unambiguously.", + }, + sort: { + type: 'array', + description: + 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.', + }, + tableId: { type: 'string', description: 'Table ID (required for every operation)' }, + viewId: { + type: 'string', + description: + 'View ID (required for get_view, update_view, delete_view, set_default_view)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The view operation to perform', + enum: [ + 'list_views', + 'get_view', + 'create_view', + 'update_view', + 'delete_view', + 'set_default_view', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Terminal: ToolCatalogEntry = { id: 'terminal', name: 'terminal', @@ -5548,7 +5618,7 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = { version: { type: 'number', description: - 'The numeric deployment version number to update (use get_deployment_log to find it).', + 'The numeric deployment version number to update (use list_deployment_versions to find it).', }, workflowId: { type: 'string', @@ -5910,35 +5980,145 @@ export const UserTable: ToolCatalogEntry = { ], }, }, - required: ['operation', 'args'], + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const Wait: ToolCatalogEntry = { + id: 'wait', + name: 'wait', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: + 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + }, + seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' }, + }, + required: ['seconds'], + }, +} + +export const WebCrawl: ToolCatalogEntry = { + id: 'web_crawl', + name: 'web_crawl', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + exclude_paths: { + type: 'array', + description: 'Skip URLs matching these patterns', + items: { type: 'string' }, + }, + include_paths: { + type: 'array', + description: 'Only crawl URLs matching these patterns', + items: { type: 'string' }, + }, + limit: { type: 'number', description: 'Maximum pages to crawl (default 10, max 50)' }, + max_depth: { type: 'number', description: 'How deep to follow links (default 2)' }, + url: { type: 'string', description: 'Starting URL to crawl from' }, + }, + required: ['url'], + }, +} + +export const WebFetch: ToolCatalogEntry = { + id: 'web_fetch', + name: 'web_fetch', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + include_highlights: { + type: 'boolean', + description: 'Include key highlights (default false)', + }, + include_summary: { + type: 'boolean', + description: 'Include AI-generated summary (default false)', + }, + include_text: { type: 'boolean', description: 'Include full page text (default true)' }, + urls: { + type: 'array', + description: 'URLs to get content from (max 10)', + items: { type: 'string' }, + }, + }, + required: ['urls'], }, - resultSchema: { +} + +export const WebScrape: ToolCatalogEntry = { + id: 'web_scrape', + name: 'web_scrape', + route: 'go', + mode: 'sync', + parameters: { type: 'object', properties: { - data: { type: 'object', description: 'Operation-specific result payload.' }, - message: { type: 'string', description: 'Human-readable outcome summary.' }, - success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + include_links: { + type: 'boolean', + description: 'Extract all links from the page (default false)', + }, + url: { type: 'string', description: 'The URL to scrape (must include https://)' }, + wait_for: { + type: 'string', + description: 'CSS selector to wait for before scraping (for JS-heavy pages)', + }, }, - required: ['success', 'message'], + required: ['url'], }, } -export const Wait: ToolCatalogEntry = { - id: 'wait', - name: 'wait', +export const WebSearch: ToolCatalogEntry = { + id: 'web_search', + name: 'web_search', route: 'go', mode: 'sync', parameters: { type: 'object', properties: { - reason: { + category: { + type: 'string', + description: 'Filter by category', + enum: [ + 'news', + 'tweet', + 'github', + 'company', + 'research paper', + 'linkedin profile', + 'pdf', + 'personal site', + ], + }, + include_text: { type: 'boolean', description: 'Include page text content (default true)' }, + num_results: { type: 'number', description: 'Number of results (default 10, max 25)' }, + query: { type: 'string', description: 'Natural language search query' }, + toolTitle: { type: 'string', description: - 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", }, - seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' }, }, - required: ['seconds'], + required: ['query', 'toolTitle'], }, } @@ -5974,129 +6154,6 @@ export const Workflow: ToolCatalogEntry = { internal: true, } -export const WorkspaceFile: ToolCatalogEntry = { - id: 'workspace_file', - name: 'workspace_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - operation: { - type: 'string', - description: 'The file operation to perform.', - enum: ['append', 'update', 'patch'], - }, - target: { - type: 'object', - description: 'Explicit file target. Use kind=path + path for existing files.', - properties: { - kind: { - type: 'string', - description: 'How the file target is identified.', - enum: ['path'], - }, - path: { - type: 'string', - description: - 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', - }, - }, - required: ['kind'], - }, - title: { - type: 'string', - description: - 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".', - }, - contentType: { - type: 'string', - description: - 'Optional MIME type override. Usually omit and let the system infer from the target file extension.', - enum: [ - 'text/markdown', - 'text/html', - 'text/plain', - 'application/json', - 'text/csv', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/pdf', - ], - }, - edit: { - type: 'object', - description: - 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired edit_content tool call.', - properties: { - after_anchor: { - type: 'string', - description: - 'Boundary line kept after inserted replacement content. Required for mode=replace_between.', - }, - anchor: { - type: 'string', - description: - 'Anchor line after which new content is inserted. Required for mode=insert_after.', - }, - before_anchor: { - type: 'string', - description: - 'Boundary line kept before inserted replacement content. Required for mode=replace_between.', - }, - end_anchor: { - type: 'string', - description: 'First line to keep after deletion. Required for mode=delete_between.', - }, - mode: { - type: 'string', - description: 'Anchored edit mode when strategy=anchored.', - enum: ['replace_between', 'insert_after', 'delete_between'], - }, - occurrence: { - type: 'number', - description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.', - }, - replaceAll: { - type: 'boolean', - description: - 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.', - }, - search: { - type: 'string', - description: - 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.', - }, - start_anchor: { - type: 'string', - description: 'First line to delete. Required for mode=delete_between.', - }, - strategy: { - type: 'string', - description: 'Patch strategy.', - enum: ['search_replace', 'anchored'], - }, - }, - }, - }, - required: ['operation', 'target', 'title'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - 'Optional operation metadata such as file id, file name, size, and content type.', - }, - message: { type: 'string', description: 'Human-readable summary of the outcome.' }, - success: { type: 'boolean', description: 'Whether the file operation succeeded.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - export const FfmpegOperation = { overlayAudio: 'overlay_audio', mixAudio: 'mix_audio', @@ -6129,47 +6186,6 @@ export const FfmpegOperationValues = [ FfmpegOperation.probe, ] as const -export const KnowledgeBaseOperation = { - create: 'create', - get: 'get', - query: 'query', - addFile: 'add_file', - update: 'update', - deleteDocument: 'delete_document', - updateDocument: 'update_document', - listTags: 'list_tags', - createTag: 'create_tag', - updateTag: 'update_tag', - deleteTag: 'delete_tag', - getTagUsage: 'get_tag_usage', - addConnector: 'add_connector', - updateConnector: 'update_connector', - deleteConnector: 'delete_connector', - syncConnector: 'sync_connector', -} as const - -export type KnowledgeBaseOperation = - (typeof KnowledgeBaseOperation)[keyof typeof KnowledgeBaseOperation] - -export const KnowledgeBaseOperationValues = [ - KnowledgeBaseOperation.create, - KnowledgeBaseOperation.get, - KnowledgeBaseOperation.query, - KnowledgeBaseOperation.addFile, - KnowledgeBaseOperation.update, - KnowledgeBaseOperation.deleteDocument, - KnowledgeBaseOperation.updateDocument, - KnowledgeBaseOperation.listTags, - KnowledgeBaseOperation.createTag, - KnowledgeBaseOperation.updateTag, - KnowledgeBaseOperation.deleteTag, - KnowledgeBaseOperation.getTagUsage, - KnowledgeBaseOperation.addConnector, - KnowledgeBaseOperation.updateConnector, - KnowledgeBaseOperation.deleteConnector, - KnowledgeBaseOperation.syncConnector, -] as const - export const ManageCredentialOperation = { rename: 'rename', delete: 'delete', @@ -6200,21 +6216,62 @@ export const ManageCustomToolOperationValues = [ ManageCustomToolOperation.list, ] as const -export const ManageMcpToolOperation = { +export const ManageKnowledgeBaseOperation = { + create: 'create', + get: 'get', + query: 'query', + addFile: 'add_file', + update: 'update', + deleteDocument: 'delete_document', + updateDocument: 'update_document', + listTags: 'list_tags', + createTag: 'create_tag', + updateTag: 'update_tag', + deleteTag: 'delete_tag', + getTagUsage: 'get_tag_usage', + addConnector: 'add_connector', + updateConnector: 'update_connector', + deleteConnector: 'delete_connector', + syncConnector: 'sync_connector', +} as const + +export type ManageKnowledgeBaseOperation = + (typeof ManageKnowledgeBaseOperation)[keyof typeof ManageKnowledgeBaseOperation] + +export const ManageKnowledgeBaseOperationValues = [ + ManageKnowledgeBaseOperation.create, + ManageKnowledgeBaseOperation.get, + ManageKnowledgeBaseOperation.query, + ManageKnowledgeBaseOperation.addFile, + ManageKnowledgeBaseOperation.update, + ManageKnowledgeBaseOperation.deleteDocument, + ManageKnowledgeBaseOperation.updateDocument, + ManageKnowledgeBaseOperation.listTags, + ManageKnowledgeBaseOperation.createTag, + ManageKnowledgeBaseOperation.updateTag, + ManageKnowledgeBaseOperation.deleteTag, + ManageKnowledgeBaseOperation.getTagUsage, + ManageKnowledgeBaseOperation.addConnector, + ManageKnowledgeBaseOperation.updateConnector, + ManageKnowledgeBaseOperation.deleteConnector, + ManageKnowledgeBaseOperation.syncConnector, +] as const + +export const ManageMcpConnectionOperation = { add: 'add', edit: 'edit', delete: 'delete', list: 'list', } as const -export type ManageMcpToolOperation = - (typeof ManageMcpToolOperation)[keyof typeof ManageMcpToolOperation] +export type ManageMcpConnectionOperation = + (typeof ManageMcpConnectionOperation)[keyof typeof ManageMcpConnectionOperation] -export const ManageMcpToolOperationValues = [ - ManageMcpToolOperation.add, - ManageMcpToolOperation.edit, - ManageMcpToolOperation.delete, - ManageMcpToolOperation.list, +export const ManageMcpConnectionOperationValues = [ + ManageMcpConnectionOperation.add, + ManageMcpConnectionOperation.edit, + ManageMcpConnectionOperation.delete, + ManageMcpConnectionOperation.list, ] as const export const ManageSandboxOperation = { @@ -6250,19 +6307,19 @@ export const ManageSkillOperationValues = [ ManageSkillOperation.list, ] as const -export const MaterializeFileOperation = { - save: 'save', - import: 'import', - extract: 'extract', +export const PrepareFileEditOperation = { + append: 'append', + update: 'update', + patch: 'patch', } as const -export type MaterializeFileOperation = - (typeof MaterializeFileOperation)[keyof typeof MaterializeFileOperation] +export type PrepareFileEditOperation = + (typeof PrepareFileEditOperation)[keyof typeof PrepareFileEditOperation] -export const MaterializeFileOperationValues = [ - MaterializeFileOperation.save, - MaterializeFileOperation.import, - MaterializeFileOperation.extract, +export const PrepareFileEditOperationValues = [ + PrepareFileEditOperation.append, + PrepareFileEditOperation.update, + PrepareFileEditOperation.patch, ] as const export const QueryUserTableOperation = { @@ -6282,6 +6339,20 @@ export const QueryUserTableOperationValues = [ QueryUserTableOperation.queryRows, ] as const +export const SaveUploadOperation = { + save: 'save', + import: 'import', + extract: 'extract', +} as const + +export type SaveUploadOperation = (typeof SaveUploadOperation)[keyof typeof SaveUploadOperation] + +export const SaveUploadOperationValues = [ + SaveUploadOperation.save, + SaveUploadOperation.import, + SaveUploadOperation.extract, +] as const + export const SearchKnowledgeBaseOperation = { get: 'get', query: 'query', @@ -6392,6 +6463,26 @@ export const TableRowsOperationValues = [ TableRowsOperation.deleteRowsByFilter, ] as const +export const TableViewsOperation = { + listViews: 'list_views', + getView: 'get_view', + createView: 'create_view', + updateView: 'update_view', + deleteView: 'delete_view', + setDefaultView: 'set_default_view', +} as const + +export type TableViewsOperation = (typeof TableViewsOperation)[keyof typeof TableViewsOperation] + +export const TableViewsOperationValues = [ + TableViewsOperation.listViews, + TableViewsOperation.getView, + TableViewsOperation.createView, + TableViewsOperation.updateView, + TableViewsOperation.deleteView, + TableViewsOperation.setDefaultView, +] as const + export const TerminalOperation = { run: 'run', read: 'read', @@ -6490,23 +6581,8 @@ export const UserTableOperationValues = [ UserTableOperation.addEnrichment, ] as const -export const WorkspaceFileOperation = { - append: 'append', - update: 'update', - patch: 'patch', -} as const - -export type WorkspaceFileOperation = - (typeof WorkspaceFileOperation)[keyof typeof WorkspaceFileOperation] - -export const WorkspaceFileOperationValues = [ - WorkspaceFileOperation.append, - WorkspaceFileOperation.update, - WorkspaceFileOperation.patch, -] as const - export const TOOL_CATALOG: Record = { - [Agent.id]: Agent, + [ApplyFileEdit.id]: ApplyFileEdit, [Auth.id]: Auth, [Browser.id]: Browser, [BrowserClick.id]: BrowserClick, @@ -6531,26 +6607,21 @@ export const TOOL_CATALOG: Record = { [BrowserType.id]: BrowserType, [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, - [CheckDeploymentStatus.id]: CheckDeploymentStatus, [Cp.id]: Cp, - [CrawlWebsite.id]: CrawlWebsite, - [CreateFile.id]: CreateFile, + [CreateEmptyFile.id]: CreateEmptyFile, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, [Deploy.id]: Deploy, - [DeployApi.id]: DeployApi, - [DeployChat.id]: DeployChat, - [DeployCustomBlock.id]: DeployCustomBlock, - [DeployMcp.id]: DeployMcp, + [DeployAsApi.id]: DeployAsApi, + [DeployAsChat.id]: DeployAsChat, + [DeployAsMcp.id]: DeployAsMcp, [DiffWorkflows.id]: DiffWorkflows, - [DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile, - [EditContent.id]: EditContent, + [DownloadFile.id]: DownloadFile, [EditWorkflow.id]: EditWorkflow, - [EnrichmentRun.id]: EnrichmentRun, + [Extensions.id]: Extensions, [Ffmpeg.id]: Ffmpeg, [File.id]: File, - [FunctionExecute.id]: FunctionExecute, [GenerateApiKey.id]: GenerateApiKey, [GenerateAudio.id]: GenerateAudio, [GenerateImage.id]: GenerateImage, @@ -6558,15 +6629,14 @@ export const TOOL_CATALOG: Record = { [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, - [GetDeploymentLog.id]: GetDeploymentLog, - [GetPageContents.id]: GetPageContents, - [GetPlatformActions.id]: GetPlatformActions, + [GetDeploymentStatus.id]: GetDeploymentStatus, + [GetUiReference.id]: GetUiReference, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, [Grep.id]: Grep, [Knowledge.id]: Knowledge, - [KnowledgeBase.id]: KnowledgeBase, + [ListDeploymentVersions.id]: ListDeploymentVersions, [ListIntegrationTools.id]: ListIntegrationTools, [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, @@ -6575,17 +6645,19 @@ export const TOOL_CATALOG: Record = { [LoadSkill.id]: LoadSkill, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, - [ManageMcpTool.id]: ManageMcpTool, + [ManageKnowledgeBase.id]: ManageKnowledgeBase, + [ManageMcpConnection.id]: ManageMcpConnection, [ManageSandbox.id]: ManageSandbox, [ManageSkill.id]: ManageSkill, - [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, [Mkdir.id]: Mkdir, [Mv.id]: Mv, [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, + [PrepareFileEdit.id]: PrepareFileEdit, [PromoteToLive.id]: PromoteToLive, + [PublishCustomBlock.id]: PublishCustomBlock, [QueryLogs.id]: QueryLogs, [QueryUserTable.id]: QueryUserTable, [Read.id]: Read, @@ -6596,17 +6668,17 @@ export const TOOL_CATALOG: Record = { [Run.id]: Run, [RunBlock.id]: RunBlock, [RunCode.id]: RunCode, + [RunEnrichment.id]: RunEnrichment, [RunFromBlock.id]: RunFromBlock, + [RunFunction.id]: RunFunction, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, - [ScrapePage.id]: ScrapePage, + [SaveUpload.id]: SaveUpload, [Search.id]: Search, - [SearchDocumentation.id]: SearchDocumentation, [SearchIntegrationTools.id]: SearchIntegrationTools, [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, - [SearchOnline.id]: SearchOnline, - [SearchPatterns.id]: SearchPatterns, + [SearchSimDocs.id]: SearchSimDocs, [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, @@ -6617,11 +6689,15 @@ export const TOOL_CATALOG: Record = { [TableEnrichments.id]: TableEnrichments, [TableManage.id]: TableManage, [TableRows.id]: TableRows, + [TableViews.id]: TableViews, [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, [Wait.id]: Wait, + [WebCrawl.id]: WebCrawl, + [WebFetch.id]: WebFetch, + [WebScrape.id]: WebScrape, + [WebSearch.id]: WebSearch, [Workflow.id]: Workflow, - [WorkspaceFile.id]: WorkspaceFile, } diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a3fd31e1c85..50d9fe521a5 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,18 +10,37 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + apply_file_edit: { parameters: { + type: 'object', properties: { - request: { - description: 'What tool/skill/MCP action is needed.', + content: { type: 'string', + description: + 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.', }, }, - required: ['request'], + required: ['content'], + }, + resultSchema: { type: 'object', + properties: { + data: { + type: 'object', + description: + 'Optional operation metadata such as file id, file name, size, and content type.', + }, + message: { + type: 'string', + description: 'Human-readable summary of the outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the content was applied successfully.', + }, + }, + required: ['success', 'message'], }, - resultSchema: undefined, }, auth: { parameters: { @@ -1106,18 +1125,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { - parameters: { - type: 'object', - properties: { - workflowId: { - type: 'string', - description: 'Workflow ID to check (defaults to current workflow)', - }, - }, - }, - resultSchema: undefined, - }, cp: { parameters: { type: 'object', @@ -1145,42 +1152,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { - parameters: { - type: 'object', - properties: { - exclude_paths: { - type: 'array', - description: 'Skip URLs matching these patterns', - items: { - type: 'string', - }, - }, - include_paths: { - type: 'array', - description: 'Only crawl URLs matching these patterns', - items: { - type: 'string', - }, - }, - limit: { - type: 'number', - description: 'Maximum pages to crawl (default 10, max 50)', - }, - max_depth: { - type: 'number', - description: 'How deep to follow links (default 2)', - }, - url: { - type: 'string', - description: 'Starting URL to crawl from', - }, - }, - required: ['url'], - }, - resultSchema: undefined, - }, - create_file: { + create_empty_file: { parameters: { type: 'object', properties: { @@ -1328,7 +1300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + deploy_as_api: { parameters: { type: 'object', properties: { @@ -1382,7 +1354,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".', + 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".', }, examples: { type: 'object', @@ -1412,7 +1384,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + deploy_as_chat: { parameters: { type: 'object', properties: { @@ -1526,7 +1498,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_chat this is always "chat".', + 'Deployment surface this result describes. For deploy_as_chat this is always "chat".', }, examples: { type: 'object', @@ -1547,7 +1519,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, success: { type: 'boolean', - description: 'Whether the deploy_chat action completed successfully.', + description: 'Whether the deploy_as_chat action completed successfully.', }, version: { type: 'number', @@ -1571,134 +1543,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_custom_block: { - parameters: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', - enum: ['deploy', 'undeploy'], - default: 'deploy', - }, - description: { - type: 'string', - description: 'Short description shown in the block picker, max 280 characters', - }, - exposedOutputs: { - type: 'array', - description: - "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", - items: { - type: 'object', - properties: { - blockId: { - type: 'string', - description: 'Block UUID inside the workflow', - }, - name: { - type: 'string', - description: 'Friendly output name shown on the block', - }, - path: { - type: 'string', - description: - "Dot-path into that block's output (from get_block_outputs relativeOutputs)", - }, - }, - required: ['blockId', 'path', 'name'], - }, - }, - iconUrl: { - type: 'string', - description: - 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', - }, - inputs: { - type: 'array', - description: - "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", - items: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Stable id of the input trigger field', - }, - placeholder: { - type: 'string', - description: "Placeholder text shown in the block's input field", - }, - }, - required: ['id'], - }, - }, - name: { - type: 'string', - description: - 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', - }, - workflowId: { - type: 'string', - description: 'Workflow ID (defaults to active workflow)', - }, - }, - }, - resultSchema: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Action performed by the tool, such as "deploy" or "undeploy".', - }, - blockId: { - type: 'string', - description: 'Custom block record ID.', - }, - blockType: { - type: 'string', - description: 'Stable block type slug (custom_block_*) used in workflow state.', - }, - deploymentConfig: { - type: 'object', - description: - "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", - }, - deploymentStatus: { - type: 'object', - description: - 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', - }, - deploymentType: { - type: 'string', - description: - 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".', - }, - isDeployed: { - type: 'boolean', - description: 'Whether the custom block is published after this tool call.', - }, - name: { - type: 'string', - description: 'Display name of the custom block.', - }, - removed: { - type: 'boolean', - description: 'Whether the custom block was unpublished during an undeploy action.', - }, - updated: { - type: 'boolean', - description: 'Whether an existing custom block was updated instead of created.', - }, - workflowId: { - type: 'string', - description: 'Workflow ID the custom block is bound to.', - }, - }, - required: ['deploymentType', 'deploymentStatus'], - }, - }, - deploy_mcp: { + deploy_as_mcp: { parameters: { type: 'object', properties: { @@ -1773,7 +1618,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_mcp this is always "mcp".', + 'Deployment surface this result describes. For deploy_as_mcp this is always "mcp".', }, examples: { type: 'object', @@ -1844,7 +1689,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - download_to_workspace_file: { + download_file: { parameters: { type: 'object', properties: { @@ -1893,38 +1738,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { - parameters: { - type: 'object', - properties: { - content: { - type: 'string', - description: - 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.', - }, - }, - required: ['content'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - 'Optional operation metadata such as file id, file name, size, and content type.', - }, - message: { - type: 'string', - description: 'Human-readable summary of the outcome.', - }, - success: { - type: 'boolean', - description: 'Whether the content was applied successfully.', - }, - }, - required: ['success', 'message'], - }, - }, edit_workflow: { parameters: { type: 'object', @@ -1964,52 +1777,21 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - enrichment_run: { + extensions: { parameters: { - type: 'object', properties: { - enrichmentId: { + request: { + description: 'What tool/skill/MCP action is needed.', type: 'string', - description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", - enum: [ - 'work-email', - 'phone-number', - 'company-domain', - 'company-info', - 'email-verification', - ], - }, - inputs: { - type: 'object', - description: - 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.', }, }, - required: ['enrichmentId', 'inputs'], + required: ['request'], + type: 'object', }, - resultSchema: { - type: 'object', - properties: { - matched: { - type: 'boolean', - description: 'True when a provider returned a non-empty result.', - }, - provider: { - type: ['string', 'null'], - description: - 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', - }, - result: { - type: 'object', - description: 'Mapped output values from the winning provider (empty object on no match).', - }, - }, - required: ['matched', 'result'], - }, - }, - ffmpeg: { - parameters: { + resultSchema: undefined, + }, + ffmpeg: { + parameters: { type: 'object', properties: { aspectRatio: { @@ -2203,156 +1985,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - function_execute: { - parameters: { - type: 'object', - properties: { - code: { - type: 'string', - description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', - }, - inputs: { - type: 'object', - description: - 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', - properties: { - directories: { - type: 'array', - description: - 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', - }, - sandboxPath: { - type: 'string', - description: - 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', - }, - }, - required: ['path'], - }, - }, - files: { - type: 'array', - description: 'Workspace files to mount into the sandbox.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', - }, - sandboxPath: { - type: 'string', - description: - 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', - }, - }, - required: ['path'], - }, - }, - tables: { - type: 'array', - description: 'Workspace tables to mount as CSV files.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Canonical VFS table path when available.', - }, - sandboxPath: { - type: 'string', - description: 'Optional full sandbox path for the mounted CSV.', - }, - tableId: { - type: 'string', - description: 'Workspace table ID.', - }, - }, - }, - }, - }, - }, - language: { - type: 'string', - description: 'Execution language.', - enum: ['javascript', 'python', 'shell'], - }, - outputTable: { - type: 'string', - description: - 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', - }, - outputs: { - type: 'object', - description: - 'Workspace files to create or overwrite from returned code results or sandbox-created files.', - properties: { - files: { - type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', - items: { - type: 'object', - properties: { - format: { - type: 'string', - description: 'Optional serialization format for returned values.', - enum: ['json', 'csv', 'txt', 'md', 'html'], - }, - mimeType: { - type: 'string', - description: 'Optional MIME type override when inference is not enough.', - }, - mode: { - type: 'string', - description: 'Create a new file or overwrite an existing file at path.', - enum: ['create', 'overwrite'], - }, - path: { - type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', - }, - sandboxPath: { - type: 'string', - description: - 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', - }, - }, - required: ['path', 'mode'], - }, - }, - }, - }, - sandboxId: { - type: 'string', - description: - 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', - }, - timeout: { - type: 'number', - description: - 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', - default: 10, - }, - title: { - type: 'string', - description: - 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', - }, - }, - required: ['code'], - }, - resultSchema: undefined, - }, generate_api_key: { parameters: { type: 'object', @@ -2877,48 +2509,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_log: { + get_deployment_status: { parameters: { type: 'object', properties: { workflowId: { type: 'string', - description: - 'Optional workflow ID. If not provided, uses the current workflow in context.', - }, - }, - }, - resultSchema: undefined, - }, - get_page_contents: { - parameters: { - type: 'object', - properties: { - include_highlights: { - type: 'boolean', - description: 'Include key highlights (default false)', - }, - include_summary: { - type: 'boolean', - description: 'Include AI-generated summary (default false)', - }, - include_text: { - type: 'boolean', - description: 'Include full page text (default true)', - }, - urls: { - type: 'array', - description: 'URLs to get content from (max 10)', - items: { - type: 'string', - }, + description: 'Workflow ID to check (defaults to current workflow)', }, }, - required: ['urls'], }, resultSchema: undefined, }, - get_platform_actions: { + get_ui_reference: { parameters: { type: 'object', properties: {}, @@ -3037,218 +2640,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + list_deployment_versions: { parameters: { type: 'object', properties: { - args: { - type: 'object', - description: 'Arguments for the operation', - properties: { - apiKey: { - type: 'string', - description: - 'API key for API-key-based connectors (required when connector auth mode is apiKey)', - }, - chunkingConfig: { - type: 'object', - description: "Chunking configuration (optional for 'create')", - properties: { - maxSize: { - type: 'number', - description: 'Maximum chunk size (100-4000, default: 1024)', - default: 1024, - }, - minSize: { - type: 'number', - description: 'Minimum chunk size (1-2000, default: 1)', - default: 1, - }, - overlap: { - type: 'number', - description: 'Overlap between chunks (0-500, default: 200)', - default: 200, - }, - }, - }, - connectorId: { - type: 'string', - description: - 'Connector ID (required for update_connector, delete_connector, sync_connector)', - }, - connectorStatus: { - type: 'string', - description: 'Connector status (optional for update_connector)', - enum: ['active', 'paused'], - }, - connectorType: { - type: 'string', - description: - "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.", - }, - credentialId: { - type: 'string', - description: - 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)', - }, - description: { - type: 'string', - description: "Description of the knowledge base (optional for 'create')", - }, - disabledTagIds: { - type: 'array', - description: - 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', - }, - documentId: { - type: 'string', - description: 'Document ID (required for update_document)', - }, - documentIds: { - type: 'array', - description: 'Document IDs (for batch delete_document)', - items: { - type: 'string', - }, - }, - enabled: { - type: 'boolean', - description: 'Enable/disable a document (optional for update_document)', - }, - filePaths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', - items: { - type: 'string', - }, - }, - filename: { - type: 'string', - description: 'New filename for a document (optional for update_document)', - }, - knowledgeBaseId: { - type: 'string', - description: - 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', - }, - knowledgeBaseIds: { - type: 'array', - description: 'Knowledge base IDs (for batch delete)', - items: { - type: 'string', - }, - }, - name: { - type: 'string', - description: "Name of the knowledge base (required for 'create')", - }, - query: { - type: 'string', - description: "Search query text (required for 'query')", - }, - sourceConfig: { - type: 'object', - description: - 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json', - }, - syncIntervalMinutes: { - type: 'number', - description: - 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', - default: 1440, - }, - tagDefinitionId: { - type: 'string', - description: 'Tag definition ID (required for update_tag, delete_tag)', - }, - tagDisplayName: { - type: 'string', - description: - 'Display name for the tag (required for create_tag, optional for update_tag)', - }, - tagFieldType: { - type: 'string', - description: - 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', - enum: ['text', 'number', 'date', 'boolean'], - }, - tagValues: { - type: 'array', - description: - 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', - items: { - type: 'object', - properties: { - tagDefinitionId: { - type: 'string', - description: 'Tag definition ID returned by list_tags.', - }, - value: { - type: ['string', 'number', 'boolean', 'null'], - description: - "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", - }, - }, - required: ['tagDefinitionId', 'value'], - }, - }, - topK: { - type: 'number', - description: 'Number of results to return (1-50, default: 5)', - default: 5, - }, - workspaceId: { - type: 'string', - description: - "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", - }, - }, - }, - operation: { + workflowId: { type: 'string', - description: 'The operation to perform', - enum: [ - 'create', - 'get', - 'query', - 'add_file', - 'update', - 'delete_document', - 'update_document', - 'list_tags', - 'create_tag', - 'update_tag', - 'delete_tag', - 'get_tag_usage', - 'add_connector', - 'update_connector', - 'delete_connector', - 'sync_connector', - ], - }, - }, - required: ['operation'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: ['object', 'array'], description: - 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', - }, - message: { - type: 'string', - description: 'Human-readable outcome summary.', - }, - success: { - type: 'boolean', - description: 'Whether the operation succeeded.', + 'Optional workflow ID. If not provided, uses the current workflow in context.', }, }, - required: ['success', 'message'], }, + resultSchema: undefined, }, list_integration_tools: { parameters: { @@ -3426,24 +2829,237 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['type', 'function'], }, - toolId: { + toolId: { + type: 'string', + description: + "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", + }, + toolIds: { + type: 'array', + description: 'Array of custom tool IDs (for batch delete)', + items: { + type: 'string', + }, + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, + manage_knowledge_base: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + apiKey: { + type: 'string', + description: + 'API key for API-key-based connectors (required when connector auth mode is apiKey)', + }, + chunkingConfig: { + type: 'object', + description: "Chunking configuration (optional for 'create')", + properties: { + maxSize: { + type: 'number', + description: 'Maximum chunk size (100-4000, default: 1024)', + default: 1024, + }, + minSize: { + type: 'number', + description: 'Minimum chunk size (1-2000, default: 1)', + default: 1, + }, + overlap: { + type: 'number', + description: 'Overlap between chunks (0-500, default: 200)', + default: 200, + }, + }, + }, + connectorId: { + type: 'string', + description: + 'Connector ID (required for update_connector, delete_connector, sync_connector)', + }, + connectorStatus: { + type: 'string', + description: 'Connector status (optional for update_connector)', + enum: ['active', 'paused'], + }, + connectorType: { + type: 'string', + description: + "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.", + }, + credentialId: { + type: 'string', + description: + 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)', + }, + description: { + type: 'string', + description: "Description of the knowledge base (optional for 'create')", + }, + disabledTagIds: { + type: 'array', + description: + 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', + }, + documentId: { + type: 'string', + description: 'Document ID (required for update_document)', + }, + documentIds: { + type: 'array', + description: 'Document IDs (for batch delete_document)', + items: { + type: 'string', + }, + }, + enabled: { + type: 'boolean', + description: 'Enable/disable a document (optional for update_document)', + }, + filePaths: { + type: 'array', + description: + 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', + items: { + type: 'string', + }, + }, + filename: { + type: 'string', + description: 'New filename for a document (optional for update_document)', + }, + knowledgeBaseId: { + type: 'string', + description: + 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', + }, + knowledgeBaseIds: { + type: 'array', + description: 'Knowledge base IDs (for batch delete)', + items: { + type: 'string', + }, + }, + name: { + type: 'string', + description: "Name of the knowledge base (required for 'create')", + }, + query: { + type: 'string', + description: "Search query text (required for 'query')", + }, + sourceConfig: { + type: 'object', + description: + 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json', + }, + syncIntervalMinutes: { + type: 'number', + description: + 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', + default: 1440, + }, + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID (required for update_tag, delete_tag)', + }, + tagDisplayName: { + type: 'string', + description: + 'Display name for the tag (required for create_tag, optional for update_tag)', + }, + tagFieldType: { + type: 'string', + description: + 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', + enum: ['text', 'number', 'date', 'boolean'], + }, + tagValues: { + type: 'array', + description: + 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', + items: { + type: 'object', + properties: { + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID returned by list_tags.', + }, + value: { + type: ['string', 'number', 'boolean', 'null'], + description: + "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", + }, + }, + required: ['tagDefinitionId', 'value'], + }, + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + workspaceId: { + type: 'string', + description: + "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", + }, + }, + }, + operation: { + type: 'string', + description: 'The operation to perform', + enum: [ + 'create', + 'get', + 'query', + 'add_file', + 'update', + 'delete_document', + 'update_document', + 'list_tags', + 'create_tag', + 'update_tag', + 'delete_tag', + 'get_tag_usage', + 'add_connector', + 'update_connector', + 'delete_connector', + 'sync_connector', + ], + }, + }, + required: ['operation'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', + }, + message: { type: 'string', - description: - "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", + description: 'Human-readable outcome summary.', }, - toolIds: { - type: 'array', - description: 'Array of custom tool IDs (for batch delete)', - items: { - type: 'string', - }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', }, }, - required: ['operation'], + required: ['success', 'message'], }, - resultSchema: undefined, }, - manage_mcp_tool: { + manage_mcp_connection: { parameters: { type: 'object', properties: { @@ -3582,30 +3198,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { - parameters: { - type: 'object', - properties: { - fileNames: { - type: 'array', - description: - 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', - items: { - type: 'string', - }, - }, - operation: { - type: 'string', - description: - 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".', - enum: ['save', 'import', 'extract'], - default: 'save', - }, - }, - required: ['fileNames'], - }, - resultSchema: undefined, - }, media: { parameters: { properties: { @@ -3708,51 +3300,306 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { resources: { type: 'array', description: - 'Array of resources to open. Each item must have type and either id or, for files, path.', + 'Array of resources to open. Each item must have type and either id or, for files, path.', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Canonical resource ID for non-file resources.', + }, + path: { + type: 'string', + description: + 'Encoded VFS path for type "file" (percent-encoded per segment, e.g. "files/Reports/Q4%20Report.pdf"). Copy it verbatim from glob/read/workspace context output — do not decode it to a display name or re-encode it.', + }, + type: { + type: 'string', + description: 'The resource type.', + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], + }, + view: { + type: 'string', + description: + 'Saved table view to open pinned (type "table" only): a view id or exact view name from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', + }, + }, + required: ['type'], + }, + }, + }, + required: ['resources'], + }, + resultSchema: undefined, + }, + prepare_file_edit: { + parameters: { + type: 'object', + properties: { + operation: { + type: 'string', + description: 'The file operation to perform.', + enum: ['append', 'update', 'patch'], + }, + target: { + type: 'object', + description: 'Explicit file target. Use kind=path + path for existing files.', + properties: { + kind: { + type: 'string', + description: 'How the file target is identified.', + enum: ['path'], + }, + path: { + type: 'string', + description: + 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', + }, + }, + required: ['kind'], + }, + title: { + type: 'string', + description: + 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".', + }, + contentType: { + type: 'string', + description: + 'Optional MIME type override. Usually omit and let the system infer from the target file extension.', + enum: [ + 'text/markdown', + 'text/html', + 'text/plain', + 'application/json', + 'text/csv', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/pdf', + ], + }, + edit: { + type: 'object', + description: + 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired apply_file_edit tool call.', + properties: { + after_anchor: { + type: 'string', + description: + 'Boundary line kept after inserted replacement content. Required for mode=replace_between.', + }, + anchor: { + type: 'string', + description: + 'Anchor line after which new content is inserted. Required for mode=insert_after.', + }, + before_anchor: { + type: 'string', + description: + 'Boundary line kept before inserted replacement content. Required for mode=replace_between.', + }, + end_anchor: { + type: 'string', + description: 'First line to keep after deletion. Required for mode=delete_between.', + }, + mode: { + type: 'string', + description: 'Anchored edit mode when strategy=anchored.', + enum: ['replace_between', 'insert_after', 'delete_between'], + }, + occurrence: { + type: 'number', + description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.', + }, + replaceAll: { + type: 'boolean', + description: + 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.', + }, + search: { + type: 'string', + description: + 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.', + }, + start_anchor: { + type: 'string', + description: 'First line to delete. Required for mode=delete_between.', + }, + strategy: { + type: 'string', + description: 'Patch strategy.', + enum: ['search_replace', 'anchored'], + }, + }, + }, + }, + required: ['operation', 'target', 'title'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Optional operation metadata such as file id, file name, size, and content type.', + }, + message: { + type: 'string', + description: 'Human-readable summary of the outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the file operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + promote_to_live: { + parameters: { + type: 'object', + properties: { + version: { + type: 'number', + description: + 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + }, + workflowId: { + type: 'string', + description: + 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], + }, + resultSchema: undefined, + }, + publish_custom_block: { + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', + enum: ['deploy', 'undeploy'], + default: 'deploy', + }, + description: { + type: 'string', + description: 'Short description shown in the block picker, max 280 characters', + }, + exposedOutputs: { + type: 'array', + description: + "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'Block UUID inside the workflow', + }, + name: { + type: 'string', + description: 'Friendly output name shown on the block', + }, + path: { + type: 'string', + description: + "Dot-path into that block's output (from get_block_outputs relativeOutputs)", + }, + }, + required: ['blockId', 'path', 'name'], + }, + }, + iconUrl: { + type: 'string', + description: + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', + }, + inputs: { + type: 'array', + description: + "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", items: { type: 'object', properties: { id: { type: 'string', - description: 'Canonical resource ID for non-file resources.', - }, - path: { - type: 'string', - description: - 'Encoded VFS path for type "file" (percent-encoded per segment, e.g. "files/Reports/Q4%20Report.pdf"). Copy it verbatim from glob/read/workspace context output — do not decode it to a display name or re-encode it.', + description: 'Stable id of the input trigger field', }, - type: { + placeholder: { type: 'string', - description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], + description: "Placeholder text shown in the block's input field", }, }, - required: ['type'], + required: ['id'], }, }, + name: { + type: 'string', + description: + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', + }, + workflowId: { + type: 'string', + description: 'Workflow ID (defaults to active workflow)', + }, }, - required: ['resources'], }, - resultSchema: undefined, - }, - promote_to_live: { - parameters: { + resultSchema: { type: 'object', properties: { - version: { - type: 'number', + action: { + type: 'string', + description: 'Action performed by the tool, such as "deploy" or "undeploy".', + }, + blockId: { + type: 'string', + description: 'Custom block record ID.', + }, + blockType: { + type: 'string', + description: 'Stable block type slug (custom_block_*) used in workflow state.', + }, + deploymentConfig: { + type: 'object', description: - 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", }, - workflowId: { + deploymentStatus: { + type: 'object', + description: + 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', + }, + deploymentType: { type: 'string', description: - 'Optional workflow ID. If not provided, uses the current workflow in context.', + 'Deployment surface this result describes. For publish_custom_block this is always "custom_block".', + }, + isDeployed: { + type: 'boolean', + description: 'Whether the custom block is published after this tool call.', + }, + name: { + type: 'string', + description: 'Display name of the custom block.', + }, + removed: { + type: 'boolean', + description: 'Whether the custom block was unpublished during an undeploy action.', + }, + updated: { + type: 'boolean', + description: 'Whether an existing custom block was updated instead of created.', + }, + workflowId: { + type: 'string', + description: 'Workflow ID the custom block is bound to.', }, }, - required: ['version'], + required: ['deploymentType', 'deploymentStatus'], }, - resultSchema: undefined, }, query_logs: { parameters: { @@ -3901,6 +3748,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: 'Table ID (required for all operations)', }, + view: { + type: 'string', + description: + "Saved view to query through (query_rows only): a view id or exact view name from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", + }, }, }, operation: { @@ -4006,7 +3858,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".', + 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".', }, examples: { type: 'object', @@ -4119,24 +3971,193 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, - required: ['request'], - type: 'object', + required: ['request'], + type: 'object', + }, + resultSchema: undefined, + }, + run_block: { + parameters: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'The block ID to run in isolation.', + }, + executionId: { + type: 'string', + description: + 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', + }, + useDeployedState: { + type: 'boolean', + description: + 'When true, runs the deployed version instead of the live draft. Default: false (draft).', + }, + workflowId: { + type: 'string', + description: + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', + }, + workflow_input: { + type: 'object', + description: 'JSON object with key-value mappings where each key is an input field name', + }, + }, + required: ['workflowId', 'blockId'], + }, + resultSchema: undefined, + }, + run_code: { + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + resultSchema: undefined, + }, + run_enrichment: { + parameters: { + type: 'object', + properties: { + enrichmentId: { + type: 'string', + description: + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", + enum: [ + 'work-email', + 'phone-number', + 'company-domain', + 'company-info', + 'email-verification', + ], + }, + inputs: { + type: 'object', + description: + 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.', + }, + }, + required: ['enrichmentId', 'inputs'], + }, + resultSchema: { + type: 'object', + properties: { + matched: { + type: 'boolean', + description: 'True when a provider returned a non-empty result.', + }, + provider: { + type: ['string', 'null'], + description: + 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', + }, + result: { + type: 'object', + description: 'Mapped output values from the winning provider (empty object on no match).', + }, + }, + required: ['matched', 'result'], }, - resultSchema: undefined, }, - run_block: { + run_from_block: { parameters: { type: 'object', properties: { - blockId: { - type: 'string', - description: 'The block ID to run in isolation.', - }, executionId: { type: 'string', description: 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', }, + startBlockId: { + type: 'string', + description: 'The block ID to start execution from.', + }, useDeployedState: { type: 'boolean', description: @@ -4152,11 +4173,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['workflowId', 'blockId'], + required: ['workflowId', 'startBlockId'], }, resultSchema: undefined, }, - run_code: { + run_function: { parameters: { type: 'object', properties: { @@ -4239,45 +4260,70 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Execution language.', enum: ['javascript', 'python', 'shell'], }, - title: { + outputTable: { type: 'string', description: - 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', }, - }, - required: ['code'], - }, - resultSchema: undefined, - }, - run_from_block: { - parameters: { - type: 'object', - properties: { - executionId: { - type: 'string', + outputs: { + type: 'object', description: - 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, - startBlockId: { + sandboxId: { type: 'string', - description: 'The block ID to start execution from.', + description: + 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default run_function environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', }, - useDeployedState: { - type: 'boolean', + timeout: { + type: 'number', description: - 'When true, runs the deployed version instead of the live draft. Default: false (draft).', + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', + default: 10, }, - workflowId: { + title: { type: 'string', description: - 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', - }, - workflow_input: { - type: 'object', - description: 'JSON object with key-value mappings where each key is an input field name', + 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', }, }, - required: ['workflowId', 'startBlockId'], + required: ['code'], }, resultSchema: undefined, }, @@ -4368,24 +4414,27 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + save_upload: { parameters: { type: 'object', properties: { - include_links: { - type: 'boolean', - description: 'Extract all links from the page (default false)', - }, - url: { - type: 'string', - description: 'The URL to scrape (must include https://)', + fileNames: { + type: 'array', + description: + 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', + items: { + type: 'string', + }, }, - wait_for: { + operation: { type: 'string', - description: 'CSS selector to wait for before scraping (for JS-heavy pages)', + description: + 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".', + enum: ['save', 'import', 'extract'], + default: 'save', }, }, - required: ['url'], + required: ['fileNames'], }, resultSchema: undefined, }, @@ -4403,25 +4452,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query', - }, - topK: { - type: 'number', - description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, - }, - }, - required: ['query'], - }, - resultSchema: undefined, - }, search_integration_tools: { parameters: { properties: { @@ -4519,65 +4549,22 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + search_sim_docs: { parameters: { type: 'object', properties: { - category: { - type: 'string', - description: 'Filter by category', - enum: [ - 'news', - 'tweet', - 'github', - 'company', - 'research paper', - 'linkedin profile', - 'pdf', - 'personal site', - ], - }, - include_text: { - type: 'boolean', - description: 'Include page text content (default true)', - }, - num_results: { - type: 'number', - description: 'Number of results (default 10, max 25)', - }, query: { type: 'string', - description: 'Natural language search query', - }, - toolTitle: { - type: 'string', - description: - "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", - }, - }, - required: ['query', 'toolTitle'], - }, - resultSchema: undefined, - }, - search_patterns: { - parameters: { - type: 'object', - properties: { - limit: { - type: 'integer', - description: 'Maximum number of pattern examples to return per query (defaults to 3).', + description: 'The search query', }, - queries: { - type: 'array', + topK: { + type: 'number', description: - 'Up to 3 descriptive strings explaining the workflow pattern(s) you need. Focus on intent and desired outcomes.', - items: { - type: 'string', - description: 'Example: "how to automate wealthbox meeting notes into follow-up tasks"', - }, + 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', + default: 10, }, }, - required: ['queries'], + required: ['query'], }, resultSchema: undefined, }, @@ -5258,46 +5245,128 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: 'Row ID (required for update_row, delete_row)', }, - rowIds: { + rowIds: { + type: 'array', + description: 'Array of row IDs to delete (required for batch_delete_rows)', + items: { + type: 'string', + }, + }, + rows: { + type: 'array', + description: 'Array of row data objects (required for batch_insert_rows)', + }, + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + updates: { + type: 'array', + description: + 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', + }, + values: { + type: 'object', + description: + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The row operation to perform', + enum: [ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_views: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: + 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.', + }, + hiddenColumns: { type: 'array', - description: 'Array of row IDs to delete (required for batch_delete_rows)', + description: + 'Column names to hide in the UI when this view is active. Display-only — queries through the view still return every column.', items: { type: 'string', }, }, - rows: { - type: 'array', - description: 'Array of row data objects (required for batch_insert_rows)', + isDefault: { + type: 'boolean', + description: + "Make this view the table's default (at most one per table; setting it clears the previous default).", }, - tableId: { + name: { type: 'string', - description: 'Table ID (required for every operation)', + description: + "View display name (required for create_view; optional rename on update_view). Free-form label, need not be unique — prefer distinct names so query_user_table's view argument can use them unambiguously.", }, - updates: { + sort: { type: 'array', description: - 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', + 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.', }, - values: { - type: 'object', + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + viewId: { + type: 'string', description: - 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + 'View ID (required for get_view, update_view, delete_view, set_default_view)', }, }, required: ['tableId'], }, operation: { type: 'string', - description: 'The row operation to perform', + description: 'The view operation to perform', enum: [ - 'insert_row', - 'batch_insert_rows', - 'update_row', - 'batch_update_rows', - 'delete_row', - 'batch_delete_rows', - 'update_rows_by_filter', - 'delete_rows_by_filter', + 'list_views', + 'get_view', + 'create_view', + 'update_view', + 'delete_view', + 'set_default_view', ], }, }, @@ -5453,7 +5522,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { version: { type: 'number', description: - 'The numeric deployment version number to update (use get_deployment_log to find it).', + 'The numeric deployment version number to update (use list_deployment_versions to find it).', }, workflowId: { type: 'string', @@ -5883,153 +5952,154 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - workflow: { + web_crawl: { parameters: { + type: 'object', properties: { - prompt: { - description: - 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', - type: 'string', + exclude_paths: { + type: 'array', + description: 'Skip URLs matching these patterns', + items: { + type: 'string', + }, }, - sessionId: { - description: - 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — never re-pass a sessionId already used this turn; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', - type: 'string', + include_paths: { + type: 'array', + description: 'Only crawl URLs matching these patterns', + items: { + type: 'string', + }, }, - title: { - description: - "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", - maxLength: 120, - minLength: 1, + limit: { + type: 'number', + description: 'Maximum pages to crawl (default 10, max 50)', + }, + max_depth: { + type: 'number', + description: 'How deep to follow links (default 2)', + }, + url: { type: 'string', + description: 'Starting URL to crawl from', }, }, - required: ['title'], - type: 'object', + required: ['url'], }, resultSchema: undefined, }, - workspace_file: { + web_fetch: { parameters: { type: 'object', properties: { - operation: { - type: 'string', - description: 'The file operation to perform.', - enum: ['append', 'update', 'patch'], + include_highlights: { + type: 'boolean', + description: 'Include key highlights (default false)', }, - target: { - type: 'object', - description: 'Explicit file target. Use kind=path + path for existing files.', - properties: { - kind: { - type: 'string', - description: 'How the file target is identified.', - enum: ['path'], - }, - path: { - type: 'string', - description: - 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', - }, + include_summary: { + type: 'boolean', + description: 'Include AI-generated summary (default false)', + }, + include_text: { + type: 'boolean', + description: 'Include full page text (default true)', + }, + urls: { + type: 'array', + description: 'URLs to get content from (max 10)', + items: { + type: 'string', }, - required: ['kind'], }, - title: { + }, + required: ['urls'], + }, + resultSchema: undefined, + }, + web_scrape: { + parameters: { + type: 'object', + properties: { + include_links: { + type: 'boolean', + description: 'Extract all links from the page (default false)', + }, + url: { type: 'string', - description: - 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".', + description: 'The URL to scrape (must include https://)', }, - contentType: { + wait_for: { type: 'string', - description: - 'Optional MIME type override. Usually omit and let the system infer from the target file extension.', + description: 'CSS selector to wait for before scraping (for JS-heavy pages)', + }, + }, + required: ['url'], + }, + resultSchema: undefined, + }, + web_search: { + parameters: { + type: 'object', + properties: { + category: { + type: 'string', + description: 'Filter by category', enum: [ - 'text/markdown', - 'text/html', - 'text/plain', - 'application/json', - 'text/csv', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/pdf', + 'news', + 'tweet', + 'github', + 'company', + 'research paper', + 'linkedin profile', + 'pdf', + 'personal site', ], }, - edit: { - type: 'object', + include_text: { + type: 'boolean', + description: 'Include page text content (default true)', + }, + num_results: { + type: 'number', + description: 'Number of results (default 10, max 25)', + }, + query: { + type: 'string', + description: 'Natural language search query', + }, + toolTitle: { + type: 'string', description: - 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired edit_content tool call.', - properties: { - after_anchor: { - type: 'string', - description: - 'Boundary line kept after inserted replacement content. Required for mode=replace_between.', - }, - anchor: { - type: 'string', - description: - 'Anchor line after which new content is inserted. Required for mode=insert_after.', - }, - before_anchor: { - type: 'string', - description: - 'Boundary line kept before inserted replacement content. Required for mode=replace_between.', - }, - end_anchor: { - type: 'string', - description: 'First line to keep after deletion. Required for mode=delete_between.', - }, - mode: { - type: 'string', - description: 'Anchored edit mode when strategy=anchored.', - enum: ['replace_between', 'insert_after', 'delete_between'], - }, - occurrence: { - type: 'number', - description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.', - }, - replaceAll: { - type: 'boolean', - description: - 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.', - }, - search: { - type: 'string', - description: - 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.', - }, - start_anchor: { - type: 'string', - description: 'First line to delete. Required for mode=delete_between.', - }, - strategy: { - type: 'string', - description: 'Patch strategy.', - enum: ['search_replace', 'anchored'], - }, - }, + "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", }, }, - required: ['operation', 'target', 'title'], + required: ['query', 'toolTitle'], }, - resultSchema: { - type: 'object', + resultSchema: undefined, + }, + workflow: { + parameters: { properties: { - data: { - type: 'object', + prompt: { description: - 'Optional operation metadata such as file id, file name, size, and content type.', + 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', + type: 'string', }, - message: { + sessionId: { + description: + 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — never re-pass a sessionId already used this turn; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', type: 'string', - description: 'Human-readable summary of the outcome.', }, - success: { - type: 'boolean', - description: 'Whether the file operation succeeded.', + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, + type: 'string', }, }, - required: ['success', 'message'], + required: ['title'], + type: 'object', }, + resultSchema: undefined, }, } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index 1947b635512..b8659cb2d6c 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1' +import { RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' import { TraceCollector } from '@/lib/copilot/request/trace' import type { StreamingContext } from '@/lib/copilot/request/types' @@ -44,7 +44,7 @@ describe('buildToolCallSummaries', () => { const context = makeContext() context.toolCalls.set('tool-1', { id: 'tool-1', - name: 'download_to_workspace_file', + name: 'download_file', status: 'pending', startTime: 1, }) @@ -59,7 +59,7 @@ describe('buildToolCallSummaries', () => { const context = makeContext() context.toolCalls.set('tool-2', { id: 'tool-2', - name: FunctionExecute.id, + name: RunFunction.id, status: 'executing', startTime: 1, }) @@ -76,7 +76,7 @@ describe('buildToolCallSummaries', () => { const context = makeContext() context.toolCalls.set('tool-3', { id: 'tool-3', - name: 'download_to_workspace_file', + name: 'download_file', status: MothershipStreamV1ToolOutcome.cancelled, result: { success: false }, error: 'Stopped by user', @@ -89,7 +89,7 @@ describe('buildToolCallSummaries', () => { expect(summaries).toHaveLength(1) expect(summaries[0]).toEqual({ id: 'tool-3', - name: 'download_to_workspace_file', + name: 'download_file', status: MothershipStreamV1ToolOutcome.cancelled, params: undefined, result: undefined, diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts index 27851eabba6..3e14e0bd0ee 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts @@ -54,21 +54,21 @@ function toolEvent(payload: Record): StreamEvent { ) } -/** One args_delta chunk of the streamed `edit_content` JSON, as a driveable StreamEvent. */ +/** One args_delta chunk of the streamed `apply_file_edit` JSON, as a driveable StreamEvent. */ function editContentDelta(argumentsDelta: string): StreamEvent { return toolEvent({ toolCallId: EDIT_TOOL_CALL_ID, - toolName: 'edit_content', + toolName: 'apply_file_edit', phase: MothershipStreamV1ToolPhase.args_delta, argumentsDelta, }) } -/** The authoritative `workspace_file` call frame for a path-targeted update. */ +/** The authoritative `prepare_file_edit` call frame for a path-targeted update. */ function workspaceFileCall(): StreamEvent { return toolEvent({ toolCallId: WORKSPACE_FILE_TOOL_CALL_ID, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', phase: MothershipStreamV1ToolPhase.call, arguments: { operation: 'update', diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index b79254a211e..d347b01fd6f 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -382,7 +382,7 @@ export async function processFilePreviewStreamEvent(input: { // Scope the in-flight intent to the invoking file subagent's channel (its // outer tool_use id) so two file agents streaming concurrently never read or - // overwrite each other's intent. workspace_file and edit_content from the same + // overwrite each other's intent. prepare_file_edit and apply_file_edit from the same // file agent share this channel id, so they pair up; siblings stay isolated. const channelId = streamEvent.scope?.parentToolCallId ?? '' const getIntent = (): FileIntent | null => context.activeFileIntents.get(channelId) ?? null @@ -393,7 +393,7 @@ export async function processFilePreviewStreamEvent(input: { context.activeFileIntents.delete(channelId) } - if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'workspace_file') { + if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'prepare_file_edit') { const toolCallId = streamEvent.payload.toolCallId const parsedArgs = parseWorkspaceFileArgs(streamEvent.payload.arguments) if (toolCallId && parsedArgs) { @@ -408,7 +408,7 @@ export async function processFilePreviewStreamEvent(input: { const { fileId, fileName } = target const isContentOp = isContentOperation(operation) - // Per-channel: a re-declared workspace_file just overwrites THIS channel's + // Per-channel: a re-declared prepare_file_edit just overwrites THIS channel's // slot. No cross-message intent clearing — that would wipe a concurrent // sibling file agent's pending intent. const intent: FileIntent = { @@ -451,12 +451,12 @@ export async function processFilePreviewStreamEvent(input: { await emitPreviewEvent(streamEvent, options, { toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_start', }) await emitPreviewEvent(streamEvent, options, { toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_target', operation, target: { @@ -469,7 +469,7 @@ export async function processFilePreviewStreamEvent(input: { if (edit) { await emitPreviewEvent(streamEvent, options, { toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_edit_meta', edit, }) @@ -481,7 +481,7 @@ export async function processFilePreviewStreamEvent(input: { const workspaceResultIntent = getIntent() if ( isToolResultStreamEvent(streamEvent) && - streamEvent.payload.toolName === 'workspace_file' && + streamEvent.payload.toolName === 'prepare_file_edit' && workspaceResultIntent && isContentOperation(workspaceResultIntent.operation) ) { @@ -526,12 +526,12 @@ export async function processFilePreviewStreamEvent(input: { await emitPreviewEvent(streamEvent, options, { toolCallId: intent.toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_start', }) await emitPreviewEvent(streamEvent, options, { toolCallId: intent.toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_target', operation: intent.operation, target: { @@ -544,7 +544,7 @@ export async function processFilePreviewStreamEvent(input: { if (intent.edit) { await emitPreviewEvent(streamEvent, options, { toolCallId: intent.toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_edit_meta', edit: intent.edit, }) @@ -555,7 +555,7 @@ export async function processFilePreviewStreamEvent(input: { const patchDeleteIntent = getIntent() if ( isToolResultStreamEvent(streamEvent) && - streamEvent.payload.toolName === 'workspace_file' && + streamEvent.payload.toolName === 'prepare_file_edit' && patchDeleteIntent && isContentOperation(patchDeleteIntent.operation) && patchDeleteIntent.operation === 'patch' && @@ -594,7 +594,7 @@ export async function processFilePreviewStreamEvent(input: { await persistFilePreviewSession(nextSession) await emitPreviewEvent(streamEvent, options, { toolCallId: nextSession.toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_content', content: previewText, contentMode: 'snapshot', @@ -608,7 +608,10 @@ export async function processFilePreviewStreamEvent(input: { } } - if (isToolArgsDeltaStreamEvent(streamEvent) && streamEvent.payload.toolName === 'edit_content') { + if ( + isToolArgsDeltaStreamEvent(streamEvent) && + streamEvent.payload.toolName === 'apply_file_edit' + ) { const toolCallId = streamEvent.payload.toolCallId const delta = streamEvent.payload.argumentsDelta const stateForTool = editContentState.get(toolCallId) ?? { raw: '' } @@ -685,7 +688,7 @@ export async function processFilePreviewStreamEvent(input: { // collaborative editor for this file is open, that client applies the stream to the shared // doc as minimal CRDT diffs (see `applyStreamedMarkdownToLiveDoc` in the editor), which // renders smoothly locally AND broadcasts to every peer — so a server-side streaming merge - // would double-write the shared doc. The final `edit_content` durable write still reconciles + // would double-write the shared doc. The final `apply_file_edit` durable write still reconciles // the file and seeds any late joiner. if ( @@ -714,7 +717,7 @@ export async function processFilePreviewStreamEvent(input: { await emitPreviewEvent(streamEvent, options, { toolCallId: nextSession.toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_content', content: previewUpdate.content, contentMode: previewUpdate.contentMode, @@ -739,7 +742,7 @@ export async function processFilePreviewStreamEvent(input: { editContentState.set(toolCallId, stateForTool) } - if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'edit_content') { + if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'apply_file_edit') { const toolCallId = streamEvent.payload.toolCallId if (toolCallId) { editContentState.delete(toolCallId) @@ -749,7 +752,7 @@ export async function processFilePreviewStreamEvent(input: { const editResultIntent = getIntent() if ( isToolResultStreamEvent(streamEvent) && - streamEvent.payload.toolName === 'edit_content' && + streamEvent.payload.toolName === 'apply_file_edit' && editResultIntent ) { const currentPreview = filePreviewState.get(editResultIntent.toolCallId) @@ -767,7 +770,7 @@ export async function processFilePreviewStreamEvent(input: { }) await emitPreviewEvent(streamEvent, options, { toolCallId: currentPreview.session.toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_content', content: currentPreview.session.previewText, contentMode: 'snapshot', @@ -801,7 +804,7 @@ export async function processFilePreviewStreamEvent(input: { await emitPreviewEvent(streamEvent, options, { toolCallId: editResultIntent.toolCallId, - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_complete', fileId: editResultIntent.target.fileId, output: streamEvent.payload.output, diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 5921c6e89b7..25f550a3a03 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -181,7 +181,7 @@ describe('copilot go stream helpers', () => { expect(decodeJsonStringPrefix('partial \\u26')).toBe('partial ') }) - it('extracts the streamed edit_content prefix from partial JSON', () => { + it('extracts the streamed apply_file_edit prefix from partial JSON', () => { expect(extractEditContent('{"content":"hello\\nwor')).toBe('hello\nwor') expect(extractEditContent('{"content":"tab\\tvalue"}')).toBe('tab\tvalue') }) @@ -216,7 +216,7 @@ describe('copilot go stream helpers', () => { }) }) - it('hydrates path-based workspace_file edits into file preview events before edit_content streams', async () => { + it('hydrates path-based prepare_file_edit edits into file preview events before apply_file_edit streams', async () => { listAllWorkspaceFilesMock.mockResolvedValue({ files: [{ id: 'file-1', name: 'notes.md', folderPath: null }], }) @@ -229,7 +229,7 @@ describe('copilot go stream helpers', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'workspace-file-path-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.call, @@ -248,7 +248,7 @@ describe('copilot go stream helpers', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'workspace-file-path-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, @@ -267,7 +267,7 @@ describe('copilot go stream helpers', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'edit-content-path-1', - toolName: 'edit_content', + toolName: 'apply_file_edit', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.args_delta, @@ -282,7 +282,7 @@ describe('copilot go stream helpers', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'edit-content-path-1', - toolName: 'edit_content', + toolName: 'apply_file_edit', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, @@ -378,7 +378,7 @@ describe('copilot go stream helpers', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'workspace-file-alias-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.call, @@ -397,7 +397,7 @@ describe('copilot go stream helpers', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'edit-content-alias-1', - toolName: 'edit_content', + toolName: 'apply_file_edit', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.args_delta, @@ -412,7 +412,7 @@ describe('copilot go stream helpers', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'edit-content-alias-1', - toolName: 'edit_content', + toolName: 'apply_file_edit', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, @@ -492,7 +492,7 @@ describe('copilot go stream helpers', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'tool-result-dedupe', - toolName: 'search_online', + toolName: 'web_search', executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, @@ -541,7 +541,7 @@ describe('copilot go stream helpers', () => { expect(context.toolCalls.get('tool-result-dedupe')).toEqual( expect.objectContaining({ id: 'tool-result-dedupe', - name: 'search_online', + name: 'web_search', status: MothershipStreamV1ToolOutcome.success, result: { success: true, output: { value: 'ok' } }, }) diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 32562b8293b..82762e5d46a 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -87,7 +87,7 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' -import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' import { prePersistClientExecutableToolCall, sseHandlers, @@ -244,7 +244,7 @@ describe('sse-handlers tool lifecycle', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'deploy-1', - toolName: 'deploy_api', + toolName: 'deploy_as_api', arguments: { versionName: 'v2' }, executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -259,7 +259,7 @@ describe('sse-handlers tool lifecycle', () => { expect(upsertAsyncToolCall).toHaveBeenCalledWith({ runId: 'run-1', toolCallId: 'deploy-1', - toolName: 'deploy_api', + toolName: 'deploy_as_api', args: { versionName: 'v2' }, status: MothershipStreamV1AsyncToolRecordStatus.pending, }) @@ -330,14 +330,14 @@ describe('sse-handlers tool lifecycle', () => { context.runId = 'run-1' context.toolPermissions = { enabled: true, - autoAllowed: new Set(['deploy_api']), + autoAllowed: new Set(['deploy_as_api']), } const event = { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'deploy-2', - toolName: 'deploy_api', + toolName: 'deploy_as_api', arguments: {}, executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -562,7 +562,7 @@ describe('sse-handlers tool lifecycle', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'tool-function', - toolName: FunctionExecute.id, + toolName: RunFunction.id, arguments: { code: 'return {{SECRET}}' }, executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -1097,7 +1097,7 @@ describe('sse-handlers tool lifecycle', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'function-finalized-args', - toolName: FunctionExecute.id, + toolName: RunFunction.id, arguments: { language: 'javascript', code: 'return {{STALE_SECRET}}' }, executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -1115,7 +1115,7 @@ describe('sse-handlers tool lifecycle', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'function-finalized-args', - toolName: FunctionExecute.id, + toolName: RunFunction.id, arguments: { language: 'javascript', code: 'return 1' }, executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, @@ -1130,7 +1130,7 @@ describe('sse-handlers tool lifecycle', () => { await sleep(0) expect(executeTool).toHaveBeenCalledWith( - FunctionExecute.id, + RunFunction.id, { language: 'javascript', code: 'return 1' }, expect.any(Object) ) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index c7e340b0133..54bf70d7800 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -501,8 +501,8 @@ async function handleCallPhase( if (!toolCall) return // Capture the invoking subagent's channel id so the executor can thread it - // into the server tool context — this is what scopes the workspace_file -> - // edit_content intent handoff to one file subagent under concurrency. + // into the server tool context — this is what scopes the prepare_file_edit -> + // apply_file_edit intent handoff to one file subagent under concurrency. if (parentToolCallId) toolCall.parentToolCallId = parentToolCallId const readPath = typeof args?.path === 'string' ? args.path : undefined diff --git a/apps/sim/lib/copilot/request/session/contract.test.ts b/apps/sim/lib/copilot/request/session/contract.test.ts index 06661e275da..86dcbc17fb4 100644 --- a/apps/sim/lib/copilot/request/session/contract.test.ts +++ b/apps/sim/lib/copilot/request/session/contract.test.ts @@ -156,7 +156,7 @@ describe('stream session contract parser', () => { type: 'tool' as const, payload: { toolCallId: 'preview-1', - toolName: 'workspace_file' as const, + toolName: 'prepare_file_edit' as const, previewPhase: 'file_preview_content' as const, content: 'draft body', contentMode: 'snapshot' as const, diff --git a/apps/sim/lib/copilot/request/session/contract.ts b/apps/sim/lib/copilot/request/session/contract.ts index dde683b966c..e7b6eb99206 100644 --- a/apps/sim/lib/copilot/request/session/contract.ts +++ b/apps/sim/lib/copilot/request/session/contract.ts @@ -47,7 +47,7 @@ export interface SyntheticFilePreviewTarget { export interface SyntheticFilePreviewStartPayload { previewPhase: typeof FILE_PREVIEW_PHASE.start toolCallId: string - toolName: 'workspace_file' + toolName: 'prepare_file_edit' } export interface SyntheticFilePreviewTargetPayload { @@ -56,14 +56,14 @@ export interface SyntheticFilePreviewTargetPayload { target: SyntheticFilePreviewTarget title?: string toolCallId: string - toolName: 'workspace_file' + toolName: 'prepare_file_edit' } export interface SyntheticFilePreviewEditMetaPayload { edit: JsonRecord previewPhase: typeof FILE_PREVIEW_PHASE.editMeta toolCallId: string - toolName: 'workspace_file' + toolName: 'prepare_file_edit' } export interface SyntheticFilePreviewContentPayload { @@ -77,7 +77,7 @@ export interface SyntheticFilePreviewContentPayload { previewVersion: number targetKind?: string toolCallId: string - toolName: 'workspace_file' + toolName: 'prepare_file_edit' } export interface SyntheticFilePreviewCompletePayload { @@ -86,7 +86,7 @@ export interface SyntheticFilePreviewCompletePayload { previewPhase: typeof FILE_PREVIEW_PHASE.complete previewVersion?: number toolCallId: string - toolName: 'workspace_file' + toolName: 'prepare_file_edit' } export type SyntheticFilePreviewPayload = @@ -360,7 +360,7 @@ function isSyntheticFilePreviewPayload(value: unknown): value is SyntheticFilePr return false } - if (typeof value.toolCallId !== 'string' || value.toolName !== 'workspace_file') { + if (typeof value.toolCallId !== 'string' || value.toolName !== 'prepare_file_edit') { return false } diff --git a/apps/sim/lib/copilot/request/session/event.test.ts b/apps/sim/lib/copilot/request/session/event.test.ts index 0f0573a24f8..29d86146c1a 100644 --- a/apps/sim/lib/copilot/request/session/event.test.ts +++ b/apps/sim/lib/copilot/request/session/event.test.ts @@ -43,7 +43,7 @@ describe('createEvent', () => { payload: { previewPhase: 'file_preview_start', toolCallId: 'preview-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', }, }) @@ -56,7 +56,7 @@ describe('createEvent', () => { payload: { previewPhase: 'file_preview_start', toolCallId: 'preview-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', }, }) }) diff --git a/apps/sim/lib/copilot/request/session/writer.test.ts b/apps/sim/lib/copilot/request/session/writer.test.ts index aa3eb5384ea..8ff64276df1 100644 --- a/apps/sim/lib/copilot/request/session/writer.test.ts +++ b/apps/sim/lib/copilot/request/session/writer.test.ts @@ -168,7 +168,7 @@ describe('StreamWriter', () => { type: MothershipStreamV1EventType.tool, payload: { toolCallId: 'preview-1', - toolName: 'workspace_file', + toolName: 'prepare_file_edit', previewPhase: 'file_preview_start', }, } satisfies StreamEvent) diff --git a/apps/sim/lib/copilot/request/sse-utils.test.ts b/apps/sim/lib/copilot/request/sse-utils.test.ts index 65b5b4319c4..d8da8edc0c2 100644 --- a/apps/sim/lib/copilot/request/sse-utils.test.ts +++ b/apps/sim/lib/copilot/request/sse-utils.test.ts @@ -36,7 +36,7 @@ describe('shouldSkipToolCallEvent', () => { it('keeps non-vfs generating placeholders visible', () => { expect( shouldSkipToolCallEvent( - toolCallEvent('search-generating-placeholder', 'search_online', undefined, true) + toolCallEvent('search-generating-placeholder', 'web_search', undefined, true) ) ).toBe(false) }) diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index b40964ab425..4bc032fae61 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -115,7 +115,7 @@ describe('toolWatchdogTimeoutMs', () => { expect(toolWatchdogTimeoutMs('read')).toBe(TOOL_WATCHDOG_DEFAULT_MS) }) - it.each(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy', 'promote_to_live'])( + it.each(['deploy_as_api', 'deploy_as_chat', 'deploy_as_mcp', 'redeploy', 'promote_to_live'])( 'does not undercut deployment tool %s with the default watchdog', (toolName) => { expect(toolWatchdogTimeoutMs(toolName)).toBe(TOOL_WATCHDOG_LONG_RUNNING_MS) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 01845830153..f4712a6fffe 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -20,36 +20,36 @@ import { MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' import { + ApplyFileEdit, BrowserRequestTakeover, - CrawlWebsite, - CreateFile, + CreateEmptyFile, CreateWorkflow, - DeployApi, - DeployChat, - DeployCustomBlock, - DeployMcp, - DownloadToWorkspaceFile, - EditContent, + DeployAsApi, + DeployAsChat, + DeployAsMcp, + DownloadFile, Ffmpeg, - FunctionExecute, GenerateApiKey, GenerateAudio, GenerateImage, GenerateVideo, - KnowledgeBase, LoadDeployment, - MaterializeFile, + ManageKnowledgeBase, Media, + PrepareFileEdit, PromoteToLive, + PublishCustomBlock, Redeploy, Run, RunBlock, RunCode, RunFromBlock, + RunFunction, RunWorkflow, RunWorkflowUntilBlock, + SaveUpload, Search, - WorkspaceFile, + WebCrawl, } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' @@ -217,7 +217,7 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ RunFromBlock.id, RunWorkflow.id, RunWorkflowUntilBlock.id, - FunctionExecute.id, + RunFunction.id, RunCode.id, GenerateImage.id, GenerateAudio.id, @@ -225,17 +225,17 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ Ffmpeg.id, Media.id, Search.id, - CrawlWebsite.id, - KnowledgeBase.id, - DownloadToWorkspaceFile.id, - CreateFile.id, - EditContent.id, - MaterializeFile.id, - WorkspaceFile.id, - DeployApi.id, - DeployChat.id, - DeployCustomBlock.id, - DeployMcp.id, + WebCrawl.id, + ManageKnowledgeBase.id, + DownloadFile.id, + CreateEmptyFile.id, + ApplyFileEdit.id, + SaveUpload.id, + PrepareFileEdit.id, + DeployAsApi.id, + DeployAsChat.id, + PublishCustomBlock.id, + DeployAsMcp.id, Redeploy.id, LoadDeployment.id, PromoteToLive.id, diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index 19c04e383e7..7a6b94dbe81 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/copilot/request/otel', () => ({ ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }), })) -import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1' +import { RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' import { extractTabularData, maybeWriteOutputToFile, @@ -37,7 +37,7 @@ import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limit import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' describe('unwrapFunctionExecuteOutput', () => { - it('unwraps the function_execute envelope { result, stdout }', () => { + it('unwraps the run_function envelope { result, stdout }', () => { expect(unwrapFunctionExecuteOutput({ result: 'name,age\nAlice,30', stdout: '' })).toBe( 'name,age\nAlice,30' ) @@ -56,7 +56,7 @@ describe('unwrapFunctionExecuteOutput', () => { }) describe('serializeOutputForFile (csv)', () => { - it('returns raw CSV text when function_execute result is already a CSV string', () => { + it('returns raw CSV text when run_function result is already a CSV string', () => { const output = { result: 'name,age\nAlice,30\nBob,40', stdout: '(2 rows)', @@ -139,7 +139,7 @@ describe('maybeWriteOutputToFile', () => { it('denies a read-only principal without writing the file', async () => { const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, { success: true, output: { result: 'name,age\nAlice,30', stdout: '' } }, buildContext({ userPermission: 'read' }) @@ -152,7 +152,7 @@ describe('maybeWriteOutputToFile', () => { it('does not deny a read-only principal when no workspace write occurs (sandbox export active)', async () => { const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, { success: true, output: { result: { files: [{ path: 'report.csv' }] }, stdout: '' } }, buildContext({ userPermission: 'read' }) @@ -164,7 +164,7 @@ describe('maybeWriteOutputToFile', () => { it('writes the output file for a write principal', async () => { const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, { success: true, output: { result: 'name,age\nAlice,30', stdout: '' } }, buildContext() @@ -201,7 +201,7 @@ describe('maybeWriteOutputToFile', () => { })) const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } }, { success: true, output: { result: rows, stdout: '' } }, buildContext({ resolvedSecretTraceRegistry: registry }) @@ -239,7 +239,7 @@ describe('maybeWriteOutputToFile', () => { registry.recordResolved('TOKEN', 'secret-value') const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, { success: true, @@ -280,7 +280,7 @@ describe('maybeWriteOutputToFile', () => { } const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } }, { success: true, output: runtimeOutput }, buildContext({ resolvedSecretTraceRegistry: toolRegistry }) @@ -317,7 +317,7 @@ describe('maybeWriteOutputToFile', () => { registry.recordResolved('CSV_SECRET', secret) const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, { success: true, output: { result: [{ value: secret }], stdout: '' } }, buildContext({ resolvedSecretTraceRegistry: registry }) @@ -356,7 +356,7 @@ describe('maybeWriteOutputToFile', () => { const rows = Array.from({ length: 10_000 }, () => ({ first: secret, second: secret })) const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, { success: true, output: { result: rows, stdout: '' } }, buildContext({ resolvedSecretTraceRegistry: registry }) @@ -377,7 +377,7 @@ describe('maybeWriteOutputToFile', () => { registry.recordResolved('CSV_SECRET', secret) const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [ @@ -402,7 +402,7 @@ describe('maybeWriteOutputToFile', () => { })) const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files } }, { success: true, output: { result: 'content', stdout: '' } }, buildContext() @@ -420,7 +420,7 @@ describe('maybeWriteOutputToFile', () => { registry.recordResolved('API_KEY', 'secret-value') const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, { success: true, output: { result: '__var_API_KEY', stdout: '' } }, buildContext({ resolvedSecretTraceRegistry: registry }) @@ -457,7 +457,7 @@ describe('maybeWriteOutputToFile', () => { } as unknown as ResolvedSecretTraceRegistry const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, { success: true, output: { result: 'anonymous-secret', stdout: '' } }, buildContext({ resolvedSecretTraceRegistry: registry }) @@ -495,7 +495,7 @@ describe('maybeWriteOutputToFile', () => { registry.recordResolved('OUTPUT_SECRET', 'secret-value') const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, { success: true, output: { result: 'secret-value', stdout: '' } }, buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry }) @@ -534,7 +534,7 @@ describe('maybeWriteOutputToFile', () => { registry.recordResolved('OUTPUT_SECRET', 'secret-value') const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } }, { success: true, output: { result: 'secret-value', stdout: '' } }, buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry }) @@ -557,7 +557,7 @@ describe('maybeWriteOutputToFile', () => { } as unknown as ResolvedSecretTraceRegistry const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [ @@ -580,7 +580,7 @@ describe('maybeWriteOutputToFile', () => { it('preserves legacy writes without a registry and marks their provenance unknown', async () => { const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } }, { success: true, output: { result: { token: 'unknown' }, stdout: '' } }, buildContext({ resolvedSecretTraceRegistry: undefined }) @@ -595,7 +595,7 @@ describe('maybeWriteOutputToFile', () => { it('fails loudly instead of silently skipping declared outputs when workspace context is missing', async () => { const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, { success: true, output: { result: 'name,age\nAlice,30', stdout: '' } }, buildContext({ workspaceId: undefined }) @@ -611,7 +611,7 @@ describe('maybeWriteOutputToFile', () => { it('still passes results through untouched when no outputs are declared, even without workspace context', async () => { const original = { success: true, output: { result: 42, stdout: '' } } const result = await maybeWriteOutputToFile( - FunctionExecute.id, + RunFunction.id, {}, original, buildContext({ workspaceId: undefined }) @@ -627,7 +627,7 @@ describe('extractTabularData', () => { expect(extractTabularData([{ a: 1 }, { a: 2 }])).toEqual([{ a: 1 }, { a: 2 }]) }) - it('does NOT unwrap function_execute envelopes on its own (callers must pre-unwrap)', () => { + it('does NOT unwrap run_function envelopes on its own (callers must pre-unwrap)', () => { // Caller is responsible for unwrapping { result, stdout } envelopes first. // Keeping that concern out of this function prevents a double unwrap when // the user's payload itself happens to have matching keys. diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 035ca0b85d0..22f1a641f9d 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import { RunFunction, UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' @@ -26,7 +26,7 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr const logger = createLogger('CopilotToolResultFiles') const MAX_OUTPUT_FILE_PROVENANCE_REPRESENTATIONS = 10_000 -export const OUTPUT_PATH_TOOLS: Set = new Set([FunctionExecute.id, UserTable.id]) +export const OUTPUT_PATH_TOOLS: Set = new Set([RunFunction.id, UserTable.id]) export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html' @@ -47,12 +47,12 @@ export const FORMAT_TO_CONTENT_TYPE: Record = { } /** - * Unwraps the `function_execute` response envelope `{ result, stdout }` so the + * Unwraps the `run_function` response envelope `{ result, stdout }` so the * rest of the serialization code works on the user's actual payload (a string, * array, object, etc.) instead of JSON-stringifying the envelope itself. * * Only unwraps when both keys are present — that's the unique shape of - * `function_execute` (see `apps/sim/tools/function/types.ts` `CodeExecutionOutput`). + * `run_function` (see `apps/sim/tools/function/types.ts` `CodeExecutionOutput`). * `user_table` returns `{ data, message, success }` which is left alone. */ export function unwrapFunctionExecuteOutput(output: unknown): unknown { @@ -66,7 +66,7 @@ export function unwrapFunctionExecuteOutput(output: unknown): unknown { /** * Try to pull a flat array of row-objects out of an already-unwrapped tool - * payload. Callers are responsible for stripping any `function_execute` + * payload. Callers are responsible for stripping any `run_function` * envelope first (via {@link unwrapFunctionExecuteOutput}) — this function * does not re-unwrap, so a user payload that coincidentally has `result` and * `stdout` keys is not mistaken for another envelope. diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 5c75b645730..58a6e6c8113 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -94,7 +94,7 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) - it.each(['deploy_api', 'deploy_chat', 'deploy_mcp'])( + it.each(['deploy_as_api', 'deploy_as_chat', 'deploy_as_mcp'])( 'honors the saved permission for a %s undeploy', (toolName) => { const context = makeContext() @@ -108,10 +108,10 @@ describe('toolCallNeedsApproval', () => { it('applies the normal saved permission to code with a secret reference', () => { const context = makeContext() - context.toolPermissions.autoAllowed.add('function_execute') + context.toolPermissions.autoAllowed.add('run_function') expect( - toolCallNeedsApproval('function_execute', context, {}, false, { + toolCallNeedsApproval('run_function', context, {}, false, { language: 'javascript', code: 'return {{API_KEY}}', }) @@ -135,7 +135,7 @@ describe('toolCallNeedsApproval', () => { context.toolPermissions.enabled = false expect( - toolCallNeedsApproval('function_execute', context, {}, false, { + toolCallNeedsApproval('run_function', context, {}, false, { language: 'javascript', code: 'return {{API_KEY}}', }) @@ -214,13 +214,13 @@ describe('gated tools are askable', () => { ).toEqual([ 'call_integration_tool', 'delete_workspace_mcp_server', - 'deploy_api', - 'deploy_chat', - 'deploy_mcp', - 'function_execute', + 'deploy_as_api', + 'deploy_as_chat', + 'deploy_as_mcp', 'promote_to_live', 'redeploy', 'run_code', + 'run_function', 'run_workflow', 'run_workflow_until_block', 'terminal', @@ -323,7 +323,7 @@ describe('runGatedToolExecution', () => { it('accepts the normal chat-level decision for code with a secret reference', async () => { const context = makeContext() const toolCall = makeToolCall() - toolCall.name = 'function_execute' + toolCall.name = 'run_function' toolCall.params = { language: 'javascript', code: 'return {{API_KEY}}' } const execute = vi.fn().mockResolvedValue({ status: 'success' }) waitForToolPermissionDecision.mockResolvedValue({ @@ -334,7 +334,7 @@ describe('runGatedToolExecution', () => { await gate(context, toolCall, execute, []) expect(execute).toHaveBeenCalledTimes(1) - expect(context.toolPermissions.autoAllowed.has('function_execute')).toBe(true) + expect(context.toolPermissions.autoAllowed.has('run_function')).toBe(true) }) it('does not suppress later prompts for a one-off allow', async () => { diff --git a/apps/sim/lib/copilot/request/tools/permissions.ts b/apps/sim/lib/copilot/request/tools/permissions.ts index 2d12c12ab13..fcd1e8d9341 100644 --- a/apps/sim/lib/copilot/request/tools/permissions.ts +++ b/apps/sim/lib/copilot/request/tools/permissions.ts @@ -4,7 +4,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ /** * Guards a post-tool output-redirection sink against read-only principals. * - * `function_execute`, `user_table`, and `read` are read-allowed for execution + * `run_function`, `user_table`, and `read` are read-allowed for execution * (they don't mutate the workspace themselves), so the router's `WRITE_ACTIONS` * gate in `tools/server/router.ts` lets read-only collaborators run them. But * their output-redirection declarations (`outputs.files`, `outputTable`) @@ -12,7 +12,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ * Those writes must satisfy the same write gate as the dedicated mutation tools. * * Returns a denial `ToolCallResult` when the caller lacks write access (so the - * agent surfaces the same `Permission denied` outcome it gets from `create_file` + * agent surfaces the same `Permission denied` outcome it gets from `create_empty_file` * / `user_table` writes), or `null` when the write may proceed. */ export function denyOutputWriteWithoutWritePermission( diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index 26bff98ec4c..536ac1adf87 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { FunctionExecute, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import { RunCode, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolResultForCopilot, TOOL_RESULT_UNAVAILABLE_ERROR, @@ -20,7 +20,7 @@ function createRegistry(): ResolvedSecretTraceRegistry { } describe('projectToolResultForCopilot', () => { - it.each([FunctionExecute.id, RunCode.id])( + it.each([RunFunction.id, RunCode.id])( 'projects active exact and embedded secrets for %s without mutating runtime output', (toolName) => { const registry = createRegistry() diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 57347739a4c..ba68f10f7c2 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -30,7 +30,7 @@ vi.mock('@/lib/table/application/rows', () => ({ ProjectedWireRowsValidationError: class ProjectedWireRowsValidationError extends Error {}, })) -import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { maybeWriteOutputToTable, @@ -94,7 +94,7 @@ describe('automatic Copilot tool-output table persistence', () => { ] const result = await maybeWriteOutputToTable( - FunctionExecute.id, + RunFunction.id, { outputTable: 'table-1' }, { success: true, output: { result: rows } }, context @@ -129,7 +129,7 @@ describe('automatic Copilot tool-output table persistence', () => { const runtimeRows = [{ name: 'secret-value', status: 'literal' }] const result = await maybeWriteOutputToTable( - FunctionExecute.id, + RunFunction.id, { outputTable: 'table-1' }, { success: true, output: { result: runtimeRows } }, buildContext({ resolvedSecretTraceRegistry: registry }) @@ -159,7 +159,7 @@ describe('automatic Copilot tool-output table persistence', () => { registry.markIncomplete('unspecified') await maybeWriteOutputToTable( - FunctionExecute.id, + RunFunction.id, { outputTable: 'table-1' }, { success: true, output: { result: [{ name: 'unknown' }] } }, buildContext({ resolvedSecretTraceRegistry: registry }) @@ -179,7 +179,7 @@ describe('automatic Copilot tool-output table persistence', () => { ) const result = await maybeWriteOutputToTable( - FunctionExecute.id, + RunFunction.id, { outputTable: 'table-1' }, { success: true, output: { result: [{ wrong: true }] } }, buildContext() @@ -199,7 +199,7 @@ describe('automatic Copilot tool-output table persistence', () => { mocks.executeReplace.mockRejectedValueOnce(new Error('database duplicate: secret-value')) const result = await maybeWriteOutputToTable( - FunctionExecute.id, + RunFunction.id, { outputTable: 'table-1' }, { success: true, output: { result: [{ name: 'secret-value' }] } }, buildContext({ resolvedSecretTraceRegistry: registry }) @@ -217,7 +217,7 @@ describe('automatic Copilot tool-output table persistence', () => { it('rejects read-only Copilot execution before any application command', async () => { const result = await maybeWriteOutputToTable( - FunctionExecute.id, + RunFunction.id, { outputTable: 'table-1' }, { success: true, output: { result: [{ name: 'Ada' }] } }, buildContext({ userPermission: 'read' }) @@ -232,7 +232,7 @@ describe('automatic Copilot tool-output table persistence', () => { mocks.executeReplace.mockResolvedValueOnce({ table, deletedCount: 1, insertedCount: 1 }) const result = await maybeWriteOutputToTable( - FunctionExecute.id, + RunFunction.id, { outputTable: 'table-1' }, { success: true, output: { result: [{ name: 'Ada' }, { name: 'Grace' }] } }, buildContext() diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 46d4d598e60..36eecdc2209 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { parse as csvParse } from 'csv-parse/sync' import { executeCopilotReplaceProjectedWireRows } from '@/lib/copilot/application/table-commands' import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' -import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' @@ -68,7 +68,7 @@ export async function maybeWriteOutputToTable( result: ToolCallResult, context: ExecutionContext ): Promise { - if (toolName !== FunctionExecute.id) return result + if (toolName !== RunFunction.id) return result if (!result.success || !result.output) return result const outputTable = params?.outputTable as string | undefined if (!outputTable) return result diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index ed76e5cd505..580b17238ff 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -41,7 +41,7 @@ export interface ToolCallState { * For a subagent-scoped tool call, the invoking subagent's channel id (its * outer tool_use id, = event.scope.parentToolCallId). Captured at dispatch so * the executor can thread it into the server tool context and scope the - * workspace_file -> edit_content intent handoff per file subagent. Undefined + * prepare_file_edit -> apply_file_edit intent handoff per file subagent. Undefined * for main-lane tool calls. */ parentToolCallId?: string diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index 65e8d40bc61..c47413711f2 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -5,9 +5,9 @@ import { describe, expect, it } from 'vitest' import { extractDeletedResourcesFromToolResult, extractResourcesFromToolResult } from './extraction' describe('extractResourcesFromToolResult', () => { - it('extracts file resources from create_file results', () => { + it('extracts file resources from create_empty_file results', () => { const resources = extractResourcesFromToolResult( - 'create_file', + 'create_empty_file', { fileName: 'notes.md', }, @@ -31,9 +31,9 @@ describe('extractResourcesFromToolResult', () => { ]) }) - it('uses the knowledge base id for knowledge_base tag mutations', () => { + it('uses the knowledge base id for manage_knowledge_base tag mutations', () => { const resources = extractResourcesFromToolResult( - 'knowledge_base', + 'manage_knowledge_base', { operation: 'update_tag', args: { @@ -63,7 +63,7 @@ describe('extractResourcesFromToolResult', () => { it('uses knowledgeBaseId from the tool result when update_tag args omit it', () => { const resources = extractResourcesFromToolResult( - 'knowledge_base', + 'manage_knowledge_base', { operation: 'update_tag', args: { @@ -93,7 +93,7 @@ describe('extractResourcesFromToolResult', () => { it('does not create resources for read-only knowledge base tag operations', () => { const resources = extractResourcesFromToolResult( - 'knowledge_base', + 'manage_knowledge_base', { operation: 'list_tags', args: { @@ -156,7 +156,7 @@ describe('extractDeletedResourcesFromToolResult', () => { { from: 'workflows/Lead%20Router', kind: 'workflow', id: 'wf-1' }, { from: 'workflows/Old%20Projects', kind: 'workflow_folder', id: 'wfolder-1' }, { from: 'tables/Leads', kind: 'table', id: 'tbl-1' }, - { from: 'knowledgebases/support-docs', kind: 'knowledge_base', id: 'kb-1' }, + { from: 'knowledgebases/support-docs', kind: 'manage_knowledge_base', id: 'kb-1' }, { from: 'files/missing.md', kind: 'file', error: 'Not found: files/missing.md' }, ], } @@ -181,10 +181,10 @@ describe('extractDeletedResourcesFromToolResult', () => { ).toEqual([{ type: 'table', id: 'table-1', title: 'Table' }]) }) - it('extracts deleted knowledge bases from knowledge_base result data', () => { + it('extracts deleted knowledge bases from manage_knowledge_base result data', () => { expect( extractDeletedResourcesFromToolResult( - 'knowledge_base', + 'manage_knowledge_base', { operation: 'delete', args: { knowledgeBaseIds: ['kb-1'] } }, { success: true, diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 2a614d944b6..fd1782a59f6 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,18 +1,18 @@ import { - CreateFile, + CreateEmptyFile, CreateWorkflow, - DownloadToWorkspaceFile, + DownloadFile, EditWorkflow, Ffmpeg, - FunctionExecute, GenerateAudio, GenerateImage, GenerateVideo, Knowledge, - KnowledgeBase, + ManageKnowledgeBase, + PrepareFileEdit, Rm, + RunFunction, UserTable, - WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import type { MothershipResource, MothershipResourceType } from './types' @@ -21,13 +21,13 @@ type ResourceType = MothershipResourceType const RESOURCE_TOOL_NAMES: Set = new Set([ UserTable.id, - CreateFile.id, - WorkspaceFile.id, - DownloadToWorkspaceFile.id, + CreateEmptyFile.id, + PrepareFileEdit.id, + DownloadFile.id, CreateWorkflow.id, EditWorkflow.id, - FunctionExecute.id, - KnowledgeBase.id, + RunFunction.id, + ManageKnowledgeBase.id, Knowledge.id, GenerateImage.id, GenerateVideo.id, @@ -110,8 +110,8 @@ export function extractResourcesFromToolResult( return [] } - case CreateFile.id: - case WorkspaceFile.id: { + case CreateEmptyFile.id: + case PrepareFileEdit.id: { const file = asRecord(data.file) if (file.id) { return [{ type: 'file', id: file.id as string, title: (file.name as string) || 'File' }] @@ -124,7 +124,7 @@ export function extractResourcesFromToolResult( return [] } - case FunctionExecute.id: { + case RunFunction.id: { if (result.tableId) { return [ { @@ -146,7 +146,7 @@ export function extractResourcesFromToolResult( return [] } - case DownloadToWorkspaceFile.id: + case DownloadFile.id: case GenerateImage.id: case GenerateVideo.id: case GenerateAudio.id: @@ -181,7 +181,7 @@ export function extractResourcesFromToolResult( return [] } - case KnowledgeBase.id: { + case ManageKnowledgeBase.id: { if (READ_ONLY_KB_OPS.has(getOperation(params) ?? '')) return [] const args = asRecord(params?.args) @@ -225,9 +225,9 @@ export function extractResourcesFromToolResult( } const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { - [WorkspaceFile.id]: 'file', + [PrepareFileEdit.id]: 'file', [UserTable.id]: 'table', - [KnowledgeBase.id]: 'knowledgebase', + [ManageKnowledgeBase.id]: 'knowledgebase', // rm spans categories, so unlike every other entry its resource type comes // from each outcome's kind rather than from this map. The entry exists so // hasDeleteCapability(rm) holds; the rm case below ignores this value. @@ -241,7 +241,7 @@ const RM_KIND_RESOURCE_TYPE: Record = { workflow: 'workflow', workflow_folder: 'folder', table: 'table', - knowledge_base: 'knowledgebase', + manage_knowledge_base: 'knowledgebase', } export function hasDeleteCapability(toolName: string): boolean { @@ -281,7 +281,7 @@ export function extractDeletedResourcesFromToolResult( return [{ type, id, title: leaf ? decodeURIComponent(leaf) : 'Deleted resource' }] }) } - case WorkspaceFile.id: { + case PrepareFileEdit.id: { if (operation !== 'delete') return [] const target = getWorkspaceFileTarget(params) const fileId = (data.id as string) ?? (target.fileId as string) ?? (args.fileId as string) @@ -306,7 +306,7 @@ export function extractDeletedResourcesFromToolResult( return [] } - case KnowledgeBase.id: { + case ManageKnowledgeBase.id: { if (operation !== 'delete') return [] const deleted = Array.isArray(data.deleted) ? data.deleted : [] const resources = deleted.flatMap((entry): ChatResource[] => { diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 7edc630d199..bc25956d3dc 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -20,6 +20,8 @@ export interface MothershipResource { id: string title: string path?: string + /** Saved table view to open pinned (type "table" only). */ + viewId?: string } /** diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 11672c1e8ed..276585eeb78 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -48,25 +48,23 @@ describe('copilot tool executor fallback', () => { isSimExecuted.mockReturnValue(true) isClientExecuted.mockReturnValue(false) const handler = vi.fn().mockResolvedValue({ success: true }) - registerHandler('function_execute', handler) + registerHandler('run_function', handler) await expect( - executeTool('function_execute', { code: 'return 1' }, { userId: 'user-1', workflowId: '' }) + executeTool('run_function', { code: 'return 1' }, { userId: 'user-1', workflowId: '' }) ).resolves.toEqual({ success: false, - error: - "Permission denied: function_execute requires write access. You have 'none' permission.", + error: "Permission denied: run_function requires write access. You have 'none' permission.", }) await expect( executeTool( - 'function_execute', + 'run_function', { code: 'return 1' }, { userId: 'user-1', workflowId: '', userPermission: 'read' } ) ).resolves.toEqual({ success: false, - error: - "Permission denied: function_execute requires write access. You have 'read' permission.", + error: "Permission denied: run_function requires write access. You have 'read' permission.", }) expect(handler).not.toHaveBeenCalled() }) @@ -77,11 +75,11 @@ describe('copilot tool executor fallback', () => { isSimExecuted.mockReturnValue(true) isClientExecuted.mockReturnValue(false) const handler = vi.fn().mockResolvedValue({ success: true, output: 'ok' }) - registerHandler('function_execute', handler) + registerHandler('run_function', handler) await expect( executeTool( - 'function_execute', + 'run_function', { code: 'return 1' }, { userId: 'user-1', workflowId: '', userPermission: 'write' } ) @@ -263,13 +261,13 @@ describe('copilot tool executor fallback', () => { expect(executeAppTool).toHaveBeenCalledWith('unknown_client_tool', expect.any(Object)) }) - it('converts function_execute timeout from seconds to milliseconds for copilot calls', async () => { + it('converts run_function timeout from seconds to milliseconds for copilot calls', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) await executeTool( - 'function_execute', + 'run_function', { code: 'return 1', timeout: 7 }, { userId: 'user-1', @@ -280,7 +278,7 @@ describe('copilot tool executor fallback', () => { ) expect(executeAppTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ timeout: 7000, _context: expect.objectContaining({ @@ -296,12 +294,12 @@ describe('copilot tool executor fallback', () => { ) }) - it('converts function_execute timeout before invoking its registered Sim handler', async () => { + it('converts run_function timeout before invoking its registered Sim handler', async () => { isKnownTool.mockReturnValue(true) isSimExecuted.mockReturnValue(true) isClientExecuted.mockReturnValue(false) const handler = vi.fn().mockResolvedValue({ success: true, output: { result: 'ok' } }) - registerHandler('function_execute', handler) + registerHandler('run_function', handler) const context = { userId: 'user-1', @@ -309,7 +307,7 @@ describe('copilot tool executor fallback', () => { workspaceId: 'ws-1', copilotToolExecution: true, } - await executeTool('function_execute', { code: 'return 1', timeout: 7 }, context) + await executeTool('run_function', { code: 'return 1', timeout: 7 }, context) expect(handler).toHaveBeenCalledWith( expect.objectContaining({ @@ -321,13 +319,13 @@ describe('copilot tool executor fallback', () => { expect(executeAppTool).not.toHaveBeenCalled() }) - it('defaults copilot function_execute timeout to 10 seconds when omitted', async () => { + it('defaults copilot run_function timeout to 10 seconds when omitted', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) await executeTool( - 'function_execute', + 'run_function', { code: 'return 1' }, { userId: 'user-1', @@ -338,7 +336,7 @@ describe('copilot tool executor fallback', () => { ) expect(executeAppTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ timeout: 10_000, }), @@ -351,13 +349,13 @@ describe('copilot tool executor fallback', () => { ) }) - it('defaults copilot function_execute timeout to 10 seconds when invalid', async () => { + it('defaults copilot run_function timeout to 10 seconds when invalid', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) await executeTool( - 'function_execute', + 'run_function', { code: 'return 1', timeout: 0 }, { userId: 'user-1', @@ -368,7 +366,7 @@ describe('copilot tool executor fallback', () => { ) expect(executeAppTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ timeout: 10_000, }), @@ -381,13 +379,13 @@ describe('copilot tool executor fallback', () => { ) }) - it('does not let copilot function_execute timeout exceed the default execution limit', async () => { + it('does not let copilot run_function timeout exceed the default execution limit', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) await executeTool( - 'function_execute', + 'run_function', { code: 'return 1', timeout: 10_000 }, { userId: 'user-1', @@ -398,7 +396,7 @@ describe('copilot tool executor fallback', () => { ) expect(executeAppTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ timeout: DEFAULT_EXECUTION_TIMEOUT_MS, }), diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 6184d682204..969e4275355 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -13,7 +13,7 @@ import type { } from './types' const logger = createLogger('ToolExecutor') -const FUNCTION_EXECUTE_TOOL_ID = 'function_execute' +const FUNCTION_EXECUTE_TOOL_ID = 'run_function' const DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS = 10 const MILLISECONDS_PER_SECOND = 1000 diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index f2f9eb6d304..61276eb7527 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -1,42 +1,40 @@ import { createLogger } from '@sim/logger' import { - CheckDeploymentStatus, Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, DeleteWorkspaceMcpServer, - DeployApi, - DeployChat, - DeployCustomBlock, - DeployMcp, + DeployAsApi, + DeployAsChat, + DeployAsMcp, DiffWorkflows, - FunctionExecute, GenerateApiKey, GetBlockOutputs, GetBlockUpstreamReferences, GetDeployedWorkflowState, - GetDeploymentLog, - GetPlatformActions, + GetDeploymentStatus, + GetUiReference, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, Grep as GrepTool, + ListDeploymentVersions, ListIntegrationTools, ListUserWorkspaces, ListWorkspaceMcpServers, LoadDeployment, ManageCredential, ManageCustomTool, - ManageMcpTool, + ManageMcpConnection, ManageSandbox, ManageSkill, - MaterializeFile, Mkdir as MkdirTool, Mv as MvTool, OauthGetAuthLink, OauthRequestAccess, OpenResource, PromoteToLive, + PublishCustomBlock, Read as ReadTool, Redeploy, RestoreResource, @@ -44,8 +42,10 @@ import { RunBlock, RunCode, RunFromBlock, + RunFunction, RunWorkflow, RunWorkflowUntilBlock, + SaveUpload, SetBlockEnabled, SetGlobalWorkflowVariables, UpdateDeploymentVersion, @@ -156,17 +156,17 @@ function buildHandlerMap(): Record { [GenerateApiKey.id]: h(executeGenerateApiKey), [SetGlobalWorkflowVariables.id]: h(executeSetGlobalWorkflowVariables), - [DeployApi.id]: h(executeDeployApi), - [DeployChat.id]: h(executeDeployChat), - [DeployMcp.id]: h(executeDeployMcp), - [DeployCustomBlock.id]: h(executeDeployCustomBlock), + [DeployAsApi.id]: h(executeDeployApi), + [DeployAsChat.id]: h(executeDeployChat), + [DeployAsMcp.id]: h(executeDeployMcp), + [PublishCustomBlock.id]: h(executeDeployCustomBlock), [Redeploy.id]: h(executeRedeploy), - [CheckDeploymentStatus.id]: h(executeCheckDeploymentStatus), + [GetDeploymentStatus.id]: h(executeCheckDeploymentStatus), [ListWorkspaceMcpServers.id]: h(executeListWorkspaceMcpServers), [CreateWorkspaceMcpServer.id]: h(executeCreateWorkspaceMcpServer), [UpdateWorkspaceMcpServer.id]: h(executeUpdateWorkspaceMcpServer), [DeleteWorkspaceMcpServer.id]: h(executeDeleteWorkspaceMcpServer), - [GetDeploymentLog.id]: h(executeGetDeploymentLog), + [ListDeploymentVersions.id]: h(executeGetDeploymentLog), [DiffWorkflows.id]: h(executeDiffWorkflows), [LoadDeployment.id]: h(executeLoadDeployment), [PromoteToLive.id]: h(executePromoteToLive), @@ -181,7 +181,7 @@ function buildHandlerMap(): Record { [RmTool.id]: h(executeVfsRm), [ManageCustomTool.id]: h(executeManageCustomTool), - [ManageMcpTool.id]: h(executeManageMcpTool), + [ManageMcpConnection.id]: h(executeManageMcpTool), [ManageSandbox.id]: h(executeManageSandbox), [ManageSkill.id]: h(executeManageSkill), [ManageCredential.id]: h(executeManageCredential), @@ -192,10 +192,10 @@ function buildHandlerMap(): Record { [OauthRequestAccess.id]: h(executeOAuthRequestAccess), [OpenResource.id]: h(executeOpenResource), [RestoreResource.id]: h(executeRestoreResource), - [GetPlatformActions.id]: h(executeGetPlatformActions), + [GetUiReference.id]: h(executeGetPlatformActions), [ListIntegrationTools.id]: h(executeListIntegrationTools), - [MaterializeFile.id]: h(executeMaterializeFile), - [FunctionExecute.id]: h(executeFunctionExecute), + [SaveUpload.id]: h(executeMaterializeFile), + [RunFunction.id]: h(executeFunctionExecute), [RunCode.id]: h(executeRunCode), ...buildServerToolHandlers(), diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 7a849821895..788b42781e6 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -173,8 +173,8 @@ describe('resolveToolDisplay', () => { }) it('falls back to a humanized tool label for generic tools', () => { - expect(resolveToolDisplay('deploy_api', ClientToolCallState.success)?.text).toBe( - 'Executed Deploy API' + expect(resolveToolDisplay('deploy_as_api', ClientToolCallState.success)?.text).toBe( + 'Executed Deploy As API' ) expect(resolveToolDisplay('oauth-integrations', ClientToolCallState.success)?.text).toBe( 'Executed OAuth Integrations' diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts index 9a877e34169..178d403603b 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts @@ -33,7 +33,7 @@ describe('getCopilotDeploymentIdempotencyKey', () => { it('separates deployment intents within the same execution', () => { const context = { executionId: 'execution-1', toolCallId: 'call-1' } - expect(getCopilotDeploymentIdempotencyKey(context, 'deploy_api')).not.toBe( + expect(getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api')).not.toBe( getCopilotDeploymentIdempotencyKey(context, 'redeploy') ) }) 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 e37f623a234..1be8a3f514f 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 @@ -192,7 +192,7 @@ describe('executeDeployCustomBlock', () => { const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) expect(result.success).toBe(false) - expect(result.error).toContain('deploy_api') + expect(result.error).toContain('deploy_as_api') expect(publishCustomBlockMock).not.toHaveBeenCalled() }) 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 58f1ac5464b..c76fdf32b78 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -262,7 +262,7 @@ export async function executeDeployCustomBlock( return { success: false, error: - 'Workflow must be deployed before publishing as a custom block. Use deploy_api first.', + 'Workflow must be deployed before publishing as a custom block. Use deploy_as_api first.', } } // Curation is required on publish: every consumer-visible field must be one diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts index 01727a9c9d0..cd5b1ec5cd1 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -134,7 +134,7 @@ describe('deployment handlers', () => { expect.any(Object), expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }), expect.objectContaining({ - idempotencyKey: 'copilot:execution-1:operation:deploy_api', + idempotencyKey: 'copilot:execution-1:operation:deploy_as_api', }) ) }) @@ -170,7 +170,7 @@ describe('deployment handlers', () => { expect.any(Object), expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }), expect.objectContaining({ - idempotencyKey: 'copilot:execution-1:operation:deploy_api', + idempotencyKey: 'copilot:execution-1:operation:deploy_as_api', }) ) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 5fd15db45d7..d5a0260f2dc 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -211,7 +211,7 @@ export async function executeDeployApi( description: versionDescription, name: versionName, requestId: generateRequestId(), - idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_api'), + idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api'), }) if (!result.success) { return { success: false, error: result.error || 'Failed to deploy workflow' } @@ -377,7 +377,7 @@ export async function executeDeployChat( includeThinking: params.includeThinking, includeToolCalls: params.includeToolCalls, requestId: generateRequestId(), - idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_chat'), + idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_chat'), }) const baseUrl = getBaseUrl() @@ -591,7 +591,7 @@ export async function executeRedeploy( description: versionDescription, name: versionName, requestId: generateRequestId(), - idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_api'), + idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api'), }) if (!result.success) { return { success: false, error: result.error || 'Failed to redeploy workflow' } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 0ec758912f6..f8768765337 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -511,7 +511,8 @@ export async function executeUpdateDeploymentVersion( if (version === null) { return { success: false, - error: 'version must be a deployment version number (use get_deployment_log to find it)', + error: + 'version must be a deployment version number (use list_deployment_versions to find it)', } } 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 0717900344a..f85769f99d9 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -247,7 +247,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { ) expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ envVars: { API_KEY: 'secret-value' }, secretScope: 'selected', @@ -272,7 +272,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) @@ -295,7 +295,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { names: ['TOKEN'], }, ])( - 'uses the shared $language compiler analysis before delegating source to function_execute', + 'uses the shared $language compiler analysis before delegating source to run_function', async ({ language, code, names }) => { await executeFunctionExecute({ language, code }, context as never) @@ -305,14 +305,14 @@ describe('executeFunctionExecute trace-secret provenance', () => { requestedNames: names, }) expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ code, language, mountedSecrets: names }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) } ) - it('routes run_code shell commands through the same function_execute boundary', async () => { + it('routes run_code shell commands through the same run_function boundary', async () => { const code = 'printf %s "{{CLI_TOKEN}}"' const abortController = new AbortController() @@ -332,7 +332,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { requestedNames: ['CLI_TOKEN'], }) expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ code, language: 'shell', mountedSecrets: ['CLI_TOKEN'] }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), @@ -342,7 +342,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { ) }) - it('uses the trusted Mothership profile for function_execute without accepting a param override', async () => { + it('uses the trusted Mothership profile for run_function without accepting a param override', async () => { await executeFunctionExecute( { code: 'return 1', @@ -353,7 +353,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { ) expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ _context: expect.not.objectContaining({ sandboxProfile: expect.anything() }), }), @@ -373,7 +373,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { expect(mockHasWorkspaceSandboxAccess).toHaveBeenCalledWith('ws_1') expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', + 'run_function', expect.objectContaining({ sandboxId: 'sandbox-1' }), expect.objectContaining({ internalSandboxProfile: 'mothership' }) ) @@ -1216,7 +1216,7 @@ describe('executeFunctionExecute unmountable namespaces', () => { it('keeps the uploads/ guidance intact', async () => { const message = await mountError({ inputFiles: ['uploads/report.json'] }) - expect(message).toContain('materialize_file') + expect(message).toContain('save_upload') }) it('still reports a genuine files/ miss as not found', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 01773a65f00..7580c3382da 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -254,10 +254,10 @@ function unmountableNamespaceReason(filePath: string): string | null { const path = `${filePath.replace(/^\/+|\/+$/g, '')}/` if (path.startsWith('uploads/')) { - return 'uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.' + return 'uploads/ files are not mountable into the sandbox. Use save_upload to save it to a files/... path first, then mount that canonical path.' } if (path.startsWith('internal/tool-results/')) { - return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (function_execute: outputs.files[].path, user_table: outputPath) and mount that files/... path.' + return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (run_function: outputs.files[].path, user_table: outputPath) and mount that files/... path.' } if (path.startsWith('internal/')) { return 'internal/ paths are served by the copilot backend, not from workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — read or grep it and inline the values you need in code instead of mounting it.' @@ -417,7 +417,7 @@ export async function resolveInputFiles( `Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.` ) } - logger.info('Mounting workspace directory for function_execute', { + logger.info('Mounting workspace directory for run_function', { vfsPath: dirPath, sandboxPath: mountRoot, fileCount: descendants.length, @@ -745,7 +745,7 @@ export async function executeFunctionExecute( } try { - const result = await executeAppTool('function_execute', enrichedParams, { + const result = await executeAppTool('run_function', enrichedParams, { resolvedSecretTraceRegistry: mountedRegistry, ...(context.abortSignal ? { signal: context.abortSignal } : {}), ...(context.sandboxProfile ? { internalSandboxProfile: context.sandboxProfile } : {}), 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 a0c5cbb9089..7d43faa71b0 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 @@ -45,7 +45,7 @@ export async function executeManageCustomTool( * workspace — so a caller could name another workspace and have it * authorized against their own. `upsertCustomTools` does no authz of its own * (it only scopes queries by the id it is handed), so nothing downstream - * caught it. Matches manage_mcp_tool and manage_skill. + * caught it. Matches manage_mcp_connection and manage_skill. */ const workspaceId = context.workspaceId 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 5158176f27c..da6dc0cdd43 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 @@ -184,12 +184,15 @@ export async function executeManageMcpTool( } } - return { success: false, error: `Unsupported operation for manage_mcp_tool: ${operation}` } + return { + success: false, + error: `Unsupported operation for manage_mcp_connection: ${operation}`, + } } catch (error) { logger.error( context.messageId - ? `manage_mcp_tool execution failed [messageId:${context.messageId}]` - : 'manage_mcp_tool execution failed', + ? `manage_mcp_connection execution failed [messageId:${context.messageId}]` + : 'manage_mcp_connection execution failed', { operation, workspaceId, 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 65af6a518cb..45eb7b9fb96 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -223,19 +223,19 @@ describe('executeMaterializeFile - unsupported operation', () => { ) expect(result.success).toBe(false) - expect(result.error).toContain('Unsupported materialize_file operation "table"') + expect(result.error).toContain('Unsupported save_upload operation "table"') expect(result.error).toContain('table subagent') expect(mockFindUpload).not.toHaveBeenCalled() }) - it('rejects the knowledge_base operation and points to the knowledge subagent', async () => { + it('rejects the manage_knowledge_base operation and points to the knowledge subagent', async () => { const result = await executeMaterializeFile( - { fileNames: ['data.csv'], operation: 'knowledge_base' }, + { fileNames: ['data.csv'], operation: 'manage_knowledge_base' }, context ) expect(result.success).toBe(false) - expect(result.error).toContain('Unsupported materialize_file operation "knowledge_base"') + expect(result.error).toContain('Unsupported save_upload operation "manage_knowledge_base"') expect(result.error).toContain('knowledge subagent') expect(mockFindUpload).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index d6b5e2dece6..26f87478e51 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -45,7 +45,7 @@ import { admitCreateWorkspaceFile } from '@/lib/workspace-files/application/crea import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { extractWorkflowMetadata } from '@/app/api/v1/admin/types' -const logger = createLogger('MaterializeFile') +const logger = createLogger('SaveUpload') const MAX_MATERIALIZE_NAME_RETRIES = 8 const WORKSPACE_FILE_NAME_UNIQUE_INDEX = 'workspace_files_workspace_folder_name_active_unique' @@ -102,7 +102,7 @@ async function executeSave( if (isArchiveFileName(displayName)) { return { success: false, - error: `"${fileName}" is a .zip archive — save it by extracting instead: materialize_file(fileNames: ["${fileName}"], operation: "extract") unpacks it into files/ where the contents stay readable. The raw .zip remains in uploads/ for this chat.`, + error: `"${fileName}" is a .zip archive — save it by extracting instead: save_upload(fileNames: ["${fileName}"], operation: "extract") unpacks it into files/ where the contents stay readable. The raw .zip remains in uploads/ for this chat.`, } } @@ -256,7 +256,7 @@ async function executeImport( if (isArchiveFileName(row.displayName ?? row.originalName)) { return { success: false, - error: `"${fileName}" is a .zip archive, not a workflow JSON. Extract it first: materialize_file(fileNames: ["${fileName}"], operation: "extract").`, + error: `"${fileName}" is a .zip archive, not a workflow JSON. Extract it first: save_upload(fileNames: ["${fileName}"], operation: "extract").`, } } @@ -553,11 +553,11 @@ export async function executeMaterializeFile( } if (!context.chatId) { - return { success: false, error: 'No chat context available for materialize_file' } + return { success: false, error: 'No chat context available for save_upload' } } if (!context.workspaceId) { - return { success: false, error: 'No workspace context available for materialize_file' } + return { success: false, error: 'No workspace context available for save_upload' } } const principal = resolveCopilotFilePrincipal(context) @@ -569,7 +569,7 @@ export async function executeMaterializeFile( if (operation !== 'save' && operation !== 'import' && operation !== 'extract') { return { success: false, - error: `Unsupported materialize_file operation "${operation}". Use "save", "import", or "extract". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`, + error: `Unsupported save_upload operation "${operation}". Use "save", "import", or "extract". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`, } } @@ -615,7 +615,7 @@ export async function executeMaterializeFile( failed.push({ fileName, error: result.error ?? 'Failed to materialize file' }) } } catch (err) { - logger.error('materialize_file failed', { + logger.error('save_upload failed', { fileName, operation, chatId: context.chatId, diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index a7ece423150..afe24b1b1ed 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -302,6 +302,8 @@ export interface OpenResourceItem { type?: OpenResourceType id?: string path?: string + /** Saved-view id or exact name to open a table pinned to (table type only). */ + view?: string } export interface OpenResourceParams { @@ -315,4 +317,5 @@ export interface ValidOpenResourceParams { type: OpenResourceType id?: string path?: string + view?: string } diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts index c3c3ac14384..c7f02974520 100644 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts @@ -1,5 +1,5 @@ /** - * Static content for the get_platform_actions tool. + * Static content for the get_ui_reference tool. * Contains the Sim platform quick reference and keyboard shortcuts. */ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboard Shortcuts diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index 4807406e830..64fa0d7610e 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -39,6 +39,10 @@ vi.mock('@/lib/table/service', () => ({ getTableById: vi.fn(), })) +vi.mock('@/lib/table/views/service', () => ({ + getTableView: vi.fn(), +})) + vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ readKnowledgeBase: { operation: { id: 'knowledge.read' }, @@ -86,6 +90,8 @@ vi.mock('@/lib/logs/service', () => ({ getLogById: vi.fn(), })) +import { getTableById } from '@/lib/table/service' +import { getTableView } from '@/lib/table/views/service' import { executeOpenResource } from './resources' describe('executeOpenResource', () => { @@ -229,3 +235,52 @@ describe('executeOpenResource', () => { ).rejects.toThrow('knowledge database unavailable') }) }) + +describe('open_resource table views', () => { + const executionContext = { userId: 'user-1', workspaceId: 'ws-1' } as never + + it('opens a table pinned to a saved view by id, stamping viewId and a pinned title', async () => { + vi.mocked(getTableById).mockResolvedValue({ + id: 'tbl-1', + name: 'Leads', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col_a', name: 'status', type: 'string' }] }, + } as never) + vi.mocked(getTableView).mockResolvedValue({ + id: 'view-1', + name: 'Overdue', + isDefault: false, + config: {}, + } as never) + + const result = await executeOpenResource( + { resources: [{ type: 'table', id: 'tbl-1', view: 'view-1' }] }, + executionContext + ) + + expect(result.success).toBe(true) + expect(result.resources?.[0]).toMatchObject({ + type: 'table', + id: 'tbl-1', + title: 'Leads — Overdue', + viewId: 'view-1', + }) + }) + + it('rejects an unknown view id and points at views.json', async () => { + vi.mocked(getTableById).mockResolvedValue({ + id: 'tbl-1', + name: 'Leads', + workspaceId: 'ws-1', + schema: { columns: [] }, + } as never) + vi.mocked(getTableView).mockResolvedValue(null as never) + + const missing = await executeOpenResource( + { resources: [{ type: 'table', id: 'tbl-1', view: 'view-nope' }] }, + executionContext + ) + expect(missing.success).toBe(false) + expect((missing.output as { errors: string[] }).errors[0]).toContain('views.json') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 743c67b7784..0bbb90efe0d 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -6,7 +6,9 @@ import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' import { getLogById } from '@/lib/logs/service' +import type { TableSchema } from '@/lib/table' import { getTableById } from '@/lib/table/service' +import { getTableView } from '@/lib/table/views/service' import { findWorkspaceFileRecord, type WorkspaceFileRecord, @@ -76,6 +78,21 @@ async function resolveResource( return { error: `Table not found in the current workspace.` } resourceId = tbl.id title = tbl.name + if (item.view) { + const view = await getTableView( + item.view.trim(), + tbl.id, + (tbl.schema as TableSchema).columns, + context.workspaceId ?? undefined + ) + if (!view) { + return { + error: `No view with id "${item.view.trim()}" on table "${tbl.name}". View ids are listed in the table's views.json.`, + } + } + title = `${tbl.name} — ${view.name}` + return { type: resourceType, id: resourceId, title, viewId: view.id } + } } if (resourceType === 'knowledgebase') { if (!item.id) return { error: 'knowledgebase resources require `id`.' } @@ -174,5 +191,8 @@ function validateOpenResourceItem( if (!item.id && !(item.type === 'file' && item.path)) { return { success: false, error: `${item.type} resources require \`id\`` } } - return { success: true, params: { type: item.type, id: item.id, path: item.path } } + return { + success: true, + params: { type: item.type, id: item.id, path: item.path, view: item.view }, + } } diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts index e27babc4512..68345ea7527 100644 --- a/apps/sim/lib/copilot/tools/handlers/run-code.ts +++ b/apps/sim/lib/copilot/tools/handlers/run-code.ts @@ -2,7 +2,7 @@ import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/to import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' /** - * Compute-only variant of function_execute for info-gathering agents: same + * Compute-only variant of run_function for info-gathering agents: same * sandbox and inputs, but it must never create or overwrite workspace * resources. The write vectors (outputs.files, outputTable) are rejected here * on top of the Go executor's fail-fast guard; run_code is also absent from diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts index 3e511d1be0c..633a4bacdd5 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts @@ -184,7 +184,7 @@ describe('readChatUpload', () => { const result = await readChatUpload('bundle.zip', CHAT_ID) - expect(result?.content).toContain('materialize_file') + expect(result?.content).toContain('save_upload') expect(result?.content).toContain('extract') expect(mockReadFileRecord).not.toHaveBeenCalled() }) @@ -200,7 +200,7 @@ describe('readChatUpload', () => { const result = await readChatUpload('huge.zip', CHAT_ID) - expect(result?.content).toContain('materialize_file') + expect(result?.content).toContain('save_upload') expect(mockFetchBuffer).not.toHaveBeenCalled() expect(mockReadFileRecord).not.toHaveBeenCalled() }) @@ -243,7 +243,7 @@ describe('grepChatUpload', () => { const error = await grepChatUpload('bundle.zip', CHAT_ID, 'foo').catch((e) => e) expect(error).toBeInstanceOf(WorkspaceFileGrepError) - expect(error.message).toContain('materialize_file') + expect(error.message).toContain('save_upload') expect(error.message).toContain('extract') expect(mockReadFileRecord).not.toHaveBeenCalled() }) 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 3245a6b8977..2d2d6880dbd 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -321,13 +321,13 @@ describe('vfs mv/cp', () => { expect(result.error).toContain('across categories') }) - it('rejects uploads with a materialize_file pointer', async () => { + it('rejects uploads with a save_upload pointer', async () => { const result = await executeVfsMv( { sources: ['uploads/data.csv'], destination: 'files/data.csv' }, context ) expect(result.success).toBe(false) - expect(result.error).toContain('materialize_file') + expect(result.error).toContain('save_upload') }) it('rejects read-only categories', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 8fa50ce6d6f..946cabe015f 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -54,7 +54,7 @@ const MUTATE_CATEGORIES = new Set(['files', 'workflows', 'tables', 'know const CATEGORY_REJECTIONS: Record = { uploads: - 'uploads/ files are chat-scoped and immutable. Use materialize_file to promote one into files/ first.', + 'uploads/ files are chat-scoped and immutable. Use save_upload to promote one into files/ first.', 'recently-deleted': 'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.', } diff --git a/apps/sim/lib/copilot/tools/permissions.test.ts b/apps/sim/lib/copilot/tools/permissions.test.ts index a15ee1f791a..3edfc033dff 100644 --- a/apps/sim/lib/copilot/tools/permissions.test.ts +++ b/apps/sim/lib/copilot/tools/permissions.test.ts @@ -34,8 +34,8 @@ describe('copilotWriteDeniedMessage', () => { }) it('omits the operation label when there is no operation', () => { - expect(copilotWriteDeniedMessage('knowledge_base', undefined, 'read')).toBe( - "Permission denied: knowledge_base requires write access. You have 'read' permission." + expect(copilotWriteDeniedMessage('manage_knowledge_base', undefined, 'read')).toBe( + "Permission denied: manage_knowledge_base requires write access. You have 'read' permission." ) }) }) 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 index 0f574e56112..f65a8b2ab70 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -24,7 +24,7 @@ describe('server tool adapter authority boundary', () => { }) it('overwrites model-supplied workspace scope and forwards trusted delegation context', async () => { - const handler = createServerToolHandler('workspace_file') + const handler = createServerToolHandler('prepare_file_edit') await handler( { workspaceId: 'attacker-workspace', operation: 'rename' }, @@ -39,7 +39,7 @@ describe('server tool adapter authority boundary', () => { ) expect(mocks.routeExecution).toHaveBeenCalledWith( - 'workspace_file', + 'prepare_file_edit', expect.objectContaining({ workspaceId: 'workspace-1', operation: 'rename' }), expect.objectContaining({ userId: 'user-1', @@ -55,7 +55,7 @@ describe('server tool adapter authority boundary', () => { const storageError = new Error('update workspace_files set secret_column = value') mocks.routeExecution.mockRejectedValue(storageError) - const result = await createServerToolHandler('workspace_file')( + const result = await createServerToolHandler('prepare_file_edit')( {}, { userId: 'user-1', @@ -68,13 +68,13 @@ describe('server tool adapter authority boundary', () => { expect(result).toEqual({ success: false, - error: `[workspace_file] ${TOOL_RESULT_UNAVAILABLE_ERROR}`, + error: `[prepare_file_edit] ${TOOL_RESULT_UNAVAILABLE_ERROR}`, }) expect(result.error).not.toContain('workspace_files') expect(mocks.loggerError).toHaveBeenCalledWith( 'Server tool execution failed', { - toolId: 'workspace_file', + toolId: 'prepare_file_edit', abortSignalAborted: false, }, storageError diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index 2af97ba7490..b4081e340a2 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -16,7 +16,7 @@ export interface ServerToolContext { messageId?: string /** * The invoking subagent's channel id (its outer tool_use id). Used to scope - * the workspace_file -> edit_content intent handoff to a single file subagent + * the prepare_file_edit -> apply_file_edit intent handoff to a single file subagent * so two file agents writing concurrently never consume each other's pending * intent. Undefined for main-agent tool calls (which never overlap). */ 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 14693f75913..f024b421de8 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 @@ -9,7 +9,7 @@ const { mockGenerateSearchEmbedding } = vi.hoisted(() => ({ })) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - SearchDocumentation: { id: 'search_documentation' }, + SearchSimDocs: { id: 'search_sim_docs' }, })) vi.mock('@/lib/knowledge/embeddings', () => ({ generateSearchEmbedding: mockGenerateSearchEmbedding, 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 ad14c3937a6..1a61dcbbfc0 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sql } from 'drizzle-orm' -import { SearchDocumentation } from '@/lib/copilot/generated/tool-catalog-v1' +import { SearchSimDocs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' @@ -15,7 +15,7 @@ interface DocsSearchParams { const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 export const searchDocumentationServerTool: BaseServerTool = { - name: SearchDocumentation.id, + name: SearchSimDocs.id, async execute(params: DocsSearchParams): Promise { const logger = createLogger('SearchDocumentationServerTool') const { query, topK = 10, threshold } = params diff --git a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts index bde0e4873b5..8b7a2a3d7ef 100644 --- a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts +++ b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { EnrichmentRun } from '@/lib/copilot/generated/tool-catalog-v1' +import { RunEnrichment } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { getEnrichment } from '@/enrichments/registry' import { runEnrichment } from '@/enrichments/run' @@ -25,7 +25,7 @@ interface EnrichmentRunResult { * bill (see image/generate-image.ts). */ export const enrichmentRunServerTool: BaseServerTool = { - name: EnrichmentRun.id, + name: RunEnrichment.id, async execute(params: EnrichmentRunParams, context): Promise { const logger = createLogger('EnrichmentRunServerTool') const { enrichmentId, inputs } = params 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 430545f8b0a..8f36784b1a1 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -13,7 +13,7 @@ import { } from '@/lib/workspace-files/application/write-workspace-file-by-path' const logger = createLogger('CreateFileServerTool') -const CREATE_FILE_TOOL_ID = 'create_file' +const CREATE_FILE_TOOL_ID = 'create_empty_file' interface CreateFileArgs { fileName: string @@ -48,7 +48,10 @@ export const createFileServerTool: BaseServerTool files). pptx also gets `iconImage` // (react-icons → sharp → PNG), which only works here because the E2B sandbox is -// a full Linux VM. The agent's edit_content source runs inside an async IIFE so +// a full Linux VM. The agent's apply_file_edit source runs inside an async IIFE so // top-level await (addImage/iconImage) works; the finalizer writes the binary. const PPTX_NODE_PREAMBLE = ` const PptxGenJS = require('pptxgenjs'); diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index ff1f7b10f26..dba8bb892b8 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import { DownloadToWorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' +import { DownloadFile } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -137,7 +137,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< DownloadToWorkspaceFileArgs, DownloadToWorkspaceFileResult > = { - name: DownloadToWorkspaceFile.id, + name: DownloadFile.id, inputSchema: DownloadToWorkspaceFileArgsSchema, outputSchema: DownloadToWorkspaceFileResultSchema, diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index 0f453ebd678..99665a3375e 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -30,10 +30,10 @@ type EditContentResult = { } export const editContentServerTool: BaseServerTool = { - name: 'edit_content', + name: 'apply_file_edit', async execute(params: EditContentArgs, context?: ServerToolContext): Promise { if (!context?.userId) { - logger.error('Unauthorized attempt to use edit_content') + logger.error('Unauthorized attempt to use apply_file_edit') throw new Error('Authentication required') } @@ -52,12 +52,12 @@ export const editContentServerTool: BaseServerTool { }) ) - // edit_content from channel F1 must get fileA — NOT the latest (fileB). + // apply_file_edit from channel F1 must get fileA — NOT the latest (fileB). const a = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F1' }) expect(a?.fileId).toBe('fileA') - // edit_content from channel F2 gets fileB. + // apply_file_edit from channel F2 gets fileB. const b = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F2' }) expect(b?.fileId).toBe('fileB') }) diff --git a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts b/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts index 82f7977b17d..e9841ce42d9 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts @@ -12,7 +12,7 @@ export type PendingFileIntent = { chatId?: string messageId?: string // The invoking file subagent's channel id (its outer tool_use id). Lets - // edit_content consume the intent for ITS OWN file subagent instead of the + // apply_file_edit consume the intent for ITS OWN file subagent instead of the // latest in the message, so two file agents writing concurrently never cross // their content into each other's file. channelId?: string diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index d99f219021b..f706231d5ee 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -135,9 +135,9 @@ function buildAppendPreview(existingContent: string, incomingContent: string): s /** * Reads the current UTF-8 text of a workspace file for streaming previews. * - * Preview runs in the SSE loop on `workspace_file` **call** events, which are + * Preview runs in the SSE loop on `prepare_file_edit` **call** events, which are * processed **before** the async tool executor persists {@link storeFileIntent}. - * Loading the base here avoids a race where `edit_content` `args_delta` arrives + * Loading the base here avoids a race where `apply_file_edit` `args_delta` arrives * before Redis holds `existingContent`, which would make append previews look like * full-file replacement until the intent landed. */ @@ -193,7 +193,7 @@ export function buildFilePreviewText({ // Fail closed (like `patch`/`update` below) when the base file content has not loaded yet: a base-less // `append` preview is just the streamed fragment, and a collaborative editor applying it as the full // body would reconcile the seeded doc down to that fragment (a wipe). Skipping the preview until the - // base is available costs only a brief render delay; the final durable `edit_content` write is + // base is available costs only a brief render delay; the final durable `apply_file_edit` write is // authoritative. An empty file has `existingContent === ''` (defined), so it is unaffected. if (existingContent === undefined) { return undefined 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 f40fbe00652..6ac90e138d3 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -10,7 +10,7 @@ import { messageForCopilotFileError, resolveCopilotFilePrincipal, } from '@/lib/copilot/auth/file-delegation' -import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' +import { PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -191,7 +191,7 @@ export type CompileForWriteResult = | { ok: false; message: string } /** - * Shared write-time doc handling for create + edit_content: validates and builds + * Shared write-time doc handling for create + apply_file_edit: validates and builds * the document (E2B doc sandbox when enabled — Node pptx/docx, Python pdf/xlsx — * else isolated-vm JS) and returns the source MIME to store, or a user-facing * failure message. Non-doc files resolve to `fallbackMime`. The remote backend publishes a @@ -260,7 +260,7 @@ export async function compileDocForWrite(args: { } export const workspaceFileServerTool: BaseServerTool = { - name: WorkspaceFile.id, + name: PrepareFileEdit.id, async execute( params: WorkspaceFileArgs, context?: ServerToolContext @@ -478,7 +478,7 @@ export const workspaceFileServerTool: BaseServerTool ({ - KnowledgeBase: { id: 'knowledge_base' }, + ManageKnowledgeBase: { id: 'manage_knowledge_base' }, })) vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { @@ -224,7 +224,7 @@ function expectDelegatedPrincipal(call: unknown): void { }) } -describe('knowledge_base trusted application delegation', () => { +describe('manage_knowledge_base trusted application delegation', () => { beforeEach(() => { vi.clearAllMocks() mockReadKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) @@ -754,7 +754,7 @@ describe('knowledge_base trusted application delegation', () => { ) }) -describe('knowledge_base add_file delegation', () => { +describe('manage_knowledge_base add_file delegation', () => { beforeEach(() => { vi.clearAllMocks() mockAddWorkspaceFiles.mockResolvedValue({ 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 1f5aa48d4c5..cd9f300a2ad 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -10,7 +10,7 @@ import { messageForCopilotKnowledgeError, requireCopilotKnowledgeWorkspaceId, } from '@/lib/copilot/application/execute-knowledge-use-case' -import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' +import { ManageKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { assertServerToolNotAborted, @@ -252,7 +252,7 @@ function isKnowledgeDocumentTagValueAssignment( * Knowledge base tool for copilot to create, list, and get knowledge bases */ export const knowledgeBaseServerTool: BaseServerTool = { - name: KnowledgeBase.id, + name: ManageKnowledgeBase.id, async execute( params: KnowledgeBaseArgs, context?: ServerToolContext @@ -1070,7 +1070,7 @@ export const knowledgeBaseServerTool: BaseServerTool ({ })) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - SearchOnline: { id: 'search_online' }, + WebSearch: { id: 'web_search' }, })) vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index 330d930c649..272c80d1035 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { SearchOnline } from '@/lib/copilot/generated/tool-catalog-v1' +import { WebSearch } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import { env } from '@/lib/core/config/env' @@ -31,7 +31,7 @@ interface SearchResponse { } export const searchOnlineServerTool: BaseServerTool = { - name: SearchOnline.id, + name: WebSearch.id, async execute(params: OnlineSearchParams, context?: ServerToolContext): Promise { const logger = createLogger('SearchOnlineServerTool') const { query, num = 10, type = 'search', gl, hl } = params diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 7d87b432218..66848fa4688 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -2,19 +2,19 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { - CreateFile, - DownloadToWorkspaceFile, + CreateEmptyFile, + DownloadFile, Ffmpeg, GenerateAudio, GenerateImage, GenerateVideo, - KnowledgeBase, ManageCredential, ManageCustomTool, - ManageMcpTool, + ManageKnowledgeBase, + ManageMcpConnection, ManageSkill, + PrepareFileEdit, UserTable, - WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { copilotToolCanWrite } from '@/lib/copilot/tools/permissions' import { @@ -53,6 +53,7 @@ import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-c import { tableEnrichmentsServerTool } from '@/lib/copilot/tools/server/table/table-enrichments' import { tableManageServerTool } from '@/lib/copilot/tools/server/table/table-manage' import { tableRowsServerTool } from '@/lib/copilot/tools/server/table/table-rows' +import { tableViewsServerTool } from '@/lib/copilot/tools/server/table/table-views' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' @@ -90,7 +91,7 @@ const VISIBILITY_GATED_TOOLS = new Set([ ]) const WRITE_ACTIONS: Record = { - [KnowledgeBase.id]: [ + [ManageKnowledgeBase.id]: [ 'create', 'add_file', 'update', @@ -133,19 +134,19 @@ const WRITE_ACTIONS: Record = { 'add_enrichment', ], [ManageCustomTool.id]: ['add', 'edit', 'delete'], - [ManageMcpTool.id]: ['add', 'edit', 'delete'], + [ManageMcpConnection.id]: ['add', 'edit', 'delete'], [ManageSkill.id]: ['add', 'edit', 'delete'], [ManageCredential.id]: ['rename', 'delete'], - [WorkspaceFile.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], + [PrepareFileEdit.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], [editContentServerTool.name]: ['*'], - [CreateFile.id]: ['*'], + [CreateEmptyFile.id]: ['*'], rename_file: ['*'], [shareFileServerTool.name]: ['*'], move_file: ['*'], create_file_folder: ['*'], rename_file_folder: ['*'], move_file_folder: ['*'], - [DownloadToWorkspaceFile.id]: ['*'], + [DownloadFile.id]: ['*'], [GenerateImage.id]: ['generate'], [GenerateVideo.id]: ['generate'], [GenerateAudio.id]: ['generate'], @@ -182,6 +183,7 @@ const baseServerToolRegistry: Record = { [tableColumnsServerTool.name]: tableColumnsServerTool, [tableAutomationsServerTool.name]: tableAutomationsServerTool, [tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool, + [tableViewsServerTool.name]: tableViewsServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.test.ts b/apps/sim/lib/copilot/tools/server/table/table-views.test.ts new file mode 100644 index 00000000000..56d4df7e797 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-views.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useCases = vi.hoisted(() => ({ + list: vi.fn(), + read: vi.fn(), + create: vi.fn(), + update: vi.fn(), + del: vi.fn(), +})) + +vi.mock('@/lib/table/application/views', () => ({ + listTableViewsUseCase: { operation: { id: 'tables.views.list' }, execute: useCases.list }, + readTableViewUseCase: { operation: { id: 'tables.views.read' }, execute: useCases.read }, + createTableViewUseCase: { operation: { id: 'tables.views.create' }, execute: useCases.create }, + updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: useCases.update }, + deleteTableViewUseCase: { operation: { id: 'tables.views.delete' }, execute: useCases.del }, +})) + +const executeUseCase = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + executeCopilotTableUseCase: executeUseCase, +})) + +import { tableViewsServerTool } from '@/lib/copilot/tools/server/table/table-views' + +const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never + +const columns = [ + { id: 'col_a', name: 'status', type: 'string' }, + { id: 'col_b', name: 'due', type: 'date' }, +] +const table = { id: 'tbl-1', schema: { columns } } + +describe('table_views adapter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('translates stored id-domain configs to column names on list', async () => { + executeUseCase.mockResolvedValueOnce({ + table, + views: [ + { + id: 'view-1', + name: 'Overdue', + isDefault: true, + config: { + filter: { all: [{ field: 'col_a', op: 'ne', value: 'Done' }] }, + sort: [{ field: 'col_b', direction: 'asc' }], + }, + }, + ], + }) + + const result = await tableViewsServerTool.execute( + { operation: 'list_views', args: { tableId: 'tbl-1' } }, + context + ) + + expect(result.success).toBe(true) + expect(result.data.views[0].filter).toEqual({ + all: [{ field: 'status', op: 'ne', value: 'Done' }], + }) + expect(result.data.views[0].sort).toEqual([{ field: 'due', direction: 'asc' }]) + }) + + it('translates agent column names to stable ids on create', async () => { + executeUseCase.mockResolvedValueOnce({ table, views: [] }).mockResolvedValueOnce({ + view: { id: 'view-2', name: 'Mine', isDefault: false, config: {} }, + table, + }) + + const result = await tableViewsServerTool.execute( + { + operation: 'create_view', + args: { + tableId: 'tbl-1', + name: 'Mine', + filter: { all: [{ field: 'status', op: 'eq', value: 'Open' }] }, + }, + }, + context + ) + + expect(result.success).toBe(true) + const createInput = executeUseCase.mock.calls[1][2] + expect(createInput.config.filter).toEqual({ + all: [{ field: 'col_a', op: 'eq', value: 'Open' }], + }) + }) + + it('rejects unknown column names with the columns spelled out', async () => { + executeUseCase.mockResolvedValueOnce({ table, views: [] }) + + await expect( + tableViewsServerTool.execute( + { + operation: 'create_view', + args: { + tableId: 'tbl-1', + name: 'Broken', + filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }, + }, + context + ) + ).rejects.toThrow(/Unknown column/) + }) + + it('rejects unsupported operations without invoking anything', async () => { + const result = await tableViewsServerTool.execute( + { operation: 'insert_row', args: { tableId: 'tbl-1' } }, + context + ) + expect(result.success).toBe(false) + expect(result.message).toContain('insert_row') + expect(executeUseCase).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.ts b/apps/sim/lib/copilot/tools/server/table/table-views.ts new file mode 100644 index 00000000000..fe7cdaf27de --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-views.ts @@ -0,0 +1,197 @@ +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { TableViews } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import type { SortSpec, TablePredicateInput, TableSchema, TableViewConfig } from '@/lib/table' +import { + createTableViewUseCase, + deleteTableViewUseCase, + listTableViewsUseCase, + readTableViewUseCase, + updateTableViewUseCase, +} from '@/lib/table/application/views' +import { viewConfigIdsToNames, viewConfigNamesToIds } from '@/lib/table/views/service' + +type TableViewsArgs = { + operation: string + args?: Record +} + +type TableViewsResult = { + success: boolean + message: string + data?: any +} + +/** + * Saved-view slice of the split table surface. Unlike the other slices this is + * NOT a user_table passthrough — it adapts the dedicated view use cases. + * Agents speak column NAMES; stored configs are keyed by stable column id, so + * inputs translate names→ids on the way in and every returned view translates + * ids→names on the way out. + */ +export const tableViewsServerTool: BaseServerTool = { + name: TableViews.id, + async execute(params: TableViewsArgs, context?: ServerToolContext) { + const operation = params?.operation + const args = params?.args ?? {} + const tableId = args.tableId as string | undefined + const workspaceId = context?.workspaceId + if (!tableId) return { success: false, message: 'Table ID is required' } + if (!workspaceId) return { success: false, message: 'Workspace ID is required' } + + const presentView = ( + view: { id: string; name: string; isDefault: boolean; config: TableViewConfig }, + columns: TableSchema['columns'] + ) => { + const named = viewConfigIdsToNames(view.config, columns) + return { + id: view.id, + name: view.name, + isDefault: view.isDefault, + filter: named.filter ?? null, + sort: named.sort ?? null, + hiddenColumns: named.hiddenColumns?.length ? named.hiddenColumns : undefined, + } + } + + const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => + viewConfigNamesToIds( + { + filter: (args.filter as TablePredicateInput | undefined) ?? null, + sort: (args.sort as SortSpec | undefined) ?? null, + hiddenColumns: args.hiddenColumns as string[] | undefined, + } as TableViewConfig, + columns + ) + + switch (operation) { + case 'list_views': { + const result = await executeCopilotTableUseCase( + context, + listTableViewsUseCase, + { tableId, workspaceId }, + { tableId } + ) + const columns = (result.table.schema as TableSchema).columns + const views = result.views.map((view) => presentView(view, columns)) + return { + success: true, + message: `Table has ${views.length} view(s)`, + data: { views }, + } + } + case 'get_view': { + if (!args.viewId) return { success: false, message: 'viewId is required' } + const result = await executeCopilotTableUseCase( + context, + readTableViewUseCase, + { tableId, workspaceId, viewId: args.viewId }, + { tableId } + ) + const columns = (result.table.schema as TableSchema).columns + return { + success: true, + message: 'View loaded', + data: { view: presentView(result.view, columns) }, + } + } + case 'create_view': { + if (!args.name) return { success: false, message: 'name is required' } + const listed = await executeCopilotTableUseCase( + context, + listTableViewsUseCase, + { tableId, workspaceId }, + { tableId } + ) + const columns = (listed.table.schema as TableSchema).columns + const created = await executeCopilotTableUseCase( + context, + createTableViewUseCase, + { tableId, workspaceId, name: args.name, config: namedConfigFromArgs(columns) }, + { tableId } + ) + if (args.isDefault === true) { + await executeCopilotTableUseCase( + context, + updateTableViewUseCase, + { tableId, workspaceId, viewId: created.view.id, isDefault: true }, + { tableId } + ) + } + return { + success: true, + message: `Created view "${created.view.name}" (${created.view.id})${args.isDefault === true ? ' as default' : ''}`, + data: { + view: presentView({ ...created.view, isDefault: args.isDefault === true }, columns), + }, + } + } + case 'update_view': { + if (!args.viewId) return { success: false, message: 'viewId is required' } + const listed = await executeCopilotTableUseCase( + context, + listTableViewsUseCase, + { tableId, workspaceId }, + { tableId } + ) + const columns = (listed.table.schema as TableSchema).columns + const hasConfigChange = + args.filter !== undefined || args.sort !== undefined || args.hiddenColumns !== undefined + const updated = await executeCopilotTableUseCase( + context, + updateTableViewUseCase, + { + tableId, + workspaceId, + viewId: args.viewId, + name: args.name as string | undefined, + ...(hasConfigChange ? { configPatch: namedConfigFromArgs(columns) } : {}), + isDefault: args.isDefault as boolean | undefined, + }, + { tableId } + ) + return { + success: true, + message: `Updated view "${updated.view.name}"`, + data: { view: presentView(updated.view, columns) }, + } + } + case 'delete_view': { + if (!args.viewId) return { success: false, message: 'viewId is required' } + const result = await executeCopilotTableUseCase( + context, + deleteTableViewUseCase, + { tableId, workspaceId, viewId: args.viewId }, + { tableId } + ) + return { success: true, message: `Deleted view "${result.viewName}"` } + } + case 'set_default_view': { + if (!args.viewId) return { success: false, message: 'viewId is required' } + const listed = await executeCopilotTableUseCase( + context, + listTableViewsUseCase, + { tableId, workspaceId }, + { tableId } + ) + const columns = (listed.table.schema as TableSchema).columns + const updated = await executeCopilotTableUseCase( + context, + updateTableViewUseCase, + { tableId, workspaceId, viewId: args.viewId, isDefault: true }, + { tableId } + ) + return { + success: true, + message: `"${updated.view.name}" is now the default view`, + data: { view: presentView(updated.view, columns) }, + } + } + default: + return { + success: false, + message: `table_views does not support operation '${operation}' (allowed: list_views, get_view, create_view, update_view, delete_view, set_default_view)`, + } + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 83c263815a2..88f2dd91568 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -604,7 +604,7 @@ describe('userTableServerTool.import_file', () => { expect(mockBatchInsertRows).not.toHaveBeenCalled() }) - it('points a chat-upload path at materialize_file instead of globbing files/', async () => { + it('points a chat-upload path at save_upload instead of globbing files/', async () => { mockResolveWorkspaceFileReference.mockResolvedValueOnce(null) const result = await userTableServerTool.execute( @@ -616,7 +616,7 @@ describe('userTableServerTool.import_file', () => { ) expect(result.success).toBe(false) - expect(result.message).toMatch(/materialize_file/) + expect(result.message).toMatch(/save_upload/) expect(result.message).not.toMatch(/glob\("files/) }) 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 aa03686ccba..8ced02221e3 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -48,6 +48,7 @@ import { readTableUseCase, updateTableUseCase, } from '@/lib/table/application/tables' +import { readTableViewUseCase } from '@/lib/table/application/views' import { namedRowMapper } from '@/lib/table/cell-format' import { isSupportedCurrencyCode } from '@/lib/table/currency' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' @@ -56,11 +57,13 @@ import { normalizeSelectOptionsInput } from '@/lib/table/select-options' import type { RowData, SortSpec, + TablePredicate, TablePredicateInput, TableSchema, WorkflowGroupDependencies, WorkflowGroupDeploymentMode, } from '@/lib/table/types' +import { viewConfigIdsToNames } from '@/lib/table/views/service' import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('UserTableServerTool') @@ -145,6 +148,19 @@ async function importRowsProvenanceForModel( await registry.importCrossingProvenance(provenance, values, { trusted: true }) } +/** AND-combines a saved view's predicate with an explicit one; either may be absent. */ +function mergeViewPredicate( + viewFilter: TablePredicateInput | undefined, + explicit: TablePredicateInput | undefined +): TablePredicate | undefined { + const parts: TablePredicate[] = [] + if (viewFilter) parts.push(normalizeTablePredicate(viewFilter)) + if (explicit) parts.push(normalizeTablePredicate(explicit)) + if (parts.length === 0) return undefined + if (parts.length === 1) return parts[0] + return { all: parts } +} + export const userTableServerTool: BaseServerTool = { name: UserTable.id, async execute(params: UserTableArgs, context?: ServerToolContext): Promise { @@ -398,6 +414,31 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } + // Saved-view scope: the view's stored filter ANDs with any explicit + // filter (query-within-the-view), its sort applies only when no + // explicit order is given, and layout fields (hidden columns, order, + // widths) are ignored — agents always see full rows. Views are + // referenced by id only (from views.json or table_views list_views). + let viewFilter: TablePredicateInput | undefined + let viewSort: SortSpec | undefined + let appliedViewName: string | undefined + if (typeof args.view === 'string' && args.view.trim() !== '') { + const viewId = (args.view as string).trim() + const resolved = await executeCopilotTableUseCase( + context, + readTableViewUseCase, + { tableId: args.tableId, workspaceId, viewId }, + { tableId: args.tableId } + ) + const named = viewConfigIdsToNames( + resolved.view.config, + (resolved.table.schema as TableSchema).columns + ) + viewFilter = (named.filter as TablePredicateInput | null) ?? undefined + viewSort = (named.sort as SortSpec | null) ?? undefined + appliedViewName = resolved.view.name + } + const queryLimitError = limitError(args.limit, TABLE_LIMITS.MAX_QUERY_LIMIT) if (queryLimitError) { return { success: false, message: queryLimitError } @@ -409,10 +450,11 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, assertedWorkspaceId: workspaceId, - predicate: args.filter - ? normalizeTablePredicate(args.filter as TablePredicateInput) - : undefined, - sort: args.order as SortSpec | undefined, + predicate: mergeViewPredicate( + viewFilter, + args.filter as TablePredicateInput | undefined + ), + sort: (args.order as SortSpec | undefined) ?? viewSort, limit: args.limit ?? TABLE_LIMITS.MAX_QUERY_LIMIT, cursor: args.cursor, includeTotal: !args.cursor, @@ -431,7 +473,9 @@ export const userTableServerTool: BaseServerTool // nextCursor covers both cut kinds (explicit limit or the 5MB byte // budget) — either way the truthful signal is "more rows exist". The // token is opaque; the agent echoes it back as `cursor` to continue. - const countSuffix = result.totalCount != null ? ` of ${result.totalCount}` : '' + const viewSuffix = appliedViewName ? ` (view: ${appliedViewName})` : '' + const countSuffix = + (result.totalCount != null ? ` of ${result.totalCount}` : '') + viewSuffix const message = result.nextCursor ? `Returned ${result.rows.length}${countSuffix} rows (more available — pass cursor=${result.nextCursor} to continue)` : `Returned ${result.rows.length}${countSuffix} rows` diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 027ce68d915..31520688721 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -4,9 +4,9 @@ import { describe, expect, it } from 'vitest' import { FfmpegOperationValues, - KnowledgeBaseOperationValues, - MaterializeFileOperationValues, + ManageKnowledgeBaseOperationValues, QueryUserTableOperationValues, + SaveUploadOperationValues, SearchKnowledgeBaseOperationValues, TOOL_CATALOG, type ToolCatalogEntry, @@ -65,22 +65,22 @@ describe('humanizeToolName', () => { it('keeps canonical acronym casing', () => { expect(humanizeToolName('create_workspace_mcp_server')).toBe('Create Workspace MCP Server') - expect(humanizeToolName('deploy_api')).toBe('Deploy API') + expect(humanizeToolName('deploy_as_api')).toBe('Deploy As API') expect(humanizeToolName('oauth_request_access')).toBe('OAuth Request Access') }) }) describe('getToolDisplayTitle natural-language coverage', () => { it('gives gerund titles to tools that previously fell through to humanize', () => { - expect(getToolDisplayTitle('deploy_api')).toBe('Deploying API') + expect(getToolDisplayTitle('deploy_as_api')).toBe('Deploying API') expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link') expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) - it('falls back to running code for function_execute without a title', () => { - expect(getToolDisplayTitle('function_execute')).toBe('Running code') - expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( + it('falls back to running code for run_function without a title', () => { + expect(getToolDisplayTitle('run_function')).toBe('Running code') + expect(getToolDisplayTitle('run_function', { title: 'Crunching numbers' })).toBe( 'Crunching numbers' ) }) @@ -141,14 +141,14 @@ describe('getToolDisplayTitle natural-language coverage', () => { describe('getToolDisplayTitle for deployments', () => { it.each([ - ['deploy_api', undefined, 'Deploying API'], - ['deploy_api', { action: 'deploy' }, 'Deploying API'], - ['deploy_api', { action: 'undeploy' }, 'Undeploying API'], - ['deploy_chat', { action: 'deploy' }, 'Deploying chat'], - ['deploy_chat', { action: 'undeploy' }, 'Undeploying chat'], - ['deploy_custom_block', { action: 'deploy' }, 'Deploying custom block'], - ['deploy_custom_block', { action: 'undeploy' }, 'Undeploying custom block'], - ['deploy_mcp', undefined, 'Deploying MCP tool'], + ['deploy_as_api', undefined, 'Deploying API'], + ['deploy_as_api', { action: 'deploy' }, 'Deploying API'], + ['deploy_as_api', { action: 'undeploy' }, 'Undeploying API'], + ['deploy_as_chat', { action: 'deploy' }, 'Deploying chat'], + ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying chat'], + ['publish_custom_block', { action: 'deploy' }, 'Deploying custom block'], + ['publish_custom_block', { action: 'undeploy' }, 'Undeploying custom block'], + ['deploy_as_mcp', undefined, 'Deploying MCP tool'], ['redeploy', undefined, 'Redeploying API'], ])('uses the action and deployment type for %s', (toolName, args, expected) => { expect(getToolDisplayTitle(toolName, args)).toBe(expected) @@ -216,19 +216,21 @@ describe('mvDisplayVerb', () => { describe('getToolDisplayTitle for the vfs verbs', () => { it('shows the created file name', () => { expect( - getToolDisplayTitle('create_file', { + getToolDisplayTitle('create_empty_file', { outputs: { files: [{ path: 'files/Reports/Quarterly%20Report.pdf', mode: 'create' }], }, }) ).toBe('Creating Quarterly Report.pdf') - expect(getToolDisplayTitle('create_file', { fileName: 'notes.md' })).toBe('Creating notes.md') + expect(getToolDisplayTitle('create_empty_file', { fileName: 'notes.md' })).toBe( + 'Creating notes.md' + ) expect( - getToolDisplayTitle('create_file', { + getToolDisplayTitle('create_empty_file', { outputs: { files: [{ path: 'files/notes.md', mode: 'overwrite' }] }, }) ).toBe('Overwriting notes.md') - expect(getToolDisplayTitle('create_file')).toBe('Creating file') + expect(getToolDisplayTitle('create_empty_file')).toBe('Creating file') }) it('titles rm from toolTitle, falling back to the paths', () => { @@ -305,7 +307,7 @@ describe('getToolDisplayTitle for managed resources', () => { }, 'Creating lookupWeather', ], - ['manage_mcp_tool', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'], + ['manage_mcp_connection', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'], ['manage_skill', { operation: 'delete', name: 'sales-research' }, 'Deleting sales-research'], [ 'manage_credential', @@ -318,7 +320,7 @@ describe('getToolDisplayTitle for managed resources', () => { ], ['rm', { paths: ['workflows/Marketing/Q3%20Campaigns'] }, 'Deleting Q3 Campaigns'], ['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'], - ['manage_mcp_tool', { operation: 'list' }, 'Viewing MCP servers'], + ['manage_mcp_connection', { operation: 'list' }, 'Viewing MCP servers'], ['manage_skill', { operation: 'list' }, 'Viewing skills'], ])('uses verb + resource name for %s', (toolName, args, expected) => { expect(getToolDisplayTitle(toolName, args)).toBe(expected) @@ -335,15 +337,15 @@ describe('getToolDisplayTitle for operation-driven tools', () => { }) it('covers every knowledge-base operation with its actual verb and resource', () => { - for (const operation of KnowledgeBaseOperationValues) { - expect(getToolDisplayTitle('knowledge_base', { operation })).not.toBe( + for (const operation of ManageKnowledgeBaseOperationValues) { + expect(getToolDisplayTitle('manage_knowledge_base', { operation })).not.toBe( 'Managing knowledge base' ) } - expect(getToolDisplayTitle('knowledge_base', { operation: 'query' })).toBe( + expect(getToolDisplayTitle('manage_knowledge_base', { operation: 'query' })).toBe( 'Searching knowledge base' ) - expect(getToolDisplayTitle('knowledge_base', { operation: 'sync_connector' })).toBe( + expect(getToolDisplayTitle('manage_knowledge_base', { operation: 'sync_connector' })).toBe( 'Syncing knowledge base connector' ) }) @@ -381,19 +383,19 @@ describe('getToolDisplayTitle for operation-driven tools', () => { }) it('distinguishes saving uploads from importing workflows', () => { - for (const operation of MaterializeFileOperationValues) { + for (const operation of SaveUploadOperationValues) { expect( - getToolDisplayTitle('materialize_file', { operation, fileNames: ['Lead Router.json'] }) + getToolDisplayTitle('save_upload', { operation, fileNames: ['Lead Router.json'] }) ).not.toBe('Preparing file') } expect( - getToolDisplayTitle('materialize_file', { + getToolDisplayTitle('save_upload', { operation: 'save', fileNames: ['Quarterly Report.pdf'], }) ).toBe('Saving Quarterly Report.pdf') expect( - getToolDisplayTitle('materialize_file', { + getToolDisplayTitle('save_upload', { operation: 'import', fileNames: ['Lead Router.json'], }) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index f15c80c5224..61a3096cb32 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -449,36 +449,37 @@ const TOOL_TITLES: Record = { table_columns: 'Editing table columns', table_automations: 'Managing table automations', table_enrichments: 'Managing table enrichments', - workspace_file: 'Editing file', - edit_content: 'Applying file content', + table_views: 'Managing table views', + prepare_file_edit: 'Editing file', + apply_file_edit: 'Applying file content', create_workflow: 'Creating workflow', edit_workflow: 'Editing workflow', - knowledge_base: 'Managing knowledge base', + manage_knowledge_base: 'Managing knowledge base', search_knowledge_base: 'Searching knowledge base', open_resource: 'Opening resource', generate_image: 'Generating image', generate_video: 'Generating video', generate_audio: 'Generating audio', ffmpeg: 'Processing media', - check_deployment_status: 'Checking deployment status', - create_file: 'Creating file', + get_deployment_status: 'Checking deployment status', + create_empty_file: 'Creating file', create_file_folder: 'Creating folder', create_workspace_mcp_server: 'Creating MCP server', delete_workspace_mcp_server: 'Deleting MCP server', - deploy_api: 'Deploying API', - deploy_chat: 'Deploying chat', - deploy_custom_block: 'Deploying custom block', - deploy_mcp: 'Deploying MCP tool', + deploy_as_api: 'Deploying API', + deploy_as_chat: 'Deploying chat', + publish_custom_block: 'Deploying custom block', + deploy_as_mcp: 'Deploying MCP tool', diff_workflows: 'Comparing workflows', - download_to_workspace_file: 'Downloading file', - function_execute: 'Running code', + download_file: 'Downloading file', + run_function: 'Running code', complete_scheduled_task: 'Completing scheduled task', generate_api_key: 'Generating API key', get_block_outputs: 'Getting block outputs', get_block_upstream_references: 'Getting block references', get_deployed_workflow_state: 'Getting deployed workflow', - get_deployment_log: 'Getting deployment logs', - get_platform_actions: 'Getting platform actions', + list_deployment_versions: 'Getting deployment logs', + get_ui_reference: 'Getting platform actions', get_scheduled_task_logs: 'Reading scheduled task logs', get_workflow_data: 'Getting workflow data', get_workflow_run_options: 'Getting run options', @@ -487,7 +488,7 @@ const TOOL_TITLES: Record = { list_user_workspaces: 'Listing workspaces', list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', - materialize_file: 'Preparing file', + save_upload: 'Preparing file', manage_sandbox: 'Managing sandbox', manage_scheduled_task: 'Managing scheduled task', move_file: 'Moving file', @@ -503,8 +504,7 @@ const TOOL_TITLES: Record = { restore_resource: 'Restoring resource', run_block: 'Running block', scheduled_task: 'Managing scheduled task', - search_documentation: 'Searching documentation', - search_patterns: 'Searching patterns', + search_sim_docs: 'Searching documentation', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', set_global_workflow_variables: 'Setting workflow variables', @@ -532,7 +532,7 @@ const TOOL_TITLES: Record = { auth: 'Auth Agent', knowledge: 'Knowledge Agent', table: 'Table Agent', - agent: 'Tools Agent', + extensions: 'Extensions Agent', research: 'Research Agent', scout: 'Scout Agent', search: 'Search Agent', @@ -676,15 +676,15 @@ export function getToolDisplayTitle(name: string, args?: Record } switch (name) { - case 'deploy_api': + case 'deploy_as_api': return deploymentTitle(args, 'API') - case 'deploy_chat': + case 'deploy_as_chat': return deploymentTitle(args, 'chat') - case 'deploy_custom_block': + case 'publish_custom_block': return deploymentTitle(args, 'custom block') case 'ffmpeg': return ffmpegTitle(args) - case 'knowledge_base': + case 'manage_knowledge_base': return knowledgeBaseTitle(args) case 'query_user_table': case 'table_manage': @@ -692,6 +692,7 @@ export function getToolDisplayTitle(name: string, args?: Record case 'table_columns': case 'table_automations': case 'table_enrichments': + case 'table_views': return queryUserTableTitle(args) case 'search_knowledge_base': return searchKnowledgeBaseTitle(args) @@ -701,7 +702,7 @@ export function getToolDisplayTitle(name: string, args?: Record return manageScheduledTaskTitle(args) case 'user_table': return userTableTitle(args) - case 'materialize_file': + case 'save_upload': return materializeFileTitle(args) case 'open_resource': return openResourceTitle(args) @@ -771,7 +772,7 @@ export function getToolDisplayTitle(name: string, args?: Record } case 'set_global_workflow_variables': return setGlobalWorkflowVariablesTitle(args) - case 'create_file': + case 'create_empty_file': return createFileTitle(args) case 'share_file': { const action = stringArg(args, 'action') || 'share' @@ -799,7 +800,7 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'serverName', 'name', 'title') return `Deleting ${target || 'MCP server'}` } - case 'search_online': { + case 'web_search': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' } @@ -838,7 +839,7 @@ export function getToolDisplayTitle(name: string, args?: Record summarizeTargets(stringArrayArg(args, 'paths').map(pathLeaf), 'resource') return target ? `Deleting ${target}` : 'Deleting' } - case 'enrichment_run': { + case 'run_enrichment': { const subject = nestedStringArg( args, 'inputs', @@ -850,7 +851,7 @@ export function getToolDisplayTitle(name: string, args?: Record ) return subject ? `Searching for ${subject}` : 'Searching' } - case 'scrape_page': { + case 'web_scrape': { const url = stringArg(args, 'url') return url ? `Scraping ${url}` : 'Scraping page' } @@ -886,11 +887,11 @@ export function getToolDisplayTitle(name: string, args?: Record const reason = stringArg(args, 'reason') return reason ? `Waiting for you: ${reason}` : 'Waiting for you in the browser' } - case 'crawl_website': { + case 'web_crawl': { const url = stringArg(args, 'url') return url ? `Crawling ${url}` : 'Crawling website' } - case 'get_page_contents': { + case 'web_fetch': { const urls = stringArrayArg(args, 'urls') if (urls.length === 1) return `Getting ${urls[0]}` if (urls.length > 1) return `Getting ${urls.length} pages` @@ -910,7 +911,7 @@ export function getToolDisplayTitle(name: string, args?: Record list: { verb: 'Viewing', resource: 'custom tools' }, }) } - case 'manage_mcp_tool': { + case 'manage_mcp_connection': { const target = firstStringArg(args, 'serverName', 'name', 'title') || nestedStringArg(args, 'config', 'name') @@ -957,9 +958,10 @@ export function getToolDisplayTitle(name: string, args?: Record } break } - case 'workspace_file': - case 'function_execute': { - const title = name === 'workspace_file' ? workspaceFileTitle(args) : stringArg(args, 'title') + case 'prepare_file_edit': + case 'run_function': { + const title = + name === 'prepare_file_edit' ? workspaceFileTitle(args) : stringArg(args, 'title') if (title) return title break } diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 0c3dd20a16a..6db6bc0319c 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -146,7 +146,7 @@ export async function writeWorkspaceFileByPath(args: { /** * Forwarded to {@link updateWorkspaceFileContent} on an overwrite. Defaults to `true` (stream a * markdown overwrite into any open collaborative editor). Pass `false` for a write whose content is - * only a placeholder — e.g. `create_file`'s empty shell, whose real content lands via a later write. + * only a placeholder — e.g. `create_empty_file`'s empty shell, whose real content lands via a later write. */ syncLiveDoc?: boolean /** Private provenance for the exact bytes being written. */ diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0fc76e89926..cb5348a061d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1221,3 +1221,38 @@ export function serializeTriggerOverview( lines.push('') return lines.join('\n') } + +/** + * tables/{name}/views.json — the table's saved views in the column-NAME + * domain agents speak (stored configs are id-keyed; the caller translates). + * Layout-only fields (order, widths, pinned) are omitted: they are UI + * concerns and never change which rows a view selects. + */ +export function serializeTableViews( + views: Array<{ + id: string + name: string + isDefault: boolean + filter?: unknown + sort?: unknown + hiddenColumns?: string[] + updatedAt: Date | string + }> +): string { + return JSON.stringify( + { + views: views.map((view) => ({ + id: view.id, + name: view.name, + isDefault: view.isDefault, + filter: view.filter ?? null, + sort: view.sort ?? null, + hiddenColumns: view.hiddenColumns?.length ? view.hiddenColumns : undefined, + updatedAt: view.updatedAt instanceof Date ? view.updatedAt.toISOString() : view.updatedAt, + })), + note: 'Query a view via query_user_table {operation: "query_rows", args: {tableId, view: ""}} — the saved filter ANDs with any extra filter you pass. Manage views via the table agent (table_views).', + }, + null, + 2 + ) +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 96ca9331725..f7ccc552e82 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -87,6 +87,7 @@ import { serializeSandboxCatalog, serializeSkill, serializeTableMeta, + serializeTableViews, serializeTriggerOverview, serializeTriggerSchema, serializeVersions, @@ -125,6 +126,12 @@ import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { listTables } from '@/lib/table/service' +import { + listTableViewsByWorkspace, + normalizeStoredViewConfig, + pruneViewConfig, + viewConfigIdsToNames, +} from '@/lib/table/views/service' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { @@ -1941,15 +1948,43 @@ export class WorkspaceVFS { */ private async materializeTables(workspaceId: string): Promise { try { - const [tables, folderPaths] = await Promise.all([ + const [tables, folderPaths, viewsByTable] = await Promise.all([ listTables(workspaceId), this.registerResourceFolders(workspaceId, 'table', 'tables'), + listTableViewsByWorkspace(workspaceId), ]) for (const table of tables) { const safeName = sanitizeName(table.name) const folderPath = table.folderId ? folderPaths.get(table.folderId) : undefined const prefix = folderPath ? `tables/${folderPath}/${safeName}` : `tables/${safeName}` + const viewRows = viewsByTable.get(table.id) ?? [] + if (viewRows.length > 0) { + const columns = table.schema.columns + this.files.set( + `${prefix}/views.json`, + serializeTableViews( + viewRows.map((row) => { + const config = viewConfigIdsToNames( + pruneViewConfig( + normalizeStoredViewConfig(row.config as Record), + columns + ), + columns + ) + return { + id: row.id, + name: row.name, + isDefault: row.isDefault, + filter: config.filter ?? null, + sort: config.sort ?? null, + hiddenColumns: config.hiddenColumns, + updatedAt: row.updatedAt, + } + }) + ) + ) + } this.files.set( `${prefix}/meta.json`, serializeTableMeta({ diff --git a/apps/sim/lib/folders/application/resource-vfs.ts b/apps/sim/lib/folders/application/resource-vfs.ts index 8c997fa7f19d5505692b5757a935e207dd50d1a4..00afc85937eb91839e923afdf233a471dc2e4ca4 100644 GIT binary patch delta 130 zcmaD-^s{J#tpsmbVoqjCVo7Fxp1Ka#gnYj?co6BT`Igt4iRE{ws^JCS- c*pc~Fx-%2%{H$$aYZrnw|OQb0NUO%9RL6T delta 109 zcmexa^rUEmtprzQURh#JW{SEF*W?K%hMTJ;zB98!#WvT-2y?(WGgXc;!a130V(c)^ a { expect(await getTableView('view-elsewhere', 'table-1', columns)).toBeNull() }) }) + +describe('view config name/id translation', () => { + const columns = [ + { id: 'col_a', name: 'status', type: 'string' }, + { id: 'col_b', name: 'due', type: 'date' }, + ] as never[] + + it('round-trips a config between id and name domains', async () => { + const { viewConfigIdsToNames, viewConfigNamesToIds } = await import('@/lib/table/views/service') + const stored = { + filter: { + any: [ + { field: 'col_a', op: 'eq', value: 'Open' }, + { all: [{ field: 'col_b', op: 'isNotNull' }] }, + ], + }, + sort: [{ field: 'col_b', direction: 'desc' }], + hiddenColumns: ['col_a'], + } as never + const named = viewConfigIdsToNames(stored, columns as never) + expect(named.filter).toEqual({ + any: [ + { field: 'status', op: 'eq', value: 'Open' }, + { all: [{ field: 'due', op: 'isNotNull' }] }, + ], + }) + expect(named.sort).toEqual([{ field: 'due', direction: 'desc' }]) + expect(named.hiddenColumns).toEqual(['status']) + expect(viewConfigNamesToIds(named, columns as never)).toEqual(stored) + }) + + it('passes stale ids through on read but rejects unknown names on write', async () => { + const { viewConfigIdsToNames, viewConfigNamesToIds } = await import('@/lib/table/views/service') + const withStale = { filter: { all: [{ field: 'col_gone', op: 'isNull' }] } } as never + expect( + ( + viewConfigIdsToNames(withStale, columns as never).filter as never as { + all: { field: string }[] + } + ).all[0].field + ).toBe('col_gone') + expect(() => + viewConfigNamesToIds( + { filter: { all: [{ field: 'nope', op: 'isNull' }] } } as never, + columns as never + ) + ).toThrow(/Unknown column/) + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index de0c8c24118..b75b36de916 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -336,3 +336,85 @@ export async function deleteTableView( } return deleted.length > 0 } + +/** + * All of a workspace's views in one query, keyed by tableId — the snapshot + * materializer's shape (per-table listTableViews would be N queries). Configs + * are returned RAW (id-domain, unpruned); callers translate/prune with each + * table's own columns. + */ +export async function listTableViewsByWorkspace( + workspaceId: string +): Promise>> { + const rows = await db + .select() + .from(tableViews) + .where(eq(tableViews.workspaceId, workspaceId)) + .orderBy(asc(tableViews.createdAt), asc(tableViews.id)) + const byTable = new Map>() + for (const row of rows) { + const list = byTable.get(row.tableId) ?? [] + list.push(row) + byTable.set(row.tableId, list) + } + return byTable +} + +function mapPredicateFields( + node: PredicateNode, + mapField: (field: string) => string +): PredicateNode { + if ('all' in node) return { all: node.all.map((child) => mapPredicateFields(child, mapField)) } + if ('any' in node) return { any: node.any.map((child) => mapPredicateFields(child, mapField)) } + const leaf = node as Predicate + return { ...leaf, field: mapField(leaf.field) } +} + +/** + * Stored (id-domain) view config → the column-NAME domain agents speak. + * Unknown ids pass through unchanged, mirroring pruneViewConfig's philosophy + * for filters: surfacing a stale reference beats silently widening the view. + */ +export function viewConfigIdsToNames( + config: TableViewConfig, + columns: ColumnDefinition[] +): TableViewConfig { + const nameById = new Map(columns.map((col) => [getColumnId(col), col.name])) + const toName = (field: string) => nameById.get(field) ?? field + const out: TableViewConfig = { ...config } + if (config.filter) out.filter = mapPredicateFields(config.filter, toName) as typeof config.filter + if (config.sort) out.sort = config.sort.map((s) => ({ ...s, field: toName(s.field) })) + if (config.hiddenColumns) out.hiddenColumns = config.hiddenColumns.map(toName) + return out +} + +/** + * Agent-supplied (name-domain) view config → the id-domain stored shape. + * Unknown column names are an error — a saved view with a dangling reference + * is exactly the artifact this translation exists to prevent. + */ +export function viewConfigNamesToIds( + config: TableViewConfig, + columns: ColumnDefinition[] +): TableViewConfig { + const idByName = new Map(columns.map((col) => [col.name, getColumnId(col)])) + const unknown = new Set() + const toId = (field: string) => { + const id = idByName.get(field) + if (!id) { + unknown.add(field) + return field + } + return id + } + const out: TableViewConfig = { ...config } + if (config.filter) out.filter = mapPredicateFields(config.filter, toId) as typeof config.filter + if (config.sort) out.sort = config.sort.map((s) => ({ ...s, field: toId(s.field) })) + if (config.hiddenColumns) out.hiddenColumns = config.hiddenColumns.map(toId) + if (unknown.size > 0) { + throw new TableViewValidationError( + `Unknown column(s): ${[...unknown].join(', ')}. Use exact column names from get_schema.` + ) + } + return out +} diff --git a/apps/sim/lib/uploads/archive.test.ts b/apps/sim/lib/uploads/archive.test.ts index 91fe18f2de0..a8484f0049f 100644 --- a/apps/sim/lib/uploads/archive.test.ts +++ b/apps/sim/lib/uploads/archive.test.ts @@ -485,7 +485,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { }) it('rolls back the folders it created when an upload fails mid-extraction', async () => { - // `materialize_file` refuses to re-extract into a root folder that still has any + // `save_upload` refuses to re-extract into a root folder that still has any // child, so a folder left behind by a failed run turns every retry into // "already extracted" until a human deletes the tree by hand. const buffer = await buildZip({ 'a/one.txt': 'first', 'b/two.txt': 'second' }) diff --git a/apps/sim/lib/uploads/archive.ts b/apps/sim/lib/uploads/archive.ts index 561d48330f0..b6894fc27bc 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -350,7 +350,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( // Uploads themselves can still fail mid-loop (storage/DB errors, quota crossed // by another writer), so a failure rolls back every file written so far *and* // every folder this call materialized — callers and their retries must never - // observe a partial tree. Leftover folders are not cosmetic: `materialize_file` + // observe a partial tree. Leftover folders are not cosmetic: `save_upload` // refuses to re-extract into a root folder that still has any child, so a // half-extracted tree would make every retry fail until a human deletes it. const folderIdCache = new Map() diff --git a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts index 50d15712462..24750a15f7c 100644 --- a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts @@ -417,7 +417,7 @@ describe('trackChatUpload', () => { /** * The ownership lookup and the write are separate statements, so a - * concurrent `materialize_file` can flip the row to context='workspace' + * concurrent `save_upload` can flip the row to context='workspace' * in between. The UPDATE must re-assert every ownership predicate rather * than matching on the captured row id alone, or it would drag a saved * workspace file back into chat scope. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index cc83cc5e98d..ebadf170bca 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -930,7 +930,7 @@ export async function trackChatUpload( if (updated.length === 0) { // The ownership lookup is a separate statement, so re-assert every // predicate here — this UPDATE is the atomic check. A concurrent - // `materialize_file` flips the same row to context='workspace' and + // `save_upload` flips the same row to context='workspace' and // clears chatId; matching on id alone would drag that saved file back // into chat scope, hiding it from the Files listing and re-exposing it // to the chat-delete cascade. diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 6d04e92c7ef..89324ad340c 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -294,7 +294,7 @@ export function isArchiveFileName(filename: string): boolean { * `files/`, so this points at the explicit one-time extract step. */ export function buildArchiveExtractGuidance(name: string): string { - return `"${name}" is a .zip archive — its contents can't be read directly. Extract it once with materialize_file(fileNames: ["${name}"], operation: "extract"), then read the unpacked files under files/ (e.g. glob("files//**") then read("files///content")).` + return `"${name}" is a .zip archive — its contents can't be read directly. Extract it once with save_upload(fileNames: ["${name}"], operation: "extract"), then read the unpacked files under files/ (e.g. glob("files//**") then read("files///content")).` } const EXTENSION_TO_MIME: Record = { diff --git a/apps/sim/lib/uploads/utils/validation.ts b/apps/sim/lib/uploads/utils/validation.ts index b400fa621a1..8729908061f 100644 --- a/apps/sim/lib/uploads/utils/validation.ts +++ b/apps/sim/lib/uploads/utils/validation.ts @@ -97,7 +97,7 @@ export const SUPPORTED_VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm'] a /** * Archive formats accepted as chat attachments. A `.zip` is stored once in - * uploads/; the agent must extract it (materialize_file operation "extract") to + * uploads/; the agent must extract it (save_upload operation "extract") to * decompress it into workspace files/ before reading its contents. */ export const SUPPORTED_ARCHIVE_EXTENSIONS = ['zip'] as const @@ -229,7 +229,7 @@ export const CHAT_ACCEPT_ATTRIBUTE = [ /** * Accept attribute for the mothership copilot input only. Archives are scoped * here — NOT in {@link CHAT_ACCEPT_ATTRIBUTE} — because only the copilot flow - * has zip handling (materialize_file "extract"); a zip picked in a workflow or + * has zip handling (save_upload "extract"); a zip picked in a workflow or * deployed chat would flow into execution, where no parser exists. */ export const MOTHERSHIP_ACCEPT_ATTRIBUTE = [ diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 4056e4db2c4..9df7d48812a 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -244,7 +244,7 @@ export async function listCustomBlocksWithInputs( /** * The custom block bound to a workflow (with live-derived input fields), or `null` * when the workflow isn't published as a block. One block per workflow is enforced - * at publish time. Used by the copilot deploy_custom_block tool. + * at publish time. Used by the copilot publish_custom_block tool. */ export async function getCustomBlockWithInputsByWorkflowId( workflowId: string diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts index 098305930de..612ae65b3ee 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -46,7 +46,7 @@ describe('performChatDeploy password guards', () => { }) /** - * The copilot `deploy_chat` tool reaches this function without a route + * The copilot `deploy_as_chat` tool reaches this function without a route * contract, so these guards are the only thing standing between an agent and * a deployment nobody can log into. */ diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 6a28925145b..f4388208a89 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -60,7 +60,7 @@ export interface PerformChatDeployResult { * Deploys a chat: deploys the underlying workflow via `performFullDeploy`, * encrypts passwords, creates or updates the chat record, fires telemetry, * and records an audit entry. Both the chat API route and the copilot - * `deploy_chat` tool must use this function. + * `deploy_as_chat` tool must use this function. */ export async function performChatDeploy( params: ChatDeployPayload @@ -81,7 +81,7 @@ export async function performChatDeploy( /** * Validate the password here rather than only at the HTTP boundary. The - * copilot `deploy_chat` tool reaches this function without going through a + * copilot `deploy_as_chat` tool reaches this function without going through a * route contract, so a whitespace-only or over-long password would otherwise * be encrypted and stored — and neither can ever be submitted through the * chat login form, permanently locking visitors out of the deployment. @@ -171,7 +171,7 @@ export async function performChatDeploy( /** * A password-protected chat must end up with a stored password. Both HTTP * routes already reject this; without the same guard here a copilot - * `deploy_chat` call could create one with no password, which fails closed at + * `deploy_as_chat` call could create one with no password, which fails closed at * login with an opaque "Authentication configuration error". */ if (authType === 'password' && !encryptedPassword && !existingDeployment?.password) { @@ -309,7 +309,7 @@ export interface PerformChatUndeployResult { /** * Undeploys a chat: deletes the chat record and records an audit entry. - * Both the chat manage DELETE route and the copilot `deploy_chat` undeploy + * Both the chat manage DELETE route and the copilot `deploy_as_chat` undeploy * action must use this function. */ export async function performChatUndeploy( From 733e5220f23ade755a55f421b4e75307b9afcfb5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 13:33:41 -0700 Subject: [PATCH 045/103] Expand workflow log query support --- apps/sim/lib/api/contracts/logs.ts | 4 + .../lib/copilot/generated/tool-catalog-v1.ts | 61 +++-- .../lib/copilot/generated/tool-schemas-v1.ts | 62 +++-- .../tools/server/workflow/query-logs.test.ts | 108 +++++++-- .../tools/server/workflow/query-logs.ts | 110 +++++++-- .../lib/copilot/tools/tool-display.test.ts | 4 +- apps/sim/lib/copilot/tools/tool-display.ts | 40 +++- apps/sim/lib/logs/list-logs.ts | 33 +++ apps/sim/lib/logs/log-views.test.ts | 73 +++++- apps/sim/lib/logs/log-views.ts | 117 +++++++++- apps/sim/lib/logs/stats-logs.ts | 214 ++++++++++++++++++ 11 files changed, 753 insertions(+), 73 deletions(-) create mode 100644 apps/sim/lib/logs/stats-logs.ts diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index b269d8c75f5..71677a738cb 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -38,6 +38,8 @@ export const listLogsQuerySchema = logFilterQuerySchema.extend({ limit: z.coerce.number().int().min(1).max(200).optional().default(100), sortBy: logSortBySchema, sortOrder: logSortOrderSchema, + /** Also run a COUNT(*) under the same filters and return it as `total`. */ + includeTotal: z.coerce.boolean().optional(), }) export const logDetailQuerySchema = z.object({ @@ -294,6 +296,8 @@ export type WorkflowLogRow = WorkflowLogSummary & export const listLogsResponseSchema = z.object({ data: z.array(workflowLogSummarySchema), nextCursor: z.string().nullable(), + /** Total rows matching the filters; present only when `includeTotal` was set. */ + total: z.number().optional(), }) export type ListLogsResponse = z.output diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 82ec12ead2a..9ba13a5aab6 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3478,7 +3478,7 @@ export const OpenResource: ToolCatalogEntry = { view: { type: 'string', description: - 'Saved table view to open pinned (type "table" only): a view id or exact view name from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', + 'Saved table view to open pinned (type "table" only): a view ID from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', }, }, required: ['type'], @@ -3760,10 +3760,22 @@ export const QueryLogs: ToolCatalogEntry = { type: 'string', description: "Optional (view='full'): only return this block's span subtree.", }, + blockIds: { + type: 'array', + description: + "(view='full') Block ids to drill into, copied from the trace digest's blockId values. Preferred over blockName; several at once is fine.", + items: { type: 'string' }, + }, blockName: { type: 'string', description: "Optional (view='full'): only return spans for this block name.", }, + bucket: { + type: 'string', + description: + "(view='stats') Calendar bucketing for the per-workflow series: 'day' or 'hour'. Omit for overall totals only.", + enum: ['day', 'hour'], + }, costOperator: { type: 'string', description: "Filter (view='list'): comparison operator for cost.", @@ -3786,24 +3798,34 @@ export const QueryLogs: ToolCatalogEntry = { type: 'number', description: "Filter (view='list'): duration threshold (ms) paired with durationOperator.", }, - endDate: { type: 'string', description: "Filter (view='list'): ISO end of the time range." }, + endDate: { + type: 'string', + description: "Filter (view='list'/'stats'): ISO end of the time range.", + }, executionId: { type: 'string', description: - "Required for 'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + "Required for 'trace'/'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + }, + fields: { + type: 'array', + description: + "(view='full') Only load these payload fields per span: whole keys ('input', 'output', 'error') or dotted paths into them ('output.result.rows', 'input.query'). Dotted selections come back under 'selected' keyed by path. Use this to pull just the field you need instead of a block's entire I/O.", + items: { type: 'string' }, }, folderIds: { type: 'string', - description: "Filter (view='list'): comma-separated folder IDs (descendants included).", + description: + "Filter (view='list'/'stats'): comma-separated folder IDs (descendants included).", }, folderName: { type: 'string', - description: "Filter (view='list'): substring match on folder name.", + description: "Filter (view='list'/'stats'): substring match on folder name.", }, level: { type: 'string', description: - "Filter (view='list'): comma-separated levels: error, info, running, pending. Default all.", + "Filter (view='list'/'stats'): comma-separated levels: error, info, running, pending. Default all.", }, limit: { type: 'number', description: "Max results (view='list'), 1-200 (default 100)." }, pattern: { @@ -3827,29 +3849,38 @@ export const QueryLogs: ToolCatalogEntry = { }, startDate: { type: 'string', - description: "Filter (view='list'): ISO start of the time range.", + description: "Filter (view='list'/'stats'): ISO start of the time range.", + }, + timezone: { + type: 'string', + description: + '(view=\'stats\') IANA timezone the buckets are computed in, e.g. "America/Los_Angeles". Defaults to UTC. Set this whenever the user\'s question is about "today"/"yesterday" in their local time.', + }, + title: { + type: 'string', + description: + 'Short human-readable label for this query, shown as the tool row in the UI, e.g. "Counting Elder failures Aug 12-13" or "Reading the failed enrichment run". Always provide one — it is how the user follows what you are looking for.', }, triggers: { type: 'string', - description: "Filter (view='list'): comma-separated trigger types.", + description: "Filter (view='list'/'stats'): comma-separated trigger types.", }, view: { type: 'string', description: - "Disclosure level: 'list' (summaries), 'overview' (one execution's trace tree, no I/O), or 'full' (one execution's trace spans with I/O).", - enum: ['list', 'overview', 'full'], + "Disclosure level: 'stats' (aggregate counts), 'list' (summaries), 'trace' (one execution's condensed block digest), 'overview' (trace tree, no I/O), 'full' (spans with I/O). Defaults to 'trace' with executionId, else 'list'.", + enum: ['list', 'stats', 'trace', 'overview', 'full'], }, workflowIds: { type: 'string', - description: "Filter (view='list'): comma-separated workflow IDs.", + description: "Filter (view='list'/'stats'): comma-separated workflow IDs.", }, workflowName: { type: 'string', - description: "Filter (view='list'): substring match on workflow name.", + description: "Filter (view='list'/'stats'): substring match on workflow name.", }, workspaceId: { type: 'string', description: 'Workspace ID to scope to.' }, }, - required: ['view'], }, } @@ -3890,7 +3921,7 @@ export const QueryUserTable: ToolCatalogEntry = { view: { type: 'string', description: - "Saved view to query through (query_rows only): a view id or exact view name from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", + "Saved view to query through (query_rows only): a view ID from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", }, }, }, @@ -5437,7 +5468,7 @@ export const TableViews: ToolCatalogEntry = { name: { type: 'string', description: - "View display name (required for create_view; optional rename on update_view). Free-form label, need not be unique — prefer distinct names so query_user_table's view argument can use them unambiguously.", + 'View display name (required for create_view; optional rename on update_view). Free-form label; references always use the view ID, so names are purely display.', }, sort: { type: 'array', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 50d9fe521a5..3bebb7cd523 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3321,7 +3321,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { view: { type: 'string', description: - 'Saved table view to open pinned (type "table" only): a view id or exact view name from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', + 'Saved table view to open pinned (type "table" only): a view ID from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', }, }, required: ['type'], @@ -3609,10 +3609,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: "Optional (view='full'): only return this block's span subtree.", }, + blockIds: { + type: 'array', + description: + "(view='full') Block ids to drill into, copied from the trace digest's blockId values. Preferred over blockName; several at once is fine.", + items: { + type: 'string', + }, + }, blockName: { type: 'string', description: "Optional (view='full'): only return spans for this block name.", }, + bucket: { + type: 'string', + description: + "(view='stats') Calendar bucketing for the per-workflow series: 'day' or 'hour'. Omit for overall totals only.", + enum: ['day', 'hour'], + }, costOperator: { type: 'string', description: "Filter (view='list'): comparison operator for cost.", @@ -3638,25 +3652,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, endDate: { type: 'string', - description: "Filter (view='list'): ISO end of the time range.", + description: "Filter (view='list'/'stats'): ISO end of the time range.", }, executionId: { type: 'string', description: - "Required for 'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + "Required for 'trace'/'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + }, + fields: { + type: 'array', + description: + "(view='full') Only load these payload fields per span: whole keys ('input', 'output', 'error') or dotted paths into them ('output.result.rows', 'input.query'). Dotted selections come back under 'selected' keyed by path. Use this to pull just the field you need instead of a block's entire I/O.", + items: { + type: 'string', + }, }, folderIds: { type: 'string', - description: "Filter (view='list'): comma-separated folder IDs (descendants included).", + description: + "Filter (view='list'/'stats'): comma-separated folder IDs (descendants included).", }, folderName: { type: 'string', - description: "Filter (view='list'): substring match on folder name.", + description: "Filter (view='list'/'stats'): substring match on folder name.", }, level: { type: 'string', description: - "Filter (view='list'): comma-separated levels: error, info, running, pending. Default all.", + "Filter (view='list'/'stats'): comma-separated levels: error, info, running, pending. Default all.", }, limit: { type: 'number', @@ -3683,32 +3706,41 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, startDate: { type: 'string', - description: "Filter (view='list'): ISO start of the time range.", + description: "Filter (view='list'/'stats'): ISO start of the time range.", + }, + timezone: { + type: 'string', + description: + '(view=\'stats\') IANA timezone the buckets are computed in, e.g. "America/Los_Angeles". Defaults to UTC. Set this whenever the user\'s question is about "today"/"yesterday" in their local time.', + }, + title: { + type: 'string', + description: + 'Short human-readable label for this query, shown as the tool row in the UI, e.g. "Counting Elder failures Aug 12-13" or "Reading the failed enrichment run". Always provide one — it is how the user follows what you are looking for.', }, triggers: { type: 'string', - description: "Filter (view='list'): comma-separated trigger types.", + description: "Filter (view='list'/'stats'): comma-separated trigger types.", }, view: { type: 'string', description: - "Disclosure level: 'list' (summaries), 'overview' (one execution's trace tree, no I/O), or 'full' (one execution's trace spans with I/O).", - enum: ['list', 'overview', 'full'], + "Disclosure level: 'stats' (aggregate counts), 'list' (summaries), 'trace' (one execution's condensed block digest), 'overview' (trace tree, no I/O), 'full' (spans with I/O). Defaults to 'trace' with executionId, else 'list'.", + enum: ['list', 'stats', 'trace', 'overview', 'full'], }, workflowIds: { type: 'string', - description: "Filter (view='list'): comma-separated workflow IDs.", + description: "Filter (view='list'/'stats'): comma-separated workflow IDs.", }, workflowName: { type: 'string', - description: "Filter (view='list'): substring match on workflow name.", + description: "Filter (view='list'/'stats'): substring match on workflow name.", }, workspaceId: { type: 'string', description: 'Workspace ID to scope to.', }, }, - required: ['view'], }, resultSchema: undefined, }, @@ -3751,7 +3783,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { view: { type: 'string', description: - "Saved view to query through (query_rows only): a view id or exact view name from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", + "Saved view to query through (query_rows only): a view ID from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", }, }, }, @@ -5338,7 +5370,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { name: { type: 'string', description: - "View display name (required for create_view; optional rename on update_view). Free-form label, need not be unique — prefer distinct names so query_user_table's view argument can use them unambiguously.", + 'View display name (required for create_view; optional rename on update_view). Free-form label; references always use the view ID, so names are purely display.', }, sort: { type: 'array', diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts index 6f195ac4a30..d4c5541ab29 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts @@ -4,21 +4,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listLogsMock, fetchLogDetailMock, toOverviewMock, toFullMock, grepSpansMock } = vi.hoisted( - () => ({ - listLogsMock: vi.fn(), - fetchLogDetailMock: vi.fn(), - toOverviewMock: vi.fn(), - toFullMock: vi.fn(), - grepSpansMock: vi.fn(), - }) -) +const { + listLogsMock, + statsLogsMock, + fetchLogDetailMock, + toOverviewMock, + toFullMock, + toTraceMock, + grepSpansMock, +} = vi.hoisted(() => ({ + listLogsMock: vi.fn(), + statsLogsMock: vi.fn(), + fetchLogDetailMock: vi.fn(), + toOverviewMock: vi.fn(), + toFullMock: vi.fn(), + toTraceMock: vi.fn(), + grepSpansMock: vi.fn(), +})) vi.mock('@/lib/logs/list-logs', () => ({ listLogs: listLogsMock })) +vi.mock('@/lib/logs/stats-logs', () => ({ statsLogs: statsLogsMock })) vi.mock('@/lib/logs/fetch-log-detail', () => ({ fetchLogDetail: fetchLogDetailMock })) vi.mock('@/lib/logs/log-views', () => ({ toOverview: toOverviewMock, toFull: toFullMock, + toTrace: toTraceMock, grepSpans: grepSpansMock, })) vi.mock('@/lib/execution/payloads/large-execution-value', () => ({ @@ -47,8 +57,8 @@ beforeEach(() => { }) describe('queryLogsServerTool', () => { - it('list view delegates to listLogs with workspaceId and no view field', async () => { - listLogsMock.mockResolvedValue({ data: [{ id: 'log-1' }], nextCursor: null }) + it('list view delegates to listLogs and leads with total and cursor', async () => { + listLogsMock.mockResolvedValue({ data: [{ id: 'log-1' }], nextCursor: null, total: 42 }) const result = await queryLogsServerTool.execute( { view: 'list', sortBy: 'date', sortOrder: 'desc', limit: 100 } as any, @@ -59,8 +69,68 @@ describe('queryLogsServerTool', () => { const [params, userId] = listLogsMock.mock.calls[0] expect(userId).toBe('user-1') expect(params.workspaceId).toBe('ws-1') + expect(params.includeTotal).toBe(true) expect(params).not.toHaveProperty('view') - expect(result).toEqual({ data: [{ id: 'log-1' }], nextCursor: null }) + expect(params).not.toHaveProperty('title') + expect(result).toEqual({ total: 42, nextCursor: null, data: [{ id: 'log-1' }] }) + expect(Object.keys(result as object)).toEqual(['total', 'nextCursor', 'data']) + }) + + it('stats view delegates to statsLogs with the workspace scoped in', async () => { + statsLogsMock.mockResolvedValue({ totals: { executions: 7 } }) + + const result = await queryLogsServerTool.execute( + { view: 'stats', bucket: 'day', timezone: 'UTC', workflowIds: 'wf-1' } as any, + ctx + ) + + expect(statsLogsMock).toHaveBeenCalledTimes(1) + const [params, userId] = statsLogsMock.mock.calls[0] + expect(userId).toBe('user-1') + expect(params).toMatchObject({ workspaceId: 'ws-1', bucket: 'day', workflowIds: 'wf-1' }) + expect(result).toEqual({ totals: { executions: 7 } }) + }) + + it('defaults to the condensed trace digest when only an executionId is given', async () => { + fetchLogDetailMock.mockResolvedValue(detail()) + toTraceMock.mockReturnValue([{ blockId: 'blk-1', name: 'Agent', executions: 3 }]) + + const result: any = await queryLogsServerTool.execute({ executionId: 'exec-1' } as any, ctx) + + expect(toTraceMock).toHaveBeenCalledTimes(1) + expect(result.blocks).toEqual([{ blockId: 'blk-1', name: 'Agent', executions: 3 }]) + expect(toOverviewMock).not.toHaveBeenCalled() + expect(toFullMock).not.toHaveBeenCalled() + }) + + it('defaults to list when no executionId is given', async () => { + listLogsMock.mockResolvedValue({ data: [], nextCursor: null, total: 0 }) + + await queryLogsServerTool.execute({} as any, ctx) + + expect(listLogsMock).toHaveBeenCalledTimes(1) + }) + + it('passes blockIds and fields through to toFull', async () => { + fetchLogDetailMock.mockResolvedValue(detail()) + toFullMock.mockResolvedValue([{ id: 's1' }]) + + await queryLogsServerTool.execute( + { + view: 'full', + executionId: 'exec-1', + blockIds: ['blk-1', 'blk-2'], + fields: ['output.rows'], + } as any, + ctx + ) + + expect(toFullMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { blockId: undefined, blockIds: ['blk-1', 'blk-2'], blockName: undefined }, + ['output.rows'] + ) }) it('overview view returns the projected span tree', async () => { @@ -87,10 +157,16 @@ describe('queryLogsServerTool', () => { ctx ) - expect(toFullMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), { - blockId: 'blk-1', - blockName: undefined, - }) + expect(toFullMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + blockId: 'blk-1', + blockIds: undefined, + blockName: undefined, + }, + undefined + ) expect(result.spans).toEqual([{ id: 's1', input: { a: 1 } }]) expect(result.truncated).toBe(false) }) 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..a60e2711404 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -8,7 +8,8 @@ import { } from '@/lib/execution/payloads/large-execution-value' import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' import { type ListLogsParams, listLogs } from '@/lib/logs/list-logs' -import { grepSpans, type LogViewContext, toFull, toOverview } from '@/lib/logs/log-views' +import { grepSpans, type LogViewContext, toFull, toOverview, toTrace } from '@/lib/logs/log-views' +import { statsLogs } from '@/lib/logs/stats-logs' import type { TraceSpan } from '@/lib/logs/types' const logger = createLogger('QueryLogsServerTool') @@ -21,8 +22,12 @@ const MAX_FULL_RESULT_BYTES = 512 * 1024 const comparisonOperator = z.enum(['=', '>', '<', '>=', '<=', '!=']) +/** Display-only label rendered in the UI tool row; never used server-side. */ +const displayTitle = z.string().optional() + const listArgsSchema = z.object({ view: z.literal('list'), + title: displayTitle, workspaceId: z.string().optional(), level: z.string().optional(), workflowIds: z.string().optional(), @@ -44,8 +49,33 @@ const listArgsSchema = z.object({ sortOrder: z.enum(['asc', 'desc']).optional().default('desc'), }) +const statsArgsSchema = z.object({ + view: z.literal('stats'), + title: displayTitle, + workspaceId: z.string().optional(), + level: z.string().optional(), + workflowIds: z.string().optional(), + folderIds: z.string().optional(), + triggers: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + search: z.string().optional(), + workflowName: z.string().optional(), + folderName: z.string().optional(), + bucket: z.enum(['day', 'hour']).optional(), + timezone: z.string().optional(), +}) + +const traceArgsSchema = z.object({ + view: z.literal('trace'), + title: displayTitle, + workspaceId: z.string().optional(), + executionId: z.string(), +}) + const overviewArgsSchema = z.object({ view: z.literal('overview'), + title: displayTitle, workspaceId: z.string().optional(), executionId: z.string(), pattern: z.string().optional(), @@ -53,19 +83,38 @@ const overviewArgsSchema = z.object({ const fullArgsSchema = z.object({ view: z.literal('full'), + title: displayTitle, workspaceId: z.string().optional(), executionId: z.string(), blockId: z.string().optional(), + blockIds: z.array(z.string()).optional(), blockName: z.string().optional(), + fields: z.array(z.string()).optional(), pattern: z.string().optional(), }) -const queryLogsArgsSchema = z.discriminatedUnion('view', [ +const queryLogsViewsSchema = z.discriminatedUnion('view', [ listArgsSchema, + statsArgsSchema, + traceArgsSchema, overviewArgsSchema, fullArgsSchema, ]) +/** + * `view` defaults to the compact disclosure level: `trace` when an + * `executionId` is supplied, `list` otherwise. + */ +const queryLogsArgsSchema = z.preprocess((value) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const record = value as Record + if (record.view === undefined) { + return { ...record, view: record.executionId ? 'trace' : 'list' } + } + } + return value +}, queryLogsViewsSchema) + type QueryLogsArgs = z.infer function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string { @@ -100,11 +149,17 @@ function buildLogViewContext( * Consolidated execution/log read tool. * * - `view: "list"` — paginated execution summaries with the full Logs-UI filter - * set (reuses `listLogs`). + * set (reuses `listLogs`); always carries `total` for the filtered set. + * - `view: "stats"` — server-side aggregation (counts by status, per workflow, + * optionally calendar-bucketed) under the same filters; answers quantitative + * questions in one call instead of a paginate-and-count walk. + * - `view: "trace"` — one execution's condensed per-block digest: names, + * statuses, execution counts (loop iterations collapse), block ids to drill + * into. * - `view: "overview"` — a single execution's trace-span tree (timing + cost, * no input/output). * - `view: "full"` — a single execution's trace spans with materialized - * input/output, optionally scoped to one block via `blockId`/`blockName`. + * input/output, scoped via `blockIds` (from the trace digest) / `blockName`. * - `pattern` (with `overview`/`full`) — grep that execution's trace spans, * streaming large values chunk-by-chunk. */ @@ -112,7 +167,10 @@ export const queryLogsServerTool: BaseServerTool = { name: QueryLogs.id, inputSchema: queryLogsArgsSchema, outputSchema: z.unknown(), - async execute(args: QueryLogsArgs, context?: ServerToolContext): Promise { + async execute(rawArgs: QueryLogsArgs, context?: ServerToolContext): Promise { + // Re-parse so the compact-view default applies even when a caller bypasses + // the router's schema validation; idempotent on already-parsed args. + const args = queryLogsArgsSchema.parse(rawArgs) as QueryLogsArgs if (!context?.userId) { throw new Error('Unauthorized access') } @@ -120,10 +178,18 @@ export const queryLogsServerTool: BaseServerTool = { const workspaceId = resolveWorkspaceId(args, context) if (args.view === 'list') { - const { view: _view, ...rest } = args - const params = { ...rest, workspaceId } as ListLogsParams + const { view: _view, title: _title, ...rest } = args + const params = { ...rest, workspaceId, includeTotal: true } as ListLogsParams logger.info('query_logs list', { workspaceId, sortBy: params.sortBy }) - return listLogs(params, userId) + const { data, nextCursor, total } = await listLogs(params, userId) + // Cursor and total lead the payload so a truncated render still shows them. + return { total, nextCursor, data } + } + + if (args.view === 'stats') { + const { view: _view, title: _title, ...rest } = args + logger.info('query_logs stats', { workspaceId, bucket: rest.bucket }) + return statsLogs({ ...rest, workspaceId }, userId) } // overview / full / grep — single execution by id @@ -141,6 +207,18 @@ export const queryLogsServerTool: BaseServerTool = { | { traceSpans?: TraceSpan[]; totalDuration?: number | null } | undefined const traceSpans = (execData?.traceSpans ?? []) as TraceSpan[] + + if (args.view === 'trace') { + return { + executionId: detail.executionId, + workflowId: detail.workflowId, + status: detail.status, + trigger: detail.trigger, + durationMs: execData?.totalDuration ?? null, + blocks: toTrace(traceSpans), + } + } + const viewCtx = buildLogViewContext(detail, workspaceId, userId) if (args.pattern) { @@ -174,10 +252,16 @@ export const queryLogsServerTool: BaseServerTool = { } // full - const spans = await toFull(traceSpans, viewCtx, { - blockId: args.blockId, - blockName: args.blockName, - }) + const spans = await toFull( + traceSpans, + viewCtx, + { + blockId: args.blockId, + blockIds: args.blockIds, + blockName: args.blockName, + }, + args.fields + ) const result = { executionId: detail.executionId, workflowId: detail.workflowId, @@ -194,7 +278,7 @@ export const queryLogsServerTool: BaseServerTool = { workflowId: detail.workflowId, status: detail.status, truncated: true, - note: 'Full result too large; returning the compact overview. Scope with blockId/blockName, or use pattern to grep.', + note: 'Full result too large; returning the compact overview. Scope with blockIds/blockName (ids from view "trace"), or use pattern to grep.', spans: toOverview(traceSpans), } } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 31520688721..e28db5e1a2f 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -146,8 +146,8 @@ describe('getToolDisplayTitle for deployments', () => { ['deploy_as_api', { action: 'undeploy' }, 'Undeploying API'], ['deploy_as_chat', { action: 'deploy' }, 'Deploying chat'], ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying chat'], - ['publish_custom_block', { action: 'deploy' }, 'Deploying custom block'], - ['publish_custom_block', { action: 'undeploy' }, 'Undeploying custom block'], + ['publish_custom_block', { action: 'deploy' }, 'Publishing custom block'], + ['publish_custom_block', { action: 'undeploy' }, 'Unpublishing custom block'], ['deploy_as_mcp', undefined, 'Deploying MCP tool'], ['redeploy', undefined, 'Redeploying API'], ])('uses the action and deployment type for %s', (toolName, args, expected) => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 61a3096cb32..b274fdd2bdf 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -352,6 +352,9 @@ function materializeFileTitle(args: ToolArgs): string { if (operation === 'import') { return `Importing ${summarizeTargets(targets, 'workflow')}` } + if (operation === 'extract') { + return `Extracting ${summarizeTargets(targets, 'archive')}` + } return `Saving ${summarizeTargets(targets, 'file')}` } @@ -468,7 +471,7 @@ const TOOL_TITLES: Record = { delete_workspace_mcp_server: 'Deleting MCP server', deploy_as_api: 'Deploying API', deploy_as_chat: 'Deploying chat', - publish_custom_block: 'Deploying custom block', + publish_custom_block: 'Publishing custom block', deploy_as_mcp: 'Deploying MCP tool', diff_workflows: 'Comparing workflows', download_file: 'Downloading file', @@ -478,8 +481,8 @@ const TOOL_TITLES: Record = { get_block_outputs: 'Getting block outputs', get_block_upstream_references: 'Getting block references', get_deployed_workflow_state: 'Getting deployed workflow', - list_deployment_versions: 'Getting deployment logs', - get_ui_reference: 'Getting platform actions', + list_deployment_versions: 'Listing deployment versions', + get_ui_reference: 'Reading UI reference', get_scheduled_task_logs: 'Reading scheduled task logs', get_workflow_data: 'Getting workflow data', get_workflow_run_options: 'Getting run options', @@ -488,7 +491,7 @@ const TOOL_TITLES: Record = { list_user_workspaces: 'Listing workspaces', list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', - save_upload: 'Preparing file', + save_upload: 'Saving upload', manage_sandbox: 'Managing sandbox', manage_scheduled_task: 'Managing scheduled task', move_file: 'Moving file', @@ -504,7 +507,7 @@ const TOOL_TITLES: Record = { restore_resource: 'Restoring resource', run_block: 'Running block', scheduled_task: 'Managing scheduled task', - search_sim_docs: 'Searching documentation', + search_sim_docs: 'Searching Sim docs', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', set_global_workflow_variables: 'Setting workflow variables', @@ -681,7 +684,7 @@ export function getToolDisplayTitle(name: string, args?: Record case 'deploy_as_chat': return deploymentTitle(args, 'chat') case 'publish_custom_block': - return deploymentTitle(args, 'custom block') + return `${stringArg(args, 'action') === 'undeploy' ? 'Unpublishing' : 'Publishing'} custom block` case 'ffmpeg': return ffmpegTitle(args) case 'manage_knowledge_base': @@ -949,8 +952,28 @@ export function getToolDisplayTitle(name: string, args?: Record case 'run_workflow_until_block': return 'Running workflow' case 'query_logs': { + // The model narrates its own query; the per-view titles are fallbacks. + const title = stringArg(args, 'title') + if (title) return title const workflowName = stringArg(args, 'workflowName') - return workflowName ? `Querying logs for ${workflowName}` : 'Querying logs' + const scope = workflowName ? ` for ${workflowName}` : '' + switch (stringArg(args, 'view')) { + case 'stats': + return `Analyzing run stats${scope}` + case 'trace': + return 'Reading execution trace' + case 'overview': + return 'Reading execution overview' + case 'full': + return 'Reading execution details' + case 'list': + return `Querying logs${scope}` + default: + // view is optional: executionId implies the trace digest default. + return stringArg(args, 'executionId') + ? 'Reading execution trace' + : `Querying logs${scope}` + } } case 'read': { if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) { @@ -992,6 +1015,9 @@ const COMPLETED_VERB_REWRITES: Record = { Creating: 'Created', Deleting: 'Deleted', Deploying: 'Deployed', + Publishing: 'Published', + Unpublishing: 'Unpublished', + Analyzing: 'Analyzed', Disabling: 'Disabled', Downloading: 'Downloaded', Duplicating: 'Duplicated', diff --git a/apps/sim/lib/logs/list-logs.ts b/apps/sim/lib/logs/list-logs.ts index c98b0e564f6..4a682260cee 100644 --- a/apps/sim/lib/logs/list-logs.ts +++ b/apps/sim/lib/logs/list-logs.ts @@ -174,6 +174,10 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< const commonFilters = buildFilterConditions(p, { useSimpleLevelFilter: false }) if (commonFilters) workflowConditions.push(commonFilters) + // Snapshot the filter-only conditions (no pagination cursor) so an + // `includeTotal` count runs over the whole filtered set, not the tail. + const workflowFilterConditions = [...workflowConditions] + const workflowCursorCond = buildCursorCondition(workflowSortExpr, workflowExecutionLogs.id) if (workflowCursorCond) workflowConditions.push(workflowCursorCond) @@ -233,6 +237,7 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< .limit(fetchSize) const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, p.workspaceId)] + let jobFilterConditions: SQL[] = jobConditions if (includeJobLogs) { if (p.level && p.level !== 'all') { @@ -303,6 +308,8 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< if (durationCond) jobConditions.push(durationCond) } + jobFilterConditions = [...jobConditions] + const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id) if (jobCursorCond) jobConditions.push(jobCursorCond) } @@ -451,8 +458,34 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< nextCursor = encodeCursor({ v: cursorV, id: last.id }) } + let total: number | undefined + if (p.includeTotal) { + const workflowCountQuery = dbReplica + .select({ count: sql`COUNT(*)` }) + .from(workflowExecutionLogs) + .leftJoin( + pausedExecutions, + eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) + ) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(and(...workflowFilterConditions)) + const jobCountQuery = includeJobLogs + ? dbReplica + .select({ count: sql`COUNT(*)` }) + .from(jobExecutionLogs) + .where(and(...jobFilterConditions)) + : Promise.resolve([{ count: 0 }]) + const [workflowCount, jobCount] = await Promise.all([workflowCountQuery, jobCountQuery]) + total = Number(workflowCount[0]?.count ?? 0) + Number(jobCount[0]?.count ?? 0) + } + return { data: page.map((row) => row.summary), nextCursor, + ...(total !== undefined ? { total } : {}), } } diff --git a/apps/sim/lib/logs/log-views.test.ts b/apps/sim/lib/logs/log-views.test.ts index de444485adc..5d79f1b1d0d 100644 --- a/apps/sim/lib/logs/log-views.test.ts +++ b/apps/sim/lib/logs/log-views.test.ts @@ -34,7 +34,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({ import { sleep } from '@sim/utils/helpers' import type { TraceSpan } from '@/lib/logs/types' -import { grepSpans, type LogViewContext, toFull, toOverview } from './log-views' +import { grepSpans, type LogViewContext, toFull, toOverview, toTrace } from './log-views' const ctx: LogViewContext = { workspaceId: 'ws-1', @@ -286,3 +286,74 @@ describe('grepSpans', () => { expect(result.truncated).toBe(false) }) }) + +describe('toTrace', () => { + it('collapses loop iterations into one per-block digest line with status counts', () => { + const spans: TraceSpan[] = [ + span({ + id: 'loop', + blockId: 'blk-loop', + name: 'Loop', + type: 'loop', + children: [ + span({ id: 'i1', blockId: 'blk-agent', name: 'Agent', status: 'success', duration: 10 }), + span({ id: 'i2', blockId: 'blk-agent', name: 'Agent', status: 'success', duration: 20 }), + span({ id: 'i3', blockId: 'blk-agent', name: 'Agent', status: 'error', duration: 5 }), + ], + }), + ] + + const digest = toTrace(spans) + + expect(digest).toHaveLength(2) + expect(digest[1]).toMatchObject({ + blockId: 'blk-agent', + name: 'Agent', + executions: 3, + statuses: { success: 2, error: 1 }, + totalDurationMs: 35, + }) + }) + + it('never materializes refs', () => { + toTrace([span({ output: ref('big') as unknown as Record })]) + expect(materializeLargeValueRefMock).not.toHaveBeenCalled() + }) +}) + +describe('toFull field projection', () => { + it('narrows spans to whole payload keys', async () => { + const out = await toFull( + [span({ input: { a: 1 }, output: { b: 2 }, errorMessage: 'boom' })], + ctx, + undefined, + ['output', 'error'] + ) + expect(out[0]).toMatchObject({ output: { b: 2 }, error: 'boom' }) + expect(out[0]).not.toHaveProperty('input') + }) + + it('extracts dotted paths under selected', async () => { + const out = await toFull( + [span({ output: { result: { rows: [1, 2, 3], meta: 'big' } } })], + ctx, + undefined, + ['output.result.rows'] + ) + expect(out[0]).not.toHaveProperty('output') + expect((out[0] as { selected?: Record }).selected).toEqual({ + 'output.result.rows': [1, 2, 3], + }) + }) + + it('supports blockIds multi-select with field projection', async () => { + const spans: TraceSpan[] = [ + span({ id: 's1', blockId: 'blk-a', name: 'A', output: { keep: 1 } }), + span({ id: 's2', blockId: 'blk-b', name: 'B', output: { keep: 2 } }), + span({ id: 's3', blockId: 'blk-c', name: 'C', output: { drop: true } }), + ] + const out = await toFull(spans, ctx, { blockIds: ['blk-a', 'blk-b'] }, ['output']) + expect(out.map((s) => s.blockId)).toEqual(['blk-a', 'blk-b']) + expect(out[0].output).toEqual({ keep: 1 }) + }) +}) diff --git a/apps/sim/lib/logs/log-views.ts b/apps/sim/lib/logs/log-views.ts index 1f679ce53b0..2de94476921 100644 --- a/apps/sim/lib/logs/log-views.ts +++ b/apps/sim/lib/logs/log-views.ts @@ -77,6 +77,56 @@ export function toOverview(spans: TraceSpan[]): OverviewSpan[] { }) } +// --------------------------------------------------------------------------- +// Trace (Level 1.5): condensed per-block digest — names, statuses, counts. +// --------------------------------------------------------------------------- + +export interface TraceDigestEntry { + /** Block id when the spans carry one; the drill-in key for `full` blockIds. */ + blockId?: string + name: string + type: string + /** How many spans (loop iterations included) this block produced. */ + executions: number + /** Span count per status, e.g. { success: 498, error: 2 }. */ + statuses: Record + totalDurationMs: number +} + +/** + * Project trace spans to a flat per-block digest in first-execution order. + * Every span in the tree is counted (loop iterations collapse into their + * block's entry), so a 500-iteration loop is one line, not 500. Never + * materializes refs. + */ +export function toTrace(spans: TraceSpan[]): TraceDigestEntry[] { + const byKey = new Map() + const walk = (list: TraceSpan[]): void => { + for (const s of list) { + const key = s.blockId ?? `${s.type}:${s.name}` + let entry = byKey.get(key) + if (!entry) { + entry = { + ...(s.blockId ? { blockId: s.blockId } : {}), + name: s.name, + type: s.type, + executions: 0, + statuses: {}, + totalDurationMs: 0, + } + byKey.set(key, entry) + } + entry.executions++ + const status = s.status ?? 'unknown' + entry.statuses[status] = (entry.statuses[status] ?? 0) + 1 + entry.totalDurationMs += s.duration ?? 0 + if (s.children && s.children.length > 0) walk(s.children) + } + } + walk(spans) + return Array.from(byKey.values()) +} + // --------------------------------------------------------------------------- // Full (Level 3): block tree WITH materialized input/output. // --------------------------------------------------------------------------- @@ -92,6 +142,8 @@ export interface FullSpan extends OverviewSpan { export interface BlockSelector { blockId?: string + /** Multiple drill-in targets at once (ids from the trace digest). */ + blockIds?: string[] blockName?: string } @@ -104,19 +156,76 @@ export interface BlockSelector { export async function toFull( spans: TraceSpan[], ctx: LogViewContext, - selector?: BlockSelector + selector?: BlockSelector, + fields?: string[] ): Promise { const roots = selectSpans(spans, selector) - return Promise.all(roots.map((s) => fullSpan(s, ctx))) + const full = await Promise.all(roots.map((s) => fullSpan(s, ctx))) + if (!fields || fields.length === 0) return full + return full.map((s) => projectSpanFields(s, fields)) +} + +/** + * Narrows a full span to the requested fields so the caller loads only what it + * needs. A field is either a whole payload key (`input` / `output` / `error`) + * or a dotted path into one (`output.result.rows`); dotted selections land + * under `selected` keyed by the full path. Span identity/status/timing always + * stay, and children are projected recursively. + */ +function projectSpanFields(span: FullSpan, fields: string[]): FullSpan { + const node: FullSpan = { + id: span.id, + blockId: span.blockId, + name: span.name, + type: span.type, + status: span.status, + durationMs: span.durationMs, + startTime: span.startTime, + endTime: span.endTime, + } + if (span.cost) node.cost = span.cost + const selected: Record = {} + let hasSelected = false + for (const field of fields) { + if (field === 'input' || field === 'output' || field === 'error') { + if (span[field] !== undefined) node[field] = span[field] as never + continue + } + const [head, ...rest] = field.split('.') + if ((head === 'input' || head === 'output') && rest.length > 0) { + let value: unknown = span[head] + for (const key of rest) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + value = (value as Record)[key] + } else if (Array.isArray(value) && /^\d+$/.test(key)) { + value = value[Number(key)] + } else { + value = undefined + break + } + } + selected[field] = value + hasSelected = true + } + } + if (hasSelected) (node as FullSpan & { selected?: Record }).selected = selected + if (span.children && span.children.length > 0) { + node.children = span.children.map((c) => projectSpanFields(c, fields)) + } + return node } function selectSpans(spans: TraceSpan[], selector?: BlockSelector): TraceSpan[] { - if (!selector || (!selector.blockId && !selector.blockName)) return spans + if (!selector || (!selector.blockId && !selector.blockIds?.length && !selector.blockName)) { + return spans + } + const idSet = new Set(selector.blockIds ?? []) + if (selector.blockId !== undefined) idSet.add(selector.blockId) const out: TraceSpan[] = [] const walk = (list: TraceSpan[]): void => { for (const s of list) { const matches = - (selector.blockId !== undefined && s.blockId === selector.blockId) || + (s.blockId !== undefined && idSet.has(s.blockId)) || (selector.blockName !== undefined && s.name === selector.blockName) if (matches) { out.push(s) diff --git a/apps/sim/lib/logs/stats-logs.ts b/apps/sim/lib/logs/stats-logs.ts new file mode 100644 index 00000000000..6a8abc1c6ab --- /dev/null +++ b/apps/sim/lib/logs/stats-logs.ts @@ -0,0 +1,214 @@ +import { dbReplica } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq, sql } from 'drizzle-orm' +import { buildFilterConditions } from '@/lib/logs/filters' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +/** + * Server-side aggregation over workflow execution logs for the copilot + * `query_logs` stats view: per-workflow, optionally calendar-bucketed counts by + * status, under the same filter set as the list view. Exists so a model never + * has to paginate the list and count client-side. Job (Sim-agent) executions + * are not included — same scope as the Logs dashboard stats. + */ + +export interface StatsLogsParams { + workspaceId: string + level?: string + workflowIds?: string + folderIds?: string + triggers?: string + startDate?: string + endDate?: string + search?: string + workflowName?: string + folderName?: string + /** Calendar bucketing for the per-workflow series; omit for totals only. */ + bucket?: 'day' | 'hour' + /** IANA timezone the buckets are computed in. Defaults to UTC. */ + timezone?: string +} + +export interface LogStatsBucket { + /** Bucket start in the requested timezone (naive local timestamp). */ + start: string + executions: number + byStatus: Record + avgDurationMs: number +} + +export interface WorkflowLogStats { + workflowId: string + workflowName: string + executions: number + byStatus: Record + avgDurationMs: number + buckets?: LogStatsBucket[] +} + +export interface LogStatsResponse { + bucket: 'day' | 'hour' | null + timezone: string + totals: { executions: number; byStatus: Record; avgDurationMs: number } + workflows: WorkflowLogStats[] + /** Set when more workflows matched than are returned (ordered by executions). */ + workflowsTruncated?: boolean +} + +const MAX_WORKFLOWS = 100 + +function assertValidTimezone(timezone: string): void { + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }) + } catch { + throw new Error(`Invalid timezone: ${timezone}. Use an IANA name like "America/Los_Angeles".`) + } +} + +interface StatsAccumulator { + executions: number + byStatus: Record + durationSumMs: number + durationCount: number +} + +function newAccumulator(): StatsAccumulator { + return { executions: 0, byStatus: {}, durationSumMs: 0, durationCount: 0 } +} + +function accumulate(acc: StatsAccumulator, status: string, row: RawStatsRow): void { + acc.executions += Number(row.executions) + acc.byStatus[status] = (acc.byStatus[status] ?? 0) + Number(row.executions) + acc.durationSumMs += Number(row.durationSumMs) + acc.durationCount += Number(row.durationCount) +} + +function avgOf(acc: StatsAccumulator): number { + return acc.durationCount > 0 ? Math.round(acc.durationSumMs / acc.durationCount) : 0 +} + +interface RawStatsRow { + workflowId: string + workflowName: string + bucketStart: string | null + status: string | null + executions: number + durationSumMs: number + durationCount: number +} + +export async function statsLogs( + params: StatsLogsParams, + userId: string +): Promise { + const timezone = params.timezone ?? 'UTC' + assertValidTimezone(timezone) + const bucket = params.bucket ?? null + + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return { + bucket, + timezone, + totals: { executions: 0, byStatus: {}, avgDurationMs: 0 }, + workflows: [], + } + } + + const folderIds = params.folderIds + ? await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) + : params.folderIds + const p = { ...params, folderIds } + + const workspaceFilter = eq(workflowExecutionLogs.workspaceId, p.workspaceId) + const commonFilters = buildFilterConditions(p, { useSimpleLevelFilter: true }) + const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter + + const bucketExpr = bucket + ? sql< + string | null + >`to_char(date_trunc(${bucket}, ${workflowExecutionLogs.startedAt} AT TIME ZONE ${timezone}), 'YYYY-MM-DD"T"HH24:MI:SS')` + : sql`NULL` + + const rows = (await dbReplica + .select({ + workflowId: sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, + workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + bucketStart: bucketExpr.as('bucket_start'), + status: workflowExecutionLogs.status, + executions: sql`COUNT(*)`, + durationSumMs: sql`COALESCE(SUM(${workflowExecutionLogs.totalDurationMs}) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0), 0)`, + durationCount: sql`COUNT(*) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0)`, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(whereCondition) + .groupBy( + sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, + sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + sql`bucket_start`, + workflowExecutionLogs.status + )) as RawStatsRow[] + + const totals = newAccumulator() + const byWorkflow = new Map< + string, + { workflowName: string; overall: StatsAccumulator; buckets: Map } + >() + + for (const row of rows) { + const status = row.status ?? 'unknown' + accumulate(totals, status, row) + let wf = byWorkflow.get(row.workflowId) + if (!wf) { + wf = { workflowName: row.workflowName, overall: newAccumulator(), buckets: new Map() } + byWorkflow.set(row.workflowId, wf) + } + accumulate(wf.overall, status, row) + if (bucket && row.bucketStart) { + let bucketAcc = wf.buckets.get(row.bucketStart) + if (!bucketAcc) { + bucketAcc = newAccumulator() + wf.buckets.set(row.bucketStart, bucketAcc) + } + accumulate(bucketAcc, status, row) + } + } + + const workflows: WorkflowLogStats[] = Array.from(byWorkflow.entries()) + .map(([workflowId, wf]) => ({ + workflowId, + workflowName: wf.workflowName, + executions: wf.overall.executions, + byStatus: wf.overall.byStatus, + avgDurationMs: avgOf(wf.overall), + ...(bucket + ? { + buckets: Array.from(wf.buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([start, acc]) => ({ + start, + executions: acc.executions, + byStatus: acc.byStatus, + avgDurationMs: avgOf(acc), + })), + } + : {}), + })) + .sort((a, b) => b.executions - a.executions) + + const truncated = workflows.length > MAX_WORKFLOWS + + return { + bucket, + timezone, + totals: { + executions: totals.executions, + byStatus: totals.byStatus, + avgDurationMs: avgOf(totals), + }, + workflows: truncated ? workflows.slice(0, MAX_WORKFLOWS) : workflows, + ...(truncated ? { workflowsTruncated: true } : {}), + } +} From 2c8acbf7c6d17f1c11355ab98f6dfd288b465523 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 14:34:20 -0700 Subject: [PATCH 046/103] checkpoint --- .../agent-group/agent-group.test.ts | 3 - .../components/agent-group/agent-group.tsx | 27 +++--- .../message-content/message-content.tsx | 1 - .../home/hooks/stream/turn-model.test.ts | 90 +++++++++++++++++++ .../home/hooks/stream/turn-model.ts | 17 +++- 5 files changed, 118 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index 4808893b4eb..9f83098c06a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -110,7 +110,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [tool('success'), browserTakeover(reason)], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) @@ -184,7 +183,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [takeover], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) @@ -206,7 +204,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [completedTakeover], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 103e6ba6e4f..070734ddf74 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -37,8 +37,6 @@ interface AgentGroupProps { items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean - /** This group is the latest section in its parent sequence (drives collapse). */ - isCurrentSection?: boolean /** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */ isLaneOpen?: boolean } @@ -110,7 +108,6 @@ export function AgentGroup({ items, isDelegating = false, isStreaming = false, - isCurrentSection = false, isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) @@ -123,17 +120,18 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Expand while the turn is live and any of: the lane is open (the subagent is - // actively running), this is the current/latest section, or there is unresolved - // work. A finished group stays open until the NEXT section starts (it is no - // longer the latest), instead of collapsing the instant its own work resolves. - // Keying "still running" off the lane-open signal (not `resolved` alone) avoids - // a collapse/reopen flicker on parallel siblings: a subagent's tools all - // momentarily read "done" in the gap between its last search and its `respond` - // ("Gathering thoughts") tool, transiently flipping `resolved` true; the open - // lane bridges that gap so the row never collapses mid-run. The turn ending - // (isStreaming false) collapses everything; a manual toggle pins the choice. - const autoExpanded = isStreaming && (isCurrentSection || isLaneOpen || !resolved) + // Expand while the turn is live and the subagent is still working: the lane + // is open, or there is unresolved work. When the lane closes and the work + // resolves the group collapses — with parallel subagents, finished siblings + // fold away while the still-running ones stay open, instead of every group + // lingering expanded until the next section starts. Keying "still running" + // off the lane-open signal (not `resolved` alone) avoids a collapse/reopen + // flicker mid-run: a subagent's tools all momentarily read "done" in the gap + // between its last search and its `respond` ("Gathering thoughts") tool, + // transiently flipping `resolved` true; the open lane bridges that gap. The + // turn ending (isStreaming false) collapses everything; a manual toggle pins + // the choice. + const autoExpanded = isStreaming && (isLaneOpen || !resolved) const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn @@ -219,7 +217,6 @@ export function AgentGroup({ items={item.group.items} isDelegating={item.group.isDelegating} isStreaming={isStreaming} - isCurrentSection={idx === items.length - 1} isLaneOpen={item.group.isOpen} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 24dfc3fd842..033ecf0cd50 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -957,7 +957,6 @@ function MessageContentInner({ items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} - isCurrentSection={i === segments.length - 1} isLaneOpen={segment.isOpen} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 971ecd57b85..1c5eb2d72d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -556,3 +556,93 @@ describe('reduceEvent — span-start owner reconciliation', () => { expect((lane as AgentNode).agentId).toBe('workflow') }) }) + +describe('reduceEvent — span end settles stale lane tools', () => { + const laneScope = { lane: 'subagent', spanId: 'S1', parentToolCallId: 'd1' } as Scope + + it('marks still-running tools success when their lane ends cleanly', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + // No result for click-1 — dropped/reordered past the lane end. + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: {} }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('success') + + const laneId = model.agentBySpanId.get('S1') + const lane = laneId ? model.nodes.get(laneId) : undefined + if (lane?.kind !== 'agent') throw new Error('expected agent lane') + expect(lane.status).toBe('success') + }) + + it('marks still-running tools error when the lane ends with an error', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: { error: 'boom' } }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('error') + }) + + it('leaves settled tools alone and lets a late result overwrite the settle', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: {} }, + laneScope + ), + // Late result arrives after the settle — it must win. + envelope( + 4, + 'tool', + { + phase: 'result', + toolCallId: 'click-1', + toolName: 'browser_click', + success: false, + error: 'nope', + }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('error') + expect(click.result?.error).toBe('nope') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index d636bf8b470..25651a0eb59 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -611,10 +611,25 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (data?.pending === true) break breakLane(model, resolvedSpanId, tsMs) const node = model.nodes.get(resolvedSpanId) + const spanErrored = Boolean(data && asString(data.error)) if (node && node.kind === 'agent' && !isNodeTerminal(node.status)) { - node.status = data && asString(data.error) ? 'error' : 'success' + node.status = spanErrored ? 'error' : 'success' node.endSeq = seq } + // The lane is over: settle any tool row still `running` in it (its + // result was dropped or reordered past the end). Left open, the row + // pins the whole group expanded and shimmering for the rest of the + // turn even though the subagent already returned. A late result event + // still corrects this — applyToolResult overwrites unconditionally. + for (const id of model.order) { + const stale = model.nodes.get(id) + if (stale?.kind === 'tool' && stale.spanId === resolvedSpanId) { + if (stale.status === 'running') { + stale.status = spanErrored ? 'error' : 'success' + stale.streamingArgs = undefined + } + } + } } break } From 46aae116b5a5638ea2b30c1bc8db6c56cd71d42a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 14:40:53 -0700 Subject: [PATCH 047/103] Port desktop-improvements-0 desktop and browser-agent work --- .../main/browser-search/suggestions.test.ts | 74 +++++++++++++ .../src/main/browser-search/suggestions.ts | 102 ++++++++++++++++++ apps/desktop/src/main/config.ts | 3 + .../desktop/src/main/desktop-settings.test.ts | 11 ++ apps/desktop/src/main/desktop-settings.ts | 7 ++ apps/desktop/src/main/ipc.test.ts | 23 ++++ apps/desktop/src/main/ipc.ts | 20 ++++ apps/desktop/src/preload/index.test.ts | 4 + apps/desktop/src/preload/index.ts | 4 + .../browser-session/browser-session.test.ts | 4 + .../browser-session/browser-session.tsx | 95 +++++++++++----- .../browser-session/url-suggestions.test.ts | 62 +++++++++++ .../browser-session/url-suggestions.ts | 55 ++++++++++ .../resource-tabs/resource-tabs.tsx | 4 +- .../app/workspace/[workspaceId]/home/home.tsx | 22 ++-- .../[workspaceId]/home/hooks/index.ts | 2 + .../[workspaceId]/home/hooks/use-chat.test.ts | 15 +++ .../[workspaceId]/home/hooks/use-chat.ts | 48 ++++++--- .../components/browser/browser.test.tsx | 42 +++++++- .../settings/components/browser/browser.tsx | 28 +++++ apps/sim/lib/browser-agent/open-in-panel.ts | 2 +- apps/sim/lib/browser-agent/transport.test.ts | 29 +++++ apps/sim/lib/browser-agent/transport.ts | 22 ++++ .../lib/copilot/chat/desktop-capabilities.ts | 3 + apps/sim/lib/copilot/chat/post.ts | 10 +- apps/sim/lib/desktop/index.test.ts | 38 +++++++ apps/sim/lib/desktop/index.ts | 27 +++-- packages/desktop-bridge/src/index.ts | 12 +++ 28 files changed, 708 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/src/main/browser-search/suggestions.test.ts create mode 100644 apps/desktop/src/main/browser-search/suggestions.ts create mode 100644 apps/sim/lib/copilot/chat/desktop-capabilities.ts diff --git a/apps/desktop/src/main/browser-search/suggestions.test.ts b/apps/desktop/src/main/browser-search/suggestions.test.ts new file mode 100644 index 00000000000..c9b839d25c0 --- /dev/null +++ b/apps/desktop/src/main/browser-search/suggestions.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + parseSearchSuggestionResponse, + SearchSuggestionService, +} from '@/main/browser-search/suggestions' + +describe('parseSearchSuggestionResponse', () => { + it('keeps unique non-empty completions and omits the exact query row', () => { + expect( + parseSearchSuggestionResponse( + [ + 'what is the be', + [ + 'what is the be', + 'what is the best sleeping position', + ' WHAT IS THE BEST SLEEPING POSITION ', + '', + 42, + 'what is the benefit of creatine', + ], + ], + 'what is the be' + ) + ).toEqual(['what is the best sleeping position', 'what is the benefit of creatine']) + }) + + it('rejects malformed provider payloads', () => { + expect(parseSearchSuggestionResponse(null, 'sim')).toEqual([]) + expect(parseSearchSuggestionResponse(['sim', {}], 'sim')).toEqual([]) + }) +}) + +describe('SearchSuggestionService', () => { + it('uses only the fixed provider URL and caches repeated queries', async () => { + const fetcher = vi.fn( + async (_url: string, _init: RequestInit): Promise => + Response.json(['sim studio', ['sim studio ai', 'sim studio workflow']]) + ) + const service = new SearchSuggestionService(fetcher, () => 1_000) + + await expect(service.suggest(' sim studio ')).resolves.toEqual([ + 'sim studio ai', + 'sim studio workflow', + ]) + await expect(service.suggest('SIM STUDIO')).resolves.toEqual([ + 'sim studio ai', + 'sim studio workflow', + ]) + + expect(fetcher).toHaveBeenCalledTimes(1) + const [url, init] = fetcher.mock.calls[0] + expect(new URL(url).origin).toBe('https://suggestqueries.google.com') + expect(new URL(url).searchParams.get('q')).toBe('sim studio') + expect(init).toMatchObject({ method: 'GET', redirect: 'error' }) + }) + + it('fails silently for short, oversized, rejected, and unsuccessful requests', async () => { + const fetcher = vi.fn( + async (_url: string, _init: RequestInit): Promise => + new Response(null, { status: 503 }) + ) + const service = new SearchSuggestionService(fetcher) + + await expect(service.suggest('s')).resolves.toEqual([]) + await expect(service.suggest('x'.repeat(201))).resolves.toEqual([]) + await expect(service.suggest('search me')).resolves.toEqual([]) + + fetcher.mockRejectedValueOnce(new Error('offline')) + await expect(service.suggest('another search')).resolves.toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/browser-search/suggestions.ts b/apps/desktop/src/main/browser-search/suggestions.ts new file mode 100644 index 00000000000..139588aaacb --- /dev/null +++ b/apps/desktop/src/main/browser-search/suggestions.ts @@ -0,0 +1,102 @@ +import { net } from 'electron' + +const GOOGLE_SUGGESTIONS_ENDPOINT = 'https://suggestqueries.google.com/complete/search' +const SEARCH_SUGGESTION_TIMEOUT_MS = 2_500 +const SEARCH_SUGGESTION_CACHE_TTL_MS = 5 * 60 * 1_000 +const MAX_SEARCH_SUGGESTION_CACHE_ENTRIES = 100 +const MAX_SEARCH_SUGGESTION_QUERY_LENGTH = 200 +const MAX_SEARCH_SUGGESTION_LENGTH = 256 +const MAX_SEARCH_SUGGESTIONS = 7 + +type SearchSuggestionFetch = (url: string, init: RequestInit) => Promise + +interface CachedSearchSuggestions { + expiresAt: number + values: string[] +} + +/** + * Validates the small portion of Google's Firefox-completion response that the + * omnibox consumes. Everything else in the provider payload is ignored. + */ +export function parseSearchSuggestionResponse(payload: unknown, query: string): string[] { + if (!Array.isArray(payload) || !Array.isArray(payload[1])) return [] + + const queryKey = query.toLocaleLowerCase() + const seen = new Set([queryKey]) + const suggestions: string[] = [] + for (const candidate of payload[1]) { + if (typeof candidate !== 'string') continue + const value = candidate.trim() + const key = value.toLocaleLowerCase() + if (!value || value.length > MAX_SEARCH_SUGGESTION_LENGTH || seen.has(key)) continue + seen.add(key) + suggestions.push(value) + if (suggestions.length === MAX_SEARCH_SUGGESTIONS) break + } + return suggestions +} + +/** + * In-memory, bounded search completion client. Queries go only to the fixed + * Google suggestions origin and are never logged or written to disk. + */ +export class SearchSuggestionService { + private readonly cache = new Map() + + constructor( + private readonly fetcher: SearchSuggestionFetch, + private readonly now: () => number = Date.now + ) {} + + async suggest(rawQuery: unknown): Promise { + if (typeof rawQuery !== 'string') return [] + const query = rawQuery.trim() + if (query.length < 2 || query.length > MAX_SEARCH_SUGGESTION_QUERY_LENGTH) return [] + + const cacheKey = query.toLocaleLowerCase() + const cached = this.cache.get(cacheKey) + if (cached && cached.expiresAt > this.now()) return [...cached.values] + if (cached) this.cache.delete(cacheKey) + + const url = new URL(GOOGLE_SUGGESTIONS_ENDPOINT) + url.searchParams.set('client', 'firefox') + url.searchParams.set('q', query) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), SEARCH_SUGGESTION_TIMEOUT_MS) + + try { + const response = await this.fetcher(url.toString(), { + method: 'GET', + redirect: 'error', + signal: controller.signal, + }) + if (!response.ok) return [] + const values = parseSearchSuggestionResponse(await response.json(), query) + this.remember(cacheKey, values) + return [...values] + } catch { + return [] + } finally { + clearTimeout(timeout) + } + } + + private remember(key: string, values: string[]): void { + if (this.cache.size >= MAX_SEARCH_SUGGESTION_CACHE_ENTRIES) { + const oldest = this.cache.keys().next().value + if (typeof oldest === 'string') this.cache.delete(oldest) + } + this.cache.set(key, { + expiresAt: this.now() + SEARCH_SUGGESTION_CACHE_TTL_MS, + values: [...values], + }) + } +} + +const searchSuggestionService = new SearchSuggestionService((url, init) => net.fetch(url, init)) + +/** Fetches live Google completions, failing closed to an empty local-only list. */ +export function getSearchSuggestions(query: unknown): Promise { + return searchSuggestionService.suggest(query) +} diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 3e0d10949a8..654fbacfa35 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -101,6 +101,8 @@ export interface DesktopSettings { launchAtLogin?: boolean autoDownloadUpdates?: boolean browserEnabled?: boolean + /** Whether omnibox typing may request live Google search completions. */ + browserSearchSuggestionsEnabled?: boolean terminalEnabled?: boolean /** Device-wide browser page appearance; `app` follows Sim. */ browserTheme?: 'app' | 'light' | 'dark' @@ -216,6 +218,7 @@ const DEFAULT_SETTINGS: DesktopSettings = { launchAtLogin: false, autoDownloadUpdates: true, browserEnabled: true, + browserSearchSuggestionsEnabled: true, terminalEnabled: true, } diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts index b7849a57558..d2bcac944ac 100644 --- a/apps/desktop/src/main/desktop-settings.test.ts +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -117,6 +117,17 @@ describe('desktop settings service', () => { expect(setTerminalEnabled).toHaveBeenCalledWith(false) }) + it('defaults live browser search suggestions on and persists the privacy switch', () => { + const { config, service } = makeService() + + expect(service.getPreferences().browserSearchSuggestionsEnabled).toBe(true) + + const preferences = service.setBrowserSearchSuggestionsEnabled(false) + + expect(config.get('browserSearchSuggestionsEnabled')).toBe(false) + expect(preferences.browserSearchSuggestionsEnabled).toBe(false) + }) + it('persists browser and terminal appearance with match-Sim defaults', () => { const { config, service, setBrowserTheme, onBrowserThemeChanged } = makeService() expect(service.getPreferences()).toMatchObject({ diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index f84b364b40b..7f25296b99b 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -36,6 +36,7 @@ export function isDesktopPreferenceKey(value: unknown): value is DesktopPreferen export interface DesktopSettingsService { getPreferences(): DesktopPreferences setPreference(key: DesktopPreferenceKey, value: boolean): DesktopPreferences + setBrowserSearchSuggestionsEnabled(enabled: boolean): DesktopPreferences setAppearancePreference( key: DesktopAppearanceSettingKey, value: DesktopAppearanceTheme @@ -90,6 +91,7 @@ function readPreferences( autoDownloadUpdates: config.get('autoDownloadUpdates') ?? true, trayEnabled: config.get('trayEnabled') ?? true, browserEnabled: config.get('browserEnabled') ?? true, + browserSearchSuggestionsEnabled: config.get('browserSearchSuggestionsEnabled') ?? true, terminalEnabled: config.get('terminalEnabled') ?? true, browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app', browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100, @@ -153,6 +155,11 @@ export function createDesktopSettingsService( } return read() }, + setBrowserSearchSuggestionsEnabled(enabled) { + deps.config.set('browserSearchSuggestionsEnabled', enabled) + deps.config.flush() + return read() + }, setAppearancePreference(key, value) { const previousBrowserTheme = key === 'browserTheme' ? read().browserTheme : undefined deps.config.set(key, value) diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index a23f83b4745..a74e43a9b56 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -18,6 +18,10 @@ vi.mock('@/main/browser-import', () => ({ })), })) +vi.mock('@/main/browser-search/suggestions', () => ({ + getSearchSuggestions: vi.fn(async () => ['sim ai workflow']), +})) + const { terminalThemeProfile } = vi.hoisted(() => ({ terminalThemeProfile: { id: 'iterm2:ocean', @@ -124,6 +128,7 @@ import { importChromePasswords, listChromeImportProfiles, } from '@/main/browser-import' +import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { trackInputActivity } from '@/main/input-activity' import { type IpcDeps, registerIpcHandlers } from '@/main/ipc' import { LocalFilesystemService } from '@/main/local-filesystem' @@ -252,6 +257,7 @@ describe('registerIpcHandlers', () => { vi.mocked(listChromeImportProfiles).mockClear() vi.mocked(importChromeCookies).mockClear() vi.mocked(importChromePasswords).mockClear() + vi.mocked(getSearchSuggestions).mockClear() vi.mocked(findCachedTerminalThemeProfile).mockClear() vi.mocked(listTerminalThemeProfiles).mockClear() vi.mocked(credentialsAvailable).mockClear() @@ -282,6 +288,7 @@ describe('registerIpcHandlers', () => { settings: { getPreferences: vi.fn(() => DEFAULT_DESKTOP_PREFERENCES), setPreference: vi.fn(), + setBrowserSearchSuggestionsEnabled: vi.fn(), setAppearancePreference: vi.fn(), setBrowserDefaultZoom: vi.fn(), setTerminalDefaultZoom: vi.fn(), @@ -325,6 +332,22 @@ describe('registerIpcHandlers', () => { expect(shell.openExternal).toHaveBeenCalledTimes(1) }) + it('keeps live search suggestions behind the app origin and privacy preference', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-agent:search-suggestions') + + expect(await handler?.(evilEvent, 'sim ai')).toEqual([]) + + expect(await handler?.(appEvent, 'sim ai')).toEqual(['sim ai workflow']) + expect(getSearchSuggestions).toHaveBeenCalledWith('sim ai') + + vi.mocked(deps.settings.getPreferences).mockReturnValue({ + ...DEFAULT_DESKTOP_PREFERENCES, + browserSearchSuggestionsEnabled: false, + }) + expect(await handler?.(appEvent, 'sim ai')).toEqual([]) + }) + it('restricts the OAuth connect handoff to the app origin', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:oauth-connect') diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 4b4b48bb7d8..23d89dcd23d 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -70,6 +70,7 @@ import { importChromePasswords, listChromeImportProfiles, } from '@/main/browser-import' +import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { listSites } from '@/main/browser-sites' import { isSafeInternalPath } from '@/main/config' import type { DesktopSettingsService } from '@/main/desktop-settings' @@ -655,6 +656,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { ? deps.settings.setPreference(key, value) : deps.settings.getPreferences(), }, + 'desktop:settings:set-browser-search-suggestions': { + kind: 'invoke', + gate: 'app-origin', + denied: null, + handler: (enabled) => + typeof enabled === 'boolean' + ? deps.settings.setBrowserSearchSuggestionsEnabled(enabled) + : deps.settings.getPreferences(), + }, 'desktop:settings:set-appearance': { kind: 'invoke', gate: 'app-origin', @@ -922,6 +932,16 @@ export function registerIpcHandlers(deps: IpcDeps): void { denied: { sessions: [] }, handler: () => getKnownSessions(), }, + 'browser-agent:search-suggestions': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + denied: [], + handler: (query) => + deps.settings.getPreferences().browserSearchSuggestionsEnabled === false + ? [] + : getSearchSuggestions(query), + }, 'browser-agent:clear-browsing-data': { kind: 'invoke', gate: 'app-origin', diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts index dea8ac89579..42cab747db4 100644 --- a/apps/desktop/src/preload/index.test.ts +++ b/apps/desktop/src/preload/index.test.ts @@ -32,6 +32,8 @@ describe('desktop preload bridge', () => { await exposed.browserAgent.setPanelOccluded(true, 'chat-default') await exposed.browserAgent.setPanelOccluded(false, 'chat-explicit-false', false) await exposed.browserAgent.setPanelOccluded(true, 'chat-force', true) + await exposed.browserAgent.getSearchSuggestions?.('sim ai') + await exposed.settings.setBrowserSearchSuggestionsEnabled?.(false) expect(invoke.mock.calls).toEqual([ ['browser-agent:cancel-tool', 'tool-1', 'chat-default'], @@ -39,6 +41,8 @@ describe('desktop preload bridge', () => { ['browser-agent:set-panel-occluded', true, 'chat-default', false], ['browser-agent:set-panel-occluded', false, 'chat-explicit-false', false], ['browser-agent:set-panel-occluded', true, 'chat-force', true], + ['browser-agent:search-suggestions', 'sim ai'], + ['desktop:settings:set-browser-search-suggestions', false], ]) }) }) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index ffb5314df73..afe84f2a42e 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -147,6 +147,8 @@ const api: SimDesktopApi = { getPreferences: (): Promise => ipcRenderer.invoke('desktop:settings:get'), setPreference: (key: DesktopPreferenceKey, value: boolean): Promise => ipcRenderer.invoke('desktop:settings:set', key, value), + setBrowserSearchSuggestionsEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set-browser-search-suggestions', enabled), notify: (payload: DesktopNotificationPayload): Promise => ipcRenderer.invoke('desktop:settings:notify', payload), setBrowserTheme: (theme: DesktopAppearanceTheme): Promise => @@ -281,6 +283,8 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-agent:get-tabs-state', scopeId), getKnownSessions: (): Promise => ipcRenderer.invoke('browser-agent:get-known-sessions'), + getSearchSuggestions: (query: string): Promise => + ipcRenderer.invoke('browser-agent:search-suggestions', query), clearBrowsingData: (kinds?: readonly BrowserDataKind[]): Promise => ipcRenderer.invoke('browser-agent:clear-browsing-data', kinds), getDownloadsState: (scopeId: string): Promise => diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts index 0edd0e9f3f0..957dab4d10d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts @@ -190,6 +190,10 @@ describe('initialUrlSuggestionIndex', () => { expect(initialUrlSuggestionIndex('https://sim.ai', 3)).toBeNull() }) + it('selects the exact search row after typing on an existing page', () => { + expect(initialUrlSuggestionIndex('https://sim.ai', 3, 'what is the best')).toBe(0) + }) + it('selects nothing when there are no suggestions', () => { expect(initialUrlSuggestionIndex('', 0)).toBeNull() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index 3d241ca738b..2f4ba98caf0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -39,6 +39,7 @@ import { onFocusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-short import { fillBrowserCredential, loadBrowserFillOptions, + loadBrowserSearchSuggestions, loadBrowserSuggestionSources, onBrowserAddToChat, onBrowserAppearanceThemeChanged, @@ -84,12 +85,16 @@ import { import { BrowserTabStrip } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip' import { BrowserThemeNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice' import { + buildOmniboxSuggestions, + googleSearchUrl, + isSearchQueryInput, mergeSuggestionSources, moveActiveIndex, - rankSuggestions, + type OmniboxSuggestion, type UrlSuggestion, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions' import { ResourceZoomMenuItems } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/resource-zoom-menu-items' +import { useDebounce } from '@/hooks/use-debounce' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useBrowserSessionStore } from '@/stores/browser-session/store' import { MOTHERSHIP_WIDTH } from '@/stores/constants' @@ -97,6 +102,7 @@ import type { ChatContext } from '@/stores/panel' /** Ties the omnibox to its listbox for assistive tech. */ const SUGGESTIONS_LIST_ID = 'browser-url-suggestions' +const SEARCH_SUGGESTIONS_DEBOUNCE_MS = 160 const NEW_TAB_CONFIRM_TIMEOUT_MS = 10_000 const EMPTY_BROWSER_TABS: BrowserTabState[] = [] @@ -156,14 +162,10 @@ export function browserSelectionContext({ export function resolveUrlBarInput(raw: string): string { const input = raw.trim() if (/^https?:\/\//i.test(input)) return input - const hostLike = - /^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i - if (!input.includes(' ') && hostLike.test(input)) { - const isLocal = - /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input) - return `${isLocal ? 'http' : 'https'}://${input}` - } - return `https://www.google.com/search?q=${encodeURIComponent(input)}` + if (isSearchQueryInput(input)) return googleSearchUrl(input) + const isLocal = + /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input) + return `${isLocal ? 'http' : 'https'}://${input}` } /** @@ -302,13 +304,14 @@ export function shouldOpenUrlSuggestions( return activeOverlay === 'suggestions' && suggestionCount > 0 } -/** New tabs submit the best suggestion; existing pages submit their current URL. */ +/** Typed searches and new tabs select the first row; an untouched page URL remains literal. */ export function initialUrlSuggestionIndex( pageUrl: string | undefined, - suggestionCount: number + suggestionCount: number, + query = '' ): number | null { if (suggestionCount === 0) return null - return !pageUrl || pageUrl === 'about:blank' ? 0 : null + return query.trim() || !pageUrl || pageUrl === 'about:blank' ? 0 : null } /** A new-tab request is complete only after the authoritative strip grows and activates a new id. */ @@ -429,6 +432,15 @@ export function BrowserSession({ const [suggestionsVisible, setSuggestionsVisible] = useState(false) /** Empty on initial focus; follows the typed text once the user edits it. */ const [suggestionQuery, setSuggestionQuery] = useState(null) + /** Live completions tagged with the query that produced them, so late replies cannot leak in. */ + const [searchCompletions, setSearchCompletions] = useState<{ + query: string + values: string[] + }>({ query: '', values: [] }) + const debouncedSuggestionQuery = useDebounce( + suggestionQuery ?? '', + SEARCH_SUGGESTIONS_DEBOUNCE_MS + ) /** Whether the find bar is docked above the page. */ const [findOpen, setFindOpen] = useState(false) const { @@ -502,6 +514,23 @@ export function BrowserSession({ } }, [panelVisible]) + /** Debounced live completions never block the immediate local/search row. */ + useEffect(() => { + const query = debouncedSuggestionQuery.trim() + if (!suggestionsVisible || !isSearchQueryInput(query)) { + setSearchCompletions({ query: '', values: [] }) + return + } + + let active = true + void loadBrowserSearchSuggestions(query).then((values) => { + if (active) setSearchCompletions({ query, values }) + }) + return () => { + active = false + } + }, [debouncedSuggestionQuery, suggestionsVisible]) + useEffect(() => { if (appearanceTheme) { const next = resolveDesktopAppearanceTheme(appearanceTheme, theme) @@ -833,17 +862,18 @@ export function BrowserSession({ * Programmatic focus on a new tab keeps the omnibox ready for typing without * opening this list. A pointer interaction or typed edit opts into suggestions. */ - const suggestions = useMemo( - () => - suggestionsVisible && suggestionQuery !== null - ? rankSuggestions(suggestionCorpus, suggestionQuery) - : [], - [suggestionCorpus, suggestionQuery, suggestionsVisible] - ) + const suggestions = useMemo((): OmniboxSuggestion[] => { + if (!suggestionsVisible || suggestionQuery === null) return [] + const query = suggestionQuery.trim() + const live = searchCompletions.query === query ? searchCompletions.values : [] + return buildOmniboxSuggestions(suggestionCorpus, suggestionQuery, live) + }, [searchCompletions, suggestionCorpus, suggestionQuery, suggestionsVisible]) useEffect(() => { - setActiveSuggestion(initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length)) - }, [suggestionOriginUrl, suggestions]) + setActiveSuggestion( + initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length, suggestionQuery ?? '') + ) + }, [suggestionOriginUrl, suggestionQuery, suggestions]) // The suggestion list is renderer UI that extends over the native page. // Keep the page's exact captured frame underneath it while it is open so @@ -1075,6 +1105,7 @@ export function BrowserSession({ placeholder='Search Google or enter a URL' autoComplete='off' role='combobox' + aria-autocomplete='list' aria-expanded={suggestionsOpen} aria-controls={SUGGESTIONS_LIST_ID} aria-activedescendant={ @@ -1092,6 +1123,7 @@ export function BrowserSession({ onChange={(event) => { setSuggestionsVisible(true) setSuggestionQuery(event.target.value) + setSearchCompletions({ query: '', values: [] }) setUrlDraft(event.target.value) // The old highlight pointed at a row that may no longer be // in the list, let alone in the same position. @@ -1150,7 +1182,11 @@ export function BrowserSession({ > {suggestions.map((suggestion, index) => ( navigateTo(suggestion.url)} >
- - {suggestion.name ? ( + {suggestion.kind === 'search' ? ( + <> + + {suggestion.query} + + ) : ( + + )} + {suggestion.kind === 'site' && suggestion.name ? (
{suggestion.name} — {suggestion.hostname}
- ) : ( + ) : suggestion.kind === 'site' ? ( {suggestion.hostname} - )} + ) : null}
))} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts index f4407ab3f6a..978d67badf1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts @@ -2,6 +2,9 @@ import type { BrowserKnownSession, BrowserSessionEvidence } from '@sim/browser-p import type { BrowserCredentialMetadata, BrowserSiteInfo } from '@sim/desktop-bridge' import { describe, expect, it } from 'vitest' import { + buildOmniboxSuggestions, + googleSearchUrl, + isSearchQueryInput, mergeSuggestionSources, moveActiveIndex, rankSuggestions, @@ -421,6 +424,65 @@ describe('rankSuggestions', () => { }) }) +describe('buildOmniboxSuggestions', () => { + it('leads with the exact search, keeps matching sites, then adds live completions', () => { + const results = buildOmniboxSuggestions( + [suggestion('mail.google.com', 100, 'Gmail')], + 'gmail', + ['gmail login', 'gmail account'] + ) + + expect(results.map((result) => [result.kind, result.url])).toEqual([ + ['search', googleSearchUrl('gmail')], + ['site', 'https://mail.google.com'], + ['search', googleSearchUrl('gmail login')], + ['search', googleSearchUrl('gmail account')], + ]) + }) + + it('does not send URL-looking input through search completions', () => { + const results = buildOmniboxSuggestions([suggestion('github.com', 100)], 'github.com', [ + 'github.com login', + ]) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' }) + }) + + it('deduplicates completions and caps the combined dropdown', () => { + const results = buildOmniboxSuggestions( + [], + 'sim ai', + ['sim ai', 'SIM AI', 'sim ai workflow', 'sim ai agents'], + 2 + ) + + expect(results.map((result) => result.kind === 'search' && result.query)).toEqual([ + 'sim ai', + 'sim ai workflow', + ]) + }) + + it('keeps an empty omnibox local-only', () => { + const results = buildOmniboxSuggestions([suggestion('github.com', 100)], '', [ + 'ignored remote completion', + ]) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' }) + }) +}) + +describe('isSearchQueryInput', () => { + it('distinguishes searches from navigable addresses', () => { + expect(isSearchQueryInput('what is the best browser')).toBe(true) + expect(isSearchQueryInput('electron')).toBe(true) + expect(isSearchQueryInput('sim.ai/docs')).toBe(false) + expect(isSearchQueryInput('https://sim.ai')).toBe(false) + expect(isSearchQueryInput('localhost:3000')).toBe(false) + }) +}) + describe('moveActiveIndex', () => { it('highlights nothing until the user arrows in, so Enter still means "go to what I typed"', () => { expect(moveActiveIndex(null, 1, 3)).toBe(0) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts index c5ae7399c7f..4ab226a38ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts @@ -47,6 +47,25 @@ export interface UrlSuggestion { visits?: number } +export type OmniboxSuggestion = + | ({ kind: 'site' } & UrlSuggestion) + | { kind: 'search'; query: string; url: string } + +const HOST_LIKE_INPUT = + /^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i + +/** Whether an omnibox value should search rather than navigate directly. */ +export function isSearchQueryInput(raw: string): boolean { + const input = raw.trim() + if (!input || /^https?:\/\//i.test(input)) return false + return input.includes(' ') || !HOST_LIKE_INPUT.test(input) +} + +/** The canonical Google results URL used by search rows and bare submission. */ +export function googleSearchUrl(query: string): string { + return `https://www.google.com/search?q=${encodeURIComponent(query.trim())}` +} + function timestamp(value: string | undefined): number { if (!value) return 0 const parsed = Date.parse(value) @@ -189,6 +208,42 @@ export function rankSuggestions( return scored.slice(0, limit).map((entry) => entry.suggestion) } +/** + * Combines immediate navigation/search actions with the user's known sites and + * live completions. The exact typed search leads, known sites retain priority, + * and remote completions fill whatever room remains. + */ +export function buildOmniboxSuggestions( + siteCorpus: readonly UrlSuggestion[], + rawQuery: string, + searchCompletions: readonly string[] = [], + limit: number = MAX_URL_SUGGESTIONS +): OmniboxSuggestion[] { + if (limit <= 0) return [] + const query = rawQuery.trim() + const sites = rankSuggestions(siteCorpus, query, limit) + if (!query || !isSearchQueryInput(query)) { + return sites.map((site) => ({ ...site, kind: 'site' })) + } + + const results: OmniboxSuggestion[] = [{ kind: 'search', query, url: googleSearchUrl(query) }] + for (const site of sites) { + if (results.length === limit) return results + results.push({ ...site, kind: 'site' }) + } + + const seen = new Set([query.toLocaleLowerCase()]) + for (const candidate of searchCompletions) { + const completion = candidate.trim() + const key = completion.toLocaleLowerCase() + if (!completion || seen.has(key)) continue + seen.add(key) + results.push({ kind: 'search', query: completion, url: googleSearchUrl(completion) }) + if (results.length === limit) break + } + return results +} + /** * How well the browser knows a host, then how much it is used, then how * recently, then alphabetically so the same corpus always comes back in the diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index d907369426b..b4333837c48 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -243,9 +243,9 @@ const ResourceTabItem = memo(function ResourceTabItem({ > {config.renderTabIcon(resource, 'mr-1.5 size-[14px]')} {displayName} - {hasActivity && !isActive && ( + {hasActivity && !isActive && !isHovered && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index dd5d02da37d..eaefecea249 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -55,7 +55,13 @@ import { UserInput, type UserInputHandle, } from './components' -import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks' +import { + getMothershipUseChatOptions, + type ResourceEventOptions, + shouldActivateResourceEvent, + useChat, + useMothershipResize, +} from './hooks' import type { FileAttachmentForApi, MothershipResource, @@ -211,14 +217,13 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const activeResourceParamRef = useRef(activeResourceParam) activeResourceParamRef.current = activeResourceParam - function handleResourceEvent(resourceId: string) { - // Agent work should always make the resource surface available, but it - // must never replace an existing selection. Activity in another resource - // stays in the background and gets an attention marker instead. + function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) { + // Agent work makes the resource surface available without replacing an + // existing selection. Explicit user navigation can request activation. if (isResourceCollapsedRef.current) setIsResourceCollapsed(false) const activeResourceId = activeResourceParamRef.current - if (activeResourceId && activeResourceId !== resourceId) { + if (!shouldActivateResourceEvent(activeResourceId, resourceId, options)) { setResourceActivityIds((current) => new Set(current).add(resourceId)) return } @@ -228,7 +233,10 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) next.delete(resourceId) return next }) - if (activeResourceId !== resourceId) setActiveResourceUrl(resourceId) + if (activeResourceId !== resourceId) { + activeResourceParamRef.current = resourceId + setActiveResourceUrl(resourceId) + } } const { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts index 995df519868..8c1fa13edd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts @@ -1,6 +1,8 @@ +export type { ResourceEventOptions } from './use-chat' export { getMothershipUseChatOptions, getWorkflowCopilotUseChatOptions, + shouldActivateResourceEvent, useChat, } from './use-chat' export { useMothershipResize } from './use-mothership-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index 281714e81d9..fa4d09e96b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -13,6 +13,7 @@ import { panelForExecutingClientTool, reconcileLiveAssistantTurn, selectReconnectReplayState, + shouldActivateResourceEvent, waitForDetachedChatResolution, } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import type { @@ -30,6 +31,20 @@ vi.mock('next/navigation', () => ({ }), })) +describe('shouldActivateResourceEvent', () => { + it('keeps background agent activity from replacing another selected resource', () => { + expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(false) + }) + + it('allows an explicit user action to replace another selected resource', () => { + expect( + shouldActivateResourceEvent('file-1', 'browser-session', { + activate: true, + }) + ).toBe(true) + }) +}) + function userMessage(id: string): PersistedMessage { return { id, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index acbea0bd61f..926e87362e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -30,7 +30,7 @@ import { onOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel' import { cancelActiveBrowserTools, initBrowserAgentTransport, - sendBrowserPanelAction, + openUrlInNewBrowserTab, } from '@/lib/browser-agent/transport' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { toDisplayMessage } from '@/lib/copilot/chat/display-message' @@ -1188,8 +1188,22 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId return true } +export interface ResourceEventOptions { + activate?: boolean +} + +export type ResourceEventHandler = (resourceId: string, options?: ResourceEventOptions) => void + +export function shouldActivateResourceEvent( + activeResourceId: string | null, + resourceId: string, + options?: ResourceEventOptions +): boolean { + return options?.activate === true || !activeResourceId || activeResourceId === resourceId +} + export interface UseChatOptions { - onResourceEvent?: (resourceId: string) => void + onResourceEvent?: ResourceEventHandler apiPath?: string stopPath?: string workflowId?: string @@ -1923,14 +1937,20 @@ export function useChat( [workspaceId] ) - const openBrowserResource = useCallback(() => { - addResource({ - type: 'browser', - id: BROWSER_SESSION_RESOURCE_ID, - title: 'Browser', - }) - onResourceEventRef.current?.(BROWSER_SESSION_RESOURCE_ID) - }, [addResource]) + const openBrowserResource = useCallback( + (activate = false) => { + addResource({ + type: 'browser', + id: BROWSER_SESSION_RESOURCE_ID, + title: 'Browser', + }) + onResourceEventRef.current?.( + BROWSER_SESSION_RESOURCE_ID, + activate ? { activate: true } : undefined + ) + }, + [addResource] + ) const getResourceActivityTracker = useCallback( (generation: number, targetChatId?: string) => { @@ -2046,8 +2066,12 @@ export function useChat( // (message components dispatch the request; this hook owns the resource). useEffect(() => { return onOpenInBrowserPanel((url) => { - openBrowserResource() - sendBrowserPanelAction('navigate', { url }, desktopScopeIdRef.current) + openBrowserResource(true) + void openUrlInNewBrowserTab(url, desktopScopeIdRef.current).catch((error) => { + logger.warn('Failed to open chat link in a new browser tab', { + error: getErrorMessage(error), + }) + }) }) }, [openBrowserResource]) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx index 327771b3a5b..bb3e1a1109e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx @@ -87,8 +87,22 @@ vi.mock('@sim/emcn', () => ({ ), Label: ({ children }: { children: ReactNode }) => {children}, - Switch: ({ checked }: { checked: boolean }) => ( - ' + const button = visible(document.querySelector('button') as HTMLButtonElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Send') + let clicked = false + button.addEventListener('click', () => { + clicked = true + }) + + window.history.pushState({}, '', '/client/T123/C456') + + expect(clickElement(ref)).toMatchObject({ dispatched: true, refRecovered: false }) + expect(clicked).toBe(true) + }) + + it('recovers a replaced ref after a same-document URL change', () => { + document.body.innerHTML = '' + const original = visible(document.querySelector('button') as HTMLButtonElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Messages') + const replacement = visible(original.cloneNode(true) as HTMLButtonElement) + let clicked = false + replacement.addEventListener('click', () => { + clicked = true + }) + + window.history.pushState({}, '', '/client/T123/C456') + original.replaceWith(replacement) + + expect(clickElement(ref)).toMatchObject({ dispatched: true, refRecovered: true }) + expect(clicked).toBe(true) + }) + it('refuses to recover a ref when replacement is ambiguous', () => { document.body.innerHTML = '' const original = visible(document.querySelector('button') as HTMLButtonElement) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index f5f4cb2f9a3..3bdb88a64c0 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -109,6 +109,7 @@ export function collectSnapshot(startingElementId = 0): unknown { '[onclick]', '[contenteditable="true"]', '[contenteditable=""]', + '[contenteditable="plaintext-only"]', ].join(', ') const landmarkSelector = [ 'nav', @@ -593,7 +594,7 @@ export function collectSnapshot(startingElementId = 0): unknown { /** * React commonly replaces a control's DOM node while preserving its - * semantics. Recover only when the old page URL and a strong semantic + * semantics. Recover only when the old page origin and a strong semantic * fingerprint still identify one candidate; a weak or ambiguous match is a * real stale ref, never permission to click something nearby. */ @@ -601,6 +602,19 @@ export function collectSnapshot(startingElementId = 0): unknown { const locator = locators[id] if (!locator) return null + // Origin, not full URL: a live SPA rewrites its path with pushState + // between snapshot and act (Slack does so continuously), while the + // element the model chose is often still the same mounted node. The + // origin still pins the document/frame; the role/name/attribute and + // ancestor/context signatures below pin the element itself. + const pageOriginOf = (url: string): string => { + try { + return new URL(url).origin + } catch { + return url + } + } + const stableAttributes = [ 'id', 'href', @@ -620,7 +634,7 @@ export function collectSnapshot(startingElementId = 0): unknown { const identityMatches = (candidate: Element, connected = false): boolean => { if ( candidate.tagName.toUpperCase() !== locator.tag || - pageUrlFor(candidate) !== locator.url || + pageOriginOf(pageUrlFor(candidate)) !== pageOriginOf(locator.url) || roleFor(candidate) !== locator.role ) { return false @@ -1028,7 +1042,7 @@ export function clickElement( addCandidate(el) for (const candidate of Array.from( el.querySelectorAll( - 'input, textarea, [contenteditable="true"], [contenteditable=""]' + 'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]' ) )) { addCandidate(candidate) @@ -1244,7 +1258,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { addEditable(el) for (const candidate of Array.from( el.querySelectorAll( - 'input, textarea, [contenteditable="true"], [contenteditable=""]' + 'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]' ) )) { addEditable(candidate) @@ -1770,7 +1784,7 @@ export function typeIntoElement(id: number, text: string, submit: boolean): unkn addEditable(el) for (const candidate of Array.from( el.querySelectorAll( - 'input, textarea, [contenteditable="true"], [contenteditable=""]' + 'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]' ) )) { addEditable(candidate) From f54f7e003f52144be325f5a8dc40c108c4e4487f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 15:06:40 -0700 Subject: [PATCH 049/103] Harden workflow sanitization and Slack setup --- .../connect-slack-bot-modal.tsx | 12 ++--- .../workflow/edit-workflow/validation.ts | 17 ++++++- apps/sim/lib/copilot/vfs/serializers.ts | 45 +++++++++++++++++++ .../sanitization/json-sanitizer.test.ts | 44 +++++++++++++++++- .../workflows/sanitization/json-sanitizer.ts | 37 ++++++++++++++- apps/sim/triggers/constants.ts | 10 +++++ apps/sim/triggers/webhook-url.ts | 10 +++++ 7 files changed, 163 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index ba923cc1ffd..31989db2375 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -17,13 +17,13 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { SlackIcon } from '@/components/icons' -import { getBaseUrl } from '@/lib/core/utils/urls' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { useCreateWorkspaceCredential, useUpdateWorkspaceCredential, } from '@/hooks/queries/credentials' import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' const logger = createLogger('ConnectSlackBotModal') @@ -109,13 +109,9 @@ export function ConnectSlackBotModal({ } }, [open, created, isReconnect, initialDisplayName, initialDescription]) - // NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be - // able to reach this URL, so it has to be the app's public base (e.g. the - // tunnel host in dev), not whatever host the browser happens to be on. - const requestUrl = useMemo( - () => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`, - [credentialId] - ) + // Shared server-side derivation: uses the app public base (not + // window.location.origin) so Slack's servers can reach it. + const requestUrl = useMemo(() => buildSlackCustomBotRequestUrl(credentialId), [credentialId]) const manifestJson = useMemo(() => { const manifest = buildSlackManifest(selected, { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 2780a902496..d53f5ddc203 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -22,7 +22,11 @@ import { BlockType, EDGE, normalizeName } from '@/executor/constants' import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models' import { isPiByokOnlyMode } from '@/providers/pi-providers' import { getTool } from '@/tools/utils' -import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { + TRIGGER_ROUTING_FIELD, + TRIGGER_RUNTIME_SUBBLOCK_IDS, + TRIGGER_WEBHOOK_URL_FIELD, +} from '@/triggers/constants' import type { EdgeHandleValidationResult, EditWorkflowOperation, @@ -75,6 +79,17 @@ export function validateInputsForBlock( inputs = omit(inputs, [TRIGGER_WEBHOOK_URL_FIELD]) } + if (TRIGGER_ROUTING_FIELD in inputs) { + errors.push({ + blockId, + blockType, + field: TRIGGER_ROUTING_FIELD, + value: inputs[TRIGGER_ROUTING_FIELD], + error: `"${TRIGGER_ROUTING_FIELD}" is read-only. Event routing is derived from the selected credential and cannot be edited on the block.`, + }) + inputs = omit(inputs, [TRIGGER_ROUTING_FIELD]) + } + const blockConfig = getBlock(blockType) if (!blockConfig) { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index cb5348a061d..5ad229b53dd 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -12,6 +12,7 @@ import { SANDBOX_SELECTABLE_CLI_TOOL_IDS, } from '@/lib/execution/remote-sandbox/cli-tools' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' @@ -24,6 +25,8 @@ import { SIM_AUTO_MODEL_ID, } from '@/providers/models' import type { ToolConfig, ToolHostingCondition } from '@/tools/types' +import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' /** The service-account alternative to OAuth for a service, when it offers one. */ export interface VfsServiceAccountAuth { @@ -733,6 +736,15 @@ export function serializeCredentials( // credential) — they reconnect differently, so the agent must branch on // this. Env-var credentials carry no type. type: a.credentialType, + // Derived, not stored: the public Request URL a Slack custom-bot app + // posts events to. One per credential; every workflow trigger that + // selects this credential shares it. This is what the setup wizard shows + // in Slack's Event Subscriptions step. + ...(a.credentialType === 'service_account' && + a.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && + a.id + ? { requestUrl: buildSlackCustomBotRequestUrl(a.id) } + : {}), connectedAt: a.createdAt.toISOString(), })), null, @@ -1137,6 +1149,38 @@ export function serializeIntegrationSchema( ) } +/** + * Derived setup reference for `slack_oauth` — the same material the custom-bot + * setup wizard shows, surfaced so the copilot can walk a user (or the browser + * agent) through Slack app creation without guessing. None of this is a block + * field: the manifest is a template for api.slack.com, and the Request URL is a + * per-credential property (`requestUrl` in environment/credentials.json). + */ +function slackOAuthSetupReference(): Record { + const defaults = SLACK_CAPABILITIES.filter((c) => c.defaultChecked).map((c) => c.id) + return { + note: + 'Setup reference (derived; NOT block fields). A custom bot is a reusable workspace credential: ' + + 'one Slack app, one Request URL, shared by every trigger that selects it. To create or rotate one, ' + + 'emit a service_account credential card for provider "slack" — the wizard collects the signing secret ' + + 'and bot token without them entering the chat. Existing custom bots appear as service_account ' + + 'credentials in environment/credentials.json, each with its requestUrl.', + requestUrlPattern: '{baseUrl}/api/webhooks/slack/custom/{credentialId}', + capabilities: SLACK_CAPABILITIES.map((c) => ({ + id: c.id, + label: c.label, + group: c.group, + defaultChecked: c.defaultChecked, + scopes: c.scopes, + events: c.events, + })), + defaultManifest: buildSlackManifest(new Set(defaults), { + appName: 'Sim Bot', + webhookUrl: '', + }), + } +} + /** * Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json */ @@ -1160,6 +1204,7 @@ export function serializeTriggerSchema(trigger: { webhook: trigger.webhook || undefined, subBlocks: trigger.subBlocks.map(serializeSubBlock), outputs: trigger.outputs, + ...(trigger.id === 'slack_oauth' ? { setup: slackOAuthSetupReference() } : {}), }, null, 2 diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 4ac13e4a496..2e8ff1b59d6 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -5,7 +5,7 @@ import { resetUrlsMock, urlsMockFns } from '@sim/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' beforeAll(() => { urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test') @@ -282,3 +282,45 @@ describe('sanitizeForCopilot webhook trigger URL', () => { expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) }) }) + +describe('sanitizeForCopilot credential-routed trigger routing', () => { + it('synthesizes the read-only routing note for a slack_v2 block in trigger mode', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('slack-1', { + type: 'slack_v2', + name: 'Slack Trigger', + enabled: true, + triggerMode: true, + subBlocks: { + selectedTriggerId: { id: 'selectedTriggerId', type: 'short-input', value: 'slack_oauth' }, + customBotCredential: { + id: 'customBotCredential', + type: 'oauth-input', + value: 'cred-123', + }, + }, + }) + ) + + const routing = result.blocks['slack-1'].inputs?.[TRIGGER_ROUTING_FIELD] as + | Record + | undefined + expect(routing?.model).toBe('credential-routed') + expect(routing?.selectedCredentialId).toBe('cred-123') + expect(String(routing?.note)).toContain('no per-workflow webhook URL') + expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) + }) + + it('omits the routing note when the block is not in trigger mode', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('slack-1', { + type: 'slack_v2', + name: 'Slack Action', + enabled: true, + subBlocks: {}, + }) + ) + + expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_ROUTING_FIELD) + }) +}) diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 5a63706a447..bd4f2377b12 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -12,8 +12,8 @@ import type { WorkflowState, } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' -import { blockAdvertisesWebhookUrl } from '@/triggers/webhook-url' +import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { blockAdvertisesWebhookUrl, resolveBlockTriggerId } from '@/triggers/webhook-url' /** * Sanitized workflow state for copilot (removes all UI-specific data) @@ -364,6 +364,35 @@ function resolveTriggerWebhookUrl(blockId: string, block: BlockState): string | } } +/** Trigger ids that deliver by credential routing — no per-workflow URL exists. */ +const CREDENTIAL_ROUTED_TRIGGER_IDS = new Set(['slack_oauth']) + +/** + * Derived routing note for trigger blocks that have NO per-workflow webhook URL + * (credential-routed delivery, e.g. Slack v2's `slack_oauth`). Mirrors what the + * setup wizard shows: events arrive at the selected credential's endpoint — a + * custom bot's per-credential Request URL (surfaced as `requestUrl` on that + * credential in environment/credentials.json) or the shared Sim-app endpoint + * routed by Slack workspace. Surfaced as the read-only + * {@link TRIGGER_ROUTING_FIELD} input; rejected on write by `edit_workflow`. + */ +function resolveTriggerRouting(block: BlockState): Record | null { + const triggerId = resolveBlockTriggerId(block) + if (!triggerId || !CREDENTIAL_ROUTED_TRIGGER_IDS.has(triggerId)) return null + const selected = + block.subBlocks?.customBotCredential?.value ?? block.subBlocks?.manualBotCredential?.value + const selectedCredentialId = typeof selected === 'string' && selected.length > 0 ? selected : null + return { + model: 'credential-routed', + note: + 'This trigger has no per-workflow webhook URL. Events are delivered via the selected Slack credential: ' + + 'a custom bot posts to its per-credential Request URL (the requestUrl field on that credential in ' + + 'environment/credentials.json — the same URL the setup wizard shows for Slack Event Subscriptions); ' + + 'a Sim-app connection routes by Slack workspace automatically. Derived at read time; not an editable field.', + ...(selectedCredentialId ? { selectedCredentialId } : {}), + } +} + /** * Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else) * Uses 0-indexed numbering for else-if conditions @@ -587,6 +616,10 @@ export function sanitizeForCopilot( if (webhookUrl) { inputs[TRIGGER_WEBHOOK_URL_FIELD] = webhookUrl } + const triggerRouting = resolveTriggerRouting(block) + if (triggerRouting) { + inputs[TRIGGER_ROUTING_FIELD] = triggerRouting + } } // Check if this is a loop or parallel (has children) diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index 64ed9a898ac..94f2cf9c382 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -40,6 +40,16 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ */ export const TRIGGER_WEBHOOK_URL_FIELD = 'triggerWebhookUrl' +/** + * Derived, read-only input surfaced on copilot reads of trigger blocks that + * route by CREDENTIAL rather than a per-workflow webhook URL (e.g. Slack v2's + * `slack_oauth`). Explains where events actually arrive — a custom bot's + * per-credential Request URL or the shared Sim-app endpoint — so the copilot + * can answer "where do I point Slack?" without inventing a field. Never + * stored; rejected on write like {@link TRIGGER_WEBHOOK_URL_FIELD}. + */ +export const TRIGGER_ROUTING_FIELD = 'triggerRouting' + /** * Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled. * This prevents runaway errors from continuously executing failing workflows. diff --git a/apps/sim/triggers/webhook-url.ts b/apps/sim/triggers/webhook-url.ts index 289e928a9cf..6f28289f084 100644 --- a/apps/sim/triggers/webhook-url.ts +++ b/apps/sim/triggers/webhook-url.ts @@ -12,6 +12,16 @@ export function buildWebhookTriggerUrl(path: string): string { return `${getBaseUrl()}/api/webhooks/trigger/${path}` } +/** + * The Request URL a Slack custom-bot app posts events to. One URL per + * credential (not per workflow): the endpoint verifies with the credential's + * signing secret and fans out to every workflow whose trigger routes by this + * credential id. Uses the app's public base so Slack's servers can reach it. + */ +export function buildSlackCustomBotRequestUrl(credentialId: string): string { + return `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}` +} + function subBlockValue(block: BlockState, subBlockId: string): unknown { return block.subBlocks?.[subBlockId]?.value } From 6f36331b7a979efb7c3db09a65a92829e290c63a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 15:25:32 -0700 Subject: [PATCH 050/103] feat(desktop): coordinate clicks, caret insertion, and drag for the browser agent --- apps/desktop/src/main/browser-agent/cdp.ts | 192 +++++++- .../src/main/browser-agent/driver.test.ts | 209 ++++++++ apps/desktop/src/main/browser-agent/driver.ts | 298 +++++++++++- .../main/browser-agent/page-functions.test.ts | 81 ++++ .../src/main/browser-agent/page-functions.ts | 152 ++++++ .../lib/copilot/generated/tool-catalog-v1.ts | 372 ++++++++++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 455 ++++++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 5 + packages/browser-protocol/src/index.ts | 3 + 9 files changed, 1743 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/cdp.ts b/apps/desktop/src/main/browser-agent/cdp.ts index 4a7088e1b24..6387461e803 100644 --- a/apps/desktop/src/main/browser-agent/cdp.ts +++ b/apps/desktop/src/main/browser-agent/cdp.ts @@ -10,6 +10,7 @@ */ import type { BrowserTheme } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' import type { WebContents, WebFrameMain } from 'electron' const logger = createLogger('BrowserAgentCdp') @@ -127,12 +128,29 @@ export async function setColorScheme(contents: WebContents, theme: BrowserTheme) }) } +/** Live drag-interception state while a dragPointer call is in flight. */ +interface DragInterception { + intercepted: boolean + data: Record | null +} +const dragInterceptionsByContents = new WeakMap() + function handleDebuggerEvent( contents: WebContents, method: string, params: Record, parentSessionId?: string ): void { + if (method === 'Input.dragIntercepted') { + const interception = dragInterceptionsByContents.get(contents) + if (interception) { + interception.intercepted = true + const data = params.data + interception.data = + data && typeof data === 'object' ? (data as Record) : null + } + return + } if (method === 'Target.attachedToTarget') { const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '' const targetInfo = params.targetInfo @@ -367,7 +385,9 @@ interface CdpViewport { * edge within {@link MAX_SCREENSHOT_EDGE}. Falls back to an unclipped capture * when layout metrics are unavailable. */ -export async function captureScreenshot(contents: WebContents): Promise { +export async function captureScreenshot( + contents: WebContents +): Promise<{ dataUrl: string; scale: number }> { const metrics = await send<{ cssLayoutViewport?: CdpViewport layoutViewport?: CdpViewport @@ -376,6 +396,8 @@ export async function captureScreenshot(contents: WebContents): Promise const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport const width = viewport?.clientWidth ?? 0 const height = viewport?.clientHeight ?? 0 + const scale = + width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1 const clip = width > 0 && height > 0 ? { @@ -383,7 +405,7 @@ export async function captureScreenshot(contents: WebContents): Promise y: 0, width, height, - scale: Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)), + scale, } : undefined @@ -392,7 +414,7 @@ export async function captureScreenshot(contents: WebContents): Promise quality: SCREENSHOT_QUALITY, ...(clip ? { clip } : {}), }) - return `data:image/jpeg;base64,${result.data}` + return { dataUrl: `data:image/jpeg;base64,${result.data}`, scale } } /** One half of a trusted key press (`Input.dispatchKeyEvent` params). */ @@ -430,7 +452,8 @@ export async function clickAt( contents: WebContents, x: number, y: number, - moveBeforePress = true + moveBeforePress = true, + clickCount = 1 ): Promise { if (moveBeforePress) await moveMouse(contents, x, y) let pressed = false @@ -439,22 +462,26 @@ export async function clickAt( // response (navigation/process swap). In that ambiguous case a release is // safer than leaving Blink's pointer state stuck down. pressed = true - await sendInput(contents, 'Input.dispatchMouseEvent', { - type: 'mousePressed', - x, - y, - button: 'left', - buttons: 1, - clickCount: 1, - }) - await sendInput(contents, 'Input.dispatchMouseEvent', { - type: 'mouseReleased', - x, - y, - button: 'left', - buttons: 0, - clickCount: 1, - }) + // A multi-click is a sequence of press/release pairs with an increasing + // clickCount — Blink synthesizes dblclick from the pair whose count is 2. + for (let count = 1; count <= clickCount; count++) { + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mousePressed', + x, + y, + button: 'left', + buttons: 1, + clickCount: count, + }) + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', + x, + y, + button: 'left', + buttons: 0, + clickCount: count, + }) + } pressed = false } finally { if (pressed && !contents.isDestroyed()) { @@ -473,6 +500,131 @@ export async function clickAt( } } +/** + * Drags the pointer from one viewport point to another through the trusted + * pipeline: press, a threshold-crossing nudge, interpolated moves with the + * button held, a settle hold over the target, then release or drop. + * + * Two drag models are covered by the one call. Pointer-sensor libraries + * (dnd-kit, react-beautiful-dnd, canvas apps) treat the held-button move + * sequence exactly like a human drag. Native HTML5 `draggable="true"` + * sources instead START a Blink drag session on the press+move — with + * `Input.setInterceptDrags` enabled, Chromium reports it as + * `Input.dragIntercepted` and the remaining movement is delivered as trusted + * `Input.dispatchDragEvent` dragEnter/dragOver events ending in a `drop` + * (the technique Playwright uses). Both paths are trusted input. + */ +export async function dragPointer( + contents: WebContents, + from: { x: number; y: number }, + to: { x: number; y: number }, + steps = 12, + stepDelayMs = 20 +): Promise<{ nativeDragIntercepted: boolean }> { + const interception: DragInterception = { intercepted: false, data: null } + dragInterceptionsByContents.set(contents, interception) + let interceptEnabled = false + try { + await send(contents, 'Input.setInterceptDrags', { enabled: true }) + interceptEnabled = true + } catch { + // Chromium without drag interception: the pointer-only path still works + // for pointer-sensor drags; native HTML5 sources will report no effect. + } + await moveMouse(contents, from.x, from.y) + let pressed = false + let dragEnterSent = false + const dragMove = async (x: number, y: number) => { + if (interception.intercepted && interception.data) { + await sendInput(contents, 'Input.dispatchDragEvent', { + type: dragEnterSent ? 'dragOver' : 'dragEnter', + x, + y, + data: interception.data, + }) + dragEnterSent = true + } else { + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mouseMoved', + x, + y, + button: 'left', + buttons: 1, + }) + } + } + try { + pressed = true + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mousePressed', + x: from.x, + y: from.y, + button: 'left', + buttons: 1, + clickCount: 1, + }) + // Small first nudge so libraries with a start threshold (commonly 3-8px) + // register the drag before the pointer sweeps across the page. + await dragMove(from.x + Math.sign(to.x - from.x || 1) * 4, from.y + 2) + await sleep(stepDelayMs) + const stepCount = Math.max(2, steps) + for (let step = 1; step <= stepCount; step++) { + const progress = step / stepCount + await dragMove(from.x + (to.x - from.x) * progress, from.y + (to.y - from.y) * progress) + await sleep(stepDelayMs) + } + // Hold over the target so drop zones running enter/over animations settle + // before the release lands. + await sleep(120) + if (interception.intercepted && interception.data) { + await sendInput(contents, 'Input.dispatchDragEvent', { + type: 'drop', + x: to.x, + y: to.y, + data: interception.data, + }) + // Blink ended the intercepted drag session itself; a trailing + // mouseReleased would be a stray click on the drop target. + pressed = false + } else { + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: to.x, + y: to.y, + button: 'left', + buttons: 0, + clickCount: 1, + }) + pressed = false + } + return { nativeDragIntercepted: interception.intercepted } + } finally { + if (pressed && !contents.isDestroyed()) { + if (interception.intercepted && interception.data) { + await sendInput(contents, 'Input.dispatchDragEvent', { + type: 'dragCancel', + x: to.x, + y: to.y, + data: interception.data, + }).catch(() => {}) + } else { + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: to.x, + y: to.y, + button: 'left', + buttons: 0, + clickCount: 1, + }).catch(() => {}) + } + } + dragInterceptionsByContents.delete(contents) + if (interceptEnabled && !contents.isDestroyed()) { + await send(contents, 'Input.setInterceptDrags', { enabled: false }).catch(() => {}) + } + } +} + /** * Inserts text at the focused element's selection (replacing it) through the * native IME path — works in plain fields and code editors alike. diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 5970afec615..38eea541b68 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1874,4 +1874,213 @@ describe('credential protection', () => { expect(result).toMatchObject({ ok: true, result: { dispatched: true } }) expect(clickReads).toBeGreaterThan(1) }) + + it('clicks a coordinate point with native input and reports the target', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { + found: true, + element: 'button "Send"', + editable: false, + secret: false, + fileInput: false, + cursor: 'pointer', + }, + readActiveElementState: {}, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 120, y: 240 }) + + expect(result).toMatchObject({ + ok: true, + result: { + dispatched: true, + trusted: true, + clickedAt: { x: 120, y: 240 }, + target: 'button "Send"', + }, + }) + const presses = cdpCalls(contents, 'Input.dispatchMouseEvent').filter( + ([, event]) => (event as { type?: string }).type === 'mousePressed' + ) + expect(presses).toHaveLength(1) + }) + + it('double-clicks a coordinate point as a rising clickCount sequence', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { found: true, element: 'canvas', editable: false }, + readActiveElementState: {}, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_click_at', { + x: 10, + y: 20, + clickCount: 2, + }) + + expect(result).toMatchObject({ ok: true, result: { clickCount: 2 } }) + const counts = cdpCalls(contents, 'Input.dispatchMouseEvent') + .filter(([, event]) => (event as { type?: string }).type === 'mousePressed') + .map(([, event]) => (event as { clickCount?: number }).clickCount) + expect(counts).toEqual([1, 2]) + }) + + it('refuses a coordinate click on a file input', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { found: true, element: 'input', fileInput: true }, + }) + + const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 5, y: 5 }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/file input/) + expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) + }) + + it('rejects a coordinate click outside the viewport with mapping guidance', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { error: 'outside-viewport' }, + }) + + const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 9999, y: 5 }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/divide image pixels by its scale/) + }) + + it('inserts text into the focused editable at the caret', async () => { + const contents = await openPage() + respondWith(contents, { + activeElementSecrecy: 'safe', + describeFocusedEditable: { editable: true, kind: 'contenteditable' }, + readActiveElementState: { activeElement: 'div', valueLength: 12 }, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_insert_text', { + text: 'hello world', + }) + + expect(result).toMatchObject({ + ok: true, + result: { dispatched: true, trusted: true, kind: 'contenteditable', insertedChars: 11 }, + }) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) + }) + + it('refuses insertion when nothing editable holds focus', async () => { + const contents = await openPage() + respondWith(contents, { + activeElementSecrecy: 'safe', + describeFocusedEditable: { editable: false, reason: 'none' }, + }) + + const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/No element is focused/) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + }) + + it('refuses insertion while a password field holds focus', async () => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'secret' }) + + const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/Refusing to act on a password field/) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + }) + + it('drags between coordinate points through the trusted pointer pipeline', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { found: true, element: 'div "Card"' }, + readActiveElementState: {}, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_drag', { + fromX: 40, + fromY: 50, + toX: 200, + toY: 260, + }) + + expect(result).toMatchObject({ + ok: true, + result: { dispatched: true, trusted: true, from: { x: 40, y: 50 }, to: { x: 200, y: 260 } }, + }) + const events = cdpCalls(contents, 'Input.dispatchMouseEvent').map( + ([, event]) => (event as { type?: string }).type + ) + expect(events[0]).toBe('mouseMoved') + expect(events).toContain('mousePressed') + expect(events[events.length - 1]).toBe('mouseReleased') + expect(cdpCalls(contents, 'Input.setInterceptDrags').length).toBeGreaterThan(0) + }) + + it('drags from a snapshot element to a coordinate target', async () => { + const contents = await openPage() + respondWith(contents, { + clickElement: { dispatched: false, x: 24, y: 48, element: 'Card "Ship it"' }, + describePointTarget: { found: true, element: 'section "Done"' }, + readActiveElementState: {}, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_drag', { + fromElementId: 0, + toX: 300, + toY: 60, + }) + + expect(result).toMatchObject({ + ok: true, + result: { dispatched: true, from: { x: 24, y: 48, element: 'Card "Ship it"' } }, + }) + }) + + it('rejects a drag whose endpoints are the same point', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { found: true, element: 'div' }, + }) + + const result = await driver.executeTool('chat-test', 'browser_drag', { + fromX: 10, + fromY: 10, + toX: 10, + toY: 10, + }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/same point/) + }) + + it('returns the screenshot scale for coordinate mapping', async () => { + const contents = await openPage() + vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { + if (method === 'Page.getLayoutMetrics') { + return Promise.resolve({ + cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 }, + }) + } + if (method === 'Page.captureScreenshot') { + return Promise.resolve({ data: 'c2lt' }) + } + return Promise.resolve(undefined) + }) + respondWith(contents, { getViewportInfo: { width: 2048, height: 1024 } }) + + const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) + + expect(result).toMatchObject({ ok: true, result: { scale: 0.5 } }) + }) }) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index f6c43f21c60..1c1003f36ed 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -46,6 +46,8 @@ import { activeElementSecrecy, clickElement, collectSnapshot, + describeFocusedEditable, + describePointTarget, focusElementForTyping, getViewportInfo, hoverElement, @@ -892,6 +894,11 @@ function unwrapPageResult(result: unknown): unknown { if (code === 'not-editable') { throw new ToolError('That element is not a text input — pick an editable element.') } + if (code === 'outside-viewport') { + throw new ToolError( + 'That point is outside the visible viewport. Coordinates are CSS pixels within the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale, and scroll the target into view first.' + ) + } if (code === 'ambiguous-editable') { throw new ToolError( 'That composite control contains multiple editable fields. Take a fresh browser_snapshot and target the exact field.' @@ -2002,19 +2009,21 @@ async function executeToolInner( case 'browser_screenshot': { const contents = session.requireAutomationTab().view.webContents - const dataUrl = await cdp.captureScreenshot(contents).catch(() => null) - if (dataUrl === null) { + const shot = await cdp.captureScreenshot(contents).catch(() => null) + if (shot === null) { throw new ToolError( 'Could not capture the page. Use browser_snapshot or browser_read_text instead.' ) } - if (dataUrl.length > 8_000_000) { + if (shot.dataUrl.length > 8_000_000) { throw new ToolError( 'The screenshot result was too large to return safely. Use browser_snapshot or browser_read_text instead.' ) } const viewport = await execInPage(contents, getViewportInfo, []).catch(() => null) - return { dataUrl, viewport } + // scale maps image pixels back to CSS viewport pixels for the + // coordinate tools: cssX = imageX / scale. + return { dataUrl: shot.dataUrl, viewport, scale: shot.scale } } case 'browser_extract': { @@ -3106,6 +3115,287 @@ async function executeToolInner( } } + case 'browser_click_at': { + const clickedTab = session.requireAutomationTab() + const contents = clickedTab.view.webContents + const x = requireNum(params, 'x') + const y = requireNum(params, 'y') + const clickCount = num(params, 'clickCount') ?? 1 + if (![1, 2, 3].includes(clickCount)) { + throw new ToolError('clickCount must be 1 (click), 2 (double-click), or 3 (triple-click).') + } + const clickNavigationEpoch = navigationEpoch(contents) + assertCurrentExecution() + assertActiveContents(contents) + const pointTarget = unwrapPageResult( + await execInPage(contents, describePointTarget, [x, y], false, executionDeadline) + ) + if (!isRecordLike(pointTarget) || pointTarget.found !== true) { + throw new ToolError( + 'Nothing is rendered at that point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale.' + ) + } + if (pointTarget.fileInput === true) { + throw new ToolError( + 'Refusing to click a file input because it opens a native chooser the browser agent cannot inspect or complete. Ask the user to upload the file themselves.' + ) + } + const beforePage = await pageActionState(contents, true) + const beforeElement = await activeElementState(contents) + assertCurrentExecution() + assertActiveContents(contents, clickNavigationEpoch) + try { + await cdp.clickAt(contents, x, y, true, clickCount) + } catch (error) { + throw new ToolError( + `Native click dispatch failed (${getErrorMessage(error)}). The action was not retried because a partial pointer press may already have reached the page. Take a fresh snapshot before continuing.` + ) + } + await sleep(150) + const afterElement = await activeElementState(contents) + const afterPage = await pageActionState(contents) + const observation = pageEffect(beforePage, afterPage, beforeElement, afterElement) + const activeTab = session.automationTab() + const tabChanged = activeTab?.id !== clickedTab.id + const effectObserved = + observation.effect.urlChanged || + observation.effect.dialogChanged || + observation.effect.popupChanged || + observation.effect.targetChanged || + (pointTarget.editable === true && observation.effect.focusChanged) || + tabChanged + const dialogs = Array.isArray(afterPage.dialogs) ? afterPage.dialogs.map(String) : [] + const notes: string[] = [] + if (pointTarget.secret === true) { + notes.push( + 'The point resolves to a password field. Focusing it is fine, but typing there is refused — call browser_request_takeover for credentials.' + ) + } + if (pointTarget.crossOriginFrame === true) { + notes.push( + 'The point lands inside an embedded frame that could not be inspected; the click was dispatched but its target is unverified.' + ) + } + if (!effectObserved) { + notes.push( + observation.possibleEffectObserved || tabChanged + ? 'Only background DOM/title churn followed the click; inspect the page before treating it as successful.' + : 'No strong observable page change followed the click; inspect the page before treating it as successful.' + ) + } + return { + dispatched: true, + trusted: true, + activation: 'native-pointer', + clickedAt: { x, y }, + clickCount, + target: pointTarget.element, + targetCursor: pointTarget.cursor, + effectObserved, + possibleEffectObserved: observation.possibleEffectObserved || tabChanged, + effect: { ...observation.effect, tabChanged }, + dialogs, + ...(tabChanged && activeTab + ? { activeTab: { tabId: activeTab.id, url: activeTab.view.webContents.getURL() } } + : {}), + ...(notes.length > 0 ? { note: notes.join(' ') } : {}), + } + } + + case 'browser_insert_text': { + const text = requireStr(params, 'text') + const submit = params.submit === true + const contents = session.requireAutomationTab().view.webContents + const insertNavigationEpoch = navigationEpoch(contents) + const target: PageExecutionTarget = focusedPageTarget(contents) + const secrecy = await execInPage(target, activeElementSecrecy, []).catch(() => 'opaque') + if (secrecy === 'secret') throw new ToolError(PASSWORD_REFUSAL) + if (secrecy === 'opaque') { + throw new ToolError( + 'Focus is inside a cross-origin frame whose contents cannot be inspected, so this ' + + 'insertion could reach a password field. Call browser_request_takeover if the user needs to type here.' + ) + } + const focusState = unwrapPageResult( + await execInPage(target, describeFocusedEditable, [], false, executionDeadline) + ) + if (!isRecordLike(focusState) || focusState.editable !== true) { + const reason = isRecordLike(focusState) ? String(focusState.reason || '') : '' + throw new ToolError( + reason === 'none' + ? 'No element is focused. Click the field first (browser_click or browser_click_at), then insert text.' + : `The focused element does not accept text${reason ? ` (${reason})` : ''}. Focus an editable field first.` + ) + } + const beforePage = await pageActionState(target, true) + const beforeElement = await activeElementState(target) + assertCurrentExecution() + assertActiveContents(contents, insertNavigationEpoch) + assertFocusedTargetUnchanged(contents, target) + try { + await cdp.insertText(contents, text) + } catch (error) { + throw new ToolError( + `Native text insertion failed (${getErrorMessage(error)}). Take a fresh snapshot before retrying.` + ) + } + let submitDispatched = false + if (submit) { + await sleep(25) + assertCurrentExecution() + assertActiveContents(contents, insertNavigationEpoch) + try { + await dispatchKeyCombo(contents, parseKeyCombo('Enter')) + submitDispatched = true + } catch { + // Reported below through submitDispatched: false. + } + } + await sleep(150) + const state = await activeElementState(target) + const afterPage = await pageActionState(target) + const observation = pageEffect(beforePage, afterPage, beforeElement, state) + const effectObserved = + observation.effect.fieldChanged || + observation.effect.urlChanged || + observation.effect.dialogChanged || + observation.effect.targetChanged + return { + dispatched: true, + trusted: true, + kind: focusState.kind, + insertedChars: text.length, + ...state, + effectObserved, + possibleEffectObserved: observation.possibleEffectObserved, + effect: observation.effect, + submitRequested: submit, + submitDispatched, + ...(focusState.kind === 'canvas' || focusState.kind === 'textbox-role' + ? { + note: 'The focused editor is canvas/model-backed, so field readback cannot confirm the text — verify visually with browser_screenshot.', + } + : !effectObserved + ? { + note: 'Insertion produced no observable field or page change; inspect the page before continuing.', + } + : {}), + } + } + + case 'browser_drag': { + const draggedTab = session.requireAutomationTab() + const contents = draggedTab.view.webContents + const dragNavigationEpoch = navigationEpoch(contents) + + const resolveEndpoint = async ( + which: 'from' | 'to' + ): Promise<{ x: number; y: number; element?: string }> => { + const elementId = num(params, `${which}ElementId`) + if (elementId !== undefined) { + const target = pageTargetForElement(contents, elementId) + if (frameExecutionTarget(target, contents)) { + throw new ToolError( + `Dragging elements inside embedded frames is not supported. Use ${which}X/${which}Y viewport coordinates instead.` + ) + } + const prepared = unwrapPageResult( + await execInPageWithSettleGrace( + target, + clickElement, + [elementId, false, false], + executionDeadline + ) + ) + if ( + !isRecordLike(prepared) || + typeof prepared.x !== 'number' || + typeof prepared.y !== 'number' + ) { + throw new ToolError(`Could not resolve the ${which} element for the drag.`) + } + return { + x: prepared.x, + y: prepared.y, + ...(typeof prepared.element === 'string' ? { element: prepared.element } : {}), + } + } + const pointX = num(params, `${which}X`) + const pointY = num(params, `${which}Y`) + if (pointX === undefined || pointY === undefined) { + throw new ToolError( + `Provide either ${which}ElementId or both ${which}X and ${which}Y for the drag ${which === 'from' ? 'source' : 'target'}.` + ) + } + const probe = unwrapPageResult( + await execInPage( + contents, + describePointTarget, + [pointX, pointY], + false, + executionDeadline + ) + ) + if (!isRecordLike(probe) || probe.found !== true) { + throw new ToolError( + `Nothing is rendered at the ${which} point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale.` + ) + } + return { + x: pointX, + y: pointY, + ...(typeof probe.element === 'string' ? { element: probe.element } : {}), + } + } + + assertCurrentExecution() + assertActiveContents(contents) + const from = await resolveEndpoint('from') + const to = await resolveEndpoint('to') + if (Math.abs(from.x - to.x) < 1 && Math.abs(from.y - to.y) < 1) { + throw new ToolError('The drag source and target are the same point; nothing to drag.') + } + const beforePage = await pageActionState(contents, true) + const beforeElement = await activeElementState(contents) + assertCurrentExecution() + assertActiveContents(contents, dragNavigationEpoch) + let interception: { nativeDragIntercepted: boolean } + try { + interception = await cdp.dragPointer(contents, from, to) + } catch (error) { + throw new ToolError( + `Native drag dispatch failed (${getErrorMessage(error)}). The pointer may have been mid-drag; take a fresh snapshot to see the page's current state before retrying.` + ) + } + await sleep(200) + const afterElement = await activeElementState(contents) + const afterPage = await pageActionState(contents) + const observation = pageEffect(beforePage, afterPage, beforeElement, afterElement) + const effectObserved = + observation.effect.domChanged || + observation.effect.urlChanged || + observation.effect.dialogChanged || + observation.effect.targetChanged || + observation.effect.scrollChanged + const dialogs = Array.isArray(afterPage.dialogs) ? afterPage.dialogs.map(String) : [] + return { + dispatched: true, + trusted: true, + nativeHtml5Drag: interception.nativeDragIntercepted, + from, + to, + effectObserved, + possibleEffectObserved: observation.possibleEffectObserved, + effect: observation.effect, + dialogs, + ...(!effectObserved + ? { + note: 'No observable page change followed the drag. Verify with browser_snapshot or browser_screenshot; some drop targets only commit on their own animation frame.', + } + : {}), + } + } + case 'browser_request_takeover': { // The reason renders in the chat's tool row, not here — but require it // so the model always tells the user why control was handed over. diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index a74f95bf9c8..a8f6b8816e0 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -5,6 +5,8 @@ import { activeElementSecrecy, clickElement, collectSnapshot, + describeFocusedEditable, + describePointTarget, focusElementForTyping, getViewportInfo, hoverElement, @@ -155,6 +157,8 @@ describe('serialization contract', () => { ['readPageText', readPageText, []], ['pageContainsText', pageContainsText, ['needle']], ['getViewportInfo', getViewportInfo, []], + ['describePointTarget', describePointTarget, [10, 10]], + ['describeFocusedEditable', describeFocusedEditable, []], ] it.each(cases)('%s is self-contained', (_name, fn, args) => { @@ -1373,3 +1377,80 @@ describe('pressKeyOnPage', () => { expect(seen).toEqual(['a']) }) }) + +describe('describePointTarget', () => { + function pointAt(el: Element | null): void { + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: () => el, + }) + } + + it('describes the element at a viewport point', () => { + document.body.innerHTML = '' + pointAt(document.querySelector('button')) + + expect(describePointTarget(10, 10)).toMatchObject({ + found: true, + tag: 'button', + element: 'button "Send message"', + editable: false, + fileInput: false, + secret: false, + }) + }) + + it('flags file inputs and password fields at the point', () => { + document.body.innerHTML = '' + pointAt(document.querySelector('input')) + expect(describePointTarget(10, 10)).toMatchObject({ found: true, fileInput: true }) + + document.body.innerHTML = '' + pointAt(document.querySelector('input')) + expect(describePointTarget(10, 10)).toMatchObject({ + found: true, + secret: true, + editable: true, + }) + }) + + it('rejects points outside the viewport', () => { + expect(describePointTarget(-5, 10)).toEqual({ error: 'outside-viewport' }) + expect(describePointTarget(10, window.innerHeight + 5)).toEqual({ + error: 'outside-viewport', + }) + }) +}) + +describe('describeFocusedEditable', () => { + it('reports no focus when the body holds focus', () => { + setActiveElement(document, document.body) + expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'none' }) + }) + + it('reports a writable input as insertable', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'input:text' }) + }) + + it('reports a read-only input as not insertable', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'readonly' }) + }) + + it('reports a focused contenteditable editor as insertable', () => { + document.body.innerHTML = '
' + const editor = document.querySelector('div') as HTMLElement + Object.defineProperty(editor, 'isContentEditable', { get: () => true }) + setActiveElement(document, editor) + expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'contenteditable' }) + }) + + it('treats a focused canvas editor surface as insertable', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('canvas')) + expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'canvas' }) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 3bdb88a64c0..33af0d6f964 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -2676,3 +2676,155 @@ export function getViewportInfo(): unknown { height: window.innerHeight, } } + +/** + * Describes whatever sits at a viewport point, descending through open shadow + * roots and same-origin iframes. Coordinate-addressed actions have no + * snapshot ref to revalidate, so this probe is their safety check: the driver + * refuses file inputs outright and reports what the point resolves to so the + * model can confirm it hit what the screenshot showed. + */ +export function describePointTarget(x: number, y: number): unknown { + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + x < 0 || + y < 0 || + x >= window.innerWidth || + y >= window.innerHeight + ) { + return { error: 'outside-viewport' } + } + + let doc: Document = document + let localX = x + let localY = y + let element: Element | null = null + for (let depth = 0; depth < 10; depth++) { + if (typeof doc.elementFromPoint !== 'function') break + let found: Element | null = doc.elementFromPoint(localX, localY) + // Open shadow roots re-hit-test at the same point until a leaf host. + for (let shadowDepth = 0; shadowDepth < 10; shadowDepth++) { + const shadow = (found as HTMLElement | null)?.shadowRoot + const inner = shadow?.elementFromPoint(localX, localY) + if (!inner || inner === found) break + found = inner + } + element = found + const tag = String(found?.tagName || '').toUpperCase() + if ((tag !== 'IFRAME' && tag !== 'FRAME') || !found) break + try { + const innerDoc = (found as HTMLIFrameElement).contentDocument + if (!innerDoc) break + const rect = found.getBoundingClientRect() + localX -= rect.left + (found as HTMLIFrameElement).clientLeft + localY -= rect.top + (found as HTMLIFrameElement).clientTop + doc = innerDoc + } catch { + // Cross-origin frame — cannot inspect further; report the frame itself. + break + } + } + if (!element) return { found: false } + + const tag = String(element.tagName || '').toUpperCase() + const inputType = + tag === 'INPUT' ? String((element as HTMLInputElement).type || 'text').toLowerCase() : '' + const secret = + tag === 'INPUT' && + (inputType === 'password' || + String(element.getAttribute('autocomplete') || '') + .toLowerCase() + .split(/\s+/) + .some((token) => token === 'current-password' || token === 'new-password')) + const editable = Boolean( + tag === 'TEXTAREA' || + (tag === 'INPUT' && + ['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType)) || + (element as HTMLElement).isContentEditable + ) + + const name = ( + element.getAttribute('aria-label') || + element.getAttribute('title') || + element.getAttribute('alt') || + ((element as HTMLElement).innerText ?? element.textContent ?? '') + ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 80) + const role = element.getAttribute('role') || '' + const style = element.ownerDocument.defaultView?.getComputedStyle(element) + + return { + found: true, + element: name + ? `${tag.toLowerCase()}${role ? `[${role}]` : ''} "${name}"` + : `${tag.toLowerCase()}${role ? `[${role}]` : ''}`, + tag: tag.toLowerCase(), + role, + editable, + secret, + fileInput: tag === 'INPUT' && inputType === 'file', + disabled: + (element as HTMLInputElement).disabled === true || + element.getAttribute('aria-disabled') === 'true', + canvas: tag === 'CANVAS', + crossOriginFrame: tag === 'IFRAME' || tag === 'FRAME', + cursor: style?.cursor || '', + } +} + +/** + * Reports whether the currently-focused element accepts text insertion, for + * browser_insert_text — which types at the caret instead of addressing a + * snapshot ref. Secrecy is separately (and authoritatively) probed by + * activeElementSecrecy before any insertion. + */ +export function describeFocusedEditable(): unknown { + let active = document.activeElement as HTMLElement | null + for (let depth = 0; active && depth < 10; depth++) { + const shadow = active.shadowRoot + if (shadow?.activeElement) { + active = shadow.activeElement as HTMLElement + continue + } + break + } + if (!active || active === document.body) return { editable: false, reason: 'none' } + const tag = String(active.tagName || '').toUpperCase() + const inputType = + tag === 'INPUT' ? String((active as HTMLInputElement).type || 'text').toLowerCase() : '' + if (tag === 'INPUT' || tag === 'TEXTAREA') { + const field = active as HTMLInputElement | HTMLTextAreaElement + if (field.disabled || active.getAttribute('aria-disabled') === 'true') { + return { editable: false, reason: 'disabled' } + } + if (field.readOnly || active.getAttribute('aria-readonly') === 'true') { + return { editable: false, reason: 'readonly' } + } + if ( + tag === 'INPUT' && + !['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType) + ) { + return { editable: false, reason: 'not-text' } + } + return { editable: true, kind: tag === 'TEXTAREA' ? 'textarea' : `input:${inputType}` } + } + if (active.isContentEditable) { + if (active.getAttribute('aria-disabled') === 'true') { + return { editable: false, reason: 'disabled' } + } + if (active.getAttribute('aria-readonly') === 'true') { + return { editable: false, reason: 'readonly' } + } + return { editable: true, kind: 'contenteditable' } + } + // A canvas-rendered editor (Google Docs) focuses a hidden proxy or the + // canvas region itself; trusted IME insertion still reaches it, so report + // it as insertable rather than refusing. + if (tag === 'CANVAS' || active.getAttribute('role') === 'textbox') { + return { editable: true, kind: tag === 'CANVAS' ? 'canvas' : 'textbox-role' } + } + return { editable: false, reason: 'not-editable' } +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 9ba13a5aab6..7648644e3f1 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -11,11 +11,14 @@ export interface ToolCatalogEntry { | 'auth' | 'browser' | 'browser_click' + | 'browser_click_at' | 'browser_close_tab' + | 'browser_drag' | 'browser_extract' | 'browser_go_back' | 'browser_go_forward' | 'browser_hover' + | 'browser_insert_text' | 'browser_list_sessions' | 'browser_list_tabs' | 'browser_navigate' @@ -132,11 +135,14 @@ export interface ToolCatalogEntry { | 'auth' | 'browser' | 'browser_click' + | 'browser_click_at' | 'browser_close_tab' + | 'browser_drag' | 'browser_extract' | 'browser_go_back' | 'browser_go_forward' | 'browser_hover' + | 'browser_insert_text' | 'browser_list_sessions' | 'browser_list_tabs' | 'browser_navigate' @@ -450,6 +456,135 @@ export const BrowserClick: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserClickAt: ToolCatalogEntry = { + id: 'browser_click_at', + name: 'browser_click_at', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + clickCount: { + type: 'number', + description: '1 = single click (default), 2 = double-click, 3 = triple-click.', + }, + x: { + type: 'number', + description: + "X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by the screenshot's scale.", + }, + y: { + type: 'number', + description: + 'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.', + }, + }, + required: ['x', 'y'], + }, + resultSchema: { + type: 'object', + properties: { + activeTab: { + type: 'object', + description: 'New active tab after a tab-changing click.', + properties: { + tabId: { type: 'string', description: 'Stable browser tab id.' }, + url: { type: 'string', description: 'New active tab URL.' }, + }, + }, + clickCount: { + type: 'number', + description: 'The click count that was dispatched (1, 2, or 3).', + }, + clickedAt: { + type: 'object', + description: 'The CSS-pixel viewport point that was clicked.', + properties: { + x: { type: 'number', description: 'Clicked X in CSS viewport pixels.' }, + y: { type: 'number', description: 'Clicked Y in CSS viewport pixels.' }, + }, + }, + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { type: 'string' }, + }, + dispatched: { + type: 'boolean', + description: 'Whether the native pointer click was dispatched at the point.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a strong page change (URL/dialog/popup/target, or focus into an editable) followed the click.', + }, + note: { + type: 'string', + description: 'Caution or follow-up guidance, present when the click needs verification.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn (DOM/title) was observed.', + }, + target: { + type: 'string', + description: + 'What the point resolved to before the click (tag/role and accessible name) — confirm it matches the intended target.', + }, + targetCursor: { + type: 'string', + description: + "The CSS cursor at the point (e.g. 'pointer', 'text', 'crosshair') — a hint about what kind of surface was hit.", + }, + trusted: { + type: 'boolean', + description: + 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.', + }, + }, + required: ['dispatched'], + }, + clientExecutable: true, +} + export const BrowserCloseTab: ToolCatalogEntry = { id: 'browser_close_tab', name: 'browser_close_tab', @@ -468,6 +603,130 @@ export const BrowserCloseTab: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserDrag: ToolCatalogEntry = { + id: 'browser_drag', + name: 'browser_drag', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + fromElementId: { + type: 'number', + description: 'Drag source element id from the latest snapshot. Alternative to fromX/fromY.', + }, + fromX: { + type: 'number', + description: + 'Drag source X in CSS viewport pixels (paired with fromY) when no source element id is available.', + }, + fromY: { type: 'number', description: 'Drag source Y in CSS viewport pixels.' }, + toElementId: { + type: 'number', + description: 'Drop target element id from the latest snapshot. Alternative to toX/toY.', + }, + toX: { + type: 'number', + description: 'Drop target X in CSS viewport pixels (paired with toY).', + }, + toY: { type: 'number', description: 'Drop target Y in CSS viewport pixels.' }, + }, + }, + resultSchema: { + type: 'object', + properties: { + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { type: 'string' }, + }, + dispatched: { type: 'boolean', description: 'Whether the full drag gesture was dispatched.' }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether an observable page change (DOM/URL/dialog/target/scroll) followed the drag.', + }, + from: { + type: 'object', + description: 'The resolved drag source.', + properties: { + element: { type: 'string', description: 'What that endpoint resolved to, when known.' }, + x: { type: 'number', description: 'Endpoint X in CSS viewport pixels.' }, + y: { type: 'number', description: 'Endpoint Y in CSS viewport pixels.' }, + }, + }, + nativeHtml5Drag: { + type: 'boolean', + description: + 'True when the page started a native HTML5 drag and it was completed as a real drag-and-drop; false for a pointer-sensor drag.', + }, + note: { + type: 'string', + description: + 'Caution or follow-up guidance — e.g. verify the drop with a fresh snapshot when no effect was observed.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn (DOM/title) was observed.', + }, + to: { + type: 'object', + description: 'The resolved drop target.', + properties: { + element: { type: 'string', description: 'What that endpoint resolved to, when known.' }, + x: { type: 'number', description: 'Endpoint X in CSS viewport pixels.' }, + y: { type: 'number', description: 'Endpoint Y in CSS viewport pixels.' }, + }, + }, + trusted: { + type: 'boolean', + description: + 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.', + }, + }, + required: ['dispatched'], + }, + clientExecutable: true, +} + export const BrowserExtract: ToolCatalogEntry = { id: 'browser_extract', name: 'browser_extract', @@ -630,6 +889,116 @@ export const BrowserHover: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserInsertText: ToolCatalogEntry = { + id: 'browser_insert_text', + name: 'browser_insert_text', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + submit: { type: 'boolean', description: 'Press Enter after inserting. Default false.' }, + text: { type: 'string', description: 'The text to insert at the caret. Must be non-empty.' }, + }, + required: ['text'], + }, + resultSchema: { + type: 'object', + properties: { + activeElement: { type: 'string', description: 'Focused element kind after the action.' }, + dispatched: { + type: 'boolean', + description: 'Whether the text was inserted through the native IME pipeline.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a field or page change confirmed the insertion. Canvas editors cannot echo one — verify visually.', + }, + insertedChars: { type: 'number', description: 'How many characters were inserted.' }, + kind: { + type: 'string', + description: + 'The kind of focused editable that received the text (input:*, textarea, contenteditable, canvas, textbox-role).', + }, + note: { + type: 'string', + description: + 'Caution or follow-up guidance, e.g. that a canvas editor needs visual verification.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn was observed.', + }, + redacted: { + type: 'boolean', + description: 'Whether sensitive focused-field details were withheld.', + }, + selectedChars: { + type: 'number', + description: 'Number of selected characters when safely inspectable.', + }, + submitDispatched: { type: 'boolean', description: 'Whether Enter was actually dispatched.' }, + submitRequested: { + type: 'boolean', + description: 'Whether Enter was requested after insertion.', + }, + trusted: { + type: 'boolean', + description: 'True — insertion uses the trusted input pipeline.', + }, + valueLength: { + type: 'number', + description: 'Focused non-secret field length when safely inspectable.', + }, + valuePreview: { + type: 'string', + description: 'Bounded focused non-secret field preview when safely inspectable.', + }, + }, + required: ['dispatched'], + }, + clientExecutable: true, +} + export const BrowserListSessions: ToolCatalogEntry = { id: 'browser_list_sessions', name: 'browser_list_sessions', @@ -6617,11 +6986,14 @@ export const TOOL_CATALOG: Record = { [Auth.id]: Auth, [Browser.id]: Browser, [BrowserClick.id]: BrowserClick, + [BrowserClickAt.id]: BrowserClickAt, [BrowserCloseTab.id]: BrowserCloseTab, + [BrowserDrag.id]: BrowserDrag, [BrowserExtract.id]: BrowserExtract, [BrowserGoBack.id]: BrowserGoBack, [BrowserGoForward.id]: BrowserGoForward, [BrowserHover.id]: BrowserHover, + [BrowserInsertText.id]: BrowserInsertText, [BrowserListSessions.id]: BrowserListSessions, [BrowserListTabs.id]: BrowserListTabs, [BrowserNavigate.id]: BrowserNavigate, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 3bebb7cd523..a75d3ddd632 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -214,6 +214,160 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['dispatched'], }, }, + browser_click_at: { + parameters: { + type: 'object', + properties: { + clickCount: { + type: 'number', + description: '1 = single click (default), 2 = double-click, 3 = triple-click.', + }, + x: { + type: 'number', + description: + "X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by the screenshot's scale.", + }, + y: { + type: 'number', + description: + 'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.', + }, + }, + required: ['x', 'y'], + }, + resultSchema: { + type: 'object', + properties: { + activeTab: { + type: 'object', + description: 'New active tab after a tab-changing click.', + properties: { + tabId: { + type: 'string', + description: 'Stable browser tab id.', + }, + url: { + type: 'string', + description: 'New active tab URL.', + }, + }, + }, + clickCount: { + type: 'number', + description: 'The click count that was dispatched (1, 2, or 3).', + }, + clickedAt: { + type: 'object', + description: 'The CSS-pixel viewport point that was clicked.', + properties: { + x: { + type: 'number', + description: 'Clicked X in CSS viewport pixels.', + }, + y: { + type: 'number', + description: 'Clicked Y in CSS viewport pixels.', + }, + }, + }, + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { + type: 'string', + }, + }, + dispatched: { + type: 'boolean', + description: 'Whether the native pointer click was dispatched at the point.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a strong page change (URL/dialog/popup/target, or focus into an editable) followed the click.', + }, + note: { + type: 'string', + description: 'Caution or follow-up guidance, present when the click needs verification.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn (DOM/title) was observed.', + }, + target: { + type: 'string', + description: + 'What the point resolved to before the click (tag/role and accessible name) — confirm it matches the intended target.', + }, + targetCursor: { + type: 'string', + description: + "The CSS cursor at the point (e.g. 'pointer', 'text', 'crosshair') — a hint about what kind of surface was hit.", + }, + trusted: { + type: 'boolean', + description: + 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.', + }, + }, + required: ['dispatched'], + }, + }, browser_close_tab: { parameters: { type: 'object', @@ -227,6 +381,171 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + browser_drag: { + parameters: { + type: 'object', + properties: { + fromElementId: { + type: 'number', + description: + 'Drag source element id from the latest snapshot. Alternative to fromX/fromY.', + }, + fromX: { + type: 'number', + description: + 'Drag source X in CSS viewport pixels (paired with fromY) when no source element id is available.', + }, + fromY: { + type: 'number', + description: 'Drag source Y in CSS viewport pixels.', + }, + toElementId: { + type: 'number', + description: 'Drop target element id from the latest snapshot. Alternative to toX/toY.', + }, + toX: { + type: 'number', + description: 'Drop target X in CSS viewport pixels (paired with toY).', + }, + toY: { + type: 'number', + description: 'Drop target Y in CSS viewport pixels.', + }, + }, + }, + resultSchema: { + type: 'object', + properties: { + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { + type: 'string', + }, + }, + dispatched: { + type: 'boolean', + description: 'Whether the full drag gesture was dispatched.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether an observable page change (DOM/URL/dialog/target/scroll) followed the drag.', + }, + from: { + type: 'object', + description: 'The resolved drag source.', + properties: { + element: { + type: 'string', + description: 'What that endpoint resolved to, when known.', + }, + x: { + type: 'number', + description: 'Endpoint X in CSS viewport pixels.', + }, + y: { + type: 'number', + description: 'Endpoint Y in CSS viewport pixels.', + }, + }, + }, + nativeHtml5Drag: { + type: 'boolean', + description: + 'True when the page started a native HTML5 drag and it was completed as a real drag-and-drop; false for a pointer-sensor drag.', + }, + note: { + type: 'string', + description: + 'Caution or follow-up guidance — e.g. verify the drop with a fresh snapshot when no effect was observed.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn (DOM/title) was observed.', + }, + to: { + type: 'object', + description: 'The resolved drop target.', + properties: { + element: { + type: 'string', + description: 'What that endpoint resolved to, when known.', + }, + x: { + type: 'number', + description: 'Endpoint X in CSS viewport pixels.', + }, + y: { + type: 'number', + description: 'Endpoint Y in CSS viewport pixels.', + }, + }, + }, + trusted: { + type: 'boolean', + description: + 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.', + }, + }, + required: ['dispatched'], + }, + }, browser_extract: { parameters: { type: 'object', @@ -410,6 +729,142 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['hovered'], }, }, + browser_insert_text: { + parameters: { + type: 'object', + properties: { + submit: { + type: 'boolean', + description: 'Press Enter after inserting. Default false.', + }, + text: { + type: 'string', + description: 'The text to insert at the caret. Must be non-empty.', + }, + }, + required: ['text'], + }, + resultSchema: { + type: 'object', + properties: { + activeElement: { + type: 'string', + description: 'Focused element kind after the action.', + }, + dispatched: { + type: 'boolean', + description: 'Whether the text was inserted through the native IME pipeline.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a field or page change confirmed the insertion. Canvas editors cannot echo one — verify visually.', + }, + insertedChars: { + type: 'number', + description: 'How many characters were inserted.', + }, + kind: { + type: 'string', + description: + 'The kind of focused editable that received the text (input:*, textarea, contenteditable, canvas, textbox-role).', + }, + note: { + type: 'string', + description: + 'Caution or follow-up guidance, e.g. that a canvas editor needs visual verification.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn was observed.', + }, + redacted: { + type: 'boolean', + description: 'Whether sensitive focused-field details were withheld.', + }, + selectedChars: { + type: 'number', + description: 'Number of selected characters when safely inspectable.', + }, + submitDispatched: { + type: 'boolean', + description: 'Whether Enter was actually dispatched.', + }, + submitRequested: { + type: 'boolean', + description: 'Whether Enter was requested after insertion.', + }, + trusted: { + type: 'boolean', + description: 'True — insertion uses the trusted input pipeline.', + }, + valueLength: { + type: 'number', + description: 'Focused non-secret field length when safely inspectable.', + }, + valuePreview: { + type: 'string', + description: 'Bounded focused non-secret field preview when safely inspectable.', + }, + }, + required: ['dispatched'], + }, + }, browser_list_sessions: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index b274fdd2bdf..5981910cd0a 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -525,7 +525,10 @@ const TOOL_TITLES: Record = { browser_read_text: 'Reading page', browser_screenshot: 'Taking screenshot', browser_click: 'Clicking element', + browser_click_at: 'Clicking point', browser_type: 'Typing text', + browser_insert_text: 'Inserting text', + browser_drag: 'Dragging element', browser_select_option: 'Selecting option', browser_hover: 'Hovering element', // Subagent trigger tools, when surfaced as a tool call. @@ -1015,6 +1018,8 @@ const COMPLETED_VERB_REWRITES: Record = { Creating: 'Created', Deleting: 'Deleted', Deploying: 'Deployed', + Dragging: 'Dragged', + Inserting: 'Inserted', Publishing: 'Published', Unpublishing: 'Unpublished', Analyzing: 'Analyzed', diff --git a/packages/browser-protocol/src/index.ts b/packages/browser-protocol/src/index.ts index 5158764dd0b..ca7dd65cbd1 100644 --- a/packages/browser-protocol/src/index.ts +++ b/packages/browser-protocol/src/index.ts @@ -33,11 +33,14 @@ export const BROWSER_TOOL_NAMES = [ 'browser_screenshot', 'browser_extract', 'browser_click', + 'browser_click_at', 'browser_type', + 'browser_insert_text', 'browser_press_key', 'browser_scroll', 'browser_select_option', 'browser_hover', + 'browser_drag', 'browser_request_takeover', ] as const From 0cab4d058987631daa2323f576de7f52cb48c58e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 15:28:48 -0700 Subject: [PATCH 051/103] Revert subagent group eager auto-collapse --- .../agent-group/agent-group.test.ts | 3 +++ .../components/agent-group/agent-group.tsx | 27 ++++++++++--------- .../message-content/message-content.tsx | 1 + 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index 9f83098c06a..4808893b4eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -110,6 +110,7 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [tool('success'), browserTakeover(reason)], isStreaming: true, + isCurrentSection: true, isLaneOpen: true, }) ) @@ -183,6 +184,7 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [takeover], isStreaming: true, + isCurrentSection: true, isLaneOpen: true, }) ) @@ -204,6 +206,7 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [completedTakeover], isStreaming: true, + isCurrentSection: true, isLaneOpen: true, }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 070734ddf74..103e6ba6e4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -37,6 +37,8 @@ interface AgentGroupProps { items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean + /** This group is the latest section in its parent sequence (drives collapse). */ + isCurrentSection?: boolean /** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */ isLaneOpen?: boolean } @@ -108,6 +110,7 @@ export function AgentGroup({ items, isDelegating = false, isStreaming = false, + isCurrentSection = false, isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) @@ -120,18 +123,17 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Expand while the turn is live and the subagent is still working: the lane - // is open, or there is unresolved work. When the lane closes and the work - // resolves the group collapses — with parallel subagents, finished siblings - // fold away while the still-running ones stay open, instead of every group - // lingering expanded until the next section starts. Keying "still running" - // off the lane-open signal (not `resolved` alone) avoids a collapse/reopen - // flicker mid-run: a subagent's tools all momentarily read "done" in the gap - // between its last search and its `respond` ("Gathering thoughts") tool, - // transiently flipping `resolved` true; the open lane bridges that gap. The - // turn ending (isStreaming false) collapses everything; a manual toggle pins - // the choice. - const autoExpanded = isStreaming && (isLaneOpen || !resolved) + // Expand while the turn is live and any of: the lane is open (the subagent is + // actively running), this is the current/latest section, or there is unresolved + // work. A finished group stays open until the NEXT section starts (it is no + // longer the latest), instead of collapsing the instant its own work resolves. + // Keying "still running" off the lane-open signal (not `resolved` alone) avoids + // a collapse/reopen flicker on parallel siblings: a subagent's tools all + // momentarily read "done" in the gap between its last search and its `respond` + // ("Gathering thoughts") tool, transiently flipping `resolved` true; the open + // lane bridges that gap so the row never collapses mid-run. The turn ending + // (isStreaming false) collapses everything; a manual toggle pins the choice. + const autoExpanded = isStreaming && (isCurrentSection || isLaneOpen || !resolved) const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn @@ -217,6 +219,7 @@ export function AgentGroup({ items={item.group.items} isDelegating={item.group.isDelegating} isStreaming={isStreaming} + isCurrentSection={idx === items.length - 1} isLaneOpen={item.group.isOpen} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 033ecf0cd50..24dfc3fd842 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -957,6 +957,7 @@ function MessageContentInner({ items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} + isCurrentSection={i === segments.length - 1} isLaneOpen={segment.isOpen} /> From 0f3f2faeb3a44c83289b9c3c38bc439875d54e75 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 16:07:07 -0700 Subject: [PATCH 052/103] fix(chat): keep sends FIFO across the streaming-to-idle drain gap --- .../[workspaceId]/home/hooks/use-chat.test.ts | 22 ++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 34 +++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index fa4d09e96b1..ec0fe69942d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -14,6 +14,7 @@ import { reconcileLiveAssistantTurn, selectReconnectReplayState, shouldActivateResourceEvent, + shouldQueueOutgoingMessage, waitForDetachedChatResolution, } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import type { @@ -45,6 +46,27 @@ describe('shouldActivateResourceEvent', () => { }) }) +describe('shouldQueueOutgoingMessage', () => { + it('queues while a send is in flight', () => { + expect(shouldQueueOutgoingMessage(true, false, 0)).toBe(true) + }) + + it('queues while a stop is still settling', () => { + expect(shouldQueueOutgoingMessage(false, true, 0)).toBe(true) + }) + + it('queues behind messages still waiting after the turn ended', () => { + // The regression: a message queued mid-stream must dispatch before one + // typed in the idle gap after the turn stopped — a direct send here would + // jump the queue and swap the user's message order. + expect(shouldQueueOutgoingMessage(false, false, 1)).toBe(true) + }) + + it('sends directly on an idle chat with an empty queue', () => { + expect(shouldQueueOutgoingMessage(false, false, 0)).toBe(false) + }) +}) + function userMessage(id: string): PersistedMessage { return { id, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 926e87362e9..2bef849721d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1202,6 +1202,25 @@ export function shouldActivateResourceEvent( return options?.activate === true || !activeResourceId || activeResourceId === resourceId } +/** + * Whether a fresh outbound message must join the chat's send queue instead of + * dispatching directly. Queueing while a send or stop is in flight is the + * obvious half; the queued-ahead term preserves FIFO across the + * streaming→idle boundary — a message queued while the previous turn streamed + * must reach the model before one typed after that turn ended but before the + * queue drained. Without it the fresh send jumps the queue and both the + * transcript and the model see the user's messages in swapped order. The two + * signals never gap mid-dispatch: a queued message stays in the queue until + * its optimistic send applies, which is after the in-flight flag is set. + */ +export function shouldQueueOutgoingMessage( + sendInFlight: boolean, + stopPending: boolean, + queuedAheadCount: number +): boolean { + return sendInFlight || stopPending || queuedAheadCount > 0 +} + export interface UseChatOptions { onResourceEvent?: ResourceEventHandler apiPath?: string @@ -4188,12 +4207,23 @@ export function useChat( // An in-flight send drains the queue from `finalize`; a pending stop kicks // the dispatcher itself, since nothing else will once the stop settles. - if (sendingRef.current || pendingStopPromiseRef.current) { + // A non-empty queue forces queueing even on an idle chat: messages + // queued while the previous turn streamed must go out first, so a fresh + // send lands behind them instead of jumping the line in the drain gap + // after a turn ends. + const queuedAheadCount = (queueStore.queues[activeChatKey] ?? EMPTY_MESSAGE_QUEUE).length + if ( + shouldQueueOutgoingMessage( + Boolean(sendingRef.current), + Boolean(pendingStopPromiseRef.current), + queuedAheadCount + ) + ) { queueStore.enqueue( activeChatKey, createQueuedMessage(message, fileAttachments, contexts, options?.resumeUserMessageId) ) - if (pendingStopPromiseRef.current) { + if (pendingStopPromiseRef.current || (queuedAheadCount > 0 && !sendingRef.current)) { void enqueueQueueDispatchRef.current({ type: 'send_head' }) } return From 98baa20f351d0f169f30dac0296cb00b4c2cde32 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 16:40:42 -0700 Subject: [PATCH 053/103] Add the steering backend surface for mid-turn sends --- .../app/api/copilot/chat/steer/route.test.ts | 128 ++++++++++++++++++ apps/sim/app/api/copilot/chat/steer/route.ts | 128 ++++++++++++++++++ apps/sim/lib/api/contracts/copilot.ts | 8 ++ .../generated/mothership-stream-v1-schema.ts | 28 +++- .../copilot/generated/mothership-stream-v1.ts | 2 + .../generated/trace-attribute-values-v1.ts | 54 ++++++++ .../copilot/generated/trace-attributes-v1.ts | 76 ++++++++++- .../lib/copilot/generated/trace-spans-v1.ts | 2 + apps/sim/lib/copilot/request/session/steer.ts | 79 +++++++++++ scripts/check-api-validation-contracts.ts | 4 +- 10 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 apps/sim/app/api/copilot/chat/steer/route.test.ts create mode 100644 apps/sim/app/api/copilot/chat/steer/route.ts create mode 100644 apps/sim/lib/copilot/request/session/steer.ts diff --git a/apps/sim/app/api/copilot/chat/steer/route.test.ts b/apps/sim/app/api/copilot/chat/steer/route.test.ts new file mode 100644 index 00000000000..417d4d96385 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/steer/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticate, mockGetLatestRunForStream, mockRequestStreamSteering, mockAppend } = + vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockGetLatestRunForStream: vi.fn(), + mockRequestStreamSteering: vi.fn(), + mockAppend: vi.fn(), + })) + +vi.mock('@/lib/copilot/request/http', () => ({ + authenticateCopilotRequestSessionOnly: mockAuthenticate, +})) +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getLatestRunForStream: mockGetLatestRunForStream, +})) +vi.mock('@/lib/copilot/request/session/steer', () => ({ + requestStreamSteering: mockRequestStreamSteering, +})) +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: mockAppend, +})) + +import { POST } from '@/app/api/copilot/chat/steer/route' + +function steerRequest(overrides: Record = {}) { + return createMockRequest('POST', { + streamId: 'stream-1', + chatId: 'chat-1', + steeringId: 'steer-1', + content: 'focus on the tests', + ...overrides, + }) +} + +describe('POST /api/copilot/chat/steer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticate.mockResolvedValue({ userId: 'user-1', isAuthenticated: true }) + mockGetLatestRunForStream.mockResolvedValue({ chatId: 'chat-1', workspaceId: 'workspace-1' }) + mockRequestStreamSteering.mockResolvedValue({ queued: true, status: 200 }) + mockAppend.mockResolvedValue(undefined) + }) + + it('queues steering with Go and persists the user message', async () => { + const response = await POST(steerRequest()) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true, queued: true }) + expect(mockRequestStreamSteering).toHaveBeenCalledWith( + expect.objectContaining({ + streamId: 'stream-1', + chatId: 'chat-1', + steeringId: 'steer-1', + content: 'focus on the tests', + userId: 'user-1', + }) + ) + expect(mockAppend).toHaveBeenCalledWith( + 'chat-1', + [ + expect.objectContaining({ + id: 'steer-1', + role: 'user', + content: 'focus on the tests', + }), + ], + { streamId: 'stream-1' } + ) + }) + + it('returns 409 when Go rejects the steer so the client falls back to a normal send', async () => { + mockRequestStreamSteering.mockResolvedValue({ queued: false, status: 429 }) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ ok: false, queued: false }) + expect(mockAppend).not.toHaveBeenCalled() + }) + + it('returns 409 when the Go forward throws', async () => { + mockRequestStreamSteering.mockRejectedValue(new Error('network down')) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(409) + expect(mockAppend).not.toHaveBeenCalled() + }) + + it('rejects a chat that does not own the stream', async () => { + mockGetLatestRunForStream.mockResolvedValue({ chatId: 'other-chat' }) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(403) + expect(mockRequestStreamSteering).not.toHaveBeenCalled() + }) + + it('rejects unauthenticated callers', async () => { + mockAuthenticate.mockResolvedValue({ userId: null, isAuthenticated: false }) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(401) + expect(mockRequestStreamSteering).not.toHaveBeenCalled() + }) + + it('rejects an empty content body', async () => { + const response = await POST(steerRequest({ content: '' })) + + expect(response.status).toBe(400) + expect(mockRequestStreamSteering).not.toHaveBeenCalled() + }) + + it('still reports queued when history persistence fails', async () => { + mockAppend.mockRejectedValue(new Error('db down')) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true, queued: true }) + }) +}) diff --git a/apps/sim/app/api/copilot/chat/steer/route.ts b/apps/sim/app/api/copilot/chat/steer/route.ts new file mode 100644 index 00000000000..7520ac01003 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/steer/route.ts @@ -0,0 +1,128 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { copilotChatSteerBodySchema } from '@/lib/api/contracts/copilot' +import { validationErrorResponse } from '@/lib/api/server' +import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' +import { CopilotSteerOutcome } 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 { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' +import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { requestStreamSteering } from '@/lib/copilot/request/session/steer' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CopilotChatSteerAPI') + +// POST /api/copilot/chat/steer — queues a mid-turn steering message with the +// Go side for a LIVE stream. Acceptance means "queued", not "applied": Go +// acknowledges application with a `run`/`steering_applied` stream event; a +// client that never sees that ack before the stream ends re-sends the content +// as an ordinary message. A 409 here tells the client to take that ordinary +// path immediately. +export const POST = withRouteHandler((request: NextRequest) => + withIncomingGoSpan( + request.headers, + TraceSpan.CopilotChatSteerStream, + undefined, + async (rootSpan) => { + const { userId: authenticatedUserId, isAuthenticated } = + await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !authenticatedUserId) { + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // boundary-raw-json: tolerant parse; validation happens via the contract schema below + const body = await request.json().catch(() => ({})) + const validation = copilotChatSteerBodySchema.safeParse(body) + if (!validation.success) { + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) + return validationErrorResponse(validation.error, 'Invalid request body') + } + const { streamId, chatId, steeringId, content } = validation.data + rootSpan.setAttributes({ + [TraceAttr.StreamId]: streamId, + [TraceAttr.ChatId]: chatId, + [TraceAttr.UserId]: authenticatedUserId, + [TraceAttr.CopilotSteeringContentChars]: content.length, + }) + + // Ownership pre-check on the Sim side (Go re-proves it independently): + // the stream must belong to a run of the authenticated user, and the + // claimed chat must match that run. + const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { + logger.warn('getLatestRunForStream failed while resolving steer context', { + streamId, + error: getErrorMessage(err), + }) + return null + }) + if (run?.chatId && run.chatId !== chatId) { + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) + return NextResponse.json({ error: 'Stream does not belong to this chat' }, { status: 403 }) + } + + let queued = false + let goStatus = 0 + try { + const result = await requestStreamSteering({ + streamId, + userId: authenticatedUserId, + chatId, + steeringId, + content, + workspaceId: run?.workspaceId ?? undefined, + }) + queued = result.queued + goStatus = result.status + } catch (err) { + logger.warn('Steer forward to Go failed', { + streamId, + chatId, + error: getErrorMessage(err), + }) + } + + if (!queued) { + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.NoActiveTurn) + // 409 = "could not queue; send it as an ordinary message instead". + return NextResponse.json({ ok: false, queued: false, goStatus }, { status: 409 }) + } + + // Persist the steering text as a user message so reloads include it. + // Failure here must not fail the steer — the message is already queued + // with Go and will reach the model; persistence is display-only. + try { + await appendCopilotChatMessages( + chatId, + [ + { + id: steeringId, + role: 'user', + content, + timestamp: new Date().toISOString(), + }, + ], + { streamId } + ) + } catch (err) { + logger.warn('Failed to persist steering message to chat history', { + chatId, + steeringId, + error: getErrorMessage(err), + }) + } + + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.Queued) + logger.info('Queued mid-turn steering message', { + streamId, + chatId, + steeringId, + contentChars: content.length, + }) + return NextResponse.json({ ok: true, queued: true }) + } + ) +) diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index c851c6cff2c..2f9a0bd4ca7 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -140,6 +140,14 @@ export const copilotChatAbortBodySchema = z.object({ }) export type CopilotChatAbortBody = z.input +export const copilotChatSteerBodySchema = z.object({ + streamId: z.string().min(1, 'streamId is required'), + chatId: z.string().min(1, 'chatId is required'), + steeringId: z.string().min(1, 'steeringId is required'), + content: z.string().min(1, 'content is required').max(32_768, 'content is too long'), +}) +export type CopilotChatSteerBody = z.input + export const copilotChatGetQuerySchema = z .object({ workflowId: z.string().optional(), diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index 76322f7b64c..f6ce59033ee 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -514,7 +514,13 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { type: 'object', }, MothershipStreamV1RunKind: { - enum: ['checkpoint_pause', 'resumed', 'compaction_start', 'compaction_done'], + enum: [ + 'checkpoint_pause', + 'resumed', + 'compaction_start', + 'compaction_done', + 'steering_applied', + ], type: 'string', }, MothershipStreamV1RunResumedEventEnvelope: { @@ -777,6 +783,26 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { enum: ['subagent', 'structured_result', 'subagent_result'], type: 'string', }, + MothershipStreamV1SteeringAppliedPayload: { + additionalProperties: false, + properties: { + content: { + type: 'string', + }, + kind: { + $ref: '#/$defs/MothershipStreamV1RunKind', + }, + messageId: { + type: 'string', + }, + mode: { + enum: ['deferred', 'interrupt'], + type: 'string', + }, + }, + required: ['kind', 'messageId', 'content', 'mode'], + type: 'object', + }, MothershipStreamV1StreamCursor: { additionalProperties: false, properties: { diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 5cdf7801bf8..7f4fbd98e19 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -468,12 +468,14 @@ export type MothershipStreamV1RunKind = | 'resumed' | 'compaction_start' | 'compaction_done' + | 'steering_applied' export const MothershipStreamV1RunKind = { checkpoint_pause: 'checkpoint_pause', resumed: 'resumed', compaction_start: 'compaction_start', compaction_done: 'compaction_done', + steering_applied: 'steering_applied', } as const export type MothershipStreamV1SessionKind = 'trace' | 'chat' | 'title' | 'start' diff --git a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts index 916ecc88569..8968f29522a 100644 --- a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts @@ -59,6 +59,14 @@ export const BillingRouteOutcome = { export type BillingRouteOutcomeKey = keyof typeof BillingRouteOutcome export type BillingRouteOutcomeValue = (typeof BillingRouteOutcome)[BillingRouteOutcomeKey] +export const ContextBudgetSource = { + Model: 'model', + PricingBoundary: 'pricing_boundary', +} as const + +export type ContextBudgetSourceKey = keyof typeof ContextBudgetSource +export type ContextBudgetSourceValue = (typeof ContextBudgetSource)[ContextBudgetSourceKey] + export const CopilotAbortOutcome = { BadRequest: 'bad_request', FallbackPersistFailed: 'fallback_persist_failed', @@ -204,6 +212,18 @@ export const CopilotSseCloseReason = { export type CopilotSseCloseReasonKey = keyof typeof CopilotSseCloseReason export type CopilotSseCloseReasonValue = (typeof CopilotSseCloseReason)[CopilotSseCloseReasonKey] +export const CopilotSteerOutcome = { + BadRequest: 'bad_request', + MissingContent: 'missing_content', + MissingMessageId: 'missing_message_id', + NoActiveTurn: 'no_active_turn', + QueueFull: 'queue_full', + Queued: 'queued', +} as const + +export type CopilotSteerOutcomeKey = keyof typeof CopilotSteerOutcome +export type CopilotSteerOutcomeValue = (typeof CopilotSteerOutcome)[CopilotSteerOutcomeKey] + export const CopilotStopOutcome = { ChatNotFound: 'chat_not_found', InternalError: 'internal_error', @@ -320,6 +340,40 @@ export const LlmErrorStage = { export type LlmErrorStageKey = keyof typeof LlmErrorStage export type LlmErrorStageValue = (typeof LlmErrorStage)[LlmErrorStageKey] +export const PromptComponent = { + ActiveSkills: 'active_skills', + AgentGuidance: 'agent_guidance', + Capabilities: 'capabilities', + Credentials: 'credentials', + DesktopContext: 'desktop_context', + ExternalTools: 'external_tools', + Files: 'files', + History: 'history', + Override: 'override', + PermissionsContext: 'permissions_context', + Persona: 'persona', + Policies: 'policies', + RuntimeTail: 'runtime_tail', + SessionContext: 'session_context', + SkillsIndex: 'skills_index', + SpawnContext: 'spawn_context', + Steering: 'steering', + SubagentDocs: 'subagent_docs', + SubagentRegistry: 'subagent_registry', + TaggedResources: 'tagged_resources', + TimeContext: 'time_context', + ToolDocs: 'tool_docs', + ToolResults: 'tool_results', + ToolsWire: 'tools_wire', + Vfs: 'vfs', + WorkflowContext: 'workflow_context', + WorkspaceGuide: 'workspace_guide', + WorkspaceInventory: 'workspace_inventory', +} as const + +export type PromptComponentKey = keyof typeof PromptComponent +export type PromptComponentValue = (typeof PromptComponent)[PromptComponentKey] + export const RateLimitOutcome = { Allowed: 'allowed', IncrError: 'incr_error', diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 5a026a33c3a..c9d4233fc30 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -69,6 +69,7 @@ export const TraceAttr = { BillingInterval: 'billing.interval', BillingIsMcp: 'billing.is_mcp', BillingLlmCost: 'billing.llm_cost', + BillingLongContext: 'billing.long_context', BillingNewPlan: 'billing.new_plan', BillingOutcome: 'billing.outcome', BillingPlan: 'billing.plan', @@ -153,6 +154,7 @@ export const TraceAttr = { ConditionName: 'condition.name', ConditionResult: 'condition.result', ContextReduceBudgetChars: 'context.reduce.budget_chars', + ContextReduceBudgetSource: 'context.reduce.budget_source', ContextReduceCaller: 'context.reduce.caller', ContextReduceDidReduce: 'context.reduce.did_reduce', ContextReduceInputChars: 'context.reduce.input_chars', @@ -258,6 +260,11 @@ export const TraceAttr = { CopilotSseTerminalEventMissing: 'copilot.sse.terminal_event_missing', CopilotSseTerminalEventSeen: 'copilot.sse.terminal_event_seen', CopilotSseTotalDispatchMs: 'copilot.sse.total_dispatch_ms', + CopilotSteerOutcome: 'copilot.steer.outcome', + CopilotSteeringContentChars: 'copilot.steering.content_chars', + CopilotSteeringEntries: 'copilot.steering.entries', + CopilotSteeringMode: 'copilot.steering.mode', + CopilotSteeringSalvagedChars: 'copilot.steering.salvaged_chars', CopilotStopAppendedAssistant: 'copilot.stop.appended_assistant', CopilotStopBlocksCount: 'copilot.stop.blocks_count', CopilotStopContentLength: 'copilot.stop.content_length', @@ -452,8 +459,6 @@ export const TraceAttr = { HttpUrl: 'http.url', HttpUserAgent: 'http.user_agent', InvitationRole: 'invitation.role', - KnowledgeBaseId: 'knowledge_base.id', - KnowledgeBaseName: 'knowledge_base.name', LlmBackend: 'llm.backend', LlmCompactionPause: 'llm.compaction.pause', LlmErrorStage: 'llm.error_stage', @@ -473,6 +478,8 @@ export const TraceAttr = { LoopId: 'loop.id', LoopIterations: 'loop.iterations', LoopName: 'loop.name', + ManageKnowledgeBaseId: 'manage_knowledge_base.id', + ManageKnowledgeBaseName: 'manage_knowledge_base.name', McpExecutionStatus: 'mcp.execution_status', McpServerId: 'mcp.server_id', McpServerName: 'mcp.server_name', @@ -503,9 +510,36 @@ export const TraceAttr = { ProcessingChunkSize: 'processing.chunk_size', ProcessingRecipe: 'processing.recipe', PromptCacheableBlocks: 'prompt.cacheable_blocks', + PromptComponent: 'prompt.component', + PromptRegionFilesChars: 'prompt.region.files_chars', + PromptRegionFilesCount: 'prompt.region.files_count', + PromptRegionHistoryChars: 'prompt.region.history_chars', + PromptRegionHistoryMessages: 'prompt.region.history_messages', + PromptRegionRuntimeTailChars: 'prompt.region.runtime_tail_chars', + PromptRegionSteeringChars: 'prompt.region.steering_chars', + PromptRegionToolResultsChars: 'prompt.region.tool_results_chars', + PromptRegionVfsChars: 'prompt.region.vfs_chars', + PromptRegionVfsMessages: 'prompt.region.vfs_messages', + PromptRuntimeActiveSkillsChars: 'prompt.runtime.active_skills_chars', + PromptRuntimeTaggedResourcesChars: 'prompt.runtime.tagged_resources_chars', + PromptSectionAgentGuidanceChars: 'prompt.section.agent_guidance_chars', + PromptSectionCapabilitiesChars: 'prompt.section.capabilities_chars', + PromptSectionCredentialsChars: 'prompt.section.credentials_chars', + PromptSectionOverrideChars: 'prompt.section.override_chars', + PromptSectionPersonaChars: 'prompt.section.persona_chars', + PromptSectionPoliciesChars: 'prompt.section.policies_chars', + PromptSectionSkillsIndexChars: 'prompt.section.skills_index_chars', + PromptSectionSpawnContextChars: 'prompt.section.spawn_context_chars', + PromptSectionSubagentDocsChars: 'prompt.section.subagent_docs_chars', + PromptSectionToolDocsChars: 'prompt.section.tool_docs_chars', + PromptSectionWorkspaceGuideChars: 'prompt.section.workspace_guide_chars', + PromptSectionWorkspaceInventoryChars: 'prompt.section.workspace_inventory_chars', PromptSet: 'prompt.set', + PromptSite: 'prompt.site', PromptSystemBlocks: 'prompt.system_blocks', PromptSystemChars: 'prompt.system_chars', + PromptToolsWireChars: 'prompt.tools.wire_chars', + PromptToolsWireCount: 'prompt.tools.wire_count', ProviderId: 'provider.id', RateLimitAttempt: 'rate_limit.attempt', RateLimitCount: 'rate_limit.count', @@ -714,6 +748,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'billing.interval', 'billing.is_mcp', 'billing.llm_cost', + 'billing.long_context', 'billing.new_plan', 'billing.outcome', 'billing.plan', @@ -798,6 +833,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'condition.name', 'condition.result', 'context.reduce.budget_chars', + 'context.reduce.budget_source', 'context.reduce.caller', 'context.reduce.did_reduce', 'context.reduce.input_chars', @@ -903,6 +939,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.sse.terminal_event_missing', 'copilot.sse.terminal_event_seen', 'copilot.sse.total_dispatch_ms', + 'copilot.steer.outcome', + 'copilot.steering.content_chars', + 'copilot.steering.entries', + 'copilot.steering.mode', + 'copilot.steering.salvaged_chars', 'copilot.stop.appended_assistant', 'copilot.stop.blocks_count', 'copilot.stop.content_length', @@ -1086,8 +1127,6 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'http.url', 'http.user_agent', 'invitation.role', - 'knowledge_base.id', - 'knowledge_base.name', 'llm.backend', 'llm.compaction.pause', 'llm.error_stage', @@ -1107,6 +1146,8 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'loop.id', 'loop.iterations', 'loop.name', + 'manage_knowledge_base.id', + 'manage_knowledge_base.name', 'mcp.execution_status', 'mcp.server_id', 'mcp.server_name', @@ -1137,9 +1178,36 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'processing.chunk_size', 'processing.recipe', 'prompt.cacheable_blocks', + 'prompt.component', + 'prompt.region.files_chars', + 'prompt.region.files_count', + 'prompt.region.history_chars', + 'prompt.region.history_messages', + 'prompt.region.runtime_tail_chars', + 'prompt.region.steering_chars', + 'prompt.region.tool_results_chars', + 'prompt.region.vfs_chars', + 'prompt.region.vfs_messages', + 'prompt.runtime.active_skills_chars', + 'prompt.runtime.tagged_resources_chars', + 'prompt.section.agent_guidance_chars', + 'prompt.section.capabilities_chars', + 'prompt.section.credentials_chars', + 'prompt.section.override_chars', + 'prompt.section.persona_chars', + 'prompt.section.policies_chars', + 'prompt.section.skills_index_chars', + 'prompt.section.spawn_context_chars', + 'prompt.section.subagent_docs_chars', + 'prompt.section.tool_docs_chars', + 'prompt.section.workspace_guide_chars', + 'prompt.section.workspace_inventory_chars', 'prompt.set', + 'prompt.site', 'prompt.system_blocks', 'prompt.system_chars', + 'prompt.tools.wire_chars', + 'prompt.tools.wire_count', 'provider.id', 'rate_limit.attempt', 'rate_limit.count', diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/copilot/generated/trace-spans-v1.ts index 5048cb2fbf7..eccf2fd94f0 100644 --- a/apps/sim/lib/copilot/generated/trace-spans-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-spans-v1.ts @@ -53,6 +53,7 @@ export const TraceSpan = { CopilotChatResolveAgentContexts: 'copilot.chat.resolve_agent_contexts', CopilotChatResolveBranch: 'copilot.chat.resolve_branch', CopilotChatResolveOrCreateChat: 'copilot.chat.resolve_or_create_chat', + CopilotChatSteerStream: 'copilot.chat.steer_stream', CopilotChatStopStream: 'copilot.chat.stop_stream', CopilotConfirmToolResult: 'copilot.confirm.tool_result', CopilotFinalizeStream: 'copilot.finalize_stream', @@ -128,6 +129,7 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'copilot.chat.resolve_agent_contexts', 'copilot.chat.resolve_branch', 'copilot.chat.resolve_or_create_chat', + 'copilot.chat.steer_stream', 'copilot.chat.stop_stream', 'copilot.confirm.tool_result', 'copilot.finalize_stream', diff --git a/apps/sim/lib/copilot/request/session/steer.ts b/apps/sim/lib/copilot/request/session/steer.ts new file mode 100644 index 00000000000..4e63afca65e --- /dev/null +++ b/apps/sim/lib/copilot/request/session/steer.ts @@ -0,0 +1,79 @@ +import type { Context } from '@opentelemetry/api' +import { + COPILOT_BILLING_PROTOCOL, + COPILOT_BILLING_PROTOCOL_HEADER, +} from '@/lib/billing/core/billing-attribution' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { fetchGo } from '@/lib/copilot/request/go/fetch' +import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' +import { env } from '@/lib/core/config/env' + +export const DEFAULT_STEER_TIMEOUT_MS = 3000 + +/** + * Queues a mid-turn steering message with the Go side (`/api/streams/steer`). + * + * Acceptance means "queued", not "applied": Go acknowledges application with a + * `run`/`steering_applied` stream event carrying the steeringId. A caller that + * never sees that ack before the stream ends must re-send the content as an + * ordinary message — that contract is what makes delivery loss-free without + * this call having to prove stream liveness. + */ +export async function requestStreamSteering(params: { + streamId: string + userId: string + chatId: string + steeringId: string + content: string + workspaceId?: string + timeoutMs?: number + otelContext?: Context +}): Promise<{ queued: boolean; status: number }> { + const { + streamId, + userId, + chatId, + steeringId, + content, + timeoutMs = DEFAULT_STEER_TIMEOUT_MS, + otelContext, + } = params + + const headers: Record = { + 'Content-Type': 'application/json', + [COPILOT_BILLING_PROTOCOL_HEADER]: COPILOT_BILLING_PROTOCOL.legacy, + } + if (env.COPILOT_API_KEY) { + headers['x-api-key'] = env.COPILOT_API_KEY + } + Object.assign(headers, getMothershipSourceEnvHeaders()) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort('steer_fetch_timeout'), timeoutMs) + + try { + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const response = await fetchGo(`${mothershipBaseURL}/api/streams/steer`, { + method: 'POST', + headers, + signal: controller.signal, + body: JSON.stringify({ + messageId: streamId, + userId, + chatId, + steeringId, + content, + }), + otelContext, + spanName: 'sim → go /api/streams/steer', + operation: 'steer', + attributes: { + [TraceAttr.StreamId]: streamId, + [TraceAttr.ChatId]: chatId, + }, + }) + return { queued: response.ok, status: response.status } + } finally { + clearTimeout(timeout) + } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 58393603987..ba484e5faf6 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: 1105, - zodRoutes: 1105, + totalRoutes: 1106, + zodRoutes: 1106, nonZodRoutes: 0, } as const From 2f6e4cab93723bf901e0c4ceec8206f81be07b4b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:05:11 -0700 Subject: [PATCH 054/103] Sync generated contracts for async subagent orchestration Pulls the mothership tool catalog (wait_agents / tail_agent / steer_agent / interrupt_agent), trace spans (chat.async_subagent.*, chat.orchestrate.*), and trace attributes (copilot.async_subagent.*) into the generated TS contracts. --- .../lib/copilot/generated/tool-catalog-v1.ts | 97 +++++++++++++++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 81 ++++++++++++++++ .../copilot/generated/trace-attributes-v1.ts | 18 ++++ .../lib/copilot/generated/trace-spans-v1.ts | 14 +++ 4 files changed, 210 insertions(+) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 7648644e3f1..da266e91fd0 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -63,6 +63,7 @@ export interface ToolCatalogEntry { | 'get_workflow_run_options' | 'glob' | 'grep' + | 'interrupt_agent' | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' @@ -111,6 +112,7 @@ export interface ToolCatalogEntry { | 'set_environment_variables' | 'set_global_workflow_variables' | 'share_file' + | 'steer_agent' | 'table' | 'table_automations' | 'table_columns' @@ -118,11 +120,13 @@ export interface ToolCatalogEntry { | 'table_manage' | 'table_rows' | 'table_views' + | 'tail_agent' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' | 'wait' + | 'wait_agents' | 'web_crawl' | 'web_fetch' | 'web_scrape' @@ -187,6 +191,7 @@ export interface ToolCatalogEntry { | 'get_workflow_run_options' | 'glob' | 'grep' + | 'interrupt_agent' | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' @@ -235,6 +240,7 @@ export interface ToolCatalogEntry { | 'set_environment_variables' | 'set_global_workflow_variables' | 'share_file' + | 'steer_agent' | 'table' | 'table_automations' | 'table_columns' @@ -242,11 +248,13 @@ export interface ToolCatalogEntry { | 'table_manage' | 'table_rows' | 'table_views' + | 'tail_agent' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' | 'wait' + | 'wait_agents' | 'web_crawl' | 'web_fetch' | 'web_scrape' @@ -3131,6 +3139,25 @@ export const Grep: ToolCatalogEntry = { }, } +export const InterruptAgent: ToolCatalogEntry = { + id: 'interrupt_agent', + name: 'interrupt_agent', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'The agent id to interrupt.' }, + reason: { + type: 'string', + description: + "Why you are stopping it, in a few words. Recorded in the agent's final status.", + }, + }, + required: ['agent_id'], + }, +} + export const Knowledge: ToolCatalogEntry = { id: 'knowledge', name: 'knowledge', @@ -5293,6 +5320,24 @@ export const ShareFile: ToolCatalogEntry = { requiredPermission: 'write', } +export const SteerAgent: ToolCatalogEntry = { + id: 'steer_agent', + name: 'steer_agent', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'The agent id to steer.' }, + content: { + type: 'string', + description: 'The instruction to deliver, phrased as you would brief a teammate mid-task.', + }, + }, + required: ['agent_id', 'content'], + }, +} + export const Table: ToolCatalogEntry = { id: 'table', name: 'table', @@ -5879,6 +5924,25 @@ export const TableViews: ToolCatalogEntry = { }, } +export const TailAgent: ToolCatalogEntry = { + id: 'tail_agent', + name: 'tail_agent', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'The agent id to inspect.' }, + max_chars: { + type: 'number', + description: + 'Max characters of activity to return. Default 4000; unread activity beyond the budget stays queued for your next tail.', + }, + }, + required: ['agent_id'], + }, +} + export const Terminal: ToolCatalogEntry = { id: 'terminal', name: 'terminal', @@ -6412,6 +6476,35 @@ export const Wait: ToolCatalogEntry = { }, } +export const WaitAgents: ToolCatalogEntry = { + id: 'wait_agents', + name: 'wait_agents', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + agent_ids: { + type: 'array', + description: 'The agent ids to wait on, as returned by their async launches.', + items: { type: 'string' }, + }, + mode: { + type: 'string', + description: + '"all" (default) wakes when every listed agent finishes; "any" wakes on the first.', + enum: ['all', 'any'], + }, + timeout_seconds: { + type: 'number', + description: + 'Max seconds to sleep before waking anyway. Default 120, capped at 600. On timeout you get current statuses and can wait again.', + }, + }, + required: ['agent_ids'], + }, +} + export const WebCrawl: ToolCatalogEntry = { id: 'web_crawl', name: 'web_crawl', @@ -7038,6 +7131,7 @@ export const TOOL_CATALOG: Record = { [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, [Grep.id]: Grep, + [InterruptAgent.id]: InterruptAgent, [Knowledge.id]: Knowledge, [ListDeploymentVersions.id]: ListDeploymentVersions, [ListIntegrationTools.id]: ListIntegrationTools, @@ -7086,6 +7180,7 @@ export const TOOL_CATALOG: Record = { [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, + [SteerAgent.id]: SteerAgent, [Table.id]: Table, [TableAutomations.id]: TableAutomations, [TableColumns.id]: TableColumns, @@ -7093,11 +7188,13 @@ export const TOOL_CATALOG: Record = { [TableManage.id]: TableManage, [TableRows.id]: TableRows, [TableViews.id]: TableViews, + [TailAgent.id]: TailAgent, [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, [Wait.id]: Wait, + [WaitAgents.id]: WaitAgents, [WebCrawl.id]: WebCrawl, [WebFetch.id]: WebFetch, [WebScrape.id]: WebScrape, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a75d3ddd632..105022d971a 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3082,6 +3082,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + interrupt_agent: { + parameters: { + type: 'object', + properties: { + agent_id: { + type: 'string', + description: 'The agent id to interrupt.', + }, + reason: { + type: 'string', + description: + "Why you are stopping it, in a few words. Recorded in the agent's final status.", + }, + }, + required: ['agent_id'], + }, + resultSchema: undefined, + }, knowledge: { parameters: { properties: { @@ -5210,6 +5228,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + steer_agent: { + parameters: { + type: 'object', + properties: { + agent_id: { + type: 'string', + description: 'The agent id to steer.', + }, + content: { + type: 'string', + description: + 'The instruction to deliver, phrased as you would brief a teammate mid-task.', + }, + }, + required: ['agent_id', 'content'], + }, + resultSchema: undefined, + }, table: { parameters: { properties: { @@ -5878,6 +5914,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + tail_agent: { + parameters: { + type: 'object', + properties: { + agent_id: { + type: 'string', + description: 'The agent id to inspect.', + }, + max_chars: { + type: 'number', + description: + 'Max characters of activity to return. Default 4000; unread activity beyond the budget stays queued for your next tail.', + }, + }, + required: ['agent_id'], + }, + resultSchema: undefined, + }, terminal: { parameters: { type: 'object', @@ -6439,6 +6493,33 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + wait_agents: { + parameters: { + type: 'object', + properties: { + agent_ids: { + type: 'array', + description: 'The agent ids to wait on, as returned by their async launches.', + items: { + type: 'string', + }, + }, + mode: { + type: 'string', + description: + '"all" (default) wakes when every listed agent finishes; "any" wakes on the first.', + enum: ['all', 'any'], + }, + timeout_seconds: { + type: 'number', + description: + 'Max seconds to sleep before waking anyway. Default 120, capped at 600. On timeout you get current statuses and can wait again.', + }, + }, + required: ['agent_ids'], + }, + resultSchema: undefined, + }, web_crawl: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index c9d4233fc30..8e8778eb6d0 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -179,6 +179,15 @@ export const TraceAttr = { CopilotAbortMarkerWritten: 'copilot.abort.marker_written', CopilotAbortOutcome: 'copilot.abort.outcome', CopilotAbortUnknownReason: 'copilot.abort.unknown_reason', + CopilotAsyncSubagentAgent: 'copilot.async_subagent.agent', + CopilotAsyncSubagentCount: 'copilot.async_subagent.count', + CopilotAsyncSubagentDeltaChars: 'copilot.async_subagent.delta_chars', + CopilotAsyncSubagentId: 'copilot.async_subagent.id', + CopilotAsyncSubagentReminders: 'copilot.async_subagent.reminders', + CopilotAsyncSubagentSleptMs: 'copilot.async_subagent.slept_ms', + CopilotAsyncSubagentStatus: 'copilot.async_subagent.status', + CopilotAsyncSubagentWaitMode: 'copilot.async_subagent.wait_mode', + CopilotAsyncSubagentWakeReason: 'copilot.async_subagent.wake_reason', CopilotAsyncToolClaimedBy: 'copilot.async_tool.claimed_by', CopilotAsyncToolHasError: 'copilot.async_tool.has_error', CopilotAsyncToolIdsCount: 'copilot.async_tool.ids_count', @@ -858,6 +867,15 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.abort.marker_written', 'copilot.abort.outcome', 'copilot.abort.unknown_reason', + 'copilot.async_subagent.agent', + 'copilot.async_subagent.count', + 'copilot.async_subagent.delta_chars', + 'copilot.async_subagent.id', + 'copilot.async_subagent.reminders', + 'copilot.async_subagent.slept_ms', + 'copilot.async_subagent.status', + 'copilot.async_subagent.wait_mode', + 'copilot.async_subagent.wake_reason', 'copilot.async_tool.claimed_by', 'copilot.async_tool.has_error', 'copilot.async_tool.ids_count', diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/copilot/generated/trace-spans-v1.ts index eccf2fd94f0..ac8665f8d22 100644 --- a/apps/sim/lib/copilot/generated/trace-spans-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-spans-v1.ts @@ -12,6 +12,9 @@ export const TraceSpan = { AsyncToolStoreSet: 'async_tool_store.set', AuthRateLimitRecord: 'auth.rate_limit.record', AuthValidateKey: 'auth.validate_key', + ChatAsyncSubagentRun: 'chat.async_subagent.run', + ChatAsyncSubagentShutdown: 'chat.async_subagent.shutdown', + ChatAsyncSubagentSpawn: 'chat.async_subagent.spawn', ChatContinueWithToolResults: 'chat.continue_with_tool_results', ChatExplicitAbortConsume: 'chat.explicit_abort.consume', ChatExplicitAbortFlushPausedBilling: 'chat.explicit_abort.flush_paused_billing', @@ -19,6 +22,10 @@ export const TraceSpan = { ChatExplicitAbortMark: 'chat.explicit_abort.mark', ChatExplicitAbortPeek: 'chat.explicit_abort.peek', ChatGateAcquire: 'chat.gate.acquire', + ChatOrchestrateInterrupt: 'chat.orchestrate.interrupt', + ChatOrchestrateSteer: 'chat.orchestrate.steer', + ChatOrchestrateTail: 'chat.orchestrate.tail', + ChatOrchestrateWait: 'chat.orchestrate.wait', ChatPersistAfterDone: 'chat.persist_after_done', ChatSetup: 'chat.setup', ContextReduce: 'context.reduce', @@ -88,6 +95,9 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'async_tool_store.set', 'auth.rate_limit.record', 'auth.validate_key', + 'chat.async_subagent.run', + 'chat.async_subagent.shutdown', + 'chat.async_subagent.spawn', 'chat.continue_with_tool_results', 'chat.explicit_abort.consume', 'chat.explicit_abort.flush_paused_billing', @@ -95,6 +105,10 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'chat.explicit_abort.mark', 'chat.explicit_abort.peek', 'chat.gate.acquire', + 'chat.orchestrate.interrupt', + 'chat.orchestrate.steer', + 'chat.orchestrate.tail', + 'chat.orchestrate.wait', 'chat.persist_after_done', 'chat.setup', 'context.reduce', From 7f9f11f6dda617b8c4637775f7c815378f1fca6c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:08:32 -0700 Subject: [PATCH 055/103] Add display titles for the async subagent orchestration tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wait_agents / tail_agent / steer_agent / interrupt_agent get natural-language running titles (naming the agent id being waited on, tailed, steered, or stopped) and a Steering→Steered completed-verb rewrite. --- apps/sim/lib/copilot/tools/tool-display.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5981910cd0a..8f1fcc83437 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -609,6 +609,15 @@ function waitTitle(args: ToolArgs): string { return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason')) } +/** Title for a wait_agents sleep, naming the agent(s) being collected. */ +function waitAgentsTitle(args: ToolArgs): string { + const raw = args?.agent_ids + const ids = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [] + if (ids.length === 1) return `Waiting for ${ids[0]}` + if (ids.length > 1) return `Waiting for ${ids.length} agents` + return 'Waiting for agents' +} + /** * The title of a pause that is still running, counting down what is left. * @@ -714,6 +723,14 @@ export function getToolDisplayTitle(name: string, args?: Record return openResourceTitle(args) case 'wait': return waitTitle(args) + case 'wait_agents': + return waitAgentsTitle(args) + case 'tail_agent': + return `Checking on ${stringArg(args, 'agent_id') || 'agent'}` + case 'steer_agent': + return `Steering ${stringArg(args, 'agent_id') || 'agent'}` + case 'interrupt_agent': + return `Stopping ${stringArg(args, 'agent_id') || 'agent'}` case 'terminal': return terminalTitle(args) // The surface used to be one tool per operation. Conversations recorded @@ -1066,6 +1083,7 @@ const COMPLETED_VERB_REWRITES: Record = { Selecting: 'Selected', Setting: 'Set', Sharing: 'Shared', + Steering: 'Steered', Stopping: 'Stopped', Summarizing: 'Summarized', Switching: 'Switched', From 4ad9f17ecb0ed6a244bc9d91f9b5b1b2f317d3ec Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:16:46 -0700 Subject: [PATCH 056/103] Show orchestrator-chosen subagent names on agent groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subagent_start whose payload data carries a name (the orchestrator's new name trigger parameter) now labels the agent group with that mission name — the agent-type icon stays. The name flows through the live stream path, the turn model (AgentNode.displayName) and its serialize/rebuild round-trip, and persisted transcripts (PersistedContentBlock.name), so reloads keep the label. --- .../message-content/message-content.tsx | 2 ++ .../home/hooks/stream/turn-model-serialize.ts | 6 ++++- .../home/hooks/stream/turn-model.test.ts | 23 +++++++++++++++++++ .../home/hooks/stream/turn-model.ts | 5 ++++ .../app/workspace/[workspaceId]/home/types.ts | 2 ++ apps/sim/lib/copilot/chat/display-message.ts | 6 ++++- .../sim/lib/copilot/chat/persisted-message.ts | 8 +++++++ apps/sim/lib/copilot/request/go/stream.ts | 8 +++++++ apps/sim/lib/copilot/request/types.ts | 2 ++ 9 files changed, 60 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 24dfc3fd842..e16282ea66b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -380,6 +380,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content] if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) + if (block.subagentName) g.agentLabel = block.subagentName if (block.endedAt !== undefined) { // Persisted backend path: the lane was stamped closed (endedAt) without // a separate subagent_end block (the Sim backend stamps endedAt only; @@ -623,6 +624,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { } groupsByKey.delete(groupKey('mothership', undefined)) const { group: g } = ensureGroup(key, block.parentToolCallId) + if (block.subagentName) g.agentLabel = block.subagentName if (inheritedDelegation) g.isDelegating = true g.isOpen = true activeGroupKey = resolveGroupKey(key, block.parentToolCallId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index d82bb65783b..6088ee0419a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -167,6 +167,7 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { block: { type: 'subagent', content: node.agentId, + ...(node.displayName ? { subagentName: node.displayName } : {}), spanId: node.spanId, parentSpanId: node.parentSpanId, ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), @@ -267,7 +268,10 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { kind: 'subagent', event: 'start', agent: block.content, - data: block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}, + data: { + ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}), + ...(block.subagentName ? { name: block.subagentName } : {}), + }, }, scopeFor(block), block.timestamp diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 1c5eb2d72d7..85c0b420e73 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -237,6 +237,29 @@ describe('reduceEvent — subagent lifecycle', () => { expect(agent(m, 'S1').parentSpanId).toBe(MAIN_SPAN) }) + it('captures the orchestrator-chosen display name from span start data', () => { + const m = apply([ + envelope( + 1, + 'span', + { + kind: 'subagent', + event: 'start', + agent: 'research', + data: { tool_call_id: 'tc-r', name: 'Pricing research' }, + }, + { + lane: 'subagent', + spanId: 'S1', + parentSpanId: MAIN_SPAN, + parentToolCallId: 'tc-r', + agentId: 'research', + } + ), + ]) + expect(agent(m, 'S1').displayName).toBe('Pricing research') + }) + it('settles an agent error when span end carries an error', () => { const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 25651a0eb59..490f86d0109 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -85,6 +85,8 @@ export interface AgentNode extends NodeBase { agentId: string /** The outer delegation tool_use that triggered this run; links the trigger tool node. */ triggerToolCallId?: string + /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */ + displayName?: string status: NodeStatus /** Wire seq at which the run terminated (span end), for ordering the close marker. */ endSeq?: number @@ -563,6 +565,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const triggerToolCallId = scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' + const displayName = asString(data?.name) const resolvedSpanId = scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`) const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN @@ -581,6 +584,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // scope.agentId can name the forwarding caller (e.g. superagent), // while this start's payload.agent is the authoritative lane owner. if (agentId && existing.agentId !== agentId) existing.agentId = agentId + if (displayName && !existing.displayName) existing.displayName = displayName if (!existing.triggerToolCallId && triggerToolCallId) { existing.triggerToolCallId = triggerToolCallId } @@ -602,6 +606,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve seq: seq, ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), ...(triggerToolCallId ? { triggerToolCallId } : {}), + ...(displayName ? { displayName } : {}), } model.nodes.set(node.id, node) model.order.push(node.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index f29c7b86125..eedb402ba87 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -114,6 +114,8 @@ export interface ContentBlock { type: ContentBlockType content?: string subagent?: string + /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */ + subagentName?: string toolCall?: ToolCallInfo options?: OptionItem[] timestamp?: number diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 443d517a7bb..28a348a5837 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -92,7 +92,11 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi if (block.lifecycle === MothershipStreamV1SpanLifecycleEvent.end) { return { type: ContentBlockType.subagent_end } } - return { type: ContentBlockType.subagent, content: block.content } + return { + type: ContentBlockType.subagent, + content: block.content, + ...(block.name ? { subagentName: block.name } : {}), + } case MothershipStreamV1EventType.complete: if (block.status === MothershipStreamV1CompletionStatus.cancelled) { return { type: ContentBlockType.stopped } diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 76372f25d9e..c57e49b5a85 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -53,6 +53,8 @@ export interface PersistedContentBlock { lifecycle?: MothershipStreamV1SpanLifecycleEvent status?: MothershipStreamV1CompletionStatus content?: string + /** Orchestrator-chosen display name on a subagent start block. */ + name?: string toolCall?: PersistedToolCall timestamp?: number endedAt?: number @@ -245,6 +247,7 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { kind: MothershipStreamV1SpanPayloadKind.subagent, lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, + ...(block.subagentName ? { name: block.subagentName } : {}), } case 'subagent_text': return { @@ -436,6 +439,9 @@ interface RawBlock { type: string lane?: string agent?: string + /** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */ + name?: string + subagentName?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -503,6 +509,7 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { result.lane = block.lane } if (block.agent) result.agent = block.agent + if (block.name) result.name = block.name const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -584,6 +591,7 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { kind: MothershipStreamV1SpanPayloadKind.subagent, lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, + ...(block.subagentName ? { name: block.subagentName } : {}), } } diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index ab924af5e67..e72bb29d0a0 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -436,9 +436,17 @@ export async function runStreamLoop( const openParents = (context.openSubagentParents ??= new Set()) if (!openParents.has(toolCallId)) { openParents.add(toolCallId) + const payloadData = streamEvent.payload.data + const displayName = + payloadData && typeof payloadData === 'object' && !Array.isArray(payloadData) + ? (payloadData as Record).name + : undefined context.contentBlocks.push({ type: 'subagent', content: subagentName, + ...(typeof displayName === 'string' && displayName + ? { subagentName: displayName } + : {}), parentToolCallId: toolCallId, ...(spanId ? { spanId } : {}), ...(parentSpanId ? { parentSpanId } : {}), diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 580b17238ff..35127f3edf9 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -80,6 +80,8 @@ export interface ContentBlock { * `subagent` start block is missing (resume legs re-emit text without start). */ subagent?: string + /** Orchestrator-chosen display name for a `subagent` start block. */ + subagentName?: string /** * Deterministic agent-run identity. `spanId` is the stable per-invocation id * of the subagent that produced the block; `parentSpanId` links it to the run From ba788296b016ba1864dc4aea0114dd3bd3cc4d55 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:18:27 -0700 Subject: [PATCH 057/103] Improve Copilot error handling and logging --- .../[workspaceId]/home/hooks/use-chat.ts | 34 ++++++++++- apps/sim/instrumentation-node.ts | 33 +++++++++++ apps/sim/lib/copilot/application/error.ts | 16 ++++- .../client/browser-tool-execution.test.ts | 35 +++++++++++ .../tools/client/browser-tool-execution.ts | 30 ++++++++++ .../lib/copilot/tools/client/completion.ts | 13 ++++- .../tools/client/run-tool-execution.test.ts | 18 ++++-- .../tools/handlers/function-execute.test.ts | 12 ++-- .../tools/handlers/function-execute.ts | 10 +++- .../tools/registry/server-tool-adapter.ts | 2 + .../sim/lib/copilot/vfs/workspace-vfs.test.ts | 33 +++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 8 ++- apps/sim/package.json | 2 + bun.lock | 3 + packages/logger/package.json | 1 + packages/logger/src/index.ts | 58 +++++++++++++++++++ 16 files changed, 289 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 2bef849721d..2f431e3c354 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -228,6 +228,7 @@ const RECONNECT_TAIL_ERROR = const MAX_RECONNECT_ATTEMPTS = 10 const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_MAX_DELAY_MS = 30_000 +const RECONNECT_EXHAUSTED_RECHECK_MS = 30_000 const STREAM_BATCH_FETCH_TIMEOUT_MS = 10_000 const STREAM_CHAT_ID_RESOLVE_TIMEOUT_MS = 10_000 const CHAT_HISTORY_RECOVERY_TIMEOUT_MS = 10_000 @@ -1470,6 +1471,10 @@ export function useChat( () => {} ) const recoveringQueuedSendHandoffRef = useRef(null) + const recoverActiveStreamRef = useRef< + (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck') => Promise + >(async () => {}) + const reconnectExhaustedRecheckTimerRef = useRef | null>(null) const abortControllerRef = useRef(null) const detachedChatResolutionControllersRef = useRef>(new Set()) @@ -3242,7 +3247,29 @@ export function useChat( maxAttempts: MAX_RECONNECT_ATTEMPTS, }) if (streamGenRef.current === gen) { + /** + * Never give up silently: surface the failure so the pane shows why + * the live stream stopped instead of a torn-down transcript. Callers + * own the finalize on a false return (every call site finalizes with + * error: true), which refetches the persisted transcript; if the + * server turn is still running, the visibility/online recovery path + * re-attaches on the next pageshow/visible/online event. + */ setIsReconnecting(false) + setError(RECONNECT_TAIL_ERROR) + /** + * The tab may stay visible (no pageshow/visible/online event will ever + * fire) while the server turn keeps running detached. One bounded + * recheck re-enters recovery once the transient network condition has + * had time to clear; recovery itself no-ops when nothing is active. + */ + if (reconnectExhaustedRecheckTimerRef.current) { + clearTimeout(reconnectExhaustedRecheckTimerRef.current) + } + reconnectExhaustedRecheckTimerRef.current = setTimeout(() => { + reconnectExhaustedRecheckTimerRef.current = null + void recoverActiveStreamRef.current('exhausted_recheck') + }, RECONNECT_EXHAUSTED_RECHECK_MS) } return false }, @@ -3251,7 +3278,7 @@ export function useChat( retryReconnectRef.current = retryReconnect const recoverActiveStreamFromRedis = useCallback( - async (reason: 'pageshow' | 'visible' | 'online'): Promise => { + async (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck'): Promise => { const startingChatId = chatIdRef.current const startingSelectedChatId = selectedChatIdRef.current const chatId = startingChatId ?? startingSelectedChatId @@ -3386,6 +3413,7 @@ export function useChat( }, [getActiveStreamIdForChat, queryClient, resumeOrFinalize, setTransportReconnecting] ) + recoverActiveStreamRef.current = recoverActiveStreamFromRedis useEffect(() => { if (typeof window === 'undefined' || typeof document === 'undefined') return @@ -3417,6 +3445,10 @@ export function useChat( document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('online', handleOnline) + if (reconnectExhaustedRecheckTimerRef.current) { + clearTimeout(reconnectExhaustedRecheckTimerRef.current) + reconnectExhaustedRecheckTimerRef.current = null + } } }, [recoverActiveStreamFromRedis]) diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index 9d536bb5276..2b9da2a012c 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -83,6 +83,23 @@ function normalizeOtlpMetricsUrl(url: string): string { } } +// Logs counterpart to `normalizeOtlpMetricsUrl` — same parsed-pathname +// handling, targeting the /v1/logs signal path. +function normalizeOtlpLogsUrl(url: string): string { + if (!url) return url + try { + const u = new URL(url) + const path = u.pathname.replace(/\/$/, '') + if (path.endsWith('/v1/logs')) return url + u.pathname = path.endsWith('/v1/traces') + ? path.replace(/\/v1\/traces$/, '/v1/logs') + : `${path}/v1/logs` + return u.toString() + } catch { + return url + } +} + // deployment.environment in the GO value space (dev | staging | prod) without // any new infra env var. Every deployed Sim tier already gets // APPCONFIG_ENVIRONMENT = the infra env name (dev | staging | production), so we @@ -177,6 +194,8 @@ async function initializeOpenTelemetry() { const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http') const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-http') const { PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics') + const { OTLPLogExporter } = await import('@opentelemetry/exporter-logs-otlp-http') + const { BatchLogRecordProcessor } = await import('@opentelemetry/sdk-logs') const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node') const { TraceIdRatioBasedSampler, SamplingDecision } = await import( '@opentelemetry/sdk-trace-base' @@ -271,6 +290,19 @@ async function initializeOpenTelemetry() { exportIntervalMillis: 60000, }) + // Logs share the trace endpoint and headers as well (signal path + // /v1/logs). Every @sim/logger line fans out through the global Logs API + // (see packages/logger), which the NodeSDK wires to this processor — the + // stdout JSON lines continue to CloudWatch unchanged. + const logRecordProcessor = new BatchLogRecordProcessor( + new OTLPLogExporter({ + url: normalizeOtlpLogsUrl(telemetryConfig.endpoint), + headers: otlpHeaders, + timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000), + keepAlive: false, + }) + ) + // Must be unique per process: replicas sharing one instance id collapse // into a single Prometheus series, so their independent cumulative // counters interleave and corrupt rate()/increase(). The slug keeps Sim @@ -320,6 +352,7 @@ async function initializeOpenTelemetry() { spanProcessors, sampler, metricReader, + logRecordProcessors: [logRecordProcessor], }) sdk.start() diff --git a/apps/sim/lib/copilot/application/error.ts b/apps/sim/lib/copilot/application/error.ts index 966a3233b7c..1626fb942b2 100644 --- a/apps/sim/lib/copilot/application/error.ts +++ b/apps/sim/lib/copilot/application/error.ts @@ -1,13 +1,25 @@ +import { trace } from '@opentelemetry/api' +import { toError } from '@sim/utils/errors' import { asOrchestrationError } from '@/lib/core/orchestration/types' export const COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE = 'The operation failed due to a system error. Please retry.' -/** Projects only caller-actionable application failures into Copilot-visible content. */ +/** + * Projects only caller-actionable application failures into Copilot-visible + * content. Whenever the real cause is swallowed by the generic fallback, it is + * recorded on the active span first — otherwise these failures are + * undiagnosable from telemetry (the cause otherwise lives only in stdout logs + * that do not ship anywhere queryable). + */ export function messageForCopilotApplicationError( error: unknown, fallback = COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE ): string { const classified = asOrchestrationError(error) - return classified && classified.code !== 'internal' ? classified.message : fallback + if (classified && classified.code !== 'internal') { + return classified.message + } + trace.getActiveSpan()?.recordException(toError(error)) + return fallback } diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts index 8731aafeedc..82b9cc74da1 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts @@ -436,3 +436,38 @@ describe('executeBrowserToolOnClient', () => { }) }) }) + +describe('pre-dispatch drops still resolve the waiter', () => { + beforeEach(() => { + vi.clearAllMocks() + mockReportCompletion.mockResolvedValue(undefined) + }) + + it('reports an error confirmation for a stale event instead of hanging the turn', async () => { + const staleTs = new Date(Date.now() - 10 * 60 * 1000).toISOString() + executeBrowserToolOnClient('stale-call-1', 'browser_list_sessions', {}, 'chat-scope-1', staleTs) + await sleep(0) + + expect(mockExecuteBrowserTool).not.toHaveBeenCalled() + expect(mockReportCompletion).toHaveBeenCalledWith( + 'stale-call-1', + 'error', + expect.stringContaining('too late'), + expect.objectContaining({ staleEvent: true }) + ) + }) + + it('reports an error confirmation when no chat scope exists', async () => { + useBrowserSessionStore.setState({ activeScopeId: null }) + executeBrowserToolOnClient('no-scope-1', 'browser_list_sessions', {}, undefined) + await sleep(0) + + expect(mockExecuteBrowserTool).not.toHaveBeenCalled() + expect(mockReportCompletion).toHaveBeenCalledWith( + 'no-scope-1', + 'error', + expect.stringContaining('no active browser session'), + expect.anything() + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts index e0a13d08125..4e1f4476822 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts @@ -175,15 +175,45 @@ export function executeBrowserToolOnClient( ): void { if (!scopeId) { logger.error('Cannot execute browser tool without a chat scope', { toolCallId, toolName }) + // Tell the waiter, or the turn hangs forever on a tool that never ran. + const message = 'This browser action could not run: no active browser session for this chat.' + void reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, { + error: message, + }).catch((reportErr) => { + logger.error('Failed to report missing-scope browser tool error', { + toolCallId, + error: toError(reportErr).message, + }) + }) return } if (hasAlreadyExecuted(toolCallId)) { + // Same-page re-delivery: the original dispatch is in flight (or done) and + // owns the result. Reporting here would race it — the server claims each + // resume exactly once, so an error now would discard the genuine result. logger.info('Skipping already-executed browser tool (replay)', { toolCallId, toolName }) return } const age = eventAgeMs(eventTs) if (age !== null && age > MAX_EVENT_AGE_MS) { logger.info('Skipping stale browser tool event', { toolCallId, toolName, age }) + // Usually a replay of an action that already ran and resumed in a previous + // page lifetime — the server claims each resume exactly once, so this + // duplicate confirmation is simply discarded. When it is NOT a replay + // (the event was delivered late, e.g. a backgrounded tab with throttled + // timers), this error unblocks the turn instead of leaving it hanging + // forever on a tool that will never execute. + const message = + 'This browser action was delivered too late to run safely. Ask again to retry it.' + void reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, { + error: message, + staleEvent: true, + }).catch((reportErr) => { + logger.error('Failed to report stale browser tool error', { + toolCallId, + error: toError(reportErr).message, + }) + }) return } markExecuted(toolCallId) diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts index b99cb55cf98..691cb1699f4 100644 --- a/apps/sim/lib/copilot/tools/client/completion.ts +++ b/apps/sim/lib/copilot/tools/client/completion.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { isRecordLike } from '@sim/utils/object' +import { backoffWithJitter } from '@sim/utils/retry' import type { AsyncCompletionData, AsyncConfirmationStatus, @@ -48,7 +49,13 @@ export async function reportClientToolCompletion( const bodySize = new Blob([body]).size let lastError: Error | null = null - for (let attempt = 1; attempt <= 2; attempt++) { + // A lost confirmation strands the server-side waiter forever (the turn shows + // the tool as running indefinitely), so ride out multi-second network blips: + // 5 attempts with jittered exponential backoff (~15s total) instead of a + // sub-second give-up. The confirm endpoint claims each resume exactly once, + // so duplicate deliveries from retries are discarded server-side. + const maxAttempts = 5 + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const response = await send(body) if (response.ok) return @@ -78,8 +85,8 @@ export async function reportClientToolCompletion( lastError = toError(error) } - if (attempt < 2) { - await sleep(250) + if (attempt < maxAttempts) { + await sleep(backoffWithJitter(attempt, null)) } } diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index ccf8c55de2d..95e58fa4640 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -57,6 +57,11 @@ const setCurrentExecutionId = vi.fn() const getCurrentExecutionId = vi.fn() const getWorkflowExecution = vi.fn(() => ({ isExecuting: false })) +// Neutralize the confirm-retry backoff so exhaustion tests stay fast. +vi.mock('@sim/utils/retry', () => ({ + backoffWithJitter: () => 0, +})) + vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils', () => ({ executeWorkflowWithFullLogging, })) @@ -265,6 +270,9 @@ describe('run tool execution cancellation', () => { }) .mockResolvedValueOnce({ ok: false, status: 503 }) .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) .mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) @@ -273,7 +281,7 @@ describe('run tool execution cancellation', () => { async: true, }) - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(6)) await vi.waitFor(() => expect(isRunToolActiveForId('tool-recover-async')).toBe(false)) loadExecutionPointer.mockResolvedValueOnce({ workflowId: 'wf-1', @@ -283,10 +291,10 @@ describe('run tool execution cancellation', () => { await expect(bindRunToolToExecution('tool-recover-async', 'wf-1')).resolves.toBe(true) - expect(fetchMock).toHaveBeenCalledTimes(4) - expect(fetchMock.mock.calls[3][0]).toBe('/api/copilot/confirm') - expect(fetchMock.mock.calls[3][1]?.body).toContain('"status":"background"') - expect(fetchMock.mock.calls[3][1]?.body).toContain('"executionId":"exec-recover-async"') + expect(fetchMock).toHaveBeenCalledTimes(7) + expect(fetchMock.mock.calls[6][0]).toBe('/api/copilot/confirm') + expect(fetchMock.mock.calls[6][1]?.body).toContain('"status":"background"') + expect(fetchMock.mock.calls[6][1]?.body).toContain('"executionId":"exec-recover-async"') expect( fetchMock.mock.calls.filter(([url]) => url === '/api/workflows/wf-1/execute') ).toHaveLength(1) 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 f85769f99d9..015db935692 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -247,7 +247,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { ) expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', expect.objectContaining({ envVars: { API_KEY: 'secret-value' }, secretScope: 'selected', @@ -272,7 +272,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) @@ -305,7 +305,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { requestedNames: names, }) expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', expect.objectContaining({ code, language, mountedSecrets: names }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) @@ -332,7 +332,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { requestedNames: ['CLI_TOKEN'], }) expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', expect.objectContaining({ code, language: 'shell', mountedSecrets: ['CLI_TOKEN'] }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), @@ -353,7 +353,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { ) expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', expect.objectContaining({ _context: expect.not.objectContaining({ sandboxProfile: expect.anything() }), }), @@ -373,7 +373,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { expect(mockHasWorkspaceSandboxAccess).toHaveBeenCalledWith('ws_1') expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', expect.objectContaining({ sandboxId: 'sandbox-1' }), expect.objectContaining({ internalSandboxProfile: 'mothership' }) ) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 7580c3382da..8923e6a4ab8 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -745,7 +745,15 @@ export async function executeFunctionExecute( } try { - const result = await executeAppTool('run_function', enrichedParams, { + /** + * The copilot-facing tool is named `run_function`, but the app-tool + * registry id stays `function_execute` — the validator in tools/index.ts + * only admits `internalSandboxProfile` for that id, and every copilot + * call carries the internal `mothership` profile. Renaming this inner id + * without renaming the registry breaks every copilot sandbox call with + * "An internal sandbox profile may only be used with function_execute". + */ + const result = await executeAppTool('function_execute', enrichedParams, { resolvedSecretTraceRegistry: mountedRegistry, ...(context.abortSignal ? { signal: context.abortSignal } : {}), ...(context.sandboxProfile ? { internalSandboxProfile: context.sandboxProfile } : {}), 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 76e001fe4f5..1c44356651e 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -44,6 +44,8 @@ export function createServerToolHandler(toolId: string): ToolHandler { return { success: true, output: result } } catch (error) { const caughtError = toError(error) + // The generic projection below records the swallowed cause on the active + // span itself (messageForCopilotApplicationError) so Tempo carries it. logger.error( 'Server tool execution failed', { diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts index 9ea57ae9350..875346ea775 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts @@ -130,3 +130,36 @@ describe('WorkspaceVFS dynamic render reads', () => { }) }) }) + +describe('WorkspaceVFS lazy grep resilience', () => { + it('skips an unmaterializable lazy artifact instead of failing the whole sweep', async () => { + const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + const internals = vfs as unknown as { + files: Map + registerLazy: (path: string, loader: () => Promise) => void + resolveLazyPath: (path: string) => Promise + } + internals.files.set('workflows/A/state.json', '{"needle": true}') + internals.registerLazy.call(vfs, 'knowledgebases/huge/documents.json', async () => { + throw new Error( + 'Knowledge base kb-1 has more than 10000 documents; documents.json cannot be materialized' + ) + }) + internals.registerLazy.call( + vfs, + 'knowledgebases/small/documents.json', + async () => '{"needle": "lazy"}' + ) + + const matches = (await vfs.grep('needle')) as Array<{ path: string }> + const paths = matches.map((m) => m.path) + expect(paths).toContain('workflows/A/state.json') + expect(paths).toContain('knowledgebases/small/documents.json') + + // Reading the failing artifact directly still surfaces its own error, and + // the loader stays re-armed for that read. + await expect( + internals.resolveLazyPath.call(vfs, 'knowledgebases/huge/documents.json') + ).rejects.toThrow('cannot be materialized') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 6a3716effba..451c535e018 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -747,7 +747,13 @@ export class WorkspaceVFS { if (!scope || ops.pathWithinGrepScope(path, scope)) targets.push(path) } if (targets.length === 0) return - await Promise.all(targets.map((path) => this.resolveLazyPath(path))) + // One unmaterializable artifact (e.g. an over-limit knowledge base's + // documents.json) must not fail the whole sweep — that would make every + // unscoped grep on the workspace error on content the caller never asked + // about. Skip it: grep proceeds over everything that resolved, the loader + // stays re-armed, and reading the failing path directly still surfaces its + // own error (resolveLazyPath logs each failure). + await Promise.allSettled(targets.map((path) => this.resolveLazyPath(path))) } /** diff --git a/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..86e6ebcdda7 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -81,10 +81,12 @@ "@monaco-editor/react": "4.7.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "2.8.0", + "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-node": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0", diff --git a/bun.lock b/bun.lock index a6a5f598185..0937d2dcf85 100644 --- a/bun.lock +++ b/bun.lock @@ -182,11 +182,13 @@ "@modelcontextprotocol/sdk": "1.29.0", "@monaco-editor/react": "4.7.0", "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-node": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0", @@ -521,6 +523,7 @@ "name": "@sim/logger", "version": "0.1.0", "dependencies": { + "@opentelemetry/api-logs": "0.219.0", "@sim/utils": "workspace:*", "chalk": "5.6.2", }, diff --git a/packages/logger/package.json b/packages/logger/package.json index 0e8135a81cc..59e4ce2bdfb 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -25,6 +25,7 @@ "test:watch": "vitest" }, "dependencies": { + "@opentelemetry/api-logs": "0.219.0", "@sim/utils": "workspace:*", "chalk": "5.6.2" }, diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index f8d3057b9e2..a4e4d5bc79e 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -4,6 +4,7 @@ * Framework-agnostic logging utilities for the Sim platform. * Provides standardized console logging with environment-aware configuration. */ +import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { filterUndefined } from '@sim/utils/object' import chalk from 'chalk' import { getRequestContext } from './request-context' @@ -348,6 +349,8 @@ export class Logger { private log(level: LogLevel, message: string, ...args: unknown[]) { if (!this.shouldLog(level)) return + emitOtelLogRecord(level, this.module, message, this.metadata, args) + const timestamp = new Date().toISOString() const formattedArgs = this.formatArgs(args) @@ -478,3 +481,58 @@ export function createLogger(module: string, config?: LoggerConfig): Logger { export type { RequestContext } from './request-context' export { getRequestContext, runWithRequestContext } from './request-context' + +const OTEL_LOG_SEVERITY: Record = { + [LogLevel.DEBUG]: { number: SeverityNumber.DEBUG, text: 'DEBUG' }, + [LogLevel.INFO]: { number: SeverityNumber.INFO, text: 'INFO' }, + [LogLevel.WARN]: { number: SeverityNumber.WARN, text: 'WARN' }, + [LogLevel.ERROR]: { number: SeverityNumber.ERROR, text: 'ERROR' }, +} + +const OTEL_LOG_ARG_MAX_CHARS = 2000 + +/** + * Fans every accepted log line out through the OTel Logs API. Until an + * application installs a global LoggerProvider (apps/sim does in + * instrumentation-node.ts), the api-logs global is a no-op delegate, so this + * costs nothing in browsers, tests, and services that do not export logs. + * The active trace context is attached by the SDK, which is what enables + * span → logs correlation in the backend. Never allowed to throw into the + * console write path. + */ +function emitOtelLogRecord( + level: LogLevel, + module: string, + message: string, + metadata: Record, + args: unknown[] +): void { + try { + const severity = OTEL_LOG_SEVERITY[level] + const attributes: Record = { 'log.module': module } + for (const [key, value] of Object.entries(filterUndefined(metadata))) { + attributes[key] = String(value) + } + const firstError = args.find((arg) => arg instanceof Error) as Error | undefined + if (firstError) { + attributes['error.message'] = firstError.message + if (firstError.stack) attributes['error.stack'] = firstError.stack + } + const plainArgs = args.filter((arg) => !(arg instanceof Error)) + if (plainArgs.length > 0) { + try { + attributes['log.args'] = JSON.stringify(plainArgs).slice(0, OTEL_LOG_ARG_MAX_CHARS) + } catch { + attributes['log.args'] = String(plainArgs).slice(0, OTEL_LOG_ARG_MAX_CHARS) + } + } + logs.getLogger('sim').emit({ + severityNumber: severity.number, + severityText: severity.text, + body: message, + attributes, + }) + } catch { + // Log export must never break the primary console write path. + } +} From 752ddd258f88d7afaedc58a90050c81633ef71e7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:50:46 -0700 Subject: [PATCH 058/103] Backfill the subagent display name from the second start event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatch-time subagent_start fires before the trigger args (and therefore the name parameter) have streamed; the phase-3 start re-announces the lane with the name. The block builder was dropping that duplicate wholesale, losing the name on streaming providers — now it backfills subagentName onto the existing block instead. (The home turn-model path already reconciled this case.) --- .../sim/lib/copilot/request/go/stream.test.ts | 66 +++++++++++++++++++ apps/sim/lib/copilot/request/go/stream.ts | 27 +++++--- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 25f550a3a03..991570b1343 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -936,4 +936,70 @@ describe('copilot go stream helpers', () => { expect(subagentBlock?.parentSpanId).toBe('S1') expect(subagentBlock?.parentToolCallId).toBe('tc-deploy-inner') }) + + it('backfills the display name when only the second subagent start carries it', async () => { + const scope = { + lane: 'subagent' as const, + agentId: 'research', + parentToolCallId: 'tc-research', + spanId: 'S3', + parentSpanId: 'S1', + } + vi.mocked(fetch).mockResolvedValueOnce( + createSseResponse([ + // Dispatch-time start: fires before the trigger args stream, so no name. + createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.span, + scope, + payload: { + kind: 'subagent', + event: 'start', + agent: 'research', + data: { tool_call_id: 'tc-research' }, + }, + }), + // Phase-3 start re-announces the lane WITH the orchestrator-chosen name. + createEvent({ + streamId: 'stream-1', + cursor: '2', + seq: 2, + requestId: 'req-1', + type: MothershipStreamV1EventType.span, + scope, + payload: { + kind: 'subagent', + event: 'start', + agent: 'research', + data: { tool_call_id: 'tc-research', name: 'Pricing research' }, + }, + }), + createEvent({ + streamId: 'stream-1', + cursor: '3', + seq: 3, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.complete }, + }), + ]) + ) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + + const subagentBlocks = context.contentBlocks.filter((block) => block.type === 'subagent') + expect(subagentBlocks).toHaveLength(1) + expect(subagentBlocks[0]?.subagentName).toBe('Pricing research') + }) }) diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index e72bb29d0a0..b5dc0f2da3b 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -433,25 +433,36 @@ export async function runStreamLoop( context.subAgentToolCalls[toolCallId] ??= [] } if (toolCallId && subagentName) { + const payloadData = streamEvent.payload.data + const rawName = + payloadData && typeof payloadData === 'object' && !Array.isArray(payloadData) + ? (payloadData as Record).name + : undefined + const displayName = typeof rawName === 'string' && rawName ? rawName : undefined const openParents = (context.openSubagentParents ??= new Set()) if (!openParents.has(toolCallId)) { openParents.add(toolCallId) - const payloadData = streamEvent.payload.data - const displayName = - payloadData && typeof payloadData === 'object' && !Array.isArray(payloadData) - ? (payloadData as Record).name - : undefined context.contentBlocks.push({ type: 'subagent', content: subagentName, - ...(typeof displayName === 'string' && displayName - ? { subagentName: displayName } - : {}), + ...(displayName ? { subagentName: displayName } : {}), parentToolCallId: toolCallId, ...(spanId ? { spanId } : {}), ...(parentSpanId ? { parentSpanId } : {}), timestamp: Date.now(), }) + } else if (displayName) { + // The lane was opened by the dispatch-time start, which fires + // before the trigger args (and therefore the name) exist. The + // phase-3 start re-announces the lane WITH the name; backfill + // it instead of dropping the duplicate wholesale. + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const b = context.contentBlocks[i] + if (b.type === 'subagent' && b.parentToolCallId === toolCallId) { + if (!b.subagentName) b.subagentName = displayName + break + } + } } } else { logger.warn('subagent start missing toolCallId or agent name', { From 0e78b14d2a8c26810d8d1fedf26ec9f79444cb53 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 18:11:32 -0700 Subject: [PATCH 059/103] Support Slack bot connection flow --- .../special-tags/special-tags.test.tsx | 15 +++ .../components/special-tags/special-tags.tsx | 11 +- .../lib/copilot/generated/tool-catalog-v1.ts | 33 ++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 28 +++++ .../tool-executor/register-handlers.ts | 3 + .../copilot/tools/client/store-utils.test.ts | 32 +++++- .../lib/copilot/tools/client/store-utils.ts | 34 +++++- .../management/connect-slack-bot.test.ts | 105 ++++++++++++++++++ .../handlers/management/connect-slack-bot.ts | 91 +++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 2 + 10 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 6270761fc44..b2ba0064beb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -1359,4 +1359,19 @@ describe('recoverTrailingBareOptions', () => { const { segments } = parseSpecialTags(`Pick one ${bareOptions}`, false) expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1) }) + + it('recovers an options payload wrapped in the singular `, + false + ) + const last = segments[segments.length - 1] + expect(last.type).toBe('options') + if (last.type === 'options') { + expect(Object.keys(last.data)).toEqual(['1', '2', '3']) + expect(last.data['1']?.title).toBe('Demo wait — sleep until an agent finishes') + } + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 21596a05ea1..ceb79140ce7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1459,11 +1459,20 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS * already parsed. Never applied mid-stream: a partial JSON tail must not * flicker between prose and a card. */ +const NEAR_MISS_OPTIONS_WRAPPER = /` fails + // the brace gate below). Unwrap it and let the strict shape check decide. + const nearMiss = NEAR_MISS_OPTIONS_WRAPPER.exec(text) + if (nearMiss) { + text = `${text.slice(0, nearMiss.index)}${nearMiss[1]}` + } if (!text.trimEnd().endsWith('}')) return // The payload nests objects, so the START brace is the first one from which // the remainder parses — probe brace positions left to right (bounded). diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index da266e91fd0..fe635a1bc9a 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -35,6 +35,7 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' + | 'connect_slack_bot' | 'cp' | 'create_empty_file' | 'create_workflow' @@ -163,6 +164,7 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' + | 'connect_slack_bot' | 'cp' | 'create_empty_file' | 'create_workflow' @@ -1636,6 +1638,36 @@ export const CallIntegrationTool: ToolCatalogEntry = { requiresApproval: true, } +export const ConnectSlackBot: ToolCatalogEntry = { + id: 'connect_slack_bot', + name: 'connect_slack_bot', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + botTokenEnvVar: { + type: 'string', + description: + 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.', + }, + description: { type: 'string', description: 'Optional description shown on the credential.' }, + displayName: { + type: 'string', + description: + 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.', + }, + signingSecretEnvVar: { + type: 'string', + description: + "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.", + }, + }, + required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'], + }, + requiredPermission: 'write', +} + export const Cp: ToolCatalogEntry = { id: 'cp', name: 'cp', @@ -7103,6 +7135,7 @@ export const TOOL_CATALOG: Record = { [BrowserType.id]: BrowserType, [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, + [ConnectSlackBot.id]: ConnectSlackBot, [Cp.id]: Cp, [CreateEmptyFile.id]: CreateEmptyFile, [CreateWorkflow.id]: CreateWorkflow, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 105022d971a..d94d43860ee 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1580,6 +1580,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + connect_slack_bot: { + parameters: { + type: 'object', + properties: { + botTokenEnvVar: { + type: 'string', + description: + 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.', + }, + description: { + type: 'string', + description: 'Optional description shown on the credential.', + }, + displayName: { + type: 'string', + description: + 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.', + }, + signingSecretEnvVar: { + type: 'string', + description: + "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.", + }, + }, + required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'], + }, + resultSchema: undefined, + }, cp: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 61276eb7527..b78eac3859d 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { + ConnectSlackBot, Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, @@ -74,6 +75,7 @@ import { } from '../tools/handlers/deployment/manage' import { executeFunctionExecute } from '../tools/handlers/function-execute' import { executeListIntegrationTools } from '../tools/handlers/integration-tools' +import { executeConnectSlackBot } from '../tools/handlers/management/connect-slack-bot' import { executeManageCredential } from '../tools/handlers/management/manage-credential' import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool' @@ -185,6 +187,7 @@ function buildHandlerMap(): Record { [ManageSandbox.id]: h(executeManageSandbox), [ManageSkill.id]: h(executeManageSkill), [ManageCredential.id]: h(executeManageCredential), + [ConnectSlackBot.id]: h(executeConnectSlackBot), [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), // Rolling-deploy compatibility for calls/checkpoints created before OAuth // moved into terminal credential cards. New agents no longer receive this diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 788b42781e6..81fa2870646 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -40,7 +40,7 @@ describe('resolveToolDisplay', () => { resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'workflows/My Workflow/meta.json', })?.text - ).toBe('Read My Workflow') + ).toBe('Read metadata for My Workflow') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { @@ -49,6 +49,34 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) + it('labels resource artifact reads distinctly instead of repeating the resource name', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'workflows/Elder v2/The Elder/state.json', + })?.text + ).toBe('Read The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'workflows/Elder v2/The Elder/deployment.json', + })?.text + ).toBe('Read deployment status for The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { + path: 'workflows/Elder v2/The Elder/lint.json', + })?.text + ).toBe('Attempted to read lint results for The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'tables/CRM/Leads/views.json', + })?.text + ).toBe('Read views of Leads') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'knowledgebases/Contracts/documents.json', + })?.text + ).toBe('Read documents in Contracts') + }) + it('decodes percent-encoded VFS path segments for display', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { @@ -60,7 +88,7 @@ describe('resolveToolDisplay', () => { resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'workflows/My%20Workflow/meta.json', })?.text - ).toBe('Read My Workflow') + ).toBe('Read metadata for My Workflow') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 343c9e2712d..2efa7d43fe5 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -107,11 +107,39 @@ function describeReadTarget(path: string | undefined): string | undefined { } if (resourceType === 'workflow') { - return stripExtension(getLeafResourceSegment(segments)) + return describeResourceArtifactTarget(segments) } - const resourceName = segments[1] || segments[segments.length - 1] - return stripExtension(resourceName) + return describeResourceArtifactTarget(segments) +} + +/** + * Resource-scoped artifact files, labeled the same prefix way as + * FILE_FACET_LABELS. `state.json` is the empty facet — reading a workflow means + * reading its state — so "Read The Elder", "Read metadata for The Elder", and + * "Read deployment status for The Elder" render as three distinct rows instead + * of three identical "Read The Elder" lines. + */ +const RESOURCE_ARTIFACT_LABELS: Record = { + 'state.json': '', + 'meta.json': 'metadata for', + 'lint.json': 'lint results for', + 'deployment.json': 'deployment status for', + 'versions.json': 'versions of', + 'executions.json': 'runs of', + 'views.json': 'views of', + 'documents.json': 'documents in', + 'connectors.json': 'connectors of', +} + +function describeResourceArtifactTarget(segments: string[]): string { + const lastSegment = segments[segments.length - 1] || '' + const resourceName = stripExtension(getLeafResourceSegment(segments)) + const artifactLabel = RESOURCE_ARTIFACT_LABELS[lastSegment] + if (artifactLabel !== undefined && segments.length > 1) { + return artifactLabel ? `${artifactLabel} ${resourceName}` : resourceName + } + return resourceName } // A workspace file is addressed as a directory of facets in the VFS diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts new file mode 100644 index 00000000000..46dc0b0dc04 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + performCreateCredential: vi.fn(), + getEffectiveDecryptedEnv: vi.fn(), +})) + +vi.mock('@/lib/credentials/orchestration', () => ({ + performCreateCredential: mocks.performCreateCredential, +})) +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: mocks.getEffectiveDecryptedEnv, +})) + +import { executeConnectSlackBot } from './connect-slack-bot' + +const context = { userId: 'user-1', workspaceId: 'ws-1' } as never + +const validParams = { + displayName: 'Elder Bot', + signingSecretEnvVar: 'SLACK_SIGNING_SECRET', + botTokenEnvVar: 'SLACK_BOT_TOKEN', +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.getEffectiveDecryptedEnv.mockResolvedValue({ + SLACK_SIGNING_SECRET: 'shhh', + SLACK_BOT_TOKEN: 'xoxb-123', + }) + mocks.performCreateCredential.mockResolvedValue({ + success: true, + created: true, + credential: { id: 'cred-1', displayName: 'Elder Bot' }, + }) +}) + +describe('executeConnectSlackBot', () => { + it('resolves env vars server-side and mints the credential with the request URL', async () => { + const result = await executeConnectSlackBot(validParams, context) + + expect(mocks.performCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + userId: 'user-1', + type: 'service_account', + providerId: 'slack-custom-bot', + displayName: 'Elder Bot', + signingSecret: 'shhh', + botToken: 'xoxb-123', + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + credentialId: 'cred-1', + created: true, + requestUrl: expect.stringContaining('/api/webhooks/slack/custom/cred-1'), + }) + }) + + it('names the missing env vars without leaking any values', async () => { + mocks.getEffectiveDecryptedEnv.mockResolvedValue({ SLACK_SIGNING_SECRET: 'shhh' }) + + const result = await executeConnectSlackBot(validParams, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('SLACK_BOT_TOKEN') + expect(result.error).not.toContain('shhh') + expect(mocks.performCreateCredential).not.toHaveBeenCalled() + }) + + it('requires displayName and both env var names', async () => { + const missingName = await executeConnectSlackBot( + { signingSecretEnvVar: 'A', botTokenEnvVar: 'B' }, + context + ) + expect(missingName.success).toBe(false) + expect(missingName.error).toContain('displayName') + + const missingVars = await executeConnectSlackBot({ displayName: 'Bot' }, context) + expect(missingVars.success).toBe(false) + expect(missingVars.error).toContain('signingSecretEnvVar') + }) + + it('surfaces orchestration failures (e.g. auth.test rejection or name conflict)', async () => { + mocks.performCreateCredential.mockResolvedValue({ + success: false, + error: 'Slack rejected the bot token', + }) + + const result = await executeConnectSlackBot(validParams, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('Slack rejected the bot token') + }) + + it('requires workspace scope', async () => { + const result = await executeConnectSlackBot(validParams, { userId: 'user-1' } as never) + expect(result.success).toBe(false) + expect(result.error).toContain('Workspace') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts new file mode 100644 index 00000000000..f02599e8d8e --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts @@ -0,0 +1,91 @@ +import { toError } from '@sim/utils/errors' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { performCreateCredential } from '@/lib/credentials/orchestration' +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' + +/** + * Mints a reusable Slack custom-bot credential from secrets ALREADY stored as + * environment variables (a v1 setup being migrated, or values saved via + * set_environment_variables after a browser-agent extraction). The agent + * passes env-var NAMES; the values are resolved here and validated by the + * credential orchestration (Slack auth.test), so no secret ever appears in + * tool args, checkpoints, or transcripts. When the USER holds the secrets, + * the service_account credential card is the right path instead. + */ +export function executeConnectSlackBot( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as { + displayName?: string + description?: string + signingSecretEnvVar?: string + botTokenEnvVar?: string + } + return (async () => { + try { + if (!context?.userId) { + return { success: false, error: 'Authentication required' } + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, error: 'Workspace scope required' } + } + const { displayName, description, signingSecretEnvVar, botTokenEnvVar } = params + if (!displayName) { + return { success: false, error: 'displayName is required' } + } + if (!signingSecretEnvVar || !botTokenEnvVar) { + return { + success: false, + error: + 'signingSecretEnvVar and botTokenEnvVar are required: the NAMES of the environment variables holding the Slack signing secret and bot token. Save the values with set_environment_variables first if needed.', + } + } + + const env = await getEffectiveDecryptedEnv(context.userId, workspaceId) + const missing = [signingSecretEnvVar, botTokenEnvVar].filter((name) => !env[name]) + if (missing.length > 0) { + return { + success: false, + error: `Environment variable(s) not found: ${missing.join(', ')}. Check environment/ in the VFS, or save the values with set_environment_variables first.`, + } + } + + const result = await performCreateCredential({ + workspaceId, + userId: context.userId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + displayName, + description, + signingSecret: env[signingSecretEnvVar], + botToken: env[botTokenEnvVar], + }) + if (!result.success || !result.credential) { + return { + success: false, + error: + result.error || + 'Failed to connect the Slack custom bot. If a credential with this display name already exists, reuse it (environment/credentials.json) or pick a different name.', + } + } + return { + success: true, + output: { + credentialId: result.credential.id, + displayName: result.credential.displayName, + created: result.created !== false, + // The Slack app's Event Subscriptions Request URL — one per + // credential, shared by every trigger that selects it; live + // immediately, no deployment needed. + requestUrl: buildSlackCustomBotRequestUrl(result.credential.id), + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } + })() +} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 8f1fcc83437..4059797c33e 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -492,6 +492,7 @@ const TOOL_TITLES: Record = { list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', save_upload: 'Saving upload', + connect_slack_bot: 'Connecting Slack bot', manage_sandbox: 'Managing sandbox', manage_scheduled_task: 'Managing scheduled task', move_file: 'Moving file', @@ -1034,6 +1035,7 @@ const COMPLETED_VERB_REWRITES: Record = { Crawling: 'Crawled', Creating: 'Created', Deleting: 'Deleted', + Connecting: 'Connected', Deploying: 'Deployed', Dragging: 'Dragged', Inserting: 'Inserted', From a4cdfa0e922ee1346af6ecec277a01831edee87b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 10:04:33 -0700 Subject: [PATCH 060/103] Harden Copilot error and VFS handling --- apps/sim/lib/copilot/application/error.ts | 23 ++++++++++++++++++++++- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 21 +++++++++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/application/error.ts b/apps/sim/lib/copilot/application/error.ts index 1626fb942b2..4f5c8626162 100644 --- a/apps/sim/lib/copilot/application/error.ts +++ b/apps/sim/lib/copilot/application/error.ts @@ -20,6 +20,27 @@ export function messageForCopilotApplicationError( if (classified && classified.code !== 'internal') { return classified.message } - trace.getActiveSpan()?.recordException(toError(error)) + trace.getActiveSpan()?.recordException(flattenErrorChain(error)) return fallback } + +/** + * Wrapper errors (Drizzle's "Failed query: ") bury the actionable cause — + * the Postgres constraint/violation — in `cause`. Join the chain so the span + * exception carries the part an investigator actually needs. + */ +function flattenErrorChain(error: unknown): Error { + const primary = toError(error) + const parts = [primary.message] + let cursor: unknown = primary.cause + let depth = 0 + while (cursor && depth < 4) { + parts.push(toError(cursor).message) + cursor = toError(cursor).cause + depth += 1 + } + if (parts.length === 1) return primary + const flattened = new Error(parts.join(' ← ')) + flattened.stack = primary.stack + return flattened +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 451c535e018..83a425199bb 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1806,11 +1806,24 @@ export class WorkspaceVFS { }) } + // deployment.json exists for EVERY workflow: "is it deployed?" is a + // question with an answer either way, and a not-found error here was a + // recurring red herring — agents probing an undeployed workflow read a + // failure instead of the fact. Versions stay gated: they genuinely + // don't exist before the first deploy. + this.registerLazy(`${prefix}deployment.json`, async () => { + if (!versionedWorkflowIds.has(wf.id)) { + return JSON.stringify({ + deployed: false, + note: 'This workflow has never been deployed.', + }) + } + const deploymentData = await this.loadDeployments(wf.id) + return deploymentData + ? serializeDeployments(deploymentData) + : JSON.stringify({ deployed: false, note: 'This workflow has never been deployed.' }) + }) if (versionedWorkflowIds.has(wf.id)) { - this.registerLazy(`${prefix}deployment.json`, async () => { - const deploymentData = await this.loadDeployments(wf.id) - return deploymentData ? serializeDeployments(deploymentData) : null - }) this.registerLazy(`${prefix}versions.json`, async () => { const deploymentData = await this.loadDeployments(wf.id) return deploymentData?.versions && deploymentData.versions.length > 0 From e0a14840c002c2352a0ee4296ee1ac5804f693ae Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 10:57:29 -0700 Subject: [PATCH 061/103] Harden VFS resource operations --- apps/sim/app/api/table/utils.ts | 23 ++++++---- apps/sim/lib/copilot/tools/handlers/vfs.ts | 19 ++++++++- apps/sim/lib/copilot/vfs/operations.test.ts | 23 +++++++++- apps/sim/lib/copilot/vfs/operations.ts | 37 +++++++++++++--- .../sim/lib/copilot/vfs/workspace-vfs.test.ts | 17 ++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 42 +++++++++++++++++-- apps/sim/lib/core/config/feature-flags.ts | 3 +- 7 files changed, 143 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index e049b1a3f68..dee6704c830 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -27,14 +27,13 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceOrganizationId } from '@/lib/workspaces/utils' /** - * Gate for the v2 tables HTTP API (`tables-v2-api` flag). Returns a 404 response - * when the flag is off for the caller — the surface behaves as if it doesn't - * exist — or `null` to proceed. Gated by userId + the workspace's org cohort. - * - * **Call this AFTER the authz check, never before.** Ahead of authz it does a - * primary-DB read keyed on a caller-supplied `workspaceId`, and the 404-vs-403 - * split tells an unauthorized caller whether that workspace's org is in the - * rollout cohort. + * Gate for the internal predicate-grammar table query route (`tables-v2-api` + * flag). Runs AFTER authorization, so the caller has already proven read + * access to the table — hiding the gate behind a bare 404 at that point + * serves nobody and reads as data loss (live incident: the table_v2 block + * hard-"Not found"-ing on every query while the copilot gateway, which + * bypasses HTTP, found the rows). Authorized callers get an honest 403 + * naming the gate instead. */ export async function tablesV2GateError( userId: string, @@ -42,7 +41,13 @@ export async function tablesV2GateError( ): Promise { const orgId = await getWorkspaceOrganizationId(workspaceId) if (await isFeatureEnabled('tables-v2-api', { userId, orgId })) return null - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json( + { + error: 'The v2 table query API is not enabled for this workspace', + code: 'tables_v2_disabled', + }, + { status: 403 } + ) } /** diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index dfa61ea3881..c8f6d2c642d 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -421,7 +421,7 @@ export async function executeVfsRead( success: false, error: isOversizedReadPlaceholder(fileContent) ? fileContent.content - : 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.', + : `Read result too large to return inline. Locate the relevant section first — grep({pattern: \"...\", path: \"${path}\"}) — then page it with read({path: \"${path}\", offset: , limit: }). Avoid catch-all greps or full-file reads because they waste context window.`, } } const windowedFileContent = applyWindow(fileContent) @@ -458,7 +458,22 @@ export async function executeVfsRead( } } - const result = await vfs.read(path, offset, limit) + let resolvedReadPath = path + let result = await vfs.read(path, offset, limit) + if (!result) { + // Same name, wrong encoding (spaces instead of %20) is the most common + // path mistake and carries zero ambiguity — resolve it instead of + // bouncing the model through a not-found round-trip. + const decodedEquivalent = vfs.resolveDecodedEquivalent(path) + if (decodedEquivalent) { + logger.info('vfs_read resolved decoded-equivalent path', { + requested: path, + resolved: decodedEquivalent, + }) + resolvedReadPath = decodedEquivalent + result = await vfs.read(decodedEquivalent, offset, limit) + } + } if (!result) { const suggestions = vfs.suggestSimilar(path) logger.warn('vfs_read file not found', { path, suggestions }) diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/copilot/vfs/operations.test.ts index 26c5b89b84c..b1d308f7250 100644 --- a/apps/sim/lib/copilot/vfs/operations.test.ts +++ b/apps/sim/lib/copilot/vfs/operations.test.ts @@ -2,7 +2,13 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { glob, grep, grepReadResult, WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' +import { + glob, + grep, + grepReadResult, + pathWithinGrepScope, + WorkspaceFileGrepError, +} from '@/lib/copilot/vfs/operations' import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' function vfsFromEntries(entries: [string, string][]): Map { @@ -235,3 +241,18 @@ describe('grepReadResult placeholders', () => { expect(grepResult({ content, totalLines: 1 })).toHaveLength(1) }) }) + +describe('decode-normalized matching', () => { + it('glob matches a decoded display pattern against encoded keys, returning canonical paths', () => { + const files = new Map([['workflows/Elder%20v2/The%20Elder/state.json', '{}']]) + const matches = glob(files, 'workflows/Elder v2/**') + expect(matches).toContain('workflows/Elder%20v2/The%20Elder/state.json') + }) + + it('grep scope written in decoded form filters in encoded keys', () => { + expect( + pathWithinGrepScope('workflows/Elder%20v2/The%20Elder/state.json', 'workflows/Elder v2') + ).toBe(true) + expect(pathWithinGrepScope('workflows/Other/state.json', 'workflows/Elder v2')).toBe(false) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index ac6d87c78a6..325609f63f4 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' import micromatch from 'micromatch' +import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' import { isNonGreppablePlaceholder, type PlaceholderKind, @@ -111,6 +112,20 @@ export interface ReadResult { * and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for * well-tested `**` and edge cases instead of a custom `RegExp`. */ +/** + * Matching is decode-normalized: canonical keys are percent-encoded, but the + * model routinely writes the decoded display form ("Elder v2"). Comparing both + * sides decoded makes scope/glob matching tolerant of the encoding difference + * while canonical (encoded) inputs behave exactly as before. Returned paths + * are always the canonical encoded keys. + */ +function decodePathForMatch(path: string): string { + return path + .split('/') + .map((segment) => decodeVfsSegmentSafe(segment)) + .join('/') +} + const VFS_GLOB_OPTIONS: micromatch.Options = { bash: false, dot: false, @@ -139,14 +154,19 @@ function splitLinesForGrep(content: string): string[] { */ export function pathWithinGrepScope(filePath: string, scope: string): boolean { const scopeUsesStarOrQuestionGlob = /[*?]/.test(scope) + const decodedPath = decodePathForMatch(filePath) + const decodedScope = decodePathForMatch(scope) if (scopeUsesStarOrQuestionGlob) { - return micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS) + return ( + micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS) || + micromatch.isMatch(decodedPath, decodedScope, VFS_GLOB_OPTIONS) + ) } - const base = scope.replace(/\/+$/, '') + const base = decodedScope.replace(/\/+$/, '') if (base === '') { return true } - return filePath === base || filePath.startsWith(`${base}/`) + return decodedPath === base || decodedPath.startsWith(`${base}/`) } /** @@ -275,15 +295,22 @@ export function glob(files: Map, pattern: string): string[] { } } + const decodedPattern = decodePathForMatch(pattern) for (const filePath of files.keys()) { if (filePath.endsWith('/.folder')) continue - if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) { + if ( + micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS) || + micromatch.isMatch(decodePathForMatch(filePath), decodedPattern, VFS_GLOB_OPTIONS) + ) { result.add(filePath) } } for (const dir of directories) { - if (micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS)) { + if ( + micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS) || + micromatch.isMatch(decodePathForMatch(dir), decodedPattern, VFS_GLOB_OPTIONS) + ) { result.add(dir) } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts index 875346ea775..d267b508656 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts @@ -163,3 +163,20 @@ describe('WorkspaceVFS lazy grep resilience', () => { ).rejects.toThrow('cannot be materialized') }) }) + +describe('WorkspaceVFS decoded-equivalent resolution', () => { + it('resolves a decoded path to its single encoded twin and rejects ambiguity', () => { + const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + const internals = vfs as unknown as { files: Map } + internals.files.set('workflows/Elder%20v2/The%20Elder/state.json', '{}') + + expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/state.json')).toBe( + 'workflows/Elder%20v2/The%20Elder/state.json' + ) + expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/meta.json')).toBeNull() + + // Two keys decoding identically (pathological) must refuse to guess. + internals.files.set('workflows/Elder v2/The Elder/state.json', '{}') + expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/state.json')).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 83a425199bb..fc13422e547 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -60,6 +60,7 @@ import { buildVfsFolderPathMap, canonicalWorkflowVfsDir, canonicalWorkspaceFilePath, + decodeVfsSegmentSafe, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' @@ -1032,9 +1033,18 @@ export class WorkspaceVFS { const normalized = path.replace(/^\/+/, '') // Prefer the path verbatim when it is itself a file leaf (e.g. a file literally // named "content"); otherwise drop a trailing "/content" read suffix. - const leaf = this.files.has(normalized) ? normalized : normalized.replace(/\/content$/, '') - - const isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) + let leaf = this.files.has(normalized) ? normalized : normalized.replace(/\/content$/, '') + + let isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) + if (isWorkspaceFilePath && !this.files.has(leaf)) { + // Same encoding tolerance as vfs_read: a decoded display form that maps + // to exactly one canonical key resolves instead of erroring. + const decodedEquivalent = this.resolveDecodedEquivalent(leaf) + if (decodedEquivalent) { + leaf = decodedEquivalent + isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) + } + } if (!isWorkspaceFilePath || !this.files.has(leaf)) { const suggestions = this.suggestSimilar(leaf) const hint = @@ -1078,6 +1088,25 @@ export class WorkspaceVFS { return ops.suggestSimilar(this.keyView(true), missingPath, max) } + /** + * Resolves a missing path to an existing one when the two differ ONLY by + * percent-encoding (the model typed the decoded display form — spaces + * instead of %20). Returns the canonical existing path when exactly one key + * decodes to the same segments; ambiguity or a genuine miss returns null so + * the not-found error (with suggestions) still fires. Never fuzzy: same + * name, different bytes only. + */ + resolveDecodedEquivalent(missingPath: string): string | null { + const target = decodeVfsPathSegmentsSafe(missingPath) + let match: string | null = null + for (const key of this.keyView(true).keys()) { + if (decodeVfsPathSegmentsSafe(key) !== target) continue + if (match !== null) return null + match = key + } + return match + } + private async resolveWorkspaceFileForDynamicRead( path: string, suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract' @@ -2688,3 +2717,10 @@ export type { FileReadResult } from '@/lib/copilot/vfs/file-reader' export function sanitizeName(name: string): string { return normalizeVfsSegment(name) } + +function decodeVfsPathSegmentsSafe(path: string): string { + return path + .split('/') + .map((segment) => decodeVfsSegmentSafe(segment)) + .join('/') +} diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index cef6ab2e66b..bf364037c49 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -118,7 +118,8 @@ const FEATURE_FLAGS = { 'tables-v2-api': { description: 'Gate the internal predicate-grammar table query route (POST /api/table/[tableId]/query), ' + - 'its only caller. When off, that route returns 404 as if it does not exist. Despite the ' + + 'its only caller. When off, that route returns 403 naming the gate (post-authz, so the ' + + 'masquerade 404 served nobody and broke the table_v2 block confusingly). Despite the ' + 'name it does NOT gate any /api/v2/tables route — the public v2 tables surface is gated ' + 'by v2-api alone. Gated by userId/orgId/admins via AppConfig; off-AppConfig falls back to ' + 'TABLES_V2_API.', From 63bc10816a3e27b2873d40e2c2f3a46a5b377218 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 11:05:45 -0700 Subject: [PATCH 062/103] Show 'Waiting for the first of N agents' for mode-any waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wait_agents title ignored the mode argument, so an any-mode wait over three agents read 'Waiting for 3 agents' while the model narrated waiting for the first — contradicting the transcript. --- apps/sim/lib/copilot/tools/tool-display.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 4059797c33e..ad08842187d 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -610,12 +610,17 @@ function waitTitle(args: ToolArgs): string { return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason')) } -/** Title for a wait_agents sleep, naming the agent(s) being collected. */ +/** Title for a wait_agents sleep, naming the agent(s) being collected and honoring mode "any". */ function waitAgentsTitle(args: ToolArgs): string { const raw = args?.agent_ids const ids = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [] + const anyMode = stringArg(args, 'mode') === 'any' if (ids.length === 1) return `Waiting for ${ids[0]}` - if (ids.length > 1) return `Waiting for ${ids.length} agents` + if (ids.length > 1) { + return anyMode + ? `Waiting for the first of ${ids.length} agents` + : `Waiting for ${ids.length} agents` + } return 'Waiting for agents' } From c51683658e12757968f8647af64a9bfbe67e685e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 11:25:40 -0700 Subject: [PATCH 063/103] Collapsed-by-default agent cards with live intent status lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagents now narrate their work through 3-5 words tags (a fleet-wide prompt protocol on the mothership side). The turn model streams each subagent's text through a split-safe tag parser: complete tags update the agent's currentIntent and disappear from the prose, tags split across deltas are carried until their close arrives, and a tag that never closes flushes back as plain text. The agent card renders as one line — display name (or agent label) plus the latest intent, replaced inline as the agent shifts gears — and never auto-expands; expanding to the full tool log is a deliberate click. Only an outstanding permission prompt or a browser hand-back forces a group open. Intents persist on the subagent block (and through the legacy persisted- message paths) so reloads keep the last status, and a renamed reinvocation now takes the latest name instead of pinning the first. --- .../agent-group/agent-group.test.ts | 7 ++ .../components/agent-group/agent-group.tsx | 32 +++++---- .../message-content/message-content.tsx | 5 ++ .../home/hooks/stream/turn-model-serialize.ts | 2 + .../home/hooks/stream/turn-model.test.ts | 40 +++++++++++ .../home/hooks/stream/turn-model.ts | 70 ++++++++++++++++++- .../app/workspace/[workspaceId]/home/types.ts | 2 + apps/sim/lib/copilot/chat/display-message.ts | 1 + .../sim/lib/copilot/chat/persisted-message.ts | 7 ++ apps/sim/lib/copilot/request/types.ts | 2 + 10 files changed, 151 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index 4808893b4eb..c2392880b27 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -213,6 +213,13 @@ describe('AgentGroup browser takeover', () => { }) expect(container.querySelector('.animate-stream-fade-in')).toBeNull() + // Groups never auto-expand: the answered question lives inside the + // collapsed log until the user opens it manually. + const headerToggle = container.querySelector('button[class*="group/agent"]') + expect(headerToggle).not.toBeNull() + act(() => { + headerToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) const resumedLog = container.querySelector('[data-state="open"]') const answeredQuestion = container.querySelector('[data-takeover-answer="true"]') expect(answeredQuestion?.textContent).toContain(reason) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 103e6ba6e4f..77a7eb18032 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -21,6 +21,8 @@ export interface NestedAgentGroup { id: string agentName: string agentLabel: string + /** The agent's latest tag — the collapsed row's live status. */ + intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -34,6 +36,8 @@ export type AgentGroupItem = interface AgentGroupProps { agentName: string agentLabel: string + /** The agent's latest tag — shown inline after the label. */ + intent?: string items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean @@ -107,6 +111,7 @@ export function isAgentGroupResolved(items: AgentGroupItem[]): boolean { export function AgentGroup({ agentName, agentLabel, + intent, items, isDelegating = false, isStreaming = false, @@ -114,6 +119,7 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) + const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() @@ -123,17 +129,12 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Expand while the turn is live and any of: the lane is open (the subagent is - // actively running), this is the current/latest section, or there is unresolved - // work. A finished group stays open until the NEXT section starts (it is no - // longer the latest), instead of collapsing the instant its own work resolves. - // Keying "still running" off the lane-open signal (not `resolved` alone) avoids - // a collapse/reopen flicker on parallel siblings: a subagent's tools all - // momentarily read "done" in the gap between its last search and its `respond` - // ("Gathering thoughts") tool, transiently flipping `resolved` true; the open - // lane bridges that gap so the row never collapses mid-run. The turn ending - // (isStreaming false) collapses everything; a manual toggle pins the choice. - const autoExpanded = isStreaming && (isCurrentSection || isLaneOpen || !resolved) + // Agent groups never auto-expand: the collapsed row IS the live view — the + // label plus the agent's latest tag, replaced inline as it works. + // Expanding is a deliberate user action (the toggle below); only an + // outstanding permission prompt or a browser hand-back forces the group + // open, because the turn cannot proceed while they wait off-screen. + const autoExpanded = false const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn @@ -166,9 +167,9 @@ export function AgentGroup({ {isWorking ? ( - {agentLabel} + {headerText} ) : ( - {agentLabel} + {headerText} )} {isWorking ? ( - {agentLabel} + {headerText} ) : ( - {agentLabel} + {headerText} )} )} @@ -216,6 +217,7 @@ export function AgentGroup({ tag (parsed upstream from its text). */ + intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -381,6 +383,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) if (block.subagentName) g.agentLabel = block.subagentName + if (block.subagentIntent) g.intent = block.subagentIntent if (block.endedAt !== undefined) { // Persisted backend path: the lane was stamped closed (endedAt) without // a separate subagent_end block (the Sim backend stamps endedAt only; @@ -625,6 +628,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { groupsByKey.delete(groupKey('mothership', undefined)) const { group: g } = ensureGroup(key, block.parentToolCallId) if (block.subagentName) g.agentLabel = block.subagentName + if (block.subagentIntent) g.intent = block.subagentIntent if (inheritedDelegation) g.isDelegating = true g.isOpen = true activeGroupKey = resolveGroupKey(key, block.parentToolCallId) @@ -956,6 +960,7 @@ function MessageContentInner({ key={segment.id} agentName={segment.agentName} agentLabel={segment.agentLabel} + intent={segment.intent} items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index 6088ee0419a..4a61a120c4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -168,6 +168,7 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { type: 'subagent', content: node.agentId, ...(node.displayName ? { subagentName: node.displayName } : {}), + ...(node.currentIntent ? { subagentIntent: node.currentIntent } : {}), spanId: node.spanId, parentSpanId: node.parentSpanId, ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), @@ -271,6 +272,7 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { data: { ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}), ...(block.subagentName ? { name: block.subagentName } : {}), + ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), }, }, scopeFor(block), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 85c0b420e73..0af21ac7e24 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -260,6 +260,46 @@ describe('reduceEvent — subagent lifecycle', () => { expect(agent(m, 'S1').displayName).toBe('Pricing research') }) + it('parses intent tags out of subagent text into the agent status', () => { + const textEv = (seq: number, text: string) => + envelope( + seq, + 'text', + { channel: 'assistant', text }, + { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } + ) + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-f'), + textEv(2, 'Drafting chapter outline\nStarting on the outline now.'), + ]) + expect(agent(m, 'S1').currentIntent).toBe('Drafting chapter outline') + const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') + expect(text && text.kind === 'text' ? text.text : '').not.toContain('') + expect(text && text.kind === 'text' ? text.text : '').toContain('Starting on the outline') + }) + + it('handles an intent tag split across deltas and takes the latest tag', () => { + const textEv = (seq: number, text: string) => + envelope( + seq, + 'text', + { channel: 'assistant', text }, + { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } + ) + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-f'), + textEv(2, 'ok. Writing first chap'), + textEv(4, 'tertext after. Reviewing draft'), + ]) + expect(agent(m, 'S1').currentIntent).toBe('Reviewing draft') + const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') + const rendered = text && text.kind === 'text' ? text.text : '' + expect(rendered).toContain('ok. ') + expect(rendered).toContain('text after. ') + expect(rendered).not.toContain('intent>') + }) + it('settles an agent error when span end carries an error', () => { const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 490f86d0109..820b41884ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -87,6 +87,10 @@ export interface AgentNode extends NodeBase { triggerToolCallId?: string /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */ displayName?: string + /** The agent's latest tag — the collapsed card's live status line. */ + currentIntent?: string + /** Streaming carry for an intent tag split across text deltas (never serialized). */ + intentCarry?: string status: NodeStatus /** Wire seq at which the run terminated (span end), for ordering the close marker. */ endSeq?: number @@ -297,6 +301,57 @@ function breakLane(model: TurnModel, spanId: string, atMs?: number): void { closeOpenText(model, spanId, 'thinking', atMs) } +const INTENT_OPEN = '' +const INTENT_CLOSE = '' +/** A tag that never closes within this many chars flushes back as plain text. */ +const INTENT_CARRY_MAX = 240 + +/** Length of the longest buf suffix that could still grow into `token`. */ +function partialSuffixLen(buf: string, token: string): number { + const max = Math.min(buf.length, token.length - 1) + for (let len = max; len > 0; len--) { + if (token.startsWith(buf.slice(buf.length - len))) return len + } + return 0 +} + +/** + * Streams a subagent's assistant text through the protocol: complete + * tags update the owning agent's currentIntent and are removed from the prose; + * a tag split across deltas is carried until its close arrives. The returned + * string is what the transcript should render. + */ +function filterIntentText(owner: AgentNode, incoming: string): string { + let buf = (owner.intentCarry ?? '') + incoming + owner.intentCarry = '' + let out = '' + while (buf) { + const openIdx = buf.indexOf(INTENT_OPEN) + if (openIdx === -1) { + const keep = partialSuffixLen(buf, INTENT_OPEN) + out += keep ? buf.slice(0, buf.length - keep) : buf + if (keep) owner.intentCarry = buf.slice(buf.length - keep) + break + } + out += buf.slice(0, openIdx) + const rest = buf.slice(openIdx) + const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) + if (closeIdx === -1) { + if (rest.length > INTENT_CARRY_MAX) { + out += rest + } else { + owner.intentCarry = rest + } + break + } + const intent = rest.slice(INTENT_OPEN.length, closeIdx).trim() + if (intent) owner.currentIntent = intent + buf = rest.slice(closeIdx + INTENT_CLOSE.length) + if (buf.startsWith('\n')) buf = buf.slice(1) + } + return out +} + function appendText( model: TurnModel, spanId: string, @@ -462,7 +517,15 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve case MothershipStreamV1EventType.text: { const payload = envelope.payload ensureSubagentLane(model, spanId, scope, seq, tsMs) - appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs) + let text = payload.text + if (spanId !== MAIN_SPAN && (payload.channel as TextChannel) === 'assistant') { + const ownerId = model.agentBySpanId.get(spanId) + const owner = ownerId ? model.nodes.get(ownerId) : undefined + if (owner && owner.kind === 'agent') { + text = filterIntentText(owner, text) + } + } + appendText(model, spanId, payload.channel as TextChannel, text, seq, tsMs) break } case MothershipStreamV1EventType.tool: { @@ -566,6 +629,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' const displayName = asString(data?.name) + const restoredIntent = asString(data?.intent) const resolvedSpanId = scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`) const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN @@ -584,7 +648,8 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // scope.agentId can name the forwarding caller (e.g. superagent), // while this start's payload.agent is the authoritative lane owner. if (agentId && existing.agentId !== agentId) existing.agentId = agentId - if (displayName && !existing.displayName) existing.displayName = displayName + if (displayName) existing.displayName = displayName + if (restoredIntent && !existing.currentIntent) existing.currentIntent = restoredIntent if (!existing.triggerToolCallId && triggerToolCallId) { existing.triggerToolCallId = triggerToolCallId } @@ -607,6 +672,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), ...(triggerToolCallId ? { triggerToolCallId } : {}), ...(displayName ? { displayName } : {}), + ...(restoredIntent ? { currentIntent: restoredIntent } : {}), } model.nodes.set(node.id, node) model.order.push(node.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index eedb402ba87..2f0124bacfc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -116,6 +116,8 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */ subagentName?: string + /** The agent's latest tag at serialization time — the collapsed card's status line. */ + subagentIntent?: string toolCall?: ToolCallInfo options?: OptionItem[] timestamp?: number diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 28a348a5837..91ce4906570 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -96,6 +96,7 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi type: ContentBlockType.subagent, content: block.content, ...(block.name ? { subagentName: block.name } : {}), + ...(block.intent ? { subagentIntent: block.intent } : {}), } case MothershipStreamV1EventType.complete: if (block.status === MothershipStreamV1CompletionStatus.cancelled) { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index c57e49b5a85..9e8c9ba26e7 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -55,6 +55,8 @@ export interface PersistedContentBlock { content?: string /** Orchestrator-chosen display name on a subagent start block. */ name?: string + /** The agent's latest tag at persistence time. */ + intent?: string toolCall?: PersistedToolCall timestamp?: number endedAt?: number @@ -248,6 +250,7 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), + ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } case 'subagent_text': return { @@ -442,6 +445,8 @@ interface RawBlock { /** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */ name?: string subagentName?: string + intent?: string + subagentIntent?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -510,6 +515,7 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { } if (block.agent) result.agent = block.agent if (block.name) result.name = block.name + if (block.intent) result.intent = block.intent const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -592,6 +598,7 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), + ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } } diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 35127f3edf9..eaaf50d95c8 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -82,6 +82,8 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block. */ subagentName?: string + /** The agent's latest tag. */ + subagentIntent?: string /** * Deterministic agent-run identity. `spanId` is the stable per-invocation id * of the subagent that produced the block; `parentSpanId` links it to the run From 97cd8b776154a16de092c567cd69a0e9188cb2b2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 11:36:58 -0700 Subject: [PATCH 064/103] Add the internal in-band tool execution route for live mothership turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/copilot/tools/execute (INTERNAL_API_SECRET, Go→Sim) runs one sim-server tool through the same server tool router the resume driver uses and returns the result synchronously — no checkpoint. This is what lets background (async) subagents write files/tables/knowledge, and lets the main lane keep streaming (instead of checkpoint-pausing and killing every background run) while async agents are live. --- .../app/api/copilot/tools/execute/route.ts | 92 +++++++++++++++++++ apps/sim/lib/api/contracts/copilot.ts | 14 +++ .../lib/copilot/generated/trace-spans-v1.ts | 4 + scripts/check-api-validation-contracts.ts | 4 +- 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/copilot/tools/execute/route.ts diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts new file mode 100644 index 00000000000..4aadb90aee9 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot' +import { validationErrorResponse } from '@/lib/api/server' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { checkInternalApiKey } from '@/lib/copilot/request/http' +import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CopilotToolExecuteInternalAPI') + +// POST /api/copilot/tools/execute — internal (Go → Sim) in-band execution of +// one sim-server tool announced on a LIVE mothership turn. This is what lets +// background (async) subagents — and the main lane while background agents are +// running — use sim-executed tools without a checkpoint pause: Go calls here +// synchronously instead of parking the turn, and the tool runs through the +// same server tool router the resume driver uses. Trusted server-to-server +// only: Go supplies the acting user, proven by the internal API secret. +export const POST = withRouteHandler((request: NextRequest) => + withIncomingGoSpan( + request.headers, + TraceSpan.CopilotToolsExecuteInband, + undefined, + async (rootSpan) => { + const authResult = checkInternalApiKey(request) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication failed' }, + { status: 401 } + ) + } + + // boundary-raw-json: tolerant parse; validation happens via the contract schema below + const body = await request.json().catch(() => ({})) + const validation = copilotToolExecuteInternalBodySchema.safeParse(body) + if (!validation.success) { + return validationErrorResponse(validation.error, 'Invalid request body') + } + const { + toolCallId, + toolName, + params, + userId, + workflowId, + workspaceId, + chatId, + messageId, + parentToolCallId, + userPermission, + } = validation.data + rootSpan.setAttributes({ + [TraceAttr.ToolName]: toolName, + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.UserId]: userId, + }) + + try { + const handler = createServerToolHandler(toolName) + const result = await handler(params, { + userId, + workflowId: workflowId ?? '', + workspaceId, + chatId, + messageId, + toolCallId, + parentToolCallId, + userPermission, + copilotToolExecution: true, + }) + if (!result.success) { + logger.warn('In-band tool execution failed', { + toolName, + toolCallId, + error: result.error, + }) + } + return NextResponse.json({ + success: result.success, + ...(result.output !== undefined ? { output: result.output } : {}), + ...(result.error ? { error: result.error } : {}), + }) + } catch (err) { + const message = getErrorMessage(err) + logger.error('In-band tool execution threw', { toolName, toolCallId, error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } + } + ) +) diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 2f9a0bd4ca7..16589b6a294 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -148,6 +148,20 @@ export const copilotChatSteerBodySchema = z.object({ }) export type CopilotChatSteerBody = z.input +export const copilotToolExecuteInternalBodySchema = z.object({ + toolCallId: z.string().min(1, 'toolCallId is required'), + toolName: z.string().min(1, 'toolName is required'), + params: z.record(z.string(), z.unknown()).default({}), + userId: z.string().min(1, 'userId is required'), + workflowId: z.string().optional(), + workspaceId: z.string().optional(), + chatId: z.string().optional(), + messageId: z.string().optional(), + parentToolCallId: z.string().optional(), + userPermission: z.string().optional(), +}) +export type CopilotToolExecuteInternalBody = z.input + export const copilotChatGetQuerySchema = z .object({ workflowId: z.string().optional(), diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/copilot/generated/trace-spans-v1.ts index ac8665f8d22..1dc7cc59300 100644 --- a/apps/sim/lib/copilot/generated/trace-spans-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-spans-v1.ts @@ -71,6 +71,7 @@ export const TraceSpan = { CopilotToolWaitForClientResult: 'copilot.tool.wait_for_client_result', CopilotToolWaitForPermission: 'copilot.tool.wait_for_permission', CopilotToolPermissionDecide: 'copilot.tool_permission.decide', + CopilotToolsExecuteInband: 'copilot.tools.execute_inband', CopilotToolsHandleResourceSideEffects: 'copilot.tools.handle_resource_side_effects', CopilotToolsWriteCsvToTable: 'copilot.tools.write_csv_to_table', CopilotToolsWriteOutputFile: 'copilot.tools.write_output_file', @@ -81,6 +82,7 @@ export const TraceSpan = { GenAiAgentExecute: 'gen_ai.agent.execute', LlmStream: 'llm.stream', ProviderRouterRoute: 'provider.router.route', + SimExecuteTool: 'sim.execute_tool', SimUpdateCost: 'sim.update_cost', SimValidateApiKey: 'sim.validate_api_key', ToolAsyncWaiterWait: 'tool.async_waiter.wait', @@ -154,6 +156,7 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'copilot.tool.wait_for_client_result', 'copilot.tool.wait_for_permission', 'copilot.tool_permission.decide', + 'copilot.tools.execute_inband', 'copilot.tools.handle_resource_side_effects', 'copilot.tools.write_csv_to_table', 'copilot.tools.write_output_file', @@ -164,6 +167,7 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'gen_ai.agent.execute', 'llm.stream', 'provider.router.route', + 'sim.execute_tool', 'sim.update_cost', 'sim.validate_api_key', 'tool.async_waiter.wait', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index ba484e5faf6..5a40d039ee0 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: 1106, - zodRoutes: 1106, + totalRoutes: 1107, + zodRoutes: 1107, nonZodRoutes: 0, } as const From dfccbac353f0cdcb9e1a98e974ab6e5884eaa6b9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 11:52:21 -0700 Subject: [PATCH 065/103] Persist resource side effects for in-band tool execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files/tables created through the internal execute route now register on the chat's resources exactly like the resume driver's executions — the route runs the same handleResourceSideEffects pass (persistence only; an out-of-band route has no live event sink, so mid-turn chip pushes are a follow-up). --- .../app/api/copilot/tools/execute/route.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index 4aadb90aee9..82c43929845 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -7,6 +7,8 @@ import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' +import type { ToolCallResult } from '@/lib/copilot/request/types' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -77,6 +79,28 @@ export const POST = withRouteHandler((request: NextRequest) => error: result.error, }) } + if (result.success && chatId) { + // Persist created/deleted resources on the chat (file chips, table + // links) exactly like the resume driver does. No live event sink + // exists for an out-of-band route, so chips surface from the + // persisted chat resources rather than a mid-turn push. + const asToolResult = { success: result.success, output: result.output } as ToolCallResult + await handleResourceSideEffects( + toolName, + params, + asToolResult, + asToolResult, + chatId, + undefined, + () => false + ).catch((err) => { + logger.warn('In-band resource side effects failed', { + toolName, + toolCallId, + error: getErrorMessage(err), + }) + }) + } return NextResponse.json({ success: result.success, ...(result.output !== undefined ? { output: result.output } : {}), From 90d07b0e3bad6dbc6a67cc7855adcf19a9a101e4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 12:31:40 -0700 Subject: [PATCH 066/103] Extract intents from group text on every path, sync and async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn-model intent filter only fires for span-scoped subagent lanes, but this surface also delivers subagent text through the legacy block path — so tags flowed through unparsed and rendered as prose rows. Groups now extract intents from their accumulated text at append time: the last complete tag becomes the card's status line and every complete tag is stripped from the rendered prose. Covers span-scoped, legacy, and persisted-reload paths for both synchronous and background delegations. --- .../message-content/message-content.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 910e8a08857..076f5e1f61b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -224,12 +224,37 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { * Streamed chunks and resume legs are concatenated verbatim, so a token split * like `v2.` + `1` is never mutated. */ +const INTENT_TAG_RE = /([\s\S]*?)<\/intent>\n?/g + +/** + * Extracts complete tags from a group's accumulated text: the last + * tag becomes the group's live status line and every complete tag is removed + * from the rendered prose. Runs on the accumulated buffer each append, so a + * tag split across streamed chunks is picked up once its close arrives — + * covering the span path, the legacy block path, and persisted reloads alike. + */ +function extractGroupIntents(group: AgentGroupSegment, item: { content: string }): void { + let lastIntent: string | undefined + const stripped = item.content.replace(INTENT_TAG_RE, (_match, inner: string) => { + const intent = inner.trim() + if (intent) lastIntent = intent + return '' + }) + if (lastIntent !== undefined) { + item.content = stripped + group.intent = lastIntent + } +} + function appendTextItem(group: AgentGroupSegment, content: string): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { lastItem.content += content + extractGroupIntents(group, lastItem) } else { - group.items.push({ type: 'text', content }) + const item = { type: 'text' as const, content } + group.items.push(item) + extractGroupIntents(group, item) } } From 9aaa42810b569ea63076f5a0a61400ae8316122a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 12:37:41 -0700 Subject: [PATCH 067/103] Fall back to the live tool title for the agent card status line Persisted data proved tool-first subagents (grok search agents) emit zero prose, so intent tags never stream no matter what the prompt says. The collapsed card now always narrates: the agent's own tag when present, else the latest tool's display title while the lane is live. --- .../components/agent-group/agent-group.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 77a7eb18032..cdabd839294 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -119,7 +119,18 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) - const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel + // Status line preference: the agent's own tag, else the latest + // tool's display title while the lane is live — so the collapsed card always + // narrates activity even for models that skip prose entirely. + const latestToolTitle = (() => { + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i] + if (it.type === 'tool') return it.data.displayTitle || String(it.data.toolName ?? '') + } + return undefined + })() + const status = intent ?? (isLaneOpen ? latestToolTitle : undefined) + const headerText = status ? `${agentLabel} — ${status}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() From 95153c0c4174ab5317edd0d930349f44943dc514 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 12:45:24 -0700 Subject: [PATCH 068/103] Catch subagent tags in the server relay The relay's subagent text handler now runs the split-safe intent extraction as chunks stream: the latest complete tag is stamped onto the lane's persisted subagent block (subagentIntent) and stripped from the stored prose, so live, persisted, and replayed views all agree. Per-lane carry handles tags split across chunks; a never-closing tag flushes back as plain text. --- .../request/handlers/text-intent.test.ts | 57 ++++++++++++++ apps/sim/lib/copilot/request/handlers/text.ts | 77 ++++++++++++++++++- apps/sim/lib/copilot/request/types.ts | 2 + 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/copilot/request/handlers/text-intent.test.ts diff --git a/apps/sim/lib/copilot/request/handlers/text-intent.test.ts b/apps/sim/lib/copilot/request/handlers/text-intent.test.ts new file mode 100644 index 00000000000..d4e4abc430e --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/text-intent.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { handleTextEvent } from '@/lib/copilot/request/handlers/text' +import type { StreamingContext } from '@/lib/copilot/request/types' + +function laneTextEvent(text: string) { + return { + type: 'text', + payload: { channel: 'assistant', text }, + scope: { lane: 'subagent', parentToolCallId: 'tc-1', agentId: 'file', spanId: 'S1' }, + } as never +} + +function makeContext(): StreamingContext { + return { + contentBlocks: [{ type: 'subagent', content: 'file', parentToolCallId: 'tc-1', timestamp: 1 }], + subAgentContent: {}, + subagentThinkingBlocks: new Map(), + isInThinkingBlock: false, + } as unknown as StreamingContext +} + +describe('subagent intent extraction (server relay)', () => { + it('strips a split tag and stamps the lane block intent', async () => { + const ctx = makeContext() + const handler = handleTextEvent('subagent') + await handler(laneTextEvent('Drafting outline\nStarting now.'), + ctx, + {} as never, + {} as never + ) + + const start = ctx.contentBlocks.find((b) => b.type === 'subagent') + expect(start?.subagentIntent).toBe('Drafting outline') + const text = ctx.contentBlocks.find((b) => b.type === 'subagent_text') + expect(text?.content).toBe('Starting now.') + expect(ctx.subAgentContent['tc-1']).toBe('Starting now.') + }) + + it('takes the latest tag and keeps surrounding prose', async () => { + const ctx = makeContext() + const handler = handleTextEvent('subagent') + await handler( + laneTextEvent('aOnebTwoc'), + ctx, + {} as never, + {} as never + ) + const start = ctx.contentBlocks.find((b) => b.type === 'subagent') + expect(start?.subagentIntent).toBe('Two') + expect(ctx.subAgentContent['tc-1']).toBe('abc') + }) +}) diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 8f110a82b28..9aaaae2094f 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -1,4 +1,5 @@ import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamingContext } from '@/lib/copilot/request/types' import type { StreamHandler, ToolScope } from './types' import { addContentBlock, @@ -8,6 +9,72 @@ import { getScopedSpanIdentity, } from './types' +const INTENT_OPEN = '' +const INTENT_CLOSE = '' +/** A tag that never closes within this many chars flushes back as plain text. */ +const INTENT_CARRY_MAX = 240 + +function partialSuffixLen(buf: string, token: string): number { + const max = Math.min(buf.length, token.length - 1) + for (let len = max; len > 0; len--) { + if (token.startsWith(buf.slice(buf.length - len))) return len + } + return 0 +} + +/** + * Streams one subagent lane's text through the protocol: complete + * tags are removed from the persisted prose and the latest one is returned; + * a tag split across chunks is carried (per lane) until its close arrives. + */ +function filterLaneIntent( + context: StreamingContext, + laneId: string, + incoming: string +): { text: string; intent?: string } { + const carries = (context.subagentIntentCarry ??= {}) + let buf = (carries[laneId] ?? '') + incoming + carries[laneId] = '' + let out = '' + let intent: string | undefined + while (buf) { + const openIdx = buf.indexOf(INTENT_OPEN) + if (openIdx === -1) { + const keep = partialSuffixLen(buf, INTENT_OPEN) + out += keep ? buf.slice(0, buf.length - keep) : buf + if (keep) carries[laneId] = buf.slice(buf.length - keep) + break + } + out += buf.slice(0, openIdx) + const rest = buf.slice(openIdx) + const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) + if (closeIdx === -1) { + if (rest.length > INTENT_CARRY_MAX) { + out += rest + } else { + carries[laneId] = rest + } + break + } + const inner = rest.slice(INTENT_OPEN.length, closeIdx).trim() + if (inner) intent = inner + buf = rest.slice(closeIdx + INTENT_CLOSE.length) + if (buf.startsWith('\n')) buf = buf.slice(1) + } + return intent !== undefined ? { text: out, intent } : { text: out } +} + +/** Stamps the latest intent onto the lane's open `subagent` start block. */ +function stampLaneIntent(context: StreamingContext, laneId: string, intent: string): void { + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const b = context.contentBlocks[i] + if (b.type === 'subagent' && b.parentToolCallId === laneId) { + b.subagentIntent = intent + return + } + } +} + export function handleTextEvent(scope: ToolScope): StreamHandler { return (event, context) => { if (event.type !== 'text') { @@ -48,11 +115,17 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { if (context.isInThinkingBlock) { flushThinkingBlock(context) } + // Catch the lane's protocol server-side: the latest tag becomes + // the persisted subagent block's status and the stored prose is stripped, + // so every surface (live, persisted, replay) agrees on both. + const { text: cleanChunk, intent } = filterLaneIntent(context, parentToolCallId, chunk) + if (intent) stampLaneIntent(context, parentToolCallId, intent) + if (!cleanChunk) return context.subAgentContent[parentToolCallId] = - (context.subAgentContent[parentToolCallId] || '') + chunk + (context.subAgentContent[parentToolCallId] || '') + cleanChunk addContentBlock(context, { type: 'subagent_text', - content: chunk, + content: cleanChunk, parentToolCallId, ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index eaaf50d95c8..3983b6f82cb 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -150,6 +150,8 @@ export interface StreamingContext { * block. Per-lane keying keeps each subagent's reasoning intact. */ subagentThinkingBlocks: Map + /** Per-lane carry for an tag split across streamed chunks. */ + subagentIntentCarry?: Record isInThinkingBlock: boolean subAgentContent: Record subAgentToolCalls: Record From 99397debde924f3c5288107ad4ca2f3c8d1d380b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 12:48:38 -0700 Subject: [PATCH 069/103] Drop the tool-title fallback: the status line is the agent's intent With the intent protocol now injected into every spawn's task message, agents open with an tag; the card shows that narration or nothing. --- .../components/agent-group/agent-group.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index cdabd839294..6d841febfbd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -119,18 +119,10 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) - // Status line preference: the agent's own tag, else the latest - // tool's display title while the lane is live — so the collapsed card always - // narrates activity even for models that skip prose entirely. - const latestToolTitle = (() => { - for (let i = items.length - 1; i >= 0; i--) { - const it = items[i] - if (it.type === 'tool') return it.data.displayTitle || String(it.data.toolName ?? '') - } - return undefined - })() - const status = intent ?? (isLaneOpen ? latestToolTitle : undefined) - const headerText = status ? `${agentLabel} — ${status}` : agentLabel + // The status line is the agent's own narration — no tool-title + // fallback: the task-message protocol reminder makes every agent open with + // an intent tag, so a bare label means the run has not produced one yet. + const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() From af516f3f58afc1ccc85665381d3a5a8ef87f3c7f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 13:01:28 -0700 Subject: [PATCH 070/103] Replace intents with live tool-title status lines on agent cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intent parsing is fully removed (turn model, relay handler, persistence fields, group extraction). The collapsed card's status is the latest tool call in its RUNNING phrasing — never the completed rewrite, which stays in the expanded log. Parallel tools show the most recently started still-running title with a +N for concurrent siblings; between rounds the last title stays frozen; a closed lane shows the bare name. Nested agent cards compute their own status recursively from their own items. --- .../components/agent-group/agent-group.tsx | 34 +++++--- .../message-content/message-content.tsx | 32 +------- .../home/hooks/stream/turn-model-serialize.ts | 2 - .../home/hooks/stream/turn-model.test.ts | 40 ---------- .../home/hooks/stream/turn-model.ts | 68 +--------------- .../app/workspace/[workspaceId]/home/types.ts | 2 - apps/sim/lib/copilot/chat/display-message.ts | 1 - .../sim/lib/copilot/chat/persisted-message.ts | 7 -- .../request/handlers/text-intent.test.ts | 57 -------------- apps/sim/lib/copilot/request/handlers/text.ts | 77 +------------------ apps/sim/lib/copilot/request/types.ts | 4 - 11 files changed, 28 insertions(+), 296 deletions(-) delete mode 100644 apps/sim/lib/copilot/request/handlers/text-intent.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 6d841febfbd..c481523e9fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -21,8 +21,6 @@ export interface NestedAgentGroup { id: string agentName: string agentLabel: string - /** The agent's latest tag — the collapsed row's live status. */ - intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -36,8 +34,6 @@ export type AgentGroupItem = interface AgentGroupProps { agentName: string agentLabel: string - /** The agent's latest tag — shown inline after the label. */ - intent?: string items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean @@ -111,7 +107,6 @@ export function isAgentGroupResolved(items: AgentGroupItem[]): boolean { export function AgentGroup({ agentName, agentLabel, - intent, items, isDelegating = false, isStreaming = false, @@ -119,10 +114,30 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) - // The status line is the agent's own narration — no tool-title - // fallback: the task-message protocol reminder makes every agent open with - // an intent tag, so a bare label means the run has not produced one yet. - const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel + // Collapsed status line: the latest tool call, always in its RUNNING + // phrasing — it never flips to the completed rewrite (that lives in the + // expanded log). With parallel tools, the most recently started + // still-running one wins, with a +N for its running siblings; between + // rounds the last tool's title stays frozen; a closed lane shows the bare + // name. + const status = (() => { + if (!isLaneOpen) return undefined + let running: string | undefined + let runningCount = 0 + let lastAny: string | undefined + for (const it of items) { + if (it.type !== 'tool') continue + const title = it.data.displayTitle || String(it.data.toolName ?? '') + lastAny = title + if (it.data.status === ToolCallStatus.executing) { + running = title + runningCount += 1 + } + } + if (running) return runningCount > 1 ? `${running} +${runningCount - 1}` : running + return lastAny + })() + const headerText = status ? `${agentLabel} — ${status}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() @@ -220,7 +235,6 @@ export function AgentGroup({ tag (parsed upstream from its text). */ - intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -224,37 +222,12 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { * Streamed chunks and resume legs are concatenated verbatim, so a token split * like `v2.` + `1` is never mutated. */ -const INTENT_TAG_RE = /([\s\S]*?)<\/intent>\n?/g - -/** - * Extracts complete tags from a group's accumulated text: the last - * tag becomes the group's live status line and every complete tag is removed - * from the rendered prose. Runs on the accumulated buffer each append, so a - * tag split across streamed chunks is picked up once its close arrives — - * covering the span path, the legacy block path, and persisted reloads alike. - */ -function extractGroupIntents(group: AgentGroupSegment, item: { content: string }): void { - let lastIntent: string | undefined - const stripped = item.content.replace(INTENT_TAG_RE, (_match, inner: string) => { - const intent = inner.trim() - if (intent) lastIntent = intent - return '' - }) - if (lastIntent !== undefined) { - item.content = stripped - group.intent = lastIntent - } -} - function appendTextItem(group: AgentGroupSegment, content: string): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { lastItem.content += content - extractGroupIntents(group, lastItem) } else { - const item = { type: 'text' as const, content } - group.items.push(item) - extractGroupIntents(group, item) + group.items.push({ type: 'text', content }) } } @@ -408,7 +381,6 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) if (block.subagentName) g.agentLabel = block.subagentName - if (block.subagentIntent) g.intent = block.subagentIntent if (block.endedAt !== undefined) { // Persisted backend path: the lane was stamped closed (endedAt) without // a separate subagent_end block (the Sim backend stamps endedAt only; @@ -653,7 +625,6 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { groupsByKey.delete(groupKey('mothership', undefined)) const { group: g } = ensureGroup(key, block.parentToolCallId) if (block.subagentName) g.agentLabel = block.subagentName - if (block.subagentIntent) g.intent = block.subagentIntent if (inheritedDelegation) g.isDelegating = true g.isOpen = true activeGroupKey = resolveGroupKey(key, block.parentToolCallId) @@ -985,7 +956,6 @@ function MessageContentInner({ key={segment.id} agentName={segment.agentName} agentLabel={segment.agentLabel} - intent={segment.intent} items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index 4a61a120c4a..6088ee0419a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -168,7 +168,6 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { type: 'subagent', content: node.agentId, ...(node.displayName ? { subagentName: node.displayName } : {}), - ...(node.currentIntent ? { subagentIntent: node.currentIntent } : {}), spanId: node.spanId, parentSpanId: node.parentSpanId, ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), @@ -272,7 +271,6 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { data: { ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}), ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), }, }, scopeFor(block), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 0af21ac7e24..85c0b420e73 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -260,46 +260,6 @@ describe('reduceEvent — subagent lifecycle', () => { expect(agent(m, 'S1').displayName).toBe('Pricing research') }) - it('parses intent tags out of subagent text into the agent status', () => { - const textEv = (seq: number, text: string) => - envelope( - seq, - 'text', - { channel: 'assistant', text }, - { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } - ) - const m = apply([ - spanStart(1, 'S1', 'file', 'tc-f'), - textEv(2, 'Drafting chapter outline\nStarting on the outline now.'), - ]) - expect(agent(m, 'S1').currentIntent).toBe('Drafting chapter outline') - const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') - expect(text && text.kind === 'text' ? text.text : '').not.toContain('') - expect(text && text.kind === 'text' ? text.text : '').toContain('Starting on the outline') - }) - - it('handles an intent tag split across deltas and takes the latest tag', () => { - const textEv = (seq: number, text: string) => - envelope( - seq, - 'text', - { channel: 'assistant', text }, - { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } - ) - const m = apply([ - spanStart(1, 'S1', 'file', 'tc-f'), - textEv(2, 'ok. Writing first chap'), - textEv(4, 'tertext after. Reviewing draft'), - ]) - expect(agent(m, 'S1').currentIntent).toBe('Reviewing draft') - const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') - const rendered = text && text.kind === 'text' ? text.text : '' - expect(rendered).toContain('ok. ') - expect(rendered).toContain('text after. ') - expect(rendered).not.toContain('intent>') - }) - it('settles an agent error when span end carries an error', () => { const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 820b41884ec..d016e22c9af 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -87,10 +87,6 @@ export interface AgentNode extends NodeBase { triggerToolCallId?: string /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */ displayName?: string - /** The agent's latest tag — the collapsed card's live status line. */ - currentIntent?: string - /** Streaming carry for an intent tag split across text deltas (never serialized). */ - intentCarry?: string status: NodeStatus /** Wire seq at which the run terminated (span end), for ordering the close marker. */ endSeq?: number @@ -301,57 +297,6 @@ function breakLane(model: TurnModel, spanId: string, atMs?: number): void { closeOpenText(model, spanId, 'thinking', atMs) } -const INTENT_OPEN = '' -const INTENT_CLOSE = '' -/** A tag that never closes within this many chars flushes back as plain text. */ -const INTENT_CARRY_MAX = 240 - -/** Length of the longest buf suffix that could still grow into `token`. */ -function partialSuffixLen(buf: string, token: string): number { - const max = Math.min(buf.length, token.length - 1) - for (let len = max; len > 0; len--) { - if (token.startsWith(buf.slice(buf.length - len))) return len - } - return 0 -} - -/** - * Streams a subagent's assistant text through the protocol: complete - * tags update the owning agent's currentIntent and are removed from the prose; - * a tag split across deltas is carried until its close arrives. The returned - * string is what the transcript should render. - */ -function filterIntentText(owner: AgentNode, incoming: string): string { - let buf = (owner.intentCarry ?? '') + incoming - owner.intentCarry = '' - let out = '' - while (buf) { - const openIdx = buf.indexOf(INTENT_OPEN) - if (openIdx === -1) { - const keep = partialSuffixLen(buf, INTENT_OPEN) - out += keep ? buf.slice(0, buf.length - keep) : buf - if (keep) owner.intentCarry = buf.slice(buf.length - keep) - break - } - out += buf.slice(0, openIdx) - const rest = buf.slice(openIdx) - const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) - if (closeIdx === -1) { - if (rest.length > INTENT_CARRY_MAX) { - out += rest - } else { - owner.intentCarry = rest - } - break - } - const intent = rest.slice(INTENT_OPEN.length, closeIdx).trim() - if (intent) owner.currentIntent = intent - buf = rest.slice(closeIdx + INTENT_CLOSE.length) - if (buf.startsWith('\n')) buf = buf.slice(1) - } - return out -} - function appendText( model: TurnModel, spanId: string, @@ -517,15 +462,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve case MothershipStreamV1EventType.text: { const payload = envelope.payload ensureSubagentLane(model, spanId, scope, seq, tsMs) - let text = payload.text - if (spanId !== MAIN_SPAN && (payload.channel as TextChannel) === 'assistant') { - const ownerId = model.agentBySpanId.get(spanId) - const owner = ownerId ? model.nodes.get(ownerId) : undefined - if (owner && owner.kind === 'agent') { - text = filterIntentText(owner, text) - } - } - appendText(model, spanId, payload.channel as TextChannel, text, seq, tsMs) + appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs) break } case MothershipStreamV1EventType.tool: { @@ -629,7 +566,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' const displayName = asString(data?.name) - const restoredIntent = asString(data?.intent) const resolvedSpanId = scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`) const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN @@ -649,7 +585,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // while this start's payload.agent is the authoritative lane owner. if (agentId && existing.agentId !== agentId) existing.agentId = agentId if (displayName) existing.displayName = displayName - if (restoredIntent && !existing.currentIntent) existing.currentIntent = restoredIntent if (!existing.triggerToolCallId && triggerToolCallId) { existing.triggerToolCallId = triggerToolCallId } @@ -672,7 +607,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), ...(triggerToolCallId ? { triggerToolCallId } : {}), ...(displayName ? { displayName } : {}), - ...(restoredIntent ? { currentIntent: restoredIntent } : {}), } model.nodes.set(node.id, node) model.order.push(node.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 2f0124bacfc..eedb402ba87 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -116,8 +116,6 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */ subagentName?: string - /** The agent's latest tag at serialization time — the collapsed card's status line. */ - subagentIntent?: string toolCall?: ToolCallInfo options?: OptionItem[] timestamp?: number diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 91ce4906570..28a348a5837 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -96,7 +96,6 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi type: ContentBlockType.subagent, content: block.content, ...(block.name ? { subagentName: block.name } : {}), - ...(block.intent ? { subagentIntent: block.intent } : {}), } case MothershipStreamV1EventType.complete: if (block.status === MothershipStreamV1CompletionStatus.cancelled) { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 9e8c9ba26e7..c57e49b5a85 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -55,8 +55,6 @@ export interface PersistedContentBlock { content?: string /** Orchestrator-chosen display name on a subagent start block. */ name?: string - /** The agent's latest tag at persistence time. */ - intent?: string toolCall?: PersistedToolCall timestamp?: number endedAt?: number @@ -250,7 +248,6 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } case 'subagent_text': return { @@ -445,8 +442,6 @@ interface RawBlock { /** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */ name?: string subagentName?: string - intent?: string - subagentIntent?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -515,7 +510,6 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { } if (block.agent) result.agent = block.agent if (block.name) result.name = block.name - if (block.intent) result.intent = block.intent const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -598,7 +592,6 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } } diff --git a/apps/sim/lib/copilot/request/handlers/text-intent.test.ts b/apps/sim/lib/copilot/request/handlers/text-intent.test.ts deleted file mode 100644 index d4e4abc430e..00000000000 --- a/apps/sim/lib/copilot/request/handlers/text-intent.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { handleTextEvent } from '@/lib/copilot/request/handlers/text' -import type { StreamingContext } from '@/lib/copilot/request/types' - -function laneTextEvent(text: string) { - return { - type: 'text', - payload: { channel: 'assistant', text }, - scope: { lane: 'subagent', parentToolCallId: 'tc-1', agentId: 'file', spanId: 'S1' }, - } as never -} - -function makeContext(): StreamingContext { - return { - contentBlocks: [{ type: 'subagent', content: 'file', parentToolCallId: 'tc-1', timestamp: 1 }], - subAgentContent: {}, - subagentThinkingBlocks: new Map(), - isInThinkingBlock: false, - } as unknown as StreamingContext -} - -describe('subagent intent extraction (server relay)', () => { - it('strips a split tag and stamps the lane block intent', async () => { - const ctx = makeContext() - const handler = handleTextEvent('subagent') - await handler(laneTextEvent('Drafting outline\nStarting now.'), - ctx, - {} as never, - {} as never - ) - - const start = ctx.contentBlocks.find((b) => b.type === 'subagent') - expect(start?.subagentIntent).toBe('Drafting outline') - const text = ctx.contentBlocks.find((b) => b.type === 'subagent_text') - expect(text?.content).toBe('Starting now.') - expect(ctx.subAgentContent['tc-1']).toBe('Starting now.') - }) - - it('takes the latest tag and keeps surrounding prose', async () => { - const ctx = makeContext() - const handler = handleTextEvent('subagent') - await handler( - laneTextEvent('aOnebTwoc'), - ctx, - {} as never, - {} as never - ) - const start = ctx.contentBlocks.find((b) => b.type === 'subagent') - expect(start?.subagentIntent).toBe('Two') - expect(ctx.subAgentContent['tc-1']).toBe('abc') - }) -}) diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 9aaaae2094f..8f110a82b28 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -1,5 +1,4 @@ import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamingContext } from '@/lib/copilot/request/types' import type { StreamHandler, ToolScope } from './types' import { addContentBlock, @@ -9,72 +8,6 @@ import { getScopedSpanIdentity, } from './types' -const INTENT_OPEN = '' -const INTENT_CLOSE = '' -/** A tag that never closes within this many chars flushes back as plain text. */ -const INTENT_CARRY_MAX = 240 - -function partialSuffixLen(buf: string, token: string): number { - const max = Math.min(buf.length, token.length - 1) - for (let len = max; len > 0; len--) { - if (token.startsWith(buf.slice(buf.length - len))) return len - } - return 0 -} - -/** - * Streams one subagent lane's text through the protocol: complete - * tags are removed from the persisted prose and the latest one is returned; - * a tag split across chunks is carried (per lane) until its close arrives. - */ -function filterLaneIntent( - context: StreamingContext, - laneId: string, - incoming: string -): { text: string; intent?: string } { - const carries = (context.subagentIntentCarry ??= {}) - let buf = (carries[laneId] ?? '') + incoming - carries[laneId] = '' - let out = '' - let intent: string | undefined - while (buf) { - const openIdx = buf.indexOf(INTENT_OPEN) - if (openIdx === -1) { - const keep = partialSuffixLen(buf, INTENT_OPEN) - out += keep ? buf.slice(0, buf.length - keep) : buf - if (keep) carries[laneId] = buf.slice(buf.length - keep) - break - } - out += buf.slice(0, openIdx) - const rest = buf.slice(openIdx) - const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) - if (closeIdx === -1) { - if (rest.length > INTENT_CARRY_MAX) { - out += rest - } else { - carries[laneId] = rest - } - break - } - const inner = rest.slice(INTENT_OPEN.length, closeIdx).trim() - if (inner) intent = inner - buf = rest.slice(closeIdx + INTENT_CLOSE.length) - if (buf.startsWith('\n')) buf = buf.slice(1) - } - return intent !== undefined ? { text: out, intent } : { text: out } -} - -/** Stamps the latest intent onto the lane's open `subagent` start block. */ -function stampLaneIntent(context: StreamingContext, laneId: string, intent: string): void { - for (let i = context.contentBlocks.length - 1; i >= 0; i--) { - const b = context.contentBlocks[i] - if (b.type === 'subagent' && b.parentToolCallId === laneId) { - b.subagentIntent = intent - return - } - } -} - export function handleTextEvent(scope: ToolScope): StreamHandler { return (event, context) => { if (event.type !== 'text') { @@ -115,17 +48,11 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { if (context.isInThinkingBlock) { flushThinkingBlock(context) } - // Catch the lane's protocol server-side: the latest tag becomes - // the persisted subagent block's status and the stored prose is stripped, - // so every surface (live, persisted, replay) agrees on both. - const { text: cleanChunk, intent } = filterLaneIntent(context, parentToolCallId, chunk) - if (intent) stampLaneIntent(context, parentToolCallId, intent) - if (!cleanChunk) return context.subAgentContent[parentToolCallId] = - (context.subAgentContent[parentToolCallId] || '') + cleanChunk + (context.subAgentContent[parentToolCallId] || '') + chunk addContentBlock(context, { type: 'subagent_text', - content: cleanChunk, + content: chunk, parentToolCallId, ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 3983b6f82cb..35127f3edf9 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -82,8 +82,6 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block. */ subagentName?: string - /** The agent's latest tag. */ - subagentIntent?: string /** * Deterministic agent-run identity. `spanId` is the stable per-invocation id * of the subagent that produced the block; `parentSpanId` links it to the run @@ -150,8 +148,6 @@ export interface StreamingContext { * block. Per-lane keying keeps each subagent's reasoning intact. */ subagentThinkingBlocks: Map - /** Per-lane carry for an tag split across streamed chunks. */ - subagentIntentCarry?: Record isInThinkingBlock: boolean subAgentContent: Record subAgentToolCalls: Record From 1e51860a20d1115017b5626d0141de7494f6ad09 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 13:15:44 -0700 Subject: [PATCH 071/103] Keep the main Sim lane live-expanded; collapse only real subagent cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mothership group is the turn's own narration, not a delegation card — collapsing it hid main-lane text and tools until manual expand, which read as mis-ordered streaming while async subagents interleaved. It keeps the original live-expand behavior and no status suffix. --- .../components/agent-group/agent-group.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index c481523e9fa..8892141a74e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -114,6 +114,7 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) + const isMainAgent = agentName === 'mothership' // Collapsed status line: the latest tool call, always in its RUNNING // phrasing — it never flips to the completed rewrite (that lives in the // expanded log). With parallel tools, the most recently started @@ -121,7 +122,7 @@ export function AgentGroup({ // rounds the last tool's title stays frozen; a closed lane shows the bare // name. const status = (() => { - if (!isLaneOpen) return undefined + if (isMainAgent || !isLaneOpen) return undefined let running: string | undefined let runningCount = 0 let lastAny: string | undefined @@ -147,12 +148,13 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Agent groups never auto-expand: the collapsed row IS the live view — the - // label plus the agent's latest tag, replaced inline as it works. - // Expanding is a deliberate user action (the toggle below); only an - // outstanding permission prompt or a browser hand-back forces the group - // open, because the turn cannot proceed while they wait off-screen. - const autoExpanded = false + // SUBAGENT groups never auto-expand: the collapsed row IS the live view — + // label plus latest running tool title. Expanding is a deliberate user + // action; only a pending permission prompt or a browser hand-back forces + // one open. The MAIN lane ("Sim") is not a delegation card: its narration + // and tool calls are the turn itself, so it keeps the original live-expand + // behavior (open while streaming/current, settles when superseded). + const autoExpanded = isMainAgent && isStreaming && (isCurrentSection || isLaneOpen || !resolved) const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn From b98ef9b70aa28c7cf2bcd5d5cb215dddbd17b7cf Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 13:18:36 -0700 Subject: [PATCH 072/103] Persist subagent lane lifecycle blocks from the span handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane-scoped span events route to the span handler, which only recorded trace side effects — no subagent start block was ever persisted (verified: a seven-agent run stored 104 blocks with zero starts). Grouping then fell back to keying lane content by agent NAME, so a respawned agent of the same type merged invisibly into the first one's card until it resolved. The handler now persists the start block (spanId-keyed and deduped, carrying the display name) and stamps endedAt on close, giving every invocation its own card. --- apps/sim/lib/copilot/request/handlers/span.ts | 41 +++++++++++++++++++ apps/sim/lib/copilot/request/types.ts | 2 + 2 files changed, 43 insertions(+) diff --git a/apps/sim/lib/copilot/request/handlers/span.ts b/apps/sim/lib/copilot/request/handlers/span.ts index 2ad6dcf3382..ba2fba1caac 100644 --- a/apps/sim/lib/copilot/request/handlers/span.ts +++ b/apps/sim/lib/copilot/request/handlers/span.ts @@ -3,6 +3,7 @@ import { MothershipStreamV1SpanPayloadKind, } from '@/lib/copilot/generated/mothership-stream-v1' import type { StreamHandler } from './types' +import { addContentBlock } from './types' /** * Mirror Go-emitted span lifecycle events onto the Sim-side TraceCollector. @@ -34,6 +35,46 @@ export const handleSpanEvent: StreamHandler = (event, context) => { // (e.g. two parallel `research` subagents) get distinct trace spans. Fall // back to agent:parentToolCallId for legacy events that predate span ids. const traceKey = event.scope?.spanId || `${scopeAgent}:${event.scope?.parentToolCallId || ''}` + // Persist the lane's lifecycle markers. Without a `subagent` start block, + // the transcript parser falls back to grouping lane content by agent NAME + // — so a respawned agent of the same type (a second concurrent `search`) + // silently merges into the first one's card and appears "missing" until + // that one resolves. Keyed and deduped by spanId, so every invocation — + // including same-type concurrent respawns — gets its own group. + const startData = payload.data as Record | undefined + if (evt === MothershipStreamV1SpanLifecycleEvent.start) { + context.openSubagentSpans ??= new Set() + if (!context.openSubagentSpans.has(traceKey)) { + context.openSubagentSpans.add(traceKey) + addContentBlock(context, { + type: 'subagent', + content: scopeAgent, + ...(event.scope?.parentToolCallId + ? { parentToolCallId: event.scope.parentToolCallId } + : {}), + ...(event.scope?.spanId ? { spanId: event.scope.spanId } : {}), + ...(event.scope?.parentSpanId ? { parentSpanId: event.scope.parentSpanId } : {}), + ...(typeof startData?.name === 'string' && startData.name + ? { subagentName: startData.name } + : {}), + }) + } + } else if (evt === MothershipStreamV1SpanLifecycleEvent.end) { + if (context.openSubagentSpans?.has(traceKey)) { + context.openSubagentSpans.delete(traceKey) + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const b = context.contentBlocks[i] + if ( + b.type === 'subagent' && + b.endedAt === undefined && + (b.spanId || '') === (event.scope?.spanId || '') + ) { + b.endedAt = Date.now() + break + } + } + } + } if (evt === MothershipStreamV1SpanLifecycleEvent.start) { const span = context.trace.startSpan(`subagent:${scopeAgent}`, 'go.subagent', { agent: scopeAgent, diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 35127f3edf9..f11a9b15a4d 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -148,6 +148,8 @@ export interface StreamingContext { * block. Per-lane keying keeps each subagent's reasoning intact. */ subagentThinkingBlocks: Map + /** Span ids whose lane start block has been persisted (dedupe across replays). */ + openSubagentSpans?: Set isInThinkingBlock: boolean subAgentContent: Record subAgentToolCalls: Record From ba5015c6e919d05590401f25ed7b245a22a168fc Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 13:21:13 -0700 Subject: [PATCH 073/103] Name agents in orchestration titles; '+ n more' overflow format wait/tail/steer/interrupt titles humanize the slugified agent ids back to their display names ('Waiting for the first of Digest Workflow Build + 4 more'), and the agent card's parallel-tool suffix uses the same '+ n more' format. --- .../components/agent-group/agent-group.tsx | 2 +- apps/sim/lib/copilot/tools/tool-display.ts | 28 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 8892141a74e..b0aa7beeaac 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -135,7 +135,7 @@ export function AgentGroup({ runningCount += 1 } } - if (running) return runningCount > 1 ? `${running} +${runningCount - 1}` : running + if (running) return runningCount > 1 ? `${running} + ${runningCount - 1} more` : running return lastAny })() const headerText = status ? `${agentLabel} — ${status}` : agentLabel diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index ad08842187d..1b5a85f435a 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -610,16 +610,26 @@ function waitTitle(args: ToolArgs): string { return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason')) } -/** Title for a wait_agents sleep, naming the agent(s) being collected and honoring mode "any". */ +/** + * An async agent id is its slugified display name plus a sequence suffix + * ("digest-workflow-build-4"); recover the human name for titles. + */ +function humanizeAgentId(id: string): string { + const words = id.replace(/-\d+$/, '').split('-').filter(Boolean) + if (words.length === 0) return id + return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') +} + +/** Title for a wait_agents sleep, naming the agents and honoring mode "any". */ function waitAgentsTitle(args: ToolArgs): string { const raw = args?.agent_ids const ids = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [] + const names = ids.map(humanizeAgentId) const anyMode = stringArg(args, 'mode') === 'any' - if (ids.length === 1) return `Waiting for ${ids[0]}` - if (ids.length > 1) { - return anyMode - ? `Waiting for the first of ${ids.length} agents` - : `Waiting for ${ids.length} agents` + if (names.length === 1) return `Waiting for ${names[0]}` + if (names.length > 1) { + const listed = `${names[0]} + ${names.length - 1} more` + return anyMode ? `Waiting for the first of ${listed}` : `Waiting for ${listed}` } return 'Waiting for agents' } @@ -732,11 +742,11 @@ export function getToolDisplayTitle(name: string, args?: Record case 'wait_agents': return waitAgentsTitle(args) case 'tail_agent': - return `Checking on ${stringArg(args, 'agent_id') || 'agent'}` + return `Checking on ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` case 'steer_agent': - return `Steering ${stringArg(args, 'agent_id') || 'agent'}` + return `Steering ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` case 'interrupt_agent': - return `Stopping ${stringArg(args, 'agent_id') || 'agent'}` + return `Stopping ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` case 'terminal': return terminalTitle(args) // The surface used to be one tool per operation. Conversations recorded From 5b723379e38569dbef753d15face5ad11ec9db1f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 14:17:08 -0700 Subject: [PATCH 074/103] Harden in-band tool execution and resources --- .../api/copilot/tools/execute/route.test.ts | 106 ++++++++++++++++++ .../app/api/copilot/tools/execute/route.ts | 103 +++++++++++++++-- .../generated/mothership-stream-v1-schema.ts | 3 + .../copilot/generated/mothership-stream-v1.ts | 1 + .../copilot/request/handlers/handlers.test.ts | 27 +++++ apps/sim/lib/copilot/request/handlers/tool.ts | 10 +- .../sim/lib/copilot/request/handlers/types.ts | 2 + .../sim/lib/copilot/request/tools/executor.ts | 19 +++- .../tools/resolved-secret-result.test.ts | 26 +++++ .../request/tools/resolved-secret-result.ts | 42 +++++-- .../tools/registry/server-tool-adapter.ts | 3 +- .../copilot/tools/server/files/create-file.ts | 58 ++++++---- .../tools/server/image/generate-image.ts | 14 +++ .../tools/server/media/generate-audio.ts | 11 ++ .../tools/server/media/generate-video.ts | 11 ++ .../lib/copilot/vfs/resource-writer.test.ts | 54 +++++++++ apps/sim/lib/copilot/vfs/resource-writer.ts | 59 ++++++---- 17 files changed, 481 insertions(+), 68 deletions(-) create mode 100644 apps/sim/app/api/copilot/tools/execute/route.test.ts diff --git a/apps/sim/app/api/copilot/tools/execute/route.test.ts b/apps/sim/app/api/copilot/tools/execute/route.test.ts new file mode 100644 index 00000000000..e1fc2d39141 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/execute/route.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const { mockCheckInternalApiKey, mockPrepareEnvironmentContext, mockHandler } = vi.hoisted(() => ({ + mockCheckInternalApiKey: vi.fn(), + mockPrepareEnvironmentContext: vi.fn(), + mockHandler: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/http', () => ({ + checkInternalApiKey: mockCheckInternalApiKey, +})) + +vi.mock('@/lib/copilot/environment-context', () => ({ + prepareCopilotEnvironmentContext: mockPrepareEnvironmentContext, +})) + +vi.mock('@/lib/copilot/tools/registry/server-tool-adapter', () => ({ + createServerToolHandler: () => mockHandler, +})) + +vi.mock('@/lib/copilot/request/tools/resources', () => ({ + handleResourceSideEffects: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/copilot/request/otel', () => ({ + withIncomingGoSpan: ( + _headers: Headers, + _span: string, + _attrs: undefined, + fn: (span: { setAttributes: () => void }) => Promise + ) => fn({ setAttributes: () => {} }), +})) + +import { POST } from '@/app/api/copilot/tools/execute/route' + +function makeRequest(body: Record): Request { + return new Request('http://localhost/api/copilot/tools/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const BASE_BODY = { + toolCallId: 'call-1', + toolName: 'read', + params: { path: 'files/a.md' }, + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + messageId: 'msg-1', +} + +describe('POST /api/copilot/tools/execute (in-band)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckInternalApiKey.mockReturnValue({ success: true }) + // A fresh, complete registry per test: the module-level turn cache is keyed + // by messageId, so each test uses a distinct messageId to avoid cross-test + // cache hits. + mockPrepareEnvironmentContext.mockImplementation(async () => ({ + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]), + })) + }) + + it('threads a per-call registry fork into the handler and returns the projected result', async () => { + mockHandler.mockResolvedValue({ success: true, output: { content: 'hello' } }) + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-fork' }) as never) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true, output: { content: 'hello' } }) + + const [, handlerContext] = mockHandler.mock.calls[0] + expect(handlerContext.resolvedSecretTraceRegistry).toBeInstanceOf(ResolvedSecretTraceRegistry) + expect(handlerContext.userId).toBe('user-1') + expect(handlerContext.copilotToolExecution).toBe(true) + }) + + it('keeps a clean tool failure message intact when the registry is available', async () => { + mockHandler.mockResolvedValue({ success: false, error: 'File not found: files/a.md' }) + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-clean-error' }) as never) + const body = await res.json() + expect(body.success).toBe(false) + expect(body.error).toBe('File not found: files/a.md') + }) + + it('withholds results when no egress registry can be built', async () => { + mockPrepareEnvironmentContext.mockRejectedValue(new Error('env unavailable')) + mockHandler.mockResolvedValue({ success: true, output: { content: 'sensitive' } }) + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-no-registry' }) as never) + const body = await res.json() + expect(body).toEqual({ success: true }) + }) + + it('reuses one turn registry across calls that share a messageId', async () => { + mockHandler.mockResolvedValue({ success: true, output: {} }) + await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-shared' }) as never) + await POST( + makeRequest({ ...BASE_BODY, toolCallId: 'call-2', messageId: 'msg-shared' }) as never + ) + expect(mockPrepareEnvironmentContext).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index 82c43929845..a2e4c3b73c6 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -3,17 +3,64 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { + inspectToolResultForCopilot, + projectToolErrorMessageForCopilot, +} from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import type { ToolCallResult } from '@/lib/copilot/request/types' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotToolExecuteInternalAPI') +/** + * In-band calls are stateless one-offs, but the turn they serve is not: secret + * provenance activated by one tool call must stay visible to the next call's + * egress projection, exactly as the request-lifecycle registry accumulates + * across a turn. Keyed by the turn (messageId) so one background lane shares + * one registry; TTL-evicted since nothing signals turn end on this route. + */ +const TURN_REGISTRY_TTL_MS = 10 * 60 * 1000 +const TURN_REGISTRY_CACHE_MAX = 256 +const turnRegistryCache = new Map< + string, + { registry: ResolvedSecretTraceRegistry; expiresAt: number } +>() + +async function getTurnEgressRegistry( + userId: string, + workspaceId: string | undefined, + messageId: string | undefined +): Promise { + const key = `${userId}\u0000${workspaceId ?? ''}\u0000${messageId ?? ''}` + const now = Date.now() + const hit = turnRegistryCache.get(key) + if (hit && hit.expiresAt > now) { + hit.expiresAt = now + TURN_REGISTRY_TTL_MS + return hit.registry + } + const environmentContext = await prepareCopilotEnvironmentContext(userId, workspaceId) + for (const [cachedKey, cached] of turnRegistryCache) { + if (cached.expiresAt <= now) turnRegistryCache.delete(cachedKey) + } + if (turnRegistryCache.size >= TURN_REGISTRY_CACHE_MAX) { + const oldest = turnRegistryCache.keys().next().value + if (oldest !== undefined) turnRegistryCache.delete(oldest) + } + turnRegistryCache.set(key, { + registry: environmentContext.resolvedSecretTraceRegistry, + expiresAt: now + TURN_REGISTRY_TTL_MS, + }) + return environmentContext.resolvedSecretTraceRegistry +} + // POST /api/copilot/tools/execute — internal (Go → Sim) in-band execution of // one sim-server tool announced on a LIVE mothership turn. This is what lets // background (async) subagents — and the main lane while background agents are @@ -21,6 +68,14 @@ const logger = createLogger('CopilotToolExecuteInternalAPI') // synchronously instead of parking the turn, and the tool runs through the // same server tool router the resume driver uses. Trusted server-to-server // only: Go supplies the acting user, proven by the internal API secret. +// +// Results cross a model boundary here just as they do in the resume driver, so +// this route mirrors its provenance discipline: a per-call registry fork feeds +// the handler, the settled result is projected before it returns to Go, and +// the fork is merged back only when the projection was safe and the fork +// stayed complete. Without this, every result crossed unprojected and every +// thrown error was replaced by the opaque "could not be returned safely" +// sentinel (the projection fails closed on a missing registry). export const POST = withRouteHandler((request: NextRequest) => withIncomingGoSpan( request.headers, @@ -59,6 +114,19 @@ export const POST = withRouteHandler((request: NextRequest) => [TraceAttr.UserId]: userId, }) + let toolRegistry: ResolvedSecretTraceRegistry | undefined + let turnRegistry: ResolvedSecretTraceRegistry | undefined + try { + turnRegistry = await getTurnEgressRegistry(userId, workspaceId, messageId) + toolRegistry = turnRegistry.forkForInputPaths([]) + } catch (err) { + logger.error('In-band egress registry unavailable; results will be withheld', { + toolName, + toolCallId, + error: getErrorMessage(err), + }) + } + try { const handler = createServerToolHandler(toolName) const result = await handler(params, { @@ -71,25 +139,34 @@ export const POST = withRouteHandler((request: NextRequest) => parentToolCallId, userPermission, copilotToolExecution: true, + resolvedSecretTraceRegistry: toolRegistry, }) - if (!result.success) { + const projection = inspectToolResultForCopilot(result, toolRegistry, toolName) + const projected = projection.result + if (projection.safe && toolRegistry?.isComplete() && turnRegistry) { + turnRegistry.mergeToolCallRegistry(toolRegistry) + } + if (!projected.success) { logger.warn('In-band tool execution failed', { toolName, toolCallId, - error: result.error, + error: projected.error, + runtimeSucceeded: result.success, + projectionSafe: projection.safe, }) } if (result.success && chatId) { // Persist created/deleted resources on the chat (file chips, table // links) exactly like the resume driver does. No live event sink // exists for an out-of-band route, so chips surface from the - // persisted chat resources rather than a mid-turn push. + // persisted chat resources rather than a mid-turn push. Side effects + // read the raw result; only model-facing content is projected. const asToolResult = { success: result.success, output: result.output } as ToolCallResult await handleResourceSideEffects( toolName, params, asToolResult, - asToolResult, + { success: projected.success, output: projected.output } as ToolCallResult, chatId, undefined, () => false @@ -102,13 +179,21 @@ export const POST = withRouteHandler((request: NextRequest) => }) } return NextResponse.json({ - success: result.success, - ...(result.output !== undefined ? { output: result.output } : {}), - ...(result.error ? { error: result.error } : {}), + success: projected.success, + ...(projected.output !== undefined ? { output: projected.output } : {}), + ...(projected.error ? { error: projected.error } : {}), }) } catch (err) { - const message = getErrorMessage(err) - logger.error('In-band tool execution threw', { toolName, toolCallId, error: message }) + const message = projectToolErrorMessageForCopilot( + getErrorMessage(err), + toolRegistry, + toolName + ) + logger.error('In-band tool execution threw', { + toolName, + toolCallId, + error: getErrorMessage(err), + }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } } diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index f6ce59033ee..e7440fb3d0f 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -1339,6 +1339,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { hidden: { type: 'boolean', }, + inbandOwned: { + type: 'boolean', + }, internal: { type: 'boolean', }, diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 7f4fbd98e19..3b47c736f7e 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -160,6 +160,7 @@ export interface MothershipStreamV1AdditionalPropertiesMap { export interface MothershipStreamV1ToolUI { clientExecutable?: boolean hidden?: boolean + inbandOwned?: boolean internal?: boolean } export interface MothershipStreamV1ToolArgsDeltaEventEnvelope { diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 82762e5d46a..aa4ef86a6a6 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -483,6 +483,33 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('registers but never dispatches an inband-owned sim tool call', async () => { + // Go executes inband-owned calls itself via /api/copilot/tools/execute; + // dispatching here too ran the tool twice, racing on mutations. + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-inband', + toolName: ReadTool.id, + arguments: { path: 'files/a.md' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + ui: { inbandOwned: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: true } + ) + await sleep(0) + + expect(context.toolCalls.get('tool-inband')).toBeDefined() + expect(executeTool).not.toHaveBeenCalled() + expect(context.pendingToolPromises.has('tool-inband')).toBe(false) + }) + it('preserves primitive tool outputs through async completion persistence', async () => { executeTool.mockResolvedValueOnce({ success: true, output: 'done' }) const onEvent = vi.fn() diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 54bf70d7800..31974d1b489 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -508,11 +508,16 @@ async function handleCallPhase( const readPath = typeof args?.path === 'string' ? args.path : undefined if (toolName === 'read' && readPath?.startsWith('internal/')) return - const { clientExecutable, simExecutable, internal } = ui + const { clientExecutable, simExecutable, internal, inbandOwned } = ui const catalogEntry = getToolEntry(toolName) const isInternal = internal || catalogEntry?.internal === true const staticSimExecuted = isSimExecuted(toolName) - const willDispatch = !isInternal && (staticSimExecuted || simExecutable || clientExecutable) + // Go executes inband-owned calls itself via /api/copilot/tools/execute + // (background lanes, and the main lane while background agents run); the + // event exists only to draw the row. Dispatching it here would run the + // tool a second time, racing the in-band execution on mutations. + const willDispatch = + !isInternal && !inbandOwned && (staticSimExecuted || simExecutable || clientExecutable) logger.info('Tool call routing decision', { toolCallId, toolName, @@ -524,6 +529,7 @@ async function handleCallPhase( simExecutable, staticSimExecuted, internal: isInternal, + inbandOwned, hasPendingPromise: context.pendingToolPromises.has(toolCallId), existingStatus: existing?.status, willDispatch, diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts index a7f9d819466..cc3301b4165 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -198,6 +198,7 @@ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): { simExecutable: boolean internal: boolean hidden: boolean + inbandOwned: boolean } { const raw = asRecord(data.ui) return { @@ -206,6 +207,7 @@ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): { simExecutable: data.executor === MothershipStreamV1ToolExecutor.sim, internal: raw.internal === true, hidden: raw.hidden === true, + inbandOwned: raw.inbandOwned === true, } } diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index f4712a6fffe..7a181bfa4f0 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -610,7 +610,8 @@ async function executeToolAndReportInner( if (abortRequested(context, execContext, options)) { const copilotResult = inspectToolResultForCopilot( result, - toolExecutionContext.resolvedSecretTraceRegistry + toolExecutionContext.resolvedSecretTraceRegistry, + toolCall.name ).result markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) @@ -726,7 +727,8 @@ async function executeToolAndReportInner( } const projection = inspectToolResultForCopilot( result, - toolExecutionContext.resolvedSecretTraceRegistry + toolExecutionContext.resolvedSecretTraceRegistry, + toolCall.name ) const copilotResult = projection.result mergeToolRegistry(projection.safe) @@ -735,6 +737,16 @@ async function executeToolAndReportInner( toolSpan.attributes = { ...toolSpan.attributes, ...summarizeToolResultForSpan(copilotResult), + ...(projection.safe ? {} : { resultWithheld: true }), + } + if (!projection.safe) { + // A withheld SUCCESS otherwise leaves no trace anywhere: the span reads + // ok and the model just sees a bare `{success: true}` with no output. + logger.warn('Tool result withheld by egress projection', { + toolCallId: toolCall.id, + toolName: toolCall.name, + runtimeSucceeded: result.success, + }) } setTerminalToolCallState(toolCall, { @@ -861,7 +873,8 @@ async function executeToolAndReportInner( const thrownMessage = toError(error).message const projection = inspectToolResultForCopilot( { success: false, error: thrownMessage }, - toolExecutionContext.resolvedSecretTraceRegistry + toolExecutionContext.resolvedSecretTraceRegistry, + toolCall.name ) const copilotError = projection.result mergeToolRegistry(projection.safe) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index 536ac1adf87..26285b0d7d5 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -5,7 +5,9 @@ import { describe, expect, it } from 'vitest' import { RunCode, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolResultForCopilot, + READ_TOOL_RESULT_UNAVAILABLE_ERROR, TOOL_RESULT_UNAVAILABLE_ERROR, + toolResultUnavailableError, } from '@/lib/copilot/request/tools/resolved-secret-result' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -430,4 +432,28 @@ describe('projectToolResultForCopilot', () => { projectToolResultForCopilot({ success: true, output: 'possibly-secret' }, undefined) ).toEqual({ success: true }) }) + + it.each(['read', 'glob', 'grep'])( + 'withholds a read-only %s failure without the mutation-retry warning', + (toolId) => { + const projected = projectToolResultForCopilot( + { success: false, error: 'anything' }, + undefined, + toolId + ) + expect(projected).toEqual({ success: false, error: READ_TOOL_RESULT_UNAVAILABLE_ERROR }) + expect(projected.error).not.toContain('mutation') + } + ) + + it('keeps the mutation-retry warning for withheld mutating-tool failures', () => { + expect( + projectToolResultForCopilot( + { success: false, error: 'anything' }, + undefined, + 'apply_file_edit' + ) + ).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR }) + expect(toolResultUnavailableError(undefined)).toBe(TOOL_RESULT_UNAVAILABLE_ERROR) + }) }) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts index fdd4584fd1a..f6785f60a8d 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts @@ -5,13 +5,30 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr export const TOOL_RESULT_UNAVAILABLE_ERROR = 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.' +/** + * Read-only tools carry no mutation-retry hazard, so their withheld results + * must not warn against retrying — that wording makes the model abandon + * harmless reads it could simply try again or work around. + */ +export const READ_TOOL_RESULT_UNAVAILABLE_ERROR = + 'Tool executed, but its result could not be returned safely. The call was read-only, so you may retry it or continue without the result.' + +const READ_ONLY_RESULT_TOOLS = new Set(['read', 'glob', 'grep']) + +/** Chooses the withheld-result message a tool's caller should surface. */ +export function toolResultUnavailableError(toolId?: string): string { + return toolId && READ_ONLY_RESULT_TOOLS.has(toolId) + ? READ_TOOL_RESULT_UNAVAILABLE_ERROR + : TOOL_RESULT_UNAVAILABLE_ERROR +} + function structuralResult(result: ToolExecutionResult): ToolExecutionResult { return { success: result.success === true } } -function omittedResult(result: ToolExecutionResult): ToolExecutionResult { +function omittedResult(result: ToolExecutionResult, toolId?: string): ToolExecutionResult { if (result.success) return { success: true } - return { success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR } + return { success: false, error: toolResultUnavailableError(toolId) } } export type CopilotToolResultProjection = @@ -26,7 +43,8 @@ export type CopilotToolResultProjection = */ export function inspectToolResultForCopilot( result: ToolExecutionResult, - registry: ResolvedSecretTraceRegistry | undefined + registry: ResolvedSecretTraceRegistry | undefined, + toolId?: string ): CopilotToolResultProjection { try { const resultRegistry = registry?.forkForPropagatedEntries() @@ -36,7 +54,7 @@ export function inspectToolResultForCopilot( if (Object.hasOwn(result, 'error')) content.error = result.error const projection = projectResolvedSecretModelJsonContent(content, resultRegistry) if (!projection.safe || !projection.value || typeof projection.value !== 'object') { - return { safe: false, result: omittedResult(result) } + return { safe: false, result: omittedResult(result, toolId) } } const projectedContent = projection.value as Record @@ -44,7 +62,7 @@ export function inspectToolResultForCopilot( if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output if (Object.hasOwn(projectedContent, 'error')) { if (typeof projectedContent.error !== 'string') { - return { safe: false, result: omittedResult(result) } + return { safe: false, result: omittedResult(result, toolId) } } projected.error = projectedContent.error } @@ -52,11 +70,11 @@ export function inspectToolResultForCopilot( projected.resources = resources } if (!projected.success && !projected.error) { - projected.error = TOOL_RESULT_UNAVAILABLE_ERROR + projected.error = toolResultUnavailableError(toolId) } return { safe: true, result: projected } } catch { - return { safe: false, result: omittedResult(result) } + return { safe: false, result: omittedResult(result, toolId) } } } @@ -66,15 +84,17 @@ export function inspectToolResultForCopilot( */ export function projectToolResultForCopilot( result: ToolExecutionResult, - registry: ResolvedSecretTraceRegistry | undefined + registry: ResolvedSecretTraceRegistry | undefined, + toolId?: string ): ToolExecutionResult { - return inspectToolResultForCopilot(result, registry).result + return inspectToolResultForCopilot(result, registry, toolId).result } /** Projects an error before post-processing can attach it to application logs or OTel events. */ export function projectToolErrorMessageForCopilot( error: string, - registry: ResolvedSecretTraceRegistry | undefined + registry: ResolvedSecretTraceRegistry | undefined, + toolId?: string ): string { - return projectToolResultForCopilot({ success: false, error }, registry).error ?? '' + return projectToolResultForCopilot({ success: false, error }, registry, toolId).error ?? '' } 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 1c44356651e..181a7d414ce 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -56,7 +56,8 @@ export function createServerToolHandler(toolId: string): ToolHandler { ) const safeMessage = projectToolErrorMessageForCopilot( messageForCopilotApplicationError(error), - context.resolvedSecretTraceRegistry + context.resolvedSecretTraceRegistry, + toolId ) return { success: false, 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 8f36784b1a1..4523d8f1dde 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -7,6 +7,8 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { inferContentType } from '@/lib/copilot/tools/server/files/workspace-file' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { createWorkspaceFileByPath, updateWorkspaceFileContentByPath, @@ -58,27 +60,43 @@ export const createFileServerTool: BaseServerTool + executeCopilotFileUseCase(context, createWorkspaceFileByPath, { + workspaceId, + path: outputPath, + mode: 'create', + content: '', + encoding: 'utf-8', + contentType, + exactName: true, + secretProvenance: emptyProvenance, + }) try { - const result = - mode === 'overwrite' - ? await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, { - workspaceId, - path: outputPath, - mode, - content: '', - encoding: 'utf-8', - contentType, - syncLiveDoc: false, - }) - : await executeCopilotFileUseCase(context, createWorkspaceFileByPath, { - workspaceId, - path: outputPath, - mode, - content: '', - encoding: 'utf-8', - contentType, - exactName: true, - }) + let result + if (mode === 'overwrite') { + try { + result = await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, { + workspaceId, + path: outputPath, + mode, + content: '', + encoding: 'utf-8', + contentType, + syncLiveDoc: false, + secretProvenance: emptyProvenance, + }) + } catch (overwriteError) { + // Upsert: overwrite of a missing path falls through to create. + if (asOrchestrationError(overwriteError)?.code !== 'not_found') throw overwriteError + result = await createShell() + } + } else { + result = await createShell() + } logger.info('File created via create_empty_file', { fileId: result.id, diff --git a/apps/sim/lib/copilot/tools/server/image/generate-image.ts b/apps/sim/lib/copilot/tools/server/image/generate-image.ts index 52cbc5c5dbe..f9f5be655e3 100644 --- a/apps/sim/lib/copilot/tools/server/image/generate-image.ts +++ b/apps/sim/lib/copilot/tools/server/image/generate-image.ts @@ -18,6 +18,7 @@ import { import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { MAX_MEDIA_BYTES } from '@/lib/media/falai' +import { createWorkspaceFileSecretProvenanceFromRegistry } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' @@ -188,6 +189,16 @@ export const generateImageServerTool: BaseServerTool { vfsPath: 'files/Reports/2026/summary.csv', }) }) + + it('upserts: an overwrite of a missing target falls through to create', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mocks.resolveWorkspaceFileReference.mockRejectedValue( + new OrchestrationError('not_found', 'File not found') + ) + mocks.createWorkspaceFileBufferByPath.execute.mockResolvedValue({ + id: 'file-new', + name: 'chart.png', + size: 3, + contentType: 'image/png', + downloadUrl: 'url', + vfsPath: 'files/chart.png', + }) + + const written = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + principal: { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }, + target: { path: 'files/chart.png', mode: 'overwrite' }, + buffer: Buffer.from('png'), + inferredMimeType: 'image/png', + }) + + expect(mocks.updateWorkspaceFileContentBufferByPath.execute).not.toHaveBeenCalled() + expect(mocks.createWorkspaceFileBufferByPath.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ path: 'files/chart.png', mode: 'create' }), + }) + ) + expect(written).toMatchObject({ id: 'file-new', mode: 'create' }) + }) + + it('keeps a genuine overwrite on the update path', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue({ id: 'file-1', name: 'chart.png' }) + mocks.updateWorkspaceFileContentBufferByPath.execute.mockResolvedValue({ + id: 'file-1', + name: 'chart.png', + size: 3, + contentType: 'image/png', + downloadUrl: 'url', + vfsPath: 'files/chart.png', + }) + + const written = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + principal: { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }, + target: { path: 'files/chart.png', mode: 'overwrite' }, + buffer: Buffer.from('png'), + inferredMimeType: 'image/png', + }) + + expect(mocks.createWorkspaceFileBufferByPath.execute).not.toHaveBeenCalled() + expect(written).toMatchObject({ id: 'file-1', mode: 'overwrite' }) + }) }) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 6db6bc0319c..58d8c527aa8 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -4,6 +4,7 @@ import { resolveCopilotFilePrincipal, } from '@/lib/copilot/auth/file-delegation' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, @@ -152,34 +153,48 @@ export async function writeWorkspaceFileByPath(args: { /** Private provenance for the exact bytes being written. */ secretProvenance?: WorkspaceFileSecretProvenance }): Promise { - await assertWorkspaceFileWriteAccess(args) - const contentType = args.target.mimeType || args.inferredMimeType if (args.target.mode === 'overwrite') { - const updated = await updateWorkspaceFileContentBufferByPath.execute({ - principal: args.principal, - input: { - workspaceId: args.workspaceId, - path: args.target.path, - mode: 'overwrite', - content: args.buffer, - contentType, - syncLiveDoc: args.syncLiveDoc, - secretProvenance: args.secretProvenance, - }, - }) + // Overwrite is an upsert: "put these bytes at this path". A missing target + // falls through to create instead of failing — otherwise every generator + // (generate_image, ffmpeg, downloads) forces the model through a + // create-vs-overwrite guessing dance racing its own earlier writes. + let missingTarget = false + try { + await assertWorkspaceFileWriteAccess(args) + } catch (accessError) { + if (asOrchestrationError(accessError)?.code !== 'not_found') throw accessError + missingTarget = true + } + if (!missingTarget) { + const updated = await updateWorkspaceFileContentBufferByPath.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + path: args.target.path, + mode: 'overwrite', + content: args.buffer, + contentType, + syncLiveDoc: args.syncLiveDoc, + secretProvenance: args.secretProvenance, + }, + }) - return { - id: updated.id, - name: updated.name, - size: updated.size, - contentType: updated.contentType, - downloadUrl: updated.downloadUrl, - vfsPath: updated.vfsPath, - mode: 'overwrite', + return { + id: updated.id, + name: updated.name, + size: updated.size, + contentType: updated.contentType, + downloadUrl: updated.downloadUrl, + vfsPath: updated.vfsPath, + mode: 'overwrite', + } } + args = { ...args, target: { ...args.target, mode: 'create' } } } + await assertWorkspaceFileWriteAccess(args) + const created = await createWorkspaceFileBufferByPath.execute({ principal: args.principal, input: { From 63f48c79675f65bfe54730f782370b6c26cef8cb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 15:15:52 -0700 Subject: [PATCH 075/103] Route in-band execution through the comprehensive tool dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal execute route used the bare server-tool router, which rejects VFS tools with 'Unknown server tool: read/glob/grep' — so nearly every background agent's first discovery call failed (102 in-band calls in one run, dozens rejected). It now uses the relay's executeTool dispatcher: registered handlers (VFS, function execute) with permission checks and param normalization, falling back to the app tool router — the same surface foreground execution gets. --- apps/sim/app/api/copilot/tools/execute/route.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index a2e4c3b73c6..9da0848f6ca 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -14,7 +14,8 @@ import { } from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import type { ToolCallResult } from '@/lib/copilot/request/types' -import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' +import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor' +import { executeTool } from '@/lib/copilot/tool-executor/executor' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -128,8 +129,12 @@ export const POST = withRouteHandler((request: NextRequest) => } try { - const handler = createServerToolHandler(toolName) - const result = await handler(params, { + // The relay's comprehensive dispatcher: registry handlers (VFS + // glob/read/grep, function execute, ...) plus the server tool router + // fallback — the plain server-tool adapter alone rejects VFS tools + // with "Unknown server tool". + ensureHandlersRegistered() + const result = await executeTool(toolName, params, { userId, workflowId: workflowId ?? '', workspaceId, From 45361000696fc8e360acc3d8a34f3f6963c00477 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 18:22:48 -0700 Subject: [PATCH 076/103] Harden chat stream transition handling --- apps/sim/app/workspace/[workspaceId]/home/home.tsx | 5 +++-- .../[workspaceId]/home/hooks/use-chat.test.ts | 14 ++++++++++++-- .../workspace/[workspaceId]/home/hooks/use-chat.ts | 10 +++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index eaefecea249..4077c2a8c14 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -218,8 +218,9 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) activeResourceParamRef.current = activeResourceParam function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) { - // Agent work makes the resource surface available without replacing an - // existing selection. Explicit user navigation can request activation. + // Agent work surfaces the resource and switches to it as it is created or + // edited; only the browser session stays in the background behind an + // existing selection (see shouldActivateResourceEvent). if (isResourceCollapsedRef.current) setIsResourceCollapsed(false) const activeResourceId = activeResourceParamRef.current diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index ec0fe69942d..6caa5a16375 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -33,17 +33,27 @@ vi.mock('next/navigation', () => ({ })) describe('shouldActivateResourceEvent', () => { - it('keeps background agent activity from replacing another selected resource', () => { + it('keeps background browser activity from replacing another selected resource', () => { expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(false) }) - it('allows an explicit user action to replace another selected resource', () => { + it('allows an explicit user action to surface the browser over another selection', () => { expect( shouldActivateResourceEvent('file-1', 'browser-session', { activate: true, }) ).toBe(true) }) + + it('activates the browser when nothing else is selected', () => { + expect(shouldActivateResourceEvent(null, 'browser-session')).toBe(true) + expect(shouldActivateResourceEvent('browser-session', 'browser-session')).toBe(true) + }) + + it('activates a non-browser resource even when another resource is selected', () => { + expect(shouldActivateResourceEvent('file-1', 'workflow-1')).toBe(true) + expect(shouldActivateResourceEvent('browser-session', 'terminal-session')).toBe(true) + }) }) describe('shouldQueueOutgoingMessage', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 2f431e3c354..76ef88190cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1195,12 +1195,20 @@ export interface ResourceEventOptions { export type ResourceEventHandler = (resourceId: string, options?: ResourceEventOptions) => void +/** + * Whether a streamed resource event should activate its tab. Resources switch + * into view as the agent creates or edits them; only the background browser + * session declines to replace an existing selection (it gets an attention + * marker instead), unless the event explicitly requests activation. + */ export function shouldActivateResourceEvent( activeResourceId: string | null, resourceId: string, options?: ResourceEventOptions ): boolean { - return options?.activate === true || !activeResourceId || activeResourceId === resourceId + if (options?.activate === true) return true + if (resourceId !== BROWSER_SESSION_RESOURCE_ID) return true + return !activeResourceId || activeResourceId === resourceId } /** From 24b24e5f8e97c178b3a830bb66902ecc1d96d836 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 19:30:43 -0700 Subject: [PATCH 077/103] Harden VFS provenance and resource writes --- apps/sim/connectors/github/github.ts | 10 ++- apps/sim/connectors/slack/slack.ts | 10 ++- .../lib/copilot/generated/tool-catalog-v1.ts | 57 ++++++++++++-- .../lib/copilot/generated/tool-schemas-v1.ts | 53 ++++++++++++- .../lib/copilot/tools/handlers/vfs.test.ts | 73 ++++++++++++++++- apps/sim/lib/copilot/tools/handlers/vfs.ts | 78 +++++++++++++------ .../server/knowledge/knowledge-base.test.ts | 70 +++++++++++++++++ .../tools/server/knowledge/knowledge-base.ts | 45 ++++++++++- .../copilot/tools/server/table/user-table.ts | 1 + apps/sim/lib/copilot/vfs/file-reader.ts | 10 ++- apps/sim/lib/copilot/vfs/operations.test.ts | 34 ++++++-- apps/sim/lib/copilot/vfs/operations.ts | 7 +- apps/sim/lib/copilot/vfs/serializers.test.ts | 57 ++++++++++++++ apps/sim/lib/copilot/vfs/serializers.ts | 10 ++- .../sim/lib/copilot/vfs/workspace-vfs.test.ts | 52 +++++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 23 +++++- apps/sim/lib/knowledge/service.ts | 5 +- .../workspace-file-secret-provenance.ts | 6 +- 18 files changed, 545 insertions(+), 56 deletions(-) diff --git a/apps/sim/connectors/github/github.ts b/apps/sim/connectors/github/github.ts index 2509ecb0df4..fc0b0dd1788 100644 --- a/apps/sim/connectors/github/github.ts +++ b/apps/sim/connectors/github/github.ts @@ -418,7 +418,15 @@ export const githubConnector: ConnectorConfig = { } if (!response.ok) { - return { valid: false, error: `Cannot access repository: ${response.status}` } + return { + valid: false, + error: + response.status === 401 + ? 'Cannot access repository: 401 — the token was rejected (invalid, expired, or not a real token). Pass a valid PAT or a {{ENV_VAR}} reference to one.' + : response.status === 403 + ? 'Cannot access repository: 403 — the token lacks access to this repository (missing repo scope, or fine-grained token not granted to it).' + : `Cannot access repository: ${response.status}`, + } } return { valid: true } diff --git a/apps/sim/connectors/slack/slack.ts b/apps/sim/connectors/slack/slack.ts index e82e9c259a4..8e085b4c2fd 100644 --- a/apps/sim/connectors/slack/slack.ts +++ b/apps/sim/connectors/slack/slack.ts @@ -640,7 +640,10 @@ export const slackConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) } catch { - return { valid: false, error: `Channel not found: ${input}` } + return { + valid: false, + error: `Channel not found: ${input}. The selected credential cannot see it — it may belong to a different Slack workspace, or the channel is private and the connected user/bot is not a member.`, + } } } else { nameLookups.push(trimmed) @@ -684,7 +687,10 @@ export const slackConnector: ConnectorConfig = { } while (cursor) const missing = Array.from(remaining) - return { valid: false, error: `Channel(s) not found: ${missing.join(', ')}` } + return { + valid: false, + error: `Channel(s) not found: ${missing.join(', ')}. The selected credential cannot see them — they may belong to a different Slack workspace, or they are private channels the connected user/bot is not a member of.`, + } } catch (error) { const message = toError(error).message || 'Failed to validate configuration' return { valid: false, error: message } diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index fe635a1bc9a..d101bd9d1e7 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -2312,8 +2312,19 @@ export const Extensions: ToolCatalogEntry = { parameters: { properties: { request: { description: 'What tool/skill/MCP action is needed.', type: 'string' }, + sessionId: { + description: + 'Reusable session ID returned by an earlier extensions call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the extensions agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, subagentId: 'agent', @@ -2500,7 +2511,19 @@ export const File: ToolCatalogEntry = { "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier file call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the file agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, + required: ['title'], type: 'object', }, subagentId: 'file', @@ -3198,8 +3221,19 @@ export const Knowledge: ToolCatalogEntry = { parameters: { properties: { request: { description: 'What knowledge base action is needed.', type: 'string' }, + sessionId: { + description: + 'Reusable session ID returned by an earlier knowledge call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the knowledge agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, subagentId: 'knowledge', @@ -3435,7 +3469,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = { apiKey: { type: 'string', description: - 'API key for API-key-based connectors (required when connector auth mode is apiKey)', + 'API key for API-key-based connectors (required when connector auth mode is apiKey). Accepts an environment-variable reference — {{NAME}} — resolved server-side from workspace/user environment variables; a raw key also works.', }, chunkingConfig: { type: 'object', @@ -5376,8 +5410,21 @@ export const Table: ToolCatalogEntry = { route: 'subagent', mode: 'async', parameters: { - properties: { request: { description: 'What table action is needed.', type: 'string' } }, - required: ['request'], + properties: { + request: { description: 'What table action is needed.', type: 'string' }, + sessionId: { + description: + 'Reusable session ID returned by an earlier table call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the table agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, + }, + required: ['request', 'title'], type: 'object', }, subagentId: 'table', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index d94d43860ee..707ca08bcb4 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2267,8 +2267,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'What tool/skill/MCP action is needed.', type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier extensions call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the extensions agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, resultSchema: undefined, @@ -2463,7 +2474,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier file call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the file agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, + required: ['title'], type: 'object', }, resultSchema: undefined, @@ -3135,8 +3158,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'What knowledge base action is needed.', type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier knowledge call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the knowledge agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, resultSchema: undefined, @@ -3358,7 +3392,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { apiKey: { type: 'string', description: - 'API key for API-key-based connectors (required when connector auth mode is apiKey)', + 'API key for API-key-based connectors (required when connector auth mode is apiKey). Accepts an environment-variable reference — {{NAME}} — resolved server-side from workspace/user environment variables; a raw key also works.', }, chunkingConfig: { type: 'object', @@ -5281,8 +5315,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'What table action is needed.', type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier table call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the table agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, resultSchema: undefined, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 5f89535f76a..da3c15bedc4 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -103,7 +103,7 @@ describe('vfs handlers oversize policy', () => { expect(result.error).toContain('context window') }) - it('fails oversized read results from VFS with grep guidance', async () => { + it('fails oversized read results from VFS with paging guidance', async () => { const vfs = makeVfs() vfs.readFileContent.mockResolvedValue(null) vfs.read.mockReturnValue({ content: OVERSIZED_INLINE_CONTENT, totalLines: 1 }) @@ -112,11 +112,58 @@ describe('vfs handlers oversize policy', () => { const result = await executeVfsRead({ path: 'workflows/My Workflow/state.json' }, GREP_CTX) expect(result.success).toBe(false) - expect(result.error).toContain('Use grep') - expect(result.error).toContain('offset/limit') + expect(result.error).toContain('Page it') + expect(result.error).toContain('grep') expect(result.error).toContain('context window') }) + it('pages an oversized workspace file when offset/limit are passed', async () => { + const vfs = makeVfs() + const lines = Array.from({ length: 5000 }, (_, i) => `line ${i} ${'y'.repeat(50)}`) + vfs.readFileContent.mockResolvedValue({ + content: lines.join('\n'), + totalLines: lines.length, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const whole = await executeVfsRead({ path: 'files/big.log/content' }, GREP_CTX) + expect(whole.success).toBe(false) + expect(whole.error).toContain('Page it') + + const paged = await executeVfsRead( + { path: 'files/big.log/content', offset: 10, limit: 5 }, + GREP_CTX + ) + expect(paged.success).toBe(true) + expect((paged.output as { content: string }).content).toBe(lines.slice(10, 15).join('\n')) + }) + + it('tells the model to reduce limit when the requested window is still oversized', async () => { + const vfs = makeVfs() + vfs.readFileContent.mockResolvedValue({ + content: Array.from({ length: 100 }, () => OVERSIZED_INLINE_CONTENT).join('\n'), + totalLines: 100, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/big.log/content', offset: 0, limit: 50 }, + GREP_CTX + ) + expect(result.success).toBe(false) + expect(result.error).toContain('Reduce limit') + }) + + it('notes an empty file instead of returning bare empty content', async () => { + const vfs = makeVfs() + vfs.readFileContent.mockResolvedValue({ content: '', totalLines: 0 }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead({ path: 'files/hi.txt/content' }, GREP_CTX) + expect(result.success).toBe(true) + expect((result.output as { note?: string }).note).toContain('empty') + }) + it('fails file-backed oversized read placeholders with original message', async () => { const vfs = makeVfs() vfs.readFileContent.mockResolvedValue( @@ -723,6 +770,26 @@ describe('vfs uploads are opt-in (like recently-deleted/)', () => { expect((broad.output as { files: string[] }).files).not.toContain('uploads/My%20Report.json') }) + it('explains an empty uploads glob instead of returning a bare []', async () => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + listChatUploads.mockResolvedValue([]) + + const result = await executeVfsGlob({ pattern: 'uploads/*' }, GREP_CTX_CHAT) + expect(result.success).toBe(true) + expect((result.output as { files: string[]; note?: string }).files).toEqual([]) + expect((result.output as { note?: string }).note).toContain('no uploads') + }) + + it('explains an empty user-local glob instead of returning a bare []', async () => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsGlob({ pattern: 'user-local/**' }, GREP_CTX_CHAT) + expect(result.success).toBe(true) + expect((result.output as { note?: string }).note).toContain('user-local') + }) + it('reads an upload directly, tolerating a spurious /content suffix', async () => { const vfs = makeVfs() getOrMaterializeVFS.mockResolvedValue(vfs) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index c8f6d2c642d..c3485b72598 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -267,6 +267,25 @@ export async function executeVfsGlob( } logger.debug('vfs_glob result', { pattern, fileCount: files.length }) + // A bare [] on a namespace that is legitimately absent reads as "my glob is + // wrong". Say why it's empty so the model doesn't retry pattern variants. + if (files.length === 0) { + if (pattern.startsWith('uploads')) { + return { + success: true, + output: { files, note: 'This chat has no uploads.' }, + } + } + if (pattern.startsWith('user-local')) { + return { + success: true, + output: { + files, + note: 'No user-local folder is granted in this chat, so user-local/ is empty.', + }, + } + } + } return { success: true, output: { files } } } catch (err) { logger.error('vfs_glob failed', { @@ -336,28 +355,29 @@ export async function executeVfsRead( const uploadResult = uploadEnvelope?.value if (uploadResult) { const isAttachment = hasModelAttachment(uploadResult) - if ( - !isAttachment && - (isOversizedReadPlaceholder(uploadResult) || - serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS) - ) { + if (!isAttachment && isOversizedReadPlaceholder(uploadResult)) { + // The loader refused to materialize the bytes at all; a window can't help. + return { success: false, error: uploadResult.content } + } + // Window BEFORE the inline-size gate, so offset/limit genuinely page a + // large upload instead of the gate rejecting the whole file first. + const windowedUpload = applyWindow(uploadResult) + if (!isAttachment && serializedResultSize(windowedUpload) > TOOL_RESULT_MAX_INLINE_CHARS) { logger.warn('Upload read result too large', { path, hasAttachment: isAttachment, contentLength: uploadResult.content.length, - serializedSize: serializedResultSize(uploadResult), + serializedSize: serializedResultSize(windowedUpload), + windowed: offset !== undefined || limit !== undefined, }) return { success: false, - error: isOversizedReadPlaceholder(uploadResult) - ? uploadResult.content - : // Same as the workspace-file branch below: this size gate runs on - // the whole upload before any window, so "retry with offset/limit" - // would loop. Point at grep scoped to this path instead. - `Read result too large to return inline. Grep this single upload instead of reading it — grep({pattern: "...", path: "${path}"}) — because offset/limit do NOT shrink an upload read: the size check runs on the whole file before the window is applied.`, + error: + offset !== undefined || limit !== undefined + ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` + : `Read result too large to return inline. Page it — read({path: "${path}", offset: 0, limit: 500}) — or locate the relevant section first with grep({pattern: "...", path: "${path}"}).`, } } - const windowedUpload = applyWindow(uploadResult) const provenanceView = offset === undefined && limit === undefined ? (uploadEnvelope?.view ?? 'derived') @@ -406,25 +426,33 @@ export async function executeVfsRead( const fileContent = fileEnvelope?.value if (fileContent) { const isAttachment = hasModelAttachment(fileContent) + if (!isAttachment && isOversizedReadPlaceholder(fileContent)) { + // The loader refused to materialize the bytes at all; a window can't help. + return { success: false, error: fileContent.content } + } + // Window BEFORE the inline-size gate, so offset/limit genuinely page a + // large file instead of the gate rejecting the whole file first — the + // paging advice in the error below has to actually work. + const windowedFileContent = applyWindow(fileContent) if ( !isAttachment && - (isOversizedReadPlaceholder(fileContent) || - serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS) + serializedResultSize(windowedFileContent) > TOOL_RESULT_MAX_INLINE_CHARS ) { logger.warn('File read result too large', { path, hasAttachment: isAttachment, contentLength: fileContent.content.length, - serializedSize: serializedResultSize(fileContent), + serializedSize: serializedResultSize(windowedFileContent), + windowed: offset !== undefined || limit !== undefined, }) return { success: false, - error: isOversizedReadPlaceholder(fileContent) - ? fileContent.content - : `Read result too large to return inline. Locate the relevant section first — grep({pattern: \"...\", path: \"${path}\"}) — then page it with read({path: \"${path}\", offset: , limit: }). Avoid catch-all greps or full-file reads because they waste context window.`, + error: + offset !== undefined || limit !== undefined + ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` + : `Read result too large to return inline. Page it — read({path: "${path}", offset: 0, limit: 500}) — or locate the relevant section first with grep({pattern: "...", path: "${path}"}), then read({path: "${path}", offset: , limit: }). Avoid catch-all greps or full-file reads because they waste context window.`, } } - const windowedFileContent = applyWindow(fileContent) const provenanceView = offset === undefined && limit === undefined ? (fileEnvelope?.view ?? 'derived') : 'derived' if ( @@ -454,7 +482,11 @@ export async function executeVfsRead( }) return { success: true, - output: windowedFileContent, + output: + fileContent.content === '' && !isAttachment + ? // An empty string with no explanation reads as a failed read. + { ...windowedFileContent, note: 'File is empty (0 bytes).' } + : windowedFileContent, } } @@ -491,7 +523,9 @@ export async function executeVfsRead( return { success: false, error: - 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.', + offset !== undefined || limit !== undefined + ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` + : 'Read result too large to return inline. Page it with read({path, offset, limit}), or use grep with a more specific pattern to locate the relevant section first. Avoid catch-all greps or full-file reads because they waste context window.', } } logger.debug('vfs_read result', { path, totalLines: result.totalLines, offset, limit }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 640032aa9c7..95afa3a9066 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -83,6 +83,12 @@ const { vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ ManageKnowledgeBase: { id: 'manage_knowledge_base' }, })) +const { mockGetEffectiveDecryptedEnv } = vi.hoisted(() => ({ + mockGetEffectiveDecryptedEnv: vi.fn(), +})) +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, +})) vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { knowledgeBaseCreated: mockKnowledgeBaseCreated, @@ -700,6 +706,70 @@ describe('manage_knowledge_base trusted application delegation', () => { }) }) + it.each(['{{SIM_GITHUB_PAT}}', '$SIM_GITHUB_PAT', 'SIM_GITHUB_PAT'])( + 'resolves the %s environment reference into the connector API key', + async (ref) => { + mockGetEffectiveDecryptedEnv.mockResolvedValue({ SIM_GITHUB_PAT: 'ghp_realtoken' }) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_connector', + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, connectorType: 'github', apiKey: ref }, + }, + BILLED_CONTEXT + ) + + expect(result.success).toBe(true) + const call = mockCreateKnowledgeConnector.mock.calls.at(-1)?.[0] as { + input: { apiKey?: string } + } + expect(call.input.apiKey).toBe('ghp_realtoken') + } + ) + + it('names the missing variable instead of sending a placeholder upstream', async () => { + mockGetEffectiveDecryptedEnv.mockResolvedValue({}) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_connector', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + connectorType: 'github', + apiKey: '{{SIM_GITHUB_PAT}}', + }, + }, + BILLED_CONTEXT + ) + + expect(result.success).toBe(false) + expect(result.message).toContain('SIM_GITHUB_PAT') + expect(result.message).toContain('not set') + expect(mockCreateKnowledgeConnector).not.toHaveBeenCalled() + }) + + it('passes a raw API key through untouched', async () => { + mockGetEffectiveDecryptedEnv.mockResolvedValue({ SIM_GITHUB_PAT: 'ghp_realtoken' }) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_connector', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + connectorType: 'github', + apiKey: 'ghp_literal_key', + }, + }, + BILLED_CONTEXT + ) + + expect(result.success).toBe(true) + const call = mockCreateKnowledgeConnector.mock.calls.at(-1)?.[0] as { + input: { apiKey?: string } + } + expect(call.input.apiKey).toBe('ghp_literal_key') + }) + it('preserves caller-actionable tag provenance conflicts', async () => { mockDeleteKnowledgeTag.mockRejectedValueOnce( new OrchestrationError( 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 cd9f300a2ad..ffdf834201d 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -19,6 +19,7 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/application/batch-policy' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' @@ -53,6 +54,42 @@ import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-sec const logger = createLogger('KnowledgeBaseServerTool') +/** + * Resolves an environment-variable reference passed as a connector API key. + * + * Models reference workspace secrets the way workflows do — `{{SIM_GITHUB_PAT}}` + * (and, when improvising, `$SIM_GITHUB_PAT` or the bare name). Before this, + * the literal placeholder string was sent upstream as the bearer token and the + * provider answered 401 — an error that never named the real problem. A raw + * key that matches no reference form passes through untouched. + * + * Returns an error string when a reference names a variable that is not set, + * so the model learns the actual fix instead of retrying reference syntaxes. + */ +async function resolveConnectorApiKey( + context: ServerToolContext, + workspaceId: string, + apiKey: string | undefined +): Promise<{ apiKey?: string; error?: string }> { + if (!apiKey) return { apiKey } + const braced = apiKey.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/) + const dollar = apiKey.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/) + const referencedName = braced?.[1] ?? dollar?.[1] + const env = await getEffectiveDecryptedEnv(context.userId, workspaceId) + const name = referencedName ?? (Object.hasOwn(env, apiKey) ? apiKey : undefined) + if (!name) return { apiKey } + const value = env[name] + if (value === undefined || value === '') { + return { + error: `Environment variable "${name}" is not set for this workspace or user, so it cannot be used as the connector API key. Set it first, pass a different {{ENV_VAR}} reference, or pass the raw key.`, + } + } + // Activate the resolved secret on the call's egress registry so any + // accidental echo of it (provider error bodies, logs) is redacted. + context.resolvedSecretTraceRegistry?.recordResolved(name, value) + return { apiKey: value } +} + function requireKnowledgeBillingAttribution( context: ServerToolContext, workspaceId: string @@ -291,6 +328,7 @@ export const knowledgeBaseServerTool: BaseServerTool 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 8ced02221e3..437970ea10e 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -194,6 +194,7 @@ export const userTableServerTool: BaseServerTool name: args.name, description: args.description, schema: normalizeSchemaSelectColumns(args.schema as TableSchema), + folderPath: args.folderPath, workspaceId, }) diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 3940152b024..75bb5c5bdf0 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -40,8 +40,14 @@ function recordSpanError(span: Span, err: unknown) { const logger = createLogger('FileReader') -/** Inline text-read cap — exported so callers can align their own byte-sniff budgets with what read() can actually display. */ -export const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB +/** + * Text-read materialization cap — exported so callers can align their own byte-sniff budgets + * with what read() can actually load. This bounds what the server LOADS, not what the model + * receives inline: the read handler windows (offset/limit) and inline-size-gates the result, + * so a large file is paged rather than sent whole. 20MB keeps multi-MB logs/exports greppable + * and pageable while still refusing genuinely unbounded blobs. + */ +export const MAX_TEXT_READ_BYTES = 20 * 1024 * 1024 // 20 MB /** Vision-attachment cap: what the prepared image must fit into after resizing. */ export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB // Parseable-document byte cap. Large office/PDF files can still diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/copilot/vfs/operations.test.ts index b1d308f7250..1f238d010e8 100644 --- a/apps/sim/lib/copilot/vfs/operations.test.ts +++ b/apps/sim/lib/copilot/vfs/operations.test.ts @@ -54,14 +54,36 @@ describe('glob', () => { expect(hits).toContain('files/a/meta.json') }) - it('treats braces literally when nobrace is set (matches old builder)', () => { + it('expands brace alternatives across path segments', () => { const files = vfsFromEntries([ - ['weird{brace}/x', ''], - ['weirdA/x', ''], + ['workflows/Elder/state.json', '{}'], + ['workflows/Utils/state.json', '{}'], + ['workflows/Other/state.json', '{}'], + ]) + const hits = glob(files, 'workflows/{Elder,Utils}/state.json') + expect(hits.sort()).toEqual(['workflows/Elder/state.json', 'workflows/Utils/state.json']) + }) + + it('expands extension braces', () => { + const files = vfsFromEntries([ + ['files/a.png', ''], + ['files/b.md', ''], + ['files/c.txt', ''], + ]) + const hits = glob(files, 'files/*.{png,md}') + expect(hits.sort()).toEqual(['files/a.png', 'files/b.md']) + }) + + it('expands braces in decoded-form patterns against encoded keys', () => { + const files = vfsFromEntries([ + ['workflows/Elder%20v1/state.json', '{}'], + ['workflows/Elder%20v2/state.json', '{}'], + ]) + const hits = glob(files, 'workflows/{Elder v1,Elder v2}/state.json') + expect(hits.sort()).toEqual([ + 'workflows/Elder%20v1/state.json', + 'workflows/Elder%20v2/state.json', ]) - const hits = glob(files, 'weird{brace}/*') - expect(hits).toContain('weird{brace}/x') - expect(hits).not.toContain('weirdA/x') }) }) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index 325609f63f4..b22d28d6490 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -108,8 +108,10 @@ export interface ReadResult { /** * Micromatch options tuned to match the prior in-house glob: `bash: false` so a single `*` - * never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace` - * and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for + * never crosses path slashes (required for `files` + star + `meta.json` style paths). Brace + * expansion is ON — `workflows/{A,B}/**` and `*.{png,md}` are the natural way to batch a + * glob, and with `nobrace` they silently matched nothing, which reads as "no such files". + * `noext` still disables extglob expansion like the old builder. Uses `micromatch` for * well-tested `**` and edge cases instead of a custom `RegExp`. */ /** @@ -130,7 +132,6 @@ const VFS_GLOB_OPTIONS: micromatch.Options = { bash: false, dot: false, windows: false, - nobrace: true, noext: true, } diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 5e86b445895..7c36001d71c 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -13,6 +13,7 @@ import type { ToolConfig } from '@/tools/types' import { serializeApiKeyIntegrations, serializeBlockSchema, + serializeConnectors, serializeCredentials, serializeDeployments, serializeFileMeta, @@ -528,3 +529,59 @@ describe('serializeCredentials — type distinguishes reconnect flow', () => { expect(json[0].type).toBeUndefined() }) }) + +describe('serializeConnectors — cloneable references, never key material', () => { + const now = new Date('2026-08-14T00:00:00.000Z') + + it('exposes credentialId and sourceConfig so a connector can be recreated', () => { + const json = JSON.parse( + serializeConnectors([ + { + id: 'conn-1', + connectorType: 'slack', + status: 'active', + syncMode: 'incremental', + syncIntervalMinutes: 1440, + credentialId: 'cred-42', + sourceConfig: { channel: 'eng-help', maxMessages: '500' }, + lastSyncAt: now, + lastSyncError: null, + lastSyncDocCount: 12, + nextSyncAt: null, + consecutiveFailures: 0, + createdAt: now, + }, + ]) + ) + expect(json[0]).toMatchObject({ + id: 'conn-1', + credentialId: 'cred-42', + sourceConfig: { channel: 'eng-help', maxMessages: '500' }, + }) + expect(JSON.stringify(json)).not.toContain('encryptedApiKey') + }) + + it('omits the credential reference when a connector has none (API-key connectors)', () => { + const json = JSON.parse( + serializeConnectors([ + { + id: 'conn-2', + connectorType: 'github', + status: 'active', + syncMode: 'incremental', + syncIntervalMinutes: 1440, + credentialId: null, + sourceConfig: { repository: 'simstudioai/sim', branch: 'staging' }, + lastSyncAt: null, + lastSyncError: null, + lastSyncDocCount: null, + nextSyncAt: null, + consecutiveFailures: 0, + createdAt: now, + }, + ]) + ) + expect(json[0].credentialId).toBeUndefined() + expect(json[0].sourceConfig).toMatchObject({ repository: 'simstudioai/sim' }) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 5ad229b53dd..dddf4d6a61e 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -313,7 +313,11 @@ export function serializeDocuments( /** * Serialize KB connectors for VFS knowledgebases/{name}/connectors.json. - * Shows connector type, sync status, and schedule — NOT credentials or source config. + * Shows connector type, sync status, schedule, the credential REFERENCE + * (an opaque id — never key material; API keys stay encrypted and are never + * serialized), and the source config (repo/branch/channels). The last two are + * what make a connector cloneable: without them, recreating a working + * connector on a new KB meant guessing both the credential and the channels. */ export function serializeConnectors( connectors: Array<{ @@ -322,6 +326,8 @@ export function serializeConnectors( status: string syncMode: string syncIntervalMinutes: number + credentialId?: string | null + sourceConfig?: unknown lastSyncAt: Date | null lastSyncError: string | null lastSyncDocCount: number | null @@ -337,6 +343,8 @@ export function serializeConnectors( status: c.status, syncMode: c.syncMode, syncIntervalMinutes: c.syncIntervalMinutes, + credentialId: c.credentialId ?? undefined, + sourceConfig: c.sourceConfig ?? undefined, lastSyncAt: c.lastSyncAt?.toISOString(), lastSyncError: c.lastSyncError || undefined, lastSyncDocCount: c.lastSyncDocCount ?? undefined, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts index d267b508656..7e0a1080076 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts @@ -34,6 +34,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ( })) import { WorkspaceVFS } from '@/lib/copilot/vfs/workspace-vfs' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' const MAX_DOC_READ_INPUT_BYTES = 50 * 1024 * 1024 const MAX_DOCUMENT_PREVIEW_CODE_BYTES = 1024 * 1024 @@ -164,6 +165,57 @@ describe('WorkspaceVFS lazy grep resilience', () => { }) }) +describe('WorkspaceVFS oversized content reads', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function arrangeOversizedContentRead() { + const record = { + id: 'file-big', + workspaceId: 'ws-1', + name: 'big.tsv', + key: 'big.tsv', + path: '/api/files/serve/big.tsv', + size: 7_500_000, + type: 'text/tab-separated-values', + uploadedBy: 'user-1', + deletedAt: null, + uploadedAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + storageContext: 'workspace' as const, + } + listAllWorkspaceFilesExecute.mockResolvedValue({ files: [record] }) + findWorkspaceFileRecord.mockReturnValue(record) + readWorkspaceFileContentExecute.mockRejectedValue( + new PayloadSizeLimitError({ label: 'Workspace file', maxBytes: 20_971_520 }) + ) + + const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + Object.assign(vfs, { _workspaceId: 'ws-1' }) + const internals = vfs as unknown as { files: Map } + internals.files.set('files/big.tsv', '') + return vfs + } + + it('answers a cap breach with an oversized placeholder, not "not found"', async () => { + const vfs = arrangeOversizedContentRead() + + const result = await vfs.readFileContent('files/big.tsv/content') + + expect(result).not.toBeNull() + expect(result).toMatchObject({ placeholder: 'oversized' }) + expect(result?.content).toContain('File too large') + expect(result?.content).toContain('big.tsv') + }) + + it('reports a cap breach honestly for grep instead of "content not found"', async () => { + const vfs = arrangeOversizedContentRead() + + await expect(vfs.grepFile('files/big.tsv', 'needle')).rejects.toThrow(/too large to search/) + }) +}) + describe('WorkspaceVFS decoded-equivalent resolution', () => { it('resolves a decoded path to its single encoded twin and rejects ambiguity', () => { const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index fc13422e547..924f3c01f13 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -100,6 +100,7 @@ import { isDocSandboxEnabled, isHosted, } from '@/lib/core/config/env-flags' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, @@ -1061,6 +1062,9 @@ export class WorkspaceVFS { if (!result) { throw new ops.WorkspaceFileGrepError(`Workspace file content not found for "${path}".`) } + if (result.value.placeholder === 'oversized') { + throw new ops.WorkspaceFileGrepError(`File is too large to search: ${result.value.content}`) + } return { value: ops.grepReadResult(leaf, result.value, pattern, contentPath, options), @@ -1600,6 +1604,8 @@ export class WorkspaceVFS { const scope = deletedMatch ? 'archived' : 'active' + let sizeCappedRecord: WorkspaceFileRecord | undefined + let sizeCap = MAX_TEXT_READ_BYTES try { const { files } = await listAllWorkspaceFiles.execute({ principal: this.requireFilePrincipal(), @@ -1607,15 +1613,17 @@ export class WorkspaceVFS { }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null + sizeCappedRecord = record + sizeCap = isImageFileType(resolveEffectiveMimeType(record.type, record.name)) + ? MAX_IMAGE_SOURCE_BYTES + : MAX_TEXT_READ_BYTES const { file, content } = await readWorkspaceFileContent.execute({ principal: this.requireFilePrincipal(), input: { fileId: record.id, assertedWorkspaceId: this._workspaceId, includeDeleted: scope === 'archived', - maxBytes: isImageFileType(resolveEffectiveMimeType(record.type, record.name)) - ? MAX_IMAGE_SOURCE_BYTES - : MAX_TEXT_READ_BYTES, + maxBytes: sizeCap, }, }) const result = await readFileRecord(file, content) @@ -1627,6 +1635,15 @@ export class WorkspaceVFS { ) : null } catch (err) { + // A cap breach is an answer, not a lookup failure: returning null here + // reported multi-MB files as "content not found". The oversized + // placeholder tells the model the file exists and why it can't be read. + if (isPayloadSizeLimitError(err) && sizeCappedRecord) { + return bindWorkspaceFileResult( + sizeCappedRecord, + readPlaceholder.fileTooLarge(sizeCappedRecord.name, sizeCappedRecord.size ?? 0, sizeCap) + ) + } logger.warn('Failed to list workspace files for readFileContent', { workspaceId: this._workspaceId, path, diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index d3b9ffa5e37..e24033eb6f6 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -55,7 +55,10 @@ const logger = createLogger('KnowledgeBaseService') */ export class KnowledgeBaseConflictError extends OrchestrationError { constructor(name: string) { - super('conflict', `A knowledge base named "${name}" already exists in this workspace`) + super( + 'conflict', + `A knowledge base named "${name}" already exists in this workspace. Names are unique across the whole workspace — folders do not namespace them — so pick a different name, or rename/delete the existing knowledge base first.` + ) this.name = 'KnowledgeBaseConflictError' } } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts index dd13b94524e..d7637b486f5 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts @@ -610,8 +610,10 @@ export async function getBoundWorkspaceFileSecretProvenance( eq(workspaceFiles.id, identity.fileId), eq(workspaceFiles.key, identity.key), eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, identity.context), - isNull(workspaceFiles.deletedAt) + eq(workspaceFiles.context, identity.context) + // Deliberately no deletedAt filter: `id` alone pins the exact row, and + // recently-deleted/ reads are a real surface — excluding soft-deleted + // rows made every archived file read as provenance-unknown and refused. ) ) .limit(1) From a9688d01b98b61d14b227403055c2fa2ddd27f44 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 14 Aug 2026 20:10:46 -0700 Subject: [PATCH 078/103] feat(library): Automation Anywhere Alternative: AI Agents vs. RPA for Real Reasoning (#6728) Co-authored-by: Sim Pi Agent --- .../automation-anywhere-alternative/index.mdx | 160 ++++++++++++++++++ .../automation-anywhere-alternative/cover.jpg | Bin 0 -> 30532 bytes 2 files changed, 160 insertions(+) create mode 100644 apps/sim/content/library/automation-anywhere-alternative/index.mdx create mode 100644 apps/sim/public/library/automation-anywhere-alternative/cover.jpg diff --git a/apps/sim/content/library/automation-anywhere-alternative/index.mdx b/apps/sim/content/library/automation-anywhere-alternative/index.mdx new file mode 100644 index 00000000000..bc81db79df6 --- /dev/null +++ b/apps/sim/content/library/automation-anywhere-alternative/index.mdx @@ -0,0 +1,160 @@ +--- +slug: automation-anywhere-alternative +title: 'Automation Anywhere Alternative: AI Agents vs. RPA for Real Reasoning' +description: 'Compare Sim and Automation Anywhere for AI agents, RPA, exception handling, deployment, and pricing to choose the right automation architecture.' +date: 2026-08-15 +updated: 2026-08-15 +authors: + - andrew +readingTime: 11 +tags: [AI Agents, RPA, Workflow Automation, Sim] +ogImage: /library/automation-anywhere-alternative/cover.jpg +canonical: https://www.sim.ai/library/automation-anywhere-alternative +draft: false +faq: + - q: "Is RPA the same as an AI agent?" + a: "No. An RPA bot follows configured steps, rules, and interface actions. An AI agent interprets context and chooses among available actions. Modern Automation Anywhere products combine both, and Sim combines Agent blocks with deterministic workflow controls. The useful distinction is whether a particular step should replay a known procedure or reason over variable input." + - q: "Can Sim replace Automation Anywhere bots entirely?" + a: "Sometimes, but that should not be the default goal. Sim can replace workflows that primarily interpret documents, messages, and changing requests before acting through integrations, APIs, or MCP tools. Automation Anywhere remains a stronger fit for stable desktop automation across legacy systems, especially inside an existing RPA program. A hybrid workflow can use Sim for interpretation and Automation Anywhere for the final UI-driven action." + - q: "Does Sim require coding?" + a: "No. You can build through Mothership in natural language or use the visual canvas. Technical users can add functions, call APIs, and expose workflows as services when the process needs custom behavior. You can begin visually and add code only where it earns its place." + - q: "Does Automation Anywhere have AI agents?" + a: "Yes. Automation 360 includes AI Agent Studio, Document Automation, Automation Co-Pilot, and the Process Reasoning Engine. Sim is not differentiated by merely having AI. Its difference is an agent-first workflow graph, an Apache 2.0 core, public entry pricing, and deployment as APIs, chat experiences, or MCP tools." + - q: "What does Sim cost compared with Automation Anywhere?" + a: "Paid Automation Anywhere deployments are quote-based, while Community Edition is free for eligible organizations with usage limits. Sim publishes Free, Pro, Max, and Enterprise plans. Compare the vendor quote and published rates alongside model usage, hosting, runner capacity, infrastructure, and maintenance for the actual process." +--- + +## TL;DR + +- **Choose Sim when your process has to interpret before it acts.** Variable emails, changing documents, exception-heavy queues, and workflows grounded in company knowledge are better fits for an agent-first graph than a recorded UI script. +- **Keep Automation Anywhere when the process is stable, high-volume, and already governed as RPA.** If your bots run reliably across fixed screens and predefined rules, replacing them creates work without creating value. +- **Automation Anywhere is not “RPA without AI.”** [Automation 360 includes AI Agent Studio](https://www.automationanywhere.com/products/ai-agent-studio), [Document Automation](https://www.automationanywhere.com/products/document-automation), [Automation Co-Pilot](https://www.automationanywhere.com/products/automation-copilot), and the [Process Reasoning Engine](https://www.automationanywhere.com/products/process-reasoning-engine). The real difference is architecture: an enterprise bot estate centered on Control Room and Bot Runners versus an open-source agent workspace built around reasoning, APIs, and tools. +- **Sim is easier to pilot and own.** Its [core is Apache 2.0](https://github.com/simstudioai/sim), [pricing is public](https://www.sim.ai/pricing), and workflows can deploy as APIs, hosted chat experiences, or MCP tools. +- **Do not migrate everything.** Start with the queue generating the most exceptions. That is where reasoning has the clearest chance to beat another RPA rule. + +## Is Sim a good Automation Anywhere alternative? + +[Sim](https://github.com/simstudioai/sim) is a good Automation Anywhere alternative when your bots spend more time falling into exception queues than completing the happy path. + +Automation Anywhere's [Automation 360 platform is built to create, govern, and run enterprise automations](https://www.automationanywhere.com/products/automation-360). Its architecture centers on [Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/control-room-overview/control-room-overview.html), Bot Creators, and Bot Runners. [Bot Agent connects each runtime machine to Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html), where teams manage access, schedules, deployments, and execution across attended and unattended bots. + +That model works well when the job is predictable. If a bot always opens the same application, reads the same fields, applies the same validation rules, and enters the same output, RPA is a practical way to automate it. For a broader framework, see [AI agents vs. RPA](https://www.sim.ai/library/ai-agents-vs-rpa). + +The problem starts when the input stops matching the script. A supplier changes an invoice layout. A customer describes the same request in a new way. A policy exception requires reading three documents before deciding what to do. You can keep adding branches to the bot, but every new exception becomes another rule to maintain. + +Sim starts from the opposite direction. You build an agent-first workflow that can interpret natural language and unstructured documents, retrieve relevant context, and choose an action. You then constrain that reasoning with functions, conditions, routers, loops, and human approval. Instead of pretending every case is deterministic, you use fixed logic where the rules are known and reasoning where they are not. This combination is the basis of an [agentic workflow](https://www.sim.ai/library/what-is-an-agentic-workflow). + +This is not an argument that Automation Anywhere lacks AI. Automation Anywhere offers [AI Agent Studio for custom agents](https://www.automationanywhere.com/products/ai-agent-studio), [Document Automation for intelligent document processing](https://www.automationanywhere.com/products/document-automation), [Automation Co-Pilot for conversational assistance](https://www.automationanywhere.com/products/automation-copilot), and a [Process Reasoning Engine for agentic process execution](https://www.automationanywhere.com/products/process-reasoning-engine). The decision is not “AI or no AI.” It is whether you want to extend an enterprise RPA estate with agent capabilities or build the workflow in an open agent workspace from the start. + +## Automation Anywhere vs. Sim at a glance + +Automation Anywhere and Sim can both combine AI with automation, but they make you operate that automation differently. Automation Anywhere provides a centralized RPA control plane and bot-runner estate. Sim provides an agent-first workflow graph that you can inspect, self-host, and expose directly to other systems. + +| Comparison | Automation Anywhere | Sim | +| --- | --- | --- | +| Core model | [Automation 360 combines automation, agents, and document processing](https://www.automationanywhere.com/products/automation-360). | Sim is an open-source workspace for building agent workflows with deterministic controls. | +| Builder | [Automation Workspace](https://www.automationanywhere.com/products/automation-workspace) and Bot Creator tooling author automations managed through Control Room. | You build through Mothership, the visual canvas, or the API. | +| Runtime | [Attended and unattended automation runs on devices connected to Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html). | Workflows run in Sim Cloud or on infrastructure you control. | +| Reasoning | [AI Agent Studio](https://www.automationanywhere.com/products/ai-agent-studio) and the [Process Reasoning Engine](https://www.automationanywhere.com/products/process-reasoning-engine) add goal-driven agents to the platform. | Agent blocks interpret variable input directly inside the workflow graph. | +| Document work | [Document Automation extracts and processes data from business documents](https://www.automationanywhere.com/products/document-automation). | Agent blocks can interpret documents, retrieve knowledge, and return structured output for later blocks. | +| Deterministic control | Bot steps, rules, and exception paths define execution around applications and screens. | Functions, conditions, routers, loops, and approval steps constrain agent behavior. | +| Context | Enterprise systems and Automation 360 products supply process data and governance. | Native Tables, Files, and knowledge bases keep structured data and retrieved context near the workflow. | +| Deployment | [Control Room centralizes orchestration and management](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/control-room-overview/control-room-overview.html). | Deploy a workflow as an API, hosted chat experience, or MCP tool. | +| Observability | [Bot Insight provides analytics for bot operations](https://www.automationanywhere.com/products/bot-insight). | Block-level traces show inputs, outputs, errors, usage, and cost. | +| Hosting and license | [A proprietary platform with cloud deployment options](https://www.automationanywhere.com/products/automation-360). | Sim Cloud plus an [Apache 2.0 core](https://github.com/simstudioai/sim) you can self-host and modify. | +| Pricing | [Paid plans require contacting sales](https://www.automationanywhere.com/company/contact-us); [Community Edition is free for eligible users](https://www.automationanywhere.com/products/automation-360/community-edition). | [Free, Pro, Max, and custom Enterprise plans](https://www.sim.ai/pricing). | +| Best fit | Mature enterprises running governed, stable, high-volume automation across desktops and legacy systems. | Technical teams automating variable work that requires reasoning, retrieval, APIs, or agent tools. | + +## The architectural difference that matters + +Automation Anywhere separates authoring, control, and execution. Bot Creators build automations, [Control Room manages the automation environment](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/control-room-overview/control-room-overview.html), and Bot Runners execute them on connected runtime devices. [Bot Agent connects those devices back to Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html). + +That separation is a feature if you already run an RPA center of excellence. It gives IT a familiar operating model for roles, schedules, devices, attended automations, and unattended automations. It also means a new process may require more than drawing the flow. You are managing runtime machines, runner capacity, permissions, and the licensed Automation 360 components the process uses. + +Sim removes the bot-estate assumption. You build a workflow around integrations, APIs, MCP tools, and agents, then deploy that workflow as a service. If the underlying application exposes an API, Sim can act on the system directly instead of opening its interface and clicking through it. Workflows can also become reusable tools; see [how to turn a workflow into an MCP tool](https://www.sim.ai/library/how-to-turn-a-workflow-into-a-reusable-mcp-tool). + +That distinction changes maintenance. An API contract can still change, but it is usually more stable than a screen selector. A button moving or a page layout changing should not break a workflow that never touches the page. When no usable API exists and desktop automation is the only path, Automation Anywhere has the advantage. + +## Where scripted RPA breaks + +A recorded bot is strongest when the world stays still. The screen loads on time, selectors remain valid, fields appear in the expected order, and every input fits a known branch. Real operations eventually violate those assumptions. + +### UI changes turn into maintenance work + +A renamed button, revised login flow, or changed page structure can stop a UI-driven bot from reaching its next step. Automation Anywhere provides [centralized tools for operating bots through Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/control-room-overview/control-room-overview.html), but the underlying interaction still depends on the interface when no connector or API is used. + +Sim avoids the interface when the target system exposes an integration, API, or MCP tool. The workflow sends structured requests to the service and receives structured results. You remove an entire class of selector failures because the workflow never clicks the button. + +### Unstructured input does not fit fixed fields + +An invoice, support email, or procurement request can express the same intent in dozens of ways. Adding a rule for every phrasing turns the workflow into a growing tree of special cases. + +A Sim Agent block can classify the request, extract a structured payload, and retrieve the relevant policy from a knowledge base. The next blocks can enforce exact rules: verify required fields, compare a total against an approval threshold, route by department, and stop for human approval before payment. This pattern is especially useful for [AI agents in procurement](https://www.sim.ai/library/ai-agents-in-procurement). + +### Exceptions become the real process + +The happy path may be automated while the operations team spends its day resolving everything that fell outside it. At that point, the exception queue is no longer an edge case. It is the work. + +Sim lets you place reasoning at the point where the fixed workflow loses certainty. A confidence check can route unclear cases to a person while allowing clean cases to continue. You do not need to let an agent improvise the entire process. Give it the narrow job of interpreting the variable input, then hand the result back to deterministic blocks. + +## Where Automation Anywhere is still the better choice + +Do not replace a stable RPA estate just because agents are newer. + +Automation Anywhere is the stronger choice when you have high-volume work running across legacy applications with no usable API, especially if your company already operates [Control Room and connected runtime devices](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html) under a mature center of excellence. + +That operating model matters. Your IT team may already have device pools, runner capacity, access controls, audit procedures, deployment gates, and support ownership built around Automation 360. A reliable bot that enters fixed data into a legacy desktop application is not automatically improved by moving it into an agent platform. + +Automation Anywhere also gives large enterprises a broader RPA operating surface. [AI Agent Studio](https://www.automationanywhere.com/products/ai-agent-studio), [Document Automation](https://www.automationanywhere.com/products/document-automation), [Automation Co-Pilot](https://www.automationanywhere.com/products/automation-copilot), and the [Process Reasoning Engine](https://www.automationanywhere.com/products/process-reasoning-engine) extend the same platform instead of forcing a separate platform decision. If your priority is adding agent capabilities while preserving the existing bot estate and its controls, staying in Automation 360 is the lower-risk move. + +The honest dividing line is simple: keep the process in Automation Anywhere when its rules are stable and its UI dependencies are acceptable. Move the exception-heavy part to Sim when the process needs interpretation, retrieval, or context-sensitive decisions that keep producing new bot branches. + +## A concrete test: invoice exception handling + +Do not begin with a platform-wide migration. Take one invoice queue that already creates manual work and run the same representative cases through both approaches. + +Assume your current bot handles invoices from approved suppliers. The happy path is straightforward: open the attachment, read known fields, validate the purchase order, enter the invoice into the finance system, and archive the file. The bot works until a supplier changes its layout, references two purchase orders, describes a credit in free text, or submits a total that conflicts with the contract. + +A practical Sim pilot would look like this: + +1. **Ingest the invoice and message.** Trigger the workflow from email, file upload, or an API call, and preserve both the document and the sender's message. +2. **Interpret the variable input.** Use an Agent block to identify the supplier, invoice number, line items, totals, purchase-order references, and any free-text explanation. +3. **Ground the decision.** Retrieve the supplier contract, purchasing policy, and known exceptions from a knowledge base instead of asking the model to rely on memory. +4. **Return structured data.** Require the agent to produce a fixed schema so later blocks receive predictable fields. +5. **Apply exact rules.** Use functions and conditions to check arithmetic, required fields, duplicate invoice numbers, approval thresholds, and purchase-order status. +6. **Route uncertainty instead of hiding it.** Send low-confidence extraction, conflicting purchase orders, or policy mismatches to a human approval step with the source document and the agent's explanation attached. +7. **Act through the system interface.** Submit approved invoices through an integration, API, or MCP tool. If the finance system only supports desktop UI automation, keep that final entry step in Automation Anywhere. +8. **Compare outcomes.** Measure straight-through completion, manual reviews, false approvals, time per exception, and how often a new input requires another hard-coded rule. + +This test does not ask whether an agent can replace every bot. It asks whether reasoning can shrink the exception queue without weakening control. If it does, keep the stable UI work where it is and move the interpretation layer to Sim. That hybrid is often better than forcing one platform to own every step. + +## Pricing and licensing + +Automation Anywhere does not publish list prices for paid Automation 360 on its public site; prospective buyers are directed to [contact sales](https://www.automationanywhere.com/company/contact-us). The total quote can depend on the environment, creator and runner requirements, and additional capabilities. + +[Community Edition is free for eligible users](https://www.automationanywhere.com/products/automation-360/community-edition), but it is not an unlimited substitute for a paid deployment. Under Automation Anywhere's [Community Edition terms](https://www.automationanywhere.com/terms/community-edition), eligibility requires an organization with fewer than 250 machines, fewer than 250 users, and less than $5 million in annual revenue. The terms also limit use to five machines in the organization and include up to 100 Document Automation pages per month. + +Sim publishes its entry pricing. The [Free plan lets you start without a sales process, Pro costs $25 per user per month, Max costs $100 per user per month, and Enterprise uses custom pricing](https://www.sim.ai/pricing). Model usage is credit-based, so include expected execution volume and model choice when you estimate a production deployment. + +The comparison is not as simple as one seat price against another. An Automation Anywhere budget can include platform licensing, runner capacity, runtime machines, and add-on products. A Sim budget can include seats, model usage, hosting, and any infrastructure you operate yourself. Price the actual process, including the people who maintain it and resolve its exceptions. + +## How to choose between Automation Anywhere and Sim + +Choose Automation Anywhere when: + +- The process depends on legacy desktop applications with no reliable API. +- Inputs and screens are stable enough that scripted execution stays predictable. +- You already operate [Control Room and connected bot runtime devices](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html) at scale. +- Attended or unattended desktop automation is the core requirement. +- Keeping new agent capabilities inside the existing Automation 360 estate matters more than adopting an open platform. + +Choose [Sim](https://sim.ai) when: + +- The workflow must interpret changing emails, documents, or natural-language requests. +- Exceptions require policy retrieval and context rather than another hard-coded branch. +- You want to combine agent reasoning with exact functions, conditions, routers, loops, and approvals. +- You want an [Apache 2.0 core](https://github.com/simstudioai/sim) that you can self-host, inspect, modify, or run in an isolated environment. +- You need to deploy the result as an API, hosted chat experience, or MCP tool. +- [Public entry pricing](https://www.sim.ai/pricing) and a fast self-serve pilot matter. + +If openness and infrastructure control are central to the decision, compare the tradeoffs among [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). The best first project is not your biggest bot. It is the workflow with the highest volume of manual exceptions. That gives you a measurable question: can Sim resolve more variable cases without adding rules or increasing risk? diff --git a/apps/sim/public/library/automation-anywhere-alternative/cover.jpg b/apps/sim/public/library/automation-anywhere-alternative/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..506fa135f9cac54f4a537908e9abc3280795d914 GIT binary patch literal 30532 zcmeFYbyVCR^HYDq5!DB3p6woEZqAG5dj_^0TBZU`Td1KjE;r= z{wBsJ#m9aBuuzeaQL%6{F)(q92?&U3C~G)6CjFTN?Em*9eE?J#@O;P`2rx1LI4T$f zD%jfqfB*mn27my2FMxj(=nqgZkPzU1S~33V@xL{{tpN}qzyRPV5Ge0Y%KiQ^>VN(X z^MW(^KOP04J`=&qOn;on4gf2{olqOMp*Gm^YDr_fA}vXs-sZD}80~}^FIT@ytegJn#P!Yyc(3nVhI4FE&DNvmx?`r*%5h=CtA*GGYZ$y+a~rne1Vb1Yy z&V5|6mQ91xn@i#;E^PfD!hp~cRNfD?^Lxh&sySv8ij4{{y8j$c@ca>h$kFJ^Z}#p_ z(|^AV><8D@`~CO(|8LL#rNIAE;QxyP96vnn7rH}ad|fkB0010>XP10SipAJ}8%88# zQRmV0YJF@>Q8^W3fB8pO{3$GAr?};>(b12`UO9g~008iyLv|wRzyDk9U%$|7viZ}Y z4?UmL8htZ(39e;MVpS4b6s(47i-4-1DybSrrisogl@Z%DZu{i3_Zz%|Z>Bf`gqZR_ zh1-{(O%Qif1kii^)*c=8xf!<_!g8%rMzs8VDTnkeRI>riqxl3g-Y#4DM-a42n*DGv zsC;G;oluuP zRkW=dA)Ol2?{ox$CbMF8Q=M7j{Ppzy)51Pi@x#2t&OXMyrzfF;6nZ2tVJ$AI`yrq(3J=KBfTztRMqu&5|_J>ur6&F;UEnbBoq79iV|4I-ACBh~OZ&A38vizu*ps`%mOtB9cm#P_#s2)H4P5QzxDL_xEsioZL-6b?H)7^Uk zZmN);7UjujLLvY*{Q_mSlg#Z=GrxZWK-8gYIkx=t_|ReYc{N(=2v%P~%b4Mk&p7Do zcFsqUQFAq)1q(_LK?MR-Q;w88?Kf2ArDm)dbP7qa#;Z;Uxt@iv2HE=c(o)cF; zYKo#vAbw(-tEub!xaJ0KryTx8?v;t_JLBakQij!{ltV*_gr|ffQ1Hlb^PNc8uK!VsYGSwzoLsEhEiXuGO6fO+AZZiyY#e#iZb-@j1gzo;T` zTBw+I5s&pI{}-M9?>B*ceZ~ESz>;cw^-r*Ix=FMDu7W);MWu$0`{5|grUD>v%+{uQ zy#4;E=fD1h7(bZ_Gdx^>VDeerN00<6&mSAa&V?beGRaXZBDVfXD5Jetjc z2w{!LQ2J|}!*9{T6#m*94 z^A&Y{n|%x6+HH_fFv$I&TBgmU8KJ;^S+F&2XL>#B2AWrgSnpdFfrQD7&0|m=qtUDO zy7lDxl!b+h%WaSlH-JJ+)3@iU z5NDxUxgqz(%TCzJ6#d<7hXIYmB8Q>{=!)9qcEEs4^M=V*dm>q0I5`V>6#0;JWF)0SCR z$0W7F)N|#4InVhL!V&gUor}H8sv#B+Nt;2 zATy$iD!YFs4&WJ`5dI>G6N}x(s|y>>MbE91R50-v(fyG3EWE`Yh3-tM*ZU4GbvoLA zVOnl#6`hk8IG04`HjND%B&eRqt6Iy#U+>py9h6~HPTzxI9 zsJzjpa7k0G7hT}}Sm94T?j$)RZZ7x!SQYSqg6~6aAy^^Qas!4?)q~Kr8TLrj3E|S0 zV7Er*y=~FaNKG>9Nn1Iop=uecZ!mk{edi9b#cI@uQE_Vt?0n;b-foi|=!`Yf}@# zzHahz*XBa)ER#>J=HmHqA2IcGMC{hqcx=(4n|Vn8UKb z8AWOYV~s<{)y#^DsOYaVs>-s(+M(|-M0oqroCGP>Dhjd7lhBCGUQt-R7ta~H+Q48l zbVP;ZpPVEdnd$c+LlK)NT)G?kZBZ-e{w$`oZ^xU0hDtEWbrAuap>`o~vfWLQ-=H=d zGbeKUE{M@^Ggdk%PTIe%ATla`UYgt^IMjLohKxbat>xx8q-4$Q)g({)sC8<zfI`pp4Za;nR*kV{jdCqsxvEiXFwNtO;d zh4cxky9N`$bCvV(YZCYd;AP>Q@g#CHs^Gtz+3`=uGUEereaFK}(o@zEsl}8S7Aqb8z$|1*)W3#(-T&If+klFOTzK zC;W%Sg8hDcj%0{biGa> zA$_l>Mp#XUU9tCOH9DOjdd;YQ_|Po;-LG03^?8e#`JmeK9b4 zwvE^NBT(sDCE~6h5os5DYa(zx*VS~m;0!PIw&m(jB=#l1a0Skf;9=&=y`5Kw(6zP& zH(}26$}3(k{u(Pi^{XvL*c-P+E71b!8G0B zY*mn*WwPm^Noj?G<5;`J(?&F{8f6{!AWh{UM5O;+pTJA}Rvi)bPRT??YyhR!Ts$zu zhMjamOb?#&1Wf%0!T=t;eI&WG@|(zwzg~Yidr@lbyZ83k&g0o=veT(r0J}ddTX1|} z5>f&0wjPcypXx;L2Fq2jnvc)f!+fDw0A}plal&GPQvlPuBi1B6CDYr)07%xgl0b<; z@S}(0=iDprUo#xxfmY>pJP-yGa@J6_`TJ~8qJy#tcy}UK!y>5i|A!_H?Y99EJco{} z`Ia(@^{;Us<>RyzBg_*M5Dd3fIpptPjXuuvQk)$-Fpa4JJ-42+d4439?;}IEb8zvJ z20WlwYozN6syqgE9QlDV-Ng0z=VkO}j<~)1h=2{Ph4i!gH?ST)hYE?I7?nwg-S}KW zz_n4&sdpd4e;|qAyXAb7*&#$+i#WC{84f!Op}pz{`|)=Rz(c?^tK3ToLMnT+|KSDg z1v)>3+rd@jVX>)pIui;2_SZ0>T;!4E_samm@#QfpS;Y%fDaDcTlx~m<=tkB7HRFZ^ z5U4-!>IZWkDrT3Rn?J!z0J2!NBGS|N0(t`kDbJi9q{M^B1_O8l{8SIVu#q@SC}9ABbf^m70J)sw zmNau(W8My4)0GzkNJm0F2D+_a3EnMjF4_R^Dn z1-}F=1cF1HfkGTf){N?7W2R(MZk%8kBsOckj4ln$=5iY?KcQ{=QV}<>cd*4b87rlw zrKWxnX_7v~+bvsomCB5b9O%nKObLp}@?Q_hrdyP?#&Jq&gDRb;B#o_eHrIM|sz7OQ zTO?8+_QLpNb>&l6VW5^S0Q|9uOA6{N=;dCmQX^%&`!`R+xrbo`gw<3~Te)?_9|=vn zFWMQ$Xr0X7;f{J|>7CXV7AY0Y@g2Rs0Z_#GdB1}4bqi{6grA!h%d2!E z%`ck1Ecy~x9#<{GmDRdSrVl!**1?0#(+(P&vt}o|_BK?SLF&!v`{4;)r2o_WwmLJV zg8|vT=V2hJi~Ohavcwh@S?1SN=ZwN{h~s5pCa9HLXAsR}4Z7`B}SmO1n-Q zsVNB0KCrfjGKtXBo+y(T11@v)A)`D4%kBSl+L!+{>l@(577c>-r&&*i%r>bB?(9&} z2ch$>r@QIz@k=KlMJ;CZf6(EGcoka_RjB@|zx#stqFvzD8k6Y58zAn9f2lE7H3C}b zIr=2QL6IWCMMi3zXVP21+<@(v`!-eM$r9Lj6yC^8O`JpXfb#=2(rG*m@Cv?xa* zX5rC3TUhlC(5a_d?ouV}NocGiH_`eAc$VbkiyAVpoh@wD7irvEVhc0H9e&2rr8Jkt z(Jt!@Zu>p}mpw1|i%yLW=Yc~RiaBZqq$3Q4!w(IhZiG4OK-HAVDs+YDiSXqE23vhcrcZjZaUN*hVG zUxB3`Pkfrp+lwX;U#w6^mvw%!P)R1z#ND`vp+5lM-~^N1O(Pu9p!Bbb(+(Y_7iw77 zrdG!}T)OKhHLKDf8+CAN_k6O%gvxb)nQrmyG60sU?Cy>0R_>xrCoyyzVbo@+I)GU? zeMn-9+{GQNr&Opt5P!#sXv+~J!M}dJ33vKnU*r0F7>+h%K6kLOclt}nko2;sZuK2q z5JcvW?zy$W66;%{;63JK`KH&Q%pj;j!GmMMXnJ6CI(nRzu&z%JjVb@KTdkffT&*qV zpx&y=1sJ?I{Q(W3|!kfALoDc zHt8+rEn!`pJYmlph(5S<9w^*HwqJp!w@hg7S)l;4eC0mC>tPw$6?1LEqlz&+wyB$F zPJ08WYeMDKj+6t8WLUW{^}k*H5x!q{w|2Y^FPZi#ci~z3%GK^92v3Ak))izWu4zj8 zO^!tMiV~IIc^Sf&S5FZJELm~lGEUf)Mo>7?*C?&UnIz_lrQ_+n84nza*{2(Qgv{px zR7^Is%$Cxq0Hp{qG{@_0mLrDO4~ld<5s=6_I}3t-XFtY64|-FJ;^h$Ym@VKWtQ#CL zjfuv#B~{>3Ztka?GAuFa4~BBdr>+zwsw#`cr{^EHC@RpDU5y)kM9im=-T)oWCu1BO z6Dt-iW$U88Z|lnOGmLX~(7_dXy54b!wZ#2#kx3NX$OfXvvQnG*?ZkDCLTFRNbX+57@8 z=?dv@088D3&L71^cUwD?5UqUk1LTL4mCzjKIg7thvgKc_qV`gU(!K=0XzkiO`-~Sl zey6>bL=u7tJCq~@I1Z|s4RakyaF$Ku34$V4TpFEIm3KMi9gbpn`YJAxiU#tCY`SBX zuKrSqYuzMuhatXOJU>vc{4^ji=tRcTzAF}o(n+FM+2Ymy?!0lS^<8D~R7Qd*EQ{o6 z>LXtP))FHNdCs40&83E5>2REj)0XJJ&i^~`d(c>^%3)daV_0h;wFzGTekLGj?3*wytzE|kA~pnU^y z&LvmnyWyZxp+(j-*8ANY5EHTz9a^NNQje5F5~MZ`xUR1LU`g1=8S0eE3lAS2AU@E% zl1GsBsWP9;Vo5~Iq8yuAHyjTbE!TyRi7{uuK_KBR_9wRrqUJOiJc5gcH zi``GoO6IB`QR~ML<&Ny5=vLA@Jb}+>Z_QjMH%cQlhc6W+-gSFI)^~9qK3fC|bRApE zrZaihABJStThIF@v8Ysd$ZpYkI(YvinBj}>x%Qn=Q@HQll%S3Q_RomTLs&~s#iyFKHgx81FC3n zU?P9y$cnIWEB*e8wh=bGhjG#0w)Xmikf2}j;S9!e*&kCfd#LYt9pw*@ex$0x6CKKtJySMsiuXvgCPl+MyNK@1DZ-7?HKZZg2{UL3mW%!U$W;lH;Q$bIS z(e}9&MXg^o2euj|EBvs-5U6V9TGuk)jQJzB8DcQBq0O`ZcoyLs!2MuWlTIfd!6_kx zdyG9@-`jsTf?;!!j0a^oX`r=+TxI5DpPXnS`6X_-kjb+2xWz}(bQ;;$bH2P1krLM` zgawC1L0{8ALLH2+t5ng!Gf;z7W{EjISETH+US>0u(7dP1@YeixE+)IVPYD^d9Lm3eZF-EVT1G= zDD2w2O?uK%asJr0lFZJHj}HfkjO7f|5c-?)1GIq?Cv+90cA7j_z~u->l1$Yltx6iP z>#$}zo7UZQsAO`CsKvVYuyGbMVV3>hP{}fOr9DezD1F>8y38XsbTG*4^etIzn3zKz z=DMbQ*!zSy&<2H}#OZT(wI&%~iwceyci8x2=`naKbWWNY3$n-e(hnXyw99o@75G~g z&TOOTtBVW1J7}g7syR^i71k|PmPc@p!!@Q_a4$kC&5*F3jquy9!^V74PwZTuJ{T&rL%}X-P z)re=@z>3K6QRFjhdOFO3jx6;%v08O;KefV)?rGF&mDSr7uW3%HK<==Nv!uDYA&;YK z>MrbPayK0^oK}3av*xxc&-6Sb^GT;F;?o;#Bq*!>q#dCYI7%;p$s1G{xI>=fzgQNg z-n~iKT~fvGx3ydYx7Ul-mS_RA>!58=>30j?V#ZM~w0N9F9pg3iscHUCFDG5L`nUCu z0!{rhDz(3V2luZt#9In{Rvf`Q2-`Zmv%4i~JGzWv*F{#=Q9FF0ni`$YcRziR^?Eh^ zQuA9GoViRwLxZL?f}+^q$wpSB<_gQE%ljXoIY1g=by&Iy7V~V;g&>V6e(*63DXoXB z_z-R^A{poN$NR#ROZm8Ot0rJallw9jDL68t@WgC$TuvH+^-#*SG9%8kcKVAH)?f+e zwiT4z)IW#i4S5j0uT$eBk>ZS_4N($BZ!Rp$ve@t?b!I%XX2xNmP9*GdCDNoB8MO|C z%k8SSm&qhLvS#{gpk^%8I+rSb+`z0`j_wI8Z?e+#@TuUsp1?3hmITgJjr>}>Yed8Y zK;zn}g&foi3>ya4M^kz$8qjzMiJtD7Fn*L0P} z6E^45IfYe#a{-wYep2~5++w?zJU+OpU;DlMvRPf&0HnNfbV%LDs6}bOkJ@3cb8G(V zM|8>k^CIXEd$@UxB~x#KKR+f#_;G8)Ob~I^%@))5l{Pq7gk;-V>J5p%mxYU^%xJb9@f z&yh>iH@U6+W|LLg;N-_Z%=^JMfVa?OQsubJGui)}k4RIGMJv-!V+urnpfbZ}+p#G` zVjHg26uRc$r`*Jn3Z*;&N*R~yq@9dzcwTQZDWzW~BW0d-0>ca6gBX>8NHyu{S!Q@S zXoAjK3K;7C;y=X}`Hlo*7EVfoc#6K{FW)~70rhA9=JYtCri6k}S!NswJYeslrrDQ` ztea$sKo<=DWn53M58q4CueeVqSF$9nbk27|4n%@d<+@byf+tiJFk^JlOv>s>US2QK zWGmYOK(^(;C=M;N$c!jvH~7NkM_%_dU47r?O}5Y@HzLtP8E>wCla$+wTVj}EeYp1{ z3e+SIJXe3+k(n@k$8g9=>XrUv-nOtYg%*|0qORU~F^sU#7DzbcTsrU5k$HFYD`%p&;`pwKcu)hJL& zwZNWY_FTs*Tf?VgTsfo5nUM-b?NGo? zO>ineL@%$_!P{y8rWb7*9@)pF95Hz)G$gdvbe=E#%*OenZ!voi2}z7})0-K!zriJS zf{f^KEv?T^YcK87E+0Wd!eIR0L*G3kAAF6^6B${em2%G$X)4nn-`8I1^aZ3-b4Tk6 zpY^vn*)Kr&t}EvFP#ztSN7MD_q6AwxGszhr7ap8>*JW<8KhhAy-%ztL{9zGgHMHY< z28?6cSS%>8K#?IwhWX!6tz`>vYttzF&|sgKi5_%tY4LHWrC|%hh_tP0frm!Cn~>I6 zRB-*HG?Due&-rwpsX{{HrcCXKf?sw)vd`WIS!BMJ2jveR96rLtp$DGDC3>BgGN4{4 zYp9!DDT#CHq{6bH1ZCA4o-d3c>FWOWIj*wiIQ}AQvR$c|u4H>=gF6kb?U%>A@=$TC zxc7qUHK|ZI=8_8U{ka{Ptc6CRm1l*&EEeZGX{6&;`HQ$!hJWNY&SW#&44~gf?0@GD zOYR}IjLq(JK1sZTuZF+F#$kW|?&e6UqKg9W-}y*aJo&OJKZrKetd8%cR3!pqOaMi- zpua!Tj#VO4H`uWMhdX#0yJ;Ua%mHwc%%XU+i|Qqmnm46aKzVhBVoH7?XJzxf`p#P^ zXOwRraU`;GZCs(iv~u9tCrS@tkwAeB{uVBx(Vvlr>3xO-efi%2g1KHQomO+)Bdo*d z_-mP_OJr`2fzU90gj@vz;zsLHrghcc61%t(PLaFaoo@g&rRbuN>NZ`pbwS_aiY^6> zGxn-LWrXi+NJ^P*#kzVA86o!qkNt$~JrSfM7Zjh;CeN-}7!g*rK^^1GH+-bJk3-(ARe@^C9XFH@JHsSh&rf zw)qGz^9#P3hsbZ&qJMq)e!aqlx{PH*w*S(~Z(z)v&W zJ-Pnlz~nF-q4RVY@c@=Rvk!L?&V(?WUy#+%N(3Q0zsu-l$Cx}D4OHFe*MXc{-Dod{ zVS%LGgaDEcLqCz}bJypJwhsQvBj!x;u=vJP5Ezh*pQbzmiSdK~b&grwY zGl8zejN!!gB3o2#7N1=F2z7%B7JneHUxwCy2{hvfw{u{DWu_HIC1$bosYUQqzF$A)( z=ngdf$MGAA#6dq$?kbWEUfUO*ff^YQX{##VugE=PVOIXm*ZA@ev;UtN&@aW;G{ngQuGYB99PqcdGH^sx+H4 znK)EIL>UL>6qO%&NYWD-MzIz>shz$had`T>N=JJ&$<%+>k+Xah4=p}CrnibFjfrM8rtRp-?2^ zYNW*zi6zzX%a}`%(CAG8Y2qQ}rPsgzvBidEVn6A{c`N;=ve^9Fss@dnPr+=h^6nBY zzga@7tq!SgRRQ>go6_|gOA27;7dRX@03S5aY{86v5`SCyj1W)1oc{-MeBzarZ;JK_wn(f{Bsmc(ofE1h>(KD5ATDUowUMhoRTuh|KWTQ_TH ziAnPI3|*EV#%+!?mbQku5pwgudk5r z`iE?n(<6#c_39W!)w#jvFF)#(vN5&?`R2B==jX3@>Ppkm$;{{!m?PUNor|Ku)3yD` zCYBTBGtv@^Ascjh;S>DB>uhRv**YaaIWGo2YJD$3K- zRj?yCYKZo?kL79#dG#n<`h)MxXg!?viKuFQK{h=P%btp-ptWNyrf_o2N>EzYt7g9NId#VrN_(b1u^7K(|l_r;HtYlq{$YK&6I}QB6z1-ZKr9R~(9&1@cdw%gc zch4sCOFU0XEy}Q(b4t^bC{n?A% zv?$vgM{mZ)f}a!UxB^6t5`IIxYqlI3bEAkt$fT*hzZ5@JA=FYO>%oPCLD&v9YU}tG z-*glfejtGZxYW+a*E#+qO4d<`ET;B6#Cf+R$Av2<3Nuty!xNnu@%6>&ETwj=Z`6*{ z$3>2<4dKPYGu{A-r|y6#qd2%`avgeJ8YD^sxDGf}_rEddzru}<|8T;<; z?)z{?KCv9k6#>UtVN{2{fN)g%^}a1zAjC{fgj?ky0_{%;nFv?N3cjyR8-inrc_nh zfK^DrLB9c{R%X@Ld>2cPFg>O~AFgGLWzlF$%VKLVeP37^L8xe2kNu{HOZdcsH7oZF zYxmAW=9*oAtI!H#ZZy;kVv4?qAmYDUA8vw&d+qP~lZVuG=OU-bd~pYABX?ss*;UQ+ zEFD+svut(M<27=1`pH3mn1m5(=T`*ggRG;a^AlzG-awQMwGGB*PIw*j%6OgkB07T4bZ zL&`znXJ9pv-(Q;@>pd$mW#aJlAF|}c-T+h%t6cJ4Y6iy$4T|%7_sb#k1C*3_YRAm# zE3*t?aaa{i-c9|5SUHW2R(jdTzrTR`uRfv0S%PV>fZL(1gy5daPu}*ANxK+>O{7kd zkjYWvN*e4DOKa@hI>@j?}`^Am7t7t!WrUz10)+PVz-GhpkF@-a_n5! z!b(dd^2%YlvCFC$4OTEcr-$sh@&CHJs}vlsy0n|L)k8eP|K_V0zw;~*GM-Uws<)}j zs$I#rQKr?4`v%ahQ%Sm10@zqZRm}}gKd||XwpnGyd4Ik~#-dTgOEM=6Zr`z72l86` z#*{C6`R#m0=$DzO^+68dv_*Dm1=h~ees^Etw#7I!xROzPzt*dp!``%7jV!i}*m2_H z`$aNTIn(yw5HH;1AL$Rn?eBz>_#bT>8J9;z{<>Sbdp#{>o4-554@0`^ zt(OOIkmohg6Fnw?r-elNhWS$bW>fZea zhC~c)2L4*3)NUM1q}0PnO~><^%? zs=AO3he`TF_@A0RNo=$XwR|4p&R4dL_IWMz)J4b65=mdHxbmKVBWiGNhM+yPinp5w zAnFR*xm7M`eh8xb3|~~Cq?fVcmTt2if?-hXnuYyk$=<#hIHe7@p84xz2##f&9d4TiSEpn~JPP(Z`RYvq; zp@xk=&L(f z_Cp=Bb;J}6=Ub#LI|mt#!qBjo*w>6$>hM-6vJ)=0#_9>=_d_3q`>tSr6on6+s9cX& zO;Ml7ji3D!wo9k)*pf9j2=o^K*BHJ`n{$D@t;56T8%L$iYg1+FMKwqR&$4IfWN7xu zVa782sT!@*ZF17Q#;agOs5{lI&oj~du)^fQeo(A7PAE@MAQkK_bSUB+>^u<0ya%IB z73)qrbV0JsRTY@Q1drv0J9oT*VV>>!;8hQwi{Co1c3eIh8p zO1-UkG4;Nm;J4Fs-jI2QVq<+0e0$eH+Wik0YEyNX!c`$^ERqOkYb7UO*cc_wR@wy< zJk5MujBCNJ@=jingh`WyGg!Ehj5n4xk@V0Sxg4|u-|pa3mq=0kNa?6>#0_(ESVWoD ztit!uGwmIPnq0$F8_{= zgyu~~d{Q@Z|I!2M1L-AB;c_s1&PwT&1)`fqe=PPJw zk;7A1iNz#Jg3~YoRGdees{ry*CoJawG zMM6Wvsa#nUQ-|KO5$KV@d4{9NYmxtI_$x&E7o+lHu-?Hdk9#8T|k zPD;BoeTo?$Z~VR}9$WnHJ6II9`f=Q|XFbOprf*+qUvEu(JBwg3JL&yYDoVb8>^6o4 zLo&;ZkFW7=I=r>wBvO^j6@2F`JK4YBJ@OBIM?VM$!d)>>%iq^#*7XlX)5qPi4*h*8 z^Lat9Q%qXSZ4y4gOpHv2`ZIe5{bF_&Nd&ijOUJTpLSwzZ-Fp~p`aO&0J>ErgGhhv8 z!q9Gz1wE;=*l54bvPWGVGJ9{zaL#*f&Y=a@$mIS{L7=m?3#^SlnFQQx?|v>N*36ib$W2iyKYQ7_X};|gBhWKuL>AuF@yFqy#CRzxn& zkWetNX;fE>t|N*nD({WO0}0EeCdAuHwnGn-8H38SgQBoD&Wvt~H$zekv9xCpL$#ae zueadQ492);#8Y+Z6#gVLXndx9t$G8nbXgOIe%XHF-6*`k6yQQ0IrI2)3H@uE5;MoT zqa!}b*X*W6Fk1xCOD0G5%0!?|r!AXQyaAB@SPSnoE4q|Eh{4Lyj;HR?`XHWS7YlFl zNA0qA4=+A($s$WCG7o_LxpN`fr;x(zpJ8O&!YSCh$T0fp$#jn9oK*IReGO!dDS88} ztL*FYqXfL zp{h|=*YHVBpc%<@h4`UCmtvl;yv8yPQV?Xf4qzz4Oemx7wboIA{BAj{1+FIDA(3~{Gkn>TIJba;Bf<#ySx z=K8Ta{@(@$ku%a=Y4~rY`Yvtrwk4VRsg`(r2PKm< zaC|Qs#*t%h01Jx?79yTXH`?i62Sd(iyF{zzlhjZKSxQ_VVbIV6e>N`Lo*|&vSk{&L zLU1IQ^B(RJ1M3cQ$o#{su&efeVqtZYG@NLd+XZW%{EqeDRHrxr4)gT$eqGHJ11!rQ zgKG6MM3nW7;cZ%{|I!UykIIhH^=hs<8rl&6?WE#R@CX?mX($F$oIaMw&m<|=@qVJ( z<^k|LPu_LoX-Wv4{5kdts1%g2Teel2D;TmTA@!xcC`_CToN{RP>U9x+LWm-NhY(#` zdz!#edPqZcIa~2-g>&tTMsW`if;d-_$naipZ#7wTEaEn$$Drmu8L7%Ztkib$+5987_3arP< z;fd1a4dw{K!8URYFYJwW|4ks(2 zqkJAdrQ)`?fTR9*Y9K6 z-8Vo1W#j?tfW3B8dWgZe##&Dfw4rf&S)}OU!F*%Y+We=ks*a`@#++bo7*hhF>!8<$ z$_*BNzH$Rf`d#jdx+xzVNtJdt<@?zg8VMRB-xsC#j9Iz&j9D;na7b7fC}HoT3~2I&-6T z@(p=>)JCC5?Ul7}7BF=ZIKJE?CU6KJaO>V1JLyWk8dgAQbFlo|yd}Ce5KmmT8o! z9l3G})m5_fW60B<9WWM*EP+I-v60q?PwogrgFON7bgCBVko*KjA4+6#Nbk!4gJGgn z4|ZHNFN4R=3q9tB#m7#<5ZhpxsfQ@0TpKO!wDhj;Pv?knc9qu1iQ+Xpic=p~Q)ZAO zohr^z10Kr_=I`Y_Q~7Ho+Vi7I)(6E{SgzQ;yiaU^8aR)LsgkQ4c+b6OOa| zz|gjF6PCF`&XBdVykGEaV&19-=^S**h4oOGqV-!E6R$0^tOvy!oZ?7ph?7Q#ODzNQ zXo7AT?;3x=#S=dN^gRsZE%`1WhA8*Lc)_zc*J<^W{L@PlLw@goEv+Jn7Y%>T)TqJo zM&%&JDAUidS+LZOiEI_MFx0)YvO)>}E#B-NtZu?!NDCbc~3Lx9|7r zl4sx3g^sHORPji&=-vacc#wHR&v&MRjFit%m*p-K{YSvqY6=}S zF-MV`)K@q?|Fy}R6}W+2!8KobAOI0?+uVDAk?vqNZbJae!&>JB16b~JVf{cV*bd=C&iH#pzd>1=H#Px86 z+8?Hc{3$t>NBB9RrM4)xjtZ1&SN_anjNt*&()%5%6X(!AA3cuY99uq^TXPpa;MAds z86R5?T&+I%`U;PQ2D42T8M;hYl3v3RHI@6-oJE2SaV;roPE1p^gSqAXzqMgM7Jr!Z zlQoVAbs(B@k3=ds?};z*k@)7M%}H^WJZ}HSD5ysP5bVv=LJg=IND00h$03&EPuUg< zR|b>!A@{yzuO<&TD~aY`dS%iEC^9}{19KwGx({C=R}*0r)@^jS*n{-7%4rrvFqXft z?Y}}Tb?0*&^-YUPliE{qo=2dWh)X* zbtt#vm!M%gsMGqb)D*OP$@zlA3BSe>Dy2Ca1?{D`#KpaFRG?-HSy`$_oNs#Xf;E>2f8GksPYmQ)`Ko)KG$*@EoLdwhPKji!x4aBrn}#*{n9M z%$3Ht#3^X9rHr8no~01_EM}R6;Z{@Aig5|EYlL+SsuU>_9dy!Wtuhqi{5tdlW)Uts zoyaDQ8{3&gh_lo5DrzEBT%x9RmG&X)mzxzizfybU;9yTF=~J6nGRBO=JkJ&C*eL*% zwgb)!PC~d4K`#jm&DHnrS1v}1&P@#NLylV)OjFn|?6BD|%SVO|SlP*eGhhn9d z6m~=$FF|Oz8#i0EtHZo)G*I762EiPm)4to!^iV^_xdJ|jRom4|AG`rzs!+&tl>tAi zm{2ly63D&6=TGr6@l{EWXD-bm&}kXwQ?A98kO7KOWVP2K4+H!|hP z=%%uI$&`MNtunK}lXY107=_PoU)5>jU|?2T9$kbd;8d-^xSPL@-m`cf>pmz?2D4E^ zWAD@UZBvf+&bygnp^iifyRLb!&p0eQ4f!k6sI?JWbCmg z7Y0zbk8Ih%?clbod#p+r1|jKLTCIqzh!cqp50z|;33I^99OF`l!(-XoeTqoUjNq}8 zI4FqcD=|hR$Q9XEk*bpjYH8*P_$o0JL#qV$TFGf{e5mtYPu!bgl5!T zlU?j;xTdfgSOaFkE&Jjiv-`&lPG(IM?em3EfPc2UHJy zye>uT-jPVDwd=$xdR;Si9xZQr3_{H5d!@`bPy5w;dG>I`g(f%X^`0LmF?KQder{z#oy?l@I_nB*mnxTNwD!*lEwLFc6b7WVxww-CaXBG9V!(jdTquT|*3A zqI3`4NDiGthoDGzOM`Ty0sKfono8NHF9Q-Q96I#j|DMr|Bue2K#<4*%fO&1Gz;ty(J zM=roWfERxN15JBszA(S}6LmXS9;HxTKTWL2jzHniA_wAY^`7tEwV_e1jslbJ^svKH zE1~#zi8z#VVZQ!g%nOK(HrG`WDCdgzvkYzY7xCUh3R?Z&=ydcG6yb&^R`lyKO9uF2 zj}x==Vz8iOAHUthO&PF_tZaaSpRycQpu+^lafYp$JHl<=%s`2bRi)eZ2!mrSV>o`* zRxDjS$`T~3=uWX+ilG^TpDZ*;JgU-;p0mtG_wk1CjXlj!6s_u2ect7T@yx_`{`otp zfPeWb$AQWpEQYPJJJipzB_;wzx2Z*h`99dpAfQKIxxCJlci(s>N#i-2o(2s=re6}y z9P7SQY98ALoh5FX(NFw<1!QxeaSJ3c`q(-7W;oIqiI1=~;6!0v0Vcm+PINu7eR+L| z%=RG1AF|#n{o^`LPV|07!!2w_zCsN(W@n{)P)z3{Z`ipWyaW9ZRw??c!I065$M+$%6#xJNHR2;V^M}#&G_M3}^)b`uQKw}M+eK9zz5I@cM)vw?fmaM! zWqa`^Xnz2;{z3Z@3(R(Io6DCo$0qN)U*tSfnK_;DQ*berj}01kdZjwr*!WaYOUemC z5`X7-4~iKKYW|(`Pj2{xG{&RS3a8$=U=XtM276j^Tp4SlEH8u8R?kicg{sAKft~7l z?&p6znM*uLTAP;!3sRM~J4<+yS~^);tiraN)6)n1^42<2OL?jWce6%xJ@#+Y^PKiD zd%75)VP@kX>XB`8RtJBsR|GUK<~961B^2QSN@uY)#OYPsRB;PlR5irI>$fOGTLhXp*^rhcZ1{1Mr&6uTRKJ%TTPAgb48 zbv9L=_^K;+M0$8Oj|7^;iC%q-&`7m1!;Oy;D!m<VNSP5#jWRZ6be;Y71OV*%Ys~#YddN9nNiB|2ZY%2YK*c+PMiloerVa>6V zbut)XZeiS0kj_6pw6UVZPDK8GEui_4@R!*F>|0#wout8a&7xQPL`;WfF7Wxbpnz=Q zfK>Q$@weEQ#r1dL2OIadx|&ZbUpIRuDkpjj6RTcuC;Rj88jKgDnhxuqlgPprz#oBu6n^1AMQvC zpk%^`K8ybsIlst*2|PRtLF;r)$EqoNPw8OqSI;F#XUxW+5~dNoP3MiTG4% zX!h2mu~0PF*2Ti9hUdJfxx$P};hIREjg32N=~QBVfmnI?fF&g$p%3iOYURV6QQ|MC zDPB)P$)AjyTK6fWpR10KSOpdgcsk>DMi?rTXEeWn>BSO2-8-)D4sEcA?7^ZCM_rHM zM-f@<#InMp=d>83 zwFm>6Ed^1fhu{c9`rF43^>jAG$cOya zl$0yz{oF<}1al3QX}H)Y-f?zYu3&fUrIG!>D=SPTidw0Q%Z_ru?R^nj&B!w>gI?>O zGN|?aWj-QBjZ(Z6rp8HsmL8ebdK!5}!;&C!+*3)BT#X@7<+YSJ%vtD11kG9^}TWV`sr23g8Na&0c;{@X8sL| z72T@5YCeMGvUek+WFLPNbUK+xlhfU4j*1J+4x{tn$n#EtRp92VgZ6T8xhI0*!Bu?f z|9oSpz&R@Zv|MbWuWc*7CCaq-; z38!aC5Q;(N3e)(6t|Xb2o;L~o=XNrx9#r+N!=Qu-p2*QIlehw;N(@s5V47;m@&`a# zFb8g6soLjG&NMtn*|io@EhO$TXTU5T}oSW+oS;2aX}3~ z43D#bKo zDl=DPf&}Ow(uB}HM~>C95_7wpz>rn<+gTBfB|3!ZvnrEOL{`2$brtH zTX5xqp24UxI{5B{j@AQ!W3XD+;Kh1RrJ-f>IEyA zOqUzoL+?aZ%6gl}{ZaJLz-e*MF7^*vpy8S$=2SFc?gWuxBi?kbqD)Gec$(+;rTPu>g74!!lc5@iOk3E zcScre?fKQ{)D(AfQw{$BE-c28sqmf4H|Cs&tn8PG)NA=8VuA%9TyAQUQfm)x8k)AE zT9eHMP(p$j0KM8}5!{@fW)^yh_w+{}tg6ief1CUY#=QdTshri5prRC*5%13q-FMk< zS0x^oL2ck9&dW|{nit6#%x@xD?QFo|@=OT2a(yivCcwDymdjK*-wGBU3HG6=VCRaTf@inMB zjM=0jem37d6c?iBwU%tjerZ(6k8sfmon>o)v-~B|<5K!j8Io%XNLRQDg6zv5_())= zYP?s)Qw~EW#&beM(0*HVFLT7K{E&q=^SwT&18qdisM@A$~Zhan`#R0@5@WKVTG)?;tfTdWj zarLN_MvE@Cg*Ih$$SQPP9qPOV=n%tcQJonsJ05jb0KW(?DGYd?kN7XgT^CRLCCZw| zxo?{bhlS0SQ9OOWzWx{4QCs-*yRG3gZ#;FyDVplaHN7>%Tpp*L4PlxxI{D`{5GOOO zGWoM^z~zWLuA9V^Ol6J={(V>>zNIOZlzDRIt`iP(5@hsPZ$2U`u`uoI6eMQ&0ythH z5=xk)0Ig&Hif08(&m^)iT?oHee(x4XktCU5u1(HASt?}^j;D!DHBmI=NuP^*}{ zWlnYN+&oGLbXYtAh`ZmU1G`{$mrB5l4?jlS#5%HP-=GHd;Dr{Z z2>D>?bYC$&#%~xunXEN;M(m@xv)Ec3$kQ0XEwH){H-BlagIKt+Ww?6#^&+6>f-1OY z$&sI-HQZw^B~IE48daM|La!3E!}pZVE1Sqa?g$ve#P`g&F^EsOkR6= zzsR0z&cQ2SlRuLxlG~lZMaY=z&K-cnh%hDfWG=K`dk6c(pIYgo9FHqll)!?x&0aHa zzin3NfToVP4bMAgPm3s#+qN&gvCNfvk$nAGYq}MhU2@jiB=|nHwal0+FSMj{W`G_ zC`fRj&-Gm_=Z`)_uD$qZp6jRns2UY}1Mf{bla_D2;7zP#jd0F8f*ONixcv!YS>4x% zXUJcCg102ez0oH>=Llt6%SYtbv2qj3ZiHEI1C4P5^u9s2*DmBmGU4{GJopA@&EZzs z7cN(h$4fONHYe#IJ?Q-&`r}J`D&wCuSV{iisCPcZtz(QPUuX~3IFazBo-3MG>E2es zOUsj~SOxbl!mcUIo%xXzGlvRWWT#UblBwSIjXo4QvnQVYL-U~45F3IVsXhql>gyb- z{pq!=?H!uttJj>eDdwZIT+DG?6$pTy?2fw?9?iL?t;GBZX1D6ioY7u0M_}M zj5RHUWU!JO5eNzw>C502f&s#{pCA9cBdA!s)xl&5IAz)32(3Xu_Y6~dHMM1IQX-AS z89p{0n0S=;J$)ZSHiraqf@s?*Qz zY(mhYvt=6`cI*LP+UsxzgZdWHccivXLfs3XSC0mdXFcdt5f~#&$of5_rJ09iCsz|` zgNDc{Pv0s2eer`lgy*Dky{liMQ{8&6;a)+EVzVVQ-{i82SPDdxAcMlC3`FG z?0*=WM#LwsbGH?MxG2|wqY&wcG3K_Vbm^~iSAB)^?k>ici~kIFjWWtt<6K{{gs z{>RsJQ47KWM1Bf^Pi_*KYJs2S3WnPD;9-O{YLN3>jaeT6XnzR@_v=X4t#9C_8Lx0dYdt!Nu6jo3QZLy%uD8O(f$O zZ*rL!UCn|6BixxqfqXuUxiq?4@0Wj7e*5hFTfaYRI(V5fh0n#@O9%4=&7rJQIb{Cp zV?OTX)UL&b4=}HQgRgatyVGJ%Fw%pzhxR#xg_gIEY*&N{0TjUbM7*~VzO<2Omgsu1 zhrP$6&(Y=;f($Pfki6p2cjj-gSKOcBi%RA>H;Z}X^h)L}Iu0o+9c+av_BpIc@8TCG z`A`oniOoM>dI}zkFTwN_15^U5e(cU^JA7_aY6Lpbml;9KQU~ z`s(|CSyBh3*PhQts2Rx<3K7%-yz8kw&B#0Zi?Y6;rdzGQjM@w2UMrQaKBx1uDgS6a zV|^LRIK*#RrQo+_W~BbHQce->cyLHF@rcTN(^b2I09vop{hiMu-R1_O`&sUnUe)ym z$^vJf5_6;h?dBo=pp43hm6j(lHwA6vB6mZSah^BQLqQhVUl*9(U!C!V+u0e8hqX75JV?8dFV$J>es<--K9TNj}m zC^3=y(i^lVvfCM=M+OF@NmFi%(C4ozQ#ec}l|!gHM@q$9AE$US&#pX8V!pzC2&hOS zntKQ%$A%x2>Jqv^U+YDXo}n_L659*nonR%7CzslvlPbtHDr%l0Db%{4*7QrA+o;}- z#ieytaa#+mI&1=QJk6tHhgL!Y4cab!PQ=P)|vO2FZOZ1}L$S6n4>m7wl zp~F2B>|1nfX&9!Ddr%O7D5d7#M5QepJ0&OL84=SW{t}$^!zLqGbL`*FZnYaQH)p8w z1q)h1pnusRjBSL}`E0)UyX(d9%IJ-El z9rsz|#;^Q}!OGe31I6WVpKY&|d#g(^%Q+Wimsn5ptCGvaj=yusGXHdJ`2MeZk-!#@ zE}V$+^ef$GaX%vCaER%MmG!O=m}x6PVc&OB`Um?J&gh@SV$UP-4pjA}z8d*lz9=0O zsCI*E(hw&E+rxGtR$$Zu2(hm8l@NhL$oaI{nyF+*cWn>b$!L_AX@n3Bn6XwY)WNBf zXwBRhk6pTB0(g05yCz-#;H_p3o4ltzADah>j>#dJn}Q#!I)}+ClviV@ zXD$>(q_> z&sXIWV$FqlzSzJZU7a4xr4 zyEFOHb}})`@H7^UZW>~bVyU8mV}L>tSfF4kCr90wDd&vE{MDb;WD=6{Ni@8$f#xwD zH)_&O6Y%O3IA*PXtGCsJ_`SRWn}lurp7r85BqEw7UQ~(+oNa$U{j>!{JVYIbp>4q# zZB*cT#QDyQ6u>ooZK<(H4QGg<6`Tlq|P?RuR}00)3$UhHS>vPNR1B=Nw}7$wm{ak96F*V{fT4aiqmeUwC@kR82}J0Ql-4$ zcR}#Fobv}SVnVs1fw(d3v$3Mt9>PcWnR7xQ-xt82qctY}f z#rL(~<&$Ui?Rdt}WmXBB=v}wrMO<;%1flCTcerhmt31wzG2(<$LSwuck z;j7?pmI72N|PV(eHf?#%maw**5Z?+SELjGIpSGkVp4a7cF}VKMmcod4eDu>b9l<&R>`BkP6ybB&sK6a!tT?C%?z6yWwnJ$aN9s!weH zc_>KkZG_3oMy%w-F_mxBu&6rG2Mee~$u|DJ+<^82o9M|&p{h_ZwoknzLCN56XX7b+FS9B}mE-l( z&d_x;wlgJxmu$VA1YEw8_u(Zxav36P#k+GxGhv)-vhB4ggESSFiNN1E-Ug?^#?g2@ zmDE=qEpnI7THZOx3*e%+L@f6ngR&Y+FSIzOzP8%VR zrIGo4^N_|LDr~$;eBh{v&(B{3+X6m=py8iw3~^{z^FR!K6&LLb*b9_ym~XQL(Sya( zZ=;c;Ag-umx{F}XiXG~e_kbsno{YNP=}xJ6#F}2b`h8SR!#>4ZQKG(k1NfIJ`>W%eQq^-6=+2C#nSzx+A+bHugS9b}42|{D; z?&?%}uXear%}gY9xNC1J+mT|IZk(ZQ#c8}`3B?9A!td4!Um-aIHSifP9Vr=VSxlxD z{=x&etTtmZMH(NEld?0IU}j!TxWf6%P*S;1ZHus#n7begN4&Eg;8qIRQ_cKo!gZPB zdi9|J{E#6lmnv2X3B__{ifn7yjl~*=qyWBH1;#MKmCR|+c2{vq3_yxTA)q4ttYZ9* zz{U`pXqpJ{r7V%KYb%NKM=fz(X9=upPh6_{7GfT5b};;xo_YE%p+HjgSORD;6C0h_ zEKGbU&?)y$Q)P{Fr>|T<;c?JjHN8nuGIqr*73H$6nX<+<{)Ax7EN7GQXE;{zDuf}) zRZoV?E8ABb|Eh(bELzq#x*+E(HG^x3Z0*}Gq%3{xpfriVF+=ROZo)r+PnH?-2Y$DF z&sOnXG%BuQf00NCCz zkBU~Hf6=CvuY*}`U?UjK0+VYZ?>^S+D;FvbJLd(bXZtV6n_o2)rP#Dl5lM(Eo)^EU z0HI6{K}lnx5_7eDZ&FPeHD%>lI+L0EvEL3b-^On`q3b2xuM^s&2uxU7-Z3Cei*g`A ziH#CrR-N;c?wU2Gr$R;b3yd9L}KYgcp<4y`OdAvf!tedWYxtkQ?Ghp z1X+bHz;feK8-G6+s;Nv%U*Yn9+qH}x=*pHAk>j@mbU|6deF$^vj{9OP8}C-aB;Elq zXUClk{gu&+X}IQ76gbeUI>uqjI?7AcoGaMn?^*EW#13qwAcl1bFqoncXOrswFrIah z<>T{VJz`E^x; znnH~J>lMwZs`t+0gQdP{aaq&L1|=y}E+dbZlcqU&H1wmM+6ub~(es&Sn7{F>C>`Nl zaU@f3gvA9Qcx$sxk;<6zyw7<}Av#T4d%(vV9*BKm4WpQt!EdWl0smpW4QfO_@wFdC z5fasZha6m<9(7#eveAS4B3Ti4fPNLNIX_j+Z_|=He{%HFO3tP06m7%tQia8|;3pYN zC6wwP{m-1-_55;EqMtC&c}v2!NGRu+dL)ypPnwjA0;hk1qL+>byI6Z^SISd;GM?EH zS8crXpx#p7sHcX83wn8xS@Df^uIX&!!lKNm%(9)Uj;pb-UUXfni8iB}C;dUd&%i_Nd&P*CC%@IZ2#p`!~wCmF3 zr-{@%Yoh&CM?8#_N{*7$rfAL<&WnQfLXOI(!zJiLthG%J%&-h2bUx7-kVD`{6v2M6 z78kc5X$o|oDJF^=y#3Mv^6yRcdpcCmacBAk8;M+{N`{7eWcWZ&5Uon#q9UY=NQ0qU zZZgsp<%O(aC=v_*KvvjIYFpw07eZh4fd<5G2uN51Z_rGOKqzl13g&a|a2Q6q0-ZL# zh{l|A@zeH1>YKH6uAm}~NSThE1Dz7hm%)gB!LIk` z6~0FdWg%woWYXaynfN^k>5PkY36V{zsDmK^Q(`x_TFN^CmCS|7C?XhXFbs>3U<+5O zWsOZPf#mZ3yaWH*V9BU&^(BU(Y zRc$|`{V}g8&HSwNJuVTC(`|3@s`*4os`)ZO{C~+jSRN!#5gV%>m7u6(!$3|nMPRl4akBaz8Z z(U!BS`7)to+*cazu)H?m1d3gL6;!!h;)-{$>lCk|?FOuP#dF+TjzD`ws`Oe@+VVj8 z>syf3V5T)x-Ok0NA$2V5 z#+DI3mth1mA6Xdi0uE2+%>MxRS6;Ro)bt*1#K9s{d6GK}=$H#KDS7{S6Ad-R%SI;^ zA~PCJr2f3~_G8UoE%SdUR(MWrw94acpY`ZPkTO0&tf)aAY1>)w_2+WIb{DZzyR9_) z=NqX^h%3xCb}J1)NEPJZ10AziMB#t&tnm%igFaF%M~t)@cO@!iZM@vF{HK`lx745Y z?>@t=CdaNb@)kI4uvH1Md6CE1G(4SZMbf8l?N>FA{^`s7QUE9=TGm48h(JP zC=wJ_YIqdH+)4en*&Vc;4O%4y5#JUT`_{ax@z_nFicte5B)Cf5S%yg0P`zSxjwjV| zce1GR@aO-^X#NFa9bWf;0|mY?XEL#gnD%Z=E{q$<5v7UE!(0fN_sO!Qex`h%cqrxzGvA5mGrlfOGp_1Z`7p@KV+1- z(nm&(P$cKy+MnJb3NqGG?AFfry%1+4B1gNOb9hXp@l9c{!0{K`g9T2vz2anz{ITjn zBdI|JA)yue-K-;^L!fZIz$Xd?DbQeAL8j8{UHUBOx76(V!AeUnC%e78kLurKB0(cz zc0K`esMuU~_xzp>tdH}E%tE<24%tsmHxb(NXtCjYOZ5!pHKF`uaCqO8tEq>G_M3Hf z7ic(LI`lLkxJDkqh{7WCMlayGWr@s_Jy^O&CYqzLDKGvWj$-DCwvOH+x62l9T?o_ub(r>wZ63nr$yam3z6hzOVjouvj zrJ=N^=7}+@(dP_0P+V+#f){SUC+qw$GfNXo5bfUw6^ zcK;y&{r^~5kyNOUkfMKxgh8-h{+f-FmdF8C)&dDR{VG6CO0;Afap=l|xtn+D6_AX~ zo}B)YWpd|j*HI$gTt0ZXR7R=@32abA4p@_mO`dl|vOmEcOsm|dA=kYP;r0Y^0=M=6 zcqQqezw(ZlnZ6Nk3%wKTU5j%9d^%K4@KU99ZFCJ!LmAzp$A<=Ayz$;Ea@y%pxrM&v`s#1~9$3R9$GiJ_5t7_~baPFm=JSa#h<~z^o+z8Vq$iO-S&aETY~D zaUs8&r+PC_th$=4#*ogZJlKG7tG=?vDFXnbQ~c$;*F75T{ z(ctG5+U5DmC1DO*i|1&FFtAZ+NQbr%ldH@Ov`eYrqFj@Y2$F8d zBOaxj`Pi8CHm(e5E?smQk2hHlIwN_c&Ikm+5Gvymv}L!hFNR2hB*k*As$`fuX*--?|Jfv1rM1LLwp4d@i0 zs1%7VpUG0UHVtZ}&_D!Hkuz)(WQesLZ=cGAotqZfN{C%t86&=qUz&OW3d@l2Oy9jr z=u;kFO*5T$L5f=58co?c2_zRH7_J>dHL2J*MM;|J!@!&XhB~+qgI|qgf-hH!(+}`P zE+%bbKs5?Cz+2MEOtZ^?ZdyXg`B>4?T0voi4O8R}6QQd#tF^kLsHb-l;ClntcTe@S#w$HGrQM+B3BC>X@_v-|Yr|gbMO(&SbcAqaRZFP};R09clz#4wKU5|Ox7w#=$FQ*h0DW?tagM-M&J6;=8hLOQ7&X(P zM)sEaBNyJj9`Xo0C;c3UZ;g%2#|jo7o)rH9{BC4u=0^e=G@Oh}UuyK~&O0zv8n1#u zx@7X7NLOX(;|y^vh5i7}jI9p;07^407(IE>d|kD_>$wQ?R6KBv$Rz}AWHH*dX?So? z1B^X$`=|_pJ~QZRWc>V@gD68k@b6&YZB`rK#9@`tlv$Jh0e?6^mgwK-%Kme#1>)~H X6sRaD(g2l$|HmE3Q!W19vHZUP2F*L5 literal 0 HcmV?d00001 From 3a9fc1c5a53d5a0683d47f7a5890fda2381b13c9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 20:16:52 -0700 Subject: [PATCH 079/103] Standardize tool environment references --- apps/desktop/src/main/terminal/session.ts | 2 +- .../api/copilot/tools/execute/route.test.ts | 12 ++- .../message-content/message-content.test.ts | 4 +- apps/sim/connectors/airtable/airtable.ts | 10 ++- apps/sim/connectors/confluence/confluence.ts | 7 +- apps/sim/connectors/discord/discord.ts | 11 ++- apps/sim/connectors/gitlab/gitlab.ts | 14 +++- .../connectors/google-drive/google-drive.ts | 7 +- .../microsoft-teams/microsoft-teams.ts | 5 +- apps/sim/connectors/notion/notion.ts | 7 +- .../lib/copilot/generated/tool-catalog-v1.ts | 20 +++-- .../lib/copilot/generated/tool-schemas-v1.ts | 20 +++-- .../tools/handlers/deployment/custom-block.ts | 6 +- .../tools/handlers/deployment/deploy.ts | 16 +++- .../tools/handlers/function-execute.ts | 10 +++ .../handlers/management/manage-custom-tool.ts | 2 +- .../handlers/management/manage-mcp-tool.ts | 2 +- .../handlers/management/manage-sandbox.ts | 6 +- .../lib/copilot/tools/handlers/resources.ts | 16 +++- .../lib/copilot/tools/handlers/vfs-mutate.ts | 18 +++-- .../lib/copilot/tools/server/env-reference.ts | 39 ++++++++++ .../files/download-to-workspace-file.ts | 10 ++- .../tools/server/files/edit-content.ts | 15 +++- .../copilot/tools/server/files/share-file.ts | 16 +++- .../tools/server/files/workspace-file.ts | 5 +- .../tools/server/knowledge/knowledge-base.ts | 3 +- .../lib/copilot/tools/server/media/ffmpeg.ts | 5 +- .../tools/server/other/search-online.ts | 4 +- .../copilot/tools/server/table/table-views.ts | 20 ++--- .../workflow/edit-workflow/validation.ts | 4 +- .../lib/copilot/tools/tool-display.test.ts | 18 ++++- apps/sim/lib/copilot/tools/tool-display.ts | 34 ++++++++- apps/sim/lib/copilot/vfs/serializers.ts | 17 +++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 6 ++ .../knowledge/application/knowledge-bases.ts | 2 + .../lib/knowledge/orchestration/connectors.ts | 6 +- .../sim/lib/table/application/context.test.ts | 5 +- apps/sim/lib/table/application/context.ts | 7 +- apps/sim/lib/table/application/tables.ts | 11 ++- apps/sim/lib/table/application/views.ts | 30 ++++++-- .../workflows/orchestration/chat-deploy.ts | 76 +++++++++++++------ apps/sim/tools/index.ts | 26 ++++++- apps/sim/tools/params.ts | 10 +++ 43 files changed, 464 insertions(+), 100 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/env-reference.ts diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 350c1aa070d..beb64a403a3 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -851,7 +851,7 @@ export class TerminalSession { (pending) => ({ command: pending.command, output: - 'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. The user can also drive it in the panel. terminal_run reports BUSY until it exits.', + 'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. If it is a pager (less, git log, man — the screen ends with ":" or "(END)"), nothing more is coming: exit it by sending terminal_input text "q"; terminal_kill delivers Ctrl-C, which a pager ignores. The user can also drive it in the panel. terminal_run reports BUSY until it exits.', status: 'interactive', exitCode: null, durationMs: Date.now() - pending.startedAt, diff --git a/apps/sim/app/api/copilot/tools/execute/route.test.ts b/apps/sim/app/api/copilot/tools/execute/route.test.ts index e1fc2d39141..87e7d1e88f2 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.test.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.test.ts @@ -18,8 +18,16 @@ vi.mock('@/lib/copilot/environment-context', () => ({ prepareCopilotEnvironmentContext: mockPrepareEnvironmentContext, })) -vi.mock('@/lib/copilot/tools/registry/server-tool-adapter', () => ({ - createServerToolHandler: () => mockHandler, +vi.mock('@/lib/copilot/tool-executor', () => ({ + ensureHandlersRegistered: vi.fn(), +})) + +vi.mock('@/lib/copilot/tool-executor/executor', () => ({ + executeTool: ( + _toolName: string, + params: Record, + context: Record + ) => mockHandler(params, context), })) vi.mock('@/lib/copilot/request/tools/resources', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index a777c7006a9..19f953fdacc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -591,9 +591,9 @@ describe('completed tool titles', () => { expect(failures).toEqual([]) }) - it('keeps present tense while executing and on error', () => { + it('keeps present tense while executing; failed rows say so', () => { expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs') - expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs') + expect(firstToolTitle([queryLogsCall('error')])).toBe('Failed querying logs') }) }) diff --git a/apps/sim/connectors/airtable/airtable.ts b/apps/sim/connectors/airtable/airtable.ts index 8f6ee74d63a..5a8fc422d0e 100644 --- a/apps/sim/connectors/airtable/airtable.ts +++ b/apps/sim/connectors/airtable/airtable.ts @@ -212,7 +212,10 @@ export const airtableConnector: ConnectorConfig = { if (response.status === 403) { return { valid: false, error: 'Access denied. Check your Airtable permissions.' } } - return { valid: false, error: `Airtable API error: ${response.status} - ${errorText}` } + return { + valid: false, + error: `Airtable API error: ${response.status} — 401 means an invalid PAT (or an unresolved {{ENV_VAR}} placeholder); 403 means the PAT has no access to base "${baseId}". Detail: ${errorText}`, + } } const viewId = sourceConfig.viewId as string | undefined @@ -229,7 +232,10 @@ export const airtableConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!viewResponse.ok) { - return { valid: false, error: `View "${viewId}" not found in table "${tableIdOrName}"` } + return { + valid: false, + error: `View "${viewId}" not found in table "${tableIdOrName}" — or the PAT lacks access to it.`, + } } } diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 470cdc8ab68..7f2201381e1 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -493,7 +493,10 @@ export const confluenceConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!response.ok) { - return { valid: false, error: `Failed to validate spaces: ${response.status}` } + return { + valid: false, + error: `Failed to list Confluence spaces: ${response.status} — 401/403 means the credential lacks space-read scope on this site; 404 means the domain is wrong.`, + } } const data = await response.json() const results = (data.results as Array> | undefined) ?? [] @@ -502,7 +505,7 @@ export const confluenceConnector: ConnectorConfig = { if (missing.length > 0) { return { valid: false, - error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`, + error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')} — the credential may not see them; they may be in another Atlassian site or restricted spaces the connected user is not a member of.`, } } return { valid: true } diff --git a/apps/sim/connectors/discord/discord.ts b/apps/sim/connectors/discord/discord.ts index 412d8d28203..299cb87a213 100644 --- a/apps/sim/connectors/discord/discord.ts +++ b/apps/sim/connectors/discord/discord.ts @@ -272,10 +272,17 @@ export const discordConnector: ConnectorConfig = { } catch (error) { const message = getErrorMessage(error, 'Failed to validate configuration') if (message.includes('401') || message.includes('403')) { - return { valid: false, error: 'Invalid bot token or missing permissions for this channel' } + return { + valid: false, + error: + 'Discord rejected the request (401/403) — the bot token is invalid, or the bot lacks access to this channel (invite the bot to the server/channel and grant Read Message History).', + } } if (message.includes('404')) { - return { valid: false, error: `Channel not found: ${channelId}` } + return { + valid: false, + error: `Channel not found: ${channelId}. The bot cannot see it — invite the bot to that server/channel, or check the channel id.`, + } } return { valid: false, error: message } } diff --git a/apps/sim/connectors/gitlab/gitlab.ts b/apps/sim/connectors/gitlab/gitlab.ts index 99586321f48..3711a9b834c 100644 --- a/apps/sim/connectors/gitlab/gitlab.ts +++ b/apps/sim/connectors/gitlab/gitlab.ts @@ -968,8 +968,18 @@ export const gitlabConnector: ConnectorConfig = { if (response.status === 404) { return { valid: false, error: `Project "${project}" not found on ${host}` } } - if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid token or insufficient permissions' } + if (response.status === 401) { + return { + valid: false, + error: + 'GitLab rejected the token (401) — it is invalid, expired, or an unresolved {{ENV_VAR}} placeholder. Pass a valid token or a {{ENV_VAR}} reference to one.', + } + } + if (response.status === 403) { + return { + valid: false, + error: `GitLab token lacks access (403) — it needs read_api/read_repository on "${project}".`, + } } if (!response.ok) { return { valid: false, error: `Cannot access project: ${response.status}` } diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index e7c2def3b5c..5c692f52ac4 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -358,7 +358,7 @@ export const googleDriveConnector: ConnectorConfig = { } return { valid: false, - error: `Failed to access folder "${folderId}": ${response.status}`, + error: `Failed to access folder "${folderId}": ${response.status} — 403 means the folder exists but is not shared with the connected Google account.`, } } @@ -383,7 +383,10 @@ export const googleDriveConnector: ConnectorConfig = { ) if (!response.ok) { - return { valid: false, error: `Failed to access Google Drive: ${response.status}` } + return { + valid: false, + error: `Failed to access Google Drive: ${response.status} — 401 means the token expired (reconnect the Google credential); 403 usually means a missing Drive scope.`, + } } } diff --git a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts index 5355918f867..6c4b07a42a5 100644 --- a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts +++ b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts @@ -348,7 +348,10 @@ export const microsoftTeamsConnector: ConnectorConfig = { for (const channelInput of channelInputs) { const channel = await resolveChannel(accessToken, teamId, channelInput) if (!channel) { - return { valid: false, error: `Channel not found: ${channelInput}` } + return { + valid: false, + error: `Channel not found: ${channelInput}. The connected account cannot see it — it may be in a different team/tenant, or a private channel the user is not a member of.`, + } } // Verify we can read messages by fetching a single message diff --git a/apps/sim/connectors/notion/notion.ts b/apps/sim/connectors/notion/notion.ts index 3904d7ddef2..21f6aaf4e91 100644 --- a/apps/sim/connectors/notion/notion.ts +++ b/apps/sim/connectors/notion/notion.ts @@ -282,7 +282,7 @@ export const notionConnector: ConnectorConfig = { if (!response.ok) { return { valid: false, - error: `Cannot access database ${databaseId}: ${response.status}`, + error: `Cannot access database ${databaseId}: ${response.status} — 401 means a rejected token; 404 usually means the database is not shared with this Notion integration (share it from the page's "Connections" menu).`, } } } @@ -300,7 +300,10 @@ export const notionConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!response.ok) { - return { valid: false, error: `Cannot access page: ${response.status}` } + return { + valid: false, + error: `Cannot access page ${rootPageId}: ${response.status} — the page is likely not shared with this Notion integration; add it under the page's "Connections" menu.`, + } } } else { // Workspace scope — just verify token works diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index d101bd9d1e7..fcfe6f075b3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3482,7 +3482,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = { }, minSize: { type: 'number', - description: 'Minimum chunk size (1-2000, default: 1)', + description: 'Minimum chunk size (1-2000, default: 100)', default: 1, }, overlap: { @@ -3604,7 +3604,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = { }, topK: { type: 'number', - description: 'Number of results to return (1-50, default: 5)', + description: 'Number of results to return (1-100, default: 5)', default: 5, }, workspaceId: { @@ -3672,7 +3672,8 @@ export const ManageMcpConnection: ToolCatalogEntry = { }, headers: { type: 'object', - description: 'Optional HTTP headers to send with requests (key-value pairs)', + description: + 'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.', }, name: { type: 'string', description: 'Display name for the MCP server' }, timeout: { @@ -4371,7 +4372,7 @@ export const QueryUserTable: ToolCatalogEntry = { limit: { type: 'number', description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', + 'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.', }, order: { type: 'array', @@ -4548,7 +4549,16 @@ export const RestoreResource: ToolCatalogEntry = { type: { type: 'string', description: 'The resource type to restore.', - enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'], + enum: [ + 'workflow', + 'table', + 'file', + 'knowledgebase', + 'folder', + 'file_folder', + 'table_folder', + 'knowledge_folder', + ], }, }, required: ['type', 'id'], diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 707ca08bcb4..b3f2047f631 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3405,7 +3405,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, minSize: { type: 'number', - description: 'Minimum chunk size (1-2000, default: 1)', + description: 'Minimum chunk size (1-2000, default: 100)', default: 1, }, overlap: { @@ -3539,7 +3539,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, topK: { type: 'number', - description: 'Number of results to return (1-50, default: 5)', + description: 'Number of results to return (1-100, default: 5)', default: 5, }, workspaceId: { @@ -3608,7 +3608,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, headers: { type: 'object', - description: 'Optional HTTP headers to send with requests (key-value pairs)', + description: + 'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.', }, name: { type: 'string', @@ -4300,7 +4301,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', + 'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.', }, order: { type: 'array', @@ -4497,7 +4498,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: { type: 'string', description: 'The resource type to restore.', - enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'], + enum: [ + 'workflow', + 'table', + 'file', + 'knowledgebase', + 'folder', + 'file_folder', + 'table_folder', + 'knowledge_folder', + ], }, }, required: ['type', 'id'], 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 c76fdf32b78..051a97fbbd4 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -303,6 +303,10 @@ export async function executeDeployCustomBlock( return { success: false, error: error.message } } logger.error('Custom block deployment failed', { error }) - return { success: false, error: 'Custom block deployment failed due to a system error' } + return { + success: false, + error: + 'Publishing the custom block failed inside Sim; assume it was NOT published. Call get_deployment_status to confirm, retry once, and report the failure if it repeats instead of retrying further.', + } } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index d5a0260f2dc..c8cc2fc7d1a 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -4,6 +4,7 @@ import { messageForCopilotWorkflowError, } from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -357,6 +358,19 @@ export async function executeDeployChat( } } + // "Use the password in {{CHAT_PW}}" arrives as the literal reference — + // resolve it, or the placeholder string becomes the chat's real password. + const resolvedPassword = await resolveEnvReferenceSecretArg({ + userId: context.userId, + workspaceId: context.workspaceId, + value: params.password ?? undefined, + argName: 'password', + registry: context.resolvedSecretTraceRegistry, + }) + if (resolvedPassword.error) { + return { success: false, error: resolvedPassword.error } + } + const result = await executeCopilotWorkflowUseCase(context, deployWorkflowChat, { workflowId, assertedWorkspaceId: context.workspaceId, @@ -371,7 +385,7 @@ export async function executeDeployChat( imageUrl: params.customizations?.imageUrl ?? params.customizations?.iconUrl, }, authType: params.authType, - password: params.password, + password: resolvedPassword.value, allowedEmails: params.allowedEmails, outputConfigs: params.outputConfigs, includeThinking: params.includeThinking, diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 8923e6a4ab8..ae68024d365 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -636,6 +636,16 @@ export async function executeFunctionExecute( 'internalSandboxProfile', PRIVATE_SECRET_PROVENANCE_FIELD, ]) + // The copilot tool doc promises `timeout` in SECONDS ("Sim converts to + // milliseconds", default 10, cap 300); the underlying function tool takes + // MILLISECONDS. Nothing converted, so `timeout: 120` armed a 120ms abort. + // Values ≤ 600 are read as seconds; larger values are assumed to already be + // milliseconds (a model habit worth tolerating). Both clamp to the 300s cap. + if (typeof enrichedParams.timeout === 'number' && Number.isFinite(enrichedParams.timeout)) { + const raw = enrichedParams.timeout + const ms = raw <= 600 ? raw * 1000 : raw + enrichedParams.timeout = Math.min(Math.max(ms, 1000), 300_000) + } if (params.sandboxId !== undefined) { if (typeof params.sandboxId !== 'string' || !params.sandboxId.trim()) { throw new Error('sandboxId must be a non-empty Sim sandbox id') 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 7d43faa71b0..adb9e45f3a0 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 @@ -250,7 +250,7 @@ export async function executeManageCustomTool( error: classified && classified.code !== 'internal' ? classified.message - : 'Failed to manage custom tool', + : `The ${operation ?? 'custom tool'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`, } } } 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 da6dc0cdd43..066fb6b8539 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 @@ -205,7 +205,7 @@ export async function executeManageMcpTool( error: classified && classified.code !== 'internal' ? classified.message - : 'Failed to manage MCP server', + : `The ${operation ?? 'MCP server'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`, } } } diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts index 90f7517b80b..47913ca6103 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts @@ -102,7 +102,11 @@ export async function executeManageSandbox( workspaceId, SANDBOX_MUTATION_LIMIT ) - if (limited) return { success: false, error: 'Rate limit exceeded' } + if (limited) + return { + success: false, + error: `Rate limit exceeded for sandbox ${operation} in this workspace — do not retry now; continue with other work or tell the user the limit was hit.`, + } if (operation === 'add') { const parsed = createSandboxBodySchema.safeParse({ diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 0bbb90efe0d..1dbfbeb00cd 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -66,7 +66,9 @@ async function resolveResource( const wf = await getWorkflowById(item.id) if (!wf) return { error: `No workflow with id "${item.id}".` } if (context.workspaceId && wf.workspaceId !== context.workspaceId) - return { error: `Workflow not found in the current workspace.` } + return { + error: `Workflow "${item.id}" is not in the current workspace — run glob("workflows/*/meta.json") for workflows you can reference.`, + } resourceId = wf.id title = wf.name } @@ -75,7 +77,9 @@ async function resolveResource( const tbl = await getTableById(item.id) if (!tbl) return { error: `No table with id "${item.id}".` } if (context.workspaceId && tbl.workspaceId !== context.workspaceId) - return { error: `Table not found in the current workspace.` } + return { + error: `Table "${item.id}" is not in the current workspace — run glob("tables/*") for tables you can reference.`, + } resourceId = tbl.id title = tbl.name if (item.view) { @@ -113,7 +117,9 @@ async function resolveResource( classified?.code === 'forbidden' || classified?.code === 'unauthorized' ) { - return { error: 'Knowledge base not found in the current workspace.' } + return { + error: `Knowledge base "${item.id}" is not readable in the current workspace — it does not exist here or you lack access. Run glob("knowledgebases/*") for ids you can open.`, + } } throw error } @@ -125,7 +131,9 @@ async function resolveResource( const logRecord = await getLogById(item.id) if (!logRecord) return { error: `No log with id "${item.id}".` } if (context.workspaceId && logRecord.workspaceId !== context.workspaceId) - return { error: `Log not found in the current workspace.` } + return { + error: `Log "${item.id}" is not in the current workspace — use query_logs to find valid execution ids.`, + } resourceId = logRecord.id const workflowName = logRecord.workflowName ?? 'Unknown Workflow' const timestamp = logRecord.startedAt.toLocaleString('en-US', { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 946cabe015f..2be4f984ac2 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -290,7 +290,11 @@ export async function executeVfsMkdir( if (top === 'tables' || top === 'knowledgebases') { outcomes.push( - folderedOutcomes.get(path) ?? { from: path, kind, error: 'Folder creation failed' } + folderedOutcomes.get(path) ?? { + from: path, + kind, + error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, + } ) continue } @@ -312,7 +316,7 @@ export async function executeVfsMkdir( fileOutcomes.get(path) ?? { from: path, kind: 'file_folder', - error: 'File folder creation failed', + error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, } ) } else { @@ -320,7 +324,7 @@ export async function executeVfsMkdir( workflowOutcomes.get(path) ?? { from: path, kind: 'workflow_folder', - error: 'Workflow folder creation failed', + error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, } ) } @@ -646,12 +650,16 @@ export async function executeVfsRm( workflowOutcomes.get(path) ?? { from: path, kind: 'workflow', - error: 'Workflow deletion failed', + error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("workflows/*") to confirm before retrying.`, } ) } else if (classified.category === 'files') { outcomes.push( - fileOutcomes.get(path) ?? { from: path, kind: 'file', error: 'File deletion failed' } + fileOutcomes.get(path) ?? { + from: path, + kind: 'file', + error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("files/**") to confirm before retrying.`, + } ) } else { outcomes.push(await removeOne(classified.category, path, context, workspaceId)) diff --git a/apps/sim/lib/copilot/tools/server/env-reference.ts b/apps/sim/lib/copilot/tools/server/env-reference.ts new file mode 100644 index 00000000000..e2339a22ab5 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/env-reference.ts @@ -0,0 +1,39 @@ +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +/** + * Resolves a whole-value `{{ENV_VAR}}` reference in a secret-bearing tool arg. + * + * Copilot agents never see secret values — the workspace exposes variable + * NAMES only — so when a user says "use the password in CHAT_PW" the model + * passes `{{CHAT_PW}}`. Without resolution the literal seven-character + * placeholder becomes the stored secret and nothing ever errors. Only the + * explicit braced form resolves here: unlike API keys, passwords are + * free-form strings, so `$NAME`/bare-name heuristics would corrupt real ones. + * + * Returns an error when the referenced variable is unset so the model learns + * the actual fix instead of silently storing the placeholder. + */ +export async function resolveEnvReferenceSecretArg(args: { + userId: string + workspaceId?: string + value: string | undefined + argName: string + registry?: ResolvedSecretTraceRegistry +}): Promise<{ value?: string; error?: string }> { + const { value } = args + if (!value) return { value } + const braced = value.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/) + if (!braced) return { value } + const name = braced[1] + const env = await getEffectiveDecryptedEnv(args.userId, args.workspaceId) + const resolved = env[name] + if (resolved === undefined || resolved === '') { + return { + error: `Environment variable "${name}" referenced by ${args.argName} is not set for this workspace or user. Set it first, or pass the raw value.`, + } + } + // Activate on the call's egress registry so an accidental echo is redacted. + args.registry?.recordResolved(name, resolved) + return { value: resolved } +} diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index dba8bb892b8..e87e861f9a1 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -166,9 +166,17 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< }) if (!response.ok) { + const hint = + response.status === 401 || response.status === 403 + ? ' — the URL requires authentication this tool cannot supply; ask for a public or pre-signed link instead' + : response.status === 404 + ? ' — the URL does not exist; verify it before retrying' + : response.status === 429 + ? ' — the host is rate-limiting; do not retry immediately' + : ' — the host rejected the request; retrying the same URL will fail again' return { success: false, - message: `Download failed with status ${response.status} ${response.statusText}`, + message: `Download failed with status ${response.status} ${response.statusText}${hint}`, } } diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index 99665a3375e..e7ef07e97f4 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { messageForCopilotFileError, @@ -114,7 +115,19 @@ export const editContentServerTool: BaseServerTool 1) { + return { + success: false, + message: `Patch failed: search string matches ${occurrences} places in "${fileRecord.name}". Add surrounding context to make it unique, or pass replaceAll: true to change every occurrence.`, + } } } finalContent = intent.edit.replaceAll diff --git a/apps/sim/lib/copilot/tools/server/files/share-file.ts b/apps/sim/lib/copilot/tools/server/files/share-file.ts index aafa2281f44..e59ae20c81c 100644 --- a/apps/sim/lib/copilot/tools/server/files/share-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/share-file.ts @@ -11,6 +11,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' @@ -60,7 +61,20 @@ export const shareFileServerTool: BaseServerTool const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as | ShareAuthType | undefined - const password = params.password || (nested?.password as string) || undefined + const rawPassword = params.password || (nested?.password as string) || undefined + // "Protect it with the password in {{SHARE_PW}}" arrives as the literal + // reference — resolve it, or the placeholder becomes the real password. + const resolvedPassword = await resolveEnvReferenceSecretArg({ + userId: context.userId, + workspaceId: context.workspaceId, + value: rawPassword, + argName: 'password', + registry: context.resolvedSecretTraceRegistry, + }) + if (resolvedPassword.error) { + return { success: false, message: resolvedPassword.error } + } + const password = resolvedPassword.value const allowedEmails = params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined 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 6ac90e138d3..b34b7271163 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -381,7 +381,10 @@ export const workspaceFileServerTool: BaseServerTool = { return { success: false, message: 'Workspace ID is required' } } if (!VALID_OPERATIONS.includes(params.operation)) { - return { success: false, message: `Invalid operation "${params.operation}".` } + return { + success: false, + message: `Invalid operation "${params.operation}" (allowed: ${VALID_OPERATIONS.join(', ')}).`, + } } const inputPaths = params.inputs?.files?.map((f) => f.path) ?? [] diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index 272c80d1035..6a517accfb0 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -104,7 +104,9 @@ export const searchOnlineServerTool: BaseServerTool - viewConfigNamesToIds( - { - filter: (args.filter as TablePredicateInput | undefined) ?? null, - sort: (args.sort as SortSpec | undefined) ?? null, - hiddenColumns: args.hiddenColumns as string[] | undefined, - } as TableViewConfig, - columns - ) + // Build the patch from only the keys the caller actually sent: the update + // path shallow-merges this into the stored config, so including an absent + // part as `null` silently wiped a view's saved sort when only the filter + // changed (and vice versa) — the doc promises "omit to keep". + const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => { + const patch: Record = {} + if (args.filter !== undefined) patch.filter = args.filter as TablePredicateInput | null + if (args.sort !== undefined) patch.sort = args.sort as SortSpec | null + if (args.hiddenColumns !== undefined) patch.hiddenColumns = args.hiddenColumns as string[] + return viewConfigNamesToIds(patch as TableViewConfig, columns) + } switch (operation) { case 'list_views': { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index d53f5ddc203..7d87fbdba63 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -1124,7 +1124,7 @@ export async function validateWorkflowSelectorIds( blockType: selector.blockType, field: selector.fieldName, value: selector.value, - error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`, + error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} — they do not exist in this workspace or you lack access. Discover valid ids first (glob/read the matching workspace resource, e.g. environment/credentials.json, knowledgebases/*/meta.json, tables/*/meta.json) instead of guessing${warningInfo}`, }) } else if (result.warning) { // Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors) @@ -1733,7 +1733,7 @@ export async function preValidateCredentialInputs( blockType: credInput.blockType, field: credInput.fieldName, value: credInput.value, - error: `Invalid credential ID "${credInput.value}" - credential does not exist or user doesn't have access${warningInfo}`, + error: `Invalid credential ID "${credInput.value}" for ${credInput.blockType}.${credInput.fieldName} — the field was removed from the block. Read environment/credentials.json for connected credential ids, or use oauth_get_auth_link (via the auth agent) to connect the provider first; never invent credential ids${warningInfo}`, }) } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index e28db5e1a2f..5e7decd1596 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -179,12 +179,26 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined() }) - it('projects completed titles only for successful rows', () => { + it('projects a terminal tense for every settled row, present tense only while running', () => { expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows') expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe( 'Comparing workflows' ) - expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe('Comparing workflows') + // An errored row must not read as still running — the frozen present-tense + // title ("Searching for X" forever) was reported as a stuck tool call. + expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe( + 'Failed comparing workflows' + ) + expect(getToolStatusDisplayTitle('Searching for admin mentions', 'error')).toBe( + 'Failed searching for admin mentions' + ) + expect(getToolStatusDisplayTitle('Comparing workflows', 'cancelled')).toBe( + 'Stopped comparing workflows' + ) + // Non-gerund titles get a prefix rather than a bad rewrite. + expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe( + 'Failed: Read recent emails' + ) }) }) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 1b5a85f435a..2b359073963 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1136,11 +1136,36 @@ export function getToolCompletedTitle(title: string): string | undefined { return past + title.slice(firstWord.length) } +/** + * Rewrite a resolved display title for a FAILED tool call. A gerund title + * becomes "Failed …" ("Searching for X" → "Failed searching for X"); + * anything else gets a "Failed: " prefix. Without this, an errored row kept + * its present-tense activity title verbatim and read as still running. + */ +export function getToolFailedTitle(title: string): string { + const spaceIndex = title.indexOf(' ') + const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex) + if (COMPLETED_VERB_REWRITES[firstWord]) { + return `Failed ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` + } + return `Failed: ${title}` +} + +/** Rewrite a resolved display title for a CANCELLED tool call ("Stopped …"). */ +export function getToolStoppedTitle(title: string): string { + const spaceIndex = title.indexOf(' ') + const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex) + if (COMPLETED_VERB_REWRITES[firstWord]) { + return `Stopped ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` + } + return `Stopped: ${title}` +} + /** * Resolve the final title for a tool status at a rendering boundary. Persisted * and live snapshots intentionally keep the present-tense activity title so a - * running/error row remains truthful; every successful renderer calls this to - * project the corresponding completed title from the canonical verb map. + * RUNNING row remains truthful; terminal states project a tense that says the + * work is over — completed (past tense), failed, or stopped. */ export function getToolStatusDisplayTitle( title: string, @@ -1150,5 +1175,8 @@ export function getToolStatusDisplayTitle( if (status === 'success' && toolName === 'browser_request_takeover') { return 'Resumed browser control' } - return status === 'success' ? (getToolCompletedTitle(title) ?? title) : title + if (status === 'success') return getToolCompletedTitle(title) ?? title + if (status === 'error' || status === 'rejected') return getToolFailedTitle(title) + if (status === 'cancelled' || status === 'aborted') return getToolStoppedTitle(title) + return title } diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index dddf4d6a61e..032ff549b11 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -224,6 +224,8 @@ export function serializeRecentExecutions( * but never filters anything. */ export interface KbTagDefinitionSummary { + /** The tagDefinitionId that update_tag / delete_tag / update_document.tagValues require. */ + id: string tagName: string tagSlot: string fieldType: string @@ -872,12 +874,17 @@ export interface DeploymentData { authType: string customizations: unknown isActive: boolean + allowedEmails?: unknown + outputConfigs?: unknown + includeThinking?: boolean | null + includeToolCalls?: boolean | null } | null mcp: Array<{ serverId: string serverName: string toolId: string toolName: string + parameterDescriptionOverrides?: unknown toolDescription?: string | null }> versions?: Array<{ @@ -911,6 +918,9 @@ export function serializeDeployments(data: DeploymentData): string { : { isDeployed: false } if (data.chat) { + // allowedEmails/outputConfigs/includeThinking/includeToolCalls are the + // fields deploy_as_chat accepts on redeploy; exposing the current values is + // what lets a caller change one setting without blanking the others. result.chat = { id: data.chat.id, identifier: data.chat.identifier, @@ -920,6 +930,10 @@ export function serializeDeployments(data: DeploymentData): string { authType: data.chat.authType, customizations: data.chat.customizations, isActive: data.chat.isActive, + allowedEmails: data.chat.allowedEmails ?? undefined, + outputConfigs: data.chat.outputConfigs ?? undefined, + includeThinking: data.chat.includeThinking ?? undefined, + includeToolCalls: data.chat.includeToolCalls ?? undefined, } } @@ -930,6 +944,9 @@ export function serializeDeployments(data: DeploymentData): string { toolId: m.toolId, toolName: m.toolName, toolDescription: m.toolDescription || undefined, + // What deploy_as_mcp accepts as `parameters` on redeploy; omitting it + // there resets the overrides, so expose the current value. + parameterDescriptionOverrides: m.parameterDescriptionOverrides ?? undefined, })) } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 924f3c01f13..e774db56099 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1925,6 +1925,7 @@ export class WorkspaceVFS { documentCount: kb.docCount, connectorTypes: kb.connectorTypes, tagDefinitions: tagDefinitions.map((definition) => ({ + id: definition.id, tagName: definition.displayName, tagSlot: definition.tagSlot, fieldType: definition.fieldType, @@ -2152,6 +2153,10 @@ export class WorkspaceVFS { authType: chatTable.authType, customizations: chatTable.customizations, isActive: chatTable.isActive, + allowedEmails: chatTable.allowedEmails, + outputConfigs: chatTable.outputConfigs, + includeThinking: chatTable.includeThinking, + includeToolCalls: chatTable.includeToolCalls, }) .from(chatTable) .where(and(eq(chatTable.workflowId, workflowId), isNull(chatTable.archivedAt))), @@ -2162,6 +2167,7 @@ export class WorkspaceVFS { toolId: workflowMcpTool.id, toolName: workflowMcpTool.toolName, toolDescription: workflowMcpTool.toolDescription, + parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides, }) .from(workflowMcpTool) .innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id)) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 9fd5ba27b57..3357deaad0e 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -95,6 +95,7 @@ export interface ListArchivedKnowledgeBasesResult { } export interface KnowledgeBaseCatalogTagDefinition { + id: string knowledgeBaseId: string tagSlot: string displayName: string @@ -386,6 +387,7 @@ export const listKnowledgeBaseCatalog = defineAuthorizedKnowledgeUseCase({ ? [] : await db .select({ + id: knowledgeBaseTagDefinitions.id, knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId, tagSlot: knowledgeBaseTagDefinitions.tagSlot, displayName: knowledgeBaseTagDefinitions.displayName, diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 16ca5d23d8c..1bb5ac7fb1a 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -178,7 +178,11 @@ export async function performCreateKnowledgeConnector( const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig) if (!configValidation.valid) { - return fail(configValidation.error || 'Invalid source configuration', 'validation') + return fail( + configValidation.error || + `The ${connectorType} connector rejected sourceConfig without a reason — re-check its required fields in knowledgebases/connectors/${connectorType}.json before retrying; the same config will fail again.`, + 'validation' + ) } if (connectorConfig.auth.mode === 'apiKey' && apiKey) { diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts index d1b8ff08a66..eb7d04ec1a2 100644 --- a/apps/sim/lib/table/application/context.test.ts +++ b/apps/sim/lib/table/application/context.test.ts @@ -47,7 +47,10 @@ describe('table application context', () => { it('conceals an asserted cross-workspace table before workspace resolution', async () => { await expect( resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-2' }) - ).rejects.toMatchObject({ code: 'not_found', message: 'Table not found' }) + ).rejects.toMatchObject({ + code: 'not_found', + message: expect.stringContaining('not found in this workspace'), + }) expect(loadWorkspace).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts index d87150c0f50..be5c2093ade 100644 --- a/apps/sim/lib/table/application/context.ts +++ b/apps/sim/lib/table/application/context.ts @@ -27,7 +27,12 @@ export async function resolveActiveTableContext(input: { !table || (input.assertedWorkspaceId !== undefined && table.workspaceId !== input.assertedWorkspaceId) ) { - throw new OrchestrationError('not_found', 'Table not found') + // One message for "no such table" and "table in another workspace" so + // existence never leaks across workspaces — but actionable either way. + throw new OrchestrationError( + 'not_found', + `Table "${input.tableId}" not found in this workspace — it may not exist or may belong to a different workspace. Run glob("tables/*") to list the tables you can use here.` + ) } const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId) return { ...workspaceContext, tableId: table.id, table } diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 8f904d3b22c..4bee5012c47 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -224,7 +224,10 @@ export const updateTableUseCase = defineAuthorizedTableUseCase({ const table = await getTableById(current.id) if (!table || table.workspaceId !== context.workspaceId) { - throw new OrchestrationError('not_found', 'Table not found') + throw new OrchestrationError( + 'not_found', + 'Table not found in this workspace — run glob("tables/*") to list valid tables' + ) } const index = resolution?.index ?? @@ -295,7 +298,11 @@ export const deleteTableUseCase = defineAuthorizedTableUseCase({ const { archived } = await deleteTable(context.table.id, generateRequestId(), { expectedWorkspaceId: context.workspaceId, }) - if (!archived) throw new OrchestrationError('not_found', 'Table not found') + if (!archived) + throw new OrchestrationError( + 'not_found', + 'Table not found in this workspace — run glob("tables/*") to list valid tables' + ) return { id: context.table.id, deleted: true as const, diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts index 1363079d812..f726d01ccab 100644 --- a/apps/sim/lib/table/application/views.ts +++ b/apps/sim/lib/table/application/views.ts @@ -61,7 +61,11 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({ (context.table.schema as TableSchema).columns, context.workspaceId ) - if (!view) throw new OrchestrationError('not_found', 'View not found') + if (!view) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) return { view, table: context.table } }, }) @@ -130,7 +134,11 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ (context.table.schema as TableSchema).columns, context.workspaceId ) - if (!existing) throw new OrchestrationError('not_found', 'View not found') + if (!existing) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) const view = await updateTableView({ viewId: input.viewId, tableId: context.table.id, @@ -141,7 +149,11 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ isDefault: input.isDefault, columns: (context.table.schema as TableSchema).columns, }) - if (!view) throw new OrchestrationError('not_found', 'View not found') + if (!view) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) return { view, table: context.table, @@ -181,9 +193,17 @@ export const deleteTableViewUseCase = defineAuthorizedTableUseCase({ (context.table.schema as TableSchema).columns, context.workspaceId ) - if (!existing) throw new OrchestrationError('not_found', 'View not found') + if (!existing) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) const deleted = await deleteTableView(input.viewId, context.table.id, context.workspaceId) - if (!deleted) throw new OrchestrationError('not_found', 'View not found') + if (!deleted) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) return { viewId: input.viewId, viewName: existing.name, table: context.table } }, projectAudit({ result }) { diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index f4388208a89..923b0ee3463 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -65,19 +65,7 @@ export interface PerformChatDeployResult { export async function performChatDeploy( params: ChatDeployPayload ): Promise { - const { - workflowId, - userId, - identifier, - title, - description = '', - authType = 'public', - password, - allowedEmails = [], - outputConfigs = [], - includeThinking = false, - includeToolCalls = false, - } = params + const { workflowId, userId, identifier, title, password } = params /** * Validate the password here rather than only at the HTTP boundary. The @@ -93,10 +81,60 @@ export async function performChatDeploy( } } + /** + * Redeploys merge: any field the caller omitted keeps the existing chat's + * value instead of being reset to a default. Before this, a copilot + * `deploy_as_chat` call that changed only the title silently flipped an + * email/sso-protected chat back to public, wiped its allowlist and output + * configuration, and reset the welcome customizations — the caller had no + * way to know, because none of those fields were readable back. Defaults + * apply only when there is no existing deployment to preserve. + */ + const [existingDeployment] = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + .limit(1) + + const authType = + params.authType ?? + (existingDeployment?.authType as ChatDeployPayload['authType'] | undefined) ?? + 'public' + const description = + params.description !== undefined ? params.description : (existingDeployment?.description ?? '') + const allowedEmails = + params.allowedEmails ?? (existingDeployment?.allowedEmails as string[] | null) ?? [] + const outputConfigs = + params.outputConfigs ?? + (existingDeployment?.outputConfigs as Array<{ blockId: string; path: string }> | null) ?? + [] + const includeThinking = params.includeThinking ?? existingDeployment?.includeThinking ?? false + const includeToolCalls = params.includeToolCalls ?? existingDeployment?.includeToolCalls ?? false + + // Per-field merge (params over existing over defaults): callers routinely + // send a customizations object with only some fields set, and a hard default + // for the rest silently reset the chat's colors and welcome message. + const existingCustomizations = + existingDeployment?.customizations && + typeof existingDeployment.customizations === 'object' && + !Array.isArray(existingDeployment.customizations) + ? (existingDeployment.customizations as { + primaryColor?: string + welcomeMessage?: string + imageUrl?: string + }) + : undefined + const mergedImageUrl = params.customizations?.imageUrl || existingCustomizations?.imageUrl const customizations = { - primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)', - welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?', - ...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}), + primaryColor: + params.customizations?.primaryColor || + existingCustomizations?.primaryColor || + 'var(--brand-hover)', + welcomeMessage: + params.customizations?.welcomeMessage || + existingCustomizations?.welcomeMessage || + 'Hi there! How can I help you today?', + ...(mergedImageUrl ? { imageUrl: mergedImageUrl } : {}), } /** @@ -162,12 +200,6 @@ export async function performChatDeploy( encryptedPassword = encrypted } - const [existingDeployment] = await db - .select() - .from(chat) - .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) - .limit(1) - /** * A password-protected chat must end up with a stored password. Both HTTP * routes already reject this; without the same guard here a copilot diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 575b6da5c35..17f863fce68 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -350,12 +350,29 @@ async function resolveCopilotEnvReferences( return } - const pending: Array<{ paramId: string; value: string }> = [] + // Models improvise reference syntax: after `{{NAME}}`, `$NAME` and the bare + // variable name are the common fallbacks — both previously went upstream as + // the literal credential and failed with an undiagnosable 401. `{{NAME}}` + // and `$NAME` are unambiguous references (a real key never starts with `$`), + // so a missing variable is a hard error. A bare name is a reference only + // when a variable by that exact name exists (`soft`): plenty of real API + // keys match the identifier pattern, and those must pass through verbatim. + const pending: Array<{ paramId: string; value: string; soft?: boolean }> = [] for (const [paramId, paramDef] of Object.entries(tool.params || {})) { if (paramDef?.visibility !== 'user-only') continue const value = params[paramId] - if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + if (typeof value !== 'string') continue + if (value.startsWith('{{') && value.endsWith('}}')) { pending.push({ paramId, value }) + continue + } + const dollar = value.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/) + if (dollar) { + pending.push({ paramId, value: `{{${dollar[1]}}}` }) + continue + } + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + pending.push({ paramId, value: `{{${value}}}`, soft: true }) } } @@ -374,7 +391,7 @@ async function resolveCopilotEnvReferences( const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) - for (const { paramId, value } of pending) { + for (const { paramId, value, soft } of pending) { const missingKeys: string[] = [] const resolved = resolveEnvVarReferences(value, envVars, { allowEmbedded: false, @@ -386,6 +403,9 @@ async function resolveCopilotEnvReferences( }, }) if (missingKeys.length > 0) { + // A bare name that matches no variable is treated as the literal + // credential it probably is; only explicit reference forms error. + if (soft) continue const scopeHint = scope.workspaceId ? '' : ' (no workspace context — only personal variables are available here)' diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index efbca622015..80ab44dacf4 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -617,6 +617,16 @@ export function createUserToolSchema( .filter(Boolean) .join(' ') } + // Copilot agents never see secret values, only names — so tell them the + // reference form works here, or they paste placeholders that fail upstream. + if (visibility === 'user-only' && surface === 'copilot') { + propertySchema.description = [ + propertySchema.description, + 'Accepts an environment-variable reference like {{VAR_NAME}} (see environment/variables.json), resolved server-side.', + ] + .filter(Boolean) + .join(' ') + } schema.properties[paramId] = propertySchema if (param.required && paramId !== hostedApiKeyParam) { From 0ce8ded1e7c84c4f4b225ffb0b629f6fd2309fea Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 14 Aug 2026 20:26:26 -0700 Subject: [PATCH 080/103] improvement(credential-groups): align settings surface with the shared page patterns (#6727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(credential-groups): align settings surface with the shared page patterns - drop the row "..." menu; a row opening a detail page carries the chevron only, and Delete moves to the detail header behind a confirm modal - replace the hand-rolled Save chip with saveDiscardActions, and wire useSettingsUnsavedGuard so detail edits survive tab switches - fix swapped staleTime constants: the list carried Infinity, which combined with the app-wide retryOnMount:false to cache one transient failure until a full page reload - evict the detail query on delete, and keep the bots prop referentially stable so a refetch cannot drop a queued Slack authorization message - reset the detail tab param on open and close so a stale link cannot open the next group on the previous group's tab - match peer rows (iconFilled + --text-icon), drop a bespoke max-w and a duplicated gap-7, align no-results copy and the Slack modal field gutter * improvement(credential-groups): hold first paint for a deep-linked group Matches the data-drains list: a deep link whose id is still resolving no longer flashes the list chrome before jumping to the detail. Keys the detail by group id so lifted draft state can never carry across groups. * fix(credential-groups): await the refetch before clearing the edit buffer The update mutation fired its invalidations without returning them, so mutateAsync resolved before the refetch landed. Callers that clear their draft on success then fell back onto the pre-save cache and flashed the old name and description until the refetch completed — or kept showing them if it failed. --- .../components/credential-group-detail.tsx | 126 +++++++- .../components/credential-group-details.tsx | 276 ++++++++---------- .../credential-group-invite-modal.tsx | 7 +- .../components/credential-groups-settings.tsx | 90 +++--- .../components/slack-managed-users-modal.tsx | 122 ++++---- apps/sim/hooks/queries/credential-groups.ts | 28 +- 6 files changed, 374 insertions(+), 275 deletions(-) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 3bab5af6bc0..672ed564a66 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -5,6 +5,7 @@ import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment, CredentialGroupEnrollmentConnection, @@ -13,6 +14,7 @@ import type { import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { credentialGroupTabParam, credentialGroupTabUrlKeys, @@ -26,12 +28,15 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' import { useCredentialGroupDetail, + useDeleteCredentialGroup, useResendCredentialGroupEnrollment, useRevokeCredentialGroupEnrollment, + useUpdateCredentialGroup, } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' @@ -120,12 +125,17 @@ export function CredentialGroupDetail({ }) const resend = useResendCredentialGroupEnrollment() const revoke = useRevokeCredentialGroupEnrollment() + const updateGroup = useUpdateCredentialGroup() + const deleteGroup = useDeleteCredentialGroup() const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { ...credentialGroupTabParam.parser, ...credentialGroupTabUrlKeys, }) const [showInvite, setShowInvite] = useState(false) + const [showDelete, setShowDelete] = useState(false) const [revokingEnrollmentId, setRevokingEnrollmentId] = useState(null) + const [draftName, setDraftName] = useState(null) + const [draftDescription, setDraftDescription] = useState(null) const credentialGroup = detail.data?.pages[0]?.credentialGroup const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] const revokingEnrollment = revokingEnrollmentId @@ -144,14 +154,65 @@ export function CredentialGroupDetail({ slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId)) ) + const name = draftName ?? credentialGroup?.name ?? '' + const description = draftDescription ?? credentialGroup?.description ?? '' + const normalizedDescription = description.trim() || null + const detailsDirty = Boolean( + credentialGroup && + (name.trim() !== credentialGroup.name || + normalizedDescription !== credentialGroup.description) + ) + const guard = useSettingsUnsavedGuard({ isDirty: detailsDirty }) + + const discardDetails = () => { + setDraftName(null) + setDraftDescription(null) + } + + const handleSaveDetails = async () => { + if (!credentialGroup || !name.trim()) return + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { name: name.trim(), description: normalizedDescription }, + }) + discardDetails() + toast.success('Details saved') + } catch (error) { + toast.error(getErrorMessage(error, 'Could not save details')) + } + } + + /** + * Each tab owns its own primary action: Details commits the edited name and + * description, People invites more users. Delete is available from both. + */ const actions: SettingsAction[] = credentialGroup ? [ + ...(activeTab === 'details' + ? saveDiscardActions({ + dirty: detailsDirty, + saving: updateGroup.isPending, + onSave: () => void handleSaveDetails(), + onDiscard: discardDetails, + saveDisabled: !name.trim(), + saveTooltip: name.trim() ? undefined : 'Name is required', + }) + : [ + { + text: 'Invite users', + icon: Plus, + variant: 'primary' as const, + onSelect: () => setShowInvite(true), + disabled: credentialGroup.status !== 'active' || !configurationReady, + }, + ]), { - text: 'Invite users', - icon: Plus, - variant: 'primary', - onSelect: () => setShowInvite(true), - disabled: credentialGroup.status !== 'active' || !configurationReady, + id: 'delete', + text: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onSelect: () => setShowDelete(true), + disabled: deleteGroup.isPending, }, ] : [] @@ -180,15 +241,25 @@ export function CredentialGroupDetail({ } } - const handleBack = () => { - void setActiveTab(null, { history: 'replace' }) - onBack() + const handleDelete = async () => { + if (!credentialGroup) return + try { + await deleteGroup.mutateAsync({ workspaceId, groupId }) + setShowDelete(false) + onBack() + } catch (error) { + toast.error(getErrorMessage(error, 'Could not delete credential group')) + } } return ( <> guard.guardBack(onBack), + }} title={credentialGroup?.name ?? 'Credential group'} description={credentialGroup?.description ?? undefined} actions={actions} @@ -198,7 +269,7 @@ export function CredentialGroupDetail({ {getErrorMessage(detail.error, "Couldn't load credential group")} ) : detail.isPending || !credentialGroup ? null : ( -
+ <> {activeTab === 'details' && ( - + )} {activeTab === 'people' && ( @@ -233,7 +311,8 @@ export function CredentialGroupDetail({ return ( } + icon={} + iconFilled title={enrollment.email} description={ @@ -272,7 +351,7 @@ export function CredentialGroupDetail({ )} )} -
+ )}
{credentialGroup && ( @@ -296,6 +375,27 @@ export function CredentialGroupDetail({ disabled: revoke.isPending, }} /> + !open && !deleteGroup.isPending && setShowDelete(false)} + srTitle='Delete credential group' + title='Delete credential group' + text={[ + `Delete ${credentialGroup?.name ?? 'this credential group'}?`, + { text: ' This cannot be undone.', error: true }, + ]} + dismissLabel='Cancel' + confirm={{ + label: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onClick: handleDelete, + disabled: deleteGroup.isPending, + }} + /> + ) } diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index 501f0a7b366..9af6a3da772 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' +import type { WorkspaceCredential } from '@/lib/api/contracts' import type { CredentialGroup, CredentialGroupOption, @@ -28,9 +29,17 @@ import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack- import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +/** Stable identity so a pending/errored credentials query cannot churn the modal's `bots` prop. */ +const EMPTY_SLACK_BOTS: WorkspaceCredential[] = [] + interface CredentialGroupDetailsProps { credentialGroup: CredentialGroup workspaceId: string + /** Edited name; committed by the panel header's Save action, which owns the dirty state. */ + name: string + onNameChange: (name: string) => void + description: string + onDescriptionChange: (description: string) => void } function toOptionUpdateInput( @@ -52,6 +61,10 @@ function toOptionUpdateInput( export function CredentialGroupDetails({ credentialGroup, workspaceId, + name, + onNameChange, + description, + onDescriptionChange, }: CredentialGroupDetailsProps) { const updateGroup = useUpdateCredentialGroup() const slackBots = useWorkspaceCredentials({ @@ -59,15 +72,9 @@ export function CredentialGroupDetails({ type: 'service_account', providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, }) - const [name, setName] = useState(credentialGroup.name) - const [description, setDescription] = useState(credentialGroup.description ?? '') - const [slackSetupOpen, setSlackSetupOpen] = useState(false) - const [slackSetupCredentialId, setSlackSetupCredentialId] = useState() + const [slackSetup, setSlackSetup] = useState<{ credentialId?: string } | null>(null) const [removingProvider, setRemovingProvider] = useState(null) - const normalizedDescription = description.trim() || null - const detailsDirty = - name.trim() !== credentialGroup.name || normalizedDescription !== credentialGroup.description const isUpdating = updateGroup.isPending const updateOptions = async ( @@ -100,8 +107,7 @@ export function CredentialGroupDetails({ } const openSlackSetup = (credentialId?: string) => { - setSlackSetupCredentialId(credentialId) - setSlackSetupOpen(true) + setSlackSetup({ credentialId }) } const handleProviderAction = (provider: CredentialGroupProvider) => { @@ -117,20 +123,6 @@ export function CredentialGroupDetails({ throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`) } - const handleSaveDetails = async () => { - if (!detailsDirty || !name.trim() || isUpdating) return - try { - await updateGroup.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - body: { name: name.trim(), description: normalizedDescription }, - }) - toast.success('Details saved') - } catch (error) { - toast.error(getErrorMessage(error, 'Could not save details')) - } - } - const handleRemoveProvider = async () => { if (!removingProvider) return const service = getCredentialGroupProviderService(removingProvider) @@ -142,141 +134,129 @@ export function CredentialGroupDetails({ return ( <> -
- void handleSaveDetails()} - disabled={!name.trim() || isUpdating} - > - {isUpdating ? 'Saving...' : 'Save changes'} - - ) : undefined - } - > -
- - setName(event.target.value)} - error={!name.trim()} - /> - - - setDescription(event.target.value)} - placeholder='What these accounts will be used for' - rows={3} - /> - -
-
+ +
+ + onNameChange(event.target.value)} + error={!name.trim()} + /> + + + onDescriptionChange(event.target.value)} + placeholder='What these accounts will be used for' + rows={3} + /> + +
+
- -
- {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { - const service = getCredentialGroupProviderService(provider) - const support = getCredentialGroupProviderSupport(provider) - const option = credentialGroup.options.find( - (candidate) => candidate.provider === provider - ) - const ProviderIcon = service.icon - const slackBot = - provider === 'slack' && option?.provider === 'slack' - ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) - : undefined - const slackNeedsSetup = - provider === 'slack' && - option?.provider === 'slack' && - (!slackBot || option.configurationStatus !== 'ready') - const descriptionText = - provider === 'slack' && option - ? slackBot - ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` - : slackBots.isPending - ? 'Loading custom Slack app...' - : 'Custom Slack app unavailable' - : support.description + +
+ {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { + const service = getCredentialGroupProviderService(provider) + const support = getCredentialGroupProviderSupport(provider) + const option = credentialGroup.options.find( + (candidate) => candidate.provider === provider + ) + const ProviderIcon = service.icon + const slackBot = + provider === 'slack' && option?.provider === 'slack' + ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) + : undefined + const slackNeedsSetup = + provider === 'slack' && + option?.provider === 'slack' && + (!slackBot || option.configurationStatus !== 'ready') + const descriptionText = + provider === 'slack' && option + ? slackBot + ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` + : slackBots.isPending + ? 'Loading custom Slack app...' + : 'Custom Slack app unavailable' + : support.description - return ( - } - title={service.name} - description={descriptionText} - badge={ - option && !slackNeedsSetup ? ( - Connected - ) : undefined - } - trailing={ - option ? ( -
- {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( - openSlackSetup(slackBot.id)} disabled={isUpdating}> - Continue setup - - ) : null} - - openSlackSetup( - option?.provider === 'slack' - ? option.slackBotCredentialId - : undefined - ), - disabled: isUpdating, - }, - ] - : []), - { - label: 'Remove', - destructive: true, - onSelect: () => setRemovingProvider(provider), - disabled: isUpdating, - }, - ]} - /> -
- ) : ( - handleProviderAction(provider)} - disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} - > - {support.configuration === 'oauth' ? 'Add' : 'Set up'} - - ) - } - /> - ) - })} -
-
-
+ return ( + } + title={service.name} + description={descriptionText} + badge={ + option && !slackNeedsSetup ? ( + Connected + ) : undefined + } + trailing={ + option ? ( +
+ {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( + openSlackSetup(slackBot.id)} disabled={isUpdating}> + Continue setup + + ) : null} + + openSlackSetup( + option?.provider === 'slack' + ? option.slackBotCredentialId + : undefined + ), + disabled: isUpdating, + }, + ] + : []), + { + label: 'Remove', + destructive: true, + onSelect: () => setRemovingProvider(provider), + disabled: isUpdating, + }, + ]} + /> +
+ ) : ( + handleProviderAction(provider)} + disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} + > + {support.configuration === 'oauth' ? 'Add' : 'Set up'} + + ) + } + /> + ) + })} +
+ { - setSlackSetupOpen(nextOpen) - if (!nextOpen) setSlackSetupCredentialId(undefined) + if (!nextOpen) setSlackSetup(null) }} - bots={slackBots.data ?? []} + bots={slackBots.data ?? EMPTY_SLACK_BOTS} isLoading={slackBots.isPending} error={slackBots.error} - initialCredentialId={slackSetupCredentialId} + initialCredentialId={slackSetup?.credentialId} /> item.email).join(', ')}` : `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}` ) - } catch (error) { - setDeliveryError(getErrorMessage(error, 'Failed to send invitations')) + } catch { + return } } @@ -100,7 +100,8 @@ export function CredentialGroupInviteModal({ disabled={invite.isPending} /> - {deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)} + {deliveryError ?? + (invite.error ? getErrorMessage(invite.error, 'Failed to send invitations') : null)} (null) const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, { ...credentialGroupIdParam.parser, ...credentialGroupIdUrlKeys, }) - const deletingGroup = groups.find((group) => group.id === deletingGroupId) + /** + * The detail view's tab is scoped to one group, so both transitions reset it — + * otherwise a `credential-group-id` that never resolves leaves + * `credential-group-tab` behind and the next group opens on the previous + * group's tab. nuqs batches these same-tick writes into one URL update. + */ + const [, setSelectedTab] = useQueryState(credentialGroupTabParam.key, { + ...credentialGroupTabParam.parser, + ...credentialGroupTabUrlKeys, + }) + const openGroup = (groupId: string) => { + void setSelectedGroupId(groupId) + void setSelectedTab(null) + } + const closeGroup = () => { + void setSelectedGroupId(null, { history: 'replace' }) + void setSelectedTab(null) + } const selectedGroup = selectedGroupId ? groups.find((group) => group.id === selectedGroupId) : undefined @@ -59,22 +75,20 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin }, ] - const handleDelete = async () => { - if (!deletingGroupId) return - try { - await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId }) - setDeletingGroupId(null) - } catch { - return - } - } + /** + * Hold the first paint while a deep-linked id could still resolve, so a valid + * link never flashes the list before jumping to it. A dead id still falls back + * to the list. + */ + if (selectedGroupId !== null && isPending) return null if (selectedGroup) { return ( void setSelectedGroupId(null, { history: 'replace' })} + onBack={closeGroup} /> ) } @@ -97,18 +111,24 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin ) : isPending ? null : groups.length === 0 ? ( Click "Create group" above to get started ) : filtered.length === 0 ? ( - No groups match "{search}" + + No credential groups found matching "{search}" + ) : (
{filtered.map((group) => { const optionCount = group.options.length + const accountTypes = `${optionCount} account type${optionCount === 1 ? '' : 's'}` return ( } + icon={} + iconFilled title={group.name} - description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`} - onClick={() => void setSelectedGroupId(group.id)} + description={ + group.description ? `${accountTypes} · ${group.description}` : accountTypes + } + onClick={() => openGroup(group.id)} clickLabel={`Open ${group.name}`} navigable badge={ @@ -116,18 +136,6 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin Disabled ) : undefined } - trailing={ - setDeletingGroupId(group.id), - }, - ]} - /> - } /> ) })} @@ -137,25 +145,9 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin void setSelectedGroupId(groupId)} + onCreated={openGroup} workspaceId={workspaceId} /> - !open && !deleteGroup.isPending && setDeletingGroupId(null)} - srTitle='Delete credential group' - title='Delete credential group' - text={[ - `Delete ${deletingGroup?.name ?? 'this credential group'}?`, - { text: ' This cannot be undone.', error: true }, - ]} - dismissLabel='Cancel' - confirm={{ - label: deleteGroup.isPending ? 'Deleting...' : 'Delete', - onClick: handleDelete, - disabled: deleteGroup.isPending, - }} - /> ) } diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx index a6bdef02c80..34d3052565c 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -94,50 +94,78 @@ export function SlackManagedUsersModal({ const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId) + const reset = () => { + popup.current?.close() + popup.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + expectedState.current = null + expectedCredentialId.current = null + setSelectedCredentialId(null) + setClientId('') + setClientSecret('') + setPending(false) + startAuthorization.reset() + } + + const handleAuthorizationMessage = (message: SlackManagedUsersMessage) => { + if (!expectedState.current || message.state !== expectedState.current) return + const verifiedCredentialId = expectedCredentialId.current + expectedState.current = null + expectedCredentialId.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + popup.current?.close() + popup.current = null + setPending(false) + if (!message.ok) { + const notification = getSlackManagedUsersFailureNotification(message.reason) + if (notification.variant === 'warning') toast.warning(notification.message) + else toast.error(notification.message) + return + } + if ( + message.credentialGroupId !== credentialGroupId || + !verifiedCredentialId || + message.slackBotCredentialId !== verifiedCredentialId + ) { + toast.error('Slack app verification failed. Please try again.') + return + } + if (!bots.some((bot) => bot.id === verifiedCredentialId)) { + toast.error('The verified Slack app is no longer available.') + return + } + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), + }) + toast.success('Slack configured') + onOpenChange(false) + reset() + } + + /** + * The subscription's identity is `open` alone. Routing the handler through a + * ref keeps a `bots` refetch from closing and reopening the channel mid-flow, + * which would drop an already-queued authorization message from the popup. + */ + const messageHandler = useRef(handleAuthorizationMessage) + useEffect(() => { + messageHandler.current = handleAuthorizationMessage + }) + useEffect(() => { if (!open) return const channel = new BroadcastChannel(CHANNEL_NAME) channel.onmessage = (event: MessageEvent) => { if (!isSlackManagedUsersMessage(event.data)) return - if (!expectedState.current || event.data.state !== expectedState.current) return - const verifiedCredentialId = expectedCredentialId.current - expectedState.current = null - expectedCredentialId.current = null - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popupWatcher.current = null - popup.current?.close() - popup.current = null - setPending(false) - if (!event.data.ok) { - const notification = getSlackManagedUsersFailureNotification(event.data.reason) - if (notification.variant === 'warning') toast.warning(notification.message) - else toast.error(notification.message) - return - } - if ( - event.data.credentialGroupId !== credentialGroupId || - !verifiedCredentialId || - event.data.slackBotCredentialId !== verifiedCredentialId - ) { - toast.error('Slack app verification failed. Please try again.') - return - } - if (!bots.some((bot) => bot.id === verifiedCredentialId)) { - toast.error('The verified Slack app is no longer available.') - return - } - void queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.list(workspaceId), - }) - void queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), - }) - toast.success('Slack configured') - onOpenChange(false) - reset() + messageHandler.current(event.data) } return () => channel.close() - }, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId]) + }, [open]) useEffect( () => () => { @@ -147,20 +175,6 @@ export function SlackManagedUsersModal({ [] ) - const reset = () => { - popup.current?.close() - popup.current = null - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popupWatcher.current = null - expectedState.current = null - expectedCredentialId.current = null - setSelectedCredentialId(null) - setClientId('') - setClientSecret('') - setPending(false) - startAuthorization.reset() - } - const handleOpenChange = (nextOpen: boolean) => { if (pending && !nextOpen) return onOpenChange(nextOpen) @@ -247,12 +261,12 @@ export function SlackManagedUsersModal({ {isLoading ? ( -
+
- +
) : noBots ? ( -

+

Add a custom Slack app from Integrations before adding Slack to this group.

) : ( diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index d45dbed396d..c231296b2cc 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -29,7 +29,7 @@ export function useCredentialGroups(workspaceId?: string) { return fetchCredentialGroupList(workspaceId, signal) }, enabled: Boolean(workspaceId), - staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) } @@ -49,7 +49,10 @@ export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) getNextPageParam: (lastPage: ContractJsonResponse) => lastPage.nextCursor ?? undefined, enabled: Boolean(workspaceId && groupId), - staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + // An infinite staleTime never goes stale, so the app-wide `retryOnMount: false` + // would cache one transient failure for the life of the QueryClient. + retryOnMount: true, }) } @@ -78,6 +81,9 @@ export function useDeleteCredentialGroup() { }), onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + queryClient.removeQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) }, }) } @@ -98,12 +104,18 @@ export function useUpdateCredentialGroup() { params: { id: workspaceId, groupId }, body, }), - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), - }) - }, + // Returned so `mutateAsync` resolves only once the refetch has landed. Callers + // clear their edit buffer on success, which would otherwise fall back onto the + // pre-save cache and flash the old values. + onSettled: (_data, _error, variables) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(variables.workspaceId), + }), + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }), + ]), }) } From 6006870f02d0308c2c3cff6b8696bfaad6e7aa10 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 21:47:06 -0700 Subject: [PATCH 081/103] feat(credentials): add v2 credential lifecycle APIs (#6664) * feat(credentials): add v2 OAuth connection APIs * fix(credentials): preserve active OAuth connection links * fix(credentials): bind OAuth links to connection intent * feat(credentials): complete v2 credential lifecycle * fix(credentials): make disconnect idempotent * fix(credentials): stabilize oauth draft retries * fix(credentials): bind oauth callbacks to drafts * fix(credentials): fail closed on oauth completion * fix(credentials): bind shopify completion to oauth state * fix(credentials): align custom oauth reconnects * fix(credentials): centralize application authorization * fix(credentials): keep OAuth draft intent immutable * fix(credentials): allow renamed reconnect targets * fix(credentials): close OAuth draft edge cases * fix(credentials): fail closed without breaking auth * fix(credentials): preserve migrated route behavior * feat(credentials): add provider search * fix(credentials): prevent stale secrets and drafts --- .../migrate-application-operation/SKILL.md | 41 + .../commands/migrate-application-operation.md | 41 + .../commands/migrate-application-operation.md | 41 + .../docs/en/integrations/logrocket.mdx | 1 - apps/docs/openapi-v2-billing.json | 2 +- apps/docs/openapi-v2-files-audit.json | 2 +- apps/docs/openapi-v2-knowledge.json | 2 +- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-resources.json | 1964 ++++++++++++----- apps/docs/openapi-v2-tables.json | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- apps/sim/app/api/auth/accounts/route.ts | 82 +- .../auth/instagram/authorize/route.test.ts | 109 + .../app/api/auth/instagram/authorize/route.ts | 60 +- .../api/auth/oauth/connections/route.test.ts | 6 +- .../app/api/auth/oauth/connections/route.ts | 156 +- .../api/auth/oauth/disconnect/route.test.ts | 6 +- .../app/api/auth/oauth/disconnect/route.ts | 148 +- .../api/auth/oauth2/authorize/route.test.ts | 493 ++--- .../app/api/auth/oauth2/authorize/route.ts | 171 +- .../auth/oauth2/callback/instagram/route.ts | 24 +- .../oauth2/callback/shopify/route.test.ts | 135 ++ .../api/auth/oauth2/callback/shopify/route.ts | 77 +- .../api/auth/oauth2/shopify/store/route.ts | 97 +- .../api/auth/shopify/authorize/route.test.ts | 80 + .../app/api/auth/shopify/authorize/route.ts | 58 +- .../app/api/auth/trello/authorize/route.ts | 23 +- apps/sim/app/api/auth/trello/store/route.ts | 21 +- .../credentials/[id]/members/route.test.ts | 154 ++ .../app/api/credentials/[id]/members/route.ts | 465 +--- apps/sim/app/api/credentials/[id]/route.ts | 247 +-- apps/sim/app/api/credentials/draft/route.ts | 119 +- .../app/api/credentials/memberships/route.ts | 165 +- apps/sim/app/api/credentials/route.test.ts | 112 +- apps/sim/app/api/credentials/route.ts | 313 +-- .../credentials/[credentialId]/route.test.ts | 77 + .../v2/credentials/[credentialId]/route.ts | 30 + .../v2/credentials/connections/route.test.ts | 93 + .../api/v2/credentials/connections/route.ts | 32 + .../v2/credentials/providers/route.test.ts | 140 ++ .../app/api/v2/credentials/providers/route.ts | 27 + apps/sim/app/api/v2/credentials/route.test.ts | 129 +- apps/sim/app/api/v2/credentials/route.ts | 50 +- .../oauth/credential-connected/page.test.tsx | 36 + .../app/oauth/credential-connected/page.tsx | 39 + apps/sim/hooks/queries/credentials.ts | 3 +- .../utils/fetch-workspace-credentials.ts | 17 +- apps/sim/hooks/use-oauth-return.ts | 5 +- apps/sim/lib/api/contracts/credentials.ts | 75 +- .../api/contracts/oauth-connections.test.ts | 12 + .../lib/api/contracts/oauth-connections.ts | 59 +- .../v2/__tests__/list-pagination.test.ts | 3 + apps/sim/lib/api/contracts/v2/credentials.ts | 392 +++- .../lib/api/contracts/v2/openapi/resources.ts | 199 +- apps/sim/lib/api/contracts/v2/shared.ts | 24 +- .../server/routes/internal-json-route.test.ts | 58 + .../api/server/routes/internal-json-route.ts | 15 +- apps/sim/lib/auth/auth.ts | 40 +- .../execute-credential-use-case.ts | 14 + .../manage-application-use-cases.test.ts | 52 + .../handlers/management/manage-credential.ts | 132 +- .../lib/copilot/tools/handlers/oauth.test.ts | 352 +-- apps/sim/lib/copilot/tools/handlers/oauth.ts | 200 +- .../authorized-workspace-use-case.test.ts | 59 + .../authorized-workspace-use-case.ts | 6 +- apps/sim/lib/core/application/forbidden.ts | 4 + apps/sim/lib/core/application/operation.ts | 5 +- .../__tests__/webhook-deactivation.test.ts | 40 +- apps/sim/lib/credentials/access.ts | 9 + .../sim/lib/credentials/api/route-policies.ts | 54 + .../credentials/application/authorization.ts | 10 + .../authorized-credential-use-case.test.ts | 105 + .../authorized-credential-use-case.ts | 82 + .../application/authorized-user-use-case.ts | 102 + .../application/connection-target.test.ts | 151 ++ .../application/connection-target.ts | 107 + .../create-credential-connection.test.ts | 133 ++ .../create-credential-connection.ts | 69 + .../application/credential-context.ts | 32 + .../application/credential-crud.ts | 244 ++ .../application/credential-members.ts | 150 ++ .../delete-many-credentials.test.ts | 124 ++ .../application/delete-many-credentials.ts | 114 + .../launch-credential-connection.test.ts | 93 + .../launch-credential-connection.ts | 52 + .../list-credential-providers.test.ts | 108 + .../application/list-credential-providers.ts | 41 + .../list-workspace-credentials.test.ts | 14 +- .../application/oauth-accounts.test.ts | 84 + .../credentials/application/oauth-accounts.ts | 132 ++ .../application/operations.test.ts | 36 + .../lib/credentials/application/operations.ts | 159 +- .../prepare-credential-connection.test.ts | 118 + .../prepare-credential-connection.ts | 109 + .../credentials/application/presentation.ts | 59 + .../application/provider-catalog.test.ts | 237 ++ .../application/provider-catalog.ts | 356 +++ .../application/save-credential-draft.test.ts | 134 ++ .../application/save-credential-draft.ts | 67 + .../application/service-account.test.ts | 326 +++ .../application/service-account.ts | 210 ++ .../sim/lib/credentials/connect-draft.test.ts | 83 + apps/sim/lib/credentials/connect-draft.ts | 107 +- apps/sim/lib/credentials/deletion.ts | 30 + apps/sim/lib/credentials/draft-constants.ts | 2 + apps/sim/lib/credentials/draft-hooks.test.ts | 45 + apps/sim/lib/credentials/draft-hooks.ts | 29 +- .../lib/credentials/draft-processor.test.ts | 152 ++ apps/sim/lib/credentials/draft-processor.ts | 80 +- apps/sim/lib/credentials/members.test.ts | 20 + apps/sim/lib/credentials/members.ts | 266 +++ apps/sim/lib/credentials/oauth-accounts.ts | 152 ++ .../orchestration/credential-create.ts | 155 +- .../credentials/orchestration/index.test.ts | 139 +- .../lib/credentials/orchestration/index.ts | 444 ++-- apps/sim/lib/credentials/queries.test.ts | 31 + apps/sim/lib/credentials/queries.ts | 86 +- .../credential-visibility.server.ts | 23 +- apps/sim/lib/oauth/shopify-state.test.ts | 92 + apps/sim/lib/oauth/shopify-state.ts | 111 + apps/sim/lib/oauth/shopify.ts | 114 + findings.txt | 19 + scripts/check-api-validation-contracts.ts | 4 +- scripts/openapi/documents.test.ts | 4 +- 124 files changed, 10370 insertions(+), 3479 deletions(-) create mode 100644 apps/sim/app/api/auth/instagram/authorize/route.test.ts create mode 100644 apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts create mode 100644 apps/sim/app/api/auth/shopify/authorize/route.test.ts create mode 100644 apps/sim/app/api/credentials/[id]/members/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/route.ts create mode 100644 apps/sim/app/api/v2/credentials/connections/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/connections/route.ts create mode 100644 apps/sim/app/api/v2/credentials/providers/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/providers/route.ts create mode 100644 apps/sim/app/oauth/credential-connected/page.test.tsx create mode 100644 apps/sim/app/oauth/credential-connected/page.tsx create mode 100644 apps/sim/lib/copilot/application/execute-credential-use-case.ts create mode 100644 apps/sim/lib/credentials/api/route-policies.ts create mode 100644 apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts create mode 100644 apps/sim/lib/credentials/application/authorized-credential-use-case.ts create mode 100644 apps/sim/lib/credentials/application/authorized-user-use-case.ts create mode 100644 apps/sim/lib/credentials/application/connection-target.test.ts create mode 100644 apps/sim/lib/credentials/application/connection-target.ts create mode 100644 apps/sim/lib/credentials/application/create-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/create-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/credential-context.ts create mode 100644 apps/sim/lib/credentials/application/credential-crud.ts create mode 100644 apps/sim/lib/credentials/application/credential-members.ts create mode 100644 apps/sim/lib/credentials/application/delete-many-credentials.test.ts create mode 100644 apps/sim/lib/credentials/application/delete-many-credentials.ts create mode 100644 apps/sim/lib/credentials/application/launch-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/launch-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/list-credential-providers.test.ts create mode 100644 apps/sim/lib/credentials/application/list-credential-providers.ts create mode 100644 apps/sim/lib/credentials/application/oauth-accounts.test.ts create mode 100644 apps/sim/lib/credentials/application/oauth-accounts.ts create mode 100644 apps/sim/lib/credentials/application/operations.test.ts create mode 100644 apps/sim/lib/credentials/application/prepare-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/prepare-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/presentation.ts create mode 100644 apps/sim/lib/credentials/application/provider-catalog.test.ts create mode 100644 apps/sim/lib/credentials/application/provider-catalog.ts create mode 100644 apps/sim/lib/credentials/application/save-credential-draft.test.ts create mode 100644 apps/sim/lib/credentials/application/save-credential-draft.ts create mode 100644 apps/sim/lib/credentials/application/service-account.test.ts create mode 100644 apps/sim/lib/credentials/application/service-account.ts create mode 100644 apps/sim/lib/credentials/connect-draft.test.ts create mode 100644 apps/sim/lib/credentials/draft-constants.ts create mode 100644 apps/sim/lib/credentials/draft-hooks.test.ts create mode 100644 apps/sim/lib/credentials/draft-processor.test.ts create mode 100644 apps/sim/lib/credentials/members.test.ts create mode 100644 apps/sim/lib/credentials/members.ts create mode 100644 apps/sim/lib/credentials/oauth-accounts.ts create mode 100644 apps/sim/lib/oauth/shopify-state.test.ts create mode 100644 apps/sim/lib/oauth/shopify-state.ts create mode 100644 apps/sim/lib/oauth/shopify.ts create mode 100644 findings.txt diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index e7f0039e84b..6caa5ff7c57 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -78,6 +78,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -270,6 +307,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.claude/commands/migrate-application-operation.md b/.claude/commands/migrate-application-operation.md index bc61333c358..d8048553e78 100644 --- a/.claude/commands/migrate-application-operation.md +++ b/.claude/commands/migrate-application-operation.md @@ -77,6 +77,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -269,6 +306,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.cursor/commands/migrate-application-operation.md b/.cursor/commands/migrate-application-operation.md index 9fac674ca6f..0742f452523 100644 --- a/.cursor/commands/migrate-application-operation.md +++ b/.cursor/commands/migrate-application-operation.md @@ -73,6 +73,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -265,6 +302,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/apps/docs/content/docs/en/integrations/logrocket.mdx b/apps/docs/content/docs/en/integrations/logrocket.mdx index b986ace5ccb..de718d20190 100644 --- a/apps/docs/content/docs/en/integrations/logrocket.mdx +++ b/apps/docs/content/docs/en/integrations/logrocket.mdx @@ -188,4 +188,3 @@ Register a release version in LogRocket so uploaded source maps can decode stack | --------- | ---- | ----------- | | `version` | string | Release version that was registered | - diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 77de6a7d2ca..122cc4f3e99 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -479,7 +479,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 045fd369674..f2e459d88fa 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2268,7 +2268,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 6f3da73eccb..a1b2d4d6eaf 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2213,7 +2213,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 46f3e4fec47..cc4cfd86202 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -601,7 +601,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 30d0b7a5ff5..a9b3861e23c 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -39,7 +39,7 @@ }, { "name": "Credentials", - "description": "List OAuth and service-account connections without secret material." + "description": "Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material." }, { "name": "Secrets", @@ -1574,7 +1574,7 @@ "get": { "operationId": "listCredentials", "summary": "List Credentials", - "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.", "tags": ["Credentials"], "parameters": [ { @@ -1716,102 +1716,131 @@ "$ref": "#/components/responses/ServiceUnavailable" } } + }, + "post": { + "operationId": "createServiceAccountCredential", + "summary": "Create Service-Account Credential", + "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialRequest" + } + } + } + }, + "responses": { + "200": { + "description": "An existing credential matched the verified source.", + "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/CreateServiceAccountCredentialResponse" + } + } + } + }, + "201": { + "description": "The service-account credential was created.", + "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/CreateServiceAccountCredentialResponse" + } + } + } + }, + "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" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } } }, - "/api/v2/secrets": { + "/api/v2/credentials/providers": { "get": { - "operationId": "listSecrets", - "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], + "operationId": "listCredentialProviders", + "summary": "List Credential Providers", + "description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Credentials"], "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace whose secret metadata should be listed.", + "description": "Workspace used to evaluate credential-provider availability and integration policy.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace whose secret metadata should be listed." - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Restrict results to one ownership scope.", - "schema": { - "description": "Restrict results to one ownership scope.", - "type": "string", - "enum": ["workspace", "personal"] + "description": "Workspace used to evaluate credential-provider availability and integration policy." } }, { "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the secret name.", + "description": "Case-insensitive substring match against the credential provider name.", "schema": { - "description": "Case-insensitive substring match against the secret name.", + "description": "Case-insensitive substring match against the credential provider name.", "type": "string", "minLength": 1, "maxLength": 200 } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } } ], "responses": { "200": { - "description": "Secret metadata visible to the caller.", + "description": "Credential provider catalog with caller-specific availability.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1826,7 +1855,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListSecretsResponse" + "$ref": "#/components/schemas/ListCredentialProvidersResponse" } } } @@ -1855,62 +1884,26 @@ } } }, - "/api/v2/secrets/{name}": { - "put": { - "operationId": "setSecret", - "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "description": "Secret to create, replace, or delete.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." - } - } - ], + "/api/v2/credentials/connections": { + "post": { + "operationId": "createCredentialConnection", + "summary": "Create Credential Connection", + "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], "requestBody": { "required": true, - "description": "Ownership scope and write-only value for the secret.", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetSecretRequest" + "$ref": "#/components/schemas/CreateCredentialConnectionBody" } } } }, "responses": { "200": { - "description": "The existing secret value was replaced.", - "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/SetSecretResponse" - } - } - } - }, - "201": { - "description": "The secret was created.", + "description": "A short-lived browser authorization URL.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1925,7 +1918,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetSecretResponse" + "$ref": "#/components/schemas/CreateCredentialConnectionResponse" } } } @@ -1942,6 +1935,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -1955,53 +1951,43 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, + } + }, + "/api/v2/credentials/{credentialId}": { "delete": { - "operationId": "deleteSecret", - "summary": "Delete Secret", - "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], + "operationId": "deleteCredential", + "summary": "Disconnect Credential", + "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], "parameters": [ { - "name": "name", + "name": "credentialId", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Credential to disconnect.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Credential to disconnect." } }, { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces.", + "description": "Workspace expected to own the credential.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." - } - }, - { - "name": "scope", - "in": "query", - "required": true, - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace.", - "schema": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "description": "Workspace expected to own the credential." } } ], "responses": { "200": { - "description": "The secret was deleted.", + "description": "The credential was disconnected.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2016,7 +2002,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteSecretResponse" + "$ref": "#/components/schemas/DeleteCredentialResponse" } } } @@ -2044,35 +2030,362 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", + "/api/v2/secrets": { + "get": { + "operationId": "listSecrets", + "summary": "List Secrets", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose secret metadata should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose secret metadata should be listed." + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Restrict results to one ownership scope.", + "schema": { + "description": "Restrict results to one ownership scope.", + "type": "string", + "enum": ["workspace", "personal"] + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the secret name.", + "schema": { + "description": "Case-insensitive substring match against the secret name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Secret metadata visible to the caller.", + "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/ListSecretsResponse" + } + } + } + }, + "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" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/secrets/{name}": { + "put": { + "operationId": "setSecret", + "summary": "Set Secret", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Secret to create, replace, or delete.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret to create, replace, or delete." + } + } + ], + "requestBody": { + "required": true, + "description": "Ownership scope and write-only value for the secret.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSecretRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The existing secret value was replaced.", + "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/SetSecretResponse" + } + } + } + }, + "201": { + "description": "The secret was created.", + "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/SetSecretResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteSecret", + "summary": "Delete Secret", + "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Secret to create, replace, or delete.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret to create, replace, or delete." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." + } + }, + { + "name": "scope", + "in": "query", + "required": true, + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace.", + "schema": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + } + } + ], + "responses": { + "200": { + "description": "The secret was deleted.", + "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/DeleteSecretResponse" + } + } + } + }, + "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" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", "description": "Requests remaining in the current window." } }, @@ -2279,7 +2592,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -3529,70 +3842,264 @@ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "description": "ISO 8601 timestamp when the tool was last updated." } - }, - "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], - "additionalProperties": false, - "title": "Custom tool", - "description": "A workspace custom tool and its callable function declaration." + }, + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Custom tool", + "description": "A workspace custom tool and its callable function declaration." + }, + "ListCustomToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CustomTool" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List custom tools response", + "description": "Custom tools defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create custom tool response", + "description": "The created custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the custom tool." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "Tool implementation executed in the sandboxed function runtime." + } + }, + "required": ["workspaceId", "title", "schema", "code"], + "additionalProperties": false, + "title": "Create custom tool request", + "description": "Definition and implementation of a new custom tool.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }" + } + ] }, - "ListCustomToolsResponse": { + "GetCustomToolResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2CustomTool" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List custom tools response", - "description": "Custom tools defined in the workspace.", + "title": "Get custom tool response", + "description": "One custom tool.", "examples": [ { - "data": [ - { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "nextCursor": null + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } } ] }, - "CreateCustomToolResponse": { + "UpdateCustomToolResponse": { "type": "object", "properties": { "data": { @@ -3602,8 +4109,8 @@ }, "required": ["data"], "additionalProperties": false, - "title": "Create custom tool response", - "description": "The created custom tool.", + "title": "Update custom tool response", + "description": "The updated custom tool.", "examples": [ { "data": { @@ -3625,29 +4132,30 @@ } } }, - "code": "return { ok: true }", + "code": "return { ok: false }", "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "CreateCustomToolRequest": { + "UpdateCustomToolRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the custom tool." + "description": "Workspace that owns the custom tool." }, "title": { + "description": "New display title for the tool.", "type": "string", "minLength": 1, - "maxLength": 200, - "description": "Display title, unique within the workspace." + "maxLength": 200 }, "schema": { + "description": "Replacement function declaration.", "type": "object", "properties": { "type": { @@ -3709,389 +4217,823 @@ "required": ["type", "function"], "additionalProperties": { "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + } }, "code": { + "description": "Replacement tool implementation.", "type": "string", - "maxLength": 100000, - "description": "Tool implementation executed in the sandboxed function runtime." + "maxLength": 100000 } }, - "required": ["workspaceId", "title", "schema", "code"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Create custom tool request", - "description": "Definition and implementation of a new custom tool.", + "title": "Update custom tool request", + "description": "Custom tool fields to change; at least one editable field is required.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }" + "code": "return { ok: false }" + } + ] + }, + "V2CustomToolDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted custom tool." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the custom tool was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete custom tool data", + "description": "Custom tool deletion acknowledgement." + }, + "DeleteCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomToolDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete custom tool response", + "description": "Acknowledgement that the custom tool was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } } ] }, - "GetCustomToolResponse": { + "V2Credential": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique credential identifier." + }, + "type": { + "type": "string", + "enum": ["oauth", "service_account"], + "description": "Authenticated connection type." + }, + "displayName": { + "type": "string", + "description": "Credential display name." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional credential description." + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Integration provider authenticated by this credential." + }, + "accountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Linked account identifier for OAuth credentials." + }, + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account payload is stored. Its contents are never returned." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the credential." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was last updated." + } + }, + "required": [ + "id", + "type", + "displayName", + "description", + "providerId", + "accountId", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Credential", + "description": "Public authenticated-connection metadata without secret material." + }, + "ListCredentialsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Credential" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Get custom tool response", - "description": "One custom tool.", + "title": "List credentials response", + "description": "Credential metadata visible to the caller.", "examples": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null } ] }, - "UpdateCustomToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update custom tool response", - "description": "The updated custom tool.", - "examples": [ + "V2CredentialProvider": { + "oneOf": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." }, - "code": "return { ok: false }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] - }, - "UpdateCustomToolRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the custom tool." - }, - "title": { - "description": "New display title for the tool.", - "type": "string", - "minLength": 1, - "maxLength": 200 + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false + }, + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false }, - "schema": { - "description": "Replacement function declaration.", + { "type": "object", "properties": { "type": { "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "const": "service_account", + "description": "Direct service-account credential method." }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID accepted by credential creation." + }, + "docsUrl": { + "type": "string", + "format": "uri", + "description": "Setup guide for the provider." + }, + "helpText": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "requiresClientGeneratedCredentialId": { + "type": "boolean", + "description": "Whether the caller must generate and submit the credential ID before setup." + }, + "fields": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } + "required": ["value", "label"], + "additionalProperties": false } }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false }, - "description": "OpenAI-style function definition." + "description": "Create-body fields accepted by this provider. Secret fields are write-only." } }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - } + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false + } + ], + "title": "Credential Provider", + "description": "An OAuth or service-account connection method available to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." }, - "code": { - "description": "Replacement tool implementation.", - "type": "string", - "maxLength": 100000 + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, - "required": ["workspaceId"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Update custom tool request", - "description": "Custom tool fields to change; at least one editable field is required.", + "title": "List credential providers response", + "description": "OAuth and service-account connection methods.", "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "code": "return { ok: false }" + "data": [ + { + "type": "oauth", + "serviceId": "salesforce", + "name": "Salesforce", + "description": "Connect to Salesforce CRM data and operations.", + "providerFamily": "salesforce", + "available": true, + "supportsReconnect": true, + "authorizationOptions": [ + { + "providerId": "salesforce", + "label": "Production" + }, + { + "providerId": "salesforce-sandbox", + "label": "Sandbox" + } + ] + }, + { + "type": "service_account", + "serviceId": "zoom-service-account", + "providerId": "zoom-service-account", + "name": "Zoom server-to-server app", + "description": "Connect Zoom with a server-to-server app.", + "providerFamily": "zoom", + "available": true, + "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", + "requiresClientGeneratedCredentialId": false, + "fields": [ + { + "id": "clientId", + "label": "Client ID", + "placeholder": "Paste the client ID", + "required": true, + "secret": false, + "multiline": false + }, + { + "id": "clientSecret", + "label": "Client secret", + "placeholder": "Paste the client secret", + "required": true, + "secret": true, + "multiline": false + }, + { + "id": "orgId", + "label": "Account ID", + "placeholder": "Paste the account ID", + "required": true, + "secret": false, + "multiline": false + } + ] + } + ], + "nextCursor": null } ] }, - "V2CustomToolDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted custom tool." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the custom tool was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete custom tool data", - "description": "Custom tool deletion acknowledgement." - }, - "DeleteCustomToolResponse": { + "CreateServiceAccountCredentialResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomToolDeleteData" + "$ref": "#/components/schemas/V2Credential" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete custom tool response", - "description": "Acknowledgement that the custom tool was deleted.", + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "V2Credential": { + "CreateServiceAccountCredentialRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", - "description": "Unique credential identifier." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." }, "type": { "type": "string", - "enum": ["oauth", "service_account"], - "description": "Authenticated connection type." + "const": "service_account", + "description": "Service-account credential discriminator." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." }, "displayName": { + "description": "Optional name; providers may derive one from the verified account identity.", "type": "string", - "description": "Credential display name." + "minLength": 1, + "maxLength": 255 }, "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional credential description." + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Integration provider authenticated by this credential." + "id": { + "description": "Required only when provider discovery requests a client-generated ID.", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "accountId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Linked account identifier for OAuth credentials." + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 }, - "hasServiceAccountKey": { - "type": "boolean", - "description": "Whether a service-account payload is stored. Its contents are never returned." + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 }, - "role": { + "domain": { + "description": "Provider account domain.", "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the credential." + "minLength": 1, + "maxLength": 2048 }, - "createdAt": { + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was created." + "minLength": 1, + "maxLength": 8192 }, - "updatedAt": { + "botToken": { + "description": "Write-only bot token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "clientId": { + "description": "OAuth client identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "description": "Write-only OAuth client secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "certificateId": { + "description": "Provider certificate mapping identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "orgId": { + "description": "Provider organization ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "description": "Provider data center.", + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "authMethod": { + "description": "Provider authentication method.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "privateKey": { + "description": "Write-only PEM private key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "username": { + "description": "Provider run-as username.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": ["workspaceId", "type", "providerId"], + "additionalProperties": false, + "title": "Create service-account credential request", + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "displayName": "Zoom automation", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + ] + }, + "V2CredentialConnectionAuthorization": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + }, + "expiresAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was last updated." + "description": "ISO 8601 timestamp when the connection link expires." } }, - "required": [ - "id", - "type", - "displayName", - "description", - "providerId", - "accountId", - "hasServiceAccountKey", - "role", - "createdAt", - "updatedAt" - ], + "required": ["authorizationUrl", "expiresAt"], "additionalProperties": false, - "title": "Credential", - "description": "Public authenticated-connection metadata without secret material." + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." }, - "ListCredentialsResponse": { + "CreateCredentialConnectionResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2Credential" + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", + "examples": [ + { + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } + } + ] + }, + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider ID returned by credential-provider discovery." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + } }, - "description": "Items in the current page." + "required": ["workspaceId", "providerId", "displayName"], + "additionalProperties": false }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." }, - { - "type": "null" + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place." } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + }, + "required": ["workspaceId", "credentialId"], + "additionalProperties": false + } + ], + "title": "Create credential connection body", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, + "V2CredentialDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Disconnected credential identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the credential was disconnected." } }, - "required": ["data", "nextCursor"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "List credentials response", - "description": "Credential metadata visible to the caller.", + "title": "Delete credential data", + "description": "Credential disconnection acknowledgement." + }, + "DeleteCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Disconnect credential response", + "description": "Acknowledgement that the credential was disconnected.", "examples": [ { - "data": [ - { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "nextCursor": null + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } } ] }, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index aa729c5c616..c4a6b1ce6fd 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3987,7 +3987,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index e0b56db6970..541863f37d3 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2295,7 +2295,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index 016384aa9c7..fd95740bf08 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -1,61 +1,23 @@ -import { db } from '@sim/db' -import { account, credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { connectedAccountsQuerySchema } from '@/lib/api/contracts/oauth-connections' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('AuthAccountsAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const { provider } = connectedAccountsQuerySchema.parse({ - provider: searchParams.get('provider') || undefined, - }) - - const whereConditions = [eq(account.userId, session.user.id)] - - if (provider) { - whereConditions.push(eq(account.providerId, provider)) - } - - const accounts = await db - .select({ - id: account.id, - accountId: account.accountId, - providerId: account.providerId, - credentialDisplayName: credential.displayName, - }) - .from(account) - .leftJoin(credential, eq(credential.accountId, account.id)) - .where(and(...whereConditions)) - .orderBy(desc(account.updatedAt)) - - const seen = new Map() - for (const acc of accounts) { - if (!seen.has(acc.id)) { - seen.set(acc.id, acc) - } - } - - const accountsWithDisplayName = Array.from(seen.values()).map((acc) => ({ - id: acc.id, - accountId: acc.accountId, - providerId: acc.providerId, - displayName: acc.credentialDisplayName || acc.accountId || acc.providerId, - })) - - return NextResponse.json({ accounts: accountsWithDisplayName }) - } catch (error) { - logger.error('Failed to fetch accounts', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { listConnectedAccountsContract } from '@/lib/api/contracts/oauth-connections' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { listConnectedAccountsUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listConnectedAccountsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listConnectedAccounts, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: listConnectedAccountsUseCase, }) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts new file mode 100644 index 00000000000..66cb5506c97 --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createCredentialConnection: vi.fn(), + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { execute: mocks.createCredentialConnection }, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getCanonicalScopesForProvider: () => ['instagram_business_basic'], +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/auth/instagram/authorize/route' + +describe('Instagram authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { INSTAGRAM_CLIENT_ID: 'instagram-client' }, + }) + mocks.createCredentialConnection.mockResolvedValue({ draftId: 'draft-created' }) + }) + + it('preserves an exact credential draft when workspaceId is also supplied', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1&draftId=draft-exact' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-exact' + ) + expect(mocks.createCredentialConnection).not.toHaveBeenCalled() + }) + + it('creates a credential draft for a legacy workspace-only launch', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-created' + ) + expect(response.headers.get('set-cookie')).toContain('Max-Age=900') + expect(mocks.createCredentialConnection).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', providerId: 'instagram' }, + request, + }) + }) + + it('returns a conflict when a different connection intent is already active', async () => { + mocks.createCredentialConnection.mockRejectedValue( + new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + ) + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: 'A different OAuth connection flow is already active for this provider', + }) + }) +}) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index b33a0c0c510..17f21e99e66 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -5,12 +5,13 @@ import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connection import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InstagramAuthorize') @@ -18,8 +19,8 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' -const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -27,6 +28,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') const { values: { INSTAGRAM_CLIENT_ID: clientId }, @@ -34,18 +37,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeInstagramContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl, workspaceId } = parsed.data.query + const { returnUrl, workspaceId, draftId } = parsed.data.query + let credentialDraftId = draftId - if (workspaceId) { - const access = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!access.canWrite) { - return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + if (workspaceId && !draftId) { + try { + const connection = await createCredentialConnection.execute({ + principal: { kind: 'session', userId: session.user.id, sessionId }, + input: { workspaceId, providerId: 'instagram' }, + request, + }) + credentialDraftId = connection.draftId + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code === 'conflict') { + logger.warn('Rejected conflicting Instagram OAuth connection intent', { + userId: session.user.id, + workspaceId, + }) + return NextResponse.json({ error: classified.message }, { status: 409 }) + } + if (classified?.code === 'forbidden' || classified?.code === 'not_found') { + return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + } + throw error } - await createConnectDraft({ - userId: session.user.id, - workspaceId, - providerId: 'instagram', - }) } const baseUrl = getBaseUrl() @@ -65,16 +81,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) + if (credentialDraftId) { + response.cookies.set(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, credentialDraftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) } diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 593079aa20c..80db8ab7a39 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -49,6 +49,7 @@ describe('OAuth Connections API Route', () => { it('should return connections successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const mockAccounts = [ @@ -105,12 +106,13 @@ describe('OAuth Connections API Route', () => { const data = await response.json() expect(response.status).toBe(401) - expect(data.error).toBe('User not authenticated') + expect(data.error).toBe('Unauthorized') }) it('should handle user with no connections', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockResolvedValueOnce([]) @@ -128,6 +130,7 @@ describe('OAuth Connections API Route', () => { it('should handle database error', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) @@ -144,6 +147,7 @@ describe('OAuth Connections API Route', () => { it('should decode ID token for display name', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const mockAccounts = [ diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 9af427f9c17..92813d0638c 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -1,139 +1,19 @@ -import { account, db, user } from '@sim/db' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { decodeJwt } from 'jose' -import { type NextRequest, NextResponse } from 'next/server' -import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { OAuthProvider } from '@/lib/oauth' -import { parseProvider } from '@/lib/oauth' - -const logger = createLogger('OAuthConnectionsAPI') - -interface GoogleIdToken { - email?: string - sub?: string - name?: string -} - -/** - * Get all OAuth connections for the current user - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - // Get the session - const session = await getSession() - - // Check if the user is authenticated - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - // Get all accounts for this user - const accounts = await db.select().from(account).where(eq(account.userId, session.user.id)) - - // Get the user's email for fallback - const userRecord = await db - .select({ email: user.email }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null - - // Process accounts to determine connections - const connections: OAuthConnection[] = [] - - for (const acc of accounts) { - const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider) - const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : [] - - if (baseProvider) { - // Try multiple methods to get a user-friendly display name - let displayName = '' - - // Method 1: Try to extract email from ID token (works for Google, etc.) - if (acc.idToken) { - try { - const decoded = decodeJwt(acc.idToken) - if (decoded.email) { - displayName = decoded.email - } else if (decoded.name) { - displayName = decoded.name - } - } catch (_error) { - logger.warn(`[${requestId}] Error decoding ID token`, { - accountId: acc.id, - }) - } - } - - // Method 2: For GitHub, the accountId might be the username - if (!displayName && baseProvider === 'github') { - displayName = `${acc.accountId} (GitHub)` - } - - // Method 3: Use the user's email from our database - if (!displayName && userEmail) { - displayName = userEmail - } - - // Fallback: Use accountId with provider type as context - if (!displayName) { - displayName = `${acc.accountId} (${baseProvider})` - } - - // Create a unique connection key that includes the full provider ID - const connectionKey = acc.providerId - - // Find existing connection for this specific provider ID - const existingConnection = connections.find((conn) => conn.provider === connectionKey) - - const accountSummary = { - id: acc.id, - name: displayName, - } - - if (existingConnection) { - // Add account to existing connection - existingConnection.accounts = existingConnection.accounts || [] - existingConnection.accounts.push(accountSummary) - - existingConnection.scopes = Array.from( - new Set([...(existingConnection.scopes || []), ...scopes]) - ) - - const existingTimestamp = existingConnection.lastConnected - ? new Date(existingConnection.lastConnected).getTime() - : 0 - const candidateTimestamp = acc.updatedAt.getTime() - - if (candidateTimestamp > existingTimestamp) { - existingConnection.lastConnected = acc.updatedAt.toISOString() - } - } else { - // Create new connection - connections.push({ - provider: connectionKey, - baseProvider, - featureType, - isConnected: true, - scopes, - lastConnected: acc.updatedAt.toISOString(), - accounts: [accountSummary], - }) - } - } - } - - return NextResponse.json({ connections }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching OAuth connections`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { listOAuthConnectionsUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listOAuthConnectionsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listOAuthConnections, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: () => ({}), + useCase: listOAuthConnectionsUseCase, }) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 757ea76c9df..e1dd3aa2eec 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -26,6 +26,7 @@ describe('OAuth Disconnect API Route', () => { it('should disconnect provider successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', { @@ -42,6 +43,7 @@ describe('OAuth Disconnect API Route', () => { it('should disconnect specific provider ID successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', { @@ -67,12 +69,13 @@ describe('OAuth Disconnect API Route', () => { const data = await response.json() expect(response.status).toBe(401) - expect(data.error).toBe('User not authenticated') + expect(data.error).toBe('Unauthorized') }) it('should handle missing provider', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', {}) @@ -87,6 +90,7 @@ describe('OAuth Disconnect API Route', () => { it('should handle database error', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index c3c145e60e7..d53f89128b7 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,132 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { account, credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray, like, or } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteCredential } from '@/lib/credentials/deletion' -import { providerIdsForService } from '@/lib/oauth/utils' -import { captureServerEvent } from '@/lib/posthog/server' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' export const dynamic = 'force-dynamic' -const logger = createLogger('OAuthDisconnectAPI') - -/** - * Disconnect an OAuth provider for the current user - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - const parsed = await parseRequest( - disconnectOAuthContract, - request, - {}, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid disconnect request`, { errors: error.issues }) - return NextResponse.json( - { error: getValidationErrorMessage(error, 'Validation failed') }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const { provider, providerId, accountId } = parsed.data.body - - logger.info(`[${requestId}] Processing OAuth disconnect request`, { - provider, - hasProviderId: !!providerId, - }) - - // Delete credentials before their accounts so deleteCredential can clear - // stored references first. Otherwise FK CASCADE would orphan them silently. - const accountFilter = accountId - ? and(eq(account.userId, session.user.id), eq(account.id, accountId)) - : providerId - ? and(eq(account.userId, session.user.id), eq(account.providerId, providerId)) - : and( - eq(account.userId, session.user.id), - or( - // The prefix sweep already caught `{base}-{feature}` ids by - // accident; an alternate authorization server shares that shape, - // so name it explicitly rather than relying on the accident. - inArray(account.providerId, providerIdsForService(provider)), - like(account.providerId, `${provider}-%`) - ) - ) - - const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) - - const targetAccountIds = targetAccounts.map((a) => a.id) - - if (targetAccountIds.length > 0) { - const credentialsToDelete = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - providerId: credential.providerId, - }) - .from(credential) - .where(inArray(credential.accountId, targetAccountIds)) - - for (const cred of credentialsToDelete) { - await deleteCredential({ - credentialId: cred.id, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - reason: 'oauth_disconnect', - request, - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'oauth', - provider_id: cred.providerId ?? providerId ?? provider, - workspace_id: cred.workspaceId, - }, - { groups: { workspace: cred.workspaceId } } - ) - } - - await db.delete(account).where(inArray(account.id, targetAccountIds)) - } - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.OAUTH_DISCONNECTED, - resourceType: AuditResourceType.OAUTH, - resourceId: providerId ?? provider, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: provider, - description: `Disconnected OAuth provider: ${provider}`, - metadata: { provider, providerId }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error disconnecting OAuth provider`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const POST = defineInternalJsonRoute({ + contract: disconnectOAuthContract, + auth: internalSessionAuth, + operation: credentialUserOperations.disconnectOAuth, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: disconnectOAuthUseCase, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 54f49a5e29f..f59ae8b4dc6 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -1,360 +1,247 @@ /** * @vitest-environment node */ -import { - createMockRequest, - dbChainMockFns, - resetDbChainMock, - resetEnvMock, - setEnv, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetSession, - mockOAuth2LinkAccount, - mockCheckWorkspaceAccess, - mockGetCredentialActorContext, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockOAuth2LinkAccount: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + linkAccount: vi.fn(), + getBaseUrl: vi.fn(), + requireClient: vi.fn(), + createConnection: vi.fn(), + launchConnection: vi.fn(), })) vi.mock('@/lib/auth/auth', () => ({ - auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, - getSession: mockGetSession, + getSession: mocks.getSession, + auth: { api: { oAuth2LinkAccount: mocks.linkAccount } }, })) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, +vi.mock('@/lib/core/utils/urls', () => ({ + SITE_URL: 'https://www.sim.ai', + getBaseUrl: mocks.getBaseUrl, })) - -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireClient, + wireServerFallback: () => ({ + configured: false, + providerIds: [], + providers: [], + execute: vi.fn(), + }), })) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), - // Real implementation: a credential id matches its service's OAuth id, an - // alternate authorization server, or the family's service-account id. - credentialProviderMatchesService: ( - credentialProviderId: string, - service: { - providerId: string - serviceAccountProviderId?: string - additionalProviderIds?: readonly string[] - } - ) => - service.providerId === credentialProviderId || - service.serviceAccountProviderId === credentialProviderId || - (service.additionalProviderIds?.includes(credentialProviderId) ?? false), +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.createConnection, + }, +})) +vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ + launchCredentialConnection: { + operation: { id: 'credentials.connections.launch' }, + execute: mocks.launchConnection, + }, })) import { GET } from '@/app/api/auth/oauth2/authorize/route' const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' -const LINK_URL = 'https://provider.example/authorize?state=abc' +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -function authorizeRequest(query: Record) { - const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) - for (const [key, value] of Object.entries(query)) { - url.searchParams.set(key, value) - } +function request(query: Record) { + const url = new URL('/api/auth/oauth2/authorize', BASE_URL) + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value) return createMockRequest('GET', undefined, {}, url.toString()) } -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - displayName: 'Work Gmail', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } +function linkResponse(url = 'https://provider.example/authorize') { + return new Response(JSON.stringify({ url }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) } describe('OAuth2 authorize route', () => { - afterAll(() => { - resetEnvMock() - }) - beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - setEnv({ - NEXT_PUBLIC_APP_URL: BASE_URL, - GOOGLE_CLIENT_ID: 'google-client', - GOOGLE_CLIENT_SECRET: 'google-secret', - }) - mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, - }) - mockOAuth2LinkAccount.mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ url: LINK_URL }), - headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] }, + mocks.getBaseUrl.mockReturnValue(BASE_URL) + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, }) + mocks.createConnection.mockResolvedValue({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date('2026-08-14T12:00:00.000Z'), + authorizationUrl: `${BASE_URL}/api/auth/oauth2/authorize?draftId=draft-1`, + }) + mocks.launchConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: null, + }, + }) + mocks.linkAccount.mockResolvedValue(linkResponse()) }) - describe('plain connect (no credentialId)', () => { - it('creates a draft with credentialId null and redirects to the provider', async () => { - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) + it('creates a canonical application draft for a legacy connect URL', async () => { + const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: USER_ID, - workspaceId: WORKSPACE_ID, + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, providerId: 'google-email' }, + }) + ) + expect(mocks.linkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ providerId: 'google-email', - credentialId: null, - }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: null }), - }) - ) - }) - - it('numbers the draft display name when the default collides with an existing credential', async () => { - dbChainMockFns.where - .mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }])) - .mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }])) - - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: "Justin's Gmail 2" }) - ) - }) - - it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => { - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] - expect(set).toHaveProperty('credentialId', null) - }) - - it('rejects an OAuth client that is not configured for the deployment', async () => { - setEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('redirects to login when unauthenticated', async () => { - mockGetSession.mockResolvedValue(null) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toContain('/login') - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects without workspace write access', async () => { - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: false, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, + callbackURL: expect.stringContaining('credentialDraftId=draft-1'), + }), }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=workspace_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + ) }) - describe('reconnect (credentialId present)', () => { - it('creates a reconnect draft carrying credentialId in values and upsert set', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).toHaveBeenCalledWith( - CREDENTIAL_ID, - USER_ID, - expect.objectContaining({ workspaceAccess: expect.anything() }) - ) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ credentialId: CREDENTIAL_ID }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: CREDENTIAL_ID }), - }) - ) + it('requires a configured OAuth client before creating a legacy draft', async () => { + mocks.requireClient.mockImplementationOnce(() => { + throw new Error('OAuth client is not configured') }) - it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { displayName: 'Renamed By User' } }) - ) + const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + expect(mocks.requireClient).toHaveBeenCalledWith('google-email') + expect(mocks.createConnection).not.toHaveBeenCalled() + }) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: 'Renamed By User' }) - ) - }) + it('launches an exact draft without creating another one', async () => { + const response = await GET(request({ draftId: 'draft-1' })) - it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => { - for (const providerId of ['trello', 'shopify']) { - const response = await GET( - authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) - ) + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.launchConnection).toHaveBeenCalledWith( + expect.objectContaining({ input: { draftId: 'draft-1' } }) + ) + expect(mocks.createConnection).not.toHaveBeenCalled() + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_reconnect_unsupported` - ) - } - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + it('passes reconnect provider assertions through the application use case', async () => { + mocks.createConnection.mockResolvedValue({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', }) - it('rejects when the caller is not a credential admin and writes no draft', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) + await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + credentialId: 'credential-1', + assertedProviderId: 'google-email', + }, + }) + ) + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + it('maps a provider mismatch without exposing the credential', async () => { + mocks.createConnection.mockRejectedValue( + new CredentialConnectionProviderMismatchError('google-email', 'slack') + ) - it('rejects when the credential belongs to a different workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) + const response = await GET( + request({ + providerId: 'slack', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_provider_mismatch` + ) + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) + it('maps credential and workspace authorization failures separately', async () => { + mocks.createConnection.mockRejectedValueOnce( + new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + ) + const credentialResponse = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) + mocks.createConnection.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Write permission required') + ) + const workspaceResponse = await GET( + request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(credentialResponse.headers.get('location')).toContain('credential_access_denied') + expect(workspaceResponse.headers.get('location')).toContain('workspace_access_denied') + }) + + it('keeps a reconnect workspace-role denial classified as workspace access', async () => { + mocks.createConnection.mockRejectedValue(new InsufficientWorkspacePermissionsError()) - it('rejects when the credential does not exist', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, + const response = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', }) + ) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: 'cred-missing', - }) - ) + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=workspace_access_denied` + ) + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) + it('redirects a draft launch infrastructure failure through the browser error contract', async () => { + mocks.launchConnection.mockRejectedValue(new Error('Database unavailable')) - it('rejects a non-oauth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) + const response = await GET(request({ draftId: 'draft-1' })) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() + it('routes custom providers through the exact application draft', async () => { + mocks.createConnection.mockResolvedValue({ + providerId: 'trello', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', }) - it('rejects when the query providerId does not match the credential provider', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const response = await GET( - authorizeRequest({ - providerId: 'slack', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + const response = await GET(request({ providerId: 'trello', workspaceId: WORKSPACE_ID })) + const location = new URL(response.headers.get('location') ?? '') - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_provider_mismatch` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + expect(location.pathname).toBe('/api/auth/trello/authorize') + expect(location.searchParams.get('draftId')).toBe('draft-1') }) }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 063de2ca015..f9f1f616ac6 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -3,12 +3,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { createConnectDraft } from '@/lib/credentials/connect-draft' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-processor' const logger = createLogger('OAuth2Authorize') @@ -27,95 +30,109 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(loginUrl.toString()) } const userId = session.user.id + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const principal = { kind: 'session' as const, userId, sessionId } const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { - providerId, - workspaceId, - callbackURL: requestedCallback, - credentialId, - } = parsed.data.query - - const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) - ? requestedCallback - : `${baseUrl}/workspace` + const { draftId } = parsed.data.query + let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query try { - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.canWrite) { - logger.warn('Workspace write access denied for OAuth2 authorize', { - userId, - workspaceId, - providerId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) - } - - let reconnectDisplayName: string | undefined - if (credentialId) { - // Trello and Shopify authorize through their own custom flows that bypass - // this endpoint, so a reconnect draft written here would linger unconsumed - // and could later be picked up by their token-store callbacks, silently - // rebinding the credential. Mirror the copilot tool and reject reconnect. - if (providerId === 'trello' || providerId === 'shopify') { - logger.warn('Reconnect not supported for custom-flow provider', { - userId, - workspaceId, - providerId, - credentialId, + let fromConnectionDraft = false + let connectionDraftId: string | undefined + if (draftId) { + try { + const { draft } = await launchCredentialConnection.execute({ + principal, + input: { draftId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`) + providerId = draft.providerId + workspaceId = draft.workspaceId + credentialId = draft.credentialId ?? undefined + connectionDraftId = draft.id + fromConnectionDraft = true + } catch (error) { + if (!(error instanceof OrchestrationError)) throw error + logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) } + } - // Reconnect: the OAuth callback will rebind this credential to the fresh - // account, so require the same credential-admin access as the draft POST - // route — workspace write alone must not be enough to swap someone's tokens. - const actor = await getCredentialActorContext(credentialId, userId, { - workspaceAccess: access, - }) - if ( - !actor.credential || - actor.credential.workspaceId !== workspaceId || - actor.credential.type !== 'oauth' || - !actor.isAdmin - ) { - logger.warn('Credential admin access denied for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) - } - if (actor.credential.providerId !== providerId) { - logger.warn('Provider mismatch for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - credentialProviderId: actor.credential.providerId, + if (!providerId || !workspaceId) { + throw new Error('Validated OAuth authorization request is missing its target') + } + + requireConfiguredOAuthClient(providerId) + + const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) + connectionCompleteUrl.searchParams.set('result', 'connected') + const callbackURL = fromConnectionDraft + ? connectionCompleteUrl.toString() + : requestedCallback?.startsWith(`${baseUrl}/`) + ? requestedCallback + : `${baseUrl}/workspace` + + if (!fromConnectionDraft) { + try { + const connection = await createCredentialConnection.execute({ + principal, + input: credentialId + ? { workspaceId, credentialId, assertedProviderId: providerId } + : { workspaceId, providerId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + providerId = connection.providerId + workspaceId = connection.workspaceId + credentialId = connection.credentialId + connectionDraftId = connection.draftId + } catch (error) { + if (error instanceof CredentialConnectionProviderMismatchError) { + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + if ( + credentialId && + error instanceof ForbiddenOperationError && + error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' + ) { + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + } + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.redirect( + `${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` + ) + } + if (error instanceof OrchestrationError && error.code === 'forbidden') { + return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + } + throw error } - reconnectDisplayName = actor.credential.displayName } - requireConfiguredOAuthClient(providerId) + if (!connectionDraftId) { + throw new Error('OAuth authorization is missing its credential draft id') + } - // Create the draft before initiating the link so it is guaranteed to exist - // (and freshly clocked) when the OAuth callback's `account.create.after` - // hook runs. If this throws, we never start the OAuth flow. - await createConnectDraft({ - userId, - workspaceId, - providerId, - credentialId, - displayName: reconnectDisplayName, - }) + if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { + const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) + authorizeUrl.searchParams.set('returnUrl', callbackURL) + authorizeUrl.searchParams.set('draftId', connectionDraftId) + return NextResponse.redirect(authorizeUrl) + } + + const stateCallbackUrl = new URL(callbackURL) + stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId) const linkResponse = await auth.api.oAuth2LinkAccount({ - body: { providerId, callbackURL }, + body: { + providerId, + callbackURL: stateCallbackUrl.toString(), + ...(fromConnectionDraft + ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } + : {}), + }, headers: request.headers, asResponse: true, }) diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 4aea1372f83..19284a950fc 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -33,11 +33,16 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' function clearOAuthCookies(response: NextResponse) { response.cookies.delete({ name: INSTAGRAM_STATE_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) response.cookies.delete({ name: INSTAGRAM_RETURN_URL_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) return response } @@ -54,6 +59,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { code, state, error, error_reason, error_description } = parsed.data.query + const draftId = request.cookies.get(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE)?.value if (error) { logger.warn('Instagram OAuth denied by user', { @@ -293,17 +299,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'instagram', - accountId: persisted.id, - }) - } catch (draftError) { - logger.error('Failed to process credential draft for Instagram', { error: draftError }) - } + if (!persisted) { + throw new Error(`Instagram OAuth account ${igUserId} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'instagram', + accountId: persisted.id, + }) const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value const redirectUrl = diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts new file mode 100644 index 00000000000..e9aa534852a --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { hmacSha256Hex } from '@sim/security/hmac' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCompleteShopifyOAuthConnection, mockGetSession, mockRequireConfiguredOAuthClient } = + vi.hoisted(() => ({ + mockCompleteShopifyOAuthConnection: vi.fn(), + mockGetSession: vi.fn(), + mockRequireConfiguredOAuthClient: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mockRequireConfiguredOAuthClient, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +vi.mock('@/lib/oauth/shopify', () => ({ + completeShopifyOAuthConnection: mockCompleteShopifyOAuthConnection, +})) + +import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' +import { GET } from '@/app/api/auth/oauth2/callback/shopify/route' + +const CLIENT_SECRET = 'shopify-client-secret' +const SHOP_DOMAIN = 'example.myshopify.com' + +function callbackRequest(state: string) { + const searchParams = new URLSearchParams({ + code: 'authorization-code', + shop: SHOP_DOMAIN, + state, + }) + const message = [...searchParams.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('&') + searchParams.set('hmac', hmacSha256Hex(message, CLIENT_SECRET)) + + return createMockRequest( + 'GET', + undefined, + { + cookie: + 'shopify_credential_draft_id=draft-from-shared-cookie; shopify_return_url=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected', + }, + `https://sim.test/api/auth/oauth2/callback/shopify?${searchParams.toString()}` + ) +} + +describe('Shopify OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockRequireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client-id', + SHOPIFY_CLIENT_SECRET: CLIENT_SECRET, + }, + }) + mockCompleteShopifyOAuthConnection.mockResolvedValue(undefined) + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + ) + }) + + it('completes the credential draft carried by signed state instead of a shared cookie', async () => { + const state = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-from-state', + returnUrl: 'https://sim.test/oauth/credential-connected?result=connected', + clientSecret: CLIENT_SECRET, + }) + + const response = await GET(callbackRequest(state)) + + expect(mockCompleteShopifyOAuthConnection).toHaveBeenCalledWith({ + accessToken: 'shopify-token', + shopDomain: SHOP_DOMAIN, + scope: 'read_products', + userId: 'user-1', + draftId: 'draft-from-state', + signal: expect.any(AbortSignal), + }) + expect(response.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?result=connected&shopify_connected=true' + ) + }) + + it('keeps overlapping flows bound to their own return destinations', async () => { + const firstState = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-first', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + clientSecret: CLIENT_SECRET, + }) + const secondState = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-second', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + clientSecret: CLIENT_SECRET, + }) + + const firstResponse = await GET(callbackRequest(firstState)) + const secondResponse = await GET(callbackRequest(secondState)) + + expect(firstResponse.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=first&shopify_connected=true' + ) + expect(secondResponse.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=second&shopify_connected=true' + ) + expect(mockCompleteShopifyOAuthConnection).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ draftId: 'draft-first' }) + ) + expect(mockCompleteShopifyOAuthConnection).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ draftId: 'draft-second' }) + ) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index 2292a76a9a6..8447e56d48d 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -10,12 +10,26 @@ import { getSession } from '@/lib/auth' import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' +import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state' const logger = createLogger('ShopifyCallback') export const dynamic = 'force-dynamic' +function clearShopifyOAuthCookies(response: NextResponse): NextResponse { + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') + response.cookies.delete('shopify_pending_token') + response.cookies.delete('shopify_pending_shop') + response.cookies.delete('shopify_pending_scope') + response.cookies.delete('shopify_return_url') + return response +} + /** * Validates the HMAC signature from Shopify to ensure the request is authentic * @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens @@ -59,9 +73,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { shop: searchParams.get('shop') || undefined, }) - const storedState = request.cookies.get('shopify_oauth_state')?.value - const storedShop = request.cookies.get('shopify_shop_domain')?.value - const { values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret }, } = requireConfiguredOAuthClient('shopify') @@ -71,8 +82,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`) } - if (!state || state !== storedState) { - logger.error('State mismatch in Shopify OAuth callback') + if (!state) { + logger.error('Missing state in Shopify OAuth callback') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`) } @@ -81,7 +92,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`) } - const shopDomain = shop || storedShop + const shopDomain = shop if (!shopDomain) { logger.error('No shop domain available') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`) @@ -92,6 +103,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) } + const { draftId, returnUrl } = parseShopifyOAuthState({ + state, + userId: session.user.id, + shopDomain, + clientSecret, + }) + const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, { method: 'POST', headers: { @@ -127,44 +145,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`) } - const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`) - - const response = NextResponse.redirect(storeUrl) - - response.cookies.set('shopify_pending_token', accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('shopify_pending_shop', shopDomain, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, + userId: session.user.id, + draftId, + signal: request.signal, }) - response.cookies.set('shopify_pending_scope', scope || '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.delete('shopify_oauth_state') - response.cookies.delete('shopify_shop_domain') + if (returnUrl && !isSameOrigin(returnUrl)) { + throw new Error('Shopify OAuth state contains an invalid return URL') + } + const redirectUrl = returnUrl ?? `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('shopify_connected', 'true') - return response + return clearShopifyOAuthCookies(NextResponse.redirect(finalUrl)) } catch (error) { logger.error('Error in Shopify OAuth callback:', error) const errorCode = error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth' ? 'shopify_config_error' : 'shopify_callback_error' - return NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + return clearShopifyOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + ) } }) diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index 182989f917a..d3c84d68883 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -1,7 +1,4 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { shopifyShopDomainSchema, @@ -11,9 +8,7 @@ import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { safeAccountInsert } from '@/lib/oauth/credential-service' -import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' const logger = createLogger('ShopifyStore') @@ -41,95 +36,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`) } const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data + const draftId = request.cookies.get('shopify_credential_draft_id')?.value if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { logger.error('Invalid shop domain format in cookie', { shopDomain }) return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) } - const shopResponse = await fetch( - `https://${shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, - { - headers: { - 'X-Shopify-Access-Token': accessToken, - 'Content-Type': 'application/json', - }, - } - ) - - if (!shopResponse.ok) { - const errorText = await shopResponse.text() - logger.error('Invalid Shopify token', { - status: shopResponse.status, - error: errorText, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`) - } - - const shopData = await shopResponse.json() - const shopInfo = shopData.shop - const stableAccountId = shopInfo.id?.toString() || shopDomain - - const existing = await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, + userId: session.user.id, + draftId, + signal: request.signal, }) - const now = new Date() - - const accountData = { - accessToken: accessToken, - accountId: stableAccountId, - scope: scope || '', - updatedAt: now, - idToken: shopDomain, - } - - if (existing) { - await db.update(account).set(accountData).where(eq(account.id, existing.id)) - logger.info('Updated existing Shopify account', { accountId: existing.id }) - } else { - await safeAccountInsert( - { - id: `shopify_${session.user.id}_${Date.now()}`, - userId: session.user.id, - providerId: 'shopify', - accountId: accountData.accountId, - accessToken: accountData.accessToken, - scope: accountData.scope, - idToken: accountData.idToken, - createdAt: now, - updatedAt: now, - }, - { provider: 'Shopify', identifier: shopDomain } - ) - } - - const persisted = - existing ?? - (await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), - })) - - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'shopify', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Shopify', { error }) - } - } - const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('shopify_connected', 'true') @@ -139,6 +61,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { response.cookies.delete('shopify_pending_shop') response.cookies.delete('shopify_pending_scope') response.cookies.delete('shopify_return_url') + response.cookies.delete('shopify_credential_draft_id') return response } catch (error) { diff --git a/apps/sim/app/api/auth/shopify/authorize/route.test.ts b/apps/sim/app/api/auth/shopify/authorize/route.test.ts new file mode 100644 index 00000000000..2e4ea50a32a --- /dev/null +++ b/apps/sim/app/api/auth/shopify/authorize/route.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), + createShopifyOAuthState: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/oauth/shopify-state', () => ({ + createShopifyOAuthState: mocks.createShopifyOAuthState, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getScopesForService: () => ['read_products'], +})) + +import { GET } from '@/app/api/auth/shopify/authorize/route' + +describe('Shopify authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client', + SHOPIFY_CLIENT_SECRET: 'shopify-secret', + }, + }) + mocks.createShopifyOAuthState.mockReturnValue('signed-state') + }) + + it('binds the post-connect return URL to the signed flow state', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/shopify/authorize?shop=test-store.myshopify.com&returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected&draftId=draft-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(mocks.createShopifyOAuthState).toHaveBeenCalledWith({ + userId: 'user-1', + shopDomain: 'test-store.myshopify.com', + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected', + clientSecret: 'shopify-secret', + }) + expect(response.headers.get('set-cookie')).toContain('shopify_return_url=;') + }) + + it('escapes a user-controlled draft id before embedding it in inline script', async () => { + const url = new URL('https://sim.test/api/auth/shopify/authorize') + url.searchParams.set('draftId', '') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + const html = await response.text() + + expect(response.status).toBe(200) + expect(html.match(/ @@ -168,8 +179,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify` - - const state = generateId() + const safeReturnUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : undefined + + const state = createShopifyOAuthState({ + userId: session.user.id, + shopDomain: cleanShop, + draftId, + returnUrl: safeReturnUrl, + clientSecret, + }) const oauthUrl = `https://${cleanShop}/admin/oauth/authorize?` + @@ -189,31 +207,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const response = NextResponse.redirect(oauthUrl) - response.cookies.set('shopify_oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - response.cookies.set('shopify_shop_domain', cleanShop, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') - if (returnUrl && isSameOrigin(returnUrl)) { - response.cookies.set('shopify_return_url', returnUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - } + response.cookies.delete('shopify_return_url') return response } catch (error) { diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index b69c6caed2e..f98aaf5aab8 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -8,6 +8,7 @@ import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('TrelloAuthorize') @@ -16,8 +17,8 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' -const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -28,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeTrelloContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl: requestedReturnUrl } = parsed.data.query + const { returnUrl: requestedReturnUrl, draftId } = parsed.data.query const apiKey = env.TRELLO_API_KEY @@ -57,15 +58,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) + if (draftId) { + response.cookies.set(TRELLO_CREDENTIAL_DRAFT_COOKIE, draftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, + path: TRELLO_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: TRELLO_CREDENTIAL_DRAFT_COOKIE, + path: TRELLO_STATE_COOKIE_PATH, + }) + } if (requestedReturnUrl && isSameOrigin(requestedReturnUrl)) { response.cookies.set(TRELLO_RETURN_URL_COOKIE, requestedReturnUrl, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) } else { diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 12233c934a4..22d9a04aefa 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -18,11 +18,13 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' function clearStateCookie(response: NextResponse) { response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) response.cookies.delete({ name: TRELLO_RETURN_URL_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) + response.cookies.delete({ name: TRELLO_CREDENTIAL_DRAFT_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) return response } @@ -37,6 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(storeTrelloTokenContract, request, {}) if (!parsed.success) return parsed.response const { token, state } = parsed.data.body + const draftId = request.cookies.get(TRELLO_CREDENTIAL_DRAFT_COOKIE)?.value const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value if (!cookieState || cookieState !== state) { @@ -135,17 +138,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'trello', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Trello', { error }) - } + if (!persisted) { + throw new Error(`Trello OAuth account ${trelloUser.id} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'trello', + accountId: persisted.id, + }) return clearStateCookie(NextResponse.json({ success: true })) } catch (error) { diff --git a/apps/sim/app/api/credentials/[id]/members/route.test.ts b/apps/sim/app/api/credentials/[id]/members/route.test.ts new file mode 100644 index 00000000000..c7e1d01fb5b --- /dev/null +++ b/apps/sim/app/api/credentials/[id]/members/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { credential } from '@sim/db/schema' +import { + auditMock, + authMockFns, + createMockRequest, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listMembers: vi.fn(), + removeMember: vi.fn(), + upsertMember: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@/lib/credentials/members', () => ({ + leaveCredentialMembership: vi.fn(), + listCredentialMembers: mocks.listMembers, + listCredentialMembershipsForUser: vi.fn(), + removeCredentialMember: mocks.removeMember, + upsertCredentialMember: mocks.upsertMember, +})) + +import { DELETE, GET, POST } from '@/app/api/credentials/[id]/members/route' + +const CREDENTIAL_ID = 'credential-1' +const WORKSPACE_ID = 'workspace-1' +const routeContext = { params: Promise.resolve({ id: CREDENTIAL_ID }) } +const credentialRow = { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'oauth' as const, + displayName: 'Google account', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +describe('/api/credentials/[id]/members compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listMembers.mockResolvedValue([ + { + id: 'member-1', + userId: 'user-2', + role: 'member', + status: 'active', + joinedAt: new Date('2026-08-02T00:00:00.000Z'), + userName: 'Member', + userEmail: 'member@example.com', + }, + ]) + }) + + it('allows any workspace reader to list the credential roster', async () => { + queueTableRows(credential, [credentialRow]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members` + ), + routeContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + members: [ + expect.objectContaining({ + id: 'member-1', + joinedAt: '2026-08-02T00:00:00.000Z', + }), + ], + }) + expect(mocks.listMembers).toHaveBeenCalledWith(credentialRow) + }) + + it('conceals an existing credential outside the caller workspace as not found', async () => { + queueTableRows(credential, [credentialRow]) + mocks.resolvePermission.mockResolvedValue(null) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members` + ), + routeContext + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Not found' }) + expect(mocks.listMembers).not.toHaveBeenCalled() + }) + + it('keeps nonexistent POST and DELETE targets behind the uniform admin denial', async () => { + queueTableRows(credential, []) + const postResponse = await POST( + createMockRequest('POST', { userId: 'user-2', role: 'member' }), + routeContext + ) + queueTableRows(credential, []) + const deleteResponse = await DELETE( + createMockRequest( + 'DELETE', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members?userId=user-2` + ), + routeContext + ) + + expect(postResponse.status).toBe(403) + expect(await postResponse.json()).toEqual({ error: 'Admin access required' }) + expect(deleteResponse.status).toBe(403) + expect(await deleteResponse.json()).toEqual({ error: 'Admin access required' }) + }) +}) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 7c87041c4d6..07e15694921 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -1,418 +1,65 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credential, credentialMember, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { + listWorkspaceCredentialMembersContract, + removeWorkspaceCredentialMemberContract, upsertWorkspaceCredentialMemberContract, - type WorkspaceCredentialMember, } from '@/lib/api/contracts/credentials' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deriveCredentialAdmin, isSharedCredentialType } from '@/lib/credentials/access' -import { captureServerEvent } from '@/lib/posthog/server' import { - getUserEntityPermissions, - getUsersWithPermissions, -} from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialMembersAPI') - -interface RouteContext { - params: Promise<{ id: string }> -} - -async function requireCredentialAdmin(credentialId: string, userId: string) { - const [cred] = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!cred || cred.type === 'managed_oauth') { - return null - } - - const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) - if (perm === null) return null - - const [membership] = await db - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) - ) - .limit(1) - - const isAdmin = deriveCredentialAdmin({ - credentialType: cred.type, - memberRole: membership?.status === 'active' ? membership.role : null, - workspaceCanAdmin: perm === 'admin', - }) - - if (!isAdmin) { - return null - } - return { credentialType: cred.type, workspaceId: cred.workspaceId } -} - -export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - - const [cred] = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!cred || cred.type === 'managed_oauth') { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const callerPerm = await getUserEntityPermissions( - session.user.id, - 'workspace', - cred.workspaceId - ) - if (callerPerm === null) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const explicitMembers = await db - .select({ - id: credentialMember.id, - userId: credentialMember.userId, - role: credentialMember.role, - status: credentialMember.status, - joinedAt: credentialMember.joinedAt, - userName: user.name, - userEmail: user.email, - }) - .from(credentialMember) - .innerJoin(user, eq(credentialMember.userId, user.id)) - .where(eq(credentialMember.credentialId, credentialId)) - - const byUser = new Map( - explicitMembers.map((m) => [ - m.userId, - { - id: m.id, - userId: m.userId, - role: m.role, - status: m.status, - joinedAt: m.joinedAt ? m.joinedAt.toISOString() : null, - userName: m.userName, - userEmail: m.userEmail, - roleSource: 'explicit' as const, - }, - ]) - ) - - if (isSharedCredentialType(cred.type)) { - const workspaceMembers = await getUsersWithPermissions(cred.workspaceId) - for (const wsMember of workspaceMembers) { - if (wsMember.permissionType !== 'admin') continue - const existing = byUser.get(wsMember.userId) - if (existing) { - existing.role = 'admin' - existing.status = 'active' - existing.roleSource = 'workspace-admin' - } else { - byUser.set(wsMember.userId, { - id: `workspace-admin-${wsMember.userId}`, - userId: wsMember.userId, - role: 'admin', - status: 'active', - joinedAt: null, - userName: wsMember.name, - userEmail: wsMember.email, - roleSource: 'workspace-admin', - }) - } - } - } - - const members = Array.from(byUser.values()) - - return NextResponse.json({ members }) - } catch (error) { - logger.error('Failed to fetch credential members', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialMemberListErrorPolicy, + internalCredentialMemberMutationErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + listCredentialMembersUseCase, + removeCredentialMemberUseCase, + upsertCredentialMemberUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialOperations } from '@/lib/credentials/application/operations' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceCredentialMembersContract, + auth: internalSessionAuth, + operation: credentialOperations.listMembers, + rateLimit, + errorPolicy: internalCredentialMemberListErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: listCredentialMembersUseCase, + present: ({ members }) => ({ + members: members.map((member) => ({ + ...member, + joinedAt: member.joinedAt?.toISOString() ?? null, + })), + }), }) -export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - - const admin = await requireCredentialAdmin(credentialId, session.user.id) - if (!admin) { - logger.warn('Credential member share denied', { - credentialId, - actorId: session.user.id, - reason: 'not-admin', - }) - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } - if (!isSharedCredentialType(admin.credentialType)) { - logger.warn('Credential member share denied', { - credentialId, - actorId: session.user.id, - reason: 'env_personal-cannot-be-shared', - }) - return NextResponse.json({ error: 'Personal secrets cannot be shared' }, { status: 400 }) - } - - const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context) - if (!parsed.success) return parsed.response - - const { userId, role } = parsed.data.body - - const targetWorkspacePerm = await getUserEntityPermissions( - userId, - 'workspace', - admin.workspaceId - ) - if (targetWorkspacePerm === 'admin' && role !== 'admin') { - return NextResponse.json( - { error: 'Workspace admins are automatically credential admins and cannot be demoted' }, - { status: 400 } - ) - } - - const now = new Date() - - const [existing] = await db - .select({ id: credentialMember.id, status: credentialMember.status }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) - ) - .limit(1) - - if (existing) { - const result = await db.transaction(async (tx) => { - const [current] = await tx - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .where(eq(credentialMember.id, existing.id)) - .limit(1) - .for('update') - if ( - !isSharedCredentialType(admin.credentialType) && - current?.role === 'admin' && - current?.status === 'active' && - role !== 'admin' - ) { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - .for('update') - if (activeAdmins.length <= 1) return { ok: false as const } - } - await tx - .update(credentialMember) - .set({ role, status: 'active', updatedAt: now }) - .where(eq(credentialMember.id, existing.id)) - return { ok: true as const, fromRole: current?.role } - }) - if (!result.ok) { - return NextResponse.json({ error: 'Cannot demote the last admin' }, { status: 400 }) - } - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: `Changed credential member role to "${role}"`, - metadata: { targetUserId: userId, fromRole: result.fromRole, toRole: role }, - request, - }) - - return NextResponse.json({ success: true }) - } - - await db.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId, - role, - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - - captureServerEvent(session.user.id, 'credential_shared', { - credential_type: admin.credentialType, - role, - workspace_id: admin.workspaceId, - }) - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_ADDED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: `Shared credential with member as "${role}"`, - metadata: { targetUserId: userId, role }, - request, - }) - - return NextResponse.json({ success: true }, { status: 201 }) - } catch (error) { - logger.error('Failed to add credential member', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const POST = defineInternalJsonRoute({ + contract: upsertWorkspaceCredentialMemberContract, + auth: internalSessionAuth, + operation: credentialOperations.upsertMember, + rateLimit, + errorPolicy: internalCredentialMemberMutationErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + useCase: upsertCredentialMemberUseCase, + present: () => ({ success: true as const }), + statusForResult: ({ created }) => (created ? 201 : 200), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - const targetUserId = new URL(request.url).searchParams.get('userId') - if (!targetUserId) { - return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 }) - } - - const admin = await requireCredentialAdmin(credentialId, session.user.id) - if (!admin) { - logger.warn('Credential member removal denied', { - credentialId, - actorId: session.user.id, - reason: 'not-admin', - }) - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } - - const [target] = await db - .select({ - id: credentialMember.id, - role: credentialMember.role, - }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, targetUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (!target) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - - if (isSharedCredentialType(admin.credentialType)) { - const targetWorkspacePerm = await getUserEntityPermissions( - targetUserId, - 'workspace', - admin.workspaceId - ) - if (targetWorkspacePerm === 'admin') { - return NextResponse.json( - { error: 'Workspace admins are automatically credential admins and cannot be removed' }, - { status: 400 } - ) - } - } - - const revoked = await db.transaction(async (tx) => { - if (!isSharedCredentialType(admin.credentialType) && target.role === 'admin') { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - .for('update') - - if (activeAdmins.length <= 1) { - return false - } - } - - await tx - .update(credentialMember) - .set({ status: 'revoked', updatedAt: new Date() }) - .where(eq(credentialMember.id, target.id)) - - return true - }) - - if (!revoked) { - return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 }) - } - - captureServerEvent(session.user.id, 'credential_unshared', { - credential_type: admin.credentialType, - workspace_id: admin.workspaceId, - }) - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_REMOVED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: 'Removed credential member', - metadata: { targetUserId }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to remove credential member', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const DELETE = defineInternalJsonRoute({ + contract: removeWorkspaceCredentialMemberContract, + auth: internalSessionAuth, + operation: credentialOperations.removeMember, + rateLimit, + errorPolicy: internalCredentialMemberMutationErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, query }) => ({ credentialId: params.id, userId: query.userId }), + useCase: removeCredentialMemberUseCase, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index ca1eee11b9c..d99ad8382c4 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,188 +1,63 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - type CredentialActorContext, - canUseCredential, - getCredentialActorContext, -} from '@/lib/credentials/access' + deleteWorkspaceCredentialContract, + getWorkspaceCredentialContract, + updateWorkspaceCredentialContract, +} from '@/lib/api/contracts/credentials' import { - isProviderOutageCode, - performDeleteCredential, - performUpdateCredential, -} from '@/lib/credentials/orchestration' - -const logger = createLogger('CredentialByIdAPI') - -function formatCredentialResponse(access: CredentialActorContext) { - const cred = access.credential - if (!cred) return null - - return { - id: cred.id, - workspaceId: cred.workspaceId, - type: cred.type, - displayName: cred.displayName, - description: cred.description, - providerId: cred.providerId, - accountId: cred.accountId, - envKey: cred.envKey, - envOwnerUserId: cred.envOwnerUserId, - createdBy: cred.createdBy, - createdAt: cred.createdAt, - updatedAt: cred.updatedAt, - role: access.isAdmin ? 'admin' : (access.member?.role ?? null), - status: access.member?.status ?? (access.isAdmin ? 'active' : null), - } -} - -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential || access.credential.type === 'managed_oauth') { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!canUseCredential(access)) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) - } catch (error) { - logger.error('Failed to fetch credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest(updateWorkspaceCredentialContract, request, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const body = parsed.data.body - - const currentAccess = await getCredentialActorContext(id, session.user.id) - if (!currentAccess.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const result = await performUpdateCredential({ - credentialId: id, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - displayName: body.displayName, - description: body.description, - serviceAccountJson: body.serviceAccountJson, - signingSecret: body.signingSecret, - botToken: body.botToken, - apiToken: body.apiToken, - domain: body.domain, - clientId: body.clientId, - clientSecret: body.clientSecret, - certificateId: body.certificateId, - orgId: body.orgId, - dataCenter: body.dataCenter, - authMethod: body.authMethod, - privateKey: body.privateKey, - username: body.username, - request, - }) - if (!result.success) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'forbidden' - ? 403 - : result.errorCode === 'conflict' - ? 409 - : // A provider outage during reconnect is infra, not a bad - // request — mirror the create route and runtime token route. - // Every provider family names its own outage code, so this - // asks the shared predicate rather than matching one literal. - isProviderOutageCode(result.providerErrorCode) - ? 502 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json( - { - error: result.error, - ...(result.providerErrorCode ? { code: result.providerErrorCode } : {}), - }, - { status } - ) - } - - const access = await getCredentialActorContext(id, session.user.id) - return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) - } catch (error) { - logger.error('Failed to update credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const currentAccess = await getCredentialActorContext(id, session.user.id) - if (!currentAccess.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - const result = await performDeleteCredential({ - credentialId: id, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - if (!result.success) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'forbidden' - ? 403 - : result.errorCode === 'conflict' - ? 409 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json({ error: result.error }, { status }) - } - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to delete credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + getWorkspaceCredentialUseCase, + updateWorkspaceCredentialUseCase, +} from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { toWorkspaceCredential } from '@/lib/credentials/application/presentation' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.read, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: getWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + credential: toWorkspaceCredential(credential, access), + }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.update, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + useCase: updateWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + credential: toWorkspaceCredential(credential, access), + }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.delete, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: deleteCredentialUseCase, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 15fdfcb5d7f..545101f591c 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -1,100 +1,23 @@ -import { db } from '@sim/db' -import { pendingCredentialDraft } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createCredentialDraftContract } from '@/lib/api/contracts/credentials' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialDraftAPI') - -const DRAFT_TTL_MS = 15 * 60 * 1000 - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(createCredentialDraftContract, request, {}) - if (!parsed.success) return parsed.response - - const { workspaceId, providerId, displayName, description, credentialId } = parsed.data.body - const userId = session.user.id - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } - - if (credentialId) { - const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) - if ( - !access.credential || - access.credential.type === 'managed_oauth' || - access.credential.workspaceId !== workspaceId || - !access.isAdmin - ) { - return NextResponse.json( - { error: 'Admin access required on the target credential' }, - { status: 403 } - ) - } - } - - const now = new Date() - - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - - await db - .insert(pendingCredentialDraft) - .values({ - id: generateId(), - userId, - workspaceId, - providerId, - displayName, - description: description || null, - credentialId: credentialId || null, - expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { - displayName, - description: description || null, - credentialId: credentialId || null, - expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), - createdAt: now, - }, - }) - - logger.info('Credential draft saved', { - userId, - workspaceId, - providerId, - displayName, - credentialId: credentialId || null, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to save credential draft', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +export const POST = defineInternalJsonRoute({ + contract: createCredentialDraftContract, + auth: internalSessionAuth, + operation: credentialOperations.saveDraft, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: saveCredentialDraft, }) diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index 33227c66de0..6ee05aa1de6 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -1,123 +1,48 @@ -import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, ne } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialMembershipsAPI') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const memberships = await db - .select({ - membershipId: credentialMember.id, - credentialId: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - providerId: credential.providerId, - role: credentialMember.role, - status: credentialMember.status, - joinedAt: credentialMember.joinedAt, - }) - .from(credentialMember) - .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where( - and(eq(credentialMember.userId, session.user.id), ne(credential.type, 'managed_oauth')) - ) - - return NextResponse.json({ memberships }, { status: 200 }) - } catch (error) { - logger.error('Failed to list credential memberships', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + leaveCredentialMembershipContract, + listCredentialMembershipsContract, +} from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + leaveCredentialMembershipUseCase, + listCredentialMembershipsUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: listCredentialMembershipsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listMemberships, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: () => ({}), + useCase: listCredentialMembershipsUseCase, + present: ({ memberships }) => ({ + memberships: memberships.map((membership) => ({ + ...membership, + joinedAt: membership.joinedAt?.toISOString() ?? null, + })), + }), }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parseResult = leaveCredentialQuerySchema.safeParse({ - credentialId: new URL(request.url).searchParams.get('credentialId'), - }) - if (!parseResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(parseResult.error) }, - { status: 400 } - ) - } - - const { credentialId } = parseResult.data - const [membership] = await db - .select() - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, session.user.id) - ) - ) - .limit(1) - - if (!membership) { - return NextResponse.json({ error: 'Membership not found' }, { status: 404 }) - } - - if (membership.status !== 'active') { - return NextResponse.json({ success: true }, { status: 200 }) - } - - const revoked = await db.transaction(async (tx) => { - if (membership.role === 'admin') { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - - if (activeAdmins.length <= 1) { - return false - } - } - - await tx - .update(credentialMember) - .set({ - status: 'revoked', - updatedAt: new Date(), - }) - .where(eq(credentialMember.id, membership.id)) - - return true - }) - - if (!revoked) { - return NextResponse.json( - { error: 'Cannot leave credential as the last active admin' }, - { status: 400 } - ) - } - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to leave credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const DELETE = defineInternalJsonRoute({ + contract: leaveCredentialMembershipContract, + auth: internalSessionAuth, + operation: credentialUserOperations.leaveMembership, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: leaveCredentialMembershipUseCase, }) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 3a105b71e57..2b0c2bfed45 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -18,11 +18,19 @@ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-serv const { mockCheckWorkspaceAccess, + mockGetCredentialActorContext, mockGetCredentialCreationWorkspaceContext, + mockLoadWorkspace, + mockResolveWorkspacePermission, + mockSyncWorkspaceOAuthCredentials, mockVerifyAndBuildServiceAccountSecret, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), + mockGetCredentialActorContext: vi.fn(), mockGetCredentialCreationWorkspaceContext: vi.fn(), + mockLoadWorkspace: vi.fn(), + mockResolveWorkspacePermission: vi.fn(), + mockSyncWorkspaceOAuthCredentials: vi.fn(), mockVerifyAndBuildServiceAccountSecret: vi.fn(), })) @@ -33,12 +41,34 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mockLoadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolveWorkspacePermission, +})) + +vi.mock('@/lib/credentials/access', () => ({ + canUseCredential: (access: { member: unknown; isAdmin: boolean; hasWorkspaceAccess: boolean }) => + access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin), + getCredentialActorContext: mockGetCredentialActorContext, + isSharedCredentialType: (type: string) => type !== 'env_personal', + requireOrdinaryCredentialType: (type: string) => { + if (type === 'managed_oauth') throw new Error('Managed OAuth credential reached test surface') + return type + }, + SHARED_CREDENTIAL_TYPES: ['oauth', 'env_workspace', 'service_account'], +})) + vi.mock('@/lib/credentials/environment', () => ({ getCredentialCreationWorkspaceContext: mockGetCredentialCreationWorkspaceContext, })) vi.mock('@/lib/credentials/oauth', () => ({ - syncWorkspaceOAuthCredentialsForUser: vi.fn(), + syncWorkspaceOAuthCredentialsForUser: mockSyncWorkspaceOAuthCredentials, })) vi.mock('@/lib/oauth', () => ({ @@ -57,6 +87,12 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ import { GET, POST } from '@/app/api/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const WORKSPACE_CONTEXT = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', +} describe('GET /api/credentials', () => { beforeEach(() => { @@ -64,7 +100,10 @@ describe('GET /api/credentials', () => { resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + session: { id: 'session-1' }, }) + mockLoadWorkspace.mockResolvedValue(WORKSPACE_CONTEXT) + mockResolveWorkspacePermission.mockResolvedValue('read') mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -112,6 +151,53 @@ describe('GET /api/credentials', () => { }), ]) }) + + it('normalizes padded, blank, and duplicate legacy query values', async () => { + queueTableRows(credential, []) + const url = new URL('http://localhost:3000/api/credentials') + url.searchParams.append('workspaceId', ` ${WORKSPACE_ID} `) + url.searchParams.append('workspaceId', 'not-the-selected-value') + url.searchParams.set('type', '') + url.searchParams.set('providerId', '') + url.searchParams.set('credentialId', ' ') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentials: [] }) + expect(mockLoadWorkspace).toHaveBeenCalledWith(WORKSPACE_ID) + }) + + it('uses the legacy workspace-scoped id/account lookup without sync, filters, or shape drift', async () => { + queueTableRows(credential, []) + queueTableRows(credential, [ + { + id: 'credential-1', + displayName: 'Google account', + type: 'oauth', + providerId: 'google-email', + }, + ]) + const url = new URL('http://localhost:3000/api/credentials') + url.searchParams.set('workspaceId', WORKSPACE_ID) + url.searchParams.set('credentialId', ' account-1 ') + url.searchParams.set('type', 'env_workspace') + url.searchParams.set('providerId', 'different-provider') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + credential: { + id: 'credential-1', + displayName: 'Google account', + type: 'oauth', + providerId: 'google-email', + }, + }) + expect(mockSyncWorkspaceOAuthCredentials).not.toHaveBeenCalled() + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) }) describe('POST /api/credentials', () => { @@ -120,7 +206,10 @@ describe('POST /api/credentials', () => { resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + session: { id: 'session-1' }, }) + mockLoadWorkspace.mockResolvedValue(WORKSPACE_CONTEXT) + mockResolveWorkspacePermission.mockResolvedValue('write') mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -132,6 +221,27 @@ describe('POST /api/credentials', () => { memberUserIds: ['user-1'], canWrite: true, }) + mockGetCredentialActorContext.mockResolvedValue({ + credential: { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Service account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2026-08-11T00:00:00.000Z'), + updatedAt: new Date('2026-08-11T00:00:00.000Z'), + }, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + }) }) describe('client-credential service accounts', () => { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 991b76d712a..ea3203d398f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,278 +1,49 @@ -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, ne } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, - credentialsListGetQuerySchema, + listWorkspaceCredentialsContract, } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { - performCreateCredential, - statusForCredentialOrchestrationError, -} from '@/lib/credentials/orchestration/credential-create' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialsAPI') - -/** - * Thrown by the inner duplicate guard inside the create transaction when a - * concurrent request slipped a row in between the outer existence check and - * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can - * map to a friendly message. - */ -class DuplicateCredentialError extends Error { - constructor() { - super('duplicate_display_name') - this.name = 'DuplicateCredentialError' - } -} - -interface ExistingCredentialSourceParams { - workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - accountId?: string | null - envKey?: string | null - envOwnerUserId?: string | null - displayName?: string | null - providerId?: string | null -} - -type DbOrTx = typeof db | Parameters[0]>[0] - -async function findExistingCredentialBySourceWith( - exec: DbOrTx, - params: ExistingCredentialSourceParams -) { - const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params - - if (type === 'oauth' && accountId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'oauth'), - eq(credential.accountId, accountId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_workspace' && envKey) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_workspace'), - eq(credential.envKey, envKey) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_personal' && envKey && envOwnerUserId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envKey, envKey), - eq(credential.envOwnerUserId, envOwnerUserId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'service_account' && displayName && providerId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'service_account'), - eq(credential.providerId, providerId), - eq(credential.displayName, displayName) - ) - ) - .limit(1) - return row ?? null - } - - return null -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { searchParams } = new URL(request.url) - const rawWorkspaceId = searchParams.get('workspaceId') - const rawType = searchParams.get('type') - const rawProviderId = searchParams.get('providerId') - const rawCredentialId = searchParams.get('credentialId') - const parseResult = credentialsListGetQuerySchema.safeParse({ - workspaceId: rawWorkspaceId?.trim(), - type: rawType?.trim() || undefined, - providerId: rawProviderId?.trim() || undefined, - credentialId: rawCredentialId?.trim() || undefined, - }) - - if (!parseResult.success) { - logger.warn(`[${requestId}] Invalid credential list request`, { - workspaceId: rawWorkspaceId, - type: rawType, - providerId: rawProviderId, - errors: parseResult.error.issues, - }) - return NextResponse.json( - { error: getValidationErrorMessage(parseResult.error) }, - { status: 400 } - ) - } - - const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data - const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) - - if (!workspaceAccess.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - if (lookupCredentialId) { - let [row] = await db - .select({ - id: credential.id, - displayName: credential.displayName, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where( - and( - eq(credential.id, lookupCredentialId), - eq(credential.workspaceId, workspaceId), - ne(credential.type, 'managed_oauth') - ) - ) - .limit(1) - - if (!row) { - ;[row] = await db - .select({ - id: credential.id, - displayName: credential.displayName, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where( - and( - eq(credential.accountId, lookupCredentialId), - eq(credential.workspaceId, workspaceId), - ne(credential.type, 'managed_oauth') - ) - ) - .limit(1) - } - - return NextResponse.json({ credential: row ?? null }) - } - - if (!type || type === 'oauth') { - await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }) - } - - const visible = await listVisibleWorkspaceCredentials({ - workspaceId, - userId: session.user.id, - workspaceAccess, - types: type ? [type] : undefined, - providerId, - }) - const credentials = visible.data.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest) - - return NextResponse.json({ credentials }) - } catch (error) { - logger.error(`[${requestId}] Failed to list credentials`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + createWorkspaceCredential, + listInternalCredentials, +} from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { toWorkspaceCredential } from '@/lib/credentials/application/presentation' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceCredentialsContract, + auth: internalSessionAuth, + operation: credentialOperations.listInternal, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: listInternalCredentials, + present: (result) => + result.mode === 'lookup' + ? { credential: result.credential } + : { credentials: result.credentials.map((row) => toWorkspaceCredential(row)) }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - createWorkspaceCredentialContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const result = await performCreateCredential({ - ...parsed.data.body, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success) { - logger.warn(`[${requestId}] Credential create rejected`, { - errorCode: result.errorCode, - providerErrorCode: result.providerErrorCode, - }) - const status = statusForCredentialOrchestrationError(result.errorCode, { - providerUnavailable: result.providerUnavailable, - }) - return NextResponse.json( - result.providerErrorCode - ? { code: result.providerErrorCode, error: result.error } - : { error: result.error }, - { status } - ) - } - - if (!result.credential) { - throw new Error('Credential creation succeeded without a credential') - } - - const responseBody = createWorkspaceCredentialContract.response.schema.parse({ - credential: { - ...result.credential, - createdAt: result.credential.createdAt.toISOString(), - updatedAt: result.credential.updatedAt.toISOString(), - }, - }) - - // An existing credential matched the source: an idempotent replay, not a create. - return NextResponse.json(responseBody, { status: result.created ? 201 : 200 }) +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: createWorkspaceCredential, + present: ({ credential, role, status }) => ({ + credential: { ...toWorkspaceCredential({ ...credential, role }), status }, + }), + statusForResult: ({ created }) => (created ? 201 : 200), }) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts new file mode 100644 index 00000000000..38362bef21f --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/service-account', () => ({ + deleteCredentialUseCase: { + operation: { id: 'credentials.delete' }, + execute: mocks.execute, + }, +})) + +import { DELETE } from '@/app/api/v2/credentials/[credentialId]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('DELETE /api/v2/credentials/[credentialId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ credential: { id: 'credential-1' } }) + }) + + it('disconnects a credential through the application operation', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/credential-1?workspaceId=${WORKSPACE_ID}`, + { method: 'DELETE' } + ) + const response = await DELETE(request, { + params: Promise.resolve({ credentialId: 'credential-1' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'credential-1', deleted: true } }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, credentialId: 'credential-1' }, + request, + }) + }) + + it('requires the asserted workspace scope', async () => { + const response = await DELETE( + new NextRequest('http://localhost:3000/api/v2/credentials/credential-1', { + method: 'DELETE', + }), + { params: Promise.resolve({ credentialId: 'credential-1' }) } + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts new file mode 100644 index 00000000000..0baea423259 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts @@ -0,0 +1,30 @@ +import { v2DeleteCredentialContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Credential not found', +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + credentialId: params.credentialId, + }), + useCase: deleteCredentialUseCase, + present: ({ credential }) => ({ data: { id: credential.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/credentials/connections/route.test.ts b/apps/sim/app/api/v2/credentials/connections/route.test.ts new file mode 100644 index 00000000000..1946b7fb81c --- /dev/null +++ b/apps/sim/app/api/v2/credentials/connections/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/v2/credentials/connections/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('POST /api/v2/credentials/connections', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it('creates a browser entrypoint for a named OAuth credential', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + const response = await POST(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }, + request, + }) + }) + + it('requires a display name for new OAuth connections', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/connections/route.ts b/apps/sim/app/api/v2/credentials/connections/route.ts new file mode 100644 index 00000000000..7fef7f6ce2d --- /dev/null +++ b/apps/sim/app/api/v2/credentials/connections/route.ts @@ -0,0 +1,32 @@ +import { v2CreateCredentialConnectionContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialConnectionErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateCredentialConnectionContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createConnection, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialConnectionErrorPolicy, + mapInput: ({ body }) => body, + useCase: createCredentialConnection, + present: ({ authorizationUrl, expiresAt }) => ({ + data: { + authorizationUrl, + expiresAt: expiresAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/credentials/providers/route.test.ts b/apps/sim/app/api/v2/credentials/providers/route.test.ts new file mode 100644 index 00000000000..648c3154db2 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/providers/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ + listCredentialProviders: { + operation: { id: 'credentials.providers.list' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/v2/credentials/providers/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const providers = [ + { + type: 'oauth' as const, + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'service_account' as const, + serviceId: 'salesforce-service-account', + providerId: 'salesforce-service-account', + name: 'Salesforce integration user app', + description: 'Connect Salesforce with an integration user app.', + providerFamily: 'salesforce', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientSecret', + label: 'Consumer secret', + placeholder: 'Paste the consumer secret', + required: false, + secret: true, + multiline: false, + requiredForAuthMethods: ['client_credentials'], + }, + ], + }, +] + +describe('GET /api/v2/credentials/providers', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ providers }) + }) + + it('returns OAuth and service-account connection methods', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: providers, nextCursor: null }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + }) + + it('rejects unsupported pagination instead of ignoring it', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&limit=1` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('forwards a provider-name search to the authorized application use case', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&search=%20Sales%20` + ) + + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, search: 'Sales' }, + request, + }) + }) + + it('rejects an empty provider search', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&search=` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/providers/route.ts b/apps/sim/app/api/v2/credentials/providers/route.ts new file mode 100644 index 00000000000..4fbe9de2f4f --- /dev/null +++ b/apps/sim/app/api/v2/credentials/providers/route.ts @@ -0,0 +1,27 @@ +import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialProviderErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const GET = defineV2JsonRoute({ + contract: v2ListCredentialProvidersContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.listProviders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialProviderErrorPolicy, + mapInput: ({ query }) => query, + useCase: listCredentialProviders, + present: ({ providers }) => ({ data: providers, nextCursor: null }), +}) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 465d2cbd6be..c06ce24edcf 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -13,7 +13,8 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - execute: vi.fn(), + list: vi.fn(), + create: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -23,13 +24,20 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ listWorkspaceCredentials: { operation: { id: 'credentials.connections.list' }, - execute: mocks.execute, + execute: mocks.list, + }, +})) + +vi.mock('@/lib/credentials/application/service-account', () => ({ + createServiceAccountCredentialUseCase: { + operation: { id: 'credentials.service_accounts.create' }, + execute: mocks.create, }, })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' -import { GET } from '@/app/api/v2/credentials/route' +import { GET, POST } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' const auth = { @@ -67,7 +75,7 @@ describe('GET /api/v2/credentials', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: null, sortBy: 'createdAt', @@ -81,7 +89,7 @@ describe('GET /api/v2/credentials', () => { expect(response.status).toBe(400) expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('calls the application operation with the workspace principal', async () => { @@ -91,7 +99,7 @@ describe('GET /api/v2/credentials', () => { const response = await GET(request) expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: { workspaceId: WORKSPACE_ID, @@ -114,7 +122,7 @@ describe('GET /api/v2/credentials', () => { * map of param names and stays green when a route drops the stamp entirely. */ it('refuses a cursor minted under a different filter', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -129,7 +137,7 @@ describe('GET /api/v2/credentials', () => { const { nextCursor } = await minted.json() expect(nextCursor).toEqual(expect.any(String)) - mocks.execute.mockClear() + mocks.list.mockClear() const replayed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=slack&cursor=${encodeURIComponent(nextCursor)}` @@ -138,11 +146,11 @@ describe('GET /api/v2/credentials', () => { expect(replayed.status).toBe(400) expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('resumes a cursor replayed under the filters it was minted with', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -156,7 +164,7 @@ describe('GET /api/v2/credentials', () => { ) const { nextCursor } = await minted.json() - mocks.execute.mockClear() + mocks.list.mockClear() const resumed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom&cursor=${encodeURIComponent(nextCursor)}` @@ -164,7 +172,7 @@ describe('GET /api/v2/credentials', () => { ) expect(resumed.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: expect.objectContaining({ search: 'zoom', @@ -202,7 +210,7 @@ describe('GET /api/v2/credentials', () => { }) it('hides repository errors that may contain secret details', async () => { - mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) + mocks.list.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) const response = await GET( new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) @@ -214,3 +222,98 @@ describe('GET /api/v2/credentials', () => { }) }) }) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + keyType: 'personal', + }) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.create.mockResolvedValue({ + credential: { ...credential, encryptedServiceAccountKey: 'must-not-leak' }, + created: true, + hasServiceAccountKey: true, + role: 'admin', + auditMetadata: {}, + }) + }) + + it('creates a verified service-account credential without returning secrets', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + clientId: 'client-id', + clientSecret: 'client-secret', + certificateId: undefined, + orgId: 'account-id', + }), + }) + const response = await POST(request) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body.data).toMatchObject({ + id: 'credential-1', + type: 'service_account', + displayName: 'Zoom account', + providerId: 'zoom-service-account', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('client-secret') + expect(JSON.stringify(body)).not.toContain('must-not-leak') + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + description: undefined, + id: undefined, + serviceAccountJson: undefined, + apiToken: undefined, + domain: undefined, + signingSecret: undefined, + botToken: undefined, + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + dataCenter: undefined, + authMethod: undefined, + privateKey: undefined, + username: undefined, + }, + request, + }) + }) + + it('rejects an unknown service-account provider before the use case', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'made-up-service-account', + serviceAccountJson: '{}', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.create).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index b0dde7a39ab..d3f8c12b27c 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,39 +1,26 @@ -import type { V2Credential } from '@/lib/api/contracts/v2/credentials' -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateServiceAccountCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' import { + createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' -import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { toV2Credential } from '@/lib/credentials/application/presentation' +import { createServiceAccountCredentialUseCase } from '@/lib/credentials/application/service-account' import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ -function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { - if (row.type !== 'oauth' && row.type !== 'service_account') { - throw new Error(`Secret credential type ${row.type} reached the credentials API`) - } - - return { - id: row.id, - type: row.type, - displayName: row.displayName, - description: row.description, - providerId: row.providerId, - accountId: row.accountId, - hasServiceAccountKey: row.hasServiceAccountKey, - role: row.role, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} +const credentialWorkspaceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) /** Every param that changes which credentials, in which order, this list returns. */ function credentialCursorFilters(query: { @@ -56,7 +43,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: credentialOperations.listConnections, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: credentialWorkspaceErrorPolicy, mapInput: ({ query }) => ({ ...query, cursorKeys: readSortedCursor( @@ -77,3 +64,18 @@ export const GET = defineV2JsonRoute({ ), }), }) + +/** POST /api/v2/credentials — Create and verify a service-account credential. */ +export const POST = defineV2JsonRoute({ + contract: v2CreateServiceAccountCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createServiceAccount, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialWorkspaceErrorPolicy, + mapInput: ({ body }) => body, + useCase: createServiceAccountCredentialUseCase, + present: ({ credential, hasServiceAccountKey, role }) => ({ + data: toV2Credential({ ...credential, hasServiceAccountKey, role }), + }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/app/oauth/credential-connected/page.test.tsx b/apps/sim/app/oauth/credential-connected/page.test.tsx new file mode 100644 index 00000000000..2a137ea39b7 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import CredentialConnectedPage from '@/app/oauth/credential-connected/page' + +describe('CredentialConnectedPage', () => { + it('confirms a successful connection', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Credential connected') + expect(markup).toContain('The credential is ready to use.') + }) + + it('does not claim success when the provider returns an error', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected', error: 'access_denied' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) + + it('does not claim success without an explicit success result', async () => { + const page = await CredentialConnectedPage({ searchParams: Promise.resolve({}) }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) +}) diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx new file mode 100644 index 00000000000..72606f82511 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -0,0 +1,39 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' +import { LogoShell } from '@/app/(landing)/components' + +export const metadata: Metadata = { + title: 'Credential connected', + robots: { index: false, follow: false }, +} + +interface CredentialConnectedPageProps { + searchParams: Promise> +} + +export default async function CredentialConnectedPage({ + searchParams, +}: CredentialConnectedPageProps) { + const params = await searchParams + const result = typeof params.result === 'string' ? params.result : undefined + const error = Array.isArray(params.error) ? params.error[0] : params.error + const connected = result === 'connected' && !error + + return ( + +
+

+ {connected ? 'Credential connected' : 'Connection failed'} +

+

+ {connected + ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.' + : 'The credential could not be connected. Return to the app that started the connection and try again.'} +

+ + Open Sim + +
+
+ ) +} diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index ca094a48c24..8d931e4e20e 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -23,6 +23,7 @@ import { environmentKeys } from '@/hooks/queries/environment' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { fetchWorkspaceCredentialList, + requireWorkspaceCredentialListResponse, WORKSPACE_CREDENTIAL_LIST_STALE_TIME, } from '@/hooks/queries/utils/fetch-workspace-credentials' @@ -74,7 +75,7 @@ export function useWorkspaceCredentials(params: { }, signal, }) - return data.credentials ?? [] + return requireWorkspaceCredentialListResponse(data) }, enabled: Boolean(workspaceId) && enabled, staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index bf1dccfe9d3..aae9ce81307 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,8 +1,21 @@ import { requestJson } from '@/lib/api/client/request' -import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' +import { + type ContractJsonResponse, + listWorkspaceCredentialsContract, + type WorkspaceCredential, +} from '@/lib/api/contracts' export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 +export function requireWorkspaceCredentialListResponse( + data: ContractJsonResponse +): WorkspaceCredential[] { + if (!('credentials' in data)) { + throw new Error('Workspace credential list returned a lookup response') + } + return data.credentials +} + /** * Fetches the workspace credential list. * @@ -18,5 +31,5 @@ export async function fetchWorkspaceCredentialList( query: { workspaceId }, signal, }) - return data.credentials ?? [] + return requireWorkspaceCredentialListResponse(data) } diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 5b2092c44cb..d49fd84f7ca 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -25,6 +25,7 @@ import { import { getDesktopBridge } from '@/lib/desktop' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { requireWorkspaceCredentialListResponse } from '@/hooks/queries/utils/fetch-workspace-credentials' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' const SETTINGS_RETURN_URL_KEY = 'settings-return-url' @@ -39,7 +40,7 @@ async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise { const data = await requestJson(listWorkspaceCredentialsContract, { query: { workspaceId: ctx.workspaceId, type: 'oauth' }, }) - const oauthCredentials = data.credentials ?? [] + const oauthCredentials = requireWorkspaceCredentialListResponse(data) const forProvider = oauthCredentials.filter((c) => c.providerId === ctx.providerId) if (forProvider.length > ctx.preCount) { @@ -97,7 +98,7 @@ async function verifyOAuthChatAttempt(queryClient: QueryClient, attemptId: strin requestJson(listWorkspaceCredentialsContract, { query: { workspaceId: attempt.workspaceId, type: 'oauth' }, signal, - }).then((data) => data.credentials ?? []), + }).then(requireWorkspaceCredentialListResponse), staleTime: 0, }) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 944cbd54980..21942cf9291 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -47,23 +47,30 @@ export type WorkspaceCredentialRole = z.output export type WorkspaceCredential = z.output +const firstQueryStringSchema = z + .union([z.string(), z.array(z.string()).min(1)]) + .transform((value) => (Array.isArray(value) ? value[0] : value)) + +function trimmedOptionalQueryString>(schema: T) { + return firstQueryStringSchema + .transform((value) => value.trim() || undefined) + .pipe(schema.optional()) + .optional() +} + export const credentialsListQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), + workspaceId: firstQueryStringSchema + .transform((value) => value.trim()) + .pipe(z.string().uuid('Workspace ID must be a valid UUID')), + type: trimmedOptionalQueryString(workspaceCredentialTypeSchema), + providerId: trimmedOptionalQueryString(z.string()), + credentialId: trimmedOptionalQueryString(z.string()), }) export const credentialIdParamsSchema = z.object({ id: z.string().min(1), }) -export const credentialsListGetQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), - credentialId: z.string().optional(), -}) - export const serviceAccountJsonSchema = z .string() .min(1, 'Service account JSON key is required') @@ -260,6 +267,18 @@ export const leaveCredentialQuerySchema = z.object({ credentialId: z.string().min(1), }) +export const credentialMembershipSchema = z.object({ + membershipId: z.string(), + credentialId: z.string(), + workspaceId: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + providerId: z.string().nullable(), + role: workspaceCredentialRoleSchema, + status: workspaceCredentialMemberStatusSchema, + joinedAt: z.string().nullable(), +}) + export const workspaceCredentialMemberSchema = z.object({ id: z.string(), userId: z.string(), @@ -306,6 +325,14 @@ export const oauthCredentialSchema = z.object({ scopes: z.array(z.string()).optional(), }) +export const workspaceCredentialLookupSchema = workspaceCredentialSchema.pick({ + id: true, + displayName: true, + type: true, + providerId: true, +}) +export type WorkspaceCredentialLookup = z.output + export const oauthCredentialsQuerySchema = z .object({ provider: z.string().nullish(), @@ -324,9 +351,10 @@ export const listWorkspaceCredentialsContract = defineRouteContract({ query: credentialsListQuerySchema, response: { mode: 'json', - schema: z.object({ - credentials: z.array(workspaceCredentialSchema), - }), + schema: z.union([ + z.object({ credentials: z.array(workspaceCredentialSchema) }), + z.object({ credential: workspaceCredentialLookupSchema.nullable() }), + ]), }, }) @@ -384,6 +412,7 @@ export const createWorkspaceCredentialContract = defineRouteContract({ body: createCredentialBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ credential: workspaceCredentialSchema, }), @@ -422,6 +451,7 @@ export const upsertWorkspaceCredentialMemberContract = defineRouteContract({ body: upsertWorkspaceCredentialMemberBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ success: z.literal(true), member: workspaceCredentialMemberSchema.optional(), @@ -441,3 +471,22 @@ export const removeWorkspaceCredentialMemberContract = defineRouteContract({ }), }, }) + +export const listCredentialMembershipsContract = defineRouteContract({ + method: 'GET', + path: '/api/credentials/memberships', + response: { + mode: 'json', + schema: z.object({ memberships: z.array(credentialMembershipSchema) }), + }, +}) + +export const leaveCredentialMembershipContract = defineRouteContract({ + method: 'DELETE', + path: '/api/credentials/memberships', + query: leaveCredentialQuerySchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) diff --git a/apps/sim/lib/api/contracts/oauth-connections.test.ts b/apps/sim/lib/api/contracts/oauth-connections.test.ts index db44ebfb81f..8e607b307fc 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.test.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.test.ts @@ -3,11 +3,23 @@ */ import { describe, expect, it } from 'vitest' import { + connectedAccountsQuerySchema, instagramAuthorizeQuerySchema, instagramCallbackQuerySchema, trelloAuthorizeQuerySchema, } from '@/lib/api/contracts/oauth-connections' +describe('Connected account query contracts', () => { + it('preserves first-value and blank-provider normalization', () => { + expect(connectedAccountsQuerySchema.parse({ provider: '' })).toEqual({ + provider: undefined, + }) + expect(connectedAccountsQuerySchema.parse({ provider: ['google', 'slack'] })).toEqual({ + provider: 'google', + }) + }) +}) + describe('Instagram OAuth query contracts', () => { it('accepts bounded authorize and callback values', () => { expect( diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 234f86419ea..89d31c2fac4 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -32,8 +32,15 @@ export const disconnectOAuthBodySchema = z.object({ accountId: z.string().optional(), }) +const firstQueryStringSchema = z + .union([z.string(), z.array(z.string()).min(1)]) + .transform((value) => (Array.isArray(value) ? value[0] : value)) + export const connectedAccountsQuerySchema = z.object({ - provider: z.string().min(1).optional(), + provider: firstQueryStringSchema + .transform((value) => value || undefined) + .pipe(z.string().min(1).optional()) + .optional(), }) export const connectedAccountSchema = z.object({ @@ -49,12 +56,18 @@ export const trelloTokenBodySchema = z.object({ state: z.string().min(1, 'state is required'), }) +const oauthCredentialDraftIdSchema = z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + export const trelloAuthorizeQuerySchema = z.object({ returnUrl: z .string() .min(1, 'Return URL cannot be empty') .max(2048, 'Return URL is too long') .optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) const trelloCallbackQuerySchema = z @@ -137,6 +150,7 @@ export const oauthTokenPostContract = defineRouteContract({ export const shopifyAuthorizeQuerySchema = z.object({ shop: z.string().optional(), returnUrl: z.string().optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const shopifyCallbackQuerySchema = z.object({ @@ -226,6 +240,7 @@ export const instagramAuthorizeQuerySchema = z.object({ .max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long') .optional(), workspaceId: workspaceIdSchema.optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const authorizeInstagramContract = defineRouteContract({ @@ -270,12 +285,42 @@ export const instagramCallbackContract = defineRouteContract({ response: { mode: 'redirect' }, }) -export const authorizeOAuth2QuerySchema = z.object({ - providerId: z.string().min(1, 'providerId is required'), - workspaceId: workspaceIdSchema, - callbackURL: z.string().min(1).optional(), - credentialId: z.string().min(1).optional(), -}) +export const authorizeOAuth2QuerySchema = z + .object({ + draftId: oauthCredentialDraftIdSchema.optional(), + providerId: z.string().min(1, 'providerId is required').optional(), + workspaceId: workspaceIdSchema.optional(), + callbackURL: z.string().min(1).optional(), + credentialId: z.string().min(1).optional(), + }) + .superRefine((data, ctx) => { + if (data.draftId) { + for (const field of ['providerId', 'workspaceId', 'callbackURL', 'credentialId'] as const) { + if (data[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} cannot be combined with draftId`, + }) + } + } + return + } + if (!data.providerId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerId'], + message: 'providerId is required', + }) + } + if (!data.workspaceId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['workspaceId'], + message: 'workspaceId is required', + }) + } + }) export const authorizeOAuth2Contract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 5004b64c806..248c4069cc4 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -78,11 +78,14 @@ const PAGED_LISTS = [ * server reports. The MCP *server* list is not bounded that way — nothing caps * how many servers a workspace registers — which is why it is paged and does * not appear here. + * - The credential-provider catalog is bounded by the code-defined OAuth and + * service-account registries. * - A knowledge base has a fixed number of tag slots, so its tag vocabulary * cannot grow past them. * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ + 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 6694a626a3c..3f4c3b05bb0 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,14 +1,20 @@ import { z } from 'zod' import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, + v2DataResponse, v2PaginationFields, v2SearchSchema, v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' +import { + getServiceAccountRequiredFields, + SERVICE_ACCOUNT_REQUIRED_FIELDS, +} from '@/lib/credentials/service-account-fields' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' /** Public credentials are authenticated connections, never raw environment secrets. */ export const v2CredentialTypeSchema = z @@ -44,6 +50,114 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output +export const v2CredentialProviderAuthorizationOptionSchema = z + .object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z + .string() + .min(1, 'label cannot be empty') + .max(255, 'label must be at most 255 characters') + .describe('Human-readable authorization-server label.'), + }) + .strict() +export type V2CredentialProviderAuthorizationOption = z.output< + typeof v2CredentialProviderAuthorizationOptionSchema +> + +export const v2CredentialProviderFieldOptionSchema = z + .object({ + value: z.string().min(1).max(255).describe('Submitted option value.'), + label: z.string().min(1).max(255).describe('Human-readable option label.'), + }) + .strict() + +export const v2CredentialProviderFieldSchema = z + .object({ + id: z.string().min(1).max(255).describe('Exact create-body field name.'), + label: z.string().min(1).max(255).describe('Human-readable field label.'), + placeholder: z.string().min(1).max(1000).describe('Suggested input placeholder.'), + required: z.boolean().describe('Whether the field is required for the selected flow.'), + secret: z.boolean().describe('Whether the submitted field is write-only secret material.'), + multiline: z.boolean().describe('Whether the field is intended for multi-line input.'), + requiredForAuthMethods: z + .array(z.string().min(1).max(64)) + .min(1) + .max(10) + .optional() + .describe('Authentication methods for which this field is required.'), + options: z + .array(v2CredentialProviderFieldOptionSchema) + .min(1) + .max(20) + .optional() + .describe('Fixed values accepted by a selector field.'), + hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + }) + .strict() + +const v2CredentialProviderBaseShape = { + serviceId: z.string().min(1).max(255).describe('Stable credential-provider identifier.'), + name: z.string().min(1).max(255).describe('Credential provider display name.'), + description: z.string().min(1).max(1000).describe('Credential provider description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can connect the provider in the current deployment.'), +} as const + +export const v2OAuthCredentialProviderSchema = z + .object({ + type: z.literal('oauth').describe('Browser-based OAuth connection method.'), + ...v2CredentialProviderBaseShape, + supportsReconnect: z + .boolean() + .describe('Whether existing credentials for this service can be reconnected.'), + authorizationOptions: z + .array(v2CredentialProviderAuthorizationOptionSchema) + .min(1) + .max(10) + .describe('Authorization servers available for this OAuth service.'), + }) + .strict() + +export const v2ServiceAccountCredentialProviderSchema = z + .object({ + type: z.literal('service_account').describe('Direct service-account credential method.'), + ...v2CredentialProviderBaseShape, + providerId: z + .string() + .min(1) + .max(255) + .describe('Exact service-account provider ID accepted by credential creation.'), + docsUrl: z.string().url().describe('Setup guide for the provider.'), + helpText: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + requiresClientGeneratedCredentialId: z + .boolean() + .describe('Whether the caller must generate and submit the credential ID before setup.'), + fields: z + .array(v2CredentialProviderFieldSchema) + .min(1) + .max(20) + .describe('Create-body fields accepted by this provider. Secret fields are write-only.'), + }) + .strict() + +export const v2CredentialProviderSchema = z + .discriminatedUnion('type', [ + v2OAuthCredentialProviderSchema, + v2ServiceAccountCredentialProviderSchema, + ]) + .meta({ + id: 'V2CredentialProvider', + title: 'Credential Provider', + description: 'An OAuth or service-account connection method available to a workspace.', + }) +export type V2CredentialProvider = z.output + /** A credential's natural name field is `displayName`, so that is what `search` matches. */ export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] @@ -66,11 +180,6 @@ export const v2ListCredentialsQuerySchema = z .strict() export type V2ListCredentialsQuery = z.output -/** - * Lists OAuth and service-account connections, keyset-paginated over the active - * sort. Credential mutations are intentionally absent. Nothing capped the - * per-workspace set before pagination, so the response grew without bound. - */ export const v2ListCredentialsContract = defineRouteContract({ method: 'GET', path: '/api/v2/credentials', @@ -80,3 +189,274 @@ export const v2ListCredentialsContract = defineRouteContract({ schema: v2CursorListResponse(v2CredentialSchema), }, }) + +export const v2ListCredentialProvidersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace used to evaluate credential-provider availability and integration policy.' + ), + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the credential provider name.' + ), + }) + .strict() +export type V2ListCredentialProvidersQuery = z.output + +export const v2ListCredentialProvidersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/providers', + query: v2ListCredentialProvidersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialProviderSchema, { paged: false }), + }, +}) + +const v2CreateCredentialConnectionByProviderSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider ID returned by credential-provider discovery.'), + displayName: z + .string({ error: 'displayName is required' }) + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .describe('Name shown for the new credential in Sim.'), + }) + .strict() + +const v2CreateCredentialConnectionByCredentialSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + credentialId: z + .string({ error: 'credentialId is required' }) + .trim() + .min(1, 'credentialId cannot be empty') + .max(255, 'credentialId must be at most 255 characters') + .describe('Existing OAuth credential to reconnect in place.'), + }) + .strict() + +export const v2CreateCredentialConnectionBodySchema = z.union([ + v2CreateCredentialConnectionByProviderSchema, + v2CreateCredentialConnectionByCredentialSchema, +]) +export type V2CreateCredentialConnectionBody = z.output< + typeof v2CreateCredentialConnectionBodySchema +> + +export const v2CredentialConnectionAuthorizationSchema = z + .object({ + authorizationUrl: z + .string() + .url('authorizationUrl must be an absolute URL') + .describe('Short-lived Sim browser URL that starts the OAuth authorization flow.'), + expiresAt: v2TimestampSchema.describe('ISO 8601 timestamp when the connection link expires.'), + }) + .meta({ + id: 'V2CredentialConnectionAuthorization', + title: 'Credential Connection Authorization', + description: 'A short-lived browser entrypoint for an OAuth connection flow.', + }) +export type V2CredentialConnectionAuthorization = z.output< + typeof v2CredentialConnectionAuthorizationSchema +> + +export const v2CreateCredentialConnectionResponseSchema = v2DataResponse( + v2CredentialConnectionAuthorizationSchema +) +export type V2CreateCredentialConnectionResponse = z.output< + typeof v2CreateCredentialConnectionResponseSchema +> + +export const v2CreateCredentialConnectionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials/connections', + query: noInputSchema, + body: v2CreateCredentialConnectionBodySchema, + response: { + mode: 'json', + schema: v2CreateCredentialConnectionResponseSchema, + }, +}) + +const v2ServiceAccountSecretFieldsShape = { + serviceAccountJson: z + .string() + .min(1) + .max(65_536) + .optional() + .describe('Write-only Google service-account JSON key.') + .meta({ writeOnly: true }), + apiToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only provider API token.') + .meta({ writeOnly: true }), + domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + signingSecret: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only webhook signing secret.') + .meta({ writeOnly: true }), + botToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only bot token.') + .meta({ writeOnly: true }), + clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'), + clientSecret: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe('Write-only OAuth client secret.') + .meta({ writeOnly: true }), + certificateId: z + .string() + .trim() + .min(1) + .max(512) + .optional() + .describe('Provider certificate mapping identifier.'), + orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'), + dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'), + authMethod: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe('Provider authentication method.'), + privateKey: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only PEM private key.') + .meta({ writeOnly: true }), + username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), +} as const + +export const v2CreateServiceAccountCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + type: z.literal('service_account').describe('Service-account credential discriminator.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact service-account provider ID returned by provider discovery.'), + displayName: z + .string() + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .optional() + .describe('Optional name; providers may derive one from the verified account identity.'), + description: z + .string() + .trim() + .max(500, 'description must be at most 500 characters') + .optional() + .describe('Optional credential description.'), + id: z + .string() + .uuid('id must be a valid UUID') + .optional() + .describe('Required only when provider discovery requests a client-generated ID.'), + ...v2ServiceAccountSecretFieldsShape, + }) + .strict() + .superRefine((body, ctx) => { + if (!Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, body.providerId)) { + ctx.addIssue({ + code: 'custom', + path: ['providerId'], + message: `Unknown service-account provider: ${body.providerId}`, + }) + return + } + if (body.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && !body.id) { + ctx.addIssue({ + code: 'custom', + path: ['id'], + message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`, + }) + } + for (const field of getServiceAccountRequiredFields(body.providerId)) { + if (!body[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${body.providerId} credentials`, + }) + } + } + }) +export type V2CreateServiceAccountCredentialBody = z.input< + typeof v2CreateServiceAccountCredentialBodySchema +> + +export const v2CreateServiceAccountCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + query: noInputSchema, + body: v2CreateServiceAccountCredentialBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse(v2CredentialSchema), + }, +}) + +export const v2CredentialParamsSchema = z + .object({ + credentialId: nonEmptyIdSchema.max(255).describe('Credential to disconnect.'), + }) + .strict() + +export const v2DeleteCredentialQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + }) + .strict() + +export const v2CredentialDeleteDataSchema = z + .object({ + id: nonEmptyIdSchema.describe('Disconnected credential identifier.'), + deleted: z.literal(true).describe('Whether the credential was disconnected.'), + }) + .meta({ + id: 'V2CredentialDeleteData', + title: 'Delete credential data', + description: 'Credential disconnection acknowledgement.', + }) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]', + params: v2CredentialParamsSchema, + query: v2DeleteCredentialQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index a87fa3f3294..23db1528093 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,4 +1,10 @@ -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, v2DeleteCustomToolContract, @@ -166,6 +172,63 @@ const CREDENTIAL_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const +const CREDENTIAL_PROVIDER_EXAMPLE = { + type: 'oauth', + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect to Salesforce CRM data and operations.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} as const + +const SERVICE_ACCOUNT_PROVIDER_EXAMPLE = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Paste the account ID', + required: true, + secret: false, + multiline: false, + }, + ], +} as const + +const CREDENTIAL_CONNECTION_EXAMPLE = { + authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', + expiresAt: '2026-06-20T14:17:11.000Z', +} as const + const SECRET_EXAMPLE = { name: 'STRIPE_API_KEY', scope: 'workspace', @@ -784,7 +847,7 @@ const declaredRoutes = [ operationId: 'listCredentials', summary: 'List Credentials', description: - 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.', + 'List OAuth and service-account connections visible to the caller. Secret material is never returned.', errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), @@ -804,6 +867,135 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListCredentialProvidersContract, + resourceOperation('Credentials', { + operationId: 'listCredentialProviders', + summary: 'List Credential Providers', + description: `List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'Credential provider catalog with caller-specific availability.' }, + }), + { + query: documentedSchema( + v2ListCredentialProvidersContract.query, + 'ListCredentialProvidersQuery', + 'List credential providers query', + 'Workspace and optional provider-name search used to filter caller-specific availability.' + ), + response: documentedSchema( + v2ListCredentialProvidersContract.response.schema, + 'ListCredentialProvidersResponse', + 'List credential providers response', + 'OAuth and service-account connection methods.', + [ + { + data: [CREDENTIAL_PROVIDER_EXAMPLE, SERVICE_ACCOUNT_PROVIDER_EXAMPLE], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2CreateServiceAccountCredentialContract, + resourceOperation('Credentials', { + operationId: 'createServiceAccountCredential', + summary: 'Create Service-Account Credential', + description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { + byStatus: { + 200: { description: 'An existing credential matched the verified source.' }, + 201: { description: 'The service-account credential was created.' }, + }, + }, + }), + { + query: v2CreateServiceAccountCredentialContract.query, + body: documentedSchema( + v2CreateServiceAccountCredentialContract.body, + 'CreateServiceAccountCredentialRequest', + 'Create service-account credential request', + 'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.', + [ + { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom automation', + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + orgId: 'YOUR_ACCOUNT_ID', + }, + ] + ), + response: documentedSchema( + v2CreateServiceAccountCredentialContract.response.schema, + 'CreateServiceAccountCredentialResponse', + 'Create service-account credential response', + 'Verified credential metadata without secret material.', + [{ data: CREDENTIAL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2CreateCredentialConnectionContract, + resourceOperation('Credentials', { + operationId: 'createCredentialConnection', + summary: 'Create Credential Connection', + description: `Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'A short-lived browser authorization URL.' }, + }), + { + query: v2CreateCredentialConnectionContract.query, + body: documentedSchema( + v2CreateCredentialConnectionContract.body, + 'CreateCredentialConnectionBody', + 'Create credential connection body', + 'For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.' + ), + response: documentedSchema( + v2CreateCredentialConnectionContract.response.schema, + 'CreateCredentialConnectionResponse', + 'Create credential connection response', + 'Short-lived Sim browser entrypoint and its expiry.', + [{ data: CREDENTIAL_CONNECTION_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2DeleteCredentialContract, + resourceOperation('Credentials', { + operationId: 'deleteCredential', + summary: 'Disconnect Credential', + description: `Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The credential was disconnected.' }, + }), + { + params: documentedSchema( + v2DeleteCredentialContract.params, + 'DeleteCredentialParams', + 'Disconnect credential path parameters', + 'Credential selected for disconnection.' + ), + query: documentedSchema( + v2DeleteCredentialContract.query, + 'DeleteCredentialQuery', + 'Disconnect credential query', + 'Workspace expected to own the credential.' + ), + response: documentedSchema( + v2DeleteCredentialContract.response.schema, + 'DeleteCredentialResponse', + 'Disconnect credential response', + 'Acknowledgement that the credential was disconnected.', + [{ data: { id: CREDENTIAL_EXAMPLE.id, deleted: true } }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -953,7 +1145,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, { name: 'Credentials', - description: 'List OAuth and service-account connections without secret material.', + description: + 'Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 5b8ff796ddf..df65bdfb1c3 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -65,12 +65,12 @@ import { * - **`search`** ({@link v2SearchSchema}) — a case-insensitive substring match * against the resource's *single* natural name field, and nothing else: * `name` for files/folders/workflows/tables/knowledge bases/MCP servers/ - * skills, `title` for custom tools, `filename` for knowledge documents - * (`GET /knowledge/{id}/documents`), and `displayName` for both credentials - * and secrets (`GET /secrets`, where the secret's name *is* the credential - * `displayName`). It never matches ids, descriptions, or content. `%` and `_` in the term are matched - * literally, not as wildcards. Empty is rejected rather than silently - * ignored — omit the param instead. + * skills/credential providers, `title` for custom tools, `filename` for + * knowledge documents (`GET /knowledge/{id}/documents`), and `displayName` + * for both credentials and secrets (`GET /secrets`, where the secret's name + * *is* the credential `displayName`). It never matches ids, descriptions, or + * content. `%` and `_` in the term are matched literally, not as wildcards. + * Empty is rejected rather than silently ignored — omit the param instead. * - **`sortBy` + `sortOrder`** ({@link v2SortFields}) — `sortBy` is a * per-resource enum, never a free string, because the value selects a column * in the query. `sortOrder` is `asc`/`desc`. Both always have a default, so @@ -93,9 +93,12 @@ import { * * Every one of these is pushed into SQL, except on `GET /skills` (which narrows the * static builtin registry with the same search term, merges it into the DB rows, - * then re-sorts the merged array) and `GET /files/folders` (which applies `parentPath` and `search` - * in JS; its sort is pushed into SQL like every other folder list). Both read a - * full result set to produce a page; neither is a pattern to copy. + * then re-sorts the merged array), `GET /files/folders` (which applies + * `parentPath` and `search` in JS; its sort is pushed into SQL like every other + * folder list), and `GET /credentials/providers` (whose bounded catalog is + * assembled from code-defined registries before its caller-specific + * availability is projected). These read a full result set to produce a page; + * none is a pattern to copy. * * ## Which lists are paged * @@ -111,7 +114,8 @@ import { * load; `GET /knowledge/{id}/tags`, capped by the fixed tag-slot table; * `GET /mcp-servers/{id}/tools`, capped by tool discovery itself; and * `GET /tables/{tableId}/views` and `GET /tables/{tableId}/groups`, capped per - * table. + * table; and the credential-provider catalog, bounded by code-defined OAuth + * and service-account registries. * * Adding `limit`/`cursor` to a full-set list is additive, but giving it a * *default* `limit` truncates callers reading the whole set today, so once v2 is diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 53c0e054622..7f10e36b703 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -383,4 +383,62 @@ describe('defineInternalJsonRoute', () => { __privateMetadata: { value: 'ok' }, }) }) + + it('selects a declared success status from the application result', async () => { + const replayableContract = defineRouteContract({ + method: 'POST', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + status: [200, 201], + }, + }) + const handler = defineInternalJsonRoute({ + contract: replayableContract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'created', created: true } + }, + }, + present: ({ value }) => ({ value }), + statusForResult: ({ created }) => (created ? 201 : 200), + }) + + const response = await handler( + new NextRequest('http://localhost/api/test/internal-json-route', { method: 'POST' }) + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ value: 'created' }) + }) + + it('fails closed when the application selects an undeclared success status', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + statusForResult: () => 201, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) + }) }) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index f9102d57217..8e362c35f66 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -230,6 +230,7 @@ type InternalJsonRouteOptions< params: Record }): void | Promise onSuccess?(args: { principal: P; input: NoInfer; result: NoInfer }): void | Promise + statusForResult?(result: NoInfer): number responseHeaders?(args: { principal: P; input: NoInfer; result: NoInfer }): HeadersInit finalizeResponse?(args: { request: NextRequest @@ -287,12 +288,6 @@ export function defineInternalJsonRoute< options.operation, options.useCase.operation ) - if (successStatuses.length !== 1) { - throw new Error( - `${options.contract.method} ${options.contract.path} internal JSON route requires one success status` - ) - } - const wrapped = withRouteHandler( async (request, context) => { if (!methodMatchesContract(request.method, options.contract.method)) { @@ -344,6 +339,12 @@ export function defineInternalJsonRoute< throw new Error('Internal JSON route response mode changed after initialization') } const validatedBody = responseSchema.schema.parse(body) as ContractJsonResponse + const responseStatus = options.statusForResult?.(result) ?? successStatus + if (!successStatuses.includes(responseStatus)) { + throw new Error( + `Internal JSON route produced undeclared success status ${responseStatus}; expected ${successStatuses.join(', ')}` + ) + } const headers = options.responseHeaders?.({ principal, input, result }) const finalization = options.finalizeResponse ? await options.finalizeResponse({ @@ -357,7 +358,7 @@ export function defineInternalJsonRoute< return NextResponse.json( appendFinalizedBodyFields(validatedBody, finalization?.bodyFields), { - status: successStatus, + status: responseStatus, headers: appendFinalizedHeaders(headers, finalization?.headers), } ) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index cdcbcd32903..7906d93707d 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' +import { APIError, createAuthMiddleware, getOAuthState, getSessionFromCtx } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' import { admin, @@ -96,7 +96,10 @@ import { } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { + loadOAuthCredentialDraftBinding, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -523,20 +526,35 @@ export const auth = betterAuth({ } } - try { - await processCredentialDraft({ - userId: account.userId, - providerId: account.providerId, - accountId: account.id, - }) - } catch (error) { - logger.error('[account.create.after] Failed to process credential draft', { + const credentialDraftBinding = await loadOAuthCredentialDraftBinding(() => + getOAuthState() + ) + if (credentialDraftBinding.status === 'unavailable') { + logger.error('[account.create.after] Failed to read OAuth credential draft state', { userId: account.userId, providerId: account.providerId, - error, + error: credentialDraftBinding.error, }) } + if (credentialDraftBinding.status === 'available') { + try { + await processCredentialDraft({ + draftId: credentialDraftBinding.draftId, + userId: account.userId, + providerId: account.providerId, + accountId: account.id, + }) + } catch (error) { + logger.error('[account.create.after] Failed to process credential draft', { + userId: account.userId, + providerId: account.providerId, + error, + }) + if (credentialDraftBinding.draftId) throw error + } + } + try { const { ensureUserStatsExists } = await import('@/lib/billing/core/usage') await ensureUserStatsExists(account.userId) diff --git a/apps/sim/lib/copilot/application/execute-credential-use-case.ts b/apps/sim/lib/copilot/application/execute-credential-use-case.ts new file mode 100644 index 00000000000..cbebe99402a --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-credential-use-case.ts @@ -0,0 +1,14 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const executeCopilotCredentialUseCase = createCopilotApplicationAdapter({ + domain: 'credential', + delegation: { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: credentialOperations, +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts index e7ea463273b..9b9ebb41da5 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts @@ -8,6 +8,7 @@ const { mocks, useCases } = vi.hoisted(() => ({ custom: vi.fn(), mcp: vi.fn(), skill: vi.fn(), + credential: vi.fn(), capture: vi.fn(), }, useCases: { @@ -23,6 +24,8 @@ const { mocks, useCases } = vi.hoisted(() => ({ deleteSkill: { operation: { id: 'skills.delete' } }, listSkill: { operation: { id: 'skills.list_available' } }, updateSkill: { operation: { id: 'skills.update' } }, + updateCredential: { operation: { id: 'credentials.update' } }, + deleteManyCredentials: { operation: { id: 'credentials.delete_many' } }, }, })) @@ -35,6 +38,9 @@ vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ vi.mock('@/lib/copilot/application/execute-skill-use-case', () => ({ executeCopilotSkillUseCase: mocks.skill, })) +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.credential, +})) vi.mock('@/lib/custom-tools/application/use-cases', () => ({ deleteAvailableCustomToolUseCase: useCases.deleteCustom, listAvailableCustomToolsUseCase: useCases.listCustom, @@ -53,9 +59,16 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ listAvailableSkillsUseCase: useCases.listSkill, updateSkillUseCase: useCases.updateSkill, })) +vi.mock('@/lib/credentials/application/credential-crud', () => ({ + updateWorkspaceCredentialUseCase: useCases.updateCredential, +})) +vi.mock('@/lib/credentials/application/delete-many-credentials', () => ({ + deleteManyCredentialsUseCase: useCases.deleteManyCredentials, +})) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeManageCredential } from '@/lib/copilot/tools/handlers/management/manage-credential' import { executeManageCustomTool } from '@/lib/copilot/tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '@/lib/copilot/tools/handlers/management/manage-mcp-tool' import { executeManageSkill } from '@/lib/copilot/tools/handlers/management/manage-skill' @@ -157,4 +170,43 @@ describe('Copilot management application boundaries', () => { } ) }) + + it('renames credentials through the shared credential use case', async () => { + mocks.credential.mockResolvedValue({ + credential: { id: 'credential-1', displayName: 'Renamed' }, + previousDisplayName: 'Original', + }) + + const result = await executeManageCredential( + { operation: 'rename', credentialId: 'credential-1', displayName: 'Renamed' }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { previousDisplayName: 'Original', displayName: 'Renamed' }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.updateCredential, { + credentialId: 'credential-1', + displayName: 'Renamed', + }) + }) + + it('keeps best-effort batch deletion inside one semantic application command', async () => { + mocks.credential.mockResolvedValue({ deleted: ['credential-1'], failed: ['credential-2'] }) + + const result = await executeManageCredential( + { operation: 'delete', credentialIds: ['credential-1', 'credential-2'] }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { deleted: ['credential-1'], failed: ['credential-2'] }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.deleteManyCredentials, { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index fb307feb7a4..07dc5d0bc44 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -1,84 +1,82 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { updateWorkspaceCredentialUseCase } from '@/lib/credentials/application/credential-crud' +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' -export function executeManageCredential( +export async function executeManageCredential( rawParams: Record, context: ExecutionContext ): Promise { - const params = rawParams as { - operation: string - credentialId?: string - credentialIds?: string[] - displayName?: string + const operation = typeof rawParams.operation === 'string' ? rawParams.operation : '' + const credentialId = + typeof rawParams.credentialId === 'string' ? rawParams.credentialId : undefined + const displayName = typeof rawParams.displayName === 'string' ? rawParams.displayName : undefined + const rawCredentialIds = rawParams.credentialIds + if ( + rawCredentialIds !== undefined && + (!Array.isArray(rawCredentialIds) || rawCredentialIds.some((id) => typeof id !== 'string')) + ) { + return { success: false, error: 'credentialIds must be an array of strings' } } - const { operation, displayName } = params - return (async () => { - try { - if (!context?.userId) { - return { success: false, error: 'Authentication required' } - } - - switch (operation) { - case 'rename': { - const credentialId = params.credentialId - if (!credentialId) return { success: false, error: 'credentialId is required for rename' } - if (!displayName) return { success: false, error: 'displayName is required for rename' } + const credentialIds = rawCredentialIds as string[] | undefined + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const result = await performUpdateCredential({ + try { + switch (operation) { + case 'rename': { + if (!credentialId) { + return { success: false, error: 'credentialId is required for rename' } + } + if (!displayName) { + return { success: false, error: 'displayName is required for rename' } + } + const result = await executeCopilotCredentialUseCase( + context, + updateWorkspaceCredentialUseCase, + { credentialId, - userId: context.userId, displayName, - allowedTypes: ['oauth'], - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename credential' } - } - return { - success: true, - output: { - credentialId, - previousDisplayName: result.previousDisplayName, - displayName, - }, } + ) + return { + success: true, + output: { + credentialId: result.credential.id, + previousDisplayName: result.previousDisplayName, + displayName: result.credential.displayName, + }, } - case 'delete': { - const ids: string[] = - params.credentialIds ?? (params.credentialId ? [params.credentialId] : []) - if (ids.length === 0) - return { success: false, error: 'credentialId or credentialIds is required for delete' } - - const deleted: string[] = [] - const failed: string[] = [] - - for (const id of ids) { - const result = await performDeleteCredential({ - credentialId: id, - userId: context.userId, - allowedTypes: ['oauth'], - reason: 'copilot_delete', - }) - if (!result.success) { - failed.push(id) - continue - } - deleted.push(id) - } - - return { - success: deleted.length > 0, - output: { deleted, failed }, - } - } - default: + } + case 'delete': { + const ids = credentialIds ?? (credentialId ? [credentialId] : []) + if (ids.length === 0) { return { success: false, - error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + error: 'credentialId or credentialIds is required for delete', } + } + const result = await executeCopilotCredentialUseCase( + context, + deleteManyCredentialsUseCase, + { workspaceId, credentialIds: ids } + ) + return { + success: result.deleted.length > 0, + output: { deleted: result.deleted, failed: result.failed }, + } } - } catch (error) { - return { success: false, error: toError(error).message } + default: + return { + success: false, + error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + } } - })() + } catch (error) { + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to manage credential'), + } + } } diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 77ff5a9922d..f3926a3ed11 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -2,324 +2,114 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' -const { - mockEnsureWorkspaceAccess, - mockGetCredentialActorContext, - mockIsOAuthServiceDeploymentAvailable, - mockGetUserPermissionConfig, -} = vi.hoisted(() => ({ - mockEnsureWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), - mockIsOAuthServiceDeploymentAvailable: vi.fn(() => true), - mockGetUserPermissionConfig: vi.fn(), +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + getBaseUrl: vi.fn(), })) -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mockEnsureWorkspaceAccess, +const useCases = vi.hoisted(() => ({ + prepare: { operation: { id: 'credentials.connections.prepare' } }, })) -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.execute, })) - -vi.mock('@/lib/integrations/availability.server', () => ({ - isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - getAllowedIntegrationsFromEnv: vi.fn(() => null), -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [ - { serviceId: 'gmail', providerId: 'google-email', name: 'Gmail', authType: 'oauth' }, - { serviceId: 'slack', providerId: 'slack', name: 'Slack', authType: 'oauth' }, - { serviceId: 'trello', providerId: 'trello', name: 'Trello', authType: 'oauth' }, - { serviceId: 'shopify', providerId: 'shopify', name: 'Shopify', authType: 'oauth' }, - { - serviceId: 'claude-platform', - providerId: 'claude-platform', - name: 'Claude Platform', - authType: 'service_account', - }, - ]), +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: mocks.getBaseUrl })) +vi.mock('@/lib/credentials/application/prepare-credential-connection', () => ({ + prepareCredentialConnection: useCases.prepare, })) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' -const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' - -const context = { - workspaceId: WORKSPACE_ID, - userId: USER_ID, +const context: ExecutionContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', chatId: 'chat-1', -} as unknown as ExecutionContext - -const WORKSPACE_ACCESS = { - exists: true, - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, -} - -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } + toolCallId: 'call-1', + copilotToolExecution: true, + userPermission: 'write', } describe('executeOAuthGetAuthLink', () => { beforeEach(() => { vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) - }) - - describe('connect (no credentialId)', () => { - it('returns an authorize URL without a credentialId param', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(true) - const url = new URL((result.output as { oauth_url: string }).oauth_url) - expect(url.pathname).toBe('/api/auth/oauth2/authorize') - expect(url.searchParams.get('providerId')).toBe('google-email') - expect(url.searchParams.get('credentialId')).toBeNull() - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - - it('rejects a provider whose OAuth client is not configured', async () => { - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(false) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not configured for this deployment') - }) - - it('rejects a provider disallowed for the workspace member', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not allowed for this workspace member') - }) - - it('does not treat service-account-only metadata as OAuth', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'Claude Platform' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') + mocks.getBaseUrl.mockReturnValue('https://sim.test') + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', }) }) - describe('reconnect (credentialId passed)', () => { - it('returns an authorize URL carrying the credentialId and a reconnect message', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) + it('uses the credential application adapter for a new connection', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'gmail' }, context) - expect(result.success).toBe(true) - const output = result.output as { oauth_url: string; message: string } - const url = new URL(output.oauth_url) - expect(url.searchParams.get('credentialId')).toBe(CREDENTIAL_ID) - expect(output.message).toContain('Reconnect') - expect(output.message).toContain(CREDENTIAL_ID) + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledWith(context, useCases.prepare, { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: undefined, }) + const url = new URL((result.output as { oauth_url: string }).oauth_url) + expect(url.pathname).toBe('/api/auth/oauth2/authorize') + expect(url.searchParams.get('providerId')).toBe('google-email') + expect(url.searchParams.get('workspaceId')).toBe('workspace-1') + expect(url.searchParams.has('credentialId')).toBe(false) + }) - it('reuses the already-resolved workspace access for the credential lookup', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, { - workspaceAccess: WORKSPACE_ACCESS, - }) - }) - - it('fails with an agent-visible error for a nonexistent credential', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, - }) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: 'cred-hallucinated' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential belongs to another workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential is not an OAuth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not an OAuth credential') - }) - - it('fails naming the actual provider when providerName does not match the credential', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'slack', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('google-email') + it('preserves the canonical credential ID for reconnect', async () => { + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', + credentialId: 'credential-1', }) - it('fails when the caller is not a credential admin', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Admin access') - }) + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail', credentialId: 'credential-1' }, + context + ) - it('rejects reconnect for Trello and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'trello', credentialId: CREDENTIAL_ID }, - context - ) + const output = result.output as { oauth_url: string; message: string } + expect(new URL(output.oauth_url).searchParams.get('credentialId')).toBe('credential-1') + expect(output.message).toContain('re-authorizes credential credential-1 in place') + }) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + it('returns application validation errors without exposing infrastructure failures', async () => { + mocks.execute.mockRejectedValue(new OrchestrationError('not_found', 'Provider not found')) - it('rejects reconnect for Shopify and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'shopify', credentialId: CREDENTIAL_ID }, - context - ) + const result = await executeOAuthGetAuthLink({ providerName: 'missing' }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + expect(result.success).toBe(false) + expect(result.error).toBe('Provider not found') }) -}) -describe('executeOAuthGetAuthLink service account rejection', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) + it('fails fast without trusted workspace context', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail' }, + { ...context, workspaceId: undefined } + ) + + expect(result).toEqual({ success: false, error: 'workspaceId is required' }) + expect(mocks.execute).not.toHaveBeenCalled() }) - /** - * Regression: a user asked for a "new custom bot", the agent correctly - * resolved that to `slack-custom-bot` and passed it here, and the fuzzy - * substring pass matched it to the Slack OAuth service — `slack-custom-bot` - * contains `slack`. The tool returned a personal-OAuth authorize URL and - * reported success, so the user connected their own account instead of a - * shared bot. Failing loudly is the point: a wrong link that looks right is - * worse than an error the agent can recover from. - */ - it('rejects a service account id with a coherent recovery message, not a workspace link', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'slack-custom-bot' }, context) + it('rejects service-account providers before OAuth resolution', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack custom bot' }, context) expect(result.success).toBe(false) - expect(result.error).toContain('service account') - expect(result.error).toContain('service_account credential tag') - const output = result.output as { setup_url?: string; oauth_url?: string; message: string } - // The rejection must not fall into the generic catch, which would attach a - // contradicting workspace oauth_url and a "connect manually" message — the - // agent would then surface a workspace link instead of the tag. - expect(output.setup_url).toBeUndefined() - expect(output.oauth_url).toBeUndefined() - expect(output.message).toContain('service_account credential tag') - expect(output.message).not.toContain('Connect manually') + expect(result.error).toContain('service account, not an OAuth provider') + expect(mocks.execute).not.toHaveBeenCalled() }) - it.each([ - 'notion-service-account', - 'salesforce-service-account', - 'google-service-account', - 'atlassian-service-account', - 'SLACK-CUSTOM-BOT', - // Readable forms must be normalized (spaces/underscores → hyphens) so they - // are caught too, not passed to the fuzzy OAuth resolver. - 'slack custom bot', - 'google service account', - 'notion_service_account', - ])('rejects %s', async (providerName) => { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('service_account credential tag') - }) + it('does not confuse integrations that also offer service accounts', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack' }, context) - it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => { - // `slack` and `notion` must keep working — the guard keys off the id being - // a service-account id, not off the integration having a service-account flow. - for (const providerName of ['slack', 'google-email']) { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(true) - expect((result.output as { oauth_url: string }).oauth_url).toContain( - '/api/auth/oauth2/authorize' - ) - } + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 5549efacd10..eb24cb86ab6 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -1,16 +1,9 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' -import { isOAuthServiceAllowedByIntegrationTypes } from '@/lib/integrations/availability' -import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' -import { getAllOAuthServices } from '@/lib/oauth/utils' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export async function executeOAuthGetAuthLink( rawParams: Record, @@ -38,33 +31,26 @@ export async function executeOAuthGetAuthLink( `value instead (e.g. "slack") — it opens the service account setup form in chat.` return { success: false, error: message, output: { message } } } + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } try { - 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 permissionConfig = await getUserPermissionConfig(context.userId, context.workspaceId) - const configuredAllowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - const allowedIntegrationTypes = configuredAllowedIntegrations - ? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase())) - : null - const result = await generateOAuthLink( - context.workspaceId, - context.workflowId, - context.chatId, + const result = await executeCopilotCredentialUseCase(context, prepareCredentialConnection, { + workspaceId, providerName, - baseUrl, - allowedIntegrationTypes, - credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined - ) + credentialId, + }) + const callbackURL = context.workflowId + ? `${baseUrl}/workspace/${workspaceId}/w/${context.workflowId}` + : context.chatId + ? `${baseUrl}/workspace/${workspaceId}/chat/${context.chatId}` + : `${baseUrl}/workspace/${workspaceId}` + const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) + authorizeUrl.searchParams.set('providerId', result.providerId) + authorizeUrl.searchParams.set('workspaceId', workspaceId) + authorizeUrl.searchParams.set('callbackURL', callbackURL) + if (result.credentialId) authorizeUrl.searchParams.set('credentialId', result.credentialId) + const action = credentialId ? 'reconnect' : 'connect' return { success: true, @@ -72,23 +58,24 @@ export async function executeOAuthGetAuthLink( message: credentialId ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` : `Authorization URL generated for ${result.serviceName}.`, - oauth_url: result.url, - instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${result.url}`, + oauth_url: authorizeUrl.toString(), + instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${authorizeUrl.toString()}`, provider: result.serviceName, providerId: result.providerId, }, } } catch (err) { + const message = messageForCopilotApplicationError(err) const workspaceUrl = context.workspaceId ? `${baseUrl}/workspace/${context.workspaceId}` : `${baseUrl}/workspace` return { success: false, - error: toError(err).message, + error: message, output: { message: `Could not generate a direct OAuth link for ${providerName}. Connect manually from the workspace.`, oauth_url: workspaceUrl, - error: toError(err).message, + error: message, }, } } @@ -109,140 +96,3 @@ export async function executeOAuthRequestAccess( }, } } - -/** - * Resolves a human-friendly provider name to a providerId and returns a - * browser-initiated authorize URL the user opens to connect the service. - * - * Steps: resolve provider → return the Sim `/api/auth/oauth2/authorize` URL. - * That endpoint (not this server-side handler) creates the credential draft and - * calls Better Auth, so the draft's TTL starts at click and the signed `state` - * cookie is planted in the user's browser and the OAuth callback's state check - * passes. - * - * When `reconnect` is set, the URL carries the existing credential id so the - * authorize endpoint creates a reconnect draft and the OAuth callback rebinds - * the credential in place instead of creating a new one. Validation happens - * here too (not just at click time) so a bad id fails in the tool result where - * the agent can see it, rather than as a silent browser redirect. - */ -async function generateOAuthLink( - workspaceId: string | undefined, - workflowId: string | undefined, - chatId: string | undefined, - providerName: string, - baseUrl: string, - allowedIntegrationTypes: ReadonlySet | null, - reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess } -): Promise<{ url: string; providerId: string; serviceName: string }> { - if (!workspaceId) { - throw new Error('workspaceId is required to generate an OAuth link') - } - - const allServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') - const normalizedInput = providerName.toLowerCase().trim() - - const matched = - allServices.find((s) => s.providerId === normalizedInput) || - allServices.find((s) => s.name.toLowerCase() === normalizedInput) || - allServices.find( - (s) => - s.name.toLowerCase().includes(normalizedInput) || - normalizedInput.includes(s.name.toLowerCase()) - ) || - allServices.find( - (s) => s.providerId.includes(normalizedInput) || normalizedInput.includes(s.providerId) - ) - - if (!matched) { - const available = allServices.map((s) => s.name).join(', ') - throw new Error(`Provider "${providerName}" not found. Available providers: ${available}`) - } - - const { providerId, name: serviceName } = matched - if (!isOAuthServiceAllowedByIntegrationTypes(matched.serviceId, allowedIntegrationTypes)) { - throw new Error(`${serviceName} is not allowed for this workspace member`) - } - if (!isOAuthServiceDeploymentAvailable(providerId)) { - throw new Error(`${serviceName} OAuth is not configured for this deployment`) - } - - if (reconnect) { - if (providerId === 'trello' || providerId === 'shopify') { - throw new Error( - `Reconnect is not supported for ${serviceName} from chat. Ask the user to open the ` + - `integrations page and press Reconnect on the credential there.` - ) - } - const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, { - workspaceAccess: reconnect.workspaceAccess, - }) - if (!actor.credential || actor.credential.workspaceId !== workspaceId) { - throw new Error( - `Credential "${reconnect.credentialId}" was not found in this workspace. Read ` + - `environment/credentials.json for valid credential ids.` - ) - } - if (actor.credential.type !== 'oauth') { - throw new Error( - `Credential "${reconnect.credentialId}" is not an OAuth credential and cannot be reconnected.` - ) - } - if (actor.credential.providerId !== providerId) { - throw new Error( - `Credential "${reconnect.credentialId}" belongs to provider "${actor.credential.providerId}", ` + - `not "${providerId}". Pass the matching providerName.` - ) - } - if (!actor.isAdmin) { - throw new Error('Admin access on the credential is required to reconnect it.') - } - } - - const callbackURL = - workflowId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` - : chatId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}` - : `${baseUrl}/workspace/${workspaceId}` - - if (providerId === 'trello') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/trello/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'instagram') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/instagram/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'shopify') { - const returnUrl = encodeURIComponent(callbackURL) - return { - url: `${baseUrl}/api/auth/shopify/authorize?returnUrl=${returnUrl}`, - providerId, - serviceName, - } - } - - // Hand back a browser-initiated authorize URL rather than calling - // oAuth2LinkAccount here. Generating the link server-side would set Better - // Auth's signed `state` cookie on this server-to-server response instead of the - // user's browser, so the OAuth callback would fail with `state_mismatch`. The - // authorize endpoint runs the link inside the user's browser, planting the - // cookie correctly while keeping the callback's state check enabled. - // - // The pending credential draft is created by that authorize endpoint at click - // time (not here), so the draft's TTL starts when the user actually initiates - // the connect and reliably outlives the OAuth round-trip. - const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) - authorizeUrl.searchParams.set('providerId', providerId) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - authorizeUrl.searchParams.set('callbackURL', callbackURL) - if (reconnect) { - authorizeUrl.searchParams.set('credentialId', reconnect.credentialId) - } - - return { url: authorizeUrl.toString(), providerId, serviceName } -} diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index aa2f4e5e494..067ad94ff10 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -181,6 +181,65 @@ describe('defineAuthorizedWorkspaceUseCase', () => { expect(mocks.events).toEqual(['execute', 'audit', 'afterSuccess']) }) + it('runs resource authorization after workspace authorization and before business effects', async () => { + mocks.resolvePermission.mockImplementation(async () => { + mocks.events.push('workspaceAuthorization') + return 'write' + }) + const execute = vi.fn(async () => { + mocks.events.push('execute') + return { ok: true as const } + }) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async (_args: { principal: SessionPrincipal; input: TestInput }) => { + mocks.events.push('canonicalLoad') + return canonicalContext + }, + authorizationOptions: {}, + authorizeResource() { + mocks.events.push('resourceAuthorization') + }, + execute, + projectAudit: () => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + }), + afterSuccess() { + mocks.events.push('afterSuccess') + }, + }) + + await useCase.authorize?.({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + + expect(mocks.events).toEqual([ + 'canonicalLoad', + 'workspaceAuthorization', + 'resourceAuthorization', + ]) + expect(execute).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + + mocks.events.length = 0 + await expect( + useCase.execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + ).resolves.toEqual({ ok: true }) + expect(mocks.events).toEqual([ + 'canonicalLoad', + 'workspaceAuthorization', + 'resourceAuthorization', + 'execute', + 'audit', + 'afterSuccess', + ]) + }) + it('supports zero or many semantic audit entries', async () => { const buildUseCase = (auditCount: number) => defineAuthorizedWorkspaceUseCase({ diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index a3286535edb..0d37830b458 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -56,6 +56,8 @@ export interface AuthorizedWorkspaceUseCaseDefinition< | (( args: AuthorizedWorkspaceUseCaseContext ) => WorkspaceAuthorizationOptions | Promise>) + /** Applies current domain-resource policy after workspace authorization. */ + authorizeResource?(args: AuthorizedWorkspaceUseCaseContext): void | Promise execute(args: AuthorizedWorkspaceUseCaseContext): Promise projectAudit?( args: AuthorizedWorkspaceUseCaseResultContext @@ -111,7 +113,8 @@ export function defineAuthorizedWorkspaceUseCase< >(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { /** * Everything that runs before the business transaction: allowed-principal - * check, canonical load, asserted-scope comparison, current access check. + * check, canonical load, asserted-scope comparison, current workspace and + * resource access checks. * * `execute` and `authorize` share it rather than each spelling it out, so a * `HEAD` probe cannot answer a different question from the `GET` it stands @@ -145,6 +148,7 @@ export function defineAuthorizedWorkspaceUseCase< context, authorizationOptions ) + await definition.authorizeResource?.(executionContext) return executionContext } diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 5ac8b5c55cc..2197f839434 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -48,6 +48,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'WORKSPACE_RESOURCE_LIMIT_REACHED', /** The workspace's organization does not permit public sharing. */ 'PUBLIC_SHARING_NOT_ALLOWED', + /** The caller can reach the workspace but cannot administer this credential. */ + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', ] as const @@ -83,6 +85,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record { /** * Runs everything {@link execute} does up to and including resource * authorization, then stops — allowed-principal check, canonical load, - * asserted-scope comparison, current access check — but not the business - * transaction, the audit projection, or the after-success effects. + * asserted-scope comparison, current workspace access check, resource access + * check — but not the business transaction, the audit projection, or the + * after-success effects. * * It exists for one caller: a surface that must answer *"would this principal * be allowed?"* without causing what the answer would cause. `HEAD` on a route diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index 917e87d666a..2fc567d9205 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -15,7 +15,7 @@ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) -import { clearCredentialRefs } from '@/lib/credentials/deletion' +import { clearCredentialRefs, deleteConnectionCredential } from '@/lib/credentials/deletion' describe('credential-bound webhook deactivation', () => { beforeEach(() => { @@ -39,3 +39,41 @@ describe('credential-bound webhook deactivation', () => { expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack') }) }) + +describe('deleteConnectionCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('deletes exactly one credential within its canonical workspace scope', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + const deleted = await deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + + expect(deleted).toBe(true) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') + }) + + it('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 9a149357a55..6f7bc25515e 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -12,6 +12,15 @@ type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect export type CredentialType = (typeof credentialTypeEnum.enumValues)[number] +export type OrdinaryCredentialType = Exclude + +/** Narrows credentials exposed through ordinary user-managed credential surfaces. */ +export function requireOrdinaryCredentialType(type: CredentialType): OrdinaryCredentialType { + if (type === 'managed_oauth') { + throw new Error('Managed OAuth credential reached an ordinary credential surface') + } + return type +} /** * Credential types shared at the workspace level — every type except a user's diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts new file mode 100644 index 00000000000..8e97f55c2ce --- /dev/null +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -0,0 +1,54 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server/validation' +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' + +export const credentialValidationParseOptions = { + validationErrorResponse: (error: Parameters[0]) => + validationErrorResponse(error, getValidationErrorMessage(error)), +} as const + +export const internalCredentialErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (!(error instanceof CredentialProviderOperationError)) return null + return internalErrorResponse(error.providerUnavailable ? 502 : 400, { + error: error.message, + code: error.providerErrorCode, + }) + } +) + +export const internalCredentialMemberListErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => { + if ( + error instanceof NoWorkspaceAccessError || + (error instanceof OrchestrationError && error.code === 'not_found') + ) { + return internalErrorResponse(404, { error: 'Not found' }) + } + return null + } +) + +export const internalCredentialMemberMutationErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => { + if ( + error instanceof NoWorkspaceAccessError || + (error instanceof ForbiddenOperationError && + error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED') || + (error instanceof OrchestrationError && error.code === 'not_found') + ) { + return internalErrorResponse(403, { error: 'Admin access required' }) + } + return null + } +) diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts index 7038b7f2eb3..fdcca435bb6 100644 --- a/apps/sim/lib/credentials/application/authorization.ts +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -2,8 +2,18 @@ import type { Principal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' +export const CREDENTIAL_DELEGATION_AUDIENCE = 'sim:credentials' export const MANAGED_OAUTH_DELEGATION_AUDIENCE = 'sim:managed-oauth-credentials' +export const credentialDelegationPolicy = { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + isWithinScope: () => true, +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> + export const managedOAuthCredentialDelegationPolicy = { audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, isWithinScope: ( diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts new file mode 100644 index 00000000000..dbfe9f99157 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineCredentialOperation } from '@/lib/credentials/application/operations' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + getActor: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) + +const memberOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_member', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' +) +const adminOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' +) +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + credential, +} + +function createUseCase(operation: typeof memberOperation | typeof adminOperation) { + return defineAuthorizedCredentialUseCase({ + operation, + resolveContext: async () => ({ ...context }), + execute: mocks.execute, + }) +} + +describe('defineAuthorizedCredentialUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.execute.mockResolvedValue({ ok: true }) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + }) + + it('allows an active credential member for member-level reads', async () => { + await expect( + createUseCase(memberOperation).execute({ principal, input: undefined }) + ).resolves.toEqual({ ok: true }) + expect(mocks.execute).toHaveBeenCalledOnce() + }) + + it('requires credential admin independently of workspace read access', async () => { + await expect( + createUseCase(adminOperation).execute({ principal, input: undefined }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('authorizes the workspace before resolving credential membership', async () => { + await createUseCase(memberOperation).execute({ principal, input: undefined }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts new file mode 100644 index 00000000000..635fc9eb621 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -0,0 +1,82 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + ForbiddenOperationError, + type WorkspaceAuthorizationContext, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialActorContext } from '@/lib/credentials/access' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import type { CredentialOperation } from '@/lib/credentials/application/operations' +import type { CredentialRow } from '@/lib/credentials/queries' + +export interface CredentialAuthorizationContext extends WorkspaceAuthorizationContext { + credential: CredentialRow + credentialAccess?: CredentialActorContext +} + +export function requireCredentialAccess( + context: CredentialAuthorizationContext +): CredentialActorContext { + if (!context.credentialAccess) { + throw new Error('Credential use case executed without resource authorization') + } + return context.credentialAccess +} + +type AuthorizedCredentialUseCaseDefinition< + O extends CredentialOperation, + I, + C extends CredentialAuthorizationContext, + R, +> = Omit< + AuthorizedWorkspaceUseCaseDefinition, + 'authorizationOptions' | 'authorizeResource' +> + +export function defineAuthorizedCredentialUseCase< + const O extends CredentialOperation, + I, + C extends CredentialAuthorizationContext, + R, +>(definition: AuthorizedCredentialUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: credentialDelegationPolicy }, + async authorizeResource({ principal, context }) { + const actor = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if ( + !actor.credential || + actor.credential.workspaceId !== context.workspaceId || + !actor.hasWorkspaceAccess + ) { + throw new OrchestrationError('not_found', 'Credential not found') + } + context.credentialAccess = actor + switch (definition.operation.minimumCredentialRole) { + case 'member': + if (!actor.member && !actor.isAdmin) { + throw new OrchestrationError('forbidden', 'Credential access required') + } + return + case 'admin': + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + } + return + default: + throw new Error( + `Unsupported credential role: ${definition.operation.minimumCredentialRole}` + ) + } + }, + }) +} diff --git a/apps/sim/lib/credentials/application/authorized-user-use-case.ts b/apps/sim/lib/credentials/application/authorized-user-use-case.ts new file mode 100644 index 00000000000..95fd6e08712 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-user-use-case.ts @@ -0,0 +1,102 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import { resolvePrincipalAuditAttribution, type SessionPrincipal } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialUserOperation } from '@/lib/credentials/application/operations' + +export interface CredentialUserAuditEntry { + workspaceId: string | null + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record +} + +interface CredentialUserUseCaseDefinition { + operation: O + execute(args: { + principal: SessionPrincipal + input: I + request?: OrchestrationRequestContext + }): Promise + projectAudit?(args: { + principal: SessionPrincipal + input: I + result: R + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] + projectErrorAudit?(args: { + principal: SessionPrincipal + input: I + error: unknown + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] | undefined + afterSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise + afterError?(args: { principal: SessionPrincipal; input: I; error: unknown }): void | Promise +} + +function recordCredentialUserAudit( + principal: SessionPrincipal, + operation: CredentialUserOperation, + projected: CredentialUserAuditEntry | CredentialUserAuditEntry[] | undefined, + request?: OrchestrationRequestContext +): void { + if (!projected) return + const attribution = resolvePrincipalAuditAttribution(principal) + const entries = Array.isArray(projected) ? projected : [projected] + for (const entry of entries) { + recordAudit({ + workspaceId: entry.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: operation.id, + actor: attribution.actor, + }, + request, + }) + } +} + +/** Defines a current-user credential operation that cannot borrow workspace identity. */ +export function defineAuthorizedCredentialUserUseCase< + const O extends CredentialUserOperation, + I, + R, +>(definition: CredentialUserUseCaseDefinition): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + if (principal.kind !== 'session') { + throw new OrchestrationError('forbidden', 'Session authentication required') + } + try { + const result = await definition.execute({ principal, input, request }) + recordCredentialUserAudit( + principal, + definition.operation, + definition.projectAudit?.({ principal, input, result }), + request + ) + await definition.afterSuccess?.({ principal, input, result }) + return result + } catch (error) { + recordCredentialUserAudit( + principal, + definition.operation, + definition.projectErrorAudit?.({ principal, input, error }), + request + ) + await definition.afterError?.({ principal, input, error }) + throw error + } + }, + } +} diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts new file mode 100644 index 00000000000..143a068961e --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listCatalog: vi.fn(), + getWorkspaceCredential: vi.fn(), + getCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableOAuthCredentialProvider: ( + catalog: Array<{ + available: boolean + authorizationOptions: Array<{ providerId: string }> + }>, + providerId: string + ) => { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) throw Object.assign(new Error('Unknown OAuth provider'), { code: 'validation' }) + if (!provider.available) + throw Object.assign(new Error('OAuth provider is unavailable'), { code: 'conflict' }) + return provider + }, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: ( + credentialProviderId: string, + service: { providerId: string; additionalProviderIds?: readonly string[] } + ) => + credentialProviderId === service.providerId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false), +})) + +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' + +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const salesforceProvider = { + type: 'oauth' as const, + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + providerId: 'salesforce-sandbox', + displayName: 'Sandbox CRM', +} + +describe('resolveCredentialConnectionTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listCatalog.mockResolvedValue([salesforceProvider]) + mocks.getWorkspaceCredential.mockResolvedValue(credential) + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true }) + }) + + it('accepts an exact authorization option for a new connection', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: 'salesforce-sandbox', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + }) + expect(mocks.getWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('loads reconnect credentials through the asserted workspace and requires admin access', async () => { + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: false }) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + + expect(mocks.getWorkspaceCredential).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }) + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1') + }) + + it('preserves the credential authorization-server ID on reconnect', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + credentialId: 'credential-1', + displayName: 'Sandbox CRM', + }) + }) + + it('rejects providers whose custom flow cannot reconnect', async () => { + mocks.listCatalog.mockResolvedValue([{ ...salesforceProvider, supportsReconnect: false }]) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts new file mode 100644 index 00000000000..a7cb618ceab --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -0,0 +1,107 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + listCredentialProviderCatalog, + type OAuthCredentialProviderCatalogEntry, + requireAvailableOAuthCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolvedCredentialConnectionTarget { + provider: OAuthCredentialProviderCatalogEntry + providerId: string + credentialId?: string + displayName?: string +} + +export class CredentialConnectionProviderMismatchError extends OrchestrationError { + constructor() { + super('validation', 'Credential provider does not match the requested OAuth provider') + this.name = 'CredentialConnectionProviderMismatchError' + } +} + +export async function resolveCredentialConnectionTarget(params: { + principal: Principal + context: ActiveWorkspaceApplicationContext + providerId?: string + credentialId?: string + assertedProviderId?: string +}): Promise { + const { principal, context, providerId, credentialId, assertedProviderId } = params + if (Boolean(providerId) === Boolean(credentialId)) { + throw new Error('Credential connection requires exactly one target identifier') + } + + const catalog = await listCredentialProviderCatalog(principal, context) + if (providerId) { + return { + provider: requireAvailableOAuthCredentialProvider(catalog, providerId), + providerId, + } + } + + if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID') + const userId = requirePrincipalSubjectUserId(principal) + const targetCredentialId = credentialId + const credential = await getWorkspaceCredential({ + workspaceId: context.workspaceId, + credentialId: targetCredentialId, + }) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + if (credential.type !== 'oauth' || !credential.providerId) { + throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected') + } + const credentialProviderId = credential.providerId + if (assertedProviderId && assertedProviderId !== credentialProviderId) { + throw new CredentialConnectionProviderMismatchError() + } + + const actor = await getCredentialActorContext(targetCredentialId, userId) + if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access on the credential is required to reconnect it' + ) + } + + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `OAuth provider is unavailable: ${credentialProviderId}` + ) + } + if (!provider.supportsReconnect) { + throw new OrchestrationError( + 'conflict', + `OAuth provider does not support reconnecting credentials: ${credentialProviderId}` + ) + } + + return { + provider, + providerId: credentialProviderId, + credentialId: credential.id, + displayName: credential.displayName, + } +} diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts new file mode 100644 index 00000000000..9c3eabbbed8 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -0,0 +1,133 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), + createDraft: vi.fn(), + getBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: mocks.getBaseUrl, +})) + +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} + +describe('createCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + mocks.createDraft.mockResolvedValue({ + id: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + mocks.getBaseUrl.mockReturnValue('https://sim.ai') + }) + + it('rejects workspace keys before canonical workspace loading', async () => { + await expect( + createCredentialConnection.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('creates a user-bound draft and returns its canonical connection context', async () => { + const result = await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: undefined, + displayName: 'Work Gmail', + displayNameDefinesIntent: true, + }) + expect(result).toEqual({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + draftId: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + providerId: 'google-email', + workspaceId: 'workspace-1', + }) + }) + + it("preserves an existing credential's name on reconnect", async () => { + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + + await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', credentialId: 'credential-1' }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + displayNameDefinesIntent: false, + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts new file mode 100644 index 00000000000..7a0d8491168 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -0,0 +1,69 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateCredentialConnectionInput = { + workspaceId: string +} & ( + | { providerId: string; displayName?: string; credentialId?: never } + | { + credentialId: string + assertedProviderId?: string + providerId?: never + displayName?: never + } +) + +export interface CreateCredentialConnectionResult { + authorizationUrl: string + draftId: string + expiresAt: Date + providerId: string + workspaceId: string + credentialId?: string +} + +export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createConnection, + resolveContext: async ({ input }: { input: CreateCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: input.providerId, + credentialId: input.credentialId, + assertedProviderId: 'assertedProviderId' in input ? input.assertedProviderId : undefined, + }) + const displayName = input.providerId ? input.displayName : target.displayName + + const draft = await createConnectDraft({ + userId: requirePrincipalSubjectUserId(principal), + workspaceId: context.workspaceId, + providerId: target.providerId, + credentialId: target.credentialId, + displayName, + displayNameDefinesIntent: input.providerId !== undefined && displayName !== undefined, + }) + const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) + authorizationUrl.searchParams.set('draftId', draft.id) + return { + authorizationUrl: authorizationUrl.toString(), + draftId: draft.id, + expiresAt: draft.expiresAt, + providerId: target.providerId, + workspaceId: context.workspaceId, + ...(target.credentialId ? { credentialId: target.credentialId } : {}), + } + }, +}) diff --git a/apps/sim/lib/credentials/application/credential-context.ts b/apps/sim/lib/credentials/application/credential-context.ts new file mode 100644 index 00000000000..d5fbf242545 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-context.ts @@ -0,0 +1,32 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialAuthorizationContext } from '@/lib/credentials/application/authorized-credential-use-case' +import { getCredentialById, getWorkspaceCredential } from '@/lib/credentials/queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolveCredentialApplicationContextInput { + credentialId: string + assertedWorkspaceId?: string +} + +/** Loads a credential canonically and verifies any asserted workspace scope. */ +export async function resolveCredentialApplicationContext( + input: ResolveCredentialApplicationContextInput +): Promise { + const assertedWorkspace = input.assertedWorkspaceId + ? await loadActiveWorkspaceApplicationContext(input.assertedWorkspaceId) + : null + if (input.assertedWorkspaceId && !assertedWorkspace) { + throw new OrchestrationError('not_found', 'Credential not found') + } + const credential = assertedWorkspace + ? await getWorkspaceCredential({ + workspaceId: assertedWorkspace.workspaceId, + credentialId: input.credentialId, + }) + : await getCredentialById(input.credentialId) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + const workspace = + assertedWorkspace ?? (await loadActiveWorkspaceApplicationContext(credential.workspaceId)) + if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') + return { ...workspace, credential } +} diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts new file mode 100644 index 00000000000..cd1d065d1e2 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -0,0 +1,244 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { + defineAuthorizedCredentialUseCase, + requireCredentialAccess, +} from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' +import { + createCredentialRecord, + isProviderOutageCode, + type PerformCreateCredentialParams, + type PerformCredentialResult, + type PerformUpdateCredentialParams, + updateCredentialRecord, +} from '@/lib/credentials/orchestration' +import { + type CredentialRow, + findWorkspaceCredentialLookup, + listVisibleWorkspaceCredentials, + type VisibleWorkspaceCredential, + type WorkspaceCredentialLookup, +} from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export class CredentialProviderOperationError extends OrchestrationError { + constructor( + message: string, + readonly providerErrorCode: string, + readonly providerUnavailable: boolean + ) { + super('validation', message) + this.name = 'CredentialProviderOperationError' + } +} + +function throwCredentialMutationFailure(result: { + success: boolean + error?: string + errorCode?: PerformCredentialResult['errorCode'] + providerErrorCode?: string + providerUnavailable?: boolean +}): never { + if (result.providerErrorCode) { + throw new CredentialProviderOperationError( + result.error ?? result.providerErrorCode, + result.providerErrorCode, + result.providerUnavailable === true || isProviderOutageCode(result.providerErrorCode) + ) + } + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential mutation failed') + case 'forbidden': + throw new OrchestrationError('forbidden', result.error ?? 'Credential mutation forbidden') + default: + throw new Error(result.error ?? 'Credential mutation failed') + } +} + +export interface ListInternalCredentialsInput { + workspaceId: string + type?: CredentialRow['type'] + providerId?: string + credentialId?: string +} + +export type ListInternalCredentialsResult = + | { mode: 'list'; credentials: VisibleWorkspaceCredential[] } + | { mode: 'lookup'; credential: WorkspaceCredentialLookup | null } + +export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listInternal, + resolveContext: async ({ input }: { input: ListInternalCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context }): Promise { + if (input.credentialId) { + return { + mode: 'lookup', + credential: await findWorkspaceCredentialLookup({ + workspaceId: context.workspaceId, + credentialId: input.credentialId, + }), + } + } + + const userId = requirePrincipalSubjectUserId(principal) + if (!input.type || input.type === 'oauth') { + await syncWorkspaceOAuthCredentialsForUser({ workspaceId: context.workspaceId, userId }) + } + const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, userId) + const page = await listVisibleWorkspaceCredentials({ + workspaceId: context.workspaceId, + userId, + workspaceAccess, + types: input.type ? [input.type] : undefined, + providerId: input.providerId, + }) + return { mode: 'list', credentials: page.data } + }, +}) + +export type CreateWorkspaceCredentialInput = Omit< + PerformCreateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'request' +> + +export interface CreateWorkspaceCredentialResult { + credential: CredentialRow + created: boolean + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + auditMetadata: Record +} + +export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.create, + resolveContext: async ({ input }: { input: CreateWorkspaceCredentialInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input }): Promise { + const userId = requirePrincipalSubjectUserId(principal) + const result = await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) + if (!result.success) throwCredentialMutationFailure(result) + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + const access = await getCredentialActorContext(result.credential.id, userId) + if (!access.credential || !canUseCredential(access)) { + throw new Error('Created credential is not visible to its creator') + } + const role = access.isAdmin ? 'admin' : access.member?.role + const status = access.member?.status ?? (access.isAdmin ? 'active' : undefined) + if (!role || !status) throw new Error('Created credential has no active actor membership') + return { + credential: access.credential, + created: result.created === true, + role, + status, + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +export interface GetWorkspaceCredentialInput { + credentialId: string +} + +export const getWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.read, + resolveContext: ({ input }: { input: GetWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ context }) { + return { credential: context.credential, access: requireCredentialAccess(context) } + }, +}) + +export type UpdateWorkspaceCredentialInput = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> + +export const updateWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.update, + resolveContext: ({ input }: { input: UpdateWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ principal, input, context }) { + if (principal.kind === 'delegated' && context.credential.type !== 'oauth') { + throw new OrchestrationError('validation', 'Copilot can update only oauth credentials') + } + const result = await updateCredentialRecord({ ...input, credential: context.credential }) + if (!result.success) throwCredentialMutationFailure(result) + const access = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if (!access.credential || !access.isAdmin) { + throw new Error('Updated credential is no longer visible to its administrator') + } + return { + credential: access.credential, + access, + previousDisplayName: context.credential.displayName, + updatedFields: result.updatedFields ?? [], + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + resourceName: context.credential.displayName, + description: `Updated ${context.credential.type} credential "${context.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: context.credential.type, + updatedFields: result.updatedFields, + }, + }), +}) diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts new file mode 100644 index 00000000000..561ae6a130d --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -0,0 +1,150 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { + credentialOperations, + credentialUserOperations, +} from '@/lib/credentials/application/operations' +import { + leaveCredentialMembership, + listCredentialMembers, + listCredentialMembershipsForUser, + removeCredentialMember, + upsertCredentialMember, +} from '@/lib/credentials/members' +import { captureServerEvent } from '@/lib/posthog/server' + +interface CredentialMemberResourceInput { + credentialId: string +} + +function resolveSessionCredentialContext( + _principal: SessionPrincipal, + input: CredentialMemberResourceInput +) { + return resolveCredentialApplicationContext(input) +} + +export const listCredentialMembersUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listMembers, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: CredentialMemberResourceInput + }) => resolveSessionCredentialContext(principal, input), + authorizationOptions: {}, + async execute({ context }) { + return { members: await listCredentialMembers(context.credential) } + }, +}) + +export interface UpsertCredentialMemberInput extends CredentialMemberResourceInput { + userId: string + role: 'admin' | 'member' +} + +export const upsertCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.upsertMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: UpsertCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ principal, input, context }) { + const result = await upsertCredentialMember({ + credential: context.credential, + actorUserId: requirePrincipalSubjectUserId(principal), + targetUserId: input.userId, + role: input.role, + }) + return { ...result, targetUserId: input.userId, role: input.role } + }, + projectAudit: ({ context, result }) => ({ + action: result.created + ? AuditAction.CREDENTIAL_MEMBER_ADDED + : AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: result.created + ? `Shared credential with member as "${result.role}"` + : `Changed credential member role to "${result.role}"`, + metadata: { + targetUserId: result.targetUserId, + ...(result.created + ? { role: result.role } + : { fromRole: result.previousRole, toRole: result.role }), + }, + }), + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_shared', { + credential_type: context.credential.type, + role: result.role, + workspace_id: context.workspaceId, + }) + }, +}) + +export interface RemoveCredentialMemberInput extends CredentialMemberResourceInput { + userId: string +} + +export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.removeMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: RemoveCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ input, context }) { + await removeCredentialMember({ credential: context.credential, targetUserId: input.userId }) + return { success: true as const, targetUserId: input.userId } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_MEMBER_REMOVED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: 'Removed credential member', + metadata: { targetUserId: result.targetUserId }, + }), + afterSuccess: ({ principal, context }) => { + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_unshared', { + credential_type: context.credential.type, + workspace_id: context.workspaceId, + }) + }, +}) + +export const listCredentialMembershipsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listMemberships, + async execute({ principal }) { + return { memberships: await listCredentialMembershipsForUser(principal.userId) } + }, +}) + +export interface LeaveCredentialMembershipInput { + credentialId: string +} + +export const leaveCredentialMembershipUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.leaveMembership, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: LeaveCredentialMembershipInput + }) { + await leaveCredentialMembership({ userId: principal.userId, credentialId: input.credentialId }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.test.ts b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts new file mode 100644 index 00000000000..c3154f33db1 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} + +function oauthCredential(id: string, workspaceId = 'workspace-1') { + return { + id, + workspaceId, + type: 'oauth' as const, + displayName: `OAuth ${id}`, + description: null, + providerId: 'google-email', + accountId: `account-${id}`, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-14T12:00:00.000Z'), + updatedAt: new Date('2026-08-14T12:00:00.000Z'), + } +} + +describe('deleteManyCredentialsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.deleteCredential.mockResolvedValue(true) + }) + + it('deletes only OAuth credentials administered in the delegated workspace', async () => { + const allowed = oauthCredential('credential-1') + mocks.getActor + .mockResolvedValueOnce({ + credential: allowed, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + .mockResolvedValueOnce({ + credential: oauthCredential('credential-2', 'workspace-2'), + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }, + }) + + expect(result).toEqual({ + deleted: ['credential-1'], + failed: ['credential-2'], + deletedCredentials: [allowed], + }) + expect(mocks.deleteCredential).toHaveBeenCalledOnce() + expect(mocks.deleteCredential).toHaveBeenCalledWith({ + credential: allowed, + reason: 'copilot_delete', + }) + }) + + it('rejects duplicate IDs before loading any credential', async () => { + await expect( + deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-1'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.deleteCredential).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.ts b/apps/sim/lib/credentials/application/delete-many-credentials.ts new file mode 100644 index 00000000000..993f1795ef2 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.ts @@ -0,0 +1,114 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { CredentialRow } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('DeleteManyCredentialsApplication') +const MAX_CREDENTIAL_DELETE_BATCH = 20 + +export interface DeleteManyCredentialsInput { + workspaceId: string + credentialIds: string[] +} + +export interface DeleteManyCredentialsResult { + deleted: string[] + failed: string[] + deletedCredentials: CredentialRow[] +} + +export const deleteManyCredentialsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.deleteMany, + resolveContext: async ({ input }: { input: DeleteManyCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + async execute({ principal, input, context }): Promise { + if (input.credentialIds.length === 0) { + throw new OrchestrationError('validation', 'At least one credential ID is required') + } + if (input.credentialIds.length > MAX_CREDENTIAL_DELETE_BATCH) { + throw new OrchestrationError( + 'validation', + `At most ${MAX_CREDENTIAL_DELETE_BATCH} credentials can be deleted at once` + ) + } + if (new Set(input.credentialIds).size !== input.credentialIds.length) { + throw new OrchestrationError('validation', 'Credential IDs must be unique') + } + + const userId = requirePrincipalSubjectUserId(principal) + const deleted: string[] = [] + const failed: string[] = [] + const deletedCredentials: CredentialRow[] = [] + + for (const credentialId of input.credentialIds) { + try { + const access = await getCredentialActorContext(credentialId, userId) + if ( + !access.credential || + access.credential.workspaceId !== context.workspaceId || + !access.hasWorkspaceAccess || + !access.isAdmin || + access.credential.type !== 'oauth' + ) { + failed.push(credentialId) + continue + } + const didDelete = await deleteCredentialRecord({ + credential: access.credential, + reason: 'copilot_delete', + }) + if (!didDelete) { + failed.push(credentialId) + continue + } + deleted.push(credentialId) + deletedCredentials.push(access.credential) + } catch (error) { + logger.error('Failed to delete credential in Copilot batch', { credentialId, error }) + failed.push(credentialId) + } + } + + return { deleted, failed, deletedCredentials } + }, + projectAudit: ({ result }) => + result.deletedCredentials.map((credential) => ({ + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (copilot_delete)`, + metadata: { + reason: 'copilot_delete', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })), + afterSuccess: ({ principal, context, result }) => { + for (const credential of result.deletedCredentials) { + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + } + }, +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.test.ts b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts new file mode 100644 index 00000000000..d983cdf3d53 --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getActiveDraft: vi.fn(), + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + getActiveConnectDraft: mocks.getActiveDraft, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const draft = { + id: 'draft-1', + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: "User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('launchCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getActiveDraft.mockResolvedValue(draft) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + }) + + it('loads the exact draft for the signed-in user and reauthorizes its target', async () => { + const result = await launchCredentialConnection.execute({ + principal, + input: { draftId: 'draft-1' }, + }) + + expect(mocks.getActiveDraft).toHaveBeenCalledWith('draft-1', 'user-1') + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: { ...workspaceContext, draft }, + providerId: 'google-email', + credentialId: undefined, + }) + expect(result).toEqual({ draft }) + }) + + it('rejects an invalid or expired draft before loading a workspace', async () => { + mocks.getActiveDraft.mockResolvedValue(null) + + await expect( + launchCredentialConnection.execute({ principal, input: { draftId: 'draft-missing' } }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.ts b/apps/sim/lib/credentials/application/launch-credential-connection.ts new file mode 100644 index 00000000000..1a19cafecee --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.ts @@ -0,0 +1,52 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { type ConnectDraft, getActiveConnectDraft } from '@/lib/credentials/connect-draft' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export interface LaunchCredentialConnectionInput { + draftId: string +} + +interface LaunchCredentialConnectionContext extends ActiveWorkspaceApplicationContext { + draft: ConnectDraft +} + +export interface LaunchCredentialConnectionResult { + draft: ConnectDraft +} + +export const launchCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.launchConnection, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: LaunchCredentialConnectionInput + }): Promise => { + const draft = await getActiveConnectDraft(input.draftId, principal.userId) + if (!draft) + throw new OrchestrationError('not_found', 'OAuth connection link is invalid or expired') + const workspace = await loadActiveWorkspaceApplicationContext(draft.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { ...workspace, draft } + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: context.draft.credentialId ? undefined : context.draft.providerId, + credentialId: context.draft.credentialId ?? undefined, + }) + if (target.providerId !== context.draft.providerId) { + throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches') + } + return { draft: context.draft } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts new file mode 100644 index 00000000000..bc6bba937e3 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) + +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('listCredentialProviders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listCatalog.mockResolvedValue([]) + }) + + it('allows sessions to inspect deployment availability', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) + + it('allows workspace keys to inspect deployment availability', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) + + it('searches provider names case-insensitively without matching ids or descriptions', async () => { + const salesforce = { + name: 'Salesforce', + serviceId: 'salesforce', + description: 'Connect a CRM.', + } + const google = { + name: 'Google', + serviceId: 'salesforce-migration', + description: 'Migrate Salesforce records.', + } + mocks.listCatalog.mockResolvedValue([salesforce, google]) + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + const result = await listCredentialProviders.execute({ + principal, + input: { workspaceId: 'workspace-1', search: 'SaLeS' }, + }) + + expect(result.providers).toEqual([salesforce]) + }) + + it('fails fast on a blank search from a non-HTTP caller', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialProviders.execute({ + principal, + input: { workspaceId: 'workspace-1', search: ' ' }, + }) + ).rejects.toThrow('search cannot be empty') + expect(mocks.listCatalog).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts new file mode 100644 index 00000000000..ded16457d4d --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -0,0 +1,41 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, +} from '@/lib/credentials/application/provider-catalog' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ListCredentialProvidersInput { + workspaceId: string + search?: string +} + +export interface ListCredentialProvidersResult { + providers: CredentialProviderCatalogEntry[] +} + +export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listProviders, + resolveContext: async ({ input }: { input: ListCredentialProvidersInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const search = input.search?.trim().toLowerCase() + if (input.search !== undefined && !search) { + throw new OrchestrationError('validation', 'search cannot be empty') + } + + const providers = await listCredentialProviderCatalog(principal, context) + return { + providers: search + ? providers.filter((provider) => provider.name.toLowerCase().includes(search)) + : providers, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts index f0dc64e48e3..c22e024cd61 100644 --- a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts @@ -54,21 +54,21 @@ describe('listWorkspaceCredentials', () => { mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true, canAdmin: false }) - mocks.listVisible.mockResolvedValue([]) - mocks.listForWorkspacePrincipal.mockResolvedValue([]) + mocks.listVisible.mockResolvedValue({ data: [], nextCursorKeys: null }) + mocks.listForWorkspacePrincipal.mockResolvedValue({ data: [], nextCursorKeys: null }) }) - it('rejects unsupported principals before canonical workspace loading', async () => { + it('preserves per-credential visibility for sessions', async () => { const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1', } - await expect(listWorkspaceCredentials.execute({ principal, input })).rejects.toMatchObject({ - code: 'forbidden', - }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() + await listWorkspaceCredentials.execute({ principal, input }) + expect(mocks.listVisible).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', types: ['oauth', 'service_account'] }) + ) }) it('lists shared connections for a workspace key without creator identity', async () => { diff --git a/apps/sim/lib/credentials/application/oauth-accounts.test.ts b/apps/sim/lib/credentials/application/oauth-accounts.test.ts new file mode 100644 index 00000000000..bf77fae5662 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { account, credential } from '@sim/db/schema' +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' + +const firstCredential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, + displayName: 'First Google account', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +describe('OAuth account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits and captures committed deletions before rethrowing a later failure', async () => { + const secondCredential = { + ...firstCredential, + id: 'credential-2', + displayName: 'Second Google account', + accountId: 'account-2', + } + queueTableRows(account, [{ id: 'account-1' }, { id: 'account-2' }]) + queueTableRows(credential, [firstCredential, secondCredential]) + mocks.deleteCredential + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('Second credential delete failed')) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'google' }, + }) + ).rejects.toMatchObject({ + name: 'OAuthDisconnectPartialFailureError', + credentials: [firstCredential], + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'credential.deleted', + resourceId: firstCredential.id, + metadata: expect.objectContaining({ reason: 'oauth_disconnect' }), + }) + ) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'credential_deleted', + expect.objectContaining({ + provider_id: 'google-email', + workspace_id: 'workspace-1', + }), + { groups: { workspace: 'workspace-1' } } + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/oauth-accounts.ts b/apps/sim/lib/credentials/application/oauth-accounts.ts new file mode 100644 index 00000000000..ff144a47524 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.ts @@ -0,0 +1,132 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { credentialUserOperations } from '@/lib/credentials/application/operations' +import { + disconnectOAuthAccounts, + listConnectedAccountsForUser, + listOAuthConnectionsForUser, + OAuthDisconnectPartialFailureError, +} from '@/lib/credentials/oauth-accounts' +import { captureServerEvent } from '@/lib/posthog/server' + +export const listOAuthConnectionsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listOAuthConnections, + async execute({ principal }) { + return { connections: await listOAuthConnectionsForUser(principal.userId) } + }, +}) + +export interface ListConnectedAccountsInput { + provider?: string +} + +export const listConnectedAccountsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listConnectedAccounts, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: ListConnectedAccountsInput + }) { + return { + accounts: await listConnectedAccountsForUser({ + userId: principal.userId, + provider: input.provider, + }), + } + }, +}) + +export interface DisconnectOAuthInput { + provider: string + providerId?: string + accountId?: string +} + +function projectDeletedCredentialAudit( + credentials: OAuthDisconnectPartialFailureError['credentials'] +) { + return credentials.map((credential) => ({ + workspaceId: credential.workspaceId, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (oauth_disconnect)`, + metadata: { + reason: 'oauth_disconnect', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })) +} + +function captureDeletedCredentialEvents( + userId: string, + credentials: OAuthDisconnectPartialFailureError['credentials'], + provider: string, + providerId?: string +): void { + for (const credential of credentials) { + captureServerEvent( + userId, + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? providerId ?? provider, + workspace_id: credential.workspaceId, + }, + { groups: { workspace: credential.workspaceId } } + ) + } +} + +export const disconnectOAuthUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.disconnectOAuth, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: DisconnectOAuthInput + }) { + const result = await disconnectOAuthAccounts({ userId: principal.userId, ...input }) + return { ...result, ...input, success: true as const } + }, + projectAudit: ({ result }) => [ + ...projectDeletedCredentialAudit(result.credentials), + { + workspaceId: null, + action: AuditAction.OAUTH_DISCONNECTED, + resourceType: AuditResourceType.OAUTH, + resourceId: result.providerId ?? result.provider, + resourceName: result.provider, + description: `Disconnected OAuth provider: ${result.provider}`, + metadata: { provider: result.provider, providerId: result.providerId }, + }, + ], + projectErrorAudit: ({ error }) => + error instanceof OAuthDisconnectPartialFailureError + ? projectDeletedCredentialAudit(error.credentials) + : undefined, + afterSuccess: ({ principal, result }) => { + captureDeletedCredentialEvents( + principal.userId, + result.credentials, + result.provider, + result.providerId + ) + }, + afterError: ({ principal, input, error }) => { + if (!(error instanceof OAuthDisconnectPartialFailureError)) return + captureDeletedCredentialEvents( + principal.userId, + error.credentials, + input.provider, + input.providerId + ) + }, +}) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts new file mode 100644 index 00000000000..26c363580f9 --- /dev/null +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { + credentialOperations, + defineCredentialOperation, +} from '@/lib/credentials/application/operations' + +describe('credential operations', () => { + it('declares credential admin as the delete authority and workspace read as reach', () => { + expect(credentialOperations.delete).toMatchObject({ + id: 'credentials.delete', + minimumRole: 'read', + minimumCredentialRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + expect(Object.isFrozen(credentialOperations.delete)).toBe(true) + }) + + it('rejects actorless workspace keys for credential admin operations', () => { + const workspaceKeyOperation = defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['workspace_api_key'], + }) + + expect(() => defineCredentialOperation(workspaceKeyOperation, 'admin')).toThrow( + 'Credential operation credentials.test_admin requires a user-bearing principal' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 3f1fad25074..43d7d531b40 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,11 +1,146 @@ -import { defineWorkspaceOperation } from '@/lib/core/application' +import type { ApplicationOperation } from '@/lib/core/application' +import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' + +export type CredentialRole = 'member' | 'admin' + +export type CredentialOperation = O & { + readonly minimumCredentialRole: CredentialRole +} + +/** Adds credential-resource policy to a workspace-scoped operation. */ +export function defineCredentialOperation< + const O extends WorkspaceOperation, + const R extends CredentialRole, +>( + operation: O, + minimumCredentialRole: R +): CredentialOperation & { + readonly minimumCredentialRole: R +} { + if (operation.principalKinds.includes('workspace_api_key')) { + throw new Error(`Credential operation ${operation.id} requires a user-bearing principal`) + } + return Object.freeze({ ...operation, minimumCredentialRole }) +} + +const HUMAN_AND_COPILOT_PRINCIPALS = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const credentialOperations = { + listInternal: defineWorkspaceOperation({ + id: 'credentials.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listProviders: defineWorkspaceOperation({ + id: 'credentials.providers.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + createConnection: defineWorkspaceOperation({ + id: 'credentials.connections.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), + prepareConnection: defineWorkspaceOperation({ + id: 'credentials.connections.prepare', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + createServiceAccount: defineWorkspaceOperation({ + id: 'credentials.service_accounts.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), + read: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' + ), + create: defineWorkspaceOperation({ + id: 'credentials.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + update: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.update', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + delete: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.delete', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + deleteMany: defineWorkspaceOperation({ + id: 'credentials.delete_many', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + saveDraft: defineWorkspaceOperation({ + id: 'credentials.drafts.save', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listMembers: defineWorkspaceOperation({ + id: 'credentials.members.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + upsertMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.upsert', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' + ), + removeMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.remove', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' + ), + launchConnection: defineWorkspaceOperation({ + id: 'credentials.connections.launch', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], }), useManagedOAuth: defineWorkspaceOperation({ id: 'credentials.managed_oauth.use', @@ -15,3 +150,23 @@ export const credentialOperations = { delegatedServices: ['executor'], }), } as const + +export interface CredentialUserOperation + extends ApplicationOperation { + readonly principalKinds: readonly ['session'] +} + +function defineCredentialUserOperation( + id: Id +): CredentialUserOperation { + if (!id.trim()) throw new Error('Credential user operation ID must not be empty') + return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) +} + +export const credentialUserOperations = { + listMemberships: defineCredentialUserOperation('credentials.memberships.list'), + leaveMembership: defineCredentialUserOperation('credentials.memberships.leave'), + listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), + listConnectedAccounts: defineCredentialUserOperation('credentials.accounts.list'), + disconnectOAuth: defineCredentialUserOperation('credentials.oauth_connections.disconnect'), +} as const diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts new file mode 100644 index 00000000000..4d2e8f8ed27 --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} +const gmailProvider = { + type: 'oauth' as const, + serviceId: 'gmail', + name: 'Gmail', + description: 'Gmail OAuth', + providerFamily: 'google', + available: true, + supportsReconnect: true, + authorizationOptions: [{ providerId: 'google-email', label: 'Gmail' }], +} + +describe('prepareCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.listCatalog.mockResolvedValue([gmailProvider]) + }) + + it('resolves a provider inside delegated workspace policy', async () => { + const result = await prepareCredentialConnection.execute({ + principal, + input: { workspaceId: 'workspace-1', providerName: 'gmail' }, + }) + + expect(result).toEqual({ providerId: 'google-email', serviceName: 'Gmail' }) + }) + + it('uses the credential target as the reconnect authority', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'google-email', + credentialId: 'credential-1', + }) + + const result = await prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + + expect(result).toEqual({ + providerId: 'google-email', + serviceName: 'Gmail', + credentialId: 'credential-1', + }) + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: workspace, + credentialId: 'credential-1', + }) + }) + + it('rejects a reconnect whose requested provider does not match the credential', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'slack', + credentialId: 'credential-1', + }) + + await expect( + prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.ts new file mode 100644 index 00000000000..cedd08e89fd --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.ts @@ -0,0 +1,109 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + type OAuthCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface PrepareCredentialConnectionInput { + workspaceId: string + providerName: string + credentialId?: string +} + +export interface PrepareCredentialConnectionResult { + providerId: string + serviceName: string + credentialId?: string +} + +function resolveRequestedProvider( + providers: readonly OAuthCredentialProviderCatalogEntry[], + providerName: string +): OAuthCredentialProviderCatalogEntry { + const requested = providerName.toLowerCase().trim() + if (!requested) throw new OrchestrationError('validation', 'OAuth provider is required') + + const provider = + providers.find((entry) => + entry.authorizationOptions.some((option) => option.providerId.toLowerCase() === requested) + ) ?? + providers.find( + (entry) => + entry.serviceId.toLowerCase() === requested || entry.name.toLowerCase() === requested + ) ?? + providers.find( + (entry) => + entry.name.toLowerCase().includes(requested) || + requested.includes(entry.name.toLowerCase()) || + entry.authorizationOptions.some( + (option) => + option.providerId.toLowerCase().includes(requested) || + requested.includes(option.providerId.toLowerCase()) + ) + ) + + if (!provider) + throw new OrchestrationError('validation', `OAuth provider not found: ${providerName}`) + if (!provider.available) { + throw new OrchestrationError('conflict', `${provider.name} is not available in this workspace`) + } + return provider +} + +export const prepareCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.prepareConnection, + resolveContext: async ({ input }: { input: PrepareCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const providers = (await listCredentialProviderCatalog(principal, context)).filter( + (entry): entry is OAuthCredentialProviderCatalogEntry => entry.type === 'oauth' + ) + const requestedProvider = resolveRequestedProvider(providers, input.providerName) + const requestedProviderId = requestedProvider.authorizationOptions[0]?.providerId + if (!requestedProviderId) { + throw new Error(`OAuth provider ${requestedProvider.serviceId} has no authorization option`) + } + + if (!input.credentialId) { + return { + providerId: requestedProviderId, + serviceName: requestedProvider.name, + } + } + + const target = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: input.credentialId, + }) + if ( + !credentialProviderMatchesService(target.providerId, { + providerId: requestedProviderId, + additionalProviderIds: requestedProvider.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) + ) { + throw new OrchestrationError( + 'validation', + `Credential belongs to provider ${target.providerId}, not ${requestedProviderId}` + ) + } + + return { + providerId: target.providerId, + serviceName: requestedProvider.name, + credentialId: target.credentialId, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts new file mode 100644 index 00000000000..b4fbe43c3c5 --- /dev/null +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -0,0 +1,59 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import { + type CredentialActorContext, + requireOrdinaryCredentialType, +} from '@/lib/credentials/access' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' + +type PublicCredentialSource = + | VisibleWorkspaceCredential + | (CredentialRow & { hasServiceAccountKey: boolean; role: 'admin' | 'member' }) + +/** Serializes connection metadata field by field so encrypted columns can never reach the wire. */ +export function toV2Credential(row: PublicCredentialSource): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Serializes credential metadata for the internal workspace surface. */ +export function toWorkspaceCredential( + row: CredentialRow | VisibleWorkspaceCredential, + access?: CredentialActorContext +): WorkspaceCredential { + const type = requireOrdinaryCredentialType(row.type) + const role = access?.isAdmin + ? 'admin' + : (access?.member?.role ?? ('role' in row ? row.role : undefined)) + const status = access?.member?.status ?? (access?.isAdmin ? 'active' : undefined) + return { + id: row.id, + workspaceId: row.workspaceId, + type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + envOwnerUserId: row.envOwnerUserId, + createdBy: row.createdBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(role ? { role } : {}), + ...(status ? { status } : {}), + } +} diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts new file mode 100644 index 00000000000..f485a80465d --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -0,0 +1,237 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBlockVisibility: vi.fn(), + getAllowedIntegrationsFromEnv: vi.fn(), + getUserPermissionConfig: vi.fn(), + createVisibility: vi.fn(), + getAllOAuthServices: vi.fn(), + getServiceConfigByServiceId: vi.fn(), +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mocks.getBlockVisibility, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/permission-groups/integration-allowlist', () => ({ + intersectIntegrationAllowlists: ( + permissionGroup: readonly string[] | null, + deployment: readonly string[] | null + ) => { + if (!permissionGroup) return deployment + if (!deployment) return permissionGroup + return permissionGroup.filter((type) => deployment.includes(type)) + }, +})) + +vi.mock('@/lib/integrations/credential-visibility.server', () => ({ + createIntegrationCredentialVisibility: mocks.createVisibility, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: mocks.getAllOAuthServices, + getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, +})) + +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, + type ServiceAccountCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' + +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', +} +const services = [ + { + serviceId: 'salesforce', + providerId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + name: 'Salesforce', + description: 'Connect Salesforce.', + baseProvider: 'salesforce', + authType: 'oauth' as const, + }, + { + serviceId: 'trello', + providerId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + baseProvider: 'trello', + authType: 'oauth' as const, + }, + { + serviceId: 'claude-platform', + providerId: 'claude-platform-service-account', + serviceAccountProviderId: 'claude-platform-service-account', + name: 'Claude Platform', + description: 'Run Claude Platform Managed Agents from your workflows.', + baseProvider: 'claude-platform', + authType: 'service_account' as const, + }, +] + +describe('listCredentialProviderCatalog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAllOAuthServices.mockReturnValue(services) + mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce']) + mocks.getUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['salesforce', 'trello'], + }) + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + }) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + isCredentialVisible: ({ providerId }: { providerId: string }) => + providerId === 'claude-platform-service-account', + }) + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { + providerIdLabels: { + salesforce: 'Production', + 'salesforce-sandbox': 'Sandbox', + }, + } + } + if (serviceId === 'trello') return {} + return null + }) + }) + + it('projects OAuth services, authorization options, and reconnect capability', async () => { + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + { + type: 'oauth', + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'oauth', + serviceId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + providerFamily: 'trello', + available: false, + supportsReconnect: true, + authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], + }, + { + type: 'service_account', + serviceId: 'claude-platform-service-account', + providerId: 'claude-platform-service-account', + name: 'Claude Platform API key', + description: 'Connect Claude Platform with a API key.', + providerFamily: 'claude-platform', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/managed-agent', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'apiToken', + label: 'API key', + placeholder: 'sk-ant-...', + required: true, + secret: true, + multiline: false, + hint: 'Claude Platform API keys usually start with sk-ant-.', + }, + ], + }, + ]) + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('does not borrow a human permission group for workspace API keys', async () => { + await listCredentialProviderCatalog( + { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + context + ) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('fails fast when a multi-server provider lacks complete labels', async () => { + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { providerIdLabels: { salesforce: 'Production' } } + } + if (serviceId === 'trello') return {} + return null + }) + + await expect(listCredentialProviderCatalog(personalPrincipal, context)).rejects.toThrow( + 'OAuth provider salesforce-sandbox is missing its authorization option label' + ) + }) +}) + +describe('requireAvailableServiceAccountCredentialProvider', () => { + const provider: ServiceAccountCredentialProviderCatalogEntry = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [], + } + + it('returns an available service-account provider', () => { + expect(requireAvailableServiceAccountCredentialProvider([provider], provider.providerId)).toBe( + provider + ) + }) + + it('rejects a service-account provider hidden by workspace policy', () => { + expect(() => + requireAvailableServiceAccountCredentialProvider( + [{ ...provider, available: false }], + provider.providerId + ) + ).toThrow('Service-account provider is unavailable: zoom-service-account') + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts new file mode 100644 index 00000000000..7dcdc507dbf --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -0,0 +1,356 @@ +import type { Principal } from '@sim/auth/principal' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, + type ClientCredentialAccountField, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, + type TokenServiceAccountField, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + type OAuthServiceMetadata, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' +import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +export interface CredentialProviderAuthorizationOption { + providerId: string + label: string +} + +export interface CredentialProviderFieldOption { + value: string + label: string +} + +export interface CredentialProviderField { + id: string + label: string + placeholder: string + required: boolean + secret: boolean + multiline: boolean + requiredForAuthMethods?: string[] + options?: CredentialProviderFieldOption[] + hint?: string +} + +interface CredentialProviderCatalogBase { + type: 'oauth' | 'service_account' + serviceId: string + name: string + description: string + providerFamily: string + available: boolean +} + +export interface OAuthCredentialProviderCatalogEntry extends CredentialProviderCatalogBase { + type: 'oauth' + supportsReconnect: boolean + authorizationOptions: CredentialProviderAuthorizationOption[] +} + +export interface ServiceAccountCredentialProviderCatalogEntry + extends CredentialProviderCatalogBase { + type: 'service_account' + providerId: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId: boolean + fields: CredentialProviderField[] +} + +export type CredentialProviderCatalogEntry = + | OAuthCredentialProviderCatalogEntry + | ServiceAccountCredentialProviderCatalogEntry + +interface CredentialProviderCatalogContext { + workspaceId: string + workspaceOrganizationId: string | null +} + +interface ServiceAccountDescriptor { + name: string + description: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId?: boolean + fields: CredentialProviderField[] +} + +const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' +const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = + 'https://docs.sim.ai/integrations/atlassian-service-account' + +function providerField( + field: TokenServiceAccountField | ClientCredentialAccountField +): CredentialProviderField { + return { + id: field.id, + label: field.label, + placeholder: field.placeholder, + required: !('optional' in field && field.optional), + secret: field.secret, + multiline: 'multiline' in field && field.multiline === true, + ...('requiredForAuthMethods' in field && field.requiredForAuthMethods + ? { requiredForAuthMethods: [...field.requiredForAuthMethods] } + : {}), + ...('options' in field && field.options ? { options: [...field.options] } : {}), + ...('hint' in field && field.hint + ? { hint: field.hint } + : 'hintMessage' in field && field.hintMessage + ? { hint: field.hintMessage } + : {}), + } +} + +function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor { + if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Google service account', + description: 'Connect Google APIs with a service-account JSON key.', + docsUrl: GOOGLE_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'serviceAccountJson', + label: 'JSON key', + placeholder: 'Paste the service-account JSON key', + required: true, + secret: true, + multiline: true, + }, + ], + } + } + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Atlassian service account', + description: 'Connect Jira and Confluence with an Atlassian API token.', + docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste the API token', + required: true, + secret: true, + multiline: false, + }, + { + id: 'domain', + label: 'Site domain', + placeholder: 'your-team.atlassian.net', + required: true, + secret: false, + multiline: false, + }, + ], + } + } + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + return { + name: 'Slack custom bot', + description: 'Connect a reusable Slack app with its signing secret and bot token.', + docsUrl: 'https://docs.sim.ai/integrations/slack', + requiresClientGeneratedCredentialId: true, + fields: [ + { + id: 'signingSecret', + label: 'Signing secret', + placeholder: 'Paste the signing secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'botToken', + label: 'Bot token', + placeholder: 'xoxb-...', + required: true, + secret: true, + multiline: false, + }, + ], + } + } + + const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) + ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS + ] + : undefined + if (tokenDescriptor) { + return { + name: `${tokenDescriptor.serviceLabel} ${tokenDescriptor.connectNoun}`, + description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`, + docsUrl: tokenDescriptor.docsUrl, + helpText: tokenDescriptor.helpText, + fields: tokenDescriptor.fields.map(providerField), + } + } + + const clientDescriptor = Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, providerId) + ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS + ] + : undefined + if (clientDescriptor) { + return { + name: `${clientDescriptor.serviceLabel} ${clientDescriptor.connectNoun}`, + description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`, + docsUrl: clientDescriptor.docsUrl, + helpText: clientDescriptor.helpText, + fields: clientDescriptor.fields.map(providerField), + } + } + + throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`) +} + +function principalUserId(principal: Principal): string | undefined { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + if (principal.kind === 'delegated') return principal.subjectUserId + return undefined +} + +async function allowedIntegrationTypes( + principal: Principal, + workspaceId: string +): Promise | null> { + const userId = principalUserId(principal) + const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const integrations = intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null +} + +export async function listCredentialProviderCatalog( + principal: Principal, + context: CredentialProviderCatalogContext +): Promise { + const userId = principalUserId(principal) + const [allowedIntegrations, blockVisibility] = await Promise.all([ + allowedIntegrationTypes(principal, context.workspaceId), + getBlockVisibility({ + ...(userId ? { userId } : {}), + ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), + }), + ]) + const services = getAllOAuthServices() + const oauthServices = services.filter((service) => service.authType === 'oauth') + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: allowedIntegrations, + blockVisibility, + oauthServices: services, + }) + + const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.map((service) => { + const config = getServiceConfigByServiceId(service.serviceId) + if (!config) { + throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) + } + const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])] + if (providerIds.length > 1 && !config.providerIdLabels) { + throw new Error(`OAuth service ${service.serviceId} is missing provider option labels`) + } + const authorizationOptions = providerIds.map((providerId) => { + const label = providerIds.length === 1 ? service.name : config.providerIdLabels?.[providerId] + if (!label) { + throw new Error(`OAuth provider ${providerId} is missing its authorization option label`) + } + return { providerId, label } + }) + + return { + type: 'oauth', + serviceId: service.serviceId, + name: service.name, + description: service.description, + providerFamily: service.baseProvider, + available: visibility.isOAuthServiceVisible(service), + supportsReconnect: true, + authorizationOptions, + } + }) + + const serviceAccountOwners = new Map() + for (const service of services) { + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId && !serviceAccountOwners.has(serviceAccountProviderId)) { + serviceAccountOwners.set(serviceAccountProviderId, service) + } + } + + const serviceAccountEntries: ServiceAccountCredentialProviderCatalogEntry[] = [ + ...serviceAccountOwners, + ].map(([providerId, owner]) => { + const descriptor = getServiceAccountDescriptor(providerId) + return { + type: 'service_account', + serviceId: providerId, + providerId, + name: descriptor.name, + description: descriptor.description, + providerFamily: owner.baseProvider, + available: visibility.isCredentialVisible({ providerId, type: 'service_account' }), + docsUrl: descriptor.docsUrl, + ...(descriptor.helpText ? { helpText: descriptor.helpText } : {}), + requiresClientGeneratedCredentialId: descriptor.requiresClientGeneratedCredentialId === true, + fields: descriptor.fields, + } + }) + + return [...oauthEntries, ...serviceAccountEntries] +} + +export function requireAvailableOAuthCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): OAuthCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError('conflict', `OAuth provider is unavailable: ${providerId}`) + } + return provider +} + +export function requireAvailableServiceAccountCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): ServiceAccountCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is ServiceAccountCredentialProviderCatalogEntry => + entry.type === 'service_account' && entry.providerId === providerId + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown service-account provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `Service-account provider is unavailable: ${providerId}` + ) + } + return provider +} diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts new file mode 100644 index 00000000000..5eb547fb6b9 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + createDraft: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} + +describe('saveCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.createDraft.mockResolvedValue({ id: 'draft-1' }) + }) + + it('authorizes workspace access before resolving reconnect credential access', async () => { + await saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + expect(mocks.createDraft).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + credentialId: 'credential-1', + displayNameDefinesIntent: false, + }) + ) + }) + + it('rejects a reconnect outside the asserted workspace', async () => { + mocks.getActor.mockResolvedValue({ + credential: { ...credential, workspaceId: 'workspace-2' }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) + + it('rejects managed OAuth credentials as reconnect targets', async () => { + mocks.getActor.mockResolvedValue({ + credential: { ...credential, type: 'managed_oauth' }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Managed Gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts new file mode 100644 index 00000000000..d737d81ebf6 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -0,0 +1,67 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface SaveCredentialDraftInput { + workspaceId: string + providerId: string + displayName: string + description?: string + credentialId?: string +} + +interface SaveCredentialDraftContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string + credentialAccess?: CredentialActorContext +} + +export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.saveDraft, + async resolveContext({ + input, + }: { + input: SaveCredentialDraftInput + }): Promise { + const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace + }, + authorizationOptions: {}, + async authorizeResource({ principal, input, context }) { + if (!input.credentialId) return + context.credentialAccess = await getCredentialActorContext( + input.credentialId, + requirePrincipalSubjectUserId(principal) + ) + if ( + !context.credentialAccess?.credential || + context.credentialAccess.credential.type === 'managed_oauth' || + context.credentialAccess.credential.workspaceId !== context.workspaceId || + !context.credentialAccess.isAdmin + ) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access required on the target credential' + ) + } + }, + async execute({ principal, input }) { + await createConnectDraft({ + userId: requirePrincipalSubjectUserId(principal), + workspaceId: input.workspaceId, + providerId: input.providerId, + displayName: input.displayName, + description: input.description, + credentialId: input.credentialId, + displayNameDefinesIntent: input.credentialId === undefined, + }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts new file mode 100644 index 00000000000..6dc2ece8096 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -0,0 +1,326 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + create: vi.fn(), + listCatalog: vi.fn(), + requireProvider: vi.fn(), + getCredential: vi.fn(), + getActor: vi.fn(), + delete: vi.fn(), + deleteRecord: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + createServiceAccountCredential: mocks.create, + deleteConnectionCredential: mocks.delete, + deleteCredentialRecord: mocks.deleteRecord, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableServiceAccountCredentialProvider: mocks.requireProvider, +})) +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getCredential, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + createServiceAccountCredentialUseCase, + deleteCredentialUseCase, +} from '@/lib/credentials/application/service-account' + +const WORKSPACE_ID = 'workspace-1' +const workspace = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted', + createdBy: 'user-1', + createdAt: new Date('2026-08-12T20:00:00.000Z'), + updatedAt: new Date('2026-08-12T20:00:00.000Z'), +} + +describe('credential service-account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getCredential.mockResolvedValue(credential) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.create.mockResolvedValue({ + success: true, + credential, + created: true, + auditMetadata: { tenantId: 'tenant-1' }, + }) + mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.delete.mockResolvedValue(true) + mocks.deleteRecord.mockResolvedValue(true) + mocks.requireProvider.mockReturnValue({ + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + }) + }) + + it('rejects workspace keys before canonical loading on create', async () => { + await expect( + createServiceAccountCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('creates through the verified service-account primitive', async () => { + const result = await createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + + expect(result).toMatchObject({ + credential, + created: true, + hasServiceAccountKey: true, + role: 'admin', + }) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + providerId: 'zoom-service-account', + }) + ) + }) + + it('rejects service-account providers hidden by workspace policy', async () => { + mocks.requireProvider.mockImplementation(() => { + throw new OrchestrationError( + 'conflict', + 'Service-account provider is unavailable: zoom-service-account' + ) + }) + + await expect( + createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires credential admin access before disconnecting', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('rejects workspace keys before canonical loading on disconnect', async () => { + await expect( + deleteCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('allows an explicit credential admin with workspace read access to disconnect', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).resolves.toEqual({ credential, deleted: true }) + expect(mocks.delete).toHaveBeenCalledOnce() + }) + + it('applies credential admin policy during authorization-only checks', async () => { + await deleteCredentialUseCase.authorize?.({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('enforces personal-key workspace policy before credential authorization', async () => { + mocks.loadWorkspace.mockResolvedValue({ ...workspace, allowPersonalApiKeys: false }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'PERSONAL_API_KEYS_DISABLED', + }) + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('disconnects an administered credential', async () => { + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: true }) + expect(mocks.delete).toHaveBeenCalledWith({ + credentialId: credential.id, + workspaceId: WORKSPACE_ID, + reason: 'user_delete', + }) + }) + + it('treats a concurrent disconnect as an idempotent success', async () => { + mocks.delete.mockResolvedValue(false) + + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: false }) + }) + + it.each([ + ['env_personal', 'personal'], + ['env_workspace', 'workspace'], + ] as const)('preserves %s deletion audit and analytics dimensions', async (type, label) => { + const envCredential = { + ...credential, + type, + displayName: 'MY_API_KEY', + providerId: null, + envKey: 'MY_API_KEY', + envOwnerUserId: type === 'env_personal' ? 'user-1' : null, + encryptedServiceAccountKey: null, + } + mocks.getCredential.mockResolvedValue(envCredential) + mocks.getActor.mockResolvedValue({ + credential: envCredential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await deleteCredentialUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, credentialId: envCredential.id }, + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + description: `Deleted ${label} env credential "MY_API_KEY"`, + metadata: expect.objectContaining({ + credentialType: type, + envKey: 'MY_API_KEY', + }), + }) + ) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'credential_deleted', + expect.objectContaining({ provider_id: 'MY_API_KEY' }), + expect.anything() + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts new file mode 100644 index 00000000000..078cb50f04d --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -0,0 +1,210 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, + deleteConnectionCredential, + deleteCredentialRecord, +} from '@/lib/credentials/orchestration' +import type { CredentialRow } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateServiceAccountInput = Omit< + CreateServiceAccountCredentialParams, + 'userId' | 'request' +> + +export interface CreateServiceAccountResult { + credential: CredentialRow + created: boolean + hasServiceAccountKey: boolean + role: 'admin' | 'member' + auditMetadata: Record +} + +class CredentialProviderUnavailableError extends HttpError { + readonly statusCode = 503 + + constructor() { + super('Credential provider is temporarily unavailable') + this.name = 'CredentialProviderUnavailableError' + } +} + +export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createServiceAccount, + resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context, request }): Promise { + const catalog = await listCredentialProviderCatalog(principal, context) + requireAvailableServiceAccountCredentialProvider(catalog, input.providerId) + const result = await createServiceAccountCredential({ + ...input, + workspaceId: context.workspaceId, + userId: requirePrincipalSubjectUserId(principal), + request, + }) + if (!result.success) { + if (result.providerUnavailable) throw new CredentialProviderUnavailableError() + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential create failed') + case 'forbidden': + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + result.error ?? 'Write permission required' + ) + default: + throw new Error('Failed to create service-account credential') + } + } + if (!result.credential) { + throw new Error('Credential creation succeeded without a credential') + } + const actor = await getCredentialActorContext( + result.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if (!actor.credential || (!actor.member && !actor.isAdmin)) { + throw new Error('Created credential is not visible to its creator') + } + return { + credential: result.credential, + created: result.created === true, + hasServiceAccountKey: Boolean(result.credential.encryptedServiceAccountKey), + role: actor.isAdmin ? 'admin' : 'member', + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created service_account credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_connected', + { + credential_type: 'service_account', + provider_id: result.credential.providerId ?? 'service_account', + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +export interface DeleteCredentialInput { + workspaceId?: string + credentialId: string +} + +export interface DeleteCredentialResult { + credential: CredentialRow + deleted: boolean +} + +export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.delete, + resolveContext: ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialApplicationContext({ + credentialId: input.credentialId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, context }): Promise { + const allowedTypes = + principal.kind === 'session' + ? ['oauth', 'env_workspace', 'env_personal', 'service_account'] + : principal.kind === 'delegated' + ? ['oauth'] + : ['oauth', 'service_account'] + if (!allowedTypes.includes(context.credential.type)) { + throw new OrchestrationError( + 'validation', + `Only ${allowedTypes.join(', ')} credentials can be managed by this caller` + ) + } + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const deleted = + context.credential.type === 'oauth' || context.credential.type === 'service_account' + ? await deleteConnectionCredential({ + credentialId: context.credential.id, + workspaceId: context.workspaceId, + reason, + }) + : await deleteCredentialRecord({ credential: context.credential, reason }) + return { credential: context.credential, deleted } + }, + projectAudit: ({ principal, result }) => { + if (!result.deleted) return [] + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const description = + result.credential.type === 'env_personal' + ? `Deleted personal env credential "${result.credential.envKey}"` + : result.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${result.credential.envKey}"` + : `Deleted ${result.credential.type} credential "${result.credential.displayName}" (${reason})` + return { + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description, + metadata: { + reason, + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + envKey: result.credential.envKey, + }, + } + }, + afterSuccess: ({ principal, context, result }) => { + if (!result.deleted) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_deleted', + { + credential_type: result.credential.type, + provider_id: + result.credential.providerId ?? result.credential.envKey ?? result.credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + }, +}) diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts new file mode 100644 index 00000000000..539a7cac8c5 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateId } = vi.hoisted(() => ({ + mockGenerateId: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { createConnectDraft } from '@/lib/credentials/connect-draft' + +describe('createConnectDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGenerateId.mockReturnValue('new-draft-id') + }) + + it('refreshes the expiry without changing an active connection intent', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + const result = await createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + displayNameDefinesIntent: true, + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ id: 'new-draft-id' }) + ) + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as + | { set?: Record; setWhere?: unknown } + | undefined + expect(conflict?.set).not.toHaveProperty('id') + expect(conflict?.set).not.toHaveProperty('displayName') + expect(conflict?.set).not.toHaveProperty('credentialId') + expect(conflict?.setWhere).toBeDefined() + expect(result).toEqual({ id: 'active-draft-id', expiresAt }) + }) + + it('refreshes a reconnect target when its mutable display name changes', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }) + ).resolves.toEqual({ id: 'active-draft-id', expiresAt }) + + expect(drizzleOrmMock.eq).not.toHaveBeenCalledWith( + schemaMock.pendingCredentialDraft.displayName, + 'Renamed Gmail' + ) + }) + + it('fails fast when an active draft has a different connection intent', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'A different OAuth connection flow is already active for this provider', + }) + }) +}) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 2e72f796526..bda705b4744 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,12 +2,20 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' +import { and, eq, gt, isNull, lt } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') -const DRAFT_TTL_MS = 15 * 60 * 1000 + +export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect + +export interface CreatedConnectDraft { + id: string + expiresAt: Date +} /** * Creates the pending credential draft at OAuth click time so custom and @@ -21,7 +29,10 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string -}): Promise { + description?: string + /** Whether an explicitly requested name distinguishes this new-connection intent. */ + displayNameDefinesIntent?: boolean +}): Promise { const { userId, workspaceId, providerId, credentialId } = params let displayName = params.displayName @@ -32,61 +43,48 @@ export async function createConnectDraft(params: { const service = getAllOAuthServices().find((s) => credentialProviderMatchesService(providerId, s) ) - const serviceName = service?.name ?? providerId + if (!service) throw new Error(`Cannot create OAuth draft for unknown provider ${providerId}`) + const serviceName = service.name - let userName: string | null = null - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - userName = row?.name ?? null - } catch (error) { - // Cosmetic only — fall back to the "My {Service}" default - logger.warn('User name lookup failed for connect draft display name', { - userId, - workspaceId, - providerId, - error, - }) - } + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (!row) throw new Error(`Cannot create OAuth draft for missing user ${userId}`) + const userName = row.name // Auto-number against existing workspace credentials so repeat connects for // the same provider stay distinguishable — same behavior as the connect - // modal, which computes this client-side. Best effort: on failure the name - // simply skips deduplication. - let takenNames: ReadonlySet = new Set() - try { - const rows = await db - .select({ displayName: credential.displayName }) - .from(credential) - .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) - takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) - } catch (error) { - // Cosmetic only — proceed without collision numbering - logger.warn('Credential name lookup failed for connect draft deduplication', { - userId, - workspaceId, - providerId, - error, - }) - } + // modal, which computes this client-side. + const rows = await db + .select({ displayName: credential.displayName }) + .from(credential) + .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + const takenNames = new Set(rows.map((credentialRow) => credentialRow.displayName.toLowerCase())) displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) } const now = new Date() - const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + const expiresAt = new Date(now.getTime() + CREDENTIAL_DRAFT_TTL_MS) await db .delete(pendingCredentialDraft) .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) - await db + const id = generateId() + const sameTarget = credentialId + ? eq(pendingCredentialDraft.credentialId, credentialId) + : isNull(pendingCredentialDraft.credentialId) + const sameIntent = params.displayNameDefinesIntent + ? and(sameTarget, eq(pendingCredentialDraft.displayName, displayName)) + : sameTarget + const [draft] = await db .insert(pendingCredentialDraft) .values({ - id: generateId(), + id, userId, workspaceId, providerId, displayName, + description: params.description?.trim() || null, credentialId: credentialId ?? null, expiresAt, createdAt: now, @@ -97,11 +95,17 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - // credentialId must be written on BOTH paths: a plain connect that reuses a - // stale reconnect draft row would otherwise silently rebind the old - // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { expiresAt, createdAt: now }, + setWhere: sameIntent, }) + .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) + + if (!draft) { + throw new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + } logger.info('Created OAuth connect credential draft', { userId, @@ -109,4 +113,23 @@ export async function createConnectDraft(params: { providerId, credentialId: credentialId ?? null, }) + return draft +} + +export async function getActiveConnectDraft( + draftId: string, + userId: string +): Promise { + const [draft] = await db + .select() + .from(pendingCredentialDraft) + .where( + and( + eq(pendingCredentialDraft.id, draftId), + eq(pendingCredentialDraft.userId, userId), + gt(pendingCredentialDraft.expiresAt, new Date()) + ) + ) + .limit(1) + return draft ?? null } diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 618e51b0d1a..f16902ddf04 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -23,6 +23,12 @@ interface DeleteCredentialParams { request?: NextRequest } +export interface DeleteConnectionCredentialParams { + credentialId: string + workspaceId: string + reason: CredentialDeleteReason +} + /** * Clears all stored references to the credential, deletes the row, and * records an audit entry. Idempotent when the row no longer exists. @@ -71,6 +77,30 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason }) } +/** Clears references and deletes one connection without surface audit attribution. */ +export async function deleteConnectionCredential( + params: DeleteConnectionCredentialParams +): Promise { + const { credentialId, workspaceId } = params + await clearCredentialRefs(credentialId, workspaceId) + const deleted = await db + .delete(schema.credential) + .where( + and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) + ) + .returning({ id: schema.credential.id }) + if (deleted.length > 1) throw new Error('Credential deletion affected multiple rows') + + if (deleted.length === 1) { + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) + } + return deleted.length === 1 +} + /** * Clears stored references to a credential across mutable workspace state * (editor blocks, copilot checkpoints, knowledge connectors) and frozen diff --git a/apps/sim/lib/credentials/draft-constants.ts b/apps/sim/lib/credentials/draft-constants.ts new file mode 100644 index 00000000000..ff7525a35f9 --- /dev/null +++ b/apps/sim/lib/credentials/draft-constants.ts @@ -0,0 +1,2 @@ +export const CREDENTIAL_DRAFT_TTL_MS = 15 * 60 * 1000 +export const CREDENTIAL_DRAFT_TTL_SECONDS = CREDENTIAL_DRAFT_TTL_MS / 1000 diff --git a/apps/sim/lib/credentials/draft-hooks.test.ts b/apps/sim/lib/credentials/draft-hooks.test.ts new file mode 100644 index 00000000000..bf2e88a15a9 --- /dev/null +++ b/apps/sim/lib/credentials/draft-hooks.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + clearDeadFlag: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/oauth/terminal-errors', () => ({ clearDeadFlag: mocks.clearDeadFlag })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { handleReconnectCredential } from '@/lib/credentials/draft-hooks' + +describe('handleReconnectCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits a reconnect with the credential current name instead of draft presentation', async () => { + queueTableRows(schemaMock.credential, [ + { id: 'credential-1', accountId: null, displayName: 'Renamed Gmail' }, + ]) + queueTableRows(schemaMock.credential, []) + + await handleReconnectCredential({ + draft: { credentialId: 'credential-1' }, + newAccountId: 'account-new', + workspaceId: 'workspace-1', + userId: 'user-1', + now: new Date('2026-08-14T18:00:00.000Z'), + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'credential-1', + resourceName: 'Renamed Gmail', + description: 'Reconnected OAuth credential "Renamed Gmail" to a new account', + }) + ) + }) +}) diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index e467f56609e..704a22c25fb 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -105,7 +105,7 @@ export async function handleCreateCredentialFromDraft(params: { * the dead flag. Callers treat that timestamp as proof the reconnect landed. */ export async function handleReconnectCredential(params: { - draft: { credentialId: string | null; workspaceId: string; displayName: string } + draft: { credentialId: string | null } newAccountId: string workspaceId: string userId: string @@ -115,19 +115,21 @@ export async function handleReconnectCredential(params: { if (!draft.credentialId) return const [existingCredential] = await db - .select({ id: schema.credential.id, accountId: schema.credential.accountId }) + .select({ + id: schema.credential.id, + accountId: schema.credential.accountId, + displayName: schema.credential.displayName, + }) .from(schema.credential) .where(eq(schema.credential.id, draft.credentialId)) .limit(1) if (!existingCredential) { - logger.warn('Credential not found for reconnect, skipping', { - credentialId: draft.credentialId, - }) - return + throw new Error(`Cannot reconnect missing credential ${draft.credentialId}`) } const oldAccountId = existingCredential.accountId + const displayName = existingCredential.displayName const accountChanged = oldAccountId !== newAccountId if (accountChanged) { @@ -144,12 +146,9 @@ export async function handleReconnectCredential(params: { .limit(1) if (conflicting) { - logger.warn('New account already used by another credential, skipping reconnect', { - credentialId: draft.credentialId, - newAccountId, - conflictingCredentialId: conflicting.id, - }) - return + throw new Error( + `Cannot reconnect credential ${draft.credentialId}: account ${newAccountId} is already used by credential ${conflicting.id}` + ) } } @@ -177,10 +176,10 @@ export async function handleReconnectCredential(params: { action: AuditAction.CREDENTIAL_RECONNECTED, resourceType: AuditResourceType.CREDENTIAL, resourceId: draft.credentialId, - resourceName: draft.displayName, + resourceName: displayName, description: accountChanged - ? `Reconnected OAuth credential "${draft.displayName}" to a new account` - : `Reconnected OAuth credential "${draft.displayName}"`, + ? `Reconnected OAuth credential "${displayName}" to a new account` + : `Reconnected OAuth credential "${displayName}"`, metadata: { oldAccountId, newAccountId }, }) diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts new file mode 100644 index 00000000000..2cf5c13014d --- /dev/null +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + drizzleOrmMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHandleCreateCredentialFromDraft, mockHandleReconnectCredential } = vi.hoisted(() => ({ + mockHandleCreateCredentialFromDraft: vi.fn(), + mockHandleReconnectCredential: vi.fn(), +})) + +vi.mock('@/lib/credentials/draft-hooks', () => ({ + handleCreateCredentialFromDraft: mockHandleCreateCredentialFromDraft, + handleReconnectCredential: mockHandleReconnectCredential, +})) + +import { + loadOAuthCredentialDraftBinding, + parseCredentialDraftIdFromCallbackUrl, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' + +function credentialDraft(id: string, workspaceId: string) { + return { + id, + userId: 'user-1', + workspaceId, + providerId: 'google-email', + displayName: 'Work Gmail', + description: null, + credentialId: null, + expiresAt: new Date('2026-08-14T18:15:00.000Z'), + createdAt: new Date('2026-08-14T18:00:00.000Z'), + } +} + +describe('processCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('processes only the exact draft bound to the OAuth state', async () => { + const draft = credentialDraft('draft-2', 'workspace-2') + queueTableRows(schemaMock.pendingCredentialDraft, [draft]) + + await processCredentialDraft({ + draftId: 'draft-2', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft.id, 'draft-2') + expect(mockHandleCreateCredentialFromDraft).toHaveBeenCalledWith({ + draft, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now: expect.any(Date), + }) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft) + }) + + it('fails closed when a legacy callback has multiple active drafts', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, [ + credentialDraft('draft-1', 'workspace-1'), + credentialDraft('draft-2', 'workspace-2'), + ]) + + await expect( + processCredentialDraft({ + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process an ambiguous OAuth credential draft for user user-1 and provider google-email' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + }) + + it('fails when an exact draft is missing or expired', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, []) + + await expect( + processCredentialDraft({ + draftId: 'draft-missing', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process missing or expired OAuth credential draft draft-missing for user user-1' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) +}) + +describe('parseCredentialDraftIdFromCallbackUrl', () => { + it('extracts the exact draft id from a valid callback URL', () => { + expect( + parseCredentialDraftIdFromCallbackUrl( + 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-1' + ) + ).toBe('draft-1') + }) + + it('fails closed for malformed or non-string callback state', () => { + expect(() => parseCredentialDraftIdFromCallbackUrl({})).toThrow( + 'OAuth state callback URL must be a string' + ) + expect(() => parseCredentialDraftIdFromCallbackUrl('not a URL')).toThrow() + }) +}) + +describe('loadOAuthCredentialDraftBinding', () => { + it('returns the exact draft id when OAuth state is readable', async () => { + await expect( + loadOAuthCredentialDraftBinding(async () => ({ + callbackURL: 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-exact', + })) + ).resolves.toEqual({ status: 'available', draftId: 'draft-exact' }) + }) + + it('marks unreadable OAuth state unavailable instead of permitting legacy draft fallback', async () => { + const stateError = new Error('OAuth state is unavailable') + + await expect( + loadOAuthCredentialDraftBinding(async () => { + throw stateError + }) + ).resolves.toEqual({ status: 'unavailable', error: stateError }) + }) + + it('marks malformed callback state unavailable without throwing from the account hook', async () => { + const binding = await loadOAuthCredentialDraftBinding(async () => ({ callbackURL: null })) + + expect(binding.status).toBe('unavailable') + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index b7b9f5cd931..0b60ccac106 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import { handleCreateCredentialFromDraft, handleReconnectCredential, @@ -9,33 +9,89 @@ import { const logger = createLogger('CredentialDraftProcessor') +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' + +interface OAuthStateWithCallbackUrl { + callbackURL?: unknown +} + +type OAuthCredentialDraftBinding = + | { status: 'available'; draftId?: string } + | { status: 'unavailable'; error: unknown } + +/** Extracts a draft binding from Better Auth state and rejects malformed callback state. */ +export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): string | undefined { + if (callbackUrl === undefined) return undefined + if (typeof callbackUrl !== 'string') { + throw new Error('OAuth state callback URL must be a string') + } + return new URL(callbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined +} + +/** Reads an exact draft binding without falling back when OAuth state is unavailable. */ +export async function loadOAuthCredentialDraftBinding( + loadOAuthState: () => Promise +): Promise { + try { + const oauthState = await loadOAuthState() + return { + status: 'available', + draftId: parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL), + } + } catch (error) { + return { status: 'unavailable', error } + } +} + interface ProcessCredentialDraftParams { + draftId?: string userId: string providerId: string accountId: string } /** - * Looks up a pending credential draft for the given user/provider and processes it. + * Looks up a pending credential draft and processes it. + * Draft-backed OAuth launches pass the exact id. Legacy callers without one are + * accepted only when the user/provider pair has a single active draft. * Creates a new credential or reconnects an existing one depending on the draft state. * Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello). */ export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise { - const { userId, providerId, accountId } = params + const { draftId, userId, providerId, accountId } = params + + const predicates = [ + eq(schema.pendingCredentialDraft.userId, userId), + eq(schema.pendingCredentialDraft.providerId, providerId), + sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`, + ] + if (draftId) { + predicates.push(eq(schema.pendingCredentialDraft.id, draftId)) + } - const [draft] = await db + const drafts = await db .select() .from(schema.pendingCredentialDraft) - .where( - and( - eq(schema.pendingCredentialDraft.userId, userId), - eq(schema.pendingCredentialDraft.providerId, providerId), - sql`${schema.pendingCredentialDraft.expiresAt} > NOW()` - ) + .where(and(...predicates)) + .orderBy(desc(schema.pendingCredentialDraft.createdAt)) + .limit(draftId ? 1 : 2) + + if (!draftId && drafts.length > 1) { + throw new Error( + `Cannot process an ambiguous OAuth credential draft for user ${userId} and provider ${providerId}` ) - .limit(1) + } + + const [draft] = drafts - if (!draft) return + if (!draft) { + if (draftId) { + throw new Error( + `Cannot process missing or expired OAuth credential draft ${draftId} for user ${userId}` + ) + } + return + } const now = new Date() diff --git a/apps/sim/lib/credentials/members.test.ts b/apps/sim/lib/credentials/members.test.ts new file mode 100644 index 00000000000..fae51df0525 --- /dev/null +++ b/apps/sim/lib/credentials/members.test.ts @@ -0,0 +1,20 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { listCredentialMembershipsForUser } from '@/lib/credentials/members' + +describe('listCredentialMembershipsForUser', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('excludes managed OAuth credentials from ordinary memberships', async () => { + dbChainMockFns.where.mockResolvedValue([]) + + await listCredentialMembershipsForUser('user-1') + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) +}) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts new file mode 100644 index 00000000000..7c3f68047d8 --- /dev/null +++ b/apps/sim/lib/credentials/members.ts @@ -0,0 +1,266 @@ +import { db } from '@sim/db' +import { credential, credentialMember, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, ne } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isSharedCredentialType, requireOrdinaryCredentialType } from '@/lib/credentials/access' +import type { CredentialRow } from '@/lib/credentials/queries' +import { + getUserEntityPermissions, + getUsersWithPermissions, +} from '@/lib/workspaces/permissions/utils' + +export interface CredentialMemberView { + id: string + userId: string + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + joinedAt: Date | null + userName: string | null + userEmail: string | null + roleSource: 'explicit' | 'workspace-admin' +} + +export async function listCredentialMembers( + credential: CredentialRow +): Promise { + const explicitMembers = await db + .select({ + id: credentialMember.id, + userId: credentialMember.userId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + userName: user.name, + userEmail: user.email, + }) + .from(credentialMember) + .innerJoin(user, eq(credentialMember.userId, user.id)) + .where(eq(credentialMember.credentialId, credential.id)) + + const byUser = new Map( + explicitMembers.map((member) => [member.userId, { ...member, roleSource: 'explicit' as const }]) + ) + + if (isSharedCredentialType(credential.type)) { + const workspaceMembers = await getUsersWithPermissions(credential.workspaceId) + for (const workspaceMember of workspaceMembers) { + if (workspaceMember.permissionType !== 'admin') continue + const existing = byUser.get(workspaceMember.userId) + if (existing) { + existing.role = 'admin' + existing.status = 'active' + existing.roleSource = 'workspace-admin' + } else { + byUser.set(workspaceMember.userId, { + id: `workspace-admin-${workspaceMember.userId}`, + userId: workspaceMember.userId, + role: 'admin', + status: 'active', + joinedAt: null, + userName: workspaceMember.name, + userEmail: workspaceMember.email, + roleSource: 'workspace-admin', + }) + } + } + } + + return Array.from(byUser.values()) +} + +export interface UpsertCredentialMemberParams { + credential: CredentialRow + actorUserId: string + targetUserId: string + role: 'admin' | 'member' +} + +export interface UpsertCredentialMemberResult { + created: boolean + previousRole?: 'admin' | 'member' +} + +export async function upsertCredentialMember( + params: UpsertCredentialMemberParams +): Promise { + if (!isSharedCredentialType(params.credential.type)) { + throw new OrchestrationError('validation', 'Personal secrets cannot be shared') + } + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === null) { + throw new OrchestrationError( + 'validation', + 'Target user must belong to the credential workspace' + ) + } + if (targetWorkspacePermission === 'admin' && params.role !== 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be demoted' + ) + } + + const [existing] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId) + ) + ) + .limit(1) + const now = new Date() + if (existing) { + const previousRole = await db.transaction(async (tx) => { + const [current] = await tx + .select({ role: credentialMember.role }) + .from(credentialMember) + .where(eq(credentialMember.id, existing.id)) + .limit(1) + .for('update') + if (!current) throw new Error('Credential membership disappeared during update') + await tx + .update(credentialMember) + .set({ role: params.role, status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existing.id)) + return current.role + }) + return { created: false, previousRole } + } + + await db.insert(credentialMember).values({ + id: generateId(), + credentialId: params.credential.id, + userId: params.targetUserId, + role: params.role, + status: 'active', + joinedAt: now, + invitedBy: params.actorUserId, + createdAt: now, + updatedAt: now, + }) + return { created: true } +} + +export async function removeCredentialMember(params: { + credential: CredentialRow + targetUserId: string +}): Promise { + const [target] = await db + .select({ id: credentialMember.id, role: credentialMember.role }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId), + eq(credentialMember.status, 'active') + ) + ) + .limit(1) + if (!target) throw new OrchestrationError('not_found', 'Member not found') + + if (isSharedCredentialType(params.credential.type)) { + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be removed' + ) + } + } + + const revoked = await db.transaction(async (tx) => { + if (!isSharedCredentialType(params.credential.type) && target.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, target.id)) + return true + }) + if (!revoked) throw new OrchestrationError('validation', 'Cannot remove the last admin') +} + +export async function listCredentialMembershipsForUser(userId: string) { + const rows = await db + .select({ + membershipId: credentialMember.id, + credentialId: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + providerId: credential.providerId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + }) + .from(credentialMember) + .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) + .where(and(eq(credentialMember.userId, userId), ne(credential.type, 'managed_oauth'))) + return rows.map((row) => ({ ...row, type: requireOrdinaryCredentialType(row.type) })) +} + +export async function leaveCredentialMembership(params: { + userId: string + credentialId: string +}): Promise { + const [membership] = await db + .select() + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.userId, params.userId) + ) + ) + .limit(1) + if (!membership) throw new OrchestrationError('not_found', 'Membership not found') + if (membership.status !== 'active') return + + const revoked = await db.transaction(async (tx) => { + if (membership.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, membership.id)) + return true + }) + if (!revoked) { + throw new OrchestrationError('validation', 'Cannot leave credential as the last active admin') + } +} diff --git a/apps/sim/lib/credentials/oauth-accounts.ts b/apps/sim/lib/credentials/oauth-accounts.ts new file mode 100644 index 00000000000..705d926d5bc --- /dev/null +++ b/apps/sim/lib/credentials/oauth-accounts.ts @@ -0,0 +1,152 @@ +import { db } from '@sim/db' +import { account, credential, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { and, desc, eq, inArray, like, or } from 'drizzle-orm' +import { decodeJwt } from 'jose' +import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { OAuthProvider } from '@/lib/oauth' +import { parseProvider } from '@/lib/oauth' +import { providerIdsForService } from '@/lib/oauth/utils' + +const logger = createLogger('CredentialOAuthAccounts') + +interface GoogleIdToken { + email?: string + name?: string +} + +export async function listOAuthConnectionsForUser(userId: string): Promise { + const [accounts, userRecord] = await Promise.all([ + db.select().from(account).where(eq(account.userId, userId)), + db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1), + ]) + const userEmail = userRecord[0]?.email ?? null + const connections: OAuthConnection[] = [] + + for (const accountRow of accounts) { + const { baseProvider, featureType } = parseProvider(accountRow.providerId as OAuthProvider) + if (!baseProvider) continue + const scopes = accountRow.scope?.split(/\s+/).filter(Boolean) ?? [] + let displayName = '' + if (accountRow.idToken) { + try { + const decoded = decodeJwt(accountRow.idToken) + displayName = decoded.email || decoded.name || '' + } catch (error) { + logger.warn('Failed to decode OAuth account ID token', { accountId: accountRow.id, error }) + } + } + if (!displayName && baseProvider === 'github') { + displayName = `${accountRow.accountId} (GitHub)` + } + displayName ||= userEmail || `${accountRow.accountId} (${baseProvider})` + + const existing = connections.find((connection) => connection.provider === accountRow.providerId) + if (existing) { + existing.accounts.push({ id: accountRow.id, name: displayName }) + existing.scopes = Array.from(new Set([...existing.scopes, ...scopes])) + if (accountRow.updatedAt.getTime() > new Date(existing.lastConnected).getTime()) { + existing.lastConnected = accountRow.updatedAt.toISOString() + } + continue + } + connections.push({ + provider: accountRow.providerId, + baseProvider, + featureType, + isConnected: true, + scopes, + lastConnected: accountRow.updatedAt.toISOString(), + accounts: [{ id: accountRow.id, name: displayName }], + }) + } + + return connections +} + +export async function listConnectedAccountsForUser(params: { userId: string; provider?: string }) { + const whereConditions = [eq(account.userId, params.userId)] + if (params.provider) whereConditions.push(eq(account.providerId, params.provider)) + const rows = await db + .select({ + id: account.id, + accountId: account.accountId, + providerId: account.providerId, + credentialDisplayName: credential.displayName, + }) + .from(account) + .leftJoin(credential, eq(credential.accountId, account.id)) + .where(and(...whereConditions)) + .orderBy(desc(account.updatedAt)) + + const seen = new Map() + for (const row of rows) { + if (!seen.has(row.id)) seen.set(row.id, row) + } + return Array.from(seen.values()).map((row) => ({ + id: row.id, + accountId: row.accountId, + providerId: row.providerId, + displayName: row.credentialDisplayName || row.accountId || row.providerId, + })) +} + +export interface DisconnectOAuthAccountsParams { + userId: string + provider: string + providerId?: string + accountId?: string +} + +export class OAuthDisconnectPartialFailureError extends Error { + constructor( + readonly credentials: Array, + cause: unknown + ) { + const error = toError(cause) + super(error.message, { cause: error }) + this.name = 'OAuthDisconnectPartialFailureError' + } +} + +export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsParams) { + const accountFilter = params.accountId + ? and(eq(account.userId, params.userId), eq(account.id, params.accountId)) + : params.providerId + ? and(eq(account.userId, params.userId), eq(account.providerId, params.providerId)) + : and( + eq(account.userId, params.userId), + or( + inArray(account.providerId, providerIdsForService(params.provider)), + like(account.providerId, `${params.provider}-%`) + ) + ) + const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) + const targetAccountIds = targetAccounts.map((row) => row.id) + if (targetAccountIds.length === 0) return { credentials: [] } + + const credentialRows = await db + .select() + .from(credential) + .where(inArray(credential.accountId, targetAccountIds)) + const deletedCredentials: typeof credentialRows = [] + try { + for (const credentialRow of credentialRows) { + if (credentialRow.type !== 'oauth') { + throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + } + const deleted = await deleteCredentialRecord({ + credential: credentialRow, + reason: 'oauth_disconnect', + }) + if (deleted) deletedCredentials.push(credentialRow) + } + await db.delete(account).where(inArray(account.id, targetAccountIds)) + } catch (error) { + if (deletedCredentials.length === 0) throw error + throw new OAuthDisconnectPartialFailureError(deletedCredentials, error) + } + return { credentials: deletedCredentials } +} diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 6120e5bec76..f795e1846e3 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -2,12 +2,14 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { account, credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' @@ -82,7 +84,7 @@ export interface PerformCreateCredentialParams { * secrets exist, so the id must be known up front. */ id?: string - request?: NextRequest + request?: OrchestrationRequestContext } export interface PerformCreateCredentialResult { @@ -96,6 +98,8 @@ export interface PerformCreateCredentialResult { credential?: CredentialRow /** False when an existing credential matched the source and was returned instead. */ created?: boolean + /** Verified provider identity metadata for the application audit projection. */ + auditMetadata?: Record } interface ExistingCredentialSourceParams { @@ -183,6 +187,18 @@ async function findExistingCredentialBySourceWith( return null } +async function serviceAccountSecretsMatch( + existingEncryptedSecret: string | null, + submittedEncryptedSecret: string | null +): Promise { + if (!existingEncryptedSecret || !submittedEncryptedSecret) return false + const [existing, submitted] = await Promise.all([ + decryptSecret(existingEncryptedSecret), + decryptSecret(submittedEncryptedSecret), + ]) + return safeCompare(existing.decrypted, submitted.decrypted) +} + function failure( error: string, errorCode: CredentialOrchestrationErrorCode, @@ -191,14 +207,17 @@ function failure( return { success: false, error, errorCode, ...extra } } -export async function performCreateCredential( - params: PerformCreateCredentialParams +export async function createCredentialRecord( + params: PerformCreateCredentialParams, + options: { authorizeWorkspace: boolean } ): Promise { const { workspaceId, type, userId } = params try { - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { + const workspaceAccess = options.authorizeWorkspace + ? await checkWorkspaceAccess(workspaceId, userId) + : undefined + if (workspaceAccess && !workspaceAccess.canWrite) { return failure('Write permission required', 'forbidden') } @@ -320,12 +339,11 @@ export async function performCreateCredential( ) } - /** - * Token service-account creates always carry a fresh token that must be - * stored — falling through to the existing-credential path would return - * the old credential as success and silently drop the submitted token. - */ - if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { + if ( + type === 'service_account' && + resolvedProviderId && + isTokenServiceAccountProviderId(resolvedProviderId) + ) { return failure( `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, 'conflict', @@ -334,13 +352,34 @@ export async function performCreateCredential( } const access = await getCredentialActorContext(existingCredential.id, userId, { - workspaceAccess, + ...(workspaceAccess ? { workspaceAccess } : {}), }) if (!access.member && !access.isAdmin) { return failure('A credential with this source already exists in this workspace', 'conflict') } + /** + * Non-token service accounts may replay only the exact stored secret. A + * source match with rotated secret material must not report success while + * silently retaining the old ciphertext. Compare only after credential + * access is established so the encrypted value stays behind its resource + * authorization boundary. + */ + if ( + type === 'service_account' && + !(await serviceAccountSecretsMatch( + existingCredential.encryptedServiceAccountKey, + resolvedEncryptedServiceAccountKey + )) + ) { + return failure( + `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + const shouldUpdateDisplayName = type === 'oauth' && resolvedDisplayName && @@ -486,37 +525,7 @@ export async function performCreateCredential( .where(eq(credential.id, credentialId)) .limit(1) - captureServerEvent( - userId, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - // Provider metadata spreads first so this path's own keys stay - // authoritative and can never be shadowed, matching the update path. - ...extraAuditMetadata, - credentialType: type, - providerId: resolvedProviderId, - }, - request: params.request, - }) - - return { success: true, credential: created, created: true } + return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata } } catch (error: unknown) { if (error instanceof AtlassianValidationError) { logger.warn(`Atlassian credential rejected: ${error.code}`, { @@ -572,6 +581,64 @@ export async function performCreateCredential( } } +export type CreateServiceAccountCredentialParams = Omit< + PerformCreateCredentialParams, + 'type' | 'actorName' | 'actorEmail' +> & { providerId: string } + +/** Creates and verifies one service-account credential without surface side effects. */ +export function createServiceAccountCredential( + params: CreateServiceAccountCredentialParams +): Promise { + return createCredentialRecord( + { ...params, type: 'service_account' }, + { authorizeWorkspace: false } + ) +} + +/** Preserves the legacy internal surface's analytics and audit behavior. */ +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const result = await createCredentialRecord(params, { authorizeWorkspace: true }) + if (!result.success || !result.created) return result + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + + captureServerEvent( + params.userId, + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: result.credential.workspaceId, + }, + { + groups: { workspace: result.credential.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId: result.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + request: params.request, + }) + + return result +} + /** * Provider error codes that mean the upstream service could not be reached, * rather than that the caller's secret was rejected. Each provider family names diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 65da20e8275..8ea6c8954b8 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -17,6 +17,7 @@ const { mockVerifyAndBuildServiceAccountSecret, mockIsClientCredentialAccountProviderId, mockGetClientCredentialAccountDescriptor, + mockDeleteConnectionCredential, } = vi.hoisted(() => ({ mockRecordAudit: vi.fn(), mockGetCredentialActorContext: vi.fn(), @@ -26,6 +27,7 @@ const { // Only a descriptor carrying `defaultAuthMethod` is multi-grant; single-grant // providers must not trigger the stored-blob read for authMethod/username. mockGetClientCredentialAccountDescriptor: vi.fn(() => undefined), + mockDeleteConnectionCredential: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -47,7 +49,9 @@ vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ isClientCredentialAccountProviderId: mockIsClientCredentialAccountProviderId, getClientCredentialAccountDescriptor: mockGetClientCredentialAccountDescriptor, })) -vi.mock('@/lib/credentials/deletion', () => ({ deleteCredential: vi.fn() })) +vi.mock('@/lib/credentials/deletion', () => ({ + deleteConnectionCredential: mockDeleteConnectionCredential, +})) vi.mock('@/lib/credentials/environment', () => ({ deleteWorkspaceEnvCredentials: vi.fn(), syncPersonalEnvCredentialsForUser: vi.fn(), @@ -60,7 +64,11 @@ vi.mock('@/lib/credentials/token-service-accounts/errors', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -import { performUpdateCredential } from '@/lib/credentials/orchestration' +import { + createServiceAccountCredential, + deleteCredentialRecord, + performUpdateCredential, +} from '@/lib/credentials/orchestration' const OLD_EMAIL = 'old-sa@old-project.iam.gserviceaccount.com' const NEW_EMAIL = 'new-sa@new-project.iam.gserviceaccount.com' @@ -415,4 +423,131 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockRecordAudit).not.toHaveBeenCalled() }) + + it('conceals managed OAuth credentials from the ordinary update path', async () => { + mockCredential({ type: 'managed_oauth' }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + description: 'should not update', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) + +describe('createServiceAccountCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('rejects an existing service-account source instead of discarding the submitted secret', async () => { + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoom-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Production Zoom', + auditMetadata: {}, + principal: { kind: 'tenant', id: 'account-1' }, + }) + queueTableRows(schemaMock.credential, [ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + encryptedServiceAccountKey: 'old-cipher', + }, + ]) + mockDecryptSecret + .mockResolvedValueOnce({ decrypted: 'stored-secret' }) + .mockResolvedValueOnce({ decrypted: 'rotated-secret' }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client-id', + clientSecret: 'rotated-client-secret', + orgId: 'account-1', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'conflict', + providerErrorCode: 'duplicate_display_name', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockGetCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1', {}) + }) + + it('returns an accessible credential for an exact non-token secret replay', async () => { + const existingCredential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + encryptedServiceAccountKey: 'stored-cipher', + } + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoom-service-account', + encryptedServiceAccountKey: 'replay-cipher', + displayName: 'Production Zoom', + auditMetadata: {}, + principal: { kind: 'tenant', id: 'account-1' }, + }) + queueTableRows(schemaMock.credential, [existingCredential]) + mockDecryptSecret.mockResolvedValue({ decrypted: 'same-secret' }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-1', + }) + + expect(result).toMatchObject({ + success: true, + credential: existingCredential, + created: false, + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) + +describe('deleteCredentialRecord', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('rejects deleting a custom Slack bot used by an active Credential Group', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'group-1' }]) + + await expect( + deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'service_account', + providerId: 'slack-custom-bot', + } as never, + reason: 'user_delete', + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'Remove this custom Slack bot from its Credential Groups before deleting it.', + }) + expect(mockDeleteConnectionCredential).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ff34fd0a244..84fd54888fb 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { decryptSecret } from '@/lib/core/security/encryption' import { listSlackCredentialGroupConfigurationsForBot } from '@/lib/credential-groups/provider-configuration' import { @@ -23,7 +24,7 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, } from '@/lib/credentials/client-credential-accounts/descriptors' -import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' +import { type CredentialDeleteReason, deleteConnectionCredential } from '@/lib/credentials/deletion' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { deleteWorkspaceEnvCredentials, @@ -42,8 +43,13 @@ import { import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +type CredentialRow = typeof credential.$inferSelect +export { deleteConnectionCredential } from '@/lib/credentials/deletion' export { + type CreateServiceAccountCredentialParams, + createCredentialRecord, + createServiceAccountCredential, isProviderOutageCode, type PerformCreateCredentialParams, type PerformCreateCredentialResult, @@ -170,41 +176,26 @@ export interface PerformCredentialResult { workspaceId?: string updatedFields?: string[] previousDisplayName?: string + auditMetadata?: Record } -export async function performUpdateCredential( - params: PerformUpdateCredentialParams +export type UpdateCredentialRecordParams = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> & { credential: CredentialRow } + +/** Updates one already-authorized credential without surface authorization or audit. */ +export async function updateCredentialRecord( + params: UpdateCredentialRecordParams ): Promise { try { - const access = await getCredentialActorContext(params.credentialId, params.userId) - if (!access.credential) { - return { success: false, error: 'Credential not found', errorCode: 'not_found' } - } - if (access.credential.type === 'managed_oauth') { - return { success: false, error: 'Credential not found', errorCode: 'not_found' } - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return { - success: false, - error: 'Credential admin permission required', - errorCode: 'forbidden', - } - } - if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { - return { - success: false, - error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, - errorCode: 'validation', - } - } - const updates: Record = {} if (params.description !== undefined) { updates.description = params.description ?? null } if ( params.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') + (params.credential.type === 'oauth' || params.credential.type === 'service_account') ) { updates.displayName = params.displayName } @@ -229,8 +220,8 @@ export async function performUpdateCredential( params.username !== undefined let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined - if (hasRotationSecret && access.credential.type === 'service_account') { - const providerId = access.credential.providerId ?? '' + if (hasRotationSecret && params.credential.type === 'service_account') { + const providerId = params.credential.providerId ?? '' // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual @@ -260,15 +251,15 @@ export async function performUpdateCredential( // One read + decrypt at most, and only for the providers that can use it. const storedBlob = needsStoredDataCenter || needsStoredAuthMethod || needsStoredUsername || needsStoredIdentity - ? await readStoredSecretBlob(access.credential.id) + ? await readStoredSecretBlob(params.credential.id) : null try { const slackConfigurations = providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? await listSlackCredentialGroupConfigurationsForBot({ - workspaceId: access.credential.workspaceId, - slackBotCredentialId: access.credential.id, + workspaceId: params.credential.workspaceId, + slackBotCredentialId: params.credential.id, }) : [] if (slackConfigurations.length > 0) { @@ -326,7 +317,7 @@ export async function performUpdateCredential( const previousIdentity = deriveStoredDisplayName(storedBlob) if ( previousIdentity !== undefined && - previousIdentity === access.credential.displayName && + previousIdentity === params.credential.displayName && secret.displayName && secret.displayName !== previousIdentity ) { @@ -360,7 +351,7 @@ export async function performUpdateCredential( } if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { + if (params.credential.type === 'oauth' || params.credential.type === 'service_account') { return { success: false, error: 'No updatable fields provided.', errorCode: 'validation' } } return { @@ -389,31 +380,12 @@ export async function performUpdateCredential( } const updatedFields = auditUpdatedFields(updates) - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - // Provider metadata first: the orchestration's own keys stay authoritative - // and can never be shadowed by a builder's audit payload. - metadata: { - ...rotatedAuditMetadata, - credentialType: access.credential.type, - updatedFields, - }, - request: params.request, - }) - return { success: true, - workspaceId: access.credential.workspaceId, + workspaceId: params.credential.workspaceId, updatedFields, - previousDisplayName: access.credential.displayName, + previousDisplayName: params.credential.displayName, + auditMetadata: rotatedAuditMetadata, } } catch (error) { if (error instanceof Error && error.message.includes('unique')) { @@ -428,6 +400,168 @@ export async function performUpdateCredential( } } +/** Preserves the legacy callers while application adapters migrate to the manager above. */ +export async function performUpdateCredential( + params: PerformUpdateCredentialParams +): Promise { + const access = await getCredentialActorContext(params.credentialId, params.userId) + if (!access.credential) { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } + if (access.credential.type === 'managed_oauth') { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } + if (!access.hasWorkspaceAccess || !access.isAdmin) { + return { + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + } + } + if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { + return { + success: false, + error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, + errorCode: 'validation', + } + } + + const result = await updateCredentialRecord({ ...params, credential: access.credential }) + if (!result.success) return result + + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: access.credential.type, + updatedFields: result.updatedFields, + }, + request: params.request, + }) + + return result +} + +export interface DeleteCredentialRecordParams { + credential: CredentialRow + reason: CredentialDeleteReason +} + +/** Deletes one already-authorized credential and its backing secret source. */ +export async function deleteCredentialRecord( + params: DeleteCredentialRecordParams +): Promise { + const { credential: credentialRow } = params + + if (credentialRow.type === 'managed_oauth') { + throw new OrchestrationError('not_found', 'Credential not found') + } + + if (credentialRow.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + const [binding] = await db + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, credentialRow.workspaceId), + sql`EXISTS ( + SELECT 1 + FROM jsonb_array_elements(${credentialGroup.options}) AS option + WHERE option->>'slackBotCredentialId' = ${credentialRow.id} + AND option->>'status' = 'active' + )` + ) + ) + .limit(1) + if (binding) { + throw new OrchestrationError( + 'conflict', + 'Remove this custom Slack bot from its Credential Groups before deleting it.' + ) + } + } + + if (credentialRow.type === 'env_personal') { + if (!credentialRow.envKey || !credentialRow.envOwnerUserId) { + throw new Error('Personal environment credential is missing its source identity') + } + const [personalRow] = await db + .select({ variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, credentialRow.envOwnerUserId)) + .limit(1) + const current = { ...((personalRow?.variables as Record | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(environment) + .values({ + id: credentialRow.envOwnerUserId, + userId: credentialRow.envOwnerUserId, + variables: current, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { variables: current, updatedAt: new Date() }, + }) + await syncPersonalEnvCredentialsForUser({ + userId: credentialRow.envOwnerUserId, + envKeys: Object.keys(current), + }) + return true + } + + if (credentialRow.type === 'env_workspace') { + if (!credentialRow.envKey) { + throw new Error('Workspace environment credential is missing its source identity') + } + const [workspaceRow] = await db + .select({ + id: workspaceEnvironment.id, + createdAt: workspaceEnvironment.createdAt, + variables: workspaceEnvironment.variables, + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, credentialRow.workspaceId)) + .limit(1) + const current = { ...((workspaceRow?.variables as Record | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(workspaceEnvironment) + .values({ + id: workspaceRow?.id ?? generateId(), + workspaceId: credentialRow.workspaceId, + variables: current, + createdAt: workspaceRow?.createdAt ?? new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: current, updatedAt: new Date() }, + }) + await deleteWorkspaceEnvCredentials({ + workspaceId: credentialRow.workspaceId, + removedKeys: [credentialRow.envKey], + }) + return true + } + + return deleteConnectionCredential({ + credentialId: credentialRow.id, + workspaceId: credentialRow.workspaceId, + reason: params.reason, + }) +} + +/** Preserves the legacy callers while application adapters migrate to the manager above. */ export async function performDeleteCredential( params: CredentialActorParams ): Promise { @@ -454,176 +588,60 @@ export async function performDeleteCredential( } } - if (access.credential.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { - const [binding] = await db - .select({ id: credentialGroup.id }) - .from(credentialGroup) - .where( - and( - eq(credentialGroup.workspaceId, access.credential.workspaceId), - sql`EXISTS ( - SELECT 1 - FROM jsonb_array_elements(${credentialGroup.options}) AS option - WHERE option->>'slackBotCredentialId' = ${access.credential.id} - AND option->>'status' = 'active' - )` - ) - ) - .limit(1) - if (binding) { - return { - success: false, - error: 'Remove this custom Slack bot from its Credential Groups before deleting it.', - errorCode: 'conflict', - } - } - } - - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return { success: false, error: 'Invalid personal secret owner', errorCode: 'validation' } - } - - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(environment) - .values({ id: ownerUserId, userId: ownerUserId, variables: current, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), - workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - await deleteCredential({ - credentialId: params.credentialId, - actorId: params.userId, - actorName: params.actorName, - actorEmail: params.actorEmail, - reason: params.reason ?? 'user_delete', - request: params.request, - }) + const reason = params.reason ?? 'user_delete' + await deleteCredentialRecord({ credential: access.credential, reason }) captureServerEvent( params.userId, 'credential_deleted', { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? params.credentialId, + credential_type: access.credential.type, + provider_id: + access.credential.providerId ?? access.credential.envKey ?? params.credentialId, workspace_id: access.credential.workspaceId, }, { groups: { workspace: access.credential.workspaceId } } ) + const envDescription = + access.credential.type === 'env_personal' + ? `Deleted personal env credential "${access.credential.envKey}"` + : access.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${access.credential.envKey}"` + : `Deleted ${access.credential.type} credential "${access.credential.displayName}" (${reason})` + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: envDescription, + metadata: { + reason, + credentialType: access.credential.type, + providerId: access.credential.providerId, + accountId: access.credential.accountId, + envKey: access.credential.envKey, + }, + request: params.request, + }) + return { success: true, workspaceId: access.credential.workspaceId } } catch (error) { + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + if (orchestrationError.code !== 'not_found' && orchestrationError.code !== 'conflict') { + throw orchestrationError + } + return { + success: false, + error: orchestrationError.message, + errorCode: orchestrationError.code, + } + } logger.error('Failed to delete credential', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts index e5ff19c7d1a..efe7459628e 100644 --- a/apps/sim/lib/credentials/queries.test.ts +++ b/apps/sim/lib/credentials/queries.test.ts @@ -4,6 +4,9 @@ import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' import { + findWorkspaceCredentialLookup, + getCredentialById, + getWorkspaceCredential, listVisibleWorkspaceCredentials, listWorkspacePrincipalCredentials, } from '@/lib/credentials/queries' @@ -130,3 +133,31 @@ describe('listWorkspacePrincipalCredentials', () => { ).rejects.toBe(failure) }) }) + +describe('ordinary credential lookups', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it.each([ + [ + 'workspace credential', + () => getWorkspaceCredential({ workspaceId: 'workspace-1', credentialId: 'credential-1' }), + ], + ['credential by id', () => getCredentialById('credential-1')], + [ + 'legacy id/account lookup', + () => + findWorkspaceCredentialLookup({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }), + ], + ])('excludes managed OAuth from the %s path', async (_name, lookup) => { + dbChainMockFns.limit.mockResolvedValue([]) + + await lookup() + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) +}) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 92122ebf37f..4781e70b8f6 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -15,7 +15,12 @@ import { textKey, timestampKey, } from '@/lib/api/list-query' -import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' +import { + isSharedCredentialType, + type OrdinaryCredentialType, + requireOrdinaryCredentialType, + SHARED_CREDENTIAL_TYPES, +} from '@/lib/credentials/access' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' /** @@ -42,6 +47,13 @@ export interface VisibleWorkspaceCredential { role: 'admin' | 'member' } +export interface WorkspaceCredentialLookup { + id: string + displayName: string + type: OrdinaryCredentialType + providerId: string | null +} + const credentialIdKey = textKey(credential.id, (row) => row.id) /** @@ -264,3 +276,75 @@ export async function listWorkspacePrincipalCredentials(params: { return keysetPage(keys, mapped, limit) } +/** + * A single credential scoped to a workspace, or null when it does not exist + * there. Scoping by workspace is what keeps a credential id from another tenant + * from resolving at all. + */ +export async function getWorkspaceCredential(params: { + workspaceId: string + credentialId: string +}): Promise { + const [row] = await db + .select() + .from(credential) + .where( + and( + eq(credential.id, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + return row ?? null +} + +/** Preserves the internal route's legacy id-first, account-id-second lookup semantics. */ +export async function findWorkspaceCredentialLookup(params: { + workspaceId: string + credentialId: string +}): Promise { + const projection = { + id: credential.id, + displayName: credential.displayName, + type: credential.type, + providerId: credential.providerId, + } + const [byId] = await db + .select(projection) + .from(credential) + .where( + and( + eq(credential.id, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + if (byId) return { ...byId, type: requireOrdinaryCredentialType(byId.type) } + + const [byAccountId] = await db + .select(projection) + .from(credential) + .where( + and( + eq(credential.accountId, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + return byAccountId + ? { ...byAccountId, type: requireOrdinaryCredentialType(byAccountId.type) } + : null +} + +/** Canonical credential lookup used before its workspace scope is known. */ +export async function getCredentialById(credentialId: string): Promise { + const [row] = await db + .select() + .from(credential) + .where(and(eq(credential.id, credentialId), ne(credential.type, 'managed_oauth'))) + .limit(1) + return row ?? null +} diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts index 8bb9720552d..1837aa67571 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.ts @@ -62,16 +62,21 @@ export function createIntegrationCredentialVisibility({ else ownersByProviderId.set(providerId, [service]) } - for (const service of oauthOwners) { - addOwner(oauthOwnersByProviderId, service.providerId, service) - // A second authorization server for the same service (`salesforce-sandbox`) - // issues ordinary OAuth credentials, so they own visibility exactly like - // the primary provider's do. - for (const extraProviderId of service.additionalProviderIds ?? []) { - addOwner(oauthOwnersByProviderId, extraProviderId, service) + for (const service of oauthServices) { + if (service.authType === 'oauth') { + addOwner(oauthOwnersByProviderId, service.providerId, service) + // A second authorization server for the same service (`salesforce-sandbox`) + // issues ordinary OAuth credentials, so they own visibility exactly like + // the primary provider's do. + for (const extraProviderId of service.additionalProviderIds ?? []) { + addOwner(oauthOwnersByProviderId, extraProviderId, service) + } } - if (service.serviceAccountProviderId) { - addOwner(serviceAccountOwnersByProviderId, service.serviceAccountProviderId, service) + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId) { + addOwner(serviceAccountOwnersByProviderId, serviceAccountProviderId, service) } } diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts new file mode 100644 index 00000000000..058bfca32dc --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' +import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state' + +const CLIENT_SECRET = 'shopify-client-secret' +const USER_ID = 'user-1' +const SHOP_DOMAIN = 'example.myshopify.com' + +function parse( + state: string, + overrides: { userId?: string; shopDomain?: string; now?: Date } = {} +) { + return parseShopifyOAuthState({ + state, + userId: overrides.userId ?? USER_ID, + shopDomain: overrides.shopDomain ?? SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + now: overrides.now, + }) +} + +describe('Shopify OAuth state', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps overlapping connection drafts bound to their own state', () => { + const first = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + clientSecret: CLIENT_SECRET, + }) + const second = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + clientSecret: CLIENT_SECRET, + }) + + expect(parse(first)).toEqual({ + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + }) + expect(parse(second)).toEqual({ + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + }) + }) + + it('rejects tampered, cross-user, and cross-shop state', () => { + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const [payload, signature] = state.split('.') + + expect(() => parse(`${payload}x.${signature}`)).toThrow( + 'Shopify OAuth state signature is invalid' + ) + expect(() => parse(state, { userId: 'user-2' })).toThrow( + 'Shopify OAuth state belongs to a different user' + ) + expect(() => parse(state, { shopDomain: 'other.myshopify.com' })).toThrow( + 'Shopify OAuth state belongs to a different shop' + ) + }) + + it('rejects expired state', () => { + const issuedAt = new Date('2026-08-14T18:00:00.000Z') + vi.spyOn(Date, 'now').mockReturnValue(issuedAt.getTime()) + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + }) + + expect(parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS) })).toEqual( + {} + ) + expect(() => + parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS + 1) }) + ).toThrow('Shopify OAuth state is expired') + }) +}) diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts new file mode 100644 index 00000000000..75c7f4b5771 --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -0,0 +1,111 @@ +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { generateId } from '@sim/utils/id' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' + +const SHOPIFY_OAUTH_STATE_VERSION = 1 + +interface ShopifyOAuthStatePayload { + v: typeof SHOPIFY_OAUTH_STATE_VERSION + nonce: string + userId: string + shopDomain: string + draftId?: string + returnUrl?: string + issuedAt: number +} + +interface CreateShopifyOAuthStateParams { + userId: string + shopDomain: string + draftId?: string + returnUrl?: string + clientSecret: string +} + +interface ParseShopifyOAuthStateParams { + state: string + userId: string + shopDomain: string + clientSecret: string + now?: Date +} + +function isShopifyOAuthStatePayload(value: unknown): value is ShopifyOAuthStatePayload { + if (!value || typeof value !== 'object') return false + const payload = value as Record + return ( + payload.v === SHOPIFY_OAUTH_STATE_VERSION && + typeof payload.nonce === 'string' && + payload.nonce.length > 0 && + typeof payload.userId === 'string' && + payload.userId.length > 0 && + typeof payload.shopDomain === 'string' && + payload.shopDomain.length > 0 && + (payload.draftId === undefined || + (typeof payload.draftId === 'string' && payload.draftId.length > 0)) && + (payload.returnUrl === undefined || + (typeof payload.returnUrl === 'string' && payload.returnUrl.length > 0)) && + typeof payload.issuedAt === 'number' && + Number.isSafeInteger(payload.issuedAt) + ) +} + +/** Creates a signed, user-bound Shopify state token carrying the exact credential draft. */ +export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): string { + const payload: ShopifyOAuthStatePayload = { + v: SHOPIFY_OAUTH_STATE_VERSION, + nonce: generateId(), + userId: params.userId, + shopDomain: params.shopDomain, + ...(params.draftId ? { draftId: params.draftId } : {}), + ...(params.returnUrl ? { returnUrl: params.returnUrl } : {}), + issuedAt: Date.now(), + } + const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const signature = hmacSha256Hex(encoded, params.clientSecret) + return `${encoded}.${signature}` +} + +/** Verifies Shopify state integrity, expiry, user ownership, and shop binding. */ +export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { + draftId?: string + returnUrl?: string +} { + const [encoded, signature, extra] = params.state.split('.') + if (!encoded || !signature || extra !== undefined) { + throw new Error('Shopify OAuth state is malformed') + } + + const expectedSignature = hmacSha256Hex(encoded, params.clientSecret) + if (!safeCompare(signature, expectedSignature)) { + throw new Error('Shopify OAuth state signature is invalid') + } + + let decoded: unknown + try { + decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) + } catch { + throw new Error('Shopify OAuth state payload is invalid') + } + if (!isShopifyOAuthStatePayload(decoded)) { + throw new Error('Shopify OAuth state payload is invalid') + } + + if (decoded.userId !== params.userId) { + throw new Error('Shopify OAuth state belongs to a different user') + } + if (decoded.shopDomain !== params.shopDomain) { + throw new Error('Shopify OAuth state belongs to a different shop') + } + + const now = params.now?.getTime() ?? Date.now() + if (decoded.issuedAt > now || now - decoded.issuedAt > CREDENTIAL_DRAFT_TTL_MS) { + throw new Error('Shopify OAuth state is expired') + } + + return { + ...(decoded.draftId ? { draftId: decoded.draftId } : {}), + ...(decoded.returnUrl ? { returnUrl: decoded.returnUrl } : {}), + } +} diff --git a/apps/sim/lib/oauth/shopify.ts b/apps/sim/lib/oauth/shopify.ts new file mode 100644 index 00000000000..29a7299b874 --- /dev/null +++ b/apps/sim/lib/oauth/shopify.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { safeAccountInsert } from '@/lib/oauth/credential-service' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' + +const logger = createLogger('ShopifyOAuth') + +interface CompleteShopifyOAuthConnectionParams { + accessToken: string + shopDomain: string + scope?: string + userId: string + draftId?: string + signal?: AbortSignal +} + +function getShopifyAccountId(value: unknown): string { + if (!value || typeof value !== 'object') { + throw new Error('Shopify shop response must be an object') + } + const shop = (value as { shop?: unknown }).shop + if (!shop || typeof shop !== 'object') { + throw new Error('Shopify shop response is missing shop data') + } + const id = (shop as { id?: unknown }).id + if ((typeof id !== 'string' && typeof id !== 'number') || String(id).length === 0) { + throw new Error('Shopify shop response is missing its account id') + } + return String(id) +} + +/** Persists a verified Shopify account and completes its exact credential draft. */ +export async function completeShopifyOAuthConnection( + params: CompleteShopifyOAuthConnectionParams +): Promise { + const shopResponse = await fetch( + `https://${params.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, + { + headers: { + 'X-Shopify-Access-Token': params.accessToken, + 'Content-Type': 'application/json', + }, + signal: params.signal, + } + ) + + if (!shopResponse.ok) { + const errorText = await shopResponse.text() + throw new Error(`Shopify token validation failed (${shopResponse.status}): ${errorText}`) + } + + const stableAccountId = getShopifyAccountId(await shopResponse.json()) + const existing = await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + }) + + const now = new Date() + const accountData = { + accessToken: params.accessToken, + accountId: stableAccountId, + scope: params.scope ?? '', + updatedAt: now, + idToken: params.shopDomain, + } + + if (existing) { + await db.update(account).set(accountData).where(eq(account.id, existing.id)) + logger.info('Updated existing Shopify account', { accountId: existing.id }) + } else { + await safeAccountInsert( + { + id: generateId(), + userId: params.userId, + providerId: 'shopify', + accountId: accountData.accountId, + accessToken: accountData.accessToken, + scope: accountData.scope, + idToken: accountData.idToken, + createdAt: now, + updatedAt: now, + }, + { provider: 'Shopify', identifier: params.shopDomain } + ) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + })) + + if (!persisted) { + throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) + } + + await processCredentialDraft({ + draftId: params.draftId, + userId: params.userId, + providerId: 'shopify', + accountId: persisted.id, + }) +} diff --git a/findings.txt b/findings.txt new file mode 100644 index 00000000000..92450d24ace --- /dev/null +++ b/findings.txt @@ -0,0 +1,19 @@ +# Behavior change (resolved) + +- [HIGH][RESOLVED] `apps/sim/app/api/auth/shopify/authorize/route.ts:44` introduced an authenticated reflected-XSS path. Inline script values now escape `<` as a Unicode escape, with a regression test using a closing-script payload. + +- [HIGH][RESOLVED] `apps/sim/app/api/credentials/[id]/members/route.ts:24` changed roster authorization and concealment. Listing is workspace-read authorized again, inaccessible credentials are concealed as `404 Not found`, and missing POST/DELETE targets retain the uniform `403 Admin access required` response. + +- [HIGH][RESOLVED] OAuth disconnect deferred audit and analytics until every destructive step finished. A typed partial-failure now carries committed deletions through the application boundary, which records their audit and PostHog effects before rethrowing the original failure. + +- [MEDIUM][RESOLVED] Shopify return destinations were stored in one browser-wide cookie. Each return URL now travels in its own signed, user/shop-bound state token, and overlapping callbacks are tested independently. + +- [MEDIUM][RESOLVED] Reconnects mapped every forbidden operation to credential denial. Only `CREDENTIAL_ADMIN_ACCESS_REQUIRED` now maps to `credential_access_denied`; workspace-role failures map to `workspace_access_denied`. + +- [MEDIUM][RESOLVED] Draft-backed OAuth launch ran outside the browser redirect error boundary. Launch and target resolution now run inside it, so unknown failures redirect to `/workspace?error=oauth_link_failed`. + +- [MEDIUM][RESOLVED] Credential lookup was folded into filtered listing. The application use case now has a dedicated workspace-authorized, ID-first/account-ID-second lookup branch that skips sync and filters and returns exactly `{ credential }`. + +- [MEDIUM][RESOLVED] Environment deletion lost its per-type audit and analytics projection. Personal/workspace descriptions, `envKey` metadata, and the PostHog provider dimension are restored within the shared use case. + +- [LOW][RESOLVED] Credentials and connected-account queries lost legacy normalization. Their shared contracts now restore trimming/blank handling where previously supported and first-value-wins behavior for duplicate query keys. diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index a4239c9c42d..95effcf86d2 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: 1118, - zodRoutes: 1118, + totalRoutes: 1121, + zodRoutes: 1121, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 21ebac6ba87..2fb9c71e064 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 44], ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 22], + ['apps/docs/openapi-v2-resources.json', 26], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(135) + expect(totalOperations).toBe(139) }) it('documents mixed workflow execution and resume responses', () => { From b19808035625a51ee501aa151c0840161bd7db39 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 01:37:16 -0700 Subject: [PATCH 082/103] feat(credential-groups): complete managed account enrollment (#6729) * fix(credential-groups): show reconnect after authorization * feat(slack): include managed user auth in custom bots * feat(credential-groups): add enrollment completion page * fix(credential-groups): align settings order and block color * fix(credential-groups): delete credentials on access revoke * fix(credential-groups): use person icon for enrollments * fix(credential-groups): keep enrollment submit visible * fix(credential-groups): make enrollment connections optional * fix(credential-groups): simplify people actions * fix(credential-groups): preserve pagination after deletion * fix(credential-groups): delete removed enrollments * fix(credential-groups): hydrate canvas labels --- .../enroll/[token]/complete/route.test.ts | 15 +- .../enroll/[token]/complete/route.ts | 10 +- .../credential-groups/enrollment-redirect.ts | 18 ++- .../enrollments/[enrollmentId]/route.ts | 14 +- .../app/credential-groups/complete/page.tsx | 18 +++ .../[token]/oauth-reconnect-link.test.tsx | 15 +- .../enroll/[token]/oauth-reconnect-link.tsx | 5 +- .../credential-groups/enroll/[token]/page.tsx | 47 +++--- .../connect-slack-bot-modal.tsx | 28 +++- .../[workspaceId]/settings/navigation.test.ts | 2 +- .../workflow-block/workflow-block.tsx | 8 + apps/sim/blocks/blocks/credential-group.ts | 15 +- apps/sim/blocks/types.ts | 3 +- .../components/settings/navigation.test.ts | 4 +- apps/sim/components/settings/navigation.ts | 6 +- .../credential-group-detail.test.ts | 38 ----- .../components/credential-group-detail.tsx | 129 ++++++--------- .../components/credential-group-details.tsx | 4 +- apps/sim/hooks/queries/credential-groups.ts | 6 +- .../queries/dynamic-subblock-options.test.tsx | 110 +++++++++++++ .../hooks/queries/dynamic-subblock-options.ts | 71 ++++++++ .../lib/api/contracts/credential-groups.ts | 2 +- .../application/list-people.ts | 6 +- .../application/manage-enrollments.test.ts | 4 +- .../application/manage-enrollments.ts | 14 +- .../application/manage-groups.test.ts | 39 ++++- .../application/manage-groups.ts | 3 +- .../application/operations.ts | 4 +- .../lib/credential-groups/enrollments.test.ts | 109 +++++++++---- apps/sim/lib/credential-groups/enrollments.ts | 151 +++++++----------- .../slack-managed-user-scopes.ts | 6 + .../credential-groups/slack-managed-users.ts | 9 +- .../lib/credential-groups/slack-provider.ts | 9 +- apps/sim/triggers/slack/capabilities.test.ts | 33 +++- apps/sim/triggers/slack/capabilities.ts | 23 +++ .../emcn/src/components/chip-tag/chip-tag.tsx | 2 + .../workflow-block-view-interaction.test.tsx | 34 ++++ .../workflow-block/workflow-block-view.tsx | 2 + 38 files changed, 658 insertions(+), 358 deletions(-) create mode 100644 apps/sim/app/credential-groups/complete/page.tsx delete mode 100644 apps/sim/ee/credential-groups/components/credential-group-detail.test.ts create mode 100644 apps/sim/hooks/queries/dynamic-subblock-options.test.tsx create mode 100644 apps/sim/hooks/queries/dynamic-subblock-options.ts diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts index bad8457d5fd..7807219fc59 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts @@ -49,15 +49,14 @@ describe('credential group enrollment completion route', () => { mocks.complete.mockResolvedValue({ completed: true }) }) - it('submits a fully connected enrollment through its invitation principal', async () => { + it('submits optional account selections through its invitation principal', async () => { const enrollmentRequest = request() const response = await POST(enrollmentRequest, context) - expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe( - '/credential-groups/enroll/invitation-token?submitted=1' - ) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/credential-groups/complete') expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') expect(mocks.complete).toHaveBeenCalledWith({ principal, input: {}, @@ -65,13 +64,13 @@ describe('credential group enrollment completion route', () => { }) }) - it('redirects an incomplete enrollment without marking it complete', async () => { - mocks.complete.mockResolvedValue({ completed: false }) + it('returns to an unavailable enrollment when completion loses authorization', async () => { + mocks.complete.mockResolvedValue({ completed: null }) const response = await POST(request(), context) expect(response.headers.get('location')).toBe( - '/credential-groups/enroll/invitation-token?oauth=incomplete' + '/credential-groups/enroll/invitation-token?oauth=unavailable' ) }) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts index d33a0709cab..7c1ffec4029 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts @@ -6,7 +6,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' -import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' +import { + createCredentialGroupCompletionRedirect, + createCredentialGroupEnrollmentRedirect, +} from '@/app/api/credential-groups/enrollment-redirect' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -36,9 +39,6 @@ export const POST = withRouteHandler( if (completed === null) { return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) } - return createCredentialGroupEnrollmentRedirect( - token, - completed ? { submitted: '1' } : { oauth: 'incomplete' } - ) + return createCredentialGroupCompletionRedirect() } ) diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts index a72ec009906..b2a34e897bf 100644 --- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -1,5 +1,10 @@ import { NextResponse } from 'next/server' +const NO_STORE_REDIRECT_HEADERS = { + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', +} as const + export function createCredentialGroupEnrollmentRedirect( token: string, params: Record @@ -10,8 +15,17 @@ export function createCredentialGroupEnrollmentRedirect( status: 307, headers: { Location: location, - 'Cache-Control': 'no-store', - 'Referrer-Policy': 'no-referrer', + ...NO_STORE_REDIRECT_HEADERS, + }, + }) +} + +export function createCredentialGroupCompletionRedirect(): NextResponse { + return new NextResponse(null, { + status: 303, + headers: { + Location: '/credential-groups/complete', + ...NO_STORE_REDIRECT_HEADERS, }, }) } diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts index 1a3fc67b10f..6ccbd8882a0 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts @@ -1,27 +1,27 @@ -import { revokeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { deleteCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' import { defineInternalJsonRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { revokeCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments' +import { deleteCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' export const DELETE = defineInternalJsonRoute({ - contract: revokeCredentialGroupEnrollmentContract, + contract: deleteCredentialGroupEnrollmentContract, auth: internalSessionAuth, - operation: credentialGroupOperations.revokeEnrollment, + operation: credentialGroupOperations.deleteEnrollment, rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal Credential Group revocation behavior', + reason: 'Preserve existing internal Credential Group deletion behavior', }), errorPolicy: createCredentialGroupInternalErrorPolicy( - 'Failed to revoke credential group enrollment' + 'Failed to delete person from credential group' ), mapInput: ({ params }) => ({ assertedWorkspaceId: params.id, credentialGroupId: params.groupId, enrollmentId: params.enrollmentId, }), - useCase: revokeCredentialGroupEnrollmentSettings, + useCase: deleteCredentialGroupEnrollmentSettings, }) diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx new file mode 100644 index 00000000000..4841914b303 --- /dev/null +++ b/apps/sim/app/credential-groups/complete/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { AuthHeader, AuthShell } from '@/app/(auth)/components' + +export const metadata: Metadata = { + title: 'Accounts connected', + robots: { index: false, follow: false }, +} + +export default function CredentialGroupCompletePage() { + return ( + + + + ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx index 7df36ad1d79..23b2293cb9a 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx @@ -12,7 +12,7 @@ vi.mock('@sim/emcn', () => ({ import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' describe('OAuthConnectLink', () => { - it('presents enrollment authorization as Connect', () => { + it('presents an unconnected authorization as Connect', () => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') const root = createRoot(container) @@ -24,4 +24,17 @@ describe('OAuthConnectLink', () => { expect(link?.getAttribute('href')).toBe('/oauth/start') act(() => root.unmount()) }) + + it('presents a connected authorization as Reconnect', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + + act(() => root.render()) + + const link = container.querySelector('a') + expect(link?.textContent).toBe('Reconnect') + expect(link?.getAttribute('href')).toBe('/oauth/start') + act(() => root.unmount()) + }) }) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx index f460446b24f..ab2fcffbfba 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx @@ -4,12 +4,13 @@ import { chipVariants } from '@sim/emcn' interface OAuthConnectLinkProps { href: string + reconnect?: boolean } -export function OAuthConnectLink({ href }: OAuthConnectLinkProps) { +export function OAuthConnectLink({ href, reconnect = false }: OAuthConnectLinkProps) { return (
- Connect + {reconnect ? 'Reconnect' : 'Connect'} ) } diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 29e8ac35ac3..86f7699e9c7 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -68,7 +68,6 @@ const OAUTH_MESSAGES = { permissions_required: 'All requested permissions are required to connect this account.', configuration_changed: 'This credential option changed. Reload the page and try again.', rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', - incomplete: 'Connect every account before submitting.', unavailable: 'Account authorization is temporarily unavailable. Please try again.', failed: 'Account authorization did not complete. Please try again.', } as const @@ -109,7 +108,6 @@ export default async function CredentialGroupEnrollmentPage({ const resolvedSearchParams = await searchParams const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') - const submitted = getSearchParam(resolvedSearchParams, 'submitted') const oauthMessage = oauthStatus && oauthStatus in OAUTH_MESSAGES ? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES] @@ -118,22 +116,14 @@ export default async function CredentialGroupEnrollmentPage({ const connectedOption = connectedOptionId ? activeOptions.find((option) => option.id === connectedOptionId) : undefined - const notification = submitted - ? { message: 'Accounts submitted successfully.', variant: 'success' as const } - : connectedOptionId - ? { - message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, - variant: 'success' as const, - } - : oauthMessage - ? { message: oauthMessage, variant: 'error' as const } - : null - const allConnected = - activeOptions.length > 0 && - activeOptions.every( - (option) => option.connections.length === 1 && option.connections[0]?.status === 'connected' - ) - + const notification = connectedOptionId + ? { + message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + variant: 'success' as const, + } + : oauthMessage + ? { message: oauthMessage, variant: 'error' as const } + : null return ( {notification && ( @@ -167,6 +157,7 @@ export default async function CredentialGroupEnrollmentPage({ trailing={ } /> @@ -174,17 +165,15 @@ export default async function CredentialGroupEnrollmentPage({ })}
- {(allConnected || enrollment.status === 'completed') && ( -
- - {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} - -
- )} +
+ + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} + +
) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index ba923cc1ffd..449e1dbe83a 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -23,7 +23,12 @@ import { useCreateWorkspaceCredential, useUpdateWorkspaceCredential, } from '@/hooks/queries/credentials' -import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { + buildSlackManifest, + getSlackManagedUserAuthorizationManifestConfig, + SLACK_CAPABILITIES, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +} from '@/triggers/slack/capabilities' const logger = createLogger('ConnectSlackBotModal') @@ -31,11 +36,16 @@ const DEFAULT_APP_NAME = 'Sim Bot' const DONE_STEP = 4 /** Every capability is granted by default; trimming is an opt-in dropdown. */ -const ALL_CAPABILITIES = new Set(SLACK_CAPABILITIES.map((c) => c.id)) +const CUSTOM_BOT_CAPABILITIES = [ + ...SLACK_CAPABILITIES, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +] as const + +const ALL_CAPABILITIES = new Set(CUSTOM_BOT_CAPABILITIES.map((capability) => capability.id)) -const CAPABILITY_OPTIONS: ChipDropdownOption[] = SLACK_CAPABILITIES.map((c) => ({ - value: c.id, - label: c.label, +const CAPABILITY_OPTIONS: ChipDropdownOption[] = CUSTOM_BOT_CAPABILITIES.map((capability) => ({ + value: capability.id, + label: capability.label, })) interface ConnectSlackBotModalProps { @@ -118,10 +128,14 @@ export function ConnectSlackBotModal({ ) const manifestJson = useMemo(() => { + const managedUserAuthorization = selected.has(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id) + ? getSlackManagedUserAuthorizationManifestConfig(getBaseUrl()) + : undefined const manifest = buildSlackManifest(selected, { appName: appName.trim() || DEFAULT_APP_NAME, webhookUrl: requestUrl, description: appDescription, + ...(managedUserAuthorization ? { managedUserAuthorization } : {}), }) return JSON.stringify(manifest, null, 2) }, [selected, appName, appDescription, requestUrl]) @@ -269,7 +283,7 @@ function StepConfigure({ capabilityIds, onCapabilityIdsChange, }: StepConfigureProps) { - const allSelected = capabilityIds.length === SLACK_CAPABILITIES.length + const allSelected = capabilityIds.length === CUSTOM_BOT_CAPABILITIES.length return (
@@ -310,7 +324,7 @@ function StepConfigure({ {allSelected && (

Full access — the bot can read and send messages, react, upload files, and chat as an AI - assistant. + assistant, and people can authorize it through Credential Groups.

)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index ef9f1a9e5c5..9cdae3dcb08 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -69,7 +69,6 @@ describe('unified settings navigation', () => { expect(idsForSection('workspace')).toEqual([ 'teammates', 'secrets', - 'credential-groups', 'mcp', 'custom-tools', 'byok', @@ -77,6 +76,7 @@ describe('unified settings navigation', () => { 'workflow-mcp-servers', 'apikeys', 'sandboxes', + 'credential-groups', 'recently-deleted', ]) expect(idsForSection('organization')).toEqual([ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 406dd5f06a0..3babe49ba7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -100,6 +100,7 @@ import { getDependsOnFields } from '@/blocks/utils' import { useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { useCustomTools } from '@/hooks/queries/custom-tools' import { useDeployWorkflow } from '@/hooks/queries/deployments' +import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp' import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' import { useSandboxes } from '@/hooks/queries/sandboxes' @@ -380,6 +381,12 @@ const SubBlockRow = memo(function SubBlockRow({ () => resolveDropdownLabel(subBlock, rawValue), [subBlock, rawValue] ) + const dynamicOptionDisplayName = useDynamicSubBlockOptionDisplayName({ + workspaceId, + blockId, + subBlock, + value: rawValue, + }) const resolveContextValue = useCallback( (key: string): string | undefined => { @@ -568,6 +575,7 @@ const SubBlockRow = memo(function SubBlockRow({ const hydratedName = credentialName || dropdownLabel || + dynamicOptionDisplayName || variablesDisplayValue || filterDisplayValue || toolsDisplayValue || diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 5b1fbd3e7cc..5f821fe2db5 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -21,13 +21,14 @@ const CREDENTIAL_GROUP_CANONICAL_GROUP = { advancedIds: ['manualCredentialGroup'], } as const satisfies CanonicalGroup -async function fetchCachedCredentialGroups() { +async function fetchCachedCredentialGroups(signal?: AbortSignal) { const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId if (!workspaceId) return [] return getQueryClient().fetchQuery({ queryKey: credentialGroupKeys.list(workspaceId), - queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal), + queryFn: ({ signal: querySignal }) => + fetchCredentialGroupList(workspaceId, signal ?? querySignal), staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) } @@ -102,7 +103,7 @@ export const CredentialGroupBlock: BlockConfig = { - "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list. `, docsLink: 'https://docs.sim.ai/workflows/blocks/credential-group', - bgColor: '#7C3AED', + bgColor: '#8B5CF6', icon: GridOffset, canvasPresentation: { defaultTitle: 'Credential Groups', @@ -169,8 +170,8 @@ export const CredentialGroupBlock: BlockConfig = { .map((group) => ({ label: group.name, id: group.id })) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchOptionById: async (_blockId: string, optionId: string) => { - const groups = await fetchCachedCredentialGroups() + fetchOptionById: async (_blockId: string, optionId: string, signal?: AbortSignal) => { + const groups = await fetchCachedCredentialGroups(signal) const group = groups.find((candidate) => candidate.id === optionId) return group ? { label: group.name, id: group.id } : null }, @@ -219,10 +220,10 @@ export const CredentialGroupBlock: BlockConfig = { }) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchOptionById: async (blockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string, signal?: AbortSignal) => { const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) if (!credentialGroupId) return null - const groups = await fetchCachedCredentialGroups() + const groups = await fetchCachedCredentialGroups(signal) const group = groups.find((candidate) => candidate.id === credentialGroupId) const option = group?.options.find( (candidate) => diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index da688484873..3d9c312436e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -489,7 +489,8 @@ export interface SubBlockConfig { // Called when component mounts with a stored value to display the correct label before options load fetchOptionById?: ( blockId: string, - optionId: string + optionId: string, + signal?: AbortSignal ) => Promise<{ label: string; id: string } | null> /** * tool-input only: tool categories the consuming block cannot execute. They diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 6a66e3ed75a..c6afcf93ca2 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -93,6 +93,7 @@ describe('settings navigation boundaries', () => { 'secrets', 'byok', 'sandboxes', + 'credential-groups', 'custom-tools', 'mcp', 'workflow-mcp-servers', @@ -100,7 +101,6 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'forks', - 'credential-groups', 'custom-blocks', 'self-host', ]) @@ -492,10 +492,10 @@ describe('settings navigation boundaries', () => { 'teammates', 'byok', 'sandboxes', + 'credential-groups', 'workflow-mcp-servers', 'recently-deleted', 'forks', - 'credential-groups', 'custom-blocks', 'self-host', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index d7707e37bcb..f70cc4193d1 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -537,13 +537,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'credential-groups', description: 'Collect and manage OAuth credentials for people outside this workspace.', group: 'workspace', - order: 2, + order: 9, requiresEnterprise: true, allowNonOrgAdmin: true, selfHostedOverride: true, }, planes: { - workspace: { id: 'credential-groups', group: 'enterprise', order: 10 }, + workspace: { id: 'credential-groups', group: 'workspace', order: 4 }, }, }, { @@ -676,7 +676,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'recently-deleted', description: 'Restore items deleted in the last 30 days.', group: 'workspace', - order: 9, + order: 10, }, planes: { workspace: { id: 'recently-deleted', group: 'system', order: 9 }, diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts b/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts deleted file mode 100644 index e0f11afb3c8..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import type { CredentialGroupEnrollmentDetail } from '@/lib/api/contracts/credential-groups' -import { getEnrollmentStatus } from '@/ee/credential-groups/components/credential-group-detail' - -const ENROLLMENT: CredentialGroupEnrollmentDetail = { - id: 'enrollment-1', - credentialGroupId: 'group-1', - email: 'person@example.com', - status: 'in_progress', - expiresAt: '2026-08-13T00:00:00.000Z', - invitedAt: '2026-08-12T00:00:00.000Z', - sentAt: '2026-08-12T00:00:00.000Z', - completedAt: null, - revokedAt: null, - expired: true, - createdAt: '2026-08-12T00:00:00.000Z', - updatedAt: '2026-08-13T00:00:00.000Z', - connections: [{ provider: 'gmail', status: 'needs_reauth', count: 1 }], -} - -describe('Credential Group enrollment status', () => { - it('keeps expired incomplete invitations ahead of credential reauthorization', () => { - expect(getEnrollmentStatus(ENROLLMENT, ['gmail'])).toEqual({ - label: 'Expired', - invalid: true, - }) - }) - - it('shows reauthorization for completed enrollments after their invitation expires', () => { - expect(getEnrollmentStatus({ ...ENROLLMENT, status: 'completed' }, ['gmail'])).toEqual({ - label: 'Reconnect needed', - invalid: false, - }) - }) -}) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 672ed564a66..062b8783cd8 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -1,15 +1,14 @@ 'use client' import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn' -import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' +import { Chip, ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn' +import { ArrowLeft, Plus, User } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment, CredentialGroupEnrollmentConnection, - CredentialGroupEnrollmentDetail, } from '@/lib/api/contracts/credential-groups' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' @@ -34,8 +33,8 @@ import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/cr import { useCredentialGroupDetail, useDeleteCredentialGroup, + useDeleteCredentialGroupEnrollment, useResendCredentialGroupEnrollment, - useRevokeCredentialGroupEnrollment, useUpdateCredentialGroup, } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' @@ -53,35 +52,6 @@ const CREDENTIAL_GROUP_TABS = [ { value: 'people', label: 'People' }, ] as const -export function getEnrollmentStatus( - enrollment: CredentialGroupEnrollmentDetail, - activeProviders: CredentialGroupProvider[] -) { - if (enrollment.status === 'revoked') return { label: 'Revoked', invalid: false } - if (enrollment.status === 'delivery_failed') return { label: 'Delivery failed', invalid: true } - if (enrollment.status !== 'completed' && enrollment.expired) { - return { label: 'Expired', invalid: true } - } - const needsReauthorization = enrollment.connections.some( - (connection) => connection.status === 'needs_reauth' - ) - if (needsReauthorization) return { label: 'Reconnect needed', invalid: false } - const connectedProviders = new Set( - enrollment.connections - .filter((connection) => connection.status === 'active') - .map((connection) => connection.provider) - ) - const allProvidersConnected = - activeProviders.length > 0 && - activeProviders.every((provider) => connectedProviders.has(provider)) - if (enrollment.status === 'completed' && allProvidersConnected) { - return { label: 'Connected', invalid: false } - } - if (enrollment.status === 'completed') return { label: 'In progress', invalid: false } - if (enrollment.status === 'in_progress') return { label: 'In progress', invalid: false } - return { label: 'Invited', invalid: false } -} - interface EnrollmentConnectionsProps { connections: CredentialGroupEnrollmentConnection[] } @@ -124,7 +94,7 @@ export function CredentialGroupDetail({ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, }) const resend = useResendCredentialGroupEnrollment() - const revoke = useRevokeCredentialGroupEnrollment() + const deleteEnrollment = useDeleteCredentialGroupEnrollment() const updateGroup = useUpdateCredentialGroup() const deleteGroup = useDeleteCredentialGroup() const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { @@ -133,18 +103,14 @@ export function CredentialGroupDetail({ }) const [showInvite, setShowInvite] = useState(false) const [showDelete, setShowDelete] = useState(false) - const [revokingEnrollmentId, setRevokingEnrollmentId] = useState(null) + const [deletingEnrollmentId, setDeletingEnrollmentId] = useState(null) const [draftName, setDraftName] = useState(null) const [draftDescription, setDraftDescription] = useState(null) const credentialGroup = detail.data?.pages[0]?.credentialGroup const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] - const revokingEnrollment = revokingEnrollmentId - ? (enrollments.find((enrollment) => enrollment.id === revokingEnrollmentId) ?? null) + const deletingEnrollment = deletingEnrollmentId + ? (enrollments.find((enrollment) => enrollment.id === deletingEnrollmentId) ?? null) : null - const activeProviders = - credentialGroup?.options - .filter((option) => option.status === 'active') - .map((option) => option.provider) ?? [] const configurationReady = Boolean(credentialGroup?.options.length) && credentialGroup?.options.every( @@ -226,18 +192,18 @@ export function CredentialGroupDetail({ } } - const handleRevoke = async () => { - if (!revokingEnrollment) return + const handleDeleteEnrollment = async () => { + if (!deletingEnrollment) return try { - await revoke.mutateAsync({ + await deleteEnrollment.mutateAsync({ workspaceId, groupId, - enrollmentId: revokingEnrollment.id, + enrollmentId: deletingEnrollment.id, }) - toast.success(`Invitation revoked for ${revokingEnrollment.email}`) - setRevokingEnrollmentId(null) + toast.success(`${deletingEnrollment.email} deleted`) + setDeletingEnrollmentId(null) } catch (error) { - toast.error(getErrorMessage(error, 'Failed to revoke invitation')) + toast.error(getErrorMessage(error, 'Failed to delete person')) } } @@ -307,42 +273,31 @@ export function CredentialGroupDetail({ ) : (
{enrollments.map((enrollment) => { - const status = getEnrollmentStatus(enrollment, activeProviders) return ( } + icon={} iconFilled title={enrollment.email} description={ } - badge={ - - {status.label} - - } trailing={ - enrollment.status === 'revoked' ? undefined : ( - void handleResend(enrollment), - disabled: resend.isPending, - }, - { - label: 'Revoke', - destructive: true, - onSelect: () => setRevokingEnrollmentId(enrollment.id), - }, - ]} - /> - ) + void handleResend(enrollment), + disabled: resend.isPending, + }, + { + label: 'Delete', + destructive: true, + onSelect: () => setDeletingEnrollmentId(enrollment.id), + }, + ]} + /> } /> ) @@ -363,16 +318,24 @@ export function CredentialGroupDetail({ /> )} !open && !revoke.isPending && setRevokingEnrollmentId(null)} - srTitle='Revoke invitation' - title='Revoke invitation?' - text={`Revoke the invitation for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working immediately.`} + open={Boolean(deletingEnrollment)} + onOpenChange={(open) => + !open && !deleteEnrollment.isPending && setDeletingEnrollmentId(null) + } + srTitle='Delete person' + title='Delete person' + text={[ + `Delete ${deletingEnrollment?.email ?? 'this person'}?`, + { + text: ' Their private link will stop working and all accounts they connected to this Credential Group will be removed.', + error: true, + }, + ]} dismissLabel='Cancel' confirm={{ - label: revoke.isPending ? 'Revoking...' : 'Revoke', - onClick: handleRevoke, - disabled: revoke.isPending, + label: deleteEnrollment.isPending ? 'Deleting...' : 'Delete', + onClick: handleDeleteEnrollment, + disabled: deleteEnrollment.isPending, }} /> [number] = { provider, label: service.name, - required: true, + required: false, } return updateOptions([...existing, nextOption], `${service.name} added`) } diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index c231296b2cc..6aa7af64f29 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -6,10 +6,10 @@ import type { ContractBodyInput } from '@/lib/api/contracts' import { createCredentialGroupContract, deleteCredentialGroupContract, + deleteCredentialGroupEnrollmentContract, getCredentialGroupContract, inviteCredentialGroupEnrollmentsContract, resendCredentialGroupEnrollmentContract, - revokeCredentialGroupEnrollmentContract, startSlackCredentialGroupConfigurationContract, updateCredentialGroupContract, } from '@/lib/api/contracts/credential-groups' @@ -184,7 +184,7 @@ export function useResendCredentialGroupEnrollment() { }) } -export function useRevokeCredentialGroupEnrollment() { +export function useDeleteCredentialGroupEnrollment() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ @@ -196,7 +196,7 @@ export function useRevokeCredentialGroupEnrollment() { groupId: string enrollmentId: string }) => - requestJson(revokeCredentialGroupEnrollmentContract, { + requestJson(deleteCredentialGroupEnrollmentContract, { params: { id: workspaceId, groupId, enrollmentId }, }), onSettled: (_data, _error, variables) => { diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx new file mode 100644 index 00000000000..6fe376384ba --- /dev/null +++ b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx @@ -0,0 +1,110 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SubBlockConfig } from '@/blocks/types' +import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' + +interface HookHarness { + result: () => T + unmount: () => void +} + +function renderHookWithClient(useHook: () => T): HookHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root: Root = createRoot(container) + let latest!: T + + function Probe() { + latest = useHook() + return null + } + + act(() => { + root.render( + + + + ) + }) + + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } +} + +async function waitForResult(assertion: () => void) { + await act(async () => { + await vi.waitFor(assertion, { interval: 1 }) + }) +} + +describe('useDynamicSubBlockOptionDisplayName', () => { + const mounted: Array<() => void> = [] + + afterEach(() => { + mounted.splice(0).forEach((unmount) => unmount()) + vi.clearAllMocks() + }) + + it('hydrates a stored dynamic dropdown id to its label', async () => { + const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({ + id: optionId, + label: 'Customer support accounts', + })) + const subBlock = { + id: 'credentialGroup', + title: 'Credential Group', + type: 'dropdown', + options: [], + fetchOptionById, + } satisfies SubBlockConfig + + const hook = renderHookWithClient(() => + useDynamicSubBlockOptionDisplayName({ + workspaceId: 'workspace-1', + blockId: 'block-1', + subBlock, + value: 'group-uuid', + }) + ) + mounted.push(hook.unmount) + + await waitForResult(() => expect(hook.result()).toBe('Customer support accounts')) + + expect(fetchOptionById).toHaveBeenCalledWith('block-1', 'group-uuid', expect.any(AbortSignal)) + }) + + it('summarizes every selected dynamic option without dropping ids', async () => { + const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({ + id: optionId, + label: optionId === 'gmail' ? 'Gmail' : 'Slack', + })) + const subBlock = { + id: 'providerFilter', + title: 'Provider', + type: 'dropdown', + options: [], + multiSelect: true, + fetchOptionById, + } satisfies SubBlockConfig + + const hook = renderHookWithClient(() => + useDynamicSubBlockOptionDisplayName({ + workspaceId: 'workspace-1', + blockId: 'block-1', + subBlock, + value: ['gmail', 'slack'], + }) + ) + mounted.push(hook.unmount) + + await waitForResult(() => expect(hook.result()).toBe('Gmail, Slack')) + }) +}) diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.ts b/apps/sim/hooks/queries/dynamic-subblock-options.ts new file mode 100644 index 00000000000..16f50005a8c --- /dev/null +++ b/apps/sim/hooks/queries/dynamic-subblock-options.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { summarizeNames } from '@/lib/workflows/subblocks/display' +import type { SubBlockConfig } from '@/blocks/types' + +export const DYNAMIC_SUBBLOCK_OPTION_STALE_TIME = 30 * 1000 + +export const dynamicSubBlockOptionKeys = { + all: ['dynamic-subblock-options'] as const, + details: () => [...dynamicSubBlockOptionKeys.all, 'detail'] as const, + detail: (workspaceId?: string, blockId?: string, subBlockId?: string, optionId?: string) => + [ + ...dynamicSubBlockOptionKeys.details(), + workspaceId ?? '', + blockId ?? '', + subBlockId ?? '', + optionId ?? '', + ] as const, +} + +interface UseDynamicSubBlockOptionDisplayNameArgs { + workspaceId?: string + blockId?: string + subBlock?: SubBlockConfig + value: unknown +} + +function getResolvableOptionIds(value: unknown): string[] { + const values = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [] + return values.filter( + (entry): entry is string => + typeof entry === 'string' && + entry.length > 0 && + !entry.startsWith('<') && + !entry.includes('{{') + ) +} + +/** Resolves labels for dropdown options whose choices are loaded dynamically. */ +export function useDynamicSubBlockOptionDisplayName({ + workspaceId, + blockId, + subBlock, + value, +}: UseDynamicSubBlockOptionDisplayNameArgs): string | null { + const optionIds = useMemo(() => getResolvableOptionIds(value), [value]) + const fetchOptionById = subBlock?.fetchOptionById + const canResolve = Boolean(blockId && fetchOptionById && optionIds.length > 0) + + const queries = useQueries({ + queries: canResolve + ? optionIds.map((optionId) => ({ + queryKey: dynamicSubBlockOptionKeys.detail(workspaceId, blockId, subBlock?.id, optionId), + queryFn: ({ signal }) => { + if (!blockId || !fetchOptionById) { + throw new Error('Dynamic subblock option resolver is required') + } + return fetchOptionById(blockId, optionId, signal) + }, + staleTime: DYNAMIC_SUBBLOCK_OPTION_STALE_TIME, + })) + : [], + }) + + return useMemo(() => { + if (!canResolve || queries.length !== optionIds.length) return null + const labels = queries.map((query) => query.data?.label) + if (!labels.every((label): label is string => Boolean(label))) return null + return summarizeNames(labels) + }, [canResolve, optionIds.length, queries]) +} diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 37cc07a4fe9..fc9bf7dc522 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -351,7 +351,7 @@ export const resendCredentialGroupEnrollmentContract = defineRouteContract({ }, }) -export const revokeCredentialGroupEnrollmentContract = defineRouteContract({ +export const deleteCredentialGroupEnrollmentContract = defineRouteContract({ method: 'DELETE', path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]', params: credentialGroupEnrollmentParamsSchema, diff --git a/apps/sim/lib/credential-groups/application/list-people.ts b/apps/sim/lib/credential-groups/application/list-people.ts index 418ee854c6b..cfb5a479f99 100644 --- a/apps/sim/lib/credential-groups/application/list-people.ts +++ b/apps/sim/lib/credential-groups/application/list-people.ts @@ -69,7 +69,11 @@ export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({ } catch (error) { if (error instanceof CredentialGroupEnrollmentError) { throw new OrchestrationError( - error.status === 404 ? 'validation' : error.status === 409 ? 'conflict' : 'internal', + error.status === 400 || error.status === 404 + ? 'validation' + : error.status === 409 + ? 'conflict' + : 'internal', error.message ) } diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts index 10c044b9634..24bc1ba580a 100644 --- a/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts @@ -21,15 +21,15 @@ vi.mock('@/lib/credential-groups/enrollments', () => ({ CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error { constructor( message: string, - readonly status: 404 | 409 | 502 + readonly status: 400 | 404 | 409 | 502 ) { super(message) } }, + deleteCredentialGroupEnrollment: vi.fn(), inviteCredentialGroupEnrollments: mocks.invite, loadCredentialGroupInviterIdentity: mocks.loadInviter, resendCredentialGroupEnrollment: vi.fn(), - revokeCredentialGroupEnrollment: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.ts index 8896aa495c3..b4220f4a18c 100644 --- a/apps/sim/lib/credential-groups/application/manage-enrollments.ts +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.ts @@ -9,10 +9,10 @@ import { credentialGroupOperations } from '@/lib/credential-groups/application/o import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation' import { CredentialGroupEnrollmentError, + deleteCredentialGroupEnrollment, inviteCredentialGroupEnrollments, loadCredentialGroupInviterIdentity, resendCredentialGroupEnrollment, - revokeCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' interface CredentialGroupEnrollmentSettingsInput { @@ -109,20 +109,20 @@ export const resendCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace }), }) -export interface RevokeCredentialGroupEnrollmentSettingsInput +export interface DeleteCredentialGroupEnrollmentSettingsInput extends CredentialGroupEnrollmentSettingsInput { enrollmentId: string } -export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ - operation: credentialGroupOperations.revokeEnrollment, - resolveContext: ({ input }: { input: RevokeCredentialGroupEnrollmentSettingsInput }) => +export const deleteCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.deleteEnrollment, + resolveContext: ({ input }: { input: DeleteCredentialGroupEnrollmentSettingsInput }) => resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), authorizationOptions: {}, async execute({ input, context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) try { - const credentialGroupEnrollment = await revokeCredentialGroupEnrollment( + const credentialGroupEnrollment = await deleteCredentialGroupEnrollment( context.workspaceId, context.credentialGroupId, input.enrollmentId @@ -137,7 +137,7 @@ export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace resourceType: AuditResourceType.CREDENTIAL_GROUP, resourceId: context.credentialGroupId, resourceName: context.name, - description: `Revoked Credential Group access for ${result.credentialGroupEnrollment.email}`, + description: `Deleted ${result.credentialGroupEnrollment.email} from the Credential Group`, metadata: { enrollmentId: result.credentialGroupEnrollment.id }, }), }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts index 3a889b91aa0..fbc3a0d3a08 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -6,7 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ create: vi.fn(), + get: vi.fn(), list: vi.fn(), + listEnrollments: vi.fn(), requireAvailable: vi.fn(), resolveGroup: vi.fn(), resolvePermission: vi.fn(), @@ -22,11 +24,23 @@ vi.mock('@/lib/credential-groups/application/context', () => ({ vi.mock('@/lib/credential-groups/service', () => ({ createCredentialGroup: mocks.create, deleteCredentialGroup: vi.fn(), - getCredentialGroup: vi.fn(), + getCredentialGroup: mocks.get, listCredentialGroups: mocks.list, updateCredentialGroup: vi.fn(), })) +vi.mock('@/lib/credential-groups/enrollments', () => ({ + CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + } + }, + listCredentialGroupEnrollments: mocks.listEnrollments, +})) + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (permission: string | null, required: string) => permission === 'admin' || permission === required, @@ -35,6 +49,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ import { createCredentialGroupSettings, + getCredentialGroupSettings, listCredentialGroupSettings, } from '@/lib/credential-groups/application/manage-groups' @@ -62,10 +77,17 @@ describe('Credential Group Settings application operations', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolveGroup.mockResolvedValue({ + ...workspaceContext, + credentialGroupId: 'group-1', + name: 'Support', + }) mocks.resolvePermission.mockResolvedValue('admin') mocks.requireAvailable.mockResolvedValue(undefined) mocks.list.mockResolvedValue([]) mocks.create.mockResolvedValue({ id: 'group-1', name: 'Support' }) + mocks.get.mockResolvedValue({ id: 'group-1', name: 'Support' }) + mocks.listEnrollments.mockResolvedValue({ enrollments: [], nextCursor: null }) }) it('rejects an enrollment bearer before loading workspace settings', async () => { @@ -115,4 +137,19 @@ describe('Credential Group Settings application operations', () => { options: [], }) }) + + it('excludes deleted people from Credential Group settings', async () => { + await getCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { + assertedWorkspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + }, + }) + + expect(mocks.listEnrollments).toHaveBeenCalledWith('workspace-1', 'group-1', 50, undefined, { + statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'], + }) + }) }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index f8e9f420bf2..f3fbd3e6761 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -108,7 +108,8 @@ export const getCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ context.workspaceId, context.credentialGroupId, input.limit, - input.cursor + input.cursor, + { statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'] } ) return { credentialGroup, ...enrollmentPage } } catch (error) { diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index ecaa65a92fe..fdd1b524246 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -43,8 +43,8 @@ export const credentialGroupOperations = { workspaceApiKey: 'deny', principalKinds: ['session'], }), - revokeEnrollment: defineWorkspaceOperation({ - id: 'credential_groups.enrollments.revoke', + deleteEnrollment: defineWorkspaceOperation({ + id: 'credential_groups.enrollments.delete', minimumRole: 'admin', workspaceApiKey: 'deny', principalKinds: ['session'], diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 4a826736f2e..b7696e2ee69 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { inArray } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { adapter } = vi.hoisted(() => ({ @@ -31,6 +32,7 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ import { completeCredentialGroupEnrollment, + deleteCredentialGroupEnrollment, listCredentialGroupEnrollments, resendCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' @@ -132,6 +134,52 @@ describe('listCredentialGroupEnrollments', () => { ) expect(dbChainMockFns.select).not.toHaveBeenCalled() }) + + it('continues pagination when the cursor enrollment was deleted between pages', async () => { + const remainingEnrollment = { + ...ENROLLMENT, + id: 'enrollment-2', + invitedAt: new Date('2026-08-10T12:00:00.000Z'), + } + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }]) + .mockResolvedValueOnce([{ options: [] }]) + .mockResolvedValueOnce([{ enrollment: remainingEnrollment }]) + + const firstPage = await listCredentialGroupEnrollments('workspace-1', 'group-1', 1, undefined, { + statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'], + }) + if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor') + const result = await listCredentialGroupEnrollments( + 'workspace-1', + 'group-1', + 50, + firstPage.nextCursor, + { statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'] } + ) + + expect(firstPage.nextCursor).toEqual(expect.any(String)) + expect(result.enrollments).toHaveLength(1) + expect(result.enrollments[0]?.id).toBe(remainingEnrollment.id) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(4) + expect(inArray).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.status, [ + 'invited', + 'in_progress', + 'completed', + 'delivery_failed', + ]) + }) + + it('rejects a malformed enrollment cursor', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ options: [] }]) + + await expect( + listCredentialGroupEnrollments('workspace-1', 'group-1', 50, 'not-a-cursor') + ).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 }) + + expect(dbChainMockFns.limit).toHaveBeenCalledOnce() + }) }) describe('resendCredentialGroupEnrollment', () => { @@ -202,18 +250,29 @@ describe('resendCredentialGroupEnrollment', () => { }) }) +describe('deleteCredentialGroupEnrollment', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('deletes the enrollment and lets its foreign-key cascade remove managed credentials', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ email: ENROLLMENT.email }]) + dbChainMockFns.returning.mockResolvedValueOnce([ENROLLMENT]) + + const result = await deleteCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id) + + expect(result.id).toBe(ENROLLMENT.id) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalledOnce() + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) + }) +}) + describe('completeCredentialGroupEnrollment', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - adapter.getPolicy.mockResolvedValue({ - provider: 'gmail', - providerId: 'google-email', - authorizationAppId: 'google:client', - requiredScopes: ['scope'], - scopeVersion: 1, - }) - adapter.hasRequiredScopes.mockReturnValue(true) }) it('returns unavailable when revocation wins before completion acquires the lifecycle lock', async () => { @@ -238,18 +297,6 @@ describe('completeCredentialGroupEnrollment', () => { inviterName: 'Inviter', }, ]) - queueTableRows(schemaMock.credential, [ - { - optionId: 'option-1', - status: 'active', - scopeVersion: 1, - authorizationAppId: 'google:client', - grantedScopes: ['scope'], - displayName: 'alex@example.com', - metadata: { email: 'alex@example.com' }, - grantedAt: new Date('2026-08-11T12:05:00.000Z'), - }, - ]) queueTableRows(schemaMock.credentialGroupEnrollment, [ { status: 'revoked', @@ -264,10 +311,10 @@ describe('completeCredentialGroupEnrollment', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() }) - it('refuses completion when a connection needs reauthorization under the row locks', async () => { + it('completes when the recipient skips every optional account', async () => { queueTableRows(schemaMock.credentialGroupEnrollment, [ { - enrollment: { ...ENROLLMENT, status: 'in_progress' }, + enrollment: { ...ENROLLMENT, status: 'invited' }, groupId: 'group-1', groupName: 'Group', groupStatus: 'active', @@ -288,7 +335,7 @@ describe('completeCredentialGroupEnrollment', () => { ]) queueTableRows(schemaMock.credentialGroupEnrollment, [ { - status: 'in_progress', + status: 'invited', invitationTokenHash: ENROLLMENT.invitationTokenHash, invitationExpiresAt: ENROLLMENT.invitationExpiresAt, }, @@ -307,20 +354,12 @@ describe('completeCredentialGroupEnrollment', () => { ], }, ]) - queueTableRows(schemaMock.credential, [ - { - optionId: 'option-1', - status: 'needs_reauth', - scopeVersion: 1, - authorizationAppId: 'google:client', - grantedScopes: ['scope'], - grantedAt: new Date('2026-08-11T12:05:00.000Z'), - }, - ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: ENROLLMENT.id }]) - await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(false) + await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(true) - expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.credential) expect(adapter.getPolicy).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index aa3a8b24d9f..c3403131c01 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -126,13 +126,52 @@ async function lockCredentialGroupInvitationTarget( export class CredentialGroupEnrollmentError extends Error { constructor( message: string, - readonly status: 404 | 409 | 502 + readonly status: 400 | 404 | 409 | 502 ) { super(message) this.name = 'CredentialGroupEnrollmentError' } } +interface CredentialGroupEnrollmentCursor { + id: string + invitedAt: Date +} + +function encodeCredentialGroupEnrollmentCursor( + enrollment: Pick +): string { + return Buffer.from( + JSON.stringify({ id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() }) + ).toString('base64url') +} + +function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupEnrollmentCursor { + try { + const decoded: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) { + throw new Error('Cursor payload must be an object') + } + const { id, invitedAt: invitedAtValue } = decoded as Record + if ( + typeof id !== 'string' || + !id.trim() || + id !== id.trim() || + id.length > 128 || + typeof invitedAtValue !== 'string' + ) { + throw new Error('Cursor payload is malformed') + } + const invitedAt = new Date(invitedAtValue) + if (Number.isNaN(invitedAt.getTime()) || invitedAt.toISOString() !== invitedAtValue) { + throw new Error('Cursor timestamp is invalid') + } + return { id, invitedAt } + } catch { + throw new CredentialGroupEnrollmentError('Enrollment cursor is invalid', 400) + } +} + function hashInvitationToken(token: string): string { return sha256Hex(token) } @@ -443,30 +482,7 @@ export async function listCredentialGroupEnrollments( .filter((option) => option.status === 'active') .map((option) => option.id) - let cursorPosition: { id: string; invitedAt: Date } | undefined - if (cursor) { - const [cursorRow] = await db - .select({ id: credentialGroupEnrollment.id, invitedAt: credentialGroupEnrollment.invitedAt }) - .from(credentialGroupEnrollment) - .innerJoin( - credentialGroup, - eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) - ) - .where( - and( - eq(credentialGroupEnrollment.id, cursor), - eq(credentialGroup.id, groupId), - eq(credentialGroup.workspaceId, workspaceId), - filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined, - filters.statuses?.length - ? inArray(credentialGroupEnrollment.status, filters.statuses) - : undefined - ) - ) - .limit(1) - if (!cursorRow) throw new CredentialGroupEnrollmentError('Enrollment cursor not found', 404) - cursorPosition = cursorRow - } + const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor(cursor) : undefined const rows = await db .select({ enrollment: credentialGroupEnrollment }) @@ -538,12 +554,18 @@ export async function listCredentialGroupEnrollments( if (current) current.push(summary) else connectionsByEnrollment.set(connection.enrollmentId, [summary]) } + const nextCursorEnrollment = hasNextPage ? pageRows.at(-1)?.enrollment : undefined + if (hasNextPage && !nextCursorEnrollment) { + throw new Error('Credential group enrollment page is missing its cursor boundary') + } return { enrollments: pageRows.map(({ enrollment }) => ({ ...toCredentialGroupEnrollment(enrollment), connections: connectionsByEnrollment.get(enrollment.id) ?? [], })), - nextCursor: hasNextPage ? (pageRows.at(-1)?.enrollment.id ?? null) : null, + nextCursor: nextCursorEnrollment + ? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment) + : null, } } @@ -637,7 +659,7 @@ export async function resendCredentialGroupEnrollment( }) } -export async function revokeCredentialGroupEnrollment( +export async function deleteCredentialGroupEnrollment( workspaceId: string, groupId: string, enrollmentId: string @@ -659,10 +681,8 @@ export async function revokeCredentialGroupEnrollment( return db.transaction(async (tx) => { await lockCredentialGroupInvitationTarget(tx, groupId, existing.email) await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId) - const now = new Date() - const [revoked] = await tx - .update(credentialGroupEnrollment) - .set({ status: 'revoked', revokedAt: now, updatedAt: now }) + const [deleted] = await tx + .delete(credentialGroupEnrollment) .where( and( eq(credentialGroupEnrollment.id, enrollmentId), @@ -670,18 +690,8 @@ export async function revokeCredentialGroupEnrollment( ) ) .returning() - if (!revoked) throw new Error('Credential group enrollment update returned no row') - - await tx - .update(credential) - .set({ managedOauthStatus: 'revoked', revokedAt: now, updatedAt: now }) - .where( - and( - eq(credential.type, 'managed_oauth'), - eq(credential.credentialGroupEnrollmentId, enrollmentId) - ) - ) - return toCredentialGroupEnrollment(revoked) + if (!deleted) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + return toCredentialGroupEnrollment(deleted) }) } @@ -780,8 +790,8 @@ async function buildPublicCredentialGroupEnrollment( } } -/** Finalizes an enrollment only after every active credential option has one usable connection. */ -export async function completeCredentialGroupEnrollment(token: string): Promise { +/** Finalizes an enrollment after the recipient finishes their optional account selections. */ +export async function completeCredentialGroupEnrollment(token: string): Promise { const row = await resolvePublicEnrollmentRowByIdentity({ invitationTokenHash: hashInvitationToken(token), }) @@ -791,7 +801,7 @@ export async function completeCredentialGroupEnrollment(token: string): Promise< export async function completeAuthorizedCredentialGroupEnrollment( identity: PublicCredentialGroupEnrollmentIdentity -): Promise { +): Promise { const row = await resolveAuthorizedPublicEnrollmentRow(identity) if (!row) return null return completeResolvedCredentialGroupEnrollment(row, identity) @@ -800,7 +810,7 @@ export async function completeAuthorizedCredentialGroupEnrollment( async function completeResolvedCredentialGroupEnrollment( row: NonNullable>>, identity: PublicCredentialGroupEnrollmentIdentity -): Promise { +): Promise { return db.transaction(async (tx) => { await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id) const now = new Date() @@ -826,7 +836,6 @@ async function completeResolvedCredentialGroupEnrollment( const [group] = await tx .select({ status: credentialGroup.status, - options: credentialGroup.options, }) .from(credentialGroup) .where( @@ -839,52 +848,6 @@ async function completeResolvedCredentialGroupEnrollment( .for('update') if (!group || group.status !== 'active') return null - const activeOptions = group.options.filter((option) => option.status === 'active') - if (activeOptions.length === 0) return false - const connections = await tx - .select({ - optionId: credential.credentialGroupOptionId, - status: credential.managedOauthStatus, - scopeVersion: credential.managedOauthScopeVersion, - authorizationAppId: credential.authorizationAppId, - grantedScopes: credential.grantedScopes, - grantedAt: credential.grantedAt, - }) - .from(credential) - .where( - and( - eq(credential.type, 'managed_oauth'), - eq(credential.credentialGroupEnrollmentId, row.enrollment.id) - ) - ) - .for('update') - - for (const option of activeOptions) { - if (!isCredentialGroupProvider(option.provider)) { - throw new Error(`Unsupported Credential Group provider: ${option.provider}`) - } - const matchingConnections = connections.filter( - (connection) => connection.optionId === option.id - ) - if (matchingConnections.length !== 1) return false - const [connection] = matchingConnections - if (!connection || connection.status !== 'active' || !connection.grantedAt) return false - - const adapter = getCredentialGroupProviderAdapter(option.provider) - const policy = await adapter.getPolicy(option, { - workspaceId: identity.workspaceId, - credentialGroupId: identity.credentialGroupId, - executor: tx, - }) - if ( - connection.authorizationAppId !== policy.authorizationAppId || - connection.scopeVersion !== policy.scopeVersion || - !adapter.hasRequiredScopes(connection.grantedScopes ?? [], policy.requiredScopes) - ) { - return false - } - } - const [completed] = await tx .update(credentialGroupEnrollment) .set({ status: 'completed', completedAt: now, updatedAt: now }) diff --git a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts index 9e6ec9990ce..3358297be25 100644 --- a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts +++ b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts @@ -27,3 +27,9 @@ export const SLACK_MANAGED_USER_SCOPES = [ 'users:read', 'users:read.email', ] as const + +export const SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH = + '/api/credential-groups/slack-managed-users/callback' + +export const SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH = + '/api/credential-groups/oauth/slack/callback' diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index 3d7368d6f3c..8814d4f97f3 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -14,7 +14,10 @@ import { decryptCredentialGroupProviderConfiguration, encryptCredentialGroupProviderConfiguration, } from '@/lib/credential-groups/provider-configuration' -import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH, + SLACK_MANAGED_USER_SCOPES, +} from '@/lib/credential-groups/slack-managed-user-scopes' import type { DbOrTx } from '@/lib/db/types' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' @@ -410,7 +413,7 @@ export async function exchangeSlackUserAuthorization(params: { } export function getSlackManagedUsersRedirectUri(): string { - return `${getBaseUrl()}/api/credential-groups/slack-managed-users/callback` + return `${getBaseUrl()}${SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH}` } export async function createSlackManagedUsersAttempt(params: { @@ -668,7 +671,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { authorizationAppId, requiredScopes: [...SLACK_MANAGED_USER_SCOPES], scopeVersion, - required: existingOption?.required ?? true, + required: false, status: existingOption?.status ?? ('active' as const), } const options = existingOption diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts index d6633c862b7..4ee972dce31 100644 --- a/apps/sim/lib/credential-groups/slack-provider.ts +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -11,7 +11,10 @@ import { } from '@/lib/credential-groups/provider-adapter' import { getSlackCredentialGroupConfiguration } from '@/lib/credential-groups/provider-configuration' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' -import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, + SLACK_MANAGED_USER_SCOPES, +} from '@/lib/credential-groups/slack-managed-user-scopes' import { exchangeSlackUserAuthorization, getSlackCustomBotCredential, @@ -128,7 +131,7 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter 409 ) } - const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + const redirectUri = `${getBaseUrl()}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}` return { redirectUri, buildAuthorizationUrl: ({ state }) => { @@ -148,7 +151,7 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter credentialGroupId: context.credentialGroupId, slackBotCredentialId: context.option.slackBotCredentialId, }) - const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + const redirectUri = `${getBaseUrl()}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}` if ( currentPolicy.authorizationAppId !== policy.authorizationAppId || attempt.redirectUri !== redirectUri diff --git a/apps/sim/triggers/slack/capabilities.test.ts b/apps/sim/triggers/slack/capabilities.test.ts index 4e965129564..b1b2615174f 100644 --- a/apps/sim/triggers/slack/capabilities.test.ts +++ b/apps/sim/triggers/slack/capabilities.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { buildSlackManifest } from '@/triggers/slack/capabilities' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + buildSlackManifest, + getSlackManagedUserAuthorizationManifestConfig, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +} from '@/triggers/slack/capabilities' const opts = { appName: 'Test Bot', webhookUrl: 'https://sim.test/api/webhooks/slack' } @@ -55,24 +60,38 @@ describe('buildSlackManifest - description', () => { }) describe('buildSlackManifest - managed users', () => { + it('defines managed user authorization as enabled by default', () => { + expect(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY).toMatchObject({ + id: 'managed_user_authorization', + defaultChecked: true, + }) + }) + it('adds user OAuth configuration and its bot prerequisite', () => { + const managedUserAuthorization = + getSlackManagedUserAuthorizationManifestConfig('https://sim.ai') const manifest = buildSlackManifest(new Set(['action_send']), { appName: 'Managed Slack', webhookUrl: 'https://sim.ai/api/webhooks/slack/custom/credential-id', - managedUserAuthorization: { - redirectUrls: ['https://sim.ai/setup', 'https://sim.ai/connect'], - userScopes: ['im:history', 'users:read'], - }, + managedUserAuthorization, }) expect(manifest).toMatchObject({ oauth_config: { - redirect_urls: ['https://sim.ai/setup', 'https://sim.ai/connect'], + redirect_urls: [ + 'https://sim.ai/api/credential-groups/slack-managed-users/callback', + 'https://sim.ai/api/credential-groups/oauth/slack/callback', + ], scopes: { bot: ['chat:write', 'users:read'], - user: ['im:history', 'users:read'], + user: [...SLACK_MANAGED_USER_SCOPES].sort(), }, }, }) }) + + it('omits managed user OAuth configuration when disabled', () => { + const manifest = buildSlackManifest(new Set(['action_send']), opts) + expect(manifest.oauth_config).toEqual({ scopes: { bot: ['chat:write'] } }) + }) }) diff --git a/apps/sim/triggers/slack/capabilities.ts b/apps/sim/triggers/slack/capabilities.ts index b2499064062..63b4f854ccc 100644 --- a/apps/sim/triggers/slack/capabilities.ts +++ b/apps/sim/triggers/slack/capabilities.ts @@ -1,3 +1,9 @@ +import { + SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH, + SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, + SLACK_MANAGED_USER_SCOPES, +} from '@/lib/credential-groups/slack-managed-user-scopes' + /** * Slack app capabilities that can be toggled on in the manifest generator. * @@ -206,6 +212,23 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ }, ] as const +export const SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY = { + id: 'managed_user_authorization', + label: 'Managed user authorization', + description: 'Let people authorize this Slack app for use in Credential Groups.', + defaultChecked: true, +} as const + +export function getSlackManagedUserAuthorizationManifestConfig(baseUrl: string) { + return { + redirectUrls: [ + `${baseUrl}${SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH}`, + `${baseUrl}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}`, + ], + userScopes: SLACK_MANAGED_USER_SCOPES, + } +} + const WEBHOOK_URL_PLACEHOLDER = '' export interface BuildManifestOptions { diff --git a/packages/emcn/src/components/chip-tag/chip-tag.tsx b/packages/emcn/src/components/chip-tag/chip-tag.tsx index 479a27d6132..cd64a5405ae 100644 --- a/packages/emcn/src/components/chip-tag/chip-tag.tsx +++ b/packages/emcn/src/components/chip-tag/chip-tag.tsx @@ -84,6 +84,7 @@ const chipTagVariants = cva( green: '', yellow: '', purple: '', + identity: '', content: '', }, brandForeground: { @@ -113,6 +114,7 @@ const chipTagVariants = cva( { variant: 'workflow', tone: 'green', className: 'bg-[#188F00] text-[#F8F8F8]' }, { variant: 'workflow', tone: 'yellow', className: 'bg-[#FFEF08] text-[#1A1A1A]' }, { variant: 'workflow', tone: 'purple', className: 'bg-[#AA00FF] text-[#F8F8F8]' }, + { variant: 'workflow', tone: 'identity', className: 'bg-[#8B5CF6] text-[#F8F8F8]' }, { variant: 'workflow', tone: 'content', className: 'bg-[#007E80] text-[#FFFFFF]' }, { variant: 'brand', brandForeground: 'light', className: 'text-[#FFFFFF]' }, { variant: 'brand', brandForeground: 'dark', className: 'text-[#000000]' }, diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx index 5ac534d06b3..d3d3ca40467 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view-interaction.test.tsx @@ -226,6 +226,7 @@ describe('WorkflowTypeTag colors', () => { expect(getWorkflowTypeRole('api')).toBe('interface') expect(getWorkflowTypeRole('condition')).toBe('logic') expect(getWorkflowTypeRole('credential')).toBe('state') + expect(getWorkflowTypeRole('credential_group')).toBe('identity') expect(getWorkflowTypeRole('router_v2')).toBe('flow') expect(getWorkflowTypeRole('table')).toBe('records') expect(getWorkflowTypeRole('a2a')).toBe('neutral') @@ -244,6 +245,10 @@ describe('WorkflowTypeTag colors', () => { expect(getWorkflowTypeAccent('parallel')).toEqual({ variant: 'workflow', tone: 'ash' }) expect(getWorkflowTypeAccent('router')).toEqual({ variant: 'workflow', tone: 'ash' }) expect(getWorkflowTypeAccent('condition')).toEqual({ variant: 'workflow', tone: 'orange' }) + expect(getWorkflowTypeAccent('credential_group')).toEqual({ + variant: 'workflow', + tone: 'identity', + }) expect(getWorkflowTypeAccent('image_generator_v2')).toEqual({ variant: 'workflow', tone: 'purple', @@ -272,6 +277,7 @@ describe('WorkflowTypeTag colors', () => { <> + ) ) @@ -284,6 +290,34 @@ describe('WorkflowTypeTag colors', () => { 'bg-[#AA00FF]', 'text-[#F8F8F8]' ) + expect(host.querySelector('[data-workflow-type-icon="credential_group"]')).toHaveClass( + 'bg-[#8B5CF6]', + 'text-[#F8F8F8]' + ) + }) + + it('renders the Credential Groups header tag with its purple identity fill', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + mountedRoots.add(root) + mountedHosts.add(host) + + act(() => + root.render( + + ) + ) + + expect(host.querySelector('[data-workflow-type-accent="credential_group"]')).toHaveClass( + 'bg-[#8B5CF6]', + 'text-[#F8F8F8]' + ) }) it('uses the provider background with a contrasting shared icon and label color', () => { diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index 71c7b5ab6d9..1a4dcae994d 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -105,6 +105,7 @@ const WORKFLOW_ROLE_ACCENTS = { state: { variant: 'workflow', tone: 'yellow' }, flow: { variant: 'workflow', tone: 'ash' }, records: { variant: 'workflow', tone: 'green' }, + identity: { variant: 'workflow', tone: 'identity' }, neutral: { variant: 'workflow', tone: 'neutral' }, generative: { variant: 'workflow', tone: 'purple' }, knowledge: { variant: 'workflow', tone: 'content' }, @@ -118,6 +119,7 @@ const WORKFLOW_TYPE_ROLES = { api: 'interface', condition: 'logic', credential: 'state', + credential_group: 'identity', deployments: 'neutral', enrichment: 'knowledge', evaluator: 'logic', From cfb99e19a26ac1a001da20f000c5e01a14316c7b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 01:37:32 -0700 Subject: [PATCH 083/103] fix(tables): auto-scroll during column drag (#6722) * fix(tables): auto-scroll during column drag * fix(tables): keep drag targets aligned while scrolling * fix(tables): preserve column targets across drag surface * fix(tables): align workflow group drop indicators --- .../table-grid/headers/column-header-menu.tsx | 2 + .../headers/workflow-group-meta-cell.tsx | 14 +- .../components/table-grid/table-grid.tsx | 265 ++++++++++++------ .../components/table-grid/utils.test.ts | 50 ++++ .../[tableId]/components/table-grid/utils.ts | 37 +++ 5 files changed, 279 insertions(+), 89 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 74b6e297b7a..04025f40920 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -252,6 +252,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ return ( ghost.parentNode?.removeChild(ghost)) - onDragStart(columnName) + onDragStart(columnKey) } function handleDragOver(e: React.DragEvent) { - if (!onDragOver || !columnName) return + if (!onDragOver) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const midX = rect.left + rect.width / 2 const side = e.clientX < midX ? 'left' : 'right' - onDragOver(columnName, side) + onDragOver(columnKey, side) } function handleDragEnd() { @@ -457,6 +459,8 @@ export function WorkflowGroupMetaCell({ return ( `. @@ -532,6 +535,8 @@ export function TableGrid({ const seededLayoutKeyRef = useRef(null) const containerRef = useRef(null) const scrollRef = useRef(null) + const columnDragPointerXRef = useRef(null) + const columnDragScrollFrameRef = useRef(null) const theadRef = useRef(null) const tbodyRef = useRef(null) const isDraggingRef = useRef(false) @@ -1763,54 +1768,176 @@ export function TableGrid({ ) }, []) - const handleColumnDragStart = useCallback((columnName: string) => { - setDragColumnName(columnName) - setSelectionAnchor(null) - setSelectionFocus(null) - setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) - setIsColumnSelection(false) + const stopColumnDragAutoScroll = useCallback(() => { + columnDragPointerXRef.current = null + if (columnDragScrollFrameRef.current !== null) { + cancelAnimationFrame(columnDragScrollFrameRef.current) + columnDragScrollFrameRef.current = null + } }, []) - const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { - const dragged = dragColumnNameRef.current - const cols = schemaColumnsRef.current - const targetCol = cols.find((c) => getColumnId(c) === columnName) - const targetGid = targetCol?.workflowGroupId + const handleColumnDragLeave = useCallback(() => { + dropTargetColumnNameRef.current = null + setDropTargetColumnName(null) + }, []) + + const updateColumnDropTarget = useCallback( + (columnName: string, side: 'left' | 'right') => { + const dragged = dragColumnNameRef.current + if (!dragged) return - // Suppress drop targeting while hovering siblings of the dragged column's - // own group: reordering inside a group is meaningless (the group renders - // as a unit) and the chasing indicator just flickers. - if (dragged) { + const cols = schemaColumnsRef.current const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId - if (draggedGid && draggedGid === targetGid) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) + const targetGid = cols.find((c) => getColumnId(c) === columnName)?.workflowGroupId + if ( + (draggedGid && draggedGid === targetGid) || + pinnedColumnsRef.current.includes(dragged) !== pinnedColumnsRef.current.includes(columnName) + ) { + handleColumnDragLeave() return } + + if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return + dropTargetColumnNameRef.current = columnName + dropSideRef.current = side + setDropTargetColumnName(columnName) + setDropSide(side) + }, + [handleColumnDragLeave] + ) + + function updateColumnDropTargetAtX(pointerX: number) { + const thead = theadRef.current + const scrollEl = scrollRef.current + const headerRow = thead?.rows.item((thead?.rows.length ?? 0) - 1) + if (!thead || !scrollEl || !headerRow) { + handleColumnDragLeave() + return + } + + const headerRowRect = headerRow.getBoundingClientRect() + const headerY = headerRowRect.top + headerRowRect.height / 2 + const hoveredElement = document.elementFromPoint(pointerX, headerY) + let header = hoveredElement?.closest('th[data-column-drag-target]') ?? null + if (!header || !headerRow.contains(header)) { + const scrollRect = scrollEl.getBoundingClientRect() + const pinnedRight = Math.min(scrollRect.right, scrollRect.left + pinnedStickyLeftEdge) + let nearestDistance = Number.POSITIVE_INFINITY + header = null + + for (const candidate of headerRow.querySelectorAll( + 'th[data-column-drag-target]' + )) { + const candidateName = candidate.dataset.columnDragTarget + if (!candidateName) continue + + const rect = candidate.getBoundingClientRect() + const isPinned = pinnedColumnsRef.current.includes(candidateName) + const left = Math.max(rect.left, isPinned ? scrollRect.left : pinnedRight) + const right = Math.min(rect.right, isPinned ? pinnedRight : scrollRect.right) + if (right <= left) continue + + const distance = pointerX < left ? left - pointerX : pointerX > right ? pointerX - right : 0 + if (distance < nearestDistance) { + nearestDistance = distance + header = candidate + } + } } - // Reorder is restricted to within a single zone so a cross-zone drop - // indicator never appears for an insertion the grid would refuse. - if (dragged) { - const pinned = pinnedColumnsRef.current - if (pinned.includes(dragged) !== pinned.includes(columnName)) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return + if (!header) { + handleColumnDragLeave() + return + } + + let columnName = header.dataset.columnDragTarget + if (!columnName) { + handleColumnDragLeave() + return + } + + const targetGroupId = header.dataset.columnDragGroup + let { left, right } = header.getBoundingClientRect() + if (targetGroupId) { + const targetColumn = columnsRef.current.find((column) => column.key === columnName) + const groupStart = targetColumn + ? columnsRef.current[targetColumn.groupStartColIndex] + : undefined + if (!groupStart || groupStart.workflowGroupId !== targetGroupId) { + throw new Error(`Missing rendered start column for workflow group ${targetGroupId}`) + } + columnName = groupStart.key + + const groupHeaders = thead.querySelectorAll('th[data-column-drag-group]') + for (const groupHeader of groupHeaders) { + if (groupHeader.dataset.columnDragGroup !== targetGroupId) continue + const rect = groupHeader.getBoundingClientRect() + left = Math.min(left, rect.left) + right = Math.max(right, rect.right) } } - // Workflow groups: skip per-`` writes and let `handleScrollDragOver` - // do the bookkeeping. The scroll handler computes side from the group's - // full bounds, so it stays stable across sibling cursor moves; the per-th - // events would otherwise oscillate name + side as the cursor crosses each - // sibling's midpoint. - if (targetGid) return + updateColumnDropTarget(columnName, pointerX < left + (right - left) / 2 ? 'left' : 'right') + } - if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return - setDropTargetColumnName(columnName) - setDropSide(side) - }, []) + function startColumnDragAutoScroll(pointerX: number) { + columnDragPointerXRef.current = pointerX + if (columnDragScrollFrameRef.current !== null) return + + const tick = () => { + columnDragScrollFrameRef.current = null + const scrollEl = scrollRef.current + const currentPointerX = columnDragPointerXRef.current + if (!scrollEl || currentPointerX === null || !dragColumnNameRef.current) return + + const scrollRect = scrollEl.getBoundingClientRect() + const velocity = horizontalEdgeScrollVelocity({ + pointerX: currentPointerX, + visibleLeft: scrollRect.left + pinnedStickyLeftEdge, + visibleRight: scrollRect.right, + hotZone: COLUMN_DRAG_SCROLL_HOT_ZONE_PX, + maxVelocity: COLUMN_DRAG_SCROLL_MAX_VELOCITY_PX, + }) + if (velocity === 0) return + + const previousScrollLeft = scrollEl.scrollLeft + scrollEl.scrollLeft += velocity + if (scrollEl.scrollLeft !== previousScrollLeft) { + updateColumnDropTargetAtX(currentPointerX) + columnDragScrollFrameRef.current = requestAnimationFrame(tick) + } + } + + columnDragScrollFrameRef.current = requestAnimationFrame(tick) + } + + useEffect(() => stopColumnDragAutoScroll, [stopColumnDragAutoScroll]) + + const handleColumnDragStart = useCallback( + (columnName: string) => { + stopColumnDragAutoScroll() + dragColumnNameRef.current = columnName + setDragColumnName(columnName) + setSelectionAnchor(null) + setSelectionFocus(null) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) + setIsColumnSelection(false) + }, + [stopColumnDragAutoScroll] + ) + + const handleColumnDragOver = useCallback( + (columnName: string, side: 'left' | 'right') => { + const cols = schemaColumnsRef.current + const targetCol = cols.find((c) => getColumnId(c) === columnName) + if (targetCol?.workflowGroupId) return + updateColumnDropTarget(columnName, side) + }, + [updateColumnDropTarget] + ) const handleColumnDragEnd = useCallback(() => { + stopColumnDragAutoScroll() const dragged = dragColumnNameRef.current if (!dragged) { setDragColumnName(null) @@ -1945,64 +2072,27 @@ export function TableGrid({ setDragColumnName(null) setDropTargetColumnName(null) setDropSide('left') - }, []) - - const handleColumnDragLeave = useCallback(() => { - dropTargetColumnNameRef.current = null - setDropTargetColumnName(null) - }, []) + }, [stopColumnDragAutoScroll]) function handleScrollDragOver(e: React.DragEvent) { - if (!dragColumnNameRef.current) return + const draggedName = dragColumnNameRef.current + if (!draggedName) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const scrollEl = scrollRef.current if (!scrollEl) return - const scrollRect = scrollEl.getBoundingClientRect() - const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft - - const cols = columnsRef.current - const draggedGid = cols.find((c) => c.key === dragColumnNameRef.current)?.workflowGroupId - let left = checkboxColWidth - let i = 0 - while (i < cols.length) { - const col = cols[i] - // Treat fanned-out groups as monolithic drop targets; accumulate across siblings. - // Clamp `groupSize` to remaining columns: dragover fires constantly and can - // race a column removal where the cached `groupSize` outpaces `cols.length`. - const groupSize = Math.min(col.groupSize, cols.length - i) - let groupWidth = 0 - for (let j = 0; j < groupSize; j++) { - groupWidth += columnWidthsRef.current[cols[i + j].key] ?? COL_WIDTH - } - if (cursorX < left + groupWidth) { - // Inside the dragged column's own group → no-op drop, no indicator. - if (draggedGid && col.workflowGroupId === draggedGid) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - const pinned = pinnedColumnsRef.current - const draggedName = dragColumnNameRef.current - if (draggedName && pinned.includes(draggedName) !== pinned.includes(col.key)) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - const midX = left + groupWidth / 2 - const side = cursorX < midX ? 'left' : 'right' - if (col.key !== dropTargetColumnNameRef.current || side !== dropSideRef.current) { - setDropTargetColumnName(col.key) - setDropSide(side) - } - return - } - left += groupWidth - i += groupSize + if (pinnedColumnsRef.current.includes(draggedName)) { + stopColumnDragAutoScroll() + } else { + startColumnDragAutoScroll(e.clientX) } + updateColumnDropTargetAtX(e.clientX) } function handleScrollDrop(e: React.DragEvent) { e.preventDefault() + stopColumnDragAutoScroll() } useEffect(() => { @@ -4275,7 +4365,12 @@ export function TableGrid({ {headerGroups.map((g) => { const firstCol = displayColumns[g.startColIndex] - const stickyLeft = firstCol ? pinnedOffsets.get(firstCol.key) : undefined + if (!firstCol) { + throw new Error( + `Missing display column for header group at index ${g.startColIndex}` + ) + } + const stickyLeft = pinnedOffsets.get(firstCol.key) if (g.kind === 'workflow') { const lastCol = displayColumns[g.startColIndex + g.size - 1] return ( @@ -4284,7 +4379,8 @@ export function TableGrid({ workflowId={g.workflowId} size={g.size} startColIndex={g.startColIndex} - columnName={firstCol?.name ?? ''} + columnName={firstCol.name} + columnKey={firstCol.key} column={firstCol} workflows={workflows} isGroupSelected={ @@ -4353,17 +4449,18 @@ export function TableGrid({ onDragLeave={ userPermissions.canEdit ? handleColumnDragLeave : undefined } - isPinned={firstCol ? pinnedColumnSet.has(firstCol.key) : false} + isPinned={pinnedColumnSet.has(firstCol.key)} onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined} stickyLeft={stickyLeft} isLastPinned={lastCol?.key === lastPinnedColKey} /> ) } - const isLastFrz = firstCol?.key === lastPinnedColKey + const isLastFrz = firstCol.key === lastPinnedColKey return ( Array.from({ length: count }, (_, i) => `r${i}`) +describe('horizontalEdgeScrollVelocity', () => { + const getVelocity = (pointerX: number) => + horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft: 140, + visibleRight: 900, + hotZone: 48, + maxVelocity: 14, + }) + + it('scrolls left when the pointer enters the visible edge after sticky columns', () => { + expect(getVelocity(140)).toBe(-14) + expect(getVelocity(164)).toBe(-7) + }) + + it('scrolls right at the opposite edge and stays still between edge zones', () => { + expect(getVelocity(876)).toBe(7) + expect(getVelocity(900)).toBe(14) + expect(getVelocity(500)).toBe(0) + }) + + it('stays still when pinned columns consume the visible viewport', () => { + expect( + horizontalEdgeScrollVelocity({ + pointerX: 100, + visibleLeft: 200, + visibleRight: 100, + hotZone: 48, + maxVelocity: 14, + }) + ).toBe(0) + }) + + it('uses the nearest edge when a narrow viewport would overlap both hot zones', () => { + const narrowVelocity = (pointerX: number) => + horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft: 100, + visibleRight: 140, + hotZone: 48, + maxVelocity: 14, + }) + + expect(narrowVelocity(105)).toBe(-11) + expect(narrowVelocity(120)).toBe(0) + expect(narrowVelocity(135)).toBe(11) + }) +}) + describe('selectedColumnIds', () => { it('returns the ids the range spans', () => { expect(selectedColumnIds(columns(5), { startCol: 1, endCol: 3 })).toEqual(['c1', 'c2', 'c3']) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index b2486fcf969..4f3e9282d17 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -31,6 +31,43 @@ export type RowSelection = export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } +interface HorizontalEdgeScrollVelocityInput { + pointerX: number + visibleLeft: number + visibleRight: number + hotZone: number + maxVelocity: number +} + +export function horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft, + visibleRight, + hotZone, + maxVelocity, +}: HorizontalEdgeScrollVelocityInput): number { + if (hotZone <= 0) throw new Error('hotZone must be greater than zero') + if (maxVelocity <= 0) throw new Error('maxVelocity must be greater than zero') + const visibleWidth = visibleRight - visibleLeft + if (visibleWidth <= 0) return 0 + + const edgeZone = Math.min(hotZone, visibleWidth / 2) + + const distanceFromLeft = pointerX - visibleLeft + if (distanceFromLeft < edgeZone) { + const intensity = 1 - Math.max(0, distanceFromLeft) / edgeZone + return -Math.ceil(intensity * maxVelocity) + } + + const distanceFromRight = visibleRight - pointerX + if (distanceFromRight < edgeZone) { + const intensity = 1 - Math.max(0, distanceFromRight) / edgeZone + return Math.ceil(intensity * maxVelocity) + } + + return 0 +} + export function rowSelectionIncludes(sel: RowSelection, id: string): boolean { if (sel.kind === 'all') return !sel.excluded?.has(id) if (sel.kind === 'some') return sel.ids.has(id) From 337a53f12c3b834a14f0588e9dd5638f8056f712 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 01:51:30 -0700 Subject: [PATCH 084/103] feat(cli): Sim CLI with AWS-style profiles and a platform key exchange (#6147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat(usage): accept X-API-Key on usage-logs list + export /api/users/me/usage-logs and /export now use checkHybridAuth — the same auth /api/users/me/usage-limits already accepts — so external monitors can read summary.bySourceCredits (the source breakdown of usage-limits' aggregate currentPeriodCost) instead of estimating Copilot spend by subtraction. Workspace-scoped keys are pinned to their own workspace's slice of the ledger: the filter defaults to the key's workspace and an explicit mismatch 403s. Both endpoints documented in openapi-core.json. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(cli): sim CLI with AWS-style profiles and a platform key exchange 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 * feat(billing): dedicated v2 usage endpoints; keep internal usage routes session-only Replaces the earlier X-API-Key enablement on /api/users/me/usage-logs with a dedicated public surface, so the internal Billing-settings endpoints can evolve with the UI while external monitors get a stable versioned contract: - GET /api/v2/billing/usage — current-billing-period summary with bySourceCredits (the source breakdown external monitors need to watch e.g. Copilot consumption without estimating by subtraction), plus limitCredits and plan - GET /api/v2/billing/usage/logs — cursor-paged credit ledger in the v2 envelope - workspace-scoped keys are pinned to their own workspace's slice; personal keys read the account ledger The public wire is credits-only: usage-logs rows now carry a hasCost boolean instead of dollarCost (the Billing UI only needed the >0 signal), and the rateLimit block is removed from the usage-limits response and docs (deploy-modal tab relabeled accordingly). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(cli): generate the CLI's v2 API from the route contracts, add tables 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 * fix(cli): make the generated v2 API a fixed point of the formatter 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 * fix(cli-auth): wait for the workspace list before allowing approval 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 * fix(cli-auth): name minted keys by timestamp, not date 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 * feat(docs): validate OpenAPI specs against the Zod contracts in CI The specs in apps/docs are hand-authored because they carry what Zod never defines — error envelopes, status codes, prose, examples — so they can't be generated; check:openapi validates them instead: - spec integrity: $refs resolve, operationIds unique, 2xx documented, no orphaned component schemas - v2 conventions: every /api/v2 operation documents 401 + 429 and every 4xx/5xx resolves to the canonical { error: { code, message } } envelope - contract cross-check: contracts are auto-discovered from lib/api/contracts/v2 (each carries its method + path); doc<->contract coverage both ways, query/body/response field diffs via z.toJSONSchema - examples: documented request/response examples must parse with the matching contract's actual Zod schemas First run caught real drift, fixed here: 16 stale orphaned schemas in the core spec, the v2 billing ops referencing v1-shaped error components, deploy/rollback examples missing the required nullable lifecycle keys, CreateTableBody missing folderId, a legacy-grammar delete-rows example, and four knowledge document ops missing their required workspaceId query param. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * fix(docs): recursive field diff in check:openapi + the deep drift it found A mutation test showed the doc<->contract field diff only compared top-level properties, so a typo inside the { data } envelope passed. The diff now descends through matching object properties and array items (both sides must expose a property set — passthrough contracts and prose-only docs end the descent instead of false-positive), with the Zod JSON-schema root doubling as the $defs context. Deep drift it immediately caught, fixed here: select-column config (options/multiple) missing from every tables column schema, AddColumnBody hand-rolling a third column shape (now composed from ColumnInput, with position/workflowGroupId as the per-op extensions the contracts actually admit), chunking strategyOptions undocumented, and the deployment lifecycle fields (activeDeployment/latestDeploymentAttempt) missing from DeploymentState. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * fix(security): close the triggerType rate-limit bypass on workflow execute Caller-supplied triggerType flowed unchecked into preprocessExecution, whose checkRateLimit default turns OFF for 'manual'/'chat' — so any API-key caller, and any anonymous public-API caller billed to the workspace owner, could execute unthrottled by sending {"triggerType":"manual"} (async runs also skipped the worker-side check via admissionCompleted). External callers may now only send the redundant 'api' value; internal JWT callers ('workflow'/'mcp') are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * refactor(execution): extract enqueue/status/cancel into shared libs Prepares the v2 execution surface: handleAsyncExecution's queue logic moves to lib/workflows/executor/enqueue-execution.ts (slot/claim semantics encoded in a discriminated outcome, not HTTP statuses), the execution-status read to execution-status.ts, and the order-sensitive cancel machinery to lib/execution/cancel-workflow-execution.ts. The v1 routes re-render identically — their suites pass unmodified. Also: preprocessExecution gains rateLimitCounter ('sync'|'async') and its 429 now carries code RATE_LIMIT_EXCEEDED + retryAfterMs (previously indistinguishable from the concurrency 429 and Retry-After was discarded); and the duplicate cancel contract in contracts/logs.ts is unified on the full 5-value reason enum — its narrower copy made requestJson throw a client ZodError when cancelling a paused HITL run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(execution): callable execution service + structured error classifier executeWorkflowService composes the same libs the v1 route holds inline (call-chain guard, execution-id claim, LoggingSession, preprocessing, deployed-state load + file-field processing, timeout-bound executeWorkflowCore, output hydration/compaction) for the deployed-state caller class — the seam the v2 execute route and in-process internal callers share, making the HTTP endpoint syntactic sugar. classifyExecutionError stops discarding the block context that buildBlockExecutionError already attaches at throw sites: failed runs now yield {message, code, blockId, blockName, blockType} with a stable append-only code enum (TIMEOUT/CANCELLED/USAGE_LIMIT_EXCEEDED/ INVALID_INPUT/BLOCK_EXECUTION_FAILED/CHILD_WORKFLOW_FAILED/ OUTPUT_TOO_LARGE/EXECUTION_FAILED), so callers route on error class instead of substring-matching messages — the single place raw errors are interpreted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(api): POST /api/v2/workflows/[id]/execute Thin route over executeWorkflowService: X-API-Key or anonymous public-API auth (sync/stream only for anonymous), strict body with body-flag async (no mode headers on v2), SSE passthrough for stream, and the execution resource response — executionId always present, in-band run failures are status:'failed' with the structured {message, code, blockId, blockName, blockType} error, sync timeout is status:'failed' + TIMEOUT instead of v1's 408, and a Response block's payload stays inside output (authors never control response status/headers on this origin). Async debits the async bucket and the 202 statusUrl points at the v2 executions resource. Adds CLIENT_CLOSED_REQUEST/SERVICE_UNAVAILABLE to the v2 error codes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(api): v2 executions status + cancel with queued backfill GET /api/v2/workflows/[id]/executions/[executionId] is the single status URL for sync and async runs: before the async worker writes the durable log row, status is backfilled from the job queue (deterministic job id) as 'queued'/'running' — closing v1's 202-to-pickup 404 window — and failed runs carry the structured error object. POST .../cancel renders the shared cancellation lib in the v2 envelope with the tightened 5-value reason enum. Both authenticate via the shared resolveV2WorkflowAccess (X-API-Key, authz masked as 404, allowPersonalApiKeys honored). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(execution): workflow tool + MCP bridge run in-process workflow_executor (workflow-as-agent-tool) short-circuits in executeTool through WorkflowBlockHandler — the same invocation boundary canvas child workflows use — mirroring the deployed_block_executor precedent. The MCP serve bridge calls executeWorkflowService directly instead of fetching its own execute endpoint; deployment-version pinning, MCP response-size rejection, and the actor override become typed options instead of header sniffing. Both callers drop the double admission slot and duplicate top-level log row the HTTP hop cost, and failed child runs now surface the structured error + child executionId so parents and MCP clients can route on error class and hand providers a reproducible handle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(infra): CORS + CSP coverage for the v2 execute path /api/v2/workflows/:id/execute gets the same wildcard-origin, credential-free CORS policy as v1 (the default credentialed policy would block browser API-key calls and open a cookie CSRF surface) with X-Sim-Stream-Protocol allowed and no X-Execution-Mode (async is body-selected on v2), plus the COEP/COOP/CSP header block. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(ui): deploy modal + copilot advertise the v2 execute surface All 20 API-tab snippets move to POST /api/v2/workflows/{id}/execute with the nested {"input": ...} body, async as the "async": true body flag (X-Execution-Mode gone), status polling against the v2 executions resource, the third tab renamed Usage and pointed at /api/v2/billing/usage, and {data} envelope unwraps in the printed responses. Fixes the latent baseUrl derivation (endpoint.split('/api/workflows/')) that would have silently built garbage URLs under a v2 endpoint, and deletes dead code (exampleCommand across 3 sites, getAsyncExampleTitle). Copilot deploy/manage/serializer endpoint builders and the api_trigger bestPractices example follow (the latter also drops its hardcoded staging host). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * docs(api): document the v2 execution surface Adds execute, execution status, and cancel to openapi-v2-workflows.json with the structured ExecutionError schema (append-only code enum + block attribution) and the ExecutionResource contract, documenting the rules that differ from v1: modes are body-selected, a failed run is HTTP 200 with status 'failed', an executionId always means data (never the error envelope), queued status is visible immediately, and Response-block payloads stay inside output. Registers the three pages in the generated workflows meta.json and bumps the route-count baseline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(api): gate the whole /api/v2 surface behind one flag; UI stays on v1 Every v2 route now runs exactly one check immediately after auth — v2ApiGateError — and answers 404 when the `v2-api` flag is off, so the surface is invisible until it is deliberately rolled out. The gate is keyed on userId only: a workspace/org-keyed check would have to read membership for a caller-supplied id before authorization runs, and its 404-vs-403 split would leak cohort membership (the trap the per-domain table gate worked around by running late). The two executions routes inherit it from the shared access resolver; the tables-specific gate is removed so no route checks twice. `tables-v2-api` stays, now gating only the internal predicate-grammar route /api/table/[tableId]/query — note v2 tables routes move to the unified flag, so enabling them is a `v2-api` decision now. Reverts the deploy modal, copilot handlers, and api_trigger example to the v1 execute endpoint: v1 works unchanged, and the UI must not advertise a surface most users would get a 404 from. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz * feat(cli): CLI contract for the v2 surface, incl. execution 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 * fix(executor): restore child-cost aggregation dropped by the staging merge Staging's custom-block rewrite deleted `aggregateChildCost` from workflow-handler.ts, and git merged that file cleanly — but this branch's workflow-tool-runner.ts, added for the v2 execute migration, still imports it. A silent semantic conflict: no marker, broken build. Taking staging's rewrite is correct, so the helper is defined locally in its one remaining consumer rather than resurrected in the file staging just rewrote. Same four lines over the still-exported `calculateCostSummary`, so a failed child workflow keeps billing the hosted-key spend it consumed instead of reporting $0. Co-Authored-By: Claude Opus 5 * feat(cli): yaml and text output formats `--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 * refactor(tables): make lib/table/orchestration the single implementation (#6134) * refactor(orchestration): move the shared error contract out of lib/workflows OrchestrationErrorCode and statusForOrchestrationError are the contract every lib/[resource]/orchestration module returns against, but they lived inside the workflows module, so resource-neutral code (lib/folders) already had to import from a workflow path. Moved to lib/core/orchestration/types. Adds a 'locked' class mapping to 423. Both tables and workflows have a lock that forbids a mutation, and each caller was translating that to a status itself. Co-Authored-By: Claude Opus 5 * refactor(tables): make lib/table/orchestration the single implementation Column update was implemented four times — the UI route, v1, v2, and the copilot table tool — each calling the same column services but owning its own guards, error mapping, and audit. The copies had drifted, and the drift was the bug: v2 was missing both guards, only the copilot copy minted stable option ids, and only v1/v2 audited. performUpdateTableColumn, performDeleteTable, and performDeleteTableRow now own that logic; all ten call sites reduce to auth, parse, call, render. The guards are asserted once in lib/table/orchestration rather than four times against four routes. Behavior this consolidates, previously true on only some paths: - The typeChanging guard. updateColumnType early-returns on an unchanged type and drops any options sent with it, so restating the current type alongside new options silently discarded them. v2 had no guard at all and, since its contract shares v1's body schema, accepted options and ignored them. - The select-unique guard. Each write is its own locked transaction, so a rename or type change paired with a constraint write that is going to fail commits first and then throws, half-applying the schema change. - Stable select-option ids. Cells reference the option id, so an edit that re-sends an option by name has to reuse it or every cell holding it is orphaned. Only the copilot path did this; normalizeSelectOptionsInput moves to lib/table/select-options and now covers every caller. It preserves a supplied id, so it is a no-op for the fully-formed options the HTTP contracts accept. - required forwarded into the type and options writes, so a conversion validates against the constraint the same request is setting. - An audit on every successful update. The UI route and the copilot tool emitted none. - Single-row delete through the row service. v2 did a raw db.delete, skipping assertRowDelete and deleteOrderedRow, so a delete-locked table returned 200 and the row-count bookkeeping never ran. - The delete actor handed to deleteTable, which audits only when a row was actually archived. v1 and v2 omitted it and audited themselves outside that check, emitting TABLE_DELETED for a no-op delete of an archived table. Failure classes come back as OrchestrationErrorCode; v2 renders them through a new v2ErrorForOrchestration, mirroring statusForOrchestrationError on the v1 and UI surfaces, so a given failure maps to the same status everywhere. Co-Authored-By: Claude Opus 5 * test(tables): bind the column-update tests to the orchestration function The base's route tests assert which column service each payload reaches — the behavior that now lives in performUpdateTableColumn. They mocked the `@/lib/table` barrel; the orchestration module imports the service directly, so they mock that too and keep asserting the same thing through the extracted implementation. The orchestration tests move onto the base's semantics: writes address the stable column id, a rename rides inside the write it accompanies rather than running first, and the currency guards replace the non-select options guard the service now owns. Co-Authored-By: Claude Opus 5 * chore(copilot): drop the column-type import the delegation made dead Co-Authored-By: Claude Opus 5 * refactor(tables): move the audit log out of the table service `lib/table/service.ts` wrote its own audit rows, so whether an operation was audited depended on which function a caller reached for rather than on a user having performed it. That is what let v1 and v2 audit a no-op delete, and what made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag. Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed call was logged against the table's *creator*. The copilot `mv` path passed no actor at all: renaming someone else's table recorded them as the renamer. Audit now lives in the orchestration functions — performDeleteTable, performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and the services just write. Internal callers (folder cascade, import rollback) keep calling the service and are silent by construction rather than by remembering to omit an argument. Two services now return what the audit needs: `deleteTable` reports whether it actually archived a row, so a repeat delete logs nothing; `updateTableLocks` returns the before/after locks, since only the locked write can observe the transition its description names. Co-Authored-By: Claude Opus 5 * fix(tables): restore audit provenance and conflict status in orchestration Moving the audits into the orchestration functions dropped three things the routes had been carrying, and added one the orchestration now owns twice. - The v1 and v2 column-update routes passed `request` to `recordAudit`, so their audit rows recorded the caller's IP and user-agent. The orchestration function had no way to receive it. Every table orchestration function now takes an optional `OrchestrationRequestContext` and every HTTP route forwards it; the copilot and VFS callers, which have no request, omit it. - `classifyTableMutation` matched `TableConflictError` on "already exists" appearing in the message and reported it as `validation`, turning the UI route's 409 on a duplicate table rename into a 400. It now matches the type, the way `performRestoreTable` already did. - `captureServerEvent` ran on every delete while the audit was gated on a row actually being archived, so a repeat delete of an archived table still reported `table_deleted`. Both now hang off the same evidence. - The copilot delete path kept its own `captureServerEvent` from when the service did not emit one, double-counting every copilot table delete. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a * fix(tables): say which type a no-op column update restated A copilot `update_column` payload whose only content was the column's current type used to return success with the live schema, while the v1, v2, and UI routes rejected the same payload with "No updates specified". Delegating to `performUpdateTableColumn` unified them onto the routes' rejection — correct, but the message tells the caller its request was empty when it named a type. The orchestration function now reports the same thing `updateColumnType` reports when it loses this race concurrently: the column is already that type, re-issue without the type change. An empty payload still reads "No updates specified". Drops the copilot's `outcome.table ?? tableForUpdate` fallback with it — the comment described the no-op that can no longer reach that line, and a success always carries a table. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a * refactor(tables): classify failures by type instead of by message text The table module decided HTTP statuses by searching error messages for phrases. `VALIDATION_MESSAGE_FRAGMENTS` and `ROW_WRITE_ERROR_PATTERNS` held 32 substrings between them, and fifteen more lists were inlined in routes — 83 matchers over 17 files, each its own copy of the guesswork and already drifted apart. It made message wording load-bearing: `TableRowLimitError`'s own doc comment noted that its text had to contain "row limit" for a route to answer 400, and adding "already exists" to a rename message silently demoted a 409 to a 400 (the bug fixed one commit ago, by adding another special case). Services now throw `OrchestrationError`, which carries the transport-neutral `OrchestrationErrorCode` the layers above already speak. Classification is one `instanceof` in `orchestrationErrorResponse` (UI + v1) and `v2CaughtOrchestrationError` (v2). Every pattern list is gone. Wording is free to change; an unclassified error still becomes a generic 500, which is what an unexpected fault should be. `asOrchestrationError` walks the `cause` chain rather than testing the caught value directly: drizzle wraps a throw raised inside a transaction callback in a `DrizzleQueryError` whose own message is the failed SQL, so a bare `instanceof` would drop every failure raised inside `withLockedTable`. That is the same reason `rootErrorMessage` had to dig for a root cause before. Three throws stay bare `Error` deliberately — `Table ID mismatch`, `Workspace ID mismatch`, and `Failed to build upsert conflict predicate` are internal invariants no consumer classified, and they keep falling through to a 500. `Insufficient capacity` was in the pattern list with no producer anywhere in the codebase. Status changes, all deliberate: - `'forbidden'` joins the code union so the table-row-limit ceiling keeps its 403; without it this refactor would have flattened it to 400. - import-async's table-limit rejection: 400 -> 403, matching the two other create routes it had drifted from. - Renaming a table to an invalid name: 500 -> 400. `validateTableName` messages don't contain "Invalid", so no matcher ever caught them. - Restoring a table that isn't archived, or into an archived workspace: 500 -> 400. - A duplicate *column* name stays `validation`/400 rather than becoming a 409 like a duplicate table name. Both v1 and the orchestration have always answered 400 for it; changing a published status is not this refactor's job. The twelve tests that changed were asserting the substring mechanism itself, constructing plain `Error`s with magic strings. They now assert the real contract, plus new cases pinning that identical wording carrying no classification stays internal and keeps its message off the wire. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a --------- Co-authored-by: Claude Opus 5 * refactor(cli): output format is a profile setting, not a flag 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 * feat(cli): runtime that builds every command from the contract 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 * fix(cli): review round 1 — flag lookup, terminal controls, download safety ## 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 * fix(cli): review round 2 — body-cursor paging, timestamp sanitization ## `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 * fix(cli): review round 3 — poll retry, download flush errors 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 * fix(cli): review round 4 — repeated flags encode per field kind `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 * fix(cli): review round 5 — header sanitization, auth ordering, stale suggestion ## 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 * feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials (#6150) * feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials * fix(api): correct credential role, skill permission bar, MCP url identity, and custom-tool conflict mapping * fix(api): align credential mutation gating, provider-outage status, and unique-violation conflicts * fix(api): close unique-violation, revival, orphan-write, and env-rename gaps * fix(api): treat every provider-outage code as unavailable on create and update * fix(credentials): use the shared outage predicate on the session update path * fix(contracts): anchor the predicate double-cast annotation to the cast `check:api-validation:strict` counted 9 unannotated double-casts against a baseline of 8, failing CI. The predicate leaf schema was annotated, but the annotation sat above the declaration while the checker anchors on the line carrying the cast — five lines below, at the close of the object literal. The scanner walks back at most three lines and stops at the first non-comment one, so it hit `value: z.unknown().optional(),` and never saw the reason. Splitting the object schema from the cast puts them adjacent, so the existing reason binds. No behavior change — the cast, the schema, and the reasoning are unchanged. Also lowers the rawJsonReads ratchet 6 -> 5 to match the current count, which had drifted down; leaving it high lets a removed raw read silently come back. Co-Authored-By: Claude Opus 5 * fix(skills): point the orchestration error contract at its moved module #6150 branched before #6134, so skill-lifecycle.ts imports @/lib/workflows/orchestration/types — the module #6134 moved to @/lib/core/orchestration/types. Git merged a file deletion on one side with a new file referencing it on the other: no textual conflict, broken build. Co-Authored-By: Claude Opus 5 * feat(cli): pick up the new v2 domains; discover modules instead of listing them 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 * fix(cli): render single-key resource envelopes, and column the new domains `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 * refactor(knowledge): make lib/knowledge/orchestration the single implementation (#6154) * refactor(knowledge): make lib/knowledge/orchestration the single implementation Knowledge base create was implemented four times — the internal route, v1, v2, and the copilot tool — and the orchestration around the shared write had drifted. Extract it the same way lib/table/orchestration was: services write, orchestration decides which writes run, guards them, audits them, and returns a transport-neutral failure. Behavior converged, not preserved: - One chunking default (DEFAULT_CHUNKING_CONFIG). The agent defaulted minSize to 1 against the API's 100, so identical input produced differently-chunked knowledge bases depending on who created it. The agent path now chunks at 100. - Every successful mutation is audited inside the orchestration function. The copilot tool called recordAudit zero times, so agent-created knowledge bases, document uploads, updates and deletes left no audit trail at all. - Failures classify by class, not by message text. The knowledge service errors are OrchestrationError subclasses and storage-quota rejections throw a shared StorageLimitExceededError, replacing four separate message greps for "already exists" / "does not have permission" / "storage limit". delete_connector reported the opposite of what happened. It reached the route through an internal HTTP self-call that sent no query string, so the route's keep-documents default always applied while the agent told the user the documents had been removed. The self-call is gone — all four connector operations run in-process — and the orchestration returns the real counts. Also: - OrchestrationErrorCode gains 'payload_too_large' (413 / PAYLOAD_TOO_LARGE). Without it, dropping the storage-limit message match would have regressed the documented 413 on knowledge base create and document upload to a 500. - messageForOrchestrationError renders a route's own wording for an unclassified fault, so a driver's message no longer reaches the client on a 500. - v1 and v2 knowledge base update now forward actorUserId, which the service requires for a workspace move; both omitted it. - The connector DELETE route reads deleteDocuments through parseRequest. Its contract declared z.boolean(), which would have rejected the string a query param actually is. - Drop the 409 from POST /api/v2/knowledge/{id}/documents in the OpenAPI spec. Nothing on the upload path throws a conflict; it was only ever reachable by the message match this change removes. Behavior change worth noting: a v1/v2 PUT carrying only the workspaceId scope field and no actual updates now returns 400 rather than 200 with the unchanged knowledge base. Deliberately deferred: document update remains internal-only. Extracting performUpdateKnowledgeDocument makes exposing it on v1/v2 a contract and a route away, but that is a new public surface rather than part of this consolidation. * fix(knowledge): make connector create atomic and stop flattening failures Review round 1 on #6154. - Resolve the billing payer before the connector is committed, not after. A malformed attribution header rejected post-commit left a live connector behind a 500, and a retry created a duplicate plus duplicate sync work. Manual sync resolves before writing its audit for the same reason. - Let the source-config validator carry its own failure class. Collapsing every rejection to `validation` flattened the connector PATCH route's 401 (stale stored credential) and 409 (missing workspace context) into a 400. - Add `unauthorized` to OrchestrationErrorCode. It is the class that 401 was already expressing on this route, and the v2 vocabulary already had UNAUTHORIZED; only the shared union was missing it. - Report a knowledge base that exists but failed to archive as failed, with the reason, rather than as not found. The copilot delete loop folded every non-not-found failure into `notFound`, telling the user it was never there. - Route copilot failures through the same message helper the HTTP surfaces use, so an unclassified fault's raw text (a driver's failed SQL) no longer reaches the agent verbatim while the UI and public APIs get the generic wording. * fix(cli): stop dropping nested fields, and emit exports as documents `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 * feat(cli): JSON flags accept @file and @- alongside inline JSON 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 * feat(api): expand the public v2 files surface (#6160) * feat(api): expand the public v2 files surface Adds folder support, rename/restore, move, bulk archive, share, and content replace to /api/v2/files, so managing files by API no longer stops at upload + download + archive-one. Routes are thin: auth -> parse -> perform* -> serialize. Share and content replace get their orchestration extracted first so the session routes and the public ones cannot diverge on the effective-authType resolution, the EE public-sharing gate, or the storage-quota classification. Presigned upload stays session-only: presign does an advisory quota check and the real debit happens in the separate register step, so a caller that never registers leaves unaccounted bytes with no reaper. The buffered multipart path debits inside uploadWorkspaceFile's own transaction. * fix(files): classify folder and content failures instead of 500ing them Bugbot round 1. The v2 routes map errorCode straight to a status, so every manager failure that arrived unclassified became a 500 for what is really a caller-fixable 400 or 404. - Folder manager throws OrchestrationError: missing target/folder -> not_found, reparent cycle / self-parent / restore-into-archived-workspace -> validation. - File manager does the same for the in-transaction 'File not found' paths that the earlier pass missed. - updateWorkspaceFileContent's outer catch re-wrapped everything in a bare Error, which stripped the class off StorageLimitExceededError and the new not_found alike. It now rethrows a classified failure untouched and attaches cause to the generic wrap, so asOrchestrationError can still walk the chain. - Every remaining perform* gained the asOrchestrationError branch. - renameWorkspaceFile returned the pre-update read, so the v2 PATCH reported a stale updatedAt; it now returns the timestamp it actually wrote. Docs: upload auto-suffixes a duplicate name rather than rejecting it, matching the in-app uploader. The description claimed 409 and was simply wrong. * fix(files): surface a failed upload read-back as the real error getWorkspaceFile swallows a query failure and returns null unless throwOnError is set, so a transient blip on the post-upload read reported as 'file could not be read back'. Distinguish the two: a real null after a just-committed write is an invariant break, a query failure is itself. * revert(api): drop the dedicated v2 file-folder routes File folders already live in the shared folder table as resourceType 'file' (#6045 cut them over, #6051 dropped workspace_file_folders), and the remaining file-specific folder machinery is being folded into the generic folder engine. Publishing /api/v2/files/folders/** would pin that transitional split into a public contract we'd then have to keep or break. Files stay folder-aware — folderId/folderPath on the projection, folderId on upload, and the move route — because a folder id is a folder.id and survives the unification untouched. Folder management belongs on /api/v2/folders once that surface serves resourceType 'file'; until then there is no v2 way to enumerate file folders, which is the deliberate gap. The orchestration classification fixes stay: the internal routes and the copilot file-folder tools still call those perform* functions. * fix(files): classify upload failures instead of matching their wording Bugbot round 2. uploadWorkspaceFile had the same outer-catch rewrap that updateWorkspaceFileContent did, so a blown storage quota reached the route as a bare Error and the v2 handler recovered the status by substring-matching the message. Any rewording silently demoted a 413 to a 500. - uploadWorkspaceFile rethrows a classified failure untouched and attaches cause to the generic wrap. - FileConflictError is now an OrchestrationError('conflict'), so a duplicate name classifies like every other conflict. Its 'FILE_EXISTS' discriminator had no readers and is gone; the instanceof checks elsewhere still hold. - The v2 upload handler uses v2CaughtOrchestrationError, dropping all three string matches. Also documents that bulk-archive is best-effort: unknown or already-archived ids are skipped rather than failing the call, and deletedItems is what actually happened. That asymmetry with the single-id DELETE was undocumented. * feat(cli): wire the expanded v2 files surface 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 * 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 * fix(cli): make tables rows query show the rows 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 * fix(cli): make boolean flags able to say false `--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 * feat(api): add search, filtering, and sorting to the v2 list endpoints (#6189) * feat(api): add search, filtering, and sorting to the v2 list endpoints One convention across every v2 list, documented on lib/api/contracts/v2/shared.ts: `search` (case-insensitive substring on the resource's natural name field), `sortBy` + `sortOrder` (per-resource enum, never a free string), and enumerated resource-specific filters. Reuses the sortBy/sortOrder pair v2 logs and v2 knowledge-documents already ship rather than inventing a third dialect alongside the Logs filters and the Tables predicate grammar. Every filter and sort is pushed into SQL. GET /api/v2/files previously read the whole scope and sorted/sliced it in JS; it now goes through a new queryWorkspaceFiles that filters, orders, and bounds the page in one query. Cursors are stamped with the sort they were minted under, so replaying one under a different sort is a 400 instead of silently duplicated or skipped rows. * fix(api): validate v2 cursor key values and compare timestamps at ms precision Two review findings, fixed at the root by making a keyset key own its cursor codec instead of hand-writing a decoder per sort. Cursor key values are caller-controlled, and matching the sort stamp and key count was not enough: an unparseable timestamp or a non-numeric size reached the query as an Invalid Date or NaN and surfaced as a 500. Each key now type- checks its own value and rejects a cursor it cannot hold, which both routes render as the documented 400. Timestamp keys now order and compare on date_trunc('milliseconds', col). Postgres keeps microseconds and defaultNow() populates them, but a cursor value round-trips through a millisecond-only JS Date — comparing the raw column against the truncated value re-admitted the page's own last row, duplicating it and stalling pagination outright at a page size of one. Reachable today via workspace_files.updated_at, which insertFileMetadata leaves to defaultNow(). * feat(api): complete the v2 workflows resource with versions and CRUD (#6184) * feat(api): complete the v2 workflows resource with versions and CRUD Adds version listing/detail plus create, update, and delete to the v2 workflows surface, which previously covered only execution and deployment. - GET /api/v2/workflows/[id]/versions — cursor-paginated, newest first - GET /api/v2/workflows/[id]/versions/[version] — version + pinned state - POST /api/v2/workflows, PATCH and DELETE /api/v2/workflows/[id] All six delegate to the existing orchestration and persistence helpers; no new domain logic. * fix(api): check folder containment before lock state; reject malformed version cursors assertFolderMutable walks a folder's ancestor chain without filtering on workspace, so inspecting it before containment let a caller tell a locked folder in someone else's workspace (423) from a nonexistent one (400). Create and update now assert containment first, matching the ordering import-workflow.ts already uses. A version cursor that decodes to JSON without a numeric version filtered every row out and returned an empty page with nextCursor null, which reads as a clean end-of-list. Malformed cursors are now a 400. * refactor(api): page workflow versions in the persistence helper listWorkflowVersions read every version row and the route filtered and sliced the result in memory, so the response was bounded but the query was not. It now takes optional limit/afterVersion, turning the cursor into a real keyset query; the route asks for limit + 1 and only trims the has-more probe. Both params are optional, so the internal, v1 admin, and copilot callers are unchanged. Also restores the untouched GET handler in [id]/route.ts to its original formatting — collapsing its signature had re-indented the whole body and buried the actual additions in whitespace churn. * feat(api): expand v2 tables with stateless multipart transfers (#6188) * feat(api): expand the public v2 tables surface Adds 16 operations so a v2 caller can do what the internal surface can: rename/move/lock a table, restore it, manage saved views, run enrichment columns, look up rows, and import/export with observable job control. Extracts lib/table/orchestration/import.ts (performTableCsvImport, performCreateTableFromCsv) and lib/table/export-stream.ts from the first-party routes, then repoints those routes at them, so v1 and v2 cannot drift on what an import or export actually does. events/stream, metadata and dispatches stay internal — they are editor state, not public API. * fix(api): make v2 table PATCH all-or-nothing and name the lock in every 423 Greptile P1: PATCH applied locks, rename and move as three sequential transactions, so a folder rejected mid-request left the earlier writes persisted while the response reported failure — and the schema-changed signal was skipped, leaving open clients on stale state. Every rejectable condition now runs before the first write, and the signal fires whenever anything did land. Cursor: v2TableLockError dropped the lock kind, so async import, column run, enrichment and table mutations returned a bare LOCKED. A table has four independent locks, so the caller could not tell which to clear. * fix(api): report the lock kind on classified 423s too, not just thrown ones The previous commit named the lock only where the rejection was thrown and caught at the route boundary. Where it instead arrives as a classified `errorCode: 'locked'` outcome — delete table, delete row, update column, and the table mutations — the kind was dropped, so those 423s stayed unactionable while their neighbours improved. The orchestration results now carry `lock`, and a shared `v2TableOrchestrationError` renders both arrival paths into the same `{ code, message, details: { lock } }` body. `details` is omitted rather than sent null when the kind is unknown, so a caller branching on it sees absence instead of a phantom value. * fix(api): make async table imports observable, not just startable `POST /import-async` pointed callers at `GET /api/v2/tables/jobs` to track progress, but that endpoint filters to `type = 'export'` — imports are derived onto the table itself, one write job at a time, and exports get a separate list precisely because they are excluded from that derivation. The public Table shape omitted those derived fields, so an async import could be started and cancelled but never observed to completion, failure, or progress. That is the gap the import/export/job-control set was meant to close. Table now carries `job` — id, type, status, rowsProcessed, error, or null when idle — and the import-async docs point at the table rather than the export list. * feat(api): make v2 table PATCH state which operations landed on failure Greptile held the PR at 4/5 on the residual non-atomicity and named two acceptable resolutions: make PATCH atomic, or have the contract adopt and expose partial-success explicitly. Atomicity would mean threading one transaction through renameTable, moveTableToFolder and updateTableLocks — three shared service functions with four non-test callers including the first-party route and two copilot tools — and deferring their per-operation audits to commit time. That is a refactor of shared write paths well outside this PR. So the contract states it instead. Every rejectable condition is already pre-validated, so a failure here is a genuine fault; when one follows a successful operation the error now carries `details.applied` listing what is live. Absent when nothing applied, so its presence always means "these changes took effect despite the error". Documented on the operation. `v2ErrorForOrchestration` gained the optional `details` this needs. * fix(api): make table lock flags read-only on the public v2 surface The new PATCH /api/v2/tables/[tableId] accepted a `locks` object, gated on workspace admin plus the table-locks feature. That still lets an API key clear the guard placed there to stop it: `write` is the floor for the endpoint, and admin keys are ordinary API keys, so a lock is no longer a boundary the key cannot cross. Locks stay readable on the table resource and enforcement is unchanged (a locked verb still returns 423). Changing one is now a first-party admin action only. The v2 body is declared here rather than reusing the first-party updateTableBodySchema, which keeps its `locks` field so the UI can still toggle them. It is .strict(), so a request carrying `locks` is rejected with a 400 naming the field instead of silently succeeding without applying it. * fix(api): keep reporting applied operations when the PATCH re-read fails The composite table PATCH promises that `error.details.applied` names the operations that are live despite an error, but `applied` was scoped inside the try. A rename or move that committed and was then followed by a throw in the final re-read — or a re-read finding the table archived — returned a bare 500/404 with no details, telling the caller nothing had landed. It would then retry into a duplicate-name conflict or repeat the move. `applied` is now function-scoped so every post-write exit carries it: the 404 on a missing re-read, a thrown lock error, a classified orchestration error, and the generic 500. `v2TableLockError` gains the same `extraDetails` parameter `v2TableOrchestrationError` already had. * feat(api): add workflow group writes to the v2 tables surface v2 exposed GET /groups but none of the writes, so the public API could run an enrichment or workflow column and read its binding, but never create one. A caller could add a plain data column and trigger the machine; wiring the two together still required the UI. Adds POST/PATCH/DELETE on /api/v2/tables/[tableId]/groups. The group is the unit that fills columns — one group feeds several — so creating one creates its output columns in the same call, matching the first-party shape rather than inverting it onto the column endpoint. Four departures from the first-party body, all public-surface concerns: - group.id is optional and server-generated. The UI mints an id to render optimistically; a public caller has no such need and a client-chosen id is a collision waiting to happen. - outputColumns[].workflowGroupId is dropped from the body and stamped from the resolved group, so it cannot disagree with it. - autoRun defaults to false. First-party defaults true so a UI add fills cells immediately; here it would make one POST fan out a metered run across every existing row. - A group naming neither a workflowId (type manual) nor an enrichmentId (type enrichment) is a 400 rather than a half-specified group the route has to guess about. Also rejects an outputColumns entry no group output feeds — the two arrays are joined by column name, and the first-party client builds both from one picker so it cannot desync, but a public caller can. Workspace containment on workflowId is asserted before it is persisted, on create and on any update that re-points the group; without it a table becomes a way to invoke workflows the key cannot otherwise reach. * improvement(api): make v2 table import and export async-only Drops the three synchronous entry points: POST /tables/[tableId]/import, POST /tables/import-csv, and GET /tables/[tableId]/export. Sync import tied a write to the lifetime of an HTTP request. The body *was* the data, so it carried a 10 MB cap that Next silently truncates past — a partial import reporting success. It also had no job, so a timeout mid-write left rows in place with nothing to poll and nothing to cancel. The async path reads the file from storage instead: upload via POST /api/v2/files for a key, start with POST /import-async, watch GET /tables/[tableId] -> job, stop with POST /job/cancel. Sync export carried no such hazard, but one shape per operation beats two: with both removed the surface has exactly one way to move a table in or out, and the CLI wraps the extra calls. This also removes the last multipart handling in v2 tables. Those were the only routes bypassing parseRequest — form fields were parsed by hand against separate form schemas, outside the contract system every other v2 write goes through. Create-a-table-from-CSV is now two calls: POST /tables, then /import-async with createColumns. csvImportModeSchema is append|replace, so there is no single-call create. Route baseline 1064 -> 1061. * docs(api): correct the import-async note about upload size limits The docstring claimed there is no synchronous upload endpoint and so no request-body size cliff. Both are wrong: POST /api/v2/files is a synchronous multipart upload with a 100 MB cap, and it is the only v2 upload path (presigned is deliberately absent). What async-only actually bought: the cap went 10 MB -> 100 MB, it fails on an explicit size check and a bounded body read rather than a proxy cap that silently truncates, authorization completes before any body is buffered, and the table write is a job that can be watched and cancelled. * feat(api): unify file and table transfers * improvement(api): make multipart transfers stateless * fix(api): make table import completion retries idempotent * feat(cli): pick up v2 workflow CRUD, table transfers, and list search 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 * feat(cli): sim tables import 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 * feat(cli): default tables import to a new table named after the file `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 * feat(v2-tables): paginate the table list `GET /api/v2/tables` returned every table in the workspace in one response — it used the cursor envelope but hardcoded `nextCursor: null`, and had no `limit`. That was defensible when tables were only created through the UI; `POST /api/v2/tables` is public now, so a script can create them in bulk and the list has no way to ask for less. Adds `queryTables` alongside `listTables` rather than changing it, so the internal callers that genuinely want the whole scope are untouched — the same split `queryWorkspaceFiles` / `listWorkspaceFiles` already uses. Filter, order and slice all run in the query, so a `search` never costs a full-workspace read. A cursor whose values don't bind raises a validation error instead of being coerced to "no filter", which would have silently served page 1 under a resumed cursor. The keyset closes on `id` so a page boundary inside a run of equal names or timestamps stays stable. The shared `LimitQuery` doc component said "Maximum rows to return"; it now serves the table list too, so the wording is resource-neutral. Co-Authored-By: Claude Opus 5 * chore(cli): regenerate for the paginated table list `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 * fix(cli): make tables rows create and tables columns run usable 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 * feat(api): add multipart knowledge document uploads * fix(api): keep usage admission at knowledge upload session creation * feat(knowledge): wire knowledge base uploads to multipart sessions * fix(knowledge): refuse to abort an upload once a document is bound * fix(uploads): prevent multipart cleanup races * fix(cli): improve command usability and structure * feat(cli): support knowledge document uploads * Unify file creation and signed upload sessions (#6264) * feat(uploads): unify signed upload sessions * fix(uploads): preserve attachment storage semantics * feat(files): add authored file creation * fix(uploads): omit hoisted S3 metadata headers * fix(cli): support unified upload sessions * feat(api): add file metadata endpoint * feat(cli): accept simple list inputs * improvement(api): scope folders to resource paths (#6284) * improvement(api): scope folders to resource paths * fix(files): serialize folder resolution with uploads * fix(files): release folder lock before upload setup * feat(cli): add path-based resource directories * feat(cli): add resource mkdir commands * fix(cli): accept positional folder paths * fix(api): normalize folder paths and unblock resource mutations * fix(api): make resource cleanup and metadata consistent * improvement(uploads): persist multipart sessions in postgres * fix(db): store table row trigger timestamps in UTC * improvement(api): default folder deletion to non-recursive * feat(cli): streamline common resource workflows * feat(cli): standardize resource command syntax * fix(billing): unify chat usage source * improvement(logs): expose trace spans on log detail * feat(cli): sync unified chat billing source * feat(cli): expose log detail trace spans * fix(logs): parse list trace spans * improvement(api): replace workflow jobs with execution resources (#6294) * improvement(api): replace workflow jobs with execution resources * fix(api): preserve legacy jobs while preferring v2 executions * fix(api): make execution polling resume-aware * fix(ui): hide async examples for public workflows * fix(api): bridge resume queue visibility lag * feat(api): add v2 workflow resume endpoint * fix(api): project pending resume attempts * fix(api): prefer terminal logs over stale resumes * improvement(api): unify v2 resource query layers (#6319) * improvement(api): unify v2 resource query layers * fix(api): address v2 review findings * fix(api): preserve cancelled queue status * fix(api): guard cancelled job transitions * fix(api): close v2 resume and log gaps * feat(cli): improve v2 command workflows * feat(api): rename v2 executions to runs * feat(api): split credentials and secrets * feat(api): add workspace metadata and email attribution * improvement(api): consolidate public v2 route handling * feat(cli): sync v2 API and personal login defaults * fix(cli): use profile workspace for workspace get * fix(cli): use profile workspace for member listing * improvement(files): centralize operations across APIs and Copilot (#6392) * improvement(files): unify rename authorization * chore(skills): add file operation migration guide * improvement(files): consolidate file operation authorization * improvement(files): extract shared operation foundation * improvement(api): simplify internal route declarations * improvement(files): centralize application authorization * refactor(api): share workspace file name validation * refactor(files): centralize copilot application calls * docs(skills): generalize application operation migration * fix(cli): restore nested knowledge document commands * feat(cli): add interactive Sim chat * improvement(api): centralize remaining v2 resource operations (#6412) * improvement(api): centralize v2 resource operations * fix(api): preserve custom tool conflict errors * improvement(api): migrate policy-sensitive v2 reads (#6410) * improvement(workflows): centralize v2 application operations (#6411) * refactor(api): migrate v2 knowledge operations (#6413) * refactor(api): migrate v2 knowledge operations * fix(knowledge): fail upload completion on dispatch errors * fix(knowledge): preserve upload retry and VFS errors * feat(cli): refine chat and desktop updates * improvement(tables): centralize v2 application operations (#6414) * improvement(tables): centralize v2 application operations * fix(tables): preserve run validation and signals * style(desktop): refine macOS installer layout * fix(api): type timestamp cursor parameters * feat(cli): add saved chat commands * chore(desktop): trigger updated prerelease * feat(cli): publish @simai/cli release channels * fix(ci): include auth package in app prune * feat(cli): separate file descriptions from content * feat(cli): add resumable async Sim Chat * fix(cli): guard file unsharing * feat(auth): add scoped internal executor delegation (#6459) * feat(auth): add scoped internal executor delegation * fix(auth): derive delegation lifetime from one timestamp * Include share status in file metadata * feat(auth): centralize delegated identity policy (#6462) * fix(cli): stream file content to stdout by default * improvement(copilot): consolidate application adapters (#6450) * improvement(api): harden application route boundaries (#6451) * improvement(api): harden application route boundaries * fix(folders): reject creates at workspace cap * fix(knowledge): enforce trusted workspace scope (#6452) * fix(knowledge): enforce trusted workspace scope * refactor(knowledge): declare v2 body lifecycle * finish knowledge application migration * refactor(knowledge): compose copilot batch commands * fix(knowledge): parse connector query flags * fix(knowledge): finalize partial batch effects * fix(knowledge): align merged application boundaries * fix(knowledge): close application boundary review gaps * style(knowledge): satisfy branch biome checks * fix(knowledge): page connector documents in editor * refactor: enforce Copilot table application boundary (#6453) * refactor: enforce copilot table application boundary * fix(tables): finish application boundary migration * fix(tables): restore scoped copilot imports * fix(tables): compose copilot commands atomically * fix(tables): preserve workflow group scheduling * fix(tables): complete fixed copilot composition * fix(tables): reject enrichment output mutation * fix(tables): complete authorized application boundary * fix(workflows): migrate Copilot application boundary (#6455) * fix(workflows): migrate Copilot application boundary * fix(workflows): finish delegated application migration * fix(workflows): encode VFS folder aliases * fix(workflows): close application composition gaps * fix(workflows): preserve VFS validation errors * fix(workflows): complete application boundary migration * test(workflows): format canonical binding coverage * fix(workflows): scope executor metadata reads * fix(workflows): bind executor metadata targets * fix(copilot): recover tool arguments lost when a call is checkpointed mid-generation Tool arguments reach Sim two ways: whole on a frame's `arguments`, or in pieces as `args_delta` chunks that accumulate into `streamingArgs`. Only the first populated `params`, so a call checkpointed before any frame carried `arguments` executed with `{}` and failed its own schema on every required property. The file subagent's `workspace_file` calls arrive exactly that way, which left the agent retrying and then routing around the tool entirely. - executor: hydrate `params` from the streamed deltas before dispatch, covering both normal dispatch and the never-dispatched resume path. - handlers: record the subagent channel at registration rather than only on a finalized frame, so the workspace_file -> edit_content intent handoff can find its intent instead of reporting "No workspace_file context found". - preview adapter: pass the frame's tool call id into file delegation, which derives its audit id from it. Without it every preview threw and no file content streamed at all. - run: a checkpointed call with no recorded result now reports a failed result instead of throwing, which ended the whole turn and cost the user the entire response. Each fix has a regression test verified to fail without it. * feat(credentials): add v2 OAuth connection APIs * fix(credentials): preserve active OAuth connection links * fix(credentials): bind OAuth links to connection intent * feat(credentials): complete v2 credential lifecycle * fix(credentials): make disconnect idempotent * fix(credentials): stabilize oauth draft retries * fix(credentials): bind oauth callbacks to drafts * fix(credentials): fail closed on oauth completion * fix(credentials): bind shopify completion to oauth state * revert(chat): remove Sim Chat and mothership changes Reverts this branch's Sim Chat surface and its mothership-view edits. d4bdb87d04 ("feat(cli): add interactive Sim chat") could not be reverted: it is a 175-file commit that also introduced the ExecutionContext refactor and the v2 workspaces API, which 33 files under lib/copilot now depend on. Reverting it produced 42 content conflicts, 24 of them in the refactor rather than in chat code. Its chat contribution is removed forward instead. Reverted: b25e7ba0c3 fix(copilot): recover tool arguments lost when checkpointed d4c74648b0 feat(cli): add resumable async Sim Chat 3f0c1fcce0 feat(cli): add saved chat commands de263204fd feat(cli): refine chat and desktop updates -- mothership-view only; desktop updater, terminal themes, and bridge are kept Removed forward: the 17 CLI chat modules, /api/v2/chat, /api/v2/chats, the v2 chat contracts, and lib/copilot/headless. lib/copilot/chat/turn-persistence.ts is kept because the web chat's post.ts imports it. Left alone: upstream Copilot application-boundary work (#6450-#6462), the sim-chat billing source, and the v2 workspaces API. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE * fix(credentials): align custom oauth reconnects * fix(credentials): centralize application authorization * fix(credentials): keep OAuth draft intent immutable * chore(cli): regenerate v2 API for the staging sweep Additive only, both picked up automatically by the derived command surface: - cancelWorkflowRun `reason` gains already_cancelled / already_completed / already_failed (#6702) - getFile gains `scope` (active | archived), so `sim files describe` grows a --scope flag defaulting to active Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE * fix(credentials): allow renamed reconnect targets * fix(credentials): close OAuth draft edge cases * fix(ci): green the repo audits after the staging merge check:import-specifiers — 79 violations, all packages/sim-cli. That package is "moduleResolution": "nodenext" while the rest of the repo is "bundler": Node's ESM resolver takes the specifier literally, so `./ini.js` is required for a file that is `./ini.ts` on disk. Following the audit's advice to drop the extension would break the CLI at runtime. The checker now reads each workspace's tsconfig and, for a NodeNext package, resolves a `.js` specifier back to its source instead of flagging it — still catching genuinely missing files. check:utils — device-flow.ts polled with `new Promise(setTimeout)`. It cannot import sleep() from @sim/utils: that package is private, so a published @simai/cli would resolve it in the monorepo and fail from npm. Added a local helpers.ts and allowlisted it, matching the existing packages/cli entry. check:tool-registry-boundary — knowledge/page.tsx measured +43 against a +42 allowance. tools/registry.ts is not a gateway on any route, so the boundary the audit exists to protect is intact; the growth is this branch's own v2 work. Re-recorded per the script's own instruction. helm — restored staging's networkpolicy_test.yaml. An earlier integration merge had dropped its trailing newline, which was the only helm delta against staging and was tripping the chart-version-bump check. Not addressed: the Security audit step reports high advisories (brace-expansion, undici, Socket.IO, OpenTelemetry, fast-uri). Every one is transitive and present in staging's lockfile too; the step is continue-on-error and did not fail the job. bun audit reads a live advisory feed, so staging's green run predates them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE * fix(cli): read share fields from the v2 share object The file-share columns pointed at a `sharing` wrapper that v2 does not return. The share travels under `share` on file metadata (null when unshared) and as the unwrapped body on the share endpoints, and its flag is `isActive`, not `enabled`. Every one of those columns was therefore rendering an em-dash on `files describe`, `files share get`, and `files share set`. A missing field path renders blank instead of failing, so nothing caught this. Added a rendering test over both surfaces; it fails if a path stops resolving. `hasPassword` is surfaced on the two share commands while they are being fixed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE * fix(credentials): fail closed without breaking auth * improvement(cli): bundle and publish as @sim/cli * fix(credentials): preserve migrated route behavior * fix(cli): confirm before overwriting login profile * fix(cli): adapt service-account credential fields * feat(cli): prompt for secret values * fix(cli): harden login and credentialless chat * fix(cli): skip existing package releases * fix(cli): avoid exposing API key fragments * fix(cli): cancel failed download streams * fix(cli): sanitize authentication metadata * fix(cli): rename authentication output metadata * fix(cli): publish downloads atomically * fix(cli): harden download destinations and labels * fix(cli): support dangling download symlinks * fix(cli): keep new downloads atomic * refactor(cli): remove unrelated Copilot changes * fix(cli): publish as sim package * fix(cli): use staging npm tag * fix(cli): align vitest lock resolution --------- Co-authored-by: Waleed Co-authored-by: Vikhyath Mondreti Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Siddharth Ganesan --- .github/workflows/publish-sim-cli.yml | 149 + .github/workflows/test-build.yml | 2 +- .../(generated)/execution/meta.json | 3 + apps/docs/openapi-core.json | 2263 +++++ .../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 | 122 +- apps/sim/app/api/cli/auth/poll/route.ts | 91 +- .../app/api/knowledge/search/utils.test.ts | 25 +- .../app/api/public-api-route-handler.test.ts | 272 + apps/sim/app/api/public-api-route-handler.ts | 79 + apps/sim/app/api/users/me/api-keys/route.ts | 72 +- apps/sim/app/api/v1/logs/[id]/route.ts | 35 +- apps/sim/app/api/v2/credentials/utils.ts | 22 + apps/sim/app/cli/auth/cli-auth-request.ts | 15 +- apps/sim/app/cli/auth/cli-auth-view.test.tsx | 139 + apps/sim/app/cli/auth/cli-auth-view.tsx | 89 +- apps/sim/app/cli/auth/search-params.ts | 17 +- 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 + apps/sim/lib/api-key/orchestration/index.ts | 119 +- apps/sim/lib/api/contracts/cli-auth.ts | 53 + .../lib/api/contracts/v1/tables/index.test.ts | 24 + apps/sim/lib/api/contracts/v1/tables/index.ts | 21 +- apps/sim/lib/cli-auth/approval-store.test.ts | 76 +- apps/sim/lib/cli-auth/approval-store.ts | 45 +- apps/sim/package.json | 2 +- bun.lock | 113 +- package.json | 2 + packages/sim-cli/LICENSE | 202 + packages/sim-cli/README.md | 331 + packages/sim-cli/THIRD_PARTY_LICENSES | 30 + packages/sim-cli/package.json | 60 + packages/sim-cli/src/auth/device-flow.test.ts | 124 + packages/sim-cli/src/auth/device-flow.ts | 175 + packages/sim-cli/src/commands/auth.test.ts | 260 + packages/sim-cli/src/commands/auth.ts | 266 + packages/sim-cli/src/commands/configure.ts | 67 + .../sim-cli/src/commands/credentials.test.ts | 241 + packages/sim-cli/src/commands/credentials.ts | 195 + .../src/commands/protocol/files-get.test.ts | 274 + .../src/commands/protocol/files-get.ts | 227 + .../commands/protocol/files-upload.test.ts | 142 + .../src/commands/protocol/files-upload.ts | 51 + .../sim-cli/src/commands/protocol/index.ts | 52 + .../knowledge-document-upload.test.ts | 225 + .../protocol/knowledge-document-upload.ts | 98 + .../protocol/resource-directory.test.ts | 153 + .../commands/protocol/resource-directory.ts | 196 + .../sim-cli/src/commands/protocol/result.ts | 7 + .../commands/protocol/tables-import.test.ts | 130 + .../src/commands/protocol/tables-import.ts | 209 + packages/sim-cli/src/commands/secrets.test.ts | 118 + packages/sim-cli/src/commands/secrets.ts | 67 + packages/sim-cli/src/config/index.ts | 18 + 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 | 167 + packages/sim-cli/src/config/profile.ts | 219 + packages/sim-cli/src/context.ts | 41 + packages/sim-cli/src/contract/commands.ts | 832 ++ packages/sim-cli/src/contract/types.ts | 181 + packages/sim-cli/src/generated/v2-api.ts | 7633 +++++++++++++++++ packages/sim-cli/src/helpers.ts | 12 + packages/sim-cli/src/http/client.test.ts | 333 + packages/sim-cli/src/http/client.ts | 274 + packages/sim-cli/src/index.ts | 105 + packages/sim-cli/src/output/render.test.ts | 343 + packages/sim-cli/src/output/render.ts | 307 + packages/sim-cli/src/output/terminal-text.ts | 106 + packages/sim-cli/src/output/trace.ts | 115 + packages/sim-cli/src/runtime/build.test.ts | 1166 +++ packages/sim-cli/src/runtime/build.ts | 245 + packages/sim-cli/src/runtime/derive.ts | 70 + packages/sim-cli/src/runtime/execute.ts | 107 + packages/sim-cli/src/runtime/options.ts | 140 + packages/sim-cli/src/runtime/request.test.ts | 255 + packages/sim-cli/src/runtime/request.ts | 385 + packages/sim-cli/src/runtime/result.ts | 196 + packages/sim-cli/src/runtime/types.ts | 13 + .../sim-cli/src/terminal/secret-input.test.ts | 89 + packages/sim-cli/src/terminal/secret-input.ts | 81 + packages/sim-cli/src/transfer/local-file.ts | 57 + .../sim-cli/src/transfer/upload-session.ts | 124 + packages/sim-cli/tsconfig.json | 5 + packages/sim-cli/vitest.config.ts | 8 + scripts/check-source-text.ts | 4 +- scripts/check-utils-enforcement.ts | 3 + scripts/generate-v2-cli-api.ts | 570 ++ 97 files changed, 22214 insertions(+), 192 deletions(-) create mode 100644 .github/workflows/publish-sim-cli.yml 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/sim/app/api/public-api-route-handler.test.ts create mode 100644 apps/sim/app/api/public-api-route-handler.ts create mode 100644 apps/sim/app/api/v2/credentials/utils.ts create mode 100644 apps/sim/app/cli/auth/cli-auth-view.test.tsx create mode 100644 apps/sim/lib/api/contracts/v1/tables/index.test.ts create mode 100644 packages/sim-cli/LICENSE create mode 100644 packages/sim-cli/README.md create mode 100644 packages/sim-cli/THIRD_PARTY_LICENSES create mode 100644 packages/sim-cli/package.json create mode 100644 packages/sim-cli/src/auth/device-flow.test.ts create mode 100644 packages/sim-cli/src/auth/device-flow.ts create mode 100644 packages/sim-cli/src/commands/auth.test.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/credentials.test.ts create mode 100644 packages/sim-cli/src/commands/credentials.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-get.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-get.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-upload.test.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/knowledge-document-upload.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts create mode 100644 packages/sim-cli/src/commands/protocol/resource-directory.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/resource-directory.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/commands/secrets.test.ts create mode 100644 packages/sim-cli/src/commands/secrets.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/contract/commands.ts create mode 100644 packages/sim-cli/src/contract/types.ts create mode 100644 packages/sim-cli/src/generated/v2-api.ts create mode 100644 packages/sim-cli/src/helpers.ts create mode 100644 packages/sim-cli/src/http/client.test.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/src/output/terminal-text.ts create mode 100644 packages/sim-cli/src/output/trace.ts create mode 100644 packages/sim-cli/src/runtime/build.test.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/execute.ts create mode 100644 packages/sim-cli/src/runtime/options.ts create mode 100644 packages/sim-cli/src/runtime/request.test.ts create mode 100644 packages/sim-cli/src/runtime/request.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/terminal/secret-input.test.ts create mode 100644 packages/sim-cli/src/terminal/secret-input.ts create mode 100644 packages/sim-cli/src/transfer/local-file.ts create mode 100644 packages/sim-cli/src/transfer/upload-session.ts create mode 100644 packages/sim-cli/tsconfig.json create mode 100644 packages/sim-cli/vitest.config.ts create mode 100644 scripts/generate-v2-cli-api.ts diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml new file mode 100644 index 00000000000..f36e514ec69 --- /dev/null +++ b/.github/workflows/publish-sim-cli.yml @@ -0,0 +1,149 @@ +name: Publish Sim API CLI Package + +on: + push: + branches: [main, staging, dev] + paths: + - 'packages/sim-cli/**' + +permissions: + contents: read + +concurrency: + group: publish-sim-cli-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish-npm: + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '20' + + - name: Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + ~/.bun/install/cache + node_modules + **/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Verify npm authentication + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun pm whoami + + - name: Run tests + working-directory: packages/sim-cli + run: bun run test + + - name: Type-check package + working-directory: packages/sim-cli + run: bun run type-check + + - name: Build package + working-directory: packages/sim-cli + run: bun run build + + - name: Resolve release channel + id: release + working-directory: packages/sim-cli + env: + BRANCH: ${{ github.ref_name }} + run: | + BASE_VERSION="$(bun -p "require('./package.json').version")" + if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Package version must be a stable X.Y.Z base, got '$BASE_VERSION'." >&2 + exit 1 + fi + + case "$BRANCH" in + dev) + VERSION="${BASE_VERSION}-dev.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + TAG="dev" + ;; + staging) + VERSION="${BASE_VERSION}-preview.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + TAG="staging" + ;; + main) + VERSION="$BASE_VERSION" + TAG="latest" + ;; + *) + echo "Unsupported release branch '$BRANCH'." >&2 + exit 1 + ;; + esac + + bun pm pkg set "version=$VERSION" + RESOLVED_VERSION="$(bun -p "require('./package.json').version")" + if [ "$RESOLVED_VERSION" != "$VERSION" ]; then + echo "Version injection mismatch: wanted '$VERSION', got '$RESOLVED_VERSION'." >&2 + exit 1 + fi + + { + echo "version=$VERSION" + echo "tag=$TAG" + } >> "$GITHUB_OUTPUT" + + - name: Smoke-test packed Node bundle + working-directory: packages/sim-cli + run: | + set -euo pipefail + SMOKE_DIR="$(mktemp -d "$RUNNER_TEMP/sim-cli-smoke.XXXXXX")" + PACKAGE_PATH="$SMOKE_DIR/sim-cli.tgz" + bun pm pack --ignore-scripts --filename "$PACKAGE_PATH" --quiet + tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" + "$SMOKE_DIR/package/dist/index.js" --version + + - name: Check if version already exists + id: version_check + working-directory: packages/sim-cli + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + if bun pm view "sim@$VERSION" version > /dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to npm + if: steps.version_check.outputs.exists == 'false' + working-directory: packages/sim-cli + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ steps.release.outputs.tag }} + run: bun publish --access public --tag "$NPM_TAG" --no-save + + - name: Summarize release + if: steps.version_check.outputs.exists == 'false' + env: + VERSION: ${{ steps.release.outputs.version }} + NPM_TAG: ${{ steps.release.outputs.tag }} + run: echo "Published sim@$VERSION with the '$NPM_TAG' tag." + + - name: Summarize skipped release + if: steps.version_check.outputs.exists == 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: echo "Skipped sim@$VERSION because that version is already published." diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 09cdf7dbb48..80a0ec62352 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -264,4 +264,4 @@ jobs: AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only TURBO_CACHE_DIR: .turbo - run: bunx turbo run build --filter=sim + run: bunx turbo run build --filter=@sim/app 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/openapi-core.json b/apps/docs/openapi-core.json new file mode 100644 index 00000000000..b7020ae27f9 --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2263 @@ +{ + "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" + }, + { + "name": "Billing", + "description": "Inspect billing status and credit-denominated ledger events" + } + ], + "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 `queued` immediately after async dispatch, then the run's durable lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. This legacy-compatible resource remains available for existing integrations.", + "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 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 usage and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + }, + "/api/v2/billing/status": { + "get": { + "operationId": "getBillingStatus", + "summary": "Get Billing Status", + "description": "Return the current plan, billing standing, period, and credit allowance. This endpoint never embeds ledger rows or per-source analytics; use `GET /api/v2/billing/logs` for billing history.", + "tags": ["Billing"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Resolve the status against this workspace's actual payer. A workspace-scoped API key is pinned to its own workspace; passing a different id returns 403." + } + ], + "responses": { + "200": { + "description": "The current billing status.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["workspaceId", "period", "plan", "status", "credits"], + "properties": { + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace whose payer was resolved, or null for account billing." + }, + "period": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, + "plan": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["active", "limit_exceeded", "billing_blocked"] + }, + "credits": { + "type": "object", + "required": ["used", "limit", "remaining"], + "properties": { + "used": { + "type": "number" + }, + "limit": { + "type": "number" + }, + "remaining": { + "type": "number" + } + } + } + } + } + } + }, + "example": { + "data": { + "workspaceId": null, + "period": { + "start": "2026-07-01T00:00:00.000Z", + "end": "2026-08-01T00:00:00.000Z" + }, + "plan": "pro", + "status": "active", + "credits": { + "used": 512, + "limit": 20000, + "remaining": 19488 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/billing/logs": { + "get": { + "operationId": "listBillingLogs", + "summary": "List Billing Logs", + "description": "Cursor-paged, credit-denominated billing ledger. This endpoint returns history only and never embeds the current billing status. Page by passing `nextCursor` back as `cursor` and stop when it is null.", + "tags": ["Billing"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "description": "Restrict to one usage source (e.g. `workflow`, `sim-chat`). `sim-chat` includes both the internal Copilot and workspace-chat ledgers." + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "enum": ["1d", "7d", "30d", "custom", "all"], + "default": "30d" + }, + "description": "Relative window, `all`, or `custom` (requires `startDate`)." + }, + { + "name": "startDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of a `custom` window. Any `Date`-parseable string." + }, + { + "name": "endDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of a `custom` window; defaults to now." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque cursor from the previous page." + } + ], + "responses": { + "200": { + "description": "A page of usage events.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "createdAt", + "source", + "workspaceId", + "workflow", + "executionId", + "creditCost" + ], + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "workspaceId": { + "type": ["string", "null"] + }, + "workflow": { + "oneOf": [ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + } + } + }, + { + "type": "null" + } + ] + }, + "executionId": { + "type": ["string", "null"] + }, + "creditCost": { + "type": "number", + "description": "Apportioned so page rows sum exactly to the rounded page total; can be 0 for a sub-credit event." + } + } + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "example": { + "data": [ + { + "id": "log_1", + "createdAt": "2026-07-29T18:04:11.000Z", + "source": "sim-chat", + "workspaceId": "ws_1", + "workflow": null, + "executionId": null, + "creditCost": 12 + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + } + }, + "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": { + "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.", + "required": ["success", "async", "jobId", "executionId", "message", "statusUrl"], + "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" + } + } + }, + "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", "cancelled"], + "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": ["queued", "pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `queued` is projected from the async queue before the durable execution log exists; `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" + } + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current usage and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "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)." + } + } + } + } + }, + "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." + } + } + } + } + }, + "V2Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable code, e.g. `BAD_REQUEST`, `FORBIDDEN`, `RATE_LIMITED`." + }, + "message": { + "type": "string" + }, + "details": { + "description": "Optional structured context (e.g. per-field validation issues)." + } + } + } + } + } + }, + "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" + ] + } + } + } + } + }, + "V2BadRequest": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Unauthorized": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Forbidden": { + "description": "The credential is not authorized for the requested resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2RateLimited": { + "description": "Rate limit exceeded; retry after the window resets.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + } + } + } +} 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..81709bd411b 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,89 @@ 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 /)) + // 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() }) + 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 +179,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..c3e6e8c00c3 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' @@ -23,9 +28,64 @@ 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 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 +): 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 } } } /** @@ -49,17 +109,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 +125,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/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 3ab31695330..c5ae91e4c30 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -666,23 +666,17 @@ describe('Knowledge Search Utils', () => { it('should throw error when no API configuration provided', async () => { const { env } = await import('@/lib/core/config/env') Object.keys(env).forEach((key) => delete (env as any)[key]) - // The env object lazily reads process.env, so a developer's local .env - // keys survive the deletion above — stub the direct key empty and fail - // the hosted rotation fallback for hermeticity on any machine. - vi.stubEnv('OPENAI_API_KEY', '') - const apiKeysModule = await import('@/lib/core/config/api-keys') - const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => { - throw new Error('No rotation keys configured') + Object.assign(env, { + OPENAI_API_KEY: undefined, + OPENAI_API_KEY_1: undefined, + OPENAI_API_KEY_2: undefined, + OPENAI_API_KEY_3: undefined, + OPENROUTER_API_KEY: undefined, }) - try { - await expect(generateSearchEmbedding('test query')).rejects.toThrow( - 'OPENAI_API_KEY is not configured' - ) - } finally { - rotationSpy.mockRestore() - vi.unstubAllEnvs() - } + await expect(generateSearchEmbedding('test query')).rejects.toThrow( + 'OPENAI_API_KEY is not configured' + ) }) it('should handle Azure OpenAI API errors properly', async () => { @@ -713,6 +707,7 @@ describe('Knowledge Search Utils', () => { Object.keys(env).forEach((key) => delete (env as any)[key]) Object.assign(env, { OPENAI_API_KEY: 'test-openai-key', + OPENROUTER_API_KEY: undefined, }) mockNextFetchResponse({ diff --git a/apps/sim/app/api/public-api-route-handler.test.ts b/apps/sim/app/api/public-api-route-handler.test.ts new file mode 100644 index 00000000000..33757c33864 --- /dev/null +++ b/apps/sim/app/api/public-api-route-handler.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' + +const { + mockCheckRateLimit, + mockGate, + mockHandler, + mockLoggerError, + mockLoggerInfo, + requestContextState, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGate: vi.fn(), + mockHandler: vi.fn(), + mockLoggerError: vi.fn(), + mockLoggerInfo: vi.fn(), + requestContextState: { + current: undefined as { requestId: string; method?: string; path?: string } | undefined, + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: (...arguments_: unknown[]) => + mockLoggerInfo(requestContextState.current?.requestId, ...arguments_), + warn: vi.fn(), + error: (...arguments_: unknown[]) => + mockLoggerError(requestContextState.current?.requestId, ...arguments_), + }), + getRequestContext: () => requestContextState.current, + runWithRequestContext: async ( + context: { requestId: string; method?: string; path?: string }, + callback: () => T | Promise + ): Promise => { + requestContextState.current = context + try { + return await callback() + } finally { + requestContextState.current = undefined + } + }, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => requestContextState.current?.requestId ?? 'outer-request-id', +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockGate, +})) + +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' + +const RATE_LIMIT = { + allowed: true, + limit: 400, + remaining: 399, + resetAt: new Date('2026-08-06T20:00:00.000Z'), + userId: 'user-1', + keyType: 'personal' as const, +} + +const queryContract = defineRouteContract({ + method: 'POST', + path: '/api/test/:itemId', + params: z.object({ itemId: z.string().min(1) }), + query: z.object({ limit: z.coerce.number().int().positive() }), + body: z.object({ name: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const listContract = defineRouteContract({ + method: 'GET', + path: '/api/test', + query: z.object({ workspaceId: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const POST = withPublicApiRouteHandler({ + contract: queryContract, + rateLimitEndpoint: 'table-rows', + parseOptions: { + maxBodyBytes: 32, + payloadTooLargeResponse: () => + NextResponse.json({ error: 'Custom payload limit response' }, { status: 413 }), + }, + handler: async (arguments_) => { + mockHandler(arguments_) + return NextResponse.json({ ok: true }) + }, +}) + +const GET = withPublicApiRouteHandler({ + contract: listContract, + rateLimitEndpoint: 'tables', + handler: async (arguments_) => { + mockHandler(arguments_) + return NextResponse.json({ ok: true }) + }, +}) + +const FAILING_GET = withPublicApiRouteHandler({ + contract: listContract, + rateLimitEndpoint: 'tables', + handler: async () => { + throw new Error('handler failed') + }, +}) + +function postRequest(body: string): NextRequest { + return new NextRequest('http://localhost:3000/api/test/item-1?limit=10', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) +} + +function listRequest(query = 'workspaceId=workspace-1'): NextRequest { + return new NextRequest(`http://localhost:3000/api/test?${query}`) +} + +describe('withPublicApiRouteHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGate.mockResolvedValue(null) + mockCheckRateLimit.mockImplementation(async (request: NextRequest) => { + recordRateLimitSnapshot(request, RATE_LIMIT) + return RATE_LIMIT + }) + }) + + it.each([ + ['authentication failure', 401], + ['rate-limit denial', 429], + ])('short-circuits %s before reading or parsing the body', async (_label, status) => { + mockCheckRateLimit.mockImplementation(async (request: NextRequest) => { + if (status === 401) { + return { + allowed: false, + limit: 0, + remaining: 0, + resetAt: new Date('2026-08-06T20:00:00.000Z'), + error: 'API key required', + } + } + + recordRateLimitSnapshot(request, RATE_LIMIT) + return { ...RATE_LIMIT, allowed: false, remaining: 0, retryAfterMs: 30_000 } + }) + const request = postRequest('{not valid json') + + const response = await POST(request, { params: { itemId: 'item-1' } }) + + expect(response.status).toBe(status) + expect(request.bodyUsed).toBe(false) + expect(mockHandler).not.toHaveBeenCalled() + expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'table-rows') + expect(mockGate).not.toHaveBeenCalled() + if (status === 401) { + expect(response.headers.get('X-RateLimit-Limit')).toBe('0') + } else { + expect(response.headers.get('Retry-After')).toBe('30') + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + } + }) + + it('checks the v2 rollout gate before reading or parsing the body', async () => { + mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 })) + const request = postRequest('{not valid json') + + const response = await POST(request, { params: { itemId: 'item-1' } }) + + expect(response.status).toBe(404) + expect(request.bodyUsed).toBe(false) + expect(mockGate).toHaveBeenCalledWith('user-1') + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('fails fast when an allowed rate-limit result has no user ID', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, userId: undefined }) + + const response = await GET(listRequest()) + + expect(response.status).toBe(500) + expect(mockGate).not.toHaveBeenCalled() + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('returns a contract validation response after authentication', async () => { + const response = await POST(postRequest(JSON.stringify({ name: '' })), { + params: { itemId: 'item-1' }, + }) + + expect(response.status).toBe(400) + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('forwards the body-size parse option', async () => { + const response = await POST(postRequest(JSON.stringify({ name: 'x'.repeat(40) })), { + params: { itemId: 'item-1' }, + }) + + expect(response.status).toBe(413) + expect(response.headers.get('X-RateLimit-Remaining')).toBe('399') + await expect(response.json()).resolves.toEqual({ error: 'Custom payload limit response' }) + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('provides parsed params, query, body, and auth to the handler', async () => { + const request = postRequest(JSON.stringify({ name: 'Ada' })) + const response = await POST(request, { params: Promise.resolve({ itemId: 'item-1' }) }) + + expect(response.status).toBe(200) + expect(mockHandler).toHaveBeenCalledWith({ + request, + input: { + params: { itemId: 'item-1' }, + query: { limit: 10 }, + body: { name: 'Ada' }, + headers: undefined, + }, + auth: { + requestId: 'outer-request-id', + userId: 'user-1', + rateLimit: RATE_LIMIT, + }, + }) + expect(response.headers.get('x-request-id')).toBe('outer-request-id') + expect(response.headers.get('X-RateLimit-Reset')).toBe(RATE_LIMIT.resetAt.toISOString()) + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'outer-request-id', + 'OK', + expect.objectContaining({ status: 200 }) + ) + }) + + it('supports direct invocation without a route context', async () => { + const request = listRequest() + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mockHandler.mock.calls[0][0].input.query).toEqual({ workspaceId: 'workspace-1' }) + expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'tables') + }) + + it('keeps rate-limit and request headers on unhandled endpoint errors', async () => { + const response = await FAILING_GET(listRequest()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(response.headers.get('x-request-id')).toBe('outer-request-id') + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + expect(mockLoggerError).toHaveBeenCalledWith( + 'outer-request-id', + 'Unhandled route error', + expect.objectContaining({ error: 'handler failed' }) + ) + }) +}) diff --git a/apps/sim/app/api/public-api-route-handler.ts b/apps/sim/app/api/public-api-route-handler.ts new file mode 100644 index 00000000000..af25a4fe3b4 --- /dev/null +++ b/apps/sim/app/api/public-api-route-handler.ts @@ -0,0 +1,79 @@ +import type { NextRequest, NextResponse } from 'next/server' +import type { AnyApiRouteContract } from '@/lib/api/contracts' +import { type ParsedRequest, type ParseRequestOptions, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type ApiEndpoint, type AuthorizedRequest, checkRateLimit } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +interface PublicApiRouteContext { + params?: + | Promise> + | Record +} + +interface PublicApiRouteHandlerArguments { + request: NextRequest + input: ParsedRequest + auth: AuthorizedRequest +} + +interface PublicApiRouteHandlerOptions { + contract: C + rateLimitEndpoint: ApiEndpoint + parseOptions?: ParseRequestOptions + handler: ( + arguments_: PublicApiRouteHandlerArguments + ) => Promise | NextResponse | Response +} + +type PublicApiNextRouteHandler = ( + request: NextRequest, + context?: PublicApiRouteContext +) => Promise + +/** + * Wraps an API-key-authenticated public route with request context, rate + * limiting, authentication, and contract parsing before invoking the route's + * authorization and business logic. Unexpected endpoint errors are logged once + * by the shared route handler and rendered as the canonical v2 500 envelope. + */ +export function withPublicApiRouteHandler({ + contract, + rateLimitEndpoint, + parseOptions, + handler, +}: PublicApiRouteHandlerOptions): PublicApiNextRouteHandler { + const wrapped = withRouteHandler( + async (request, context) => { + const requestId = generateRequestId() + const rateLimit = await checkRateLimit(request, rateLimitEndpoint) + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + if (!rateLimit.userId) { + throw new Error('Allowed public API request is missing a user ID') + } + const userId = rateLimit.userId + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(contract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + ...parseOptions, + }) + if (!parsed.success) return parsed.response + + return handler({ + request, + input: parsed.data, + auth: { requestId, userId, rateLimit }, + }) + }, + { + unhandledErrorResponse: () => v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} 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/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 108e9fc534e..12066f2f9cc 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -1,13 +1,11 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -38,36 +36,7 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - 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] + const log = await getPublicWorkflowLog({ column: 'id', value: id }) if (!log) { return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts new file mode 100644 index 00000000000..e186a4f1558 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/utils.ts @@ -0,0 +1,22 @@ +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' + +/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ +export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} 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.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx new file mode 100644 index 00000000000..c2f4e9b6005 --- /dev/null +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -0,0 +1,139 @@ +/** + * @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 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 default workspace') + }) + + it('does not present a workspace choice as final while loading', () => { + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(container.textContent).toContain('Loading your workspace options') + 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('personal key') + expect(container.textContent).toContain('makes Acme the CLI default') + }) + + it('issues a personal key even when the approver is a workspace admin', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + act(() => { + connectButton().click() + }) + + expect(mockApprove).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'platform', + workspaceId: 'ws_admin', + bindKeyToWorkspace: false, + }), + 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 96ad0648ffc..f7865af95da 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 "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 @@ -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 default workspace', value: NO_DEFAULT_WORKSPACE_VALUE }] + }, [workspaces.data]) if (!resolution.valid) { return ( @@ -41,6 +59,34 @@ 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 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 + + /** + * 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) + return (
+ {isPlatform && ( +
+ + 8} + searchPlaceholder='Search workspaces' + fullWidth + dropdownWidth='trigger' + /> +

+ {loadingWorkspaces + ? '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.' + : 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.'} +

+
+ )} approve.mutate( - { request: request.request, challenge: request.challenge }, + { + request: request.request, + challenge: request.challenge, + scope: request.scope, + // 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: false, + }, { onSuccess: () => router.push('/cli/auth/done') } ) } 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/blocks/blocks/browser_use.ts b/apps/sim/blocks/blocks/browser_use.ts index 193a29d8329..6d0671d1867 100644 --- a/apps/sim/blocks/blocks/browser_use.ts +++ b/apps/sim/blocks/blocks/browser_use.ts @@ -41,6 +41,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 bb31bfe5eab..7611c690036 100644 --- a/apps/sim/blocks/blocks/codepipeline.ts +++ b/apps/sim/blocks/blocks/codepipeline.ts @@ -289,6 +289,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 5fb3d01153b..a9d7d9b93fa 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -446,6 +446,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 d3252b25438..1aeaf91c8d5 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -484,6 +484,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 d3867e13df0..74fad7758a5 100644 --- a/apps/sim/blocks/blocks/secrets_manager.ts +++ b/apps/sim/blocks/blocks/secrets_manager.ts @@ -138,6 +138,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 5c88b787ae5..62181bdaabb 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -100,6 +100,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 74d0c570aac..a991a0ea6c8 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -150,6 +150,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 6cb1a05048f..e18134b9483 100644 --- a/apps/sim/blocks/blocks/sts.ts +++ b/apps/sim/blocks/blocks/sts.ts @@ -130,6 +130,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' }, @@ -155,6 +156,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' }, @@ -240,6 +242,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 f29968b3061..c971e41eb53 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -271,6 +271,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-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index f285bf0fe3f..fe7d2e40948 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 @@ -23,11 +35,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/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/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/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..5ae6a5509f2 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -1,5 +1,5 @@ { - "name": "sim", + "name": "@sim/app", "version": "0.1.0", "private": true, "license": "Apache-2.0", diff --git a/bun.lock b/bun.lock index efb1d9c2298..78d53a9db71 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -134,7 +135,7 @@ }, }, "apps/sim": { - "name": "sim", + "name": "@sim/app", "version": "0.1.0", "dependencies": { "@1password/sdk": "0.3.1", @@ -585,13 +586,35 @@ "vitest": "^4.1.0", }, }, + "packages/sim-cli": { + "name": "sim", + "version": "2.0.0", + "bin": { + "sim": "dist/index.js", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", + "@types/node": "24.2.1", + "@xterm/headless": "6.0.0", + "chalk": "5.6.2", + "commander": "^11.1.0", + "js-yaml": "4.3.0", + "typescript": "^7.0.2", + "vitest": "^4.1.0", + }, + }, "packages/terminal-protocol": { "name": "@sim/terminal-protocol", "version": "0.1.0", + "dependencies": { + "@sim/utils": "workspace:*", + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", "typescript": "^7.0.2", + "vitest": "^4.1.0", }, }, "packages/testing": { @@ -1725,6 +1748,8 @@ "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sim/app": ["@sim/app@workspace:apps/sim"], + "@sim/audit": ["@sim/audit@workspace:packages/audit"], "@sim/auth": ["@sim/auth@workspace:packages/auth"], @@ -1857,7 +1882,7 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -2459,7 +2484,7 @@ "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=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -3399,7 +3424,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], @@ -3935,7 +3960,7 @@ "readdir-glob": ["readdir-glob@3.0.0", "", { "dependencies": { "minimatch": "^10.2.2" } }, "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -4117,7 +4142,7 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "sim": ["sim@workspace:apps/sim"], + "sim": ["sim@workspace:packages/sim-cli"], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -4253,7 +4278,7 @@ "tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="], - "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], @@ -4537,6 +4562,8 @@ "@a2a-js/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@apidevtools/json-schema-ref-parser/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4569,6 +4596,8 @@ "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4627,6 +4656,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@fumadocs/tailwind/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -4781,7 +4812,7 @@ "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="], - "@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@react-email/tailwind/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "@reactflow/background/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], @@ -4807,6 +4838,8 @@ "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -4821,6 +4854,10 @@ "@tailwindcss/postcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + + "@tiptap/markdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "@trigger.dev/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], "@trigger.dev/core/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], @@ -4883,6 +4920,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/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "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=="], @@ -4923,8 +4962,6 @@ "builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], - "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "c12/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -4971,6 +5008,8 @@ "docs/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "docs/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "docx/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], @@ -4989,6 +5028,8 @@ "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], + "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "electron/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -5035,12 +5076,18 @@ "fumadocs-core/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "fumadocs-mdx/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "fumadocs-mdx/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "fumadocs-mdx/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "fumadocs-openapi/@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=="], "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "fumadocs-openapi/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "fumadocs-openapi/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], @@ -5133,8 +5180,6 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -5169,13 +5214,11 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "react-email/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], "react-email/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "react-email/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "react-email/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -5195,7 +5238,7 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + "sim/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -5209,6 +5252,8 @@ "stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "streamdown/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -5225,6 +5270,14 @@ "svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], "teeny-request/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -5435,8 +5488,6 @@ "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -5523,6 +5574,10 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "fumadocs-mdx/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "fumadocs-openapi/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "giget/nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], @@ -5615,21 +5670,17 @@ "protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "sim/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - - "sim/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sim/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sim/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tailwindcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -5679,14 +5730,8 @@ "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "sim/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "sim/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - - "sim/tailwindcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - - "sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], } } diff --git a/package.json b/package.json index 5ecfb3ab44f..332dd51c4bc 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", "generate:openapi": "bun run scripts/generate-openapi.ts", "check:openapi": "bun run scripts/check-openapi.ts", + "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", + "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", "check:cron-parity": "bun run scripts/check-cron-parity.ts", "check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline", "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", diff --git a/packages/sim-cli/LICENSE b/packages/sim-cli/LICENSE new file mode 100644 index 00000000000..f4e76aaaac1 --- /dev/null +++ b/packages/sim-cli/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Sim Studio, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md new file mode 100644 index 00000000000..979c2b77142 --- /dev/null +++ b/packages/sim-cli/README.md @@ -0,0 +1,331 @@ +# Sim CLI + +Talk to the [Sim](https://sim.ai) API from your terminal. + +```bash +npm install --global sim +sim login +sim workflows list +``` + +Prerelease channels track the corresponding Sim environments: + +```bash +npm install --global sim@staging # staging +npm install --global sim@dev # dev +``` + +## Profiles + +Profiles work like the AWS CLI: one identity and one set of defaults per named +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`: + +```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`) | + +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. + +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 + 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. `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. + +`sim logout` removes the stored key. It does not revoke it — do that in +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 get`, 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 update [--name ] [--description ] [--folder ] +sim workflows mv +sim workflows deploy|undeploy|rollback +sim workflows run [--input ] [--select-output …] [--async] +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 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 +sim tables update [--name ] [--description ] [--folder ] +sim tables mv +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 + +sim files ls [path] [--search ] [--limit ] +sim files list [--folder ] +sim files describe +sim files get [-o ] # stdout by default +sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] +sim files upload [--name ] [--folder ] +sim files share get +sim files share set --is-active [--auth-type public|password|email|sso] +sim files mv --file-ids … [--to ] +sim files batch-delete --file-ids … --yes +sim files delete --yes + +sim knowledge ls [path] [--search ] [--limit ] +sim knowledge list [--folder ] +sim knowledge get +sim knowledge update [--name ] [--description ] [--folder ] +sim knowledge mv +sim knowledge search --query --kb … [--search-mode vector|hybrid] + +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] +``` + +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 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. + +`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 --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 +`--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 +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 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 --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 API inputs; the API +returns the canonical leading-slash form. Omit the `ls` path to list root. + +### 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 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 +flat list without losing structure. + +### 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 query 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. + +### Output formats + +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 | +| --- | --- | +| `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 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 '.[].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 + echo "$id $name" +done +``` + +An absent value is an em-dash in `table` and an **empty field** in `text`, so +emptiness tests downstream behave. + +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 + +`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 # 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 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 + +- Commands talk to the `/api/v2` surface, which returns `{ data }` and + `{ data, nextCursor }`. List commands auto-page up to `--limit`. + +## License + +Apache-2.0 diff --git a/packages/sim-cli/THIRD_PARTY_LICENSES b/packages/sim-cli/THIRD_PARTY_LICENSES new file mode 100644 index 00000000000..f8ff0105dc1 --- /dev/null +++ b/packages/sim-cli/THIRD_PARTY_LICENSES @@ -0,0 +1,30 @@ +The Sim CLI bundle includes the following third-party software. + +chalk +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +commander +Copyright (c) 2011 TJ Holowaychuk + +js-yaml +Copyright (C) 2011-2015 by Vitaly Puzrin + +Each dependency above is licensed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json new file mode 100644 index 00000000000..432ee0f3fb3 --- /dev/null +++ b/packages/sim-cli/package.json @@ -0,0 +1,60 @@ +{ + "name": "sim", + "version": "2.0.0", + "description": "Sim CLI - talk to the Sim API from your terminal", + "type": "module", + "bin": { + "sim": "dist/index.js" + }, + "scripts": { + "prebuild": "bun run clean", + "build": "bun build src/index.ts --target=node --format=esm --packages=bundle --reject-unresolved --outfile=dist/index.js", + "clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", + "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", + "THIRD_PARTY_LICENSES" + ], + "keywords": [ + "sim", + "ai", + "agents", + "cli", + "workflow" + ], + "author": "Sim", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/simstudioai/sim.git", + "directory": "packages/sim-cli" + }, + "homepage": "https://github.com/simstudioai/sim/tree/main/packages/sim-cli#readme", + "bugs": { + "url": "https://github.com/simstudioai/sim/issues" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", + "@types/node": "24.2.1", + "@xterm/headless": "6.0.0", + "chalk": "5.6.2", + "commander": "^11.1.0", + "js-yaml": "4.3.0", + "typescript": "^7.0.2", + "vitest": "^4.1.0" + } +} 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..4b2747c53ce --- /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' + +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 new file mode 100644 index 00000000000..198f3610c30 --- /dev/null +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -0,0 +1,175 @@ +import { createHash, randomBytes, randomInt } from 'node:crypto' +import { sleep } from '../helpers' +import { SimApiError } from '../http/client' + +/** + * 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 + +/** + * 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 { + /** 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) { + if (!RETRYABLE_POLL_STATUSES.has(response.status)) { + 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 sleep(POLL_INTERVAL_MS) + } + + throw new SimApiError('Timed out waiting for browser approval.', 0) +} 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..a75fe6174ee --- /dev/null +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -0,0 +1,260 @@ +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + buildApprovalUrl: vi.fn(() => 'https://sim.ai/cli/auth?code=ABCD'), + createAuthRequest: vi.fn(() => ({ pairing: 'ABCD', verifier: 'verifier' })), + createInterface: vi.fn(), + listProfiles: vi.fn<() => string[]>(() => []), + readCredentialsProfile: vi.fn<() => Record>(() => ({})), + pollForKey: vi.fn(async () => ({ + apiKey: 'sim-key', + scope: 'platform' as const, + workspaceBound: false, + workspaceId: 'ws_1' as string | undefined, + })), + profileFrom: vi.fn(() => ({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null as string | null, + workspaceId: null as string | null, + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'default', + }, + })), + writeConfigProfile: vi.fn(), + writeCredentialsProfile: vi.fn(), +})) + +vi.mock('node:readline/promises', () => ({ createInterface: mocks.createInterface })) +vi.mock('../auth/device-flow', () => ({ + buildApprovalUrl: mocks.buildApprovalUrl, + createAuthRequest: mocks.createAuthRequest, + pollForKey: mocks.pollForKey, +})) +vi.mock('../config/index', () => ({ + credentialsPath: () => '/tmp/sim-credentials', + deleteProfile: vi.fn(), + listProfiles: mocks.listProfiles, + readCredentialsProfile: mocks.readCredentialsProfile, + writeConfigProfile: mocks.writeConfigProfile, + writeCredentialsProfile: mocks.writeCredentialsProfile, +})) +vi.mock('../context', () => ({ profileFrom: mocks.profileFrom })) + +import { loginCommand, profilesCommand, whoamiCommand } from './auth' + +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + +function setInteractive(value: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value }) +} + +async function login(...args: string[]): Promise { + const root = new Command('sim').exitOverride() + root.addCommand(loginCommand()) + await root.parseAsync(['node', 'sim', 'login', '--no-browser', ...args]) +} + +async function whoami(...args: string[]): Promise { + const root = new Command('sim').exitOverride() + root.addCommand(whoamiCommand()) + await root.parseAsync(['node', 'sim', 'whoami', ...args]) +} + +describe('login command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listProfiles.mockReturnValue([]) + mocks.readCredentialsProfile.mockReturnValue({}) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: null, + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'default', + }, + }) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + }) + mocks.createInterface.mockReturnValue({ + question: vi.fn(async () => 'yes'), + close: vi.fn(), + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + if (originalIsTTY) Object.defineProperty(process.stdin, 'isTTY', originalIsTTY) + else Reflect.deleteProperty(process.stdin, 'isTTY') + }) + + it('does not prompt when the profile is new', async () => { + setInteractive(false) + await login() + + expect(mocks.createInterface).not.toHaveBeenCalled() + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('requires --yes before overwriting non-interactively', async () => { + setInteractive(false) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'existing-key' }) + + await expect(login()).rejects.toThrow( + 'Profile "default" already exists. Re-run with --yes to overwrite it.' + ) + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + + await login('--yes') + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('continues only when an interactive overwrite is confirmed', async () => { + setInteractive(true) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'existing-key' }) + const question = vi.fn(async () => 'yes') + const close = vi.fn() + mocks.createInterface.mockReturnValue({ question, close }) + + await login() + + expect(question).toHaveBeenCalledWith( + 'Profile "default" already exists. Replace its API key and login defaults? (y/N) ' + ) + expect(close).toHaveBeenCalledOnce() + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('leaves the profile unchanged when confirmation is declined', async () => { + setInteractive(true) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'existing-key' }) + mocks.createInterface.mockReturnValue({ + question: vi.fn(async () => 'no'), + close: vi.fn(), + }) + + await login() + + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('does not prompt for a config-only or logged-out profile', async () => { + setInteractive(false) + mocks.listProfiles.mockReturnValue(['default']) + mocks.readCredentialsProfile.mockReturnValue({}) + + await login() + + expect(mocks.createInterface).not.toHaveBeenCalled() + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('clears a stale workspace default when none is selected during login', async () => { + setInteractive(false) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: 'ws_old', + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'config', + output: 'default', + }, + }) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: undefined, + }) + + await login() + + expect(mocks.writeConfigProfile).toHaveBeenCalledWith('default', { + endpoint: 'https://sim.ai', + workspace: null, + }) + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('no default workspace')) + }) +}) + +describe('profiles command', () => { + it('accepts the singular profile alias', () => { + expect(profilesCommand().alias()).toBe('profile') + }) +}) + +describe('whoami command', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('reports authentication without exposing any part of the API key', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'sim_super_secret_value', + workspaceId: 'ws_1', + output: 'text', + sources: { + endpoint: 'default', + apiKey: 'credentials', + workspaceId: 'config', + output: 'flag', + }, + }) + + await whoami() + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('API key\tconfigured (credentials)') + expect(output).not.toContain('sim_super_secret_value') + expect(output).not.toContain('secret') + }) + + it('uses non-secret-shaped authentication metadata in machine output', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'sim_super_secret_value', + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'credentials', + workspaceId: 'config', + output: 'flag', + }, + }) + + await whoami() + + const output = String(vi.mocked(console.log).mock.calls[0][0]) + expect(JSON.parse(output)).toMatchObject({ + authenticated: true, + sources: { authentication: 'credentials' }, + }) + expect(output).not.toContain('apiKey') + expect(output).not.toContain('sim_super_secret_value') + }) +}) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts new file mode 100644 index 00000000000..242683ca644 --- /dev/null +++ b/packages/sim-cli/src/commands/auth.ts @@ -0,0 +1,266 @@ +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline/promises' +import chalk from 'chalk' +import { Command } from 'commander' +import { + buildApprovalUrl, + type CliAuthScope, + createAuthRequest, + pollForKey, +} from '../auth/device-flow' +import { + credentialsPath, + deleteProfile, + listProfiles, + readCredentialsProfile, + type SettingSource, + writeConfigProfile, + writeCredentialsProfile, +} from '../config/index' +import { profileFrom } from '../context' +import { SimApiError } from '../http/client' +import { printRecord } from '../output/render' + +/** + * 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 { + /** + * 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, args, { stdio: 'ignore', detached: true }) + child.on('error', () => {}) + child.unref() + } catch {} +} + +function presentAuthentication(source: SettingSource): { + authenticated: boolean + source: SettingSource +} { + switch (source) { + case 'flag': + return { authenticated: true, source: 'flag' } + case 'env': + return { authenticated: true, source: 'env' } + case 'credentials': + return { authenticated: true, source: 'credentials' } + case 'unset': + return { authenticated: false, source: 'unset' } + case 'config': + case 'default': + throw new SimApiError(`Unexpected API key source "${source}".`, 0) + } +} + +async function confirmProfileOverwrite(profileName: string): Promise { + if (!process.stdin.isTTY) { + throw new SimApiError( + `Profile "${profileName}" already exists. Re-run with --yes to overwrite it.`, + 0 + ) + } + + const prompt = createInterface({ input: process.stdin, output: process.stderr }) + try { + const answer = await prompt.question( + `Profile "${profileName}" already exists. Replace its API key and login defaults? (y/N) ` + ) + return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes' + } finally { + prompt.close() + } +} + +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') + .option('-y, --yes', 'Overwrite an existing profile without prompting') + .action( + async (options: { scope: string; browser: boolean; yes?: 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 + + if (readCredentialsProfile(profile.name).api_key && !options.yes) { + const confirmed = await confirmProfileOverwrite(profile.name) + if (!confirmed) { + console.log(chalk.dim('Login cancelled; the existing profile was not changed.')) + return + } + } + + 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, + workspace: key.workspaceId ?? null, + } + 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 { + 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 authentication = presentAuthentication(sources.apiKey) + + 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', + authentication.authenticated + ? annotate('configured', authentication.source) + : 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: authentication.authenticated, + sources: { + endpoint: sources.endpoint, + authentication: authentication.source, + workspaceId: sources.workspaceId, + output: sources.output, + }, + } + ) + }) +} + +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() + 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..88879206b55 --- /dev/null +++ b/packages/sim-cli/src/commands/configure.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { configPath, OUTPUT_FORMATS, readConfigProfile, writeConfigProfile } from '../config/index' +import { profileFrom } from '../context' +import { SimApiError } from '../http/client' + +/** + * 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/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts new file mode 100644 index 00000000000..842e8022f58 --- /dev/null +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -0,0 +1,241 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../runtime/build' +import { attachCredentialCommands } from './credentials' + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'table' }, +})) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + client: { + request: mockRequest, + requireWorkspace: () => 'ws_local', + }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'key', + }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachCredentialCommands(root) + return root +} + +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 +} + +describe('credential connection commands', () => { + beforeEach(() => { + vi.restoreAllMocks() + output.format = 'table' + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('discovers and validates a service-account provider before creating it', async () => { + mockRequest + .mockReset() + .mockResolvedValueOnce({ + data: [ + { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/zoom', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Account ID', + required: true, + secret: false, + multiline: false, + }, + ], + }, + ], + nextCursor: null, + }) + .mockResolvedValueOnce({ + data: { + id: 'cred_123', + type: 'service_account', + displayName: 'Production Zoom', + description: null, + providerId: 'zoom-service-account', + accountId: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2026-08-12T20:15:00.000Z', + updatedAt: '2026-08-12T20:15:00.000Z', + }, + }) + + await program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret","orgId":"account"}', + ]) + + expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/v2/credentials/providers', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) + expect(mockRequest).toHaveBeenNthCalledWith(2, '/api/v2/credentials', { + method: 'POST', + body: { + workspaceId: 'ws_local', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client', + clientSecret: 'secret', + orgId: 'account', + }, + }) + }) + + it('exposes one provider-shaped credential object instead of every provider secret', () => { + const help = commandAt('credentials', 'create').helpInformation() + + expect(help).toContain('') + expect(help).toContain('--credentials ') + expect(help).not.toContain('--type') + expect(help).not.toContain('--client-secret') + expect(help).not.toContain('--service-account-json') + }) + + it('rejects missing and unsupported provider fields before creation', async () => { + mockRequest.mockReset().mockResolvedValue({ + data: [ + { + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + requiresClientGeneratedCredentialId: false, + fields: [ + { id: 'clientId', required: true }, + { id: 'clientSecret', required: true }, + { id: 'orgId', required: true }, + ], + }, + ], + nextCursor: null, + }) + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret"}', + ]) + ).rejects.toThrow('missing required fields for zoom-service-account: orgId') + expect(mockRequest).toHaveBeenCalledTimes(1) + + mockRequest.mockClear() + await expect( + program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret","orgId":"account","extra":"no"}', + ]) + ).rejects.toThrow('unsupported field "extra" for zoom-service-account') + expect(mockRequest).toHaveBeenCalledTimes(1) + }) + + it('creates and prints a new-provider connection link', async () => { + await program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'connect', + 'google-email', + '--name', + 'Work Gmail', + ]) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/credentials/connections', { + method: 'POST', + body: { + workspaceId: 'ws_local', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1' + ) + }) + + it('creates a reconnect link for an existing credential', async () => { + output.format = 'json' + await program().parseAsync(['node', 'sim', 'credentials', 'reconnect', 'cred_1']) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/credentials/connections', { + method: 'POST', + body: { workspaceId: 'ws_local', credentialId: 'cred_1' }, + }) + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain('authorizationUrl') + }) +}) diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts new file mode 100644 index 00000000000..39b7684f978 --- /dev/null +++ b/packages/sim-cli/src/commands/credentials.ts @@ -0,0 +1,195 @@ +import type { Command } from 'commander' +import { clientFrom } from '../context' +import type { CommandSpec } from '../contract/types' +import { + type CreateCredentialConnectionResponse, + type CreateServiceAccountCredentialResponse, + type ListCredentialProvidersResponse, + V2_OPERATIONS, +} from '../generated/v2-api' +import { SimApiError } from '../http/client' +import { coerce } from '../runtime/request' +import { renderResult } from '../runtime/result' + +const CONNECTION_RESULT: CommandSpec = { + fields: [ + { header: 'connection link', path: 'authorizationUrl' }, + { header: 'expires', path: 'expiresAt', format: 'timestamp' }, + ], +} + +const SERVICE_ACCOUNT_RESULT: CommandSpec = { + fields: [ + { header: 'id' }, + { header: 'name', path: 'displayName' }, + { header: 'provider', path: 'providerId' }, + { header: 'role' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + ], +} + +type ConnectionBody = { providerId: string; displayName: string } | { credentialId: string } +type CredentialProvider = ListCredentialProvidersResponse['data'][number] +type ServiceAccountProvider = Extract + +interface CreateServiceAccountOptions { + credentials: string + description?: string + id?: string + name: string +} + +function serviceAccountProvider( + providers: CredentialProvider[], + providerId: string +): ServiceAccountProvider { + const provider = providers.find( + (candidate): candidate is ServiceAccountProvider => + candidate.type === 'service_account' && candidate.providerId === providerId + ) + if (!provider) { + throw new SimApiError(`Unknown service-account provider "${providerId}".`, 0) + } + if (!provider.available) { + throw new SimApiError(`Service-account provider "${providerId}" is not available.`, 0) + } + return provider +} + +function credentialValues(provider: ServiceAccountProvider, raw: string): Record { + const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'credentials') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new SimApiError('--credentials must be a JSON object', 0) + } + + const values = parsed as Record + const fields = new Map(provider.fields.map((field) => [field.id, field])) + for (const [id, value] of Object.entries(values)) { + const field = fields.get(id) + if (!field) { + throw new SimApiError( + `--credentials contains unsupported field "${id}" for ${provider.providerId}.`, + 0 + ) + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new SimApiError(`--credentials.${id} must be a non-empty string.`, 0) + } + if (field.options && !field.options.some((option) => option.value === value)) { + throw new SimApiError( + `--credentials.${id} must be one of: ${field.options.map((option) => option.value).join(', ')}.`, + 0 + ) + } + } + + const authMethod = typeof values.authMethod === 'string' ? values.authMethod : undefined + const missing = provider.fields + .filter( + (field) => + field.required || + (authMethod !== undefined && field.requiredForAuthMethods?.includes(authMethod)) + ) + .filter((field) => values[field.id] === undefined) + .map((field) => field.id) + if (missing.length > 0) { + throw new SimApiError( + `--credentials is missing required fields for ${provider.providerId}: ${missing.join(', ')}.`, + 0 + ) + } + + return values as Record +} + +async function createServiceAccount( + command: Command, + providerId: string, + options: CreateServiceAccountOptions +): Promise { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const discovery = V2_OPERATIONS.listCredentialProviders + const catalog = await client.request(discovery.path, { + method: discovery.method, + query: { workspaceId }, + }) + const provider = serviceAccountProvider(catalog.data, providerId) + if (provider.requiresClientGeneratedCredentialId && !options.id) { + throw new SimApiError(`--id is required for ${providerId}.`, 0) + } + + const credentials = credentialValues(provider, options.credentials) + const operation = V2_OPERATIONS.createServiceAccountCredential + const response = await client.request(operation.path, { + method: operation.method, + body: { + workspaceId, + type: 'service_account', + providerId, + displayName: options.name, + ...(options.description ? { description: options.description } : {}), + ...(options.id ? { id: options.id } : {}), + ...credentials, + }, + }) + + renderResult( + 'createServiceAccountCredential', + profile.output, + response.data, + SERVICE_ACCOUNT_RESULT + ) +} + +async function createConnectionLink(command: Command, body: ConnectionBody): Promise { + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS.createCredentialConnection + const response = await client.request(operation.path, { + method: operation.method, + body: { + workspaceId: client.requireWorkspace(), + ...body, + }, + }) + + renderResult('createCredentialConnection', profile.output, response.data, CONNECTION_RESULT) +} + +/** Adds the human-facing OAuth connection commands backed by the v2 credentials API. */ +export function attachCredentialCommands(program: Command): void { + const credentials = program.commands.find((command) => command.name() === 'credentials') + if (!credentials) throw new Error('The generated credentials command group is missing') + + credentials + .command('create ') + .description('Create a service-account credential using its discovered provider schema') + .requiredOption('--name ', 'Name shown for the credential in Sim') + .requiredOption( + '--credentials ', + 'Provider credentials as JSON (or @path / @- to read a file or stdin)' + ) + .option('--description ', 'Optional credential description') + .option( + '--id ', + 'Client-generated credential ID when provider discovery requires it' + ) + .action((providerId: string, options: CreateServiceAccountOptions, command: Command) => + createServiceAccount(command, providerId, options) + ) + + credentials + .command('connect ') + .description('Create a short-lived link for connecting an OAuth provider') + .requiredOption('--name ', 'Name shown for the new credential in Sim') + .action(async (providerId: string, options: { name: string }, command: Command) => + createConnectionLink(command, { providerId, displayName: options.name }) + ) + + credentials + .command('reconnect ') + .description('Create a short-lived link for reconnecting an OAuth credential') + .action((credentialId: string, _options: unknown, command: Command) => + createConnectionLink(command, { credentialId }) + ) +} diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts new file mode 100644 index 00000000000..fe948283df8 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -0,0 +1,274 @@ +import { + createWriteStream, + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Writable } from 'node:stream' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build' +import { isTerminalSafeContentType, saveToFile, streamToFile } from './files-get' +import { attachProtocolCommands } from './index' + +const { output, requestRaw } = vi.hoisted(() => ({ + output: { format: 'json' }, + requestRaw: vi.fn(), +})) + +vi.mock('../../context', () => ({ + clientFrom: () => ({ + client: { requestRaw, 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' + requestRaw.mockReset() +}) + +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 failingBody(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('partial')) + controller.error(new Error('connection lost')) + }, + }) +} + +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/) + } + ) + + it('cancels the response body and waits for the pump when writing fails', async () => { + const cancelled = vi.fn() + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('first chunk')) + }, + cancel: cancelled, + }) + const target = join(dir, 'out.txt') + const destination = Object.assign( + new Writable({ + write(_chunk, _encoding, callback) { + const error = Object.assign(new Error('disk full'), { code: 'ENOSPC' }) + callback(error) + }, + }), + { path: target } + ) + + await expect(streamToFile(body, destination)).rejects.toThrow( + `Could not write ${target}: disk full` + ) + expect(cancelled).toHaveBeenCalledOnce() + }) +}) + +describe('saveToFile', () => { + it('preserves the original destination when a forced download fails', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + + await expect(saveToFile(failingBody(), target, true)).rejects.toThrow(/connection lost/) + + expect(readFileSync(target, 'utf8')).toBe('precious') + }) + + it('leaves no partial destination when a new download fails', async () => { + const target = join(dir, 'out.txt') + + await expect(saveToFile(failingBody(), target, false)).rejects.toThrow(/connection lost/) + + expect(existsSync(target)).toBe(false) + }) + + it('preserves an existing destination without --force', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + + await expect(saveToFile(bodyOf(['new']), target, false)).rejects.toThrow( + /already exists.*--force/s + ) + + expect(readFileSync(target, 'utf8')).toBe('precious') + }) + + it('publishes a completed forced download over the original', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'old') + + await saveToFile(bodyOf(['new']), target, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + }) + + it('preserves a forced symlink destination and replaces its target', async () => { + const target = join(dir, 'target.txt') + const link = join(dir, 'link.txt') + writeFileSync(target, 'old') + symlinkSync(target, link) + + await saveToFile(bodyOf(['new']), link, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + expect(readFileSync(link, 'utf8')).toBe('new') + expect(lstatSync(link).isSymbolicLink()).toBe(true) + }) + + it('preserves a dangling forced symlink and creates its target', async () => { + const target = join(dir, 'missing.txt') + const link = join(dir, 'link.txt') + symlinkSync('missing.txt', link) + + await saveToFile(bodyOf(['new']), link, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + expect(readFileSync(link, 'utf8')).toBe('new') + expect(lstatSync(link).isSymbolicLink()).toBe(true) + }) +}) + +describe('isTerminalSafeContentType', () => { + it('accepts text formats and rejects binary or unknown formats', () => { + expect(isTerminalSafeContentType('text/markdown; charset=utf-8')).toBe(true) + expect(isTerminalSafeContentType('application/problem+json')).toBe(true) + expect(isTerminalSafeContentType('application/pdf')).toBe(false) + expect(isTerminalSafeContentType(null)).toBe(false) + }) +}) + +describe('files get', () => { + it('prints a normalized machine-readable result', async () => { + const target = join(dir, 'download.txt') + requestRaw.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', 'get', 'file_1', '--output-file', target]) + + expect(JSON.parse(logged[0])).toEqual({ + id: 'file_1', + path: target, + status: 'saved', + }) + expect(requestRaw).toHaveBeenCalledWith('/api/v2/files/file_1', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) + }) + + it('streams raw bytes to stdout by default', async () => { + 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) + return true + }) + const logged = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'file', 'get', 'file_1']) + + expect(Buffer.concat(chunks).toString('utf8')).toBe('downloaded') + expect(logged).not.toHaveBeenCalled() + }) + + it.each([ + ['without an output path', ['--force']], + ['with the stdout alias', ['-o', '-', '--force']], + ])('rejects --force %s', async (_label, args) => { + await expect( + program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', ...args]) + ).rejects.toThrow(/--force requires --output-file /) + expect(requestRaw).not.toHaveBeenCalled() + }) + + it('refuses binary content when stdout is an interactive terminal', async () => { + requestRaw.mockResolvedValue( + new Response(new Uint8Array([0, 1, 2]), { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }) + ) + const originalDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) + + try { + await expect(program().parseAsync(['node', 'sim', 'file', 'get', 'file_1'])).rejects.toThrow( + /Refusing to write application\/octet-stream.*--output-file/s + ) + } finally { + if (originalDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', originalDescriptor) + } else { + Reflect.deleteProperty(process.stdout, 'isTTY') + } + } + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts new file mode 100644 index 00000000000..ae325c09816 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -0,0 +1,227 @@ +import { once } from 'node:events' +import { createWriteStream, type WriteStream } from 'node:fs' +import { link, lstat, mkdtemp, readlink, rename, rm } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { Readable, type Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import type { Command } from 'commander' +import { clientFrom } from '../../context' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { resolvePath, SimApiError } from '../../http/client' +import { printProtocolResult } from './result' + +function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + return new SimApiError( + `${path} already exists. Pass --force to overwrite it, or choose another output path.`, + 0 + ) + } + return new SimApiError(`Could not write ${path}: ${(error as Error).message}`, 0) +} + +async function forcedPublicationTarget(target: string): Promise { + let candidate = target + const visited = new Set() + + while (true) { + const absoluteCandidate = resolve(candidate) + if (visited.has(absoluteCandidate)) { + throw Object.assign(new Error(`Symbolic link loop at ${target}`), { code: 'ELOOP' }) + } + visited.add(absoluteCandidate) + + let metadata + try { + metadata = await lstat(candidate) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return candidate + throw error + } + + if (!metadata.isSymbolicLink()) return candidate + candidate = resolve(dirname(candidate), await readlink(candidate)) + } +} + +function normalizedWriteFailure(target: string, error: unknown): SimApiError { + return error instanceof SimApiError ? error : writeFailure(target, error) +} + +function combinedCleanupFailure( + failure: SimApiError, + temporaryPath: string, + cleanupError: unknown +): SimApiError { + return new SimApiError( + `${failure.message} Cleanup also failed for ${temporaryPath}: ${(cleanupError as Error).message}`, + 0 + ) +} + +function unsupportedAtomicPublish(target: string, error: unknown): SimApiError | null { + const code = (error as NodeJS.ErrnoException).code + if (!['ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM'].includes(code ?? '')) return null + return new SimApiError( + `Could not publish ${target} without overwrite protection because this filesystem does not support atomic hard links. Re-run with --force to publish the completed download with an atomic rename.`, + 0 + ) +} + +/** Streams a fetch body to disk while honoring write-stream backpressure. */ +export async function streamToFile( + body: ReadableStream, + file: Writable & Pick, + reportedPath: WriteStream['path'] = file.path +): Promise { + try { + await pipeline(Readable.fromWeb(body as Parameters[0]), file) + } catch (error) { + throw writeFailure(reportedPath, error) + } +} + +async function saveStagedFile( + body: ReadableStream, + target: string, + force: boolean +): Promise { + let temporaryDirectory: string | null = null + let failure: SimApiError | null = null + + try { + const publicationTarget = force ? await forcedPublicationTarget(target) : target + temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-')) + const temporaryPath = join(temporaryDirectory, 'payload') + await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) + if (force) { + await rename(temporaryPath, publicationTarget) + } else { + try { + await link(temporaryPath, publicationTarget) + } catch (error) { + throw unsupportedAtomicPublish(target, error) ?? error + } + } + } catch (error) { + failure = normalizedWriteFailure(target, error) + } + + if (temporaryDirectory) { + try { + await rm(temporaryDirectory, { recursive: true, force: true }) + } catch (cleanupError) { + if (failure) throw combinedCleanupFailure(failure, temporaryDirectory, cleanupError) + throw new SimApiError( + `Saved ${target}, but could not remove temporary directory ${temporaryDirectory}: ${(cleanupError as Error).message}`, + 0 + ) + } + } + + if (failure) throw failure +} + +/** Publishes a complete staged body atomically, with overwrite requiring explicit force. */ +export async function saveToFile( + body: ReadableStream, + target: string, + force: boolean +): Promise { + return saveStagedFile(body, target, force) +} + +/** 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() + } +} + +/** Returns whether content can be written directly to an interactive terminal. */ +export function isTerminalSafeContentType(contentType: string | null): boolean { + if (!contentType) return false + + const mediaType = contentType.split(';', 1)[0].trim().toLowerCase() + return ( + mediaType.startsWith('text/') || + mediaType.endsWith('+json') || + mediaType.endsWith('+xml') || + [ + 'application/graphql', + 'application/javascript', + 'application/json', + 'application/sql', + 'application/x-javascript', + 'application/x-yaml', + 'application/xml', + 'application/yaml', + 'image/svg+xml', + ].includes(mediaType) + ) +} + +export function attachFileGet(files: Command): void { + files + .command('get ') + .description('Get a file’s content') + .option('-o, --output-file ', 'Write content to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action( + async ( + fileId: string, + options: { outputFile?: string; force?: boolean }, + command: Command + ) => { + const writesToStdout = options.outputFile === undefined || options.outputFile === '-' + if (writesToStdout && options.force) { + throw new SimApiError('--force requires --output-file ', 0) + } + + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const operation = V2_OPERATIONS.downloadFile + const response = await client.requestRaw(resolvePath(operation.path, { fileId }), { + method: operation.method, + query: { workspaceId }, + }) + if (!response.body) { + throw new SimApiError('File content response was empty.', response.status) + } + + if (options.outputFile === undefined || options.outputFile === '-') { + const contentType = response.headers.get('content-type') + if (process.stdout.isTTY && !isTerminalSafeContentType(contentType)) { + await response.body.cancel() + throw new SimApiError( + `Refusing to write ${contentType ?? 'unknown content'} to an interactive terminal. Use --output-file or pipe stdout.`, + 0 + ) + } + + await streamToStdout(response.body) + return + } + + const target = options.outputFile + + await saveToFile(response.body, target, Boolean(options.force)) + 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 new file mode 100644 index 00000000000..c56ef989fd8 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -0,0 +1,142 @@ +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' +import { attachProtocolCommands } from './index' + +const { mockRequest } = vi.hoisted(() => ({ + mockRequest: vi.fn(), +})) + +vi.mock('../../context', () => ({ + 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 without a request 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', + folderPath: '/', + 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, '--folder', 'Reports']) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://storage.example/file', + expect.objectContaining({ + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + 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', + { + method: 'POST', + query: { workspaceId: 'ws_local' }, + headers: { 'upload-token': 'secret-token' }, + }, + ]) + expect(JSON.parse(logged[0])).toEqual({ + id: 'file_1', + name: 'notes.txt', + size: 5, + 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 new file mode 100644 index 00000000000..99dfe3c9418 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -0,0 +1,51 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context' +import type { CompleteFileUploadResponse, CreateFileUploadResponse } from '../../generated/v2-api' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' + +export function attachFileUpload(files: Command): void { + files + .command('upload ') + .description('Upload a file to the workspace') + .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) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + + const created = await client.request( + 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( + client, + workspaceId, + { + basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, + size, + }, + path + ) + + 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/index.ts b/packages/sim-cli/src/commands/protocol/index.ts new file mode 100644 index 00000000000..8be3088630c --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -0,0 +1,52 @@ +import { Command } from 'commander' +import { attachFileGet } from './files-get' +import { attachFileUpload } from './files-upload' +import { attachKnowledgeDocumentUpload } from './knowledge-document-upload' +import { attachResourceDirectoryCommands } from './resource-directory' +import { attachTableImport } from './tables-import' + +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) + attachFileGet(files) + attachResourceDirectoryCommands(files, { + kind: 'file', + resources: 'listFiles', + folders: 'listFileFolders', + createFolder: 'createFileFolder', + }) + + const knowledge = group(program, 'knowledge') + attachKnowledgeDocumentUpload(group(knowledge, 'documents')) + attachResourceDirectoryCommands(knowledge, { + kind: 'knowledge', + resources: 'listKnowledgeBases', + folders: 'listKnowledgeFolders', + createFolder: 'createKnowledgeFolder', + }) + + const tables = group(program, 'tables') + attachTableImport(tables) + attachResourceDirectoryCommands(tables, { + kind: 'table', + resources: 'listTables', + folders: 'listTableFolders', + createFolder: 'createTableFolder', + }) + + attachResourceDirectoryCommands(group(program, 'workflows'), { + kind: 'workflow', + resources: 'listWorkflows', + folders: 'listWorkflowFolders', + createFolder: 'createWorkflowFolder', + }) +} 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..c7baf48d5bf --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -0,0 +1,225 @@ +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' +import { attachProtocolCommands } from './index' + +const { mockRequest } = vi.hoisted(() => ({ + mockRequest: vi.fn(), +})) + +vi.mock('../../context', () => ({ + 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, + 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 root = program() + const knowledge = root.commands.find((command) => command.name() === 'knowledge') + expect(root.commands.map((command) => command.name())).not.toContain('documents') + + 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 () => { + const path = join(dir, 'notes.doc') + writeFileSync(path, 'hello') + const session = uploadSession() + mockRequest + .mockResolvedValueOnce({ + data: { + session, + uploadToken: 'secret-token', + transfer: { method: 'multipart', partSize: 10, partCount: 1 }, + }, + }) + .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', + 'kb', + '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]).toEqual({ + method: 'POST', + query: { workspaceId: 'ws_local' }, + headers: { 'upload-token': 'secret-token' }, + }) + 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', + 'kb', + 'documents', + 'upload', + 'kb_1', + path, + '--tag', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + ]) + ).rejects.toThrow(/at most seven/) + expect(mockRequest).not.toHaveBeenCalled() + }) + + 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', '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 new file mode 100644 index 00000000000..f268a0e5a25 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -0,0 +1,98 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context' +import type { + CompleteKnowledgeDocumentUploadResponse, + CreateKnowledgeDocumentUploadResponse, +} from '../../generated/v2-api' +import { SimApiError } from '../../http/client' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' + +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 { 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, + }, + 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/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts new file mode 100644 index 00000000000..1ef6d4b1d12 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -0,0 +1,153 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) + +vi.mock('../../context', () => ({ + 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) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +beforeEach(() => { + vi.restoreAllMocks() + mockRequest.mockReset() + output.format = 'json' +}) + +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) + } + }) + + 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', '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', + }, + ]) + }) + + 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' }, + }) + }) + + 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 new file mode 100644 index 00000000000..fa0ca8eb07a --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -0,0 +1,196 @@ +import { type Command, Option } from 'commander' +import { clientFrom } from '../../context' +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' +import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client' +import { type Column, printList, text, timestamp } from '../../output/render' +import { DEFAULT_LIMIT } from '../../runtime/options' +import { renderResult } from '../../runtime/result' + +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' + 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' + } + +interface ListOptions { + 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) + } + + return requestAllPages(client, path, { + query, + pageSize: DEFAULT_LIMIT, + 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 attachResourceDirectoryCommands( + group: Command, + config: ResourceDirectoryConfig +): void { + group + .command('ls [path]') + .allowExcessArguments(false) + .description(`List ${config.kind} resources and child folders together`) + .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 (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 = path ?? '/' + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const [folders, resources] = await Promise.all([ + 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) + }) + + group + .command('mkdir ') + .allowExcessArguments(false) + .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 }, + }) + renderResult(config.createFolder, profile.output, result.data ?? result, {}) + }) +} 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..35e84b03fe6 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/result.ts @@ -0,0 +1,7 @@ +import type { OutputFormat } from '../../config/index' +import { printRecord, text } from '../../output/render' + +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..b14cee1a0dd --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -0,0 +1,130 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) + +vi.mock('../../context', () => ({ + 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', '/Reports'])).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: { + session: { + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + error: null, + }, + uploadToken: null, + transfer: null, + }, + }) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + 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', + 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..d60bbcc6288 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -0,0 +1,209 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import chalk from 'chalk' +import { type Command, Option } from 'commander' +import { clientFrom } from '../../context' +import type { + CompleteTableImportResponse, + CreateTableImportResponse, + GetTableImportResponse, +} from '../../generated/v2-api' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { SimApiError, type SimClient } from '../../http/client' +import { coerce, type FieldSpec } from '../../runtime/request' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' + +type TableImport = GetTableImportResponse['data'] + +interface ImportOptions { + name?: string + tableId?: string + mode?: string + folder?: 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, kind: FieldSpec['kind']): unknown { + return coerce(raw, { kind }, { 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', options.folder], + ] 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 ', '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)') + .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.folder !== undefined ? { folderPath: options.folder } : {}), + } + } + + const started = await client.request( + 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) { + 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: started.data.uploadToken, + transfer: started.data.transfer, + size: local.size, + }, + 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/commands/secrets.test.ts b/packages/sim-cli/src/commands/secrets.test.ts new file mode 100644 index 00000000000..b2b2a5d6cf6 --- /dev/null +++ b/packages/sim-cli/src/commands/secrets.test.ts @@ -0,0 +1,118 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../runtime/build' +import { attachSecretCommands } from './secrets' + +const { mockPromptSecret, mockRequest } = vi.hoisted(() => ({ + mockPromptSecret: vi.fn(async () => 'prompted-secret'), + mockRequest: vi.fn(), +})) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + client: { + request: mockRequest, + requireWorkspace: () => 'ws_local', + }, + profile: { + workspaceId: 'ws_local', + output: 'json', + name: 'default', + apiKey: 'key', + }, + }), +})) +vi.mock('../terminal/secret-input', () => ({ promptSecret: mockPromptSecret })) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachSecretCommands(root) + return root +} + +describe('secrets set', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPromptSecret.mockResolvedValue('prompted-secret') + mockRequest.mockResolvedValue({ + data: { + name: 'STRIPE_API_KEY', + scope: 'workspace', + role: 'admin', + createdAt: '2026-08-12T20:15:00.000Z', + updatedAt: '2026-08-12T20:15:00.000Z', + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('prompts when no value flag is supplied', async () => { + await program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'workspace', + ]) + + expect(mockPromptSecret).toHaveBeenCalledOnce() + expect(mockRequest).toHaveBeenCalledWith('/api/v2/secrets/STRIPE_API_KEY', { + method: 'PUT', + body: { + workspaceId: 'ws_local', + scope: 'workspace', + value: 'prompted-secret', + }, + }) + }) + + it('accepts --value directly without prompting', async () => { + await program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'personal', + '--value', + 'direct-secret', + ]) + + expect(mockPromptSecret).not.toHaveBeenCalled() + expect(mockRequest).toHaveBeenCalledWith('/api/v2/secrets/STRIPE_API_KEY', { + method: 'PUT', + body: { + workspaceId: 'ws_local', + scope: 'personal', + value: 'direct-secret', + }, + }) + }) + + it('keeps --value optional in help and rejects an empty direct value', async () => { + const secrets = program().commands.find((command) => command.name() === 'secrets') + const set = secrets?.commands.find((command) => command.name() === 'set') + if (!set) throw new Error('Missing secrets set command') + expect(set.helpInformation()).toContain('--value ') + expect(set.helpInformation()).not.toContain('Set value (required)') + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'workspace', + '--value', + '', + ]) + ).rejects.toThrow('Secret value cannot be empty.') + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/secrets.ts b/packages/sim-cli/src/commands/secrets.ts new file mode 100644 index 00000000000..922e348c132 --- /dev/null +++ b/packages/sim-cli/src/commands/secrets.ts @@ -0,0 +1,67 @@ +import { type Command, Option } from 'commander' +import { clientFrom } from '../context' +import type { CommandSpec } from '../contract/types' +import { type SetSecretResponse, V2_OPERATIONS } from '../generated/v2-api' +import { resolvePath, SimApiError } from '../http/client' +import { renderResult } from '../runtime/result' +import { promptSecret } from '../terminal/secret-input' + +const MAX_SECRET_LENGTH = 65_536 +const SECRET_SCOPES = ['workspace', 'personal'] as const + +const SECRET_RESULT: CommandSpec = { + fields: [ + { header: 'name' }, + { header: 'scope' }, + { header: 'role' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], +} + +interface SetSecretOptions { + scope: (typeof SECRET_SCOPES)[number] + value?: string +} + +function validateSecretValue(value: string): string { + if (value.length === 0) throw new SimApiError('Secret value cannot be empty.', 0) + if (value.length > MAX_SECRET_LENGTH) { + throw new SimApiError(`Secret value cannot exceed ${MAX_SECRET_LENGTH} characters.`, 0) + } + return value +} + +async function setSecret(name: string, options: SetSecretOptions, command: Command): Promise { + const value = validateSecretValue(options.value ?? (await promptSecret())) + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS.setSecret + const response = await client.request(resolvePath(operation.path, { name }), { + method: operation.method, + body: { + workspaceId: client.requireWorkspace(), + scope: options.scope, + value, + }, + }) + + renderResult('setSecret', profile.output, response.data, SECRET_RESULT) +} + +/** Adds interactive secret entry while preserving an explicit value flag for scripts. */ +export function attachSecretCommands(program: Command): void { + const secrets = program.commands.find((command) => command.name() === 'secrets') + if (!secrets) throw new Error('The generated secrets command group is missing') + + secrets + .command('set ') + .description('Create or replace a named secret') + .addOption( + new Option('--scope ', 'Secret ownership scope') + .choices([...SECRET_SCOPES]) + .makeOptionMandatory() + ) + .option('--value ', 'Secret value; visible to shell history when supplied directly') + .action((name: string, options: SetSecretOptions, command: Command) => + setSecret(name, options, command) + ) +} diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts new file mode 100644 index 00000000000..79b5751a0b0 --- /dev/null +++ b/packages/sim-cli/src/config/index.ts @@ -0,0 +1,18 @@ +export { configDir, configPath, credentialsPath } from './paths' +export { + DEFAULT_ENDPOINT, + DEFAULT_PROFILE, + deleteProfile, + listProfiles, + OUTPUT_FORMATS, + type OutputFormat, + ProfileConfigError, + type ProfileOverrides, + type ResolvedProfile, + readConfigProfile, + readCredentialsProfile, + resolveProfile, + type SettingSource, + writeConfigProfile, + writeCredentialsProfile, +} from './profile' 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..d26ba8bd594 --- /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' + +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..225af15842b --- /dev/null +++ b/packages/sim-cli/src/config/profile.test.ts @@ -0,0 +1,167 @@ +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' +import { + deleteProfile, + listProfiles, + OUTPUT_FORMATS, + resolveProfile, + writeConfigProfile, + writeCredentialsProfile, +} from './profile' + +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('fails fast on an unrecognized active output format', () => { + process.env.SIM_OUTPUT = 'xml' + expect(() => resolveProfile()).toThrow( + 'Unknown output format "xml" from env. Use one of: table, json, yaml, text' + ) + + Reflect.deleteProperty(process.env, 'SIM_OUTPUT') + writeConfigProfile('default', { output: 'xml' }) + expect(() => resolveProfile()).toThrow( + 'Unknown output format "xml" from config. Use one of: table, json, yaml, text' + ) + expect(resolveProfile({ output: 'json' }).output).toBe('json') + }) + + 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', () => { + 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') + 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..c770cc2aae9 --- /dev/null +++ b/packages/sim-cli/src/config/profile.ts @@ -0,0 +1,219 @@ +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' +import { configPath, credentialsPath } from './paths' + +export const DEFAULT_PROFILE = 'default' +export const DEFAULT_ENDPOINT = 'https://sim.ai' + +/** + * 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] + +/** 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 + 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?: OutputFormat +} + +/** + * 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(/\/+$/, '') +} + +/** + * 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', 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, + 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..61cc307a2b4 --- /dev/null +++ b/packages/sim-cli/src/context.ts @@ -0,0 +1,41 @@ +import type { Command } from 'commander' +import { + type OutputFormat, + type ProfileOverrides, + type ResolvedProfile, + resolveProfile, +} from './config/index' +import { SimClient } from './http/client' + +/** Global flags, shared by every subcommand. */ +export interface GlobalOptions { + profile?: string + endpoint?: string + workspace?: string + output?: OutputFormat +} + +/** + * 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/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts new file mode 100644 index 00000000000..46c7c65e108 --- /dev/null +++ b/packages/sim-cli/src/contract/commands.ts @@ -0,0 +1,832 @@ +import type { CliContract, ColumnSpec, CommandVariantSpec } from './types' + +const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' +const TABLE_FILTER_HELP = + '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 = { + 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 KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS = { id: 'knowledgeBaseId' } as const +const WORKFLOW_RUN_SCOPE = { + id: { + name: 'workflow', + placeholder: 'workflowId', + describe: 'Workflow ID', + }, +} as const +const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ + { header: 'path' }, + { header: 'name' }, + { header: 'parent', path: 'parentPath' }, + { 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. + * + * 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 = { + createCredentialConnection: { hidden: true }, + createServiceAccountCredential: { hidden: true }, + 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: 'credits.used' }, + { header: 'limit credits', path: 'credits.limit' }, + { header: 'remaining credits', path: 'credits.remaining' }, + ], + }, + 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' }, + 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: 'workspace', path: 'workspaceId' }, + { header: 'source' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'credits', path: 'creditCost' }, + { header: 'run', path: 'runId' }, + { 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 + // 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, 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, 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. + undeployWorkflow: { + command: 'workflows undeploy', + describe: 'Take a workflow out of deployment', + }, + setSecret: { hidden: true }, + + // ─── 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.', + fields: [{ header: 'remaining columns', path: 'columns', format: 'count' }], + }, + deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, + deleteKnowledgeDocument: { + pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, + confirm: 'This deletes the document and its embeddings.', + }, + deleteFile: { confirm: 'This archives the file.' }, + deleteCredential: { + confirm: 'This disconnects the credential and removes its stored authentication.', + }, + 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.', + }, + 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.' }, + 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.', + fields: [ + { header: 'id' }, + { header: 'deleted', format: 'bool' }, + { header: 'remaining columns', path: 'columns', format: 'count' }, + ], + }, + // ─── 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 }, + 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' }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'run', path: 'runId' }, + ], + }, + getLog: { + describe: 'Show run diagnostics', + expandedTrace: true, + fields: [ + { header: 'run', path: 'runId' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'status' }, + { 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' }, + { header: 'trace', path: 'traceSpans', format: 'trace-count' }, + ], + }, + searchKnowledge: { + // Accepts a string or an array on the wire; the CLI always sends the array. + 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' }, + { 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, describe: TABLE_FILTER_HELP }, + 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 }, + folderPath: FOLDER_PATH_FLAG, + schema: { + json: true, + describe: 'Table schema: {"columns":[{"name":"email","type":"string"}]}', + }, + }, + }, + updateTable: { + variants: [moveResource('tables mv', 'table')], + flags: { + name: { describe: TABLE_NAME_HELP }, + folderPath: FOLDER_PATH_FLAG, + }, + }, + createFile: { flags: { folderPath: FOLDER_PATH_FLAG } }, + createKnowledgeBase: { flags: { folderPath: FOLDER_PATH_FLAG } }, + updateKnowledgeBase: { + variants: [moveResource('knowledge mv', 'knowledge base')], + flags: { folderPath: FOLDER_PATH_FLAG }, + }, + createWorkflow: { 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 } } }, + + // ─── 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' }, + // 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 by', path: 'uploadedByEmail' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + ], + }, + 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' }, + ], + }, + getKnowledgeDocument: { pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS }, + listKnowledgeDocuments: { + pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, + columns: [ + { header: 'id' }, + { header: 'filename' }, + { header: 'size', path: 'fileSize', format: 'bytes' }, + { header: 'status', path: 'processingStatus' }, + { 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: 'built-in', path: 'readOnly', format: 'bool' }, + ], + }, + listCustomTools: { + columns: [ + { header: 'id' }, + { header: 'name', path: 'title' }, + { header: 'description', path: 'schema.function.description' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listCredentials: { + columns: [ + { header: 'id' }, + { header: 'name', path: 'displayName' }, + { header: 'provider', path: 'providerId' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listSecrets: { + columns: [ + { header: 'name' }, + { header: 'scope' }, + { header: 'role' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + getWorkspace: { + profileWorkspacePath: true, + 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', + profileWorkspacePath: true, + columns: [ + { header: 'email' }, + { header: 'name' }, + { header: 'role' }, + { header: 'external', path: 'isExternal', format: 'bool' }, + { header: 'joined', path: 'joinedAt', format: 'timestamp' }, + ], + }, + + 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` + // are verbs sitting where the deriver expects a sub-resource, so it made them + // groups holding a lone `create`. + bulkDeleteFiles: { + // `batch-` for the bulk form, matching `tables rows batch-delete`. + command: 'files batch-delete', + describe: 'Delete several files at once', + flags: { + fileIds: { list: true }, + }, + confirm: 'This deletes every listed file.', + }, + getFile: { + command: 'files describe', + describe: 'Show file metadata and sharing status', + fields: [ + { header: 'id' }, + { header: 'name' }, + { header: 'size', format: 'bytes' }, + { header: 'type' }, + { header: 'folder', path: 'folderPath' }, + { header: 'uploaded by', path: 'uploadedByEmail' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + // v2 returns the share under `share` (null when unshared), and its flag + // is `isActive`. + { header: 'shared', path: 'share.isActive', format: 'bool' }, + { header: 'share URL', path: 'share.url' }, + { header: 'share auth', path: 'share.authType' }, + { header: 'allowed emails', path: 'share.allowedEmails', format: 'count' }, + ], + }, + moveFileItems: { + command: 'files move', + aliases: ['mv'], + describe: 'Move files into another folder', + flags: { + fileIds: { list: true }, + targetFolderPath: { + ...FOLDER_PATH_INPUT, + name: 'to', + describe: 'Destination folder path; omit for root', + }, + }, + }, + renameFile: { + // Derived to `files update`, which contradicted its own summary. + command: 'files rename', + describe: 'Rename a file', + }, + updateFileContent: { + command: 'files set-content', + describe: 'Replace a file’s contents', + flags: { + encoding: { choices: ['utf-8', 'base64'], describe: 'Content encoding' }, + }, + }, + // Both share commands return the share itself as `data`, which the runtime + // unwraps, so these fields sit at the top level rather than under a wrapper. + getFileShare: { + command: 'files share get', + describe: 'Show a file’s share settings', + fields: [ + { header: 'shared', path: 'isActive', format: 'bool' }, + { header: 'URL', path: 'url' }, + { header: 'auth', path: 'authType' }, + { header: 'password set', path: 'hasPassword', format: 'bool' }, + { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, + ], + }, + // v2 folds share and unshare into one PATCH; `--is-active false` disables it, + // so there is no separate unshare operation to expose. + upsertFileShare: { + command: 'files share set', + describe: 'Enable or disable sharing for a file', + flags: { + allowedEmails: { list: true }, + }, + fields: [ + { header: 'shared', path: 'isActive', format: 'bool' }, + { header: 'URL', path: 'url' }, + { header: 'auth', path: 'authType' }, + { header: 'password set', path: 'hasPassword', format: 'bool' }, + { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, + ], + }, + + // ─── Resource-scoped, path-addressed folders ────────────────────────────── + listFileFolders: { + aliases: ['ls'], + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, + columns: FOLDER_LIST_COLUMNS, + }, + listKnowledgeFolders: { + aliases: ['ls'], + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, + columns: FOLDER_LIST_COLUMNS, + }, + listTableFolders: { + aliases: ['ls'], + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, + columns: FOLDER_LIST_COLUMNS, + }, + listWorkflowFolders: { + aliases: ['ls'], + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, + columns: FOLDER_LIST_COLUMNS, + }, + 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'], + 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: { + 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: { + 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: { + 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: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, + describe: 'Rename or move a workflow folder', + }, + deleteFileFolder: { + positionals: ['path'], + flags: FOLDER_DELETE_FLAGS, + confirm: 'This archives the file folder and, when recursive, everything inside it.', + }, + deleteKnowledgeFolder: { + positionals: ['path'], + flags: FOLDER_DELETE_FLAGS, + confirm: 'This archives the knowledge folder and, when recursive, everything inside it.', + }, + deleteTableFolder: { + positionals: ['path'], + flags: FOLDER_DELETE_FLAGS, + confirm: 'This archives the table folder and, when recursive, everything inside it.', + }, + deleteWorkflowFolder: { + positionals: ['path'], + flags: FOLDER_DELETE_FLAGS, + confirm: 'This archives the workflow folder and, when recursive, everything inside it.', + }, + + // ─── The expanded tables surface ────────────────────────────────────────── + // `/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', + 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', + flags: { + q: { describe: 'Value to find' }, + predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, + sort: { json: true, describe: TABLE_SORT_HELP }, + }, + itemsPath: 'matches', + columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], + }, + runTableColumn: { + command: 'tables columns run', + describe: 'Run a column’s workflow', + flags: { + groupIds: { list: true }, + rowIds: { list: true }, + excludeRowIds: { list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, + }, + runRowEnrichment: { + command: 'tables rows enrich', + describe: 'Run one row’s enrichment group', + }, + + // 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: { + // 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. + exportWorkflow: { + describe: 'Print a workflow as a portable JSON document', + document: true, + }, + + // ─── 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`. + executeWorkflow: { + command: 'workflows run', + describe: 'Run a deployed workflow', + flags: { + async: { boolean: true, describe: 'Queue the run and return immediately' }, + input: { json: true, describe: 'Trigger input as JSON' }, + 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 + // not offering it yet. + stream: { omit: true }, + includeThinking: { omit: true }, + includeToolCalls: { omit: true }, + }, + }, + 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, + 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: 'run', path: 'runId' }, + { 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' }, + ], + }, + 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: 'run', path: 'runId' }, + ], + }, + 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 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 run status', + }, + input: { + json: true, + describe: 'Resume input as JSON', + }, + }, + fields: [ + { header: 'run', path: 'runId' }, + { 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 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 + // 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/contract/types.ts b/packages/sim-cli/src/contract/types.ts new file mode 100644 index 00000000000..736c247ef24 --- /dev/null +++ b/packages/sim-cli/src/contract/types.ts @@ -0,0 +1,181 @@ +import type { V2OperationName } from '../generated/v2-api' + +/** + * 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 usually derives from ` `. + * + * 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`). + * - `pathFlags` — when a parent path segment is command context rather than the + * 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. + * - `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 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 + * 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. */ + 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[] + /** Expose a string-backed API boolean as a conventional terminal toggle. */ + boolean?: true + /** + * 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 +} + +/** 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. */ + 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' | 'count' | 'trace-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 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 + * ` [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[] + /** 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. */ + 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. */ + 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. */ + 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. + */ + 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. + * + * `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 +} + +/** 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 new file mode 100644 index 00000000000..9acec0d2016 --- /dev/null +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -0,0 +1,7633 @@ +/** + * 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. + */ + +/** `DELETE /api/v2/files/uploads/[uploadId]` */ +export type AbortFileUploadParams = { + uploadId: string +} + +export type AbortFileUploadQuery = { + workspaceId: string +} + +export type AbortFileUploadHeaders = { + 'upload-token': string +} + +type AbortFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +type AbortFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: AbortFileUploadResponseRef0 | null +} + +export type AbortFileUploadResponse = { + data: AbortFileUploadResponseRef1 +} + +/** `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 +} + +type AbortKnowledgeDocumentUploadResponseRef0 = { + 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 +} + +type AbortKnowledgeDocumentUploadResponseRef1 = { + id: string + knowledgeBaseId: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + document: AbortKnowledgeDocumentUploadResponseRef0 | null +} + +export type AbortKnowledgeDocumentUploadResponse = { + data: AbortKnowledgeDocumentUploadResponseRef1 +} + +/** `POST /api/v2/tables/[tableId]/columns` */ +export type AddTableColumnParams = { + tableId: string +} + +export type AddTableColumnQuery = Record + +export type AddTableColumnBody = { + workspaceId: string + column: { + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: string + position?: number + } +} + +type AddTableColumnResponseRef0 = { + 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?: string + }> +} + +export type AddTableColumnResponse = { + data: AddTableColumnResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/groups` */ +export type AddWorkflowGroupParams = { + tableId: string +} + +export type AddWorkflowGroupQuery = Record + +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 +} + +type AddWorkflowGroupResponseRef0 = { + 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 +} + +type AddWorkflowGroupResponseRef1 = { + group: AddWorkflowGroupResponseRef0 + 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?: string + }> +} + +export type AddWorkflowGroupResponse = { + data: AddWorkflowGroupResponseRef1 +} + +/** `POST /api/v2/files/bulk-delete` */ +export type BulkDeleteFilesQuery = Record + +export type BulkDeleteFilesBody = { + workspaceId: string + fileIds: Array +} + +type BulkDeleteFilesResponseRef0 = { + deletedItems: { + files: number + } +} + +export type BulkDeleteFilesResponse = { + data: BulkDeleteFilesResponseRef0 +} + +/** `PATCH /api/v2/knowledge/[id]/documents` */ +export type BulkUpdateKnowledgeDocumentsParams = { + id: string +} + +export type BulkUpdateKnowledgeDocumentsQuery = Record + +export type BulkUpdateKnowledgeDocumentsBody = { + workspaceId: string + operation: 'enable' | 'disable' + documentIds?: Array + selectAll?: true + enabledFilter?: 'all' | 'enabled' | 'disabled' +} + +type BulkUpdateKnowledgeDocumentsResponseRef0 = { + operation: 'enable' | 'disable' + updatedCount: number + documentIds?: Array +} + +export type BulkUpdateKnowledgeDocumentsResponse = { + data: BulkUpdateKnowledgeDocumentsResponseRef0 +} + +/** `DELETE /api/v2/tables/exports/[exportId]` */ +export type CancelTableExportParams = { + exportId: string +} + +export type CancelTableExportQuery = { + workspaceId: string +} + +type CancelTableExportResponseRef0 = { + 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 +} + +export type CancelTableExportResponse = { + data: CancelTableExportResponseRef0 +} + +/** `DELETE /api/v2/tables/imports/[importId]` */ +export type CancelTableImportParams = { + importId: string +} + +export type CancelTableImportQuery = { + workspaceId: string +} + +export type CancelTableImportHeaders = { + 'upload-token'?: string +} + +type CancelTableImportResponseRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type CancelTableImportResponseRef1 = { + type: 'workspace_file' + fileId: string +} + +type CancelTableImportResponseRef2 = string + +type CancelTableImportResponseRef3 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: CancelTableImportResponseRef0 | CancelTableImportResponseRef1 + target: + | { + type: 'new' + name: string + folderPath?: CancelTableImportResponseRef2 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +export type CancelTableImportResponse = { + data: CancelTableImportResponseRef3 +} + +/** `POST /api/v2/tables/[tableId]/cancel-runs` */ +export type CancelTableRunsParams = { + tableId: string +} + +export type CancelTableRunsQuery = Record + +type CancelTableRunsBodyRef0 = + | { + all: Array< + | CancelTableRunsBodyRef0 + | { + 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< + | CancelTableRunsBodyRef0 + | { + 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 CancelTableRunsBody = { + workspaceId: string + scope: 'all' | 'row' + rowId?: string + filter?: CancelTableRunsBodyRef0 + excludeRowIds?: Array +} + +type CancelTableRunsResponseRef0 = { + cancelled: number +} + +export type CancelTableRunsResponse = { + data: CancelTableRunsResponseRef0 +} + +/** `POST /api/v2/workflows/[id]/runs/[runId]/cancel` */ +export type CancelWorkflowRunParams = { + id: string + runId: string +} + +export type CancelWorkflowRunQuery = Record + +type CancelWorkflowRunResponseRef0 = { + success: boolean + runId: string + redisAvailable: boolean + durablyRecorded: boolean + locallyAborted: boolean + pausedCancelled: boolean + reason?: + | 'recorded' + | 'already_cancelled' + | 'already_completed' + | 'already_failed' + | 'redis_unavailable' + | 'redis_write_failed' + | 'paused_event_publish_failed' + | 'paused_database_cancel_failed' +} + +export type CancelWorkflowRunResponse = { + data: CancelWorkflowRunResponseRef0 +} + +/** `POST /api/v2/files/uploads/[uploadId]/complete` */ +export type CompleteFileUploadParams = { + uploadId: string +} + +export type CompleteFileUploadQuery = { + workspaceId: string +} + +export type CompleteFileUploadHeaders = { + 'upload-token': string +} + +type CompleteFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +type CompleteFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: CompleteFileUploadResponseRef0 | null +} + +export type CompleteFileUploadResponse = { + data: CompleteFileUploadResponseRef1 +} + +/** `POST /api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete` */ +export type CompleteKnowledgeDocumentUploadParams = { + id: string + uploadId: string +} + +export type CompleteKnowledgeDocumentUploadQuery = { + workspaceId: string +} + +export type CompleteKnowledgeDocumentUploadHeaders = { + 'upload-token': string +} + +type CompleteKnowledgeDocumentUploadResponseRef0 = { + 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 +} + +type CompleteKnowledgeDocumentUploadResponseRef1 = { + id: string + knowledgeBaseId: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + document: CompleteKnowledgeDocumentUploadResponseRef0 | null +} + +export type CompleteKnowledgeDocumentUploadResponse = { + data: CompleteKnowledgeDocumentUploadResponseRef1 +} + +/** `POST /api/v2/tables/imports/[importId]/complete` */ +export type CompleteTableImportParams = { + importId: string +} + +export type CompleteTableImportQuery = { + workspaceId: string +} + +export type CompleteTableImportHeaders = { + 'upload-token': string +} + +type CompleteTableImportResponseRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type CompleteTableImportResponseRef1 = { + type: 'workspace_file' + fileId: string +} + +type CompleteTableImportResponseRef2 = string + +type CompleteTableImportResponseRef3 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: CompleteTableImportResponseRef0 | CompleteTableImportResponseRef1 + target: + | { + type: 'new' + name: string + folderPath?: CompleteTableImportResponseRef2 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +export type CompleteTableImportResponse = { + data: CompleteTableImportResponseRef3 +} + +/** `POST /api/v2/credentials/connections` */ +export type CreateCredentialConnectionQuery = Record + +export type CreateCredentialConnectionBody = + | { + workspaceId: string + providerId: string + displayName: string + } + | { + workspaceId: string + credentialId: string + } + +type CreateCredentialConnectionResponseRef0 = { + authorizationUrl: string + expiresAt: string +} + +export type CreateCredentialConnectionResponse = { + data: CreateCredentialConnectionResponseRef0 +} + +/** `POST /api/v2/custom-tools` */ +export type CreateCustomToolQuery = Record + +export type CreateCustomToolBody = { + workspaceId: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string +} + +type CreateCustomToolResponseRef0 = { + 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 +} + +export type CreateCustomToolResponse = { + data: CreateCustomToolResponseRef0 +} + +/** `POST /api/v2/files` */ +export type CreateFileQuery = Record + +type CreateFileBodyRef0 = string + +export type CreateFileBody = { + workspaceId: string + name: string + contentType?: string + folderPath?: CreateFileBodyRef0 + content?: string + encoding?: 'utf-8' | 'base64' +} + +type CreateFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type CreateFileResponse = { + data: CreateFileResponseRef0 +} + +/** `POST /api/v2/files/folders` */ +export type CreateFileFolderQuery = Record + +type CreateFileFolderBodyRef0 = string + +export type CreateFileFolderBody = { + workspaceId: string + path: CreateFileFolderBodyRef0 +} + +type CreateFileFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type CreateFileFolderResponse = { + data: CreateFileFolderResponseRef0 +} + +/** `POST /api/v2/files/uploads` */ +export type CreateFileUploadQuery = Record + +type CreateFileUploadBodyRef0 = string + +export type CreateFileUploadBody = { + workspaceId: string + name: string + contentType: string + size: number + folderPath?: CreateFileUploadBodyRef0 +} + +type CreateFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +type CreateFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: CreateFileUploadResponseRef0 | null +} + +type CreateFileUploadResponseRef2 = { + method: 'put' + url: string + headers: Record + expiresAt: string +} + +type CreateFileUploadResponseRef3 = { + method: 'multipart' + partSize: number + partCount: number +} + +type CreateFileUploadResponseRef4 = { + session: CreateFileUploadResponseRef1 + uploadToken: string + transfer: CreateFileUploadResponseRef2 | CreateFileUploadResponseRef3 +} + +export type CreateFileUploadResponse = { + data: CreateFileUploadResponseRef4 +} + +/** `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 +} + +type CreateFileUploadPartUrlsResponseRef0 = { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +type CreateFileUploadPartUrlsResponseRef1 = { + parts: Array +} + +export type CreateFileUploadPartUrlsResponse = { + data: CreateFileUploadPartUrlsResponseRef1 +} + +/** `POST /api/v2/knowledge` */ +export type CreateKnowledgeBaseQuery = Record + +type CreateKnowledgeBaseBodyRef0 = { + maxSize?: number + minSize?: number + overlap?: number +} + +type CreateKnowledgeBaseBodyRef1 = string + +export type CreateKnowledgeBaseBody = { + workspaceId: string + name: string + description?: string + chunkingConfig?: CreateKnowledgeBaseBodyRef0 + folderPath?: CreateKnowledgeBaseBodyRef1 +} + +type CreateKnowledgeBaseResponseRef0 = { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } +} + +type CreateKnowledgeBaseResponseRef1 = { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: CreateKnowledgeBaseResponseRef0 + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + ownerEmail: string + folderPath: string +} + +export type CreateKnowledgeBaseResponse = { + data: CreateKnowledgeBaseResponseRef1 +} + +/** `POST /api/v2/knowledge/[id]/documents/uploads` */ +export type CreateKnowledgeDocumentUploadParams = { + id: string +} + +export type CreateKnowledgeDocumentUploadQuery = Record + +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 + } +} + +type CreateKnowledgeDocumentUploadResponseRef0 = { + id: string + knowledgeBaseId: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + document: CreateKnowledgeDocumentUploadResponseRef1 | null +} + +type CreateKnowledgeDocumentUploadResponseRef1 = { + 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 +} + +type CreateKnowledgeDocumentUploadResponseRef2 = + | CreateKnowledgeDocumentUploadResponseRef3 + | CreateKnowledgeDocumentUploadResponseRef4 + +type CreateKnowledgeDocumentUploadResponseRef3 = { + method: 'put' + url: string + headers: Record + expiresAt: string +} + +type CreateKnowledgeDocumentUploadResponseRef4 = { + method: 'multipart' + partSize: number + partCount: number +} + +type CreateKnowledgeDocumentUploadResponseRef5 = { + session: CreateKnowledgeDocumentUploadResponseRef0 + uploadToken: string + transfer: CreateKnowledgeDocumentUploadResponseRef2 +} + +export type CreateKnowledgeDocumentUploadResponse = { + data: CreateKnowledgeDocumentUploadResponseRef5 +} + +/** `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 +} + +type CreateKnowledgeDocumentUploadPartUrlsResponseRef0 = { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +type CreateKnowledgeDocumentUploadPartUrlsResponseRef1 = { + parts: Array +} + +export type CreateKnowledgeDocumentUploadPartUrlsResponse = { + data: CreateKnowledgeDocumentUploadPartUrlsResponseRef1 +} + +/** `POST /api/v2/knowledge/folders` */ +export type CreateKnowledgeFolderQuery = Record + +type CreateKnowledgeFolderBodyRef0 = string + +export type CreateKnowledgeFolderBody = { + workspaceId: string + path: CreateKnowledgeFolderBodyRef0 +} + +type CreateKnowledgeFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type CreateKnowledgeFolderResponse = { + data: CreateKnowledgeFolderResponseRef0 +} + +/** `POST /api/v2/mcp-servers` */ +export type CreateMcpServerQuery = Record + +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 +} + +type CreateMcpServerResponseRef0 = { + 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 +} + +export type CreateMcpServerResponse = { + data: CreateMcpServerResponseRef0 +} + +/** `POST /api/v2/credentials` */ +export type CreateServiceAccountCredentialQuery = Record + +export type CreateServiceAccountCredentialBody = { + workspaceId: string + type: 'service_account' + providerId: string + displayName?: string + description?: string + id?: string + serviceAccountJson?: string + apiToken?: string + domain?: string + signingSecret?: string + botToken?: string + clientId?: string + clientSecret?: string + certificateId?: string + orgId?: string + dataCenter?: string + authMethod?: string + privateKey?: string + username?: string +} + +type CreateServiceAccountCredentialResponseRef0 = { + id: string + type: 'oauth' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string +} + +export type CreateServiceAccountCredentialResponse = { + data: CreateServiceAccountCredentialResponseRef0 +} + +/** `POST /api/v2/skills` */ +export type CreateSkillQuery = Record + +export type CreateSkillBody = { + workspaceId: string + name: string + description: string + content: string +} + +type CreateSkillResponseRef0 = { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string +} + +export type CreateSkillResponse = { + data: CreateSkillResponseRef0 +} + +/** `POST /api/v2/tables` */ +export type CreateTableQuery = Record + +type CreateTableBodyRef0 = string + +export type CreateTableBody = { + name: string + description?: string + workspaceId: string + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: string + }> + } + folderPath?: CreateTableBodyRef0 +} + +type CreateTableResponseRef0 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null +} + +type CreateTableResponseRef1 = { + id: string + name: string + description: string | null + ownerEmail: string + 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?: string + }> + } + rowCount: number + maxRows: number + folderPath: string + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: CreateTableResponseRef0 | null + createdAt: string + updatedAt: string +} + +export type CreateTableResponse = { + data: CreateTableResponseRef1 +} + +/** `POST /api/v2/tables/[tableId]/exports` */ +export type CreateTableExportParams = { + tableId: string +} + +export type CreateTableExportQuery = Record + +export type CreateTableExportBody = { + workspaceId: string + format?: 'csv' | 'json' +} + +type CreateTableExportResponseRef0 = { + 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 +} + +export type CreateTableExportResponse = { + data: CreateTableExportResponseRef0 +} + +/** `POST /api/v2/tables/folders` */ +export type CreateTableFolderQuery = Record + +type CreateTableFolderBodyRef0 = string + +export type CreateTableFolderBody = { + workspaceId: string + path: CreateTableFolderBodyRef0 +} + +type CreateTableFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type CreateTableFolderResponse = { + data: CreateTableFolderResponseRef0 +} + +/** `POST /api/v2/tables/imports` */ +export type CreateTableImportQuery = Record + +type CreateTableImportBodyRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type CreateTableImportBodyRef1 = { + type: 'workspace_file' + fileId: string +} + +type CreateTableImportBodyRef2 = string + +export type CreateTableImportBody = { + workspaceId: string + source: CreateTableImportBodyRef0 | CreateTableImportBodyRef1 + target: + | { + type: 'new' + name: string + folderPath?: CreateTableImportBodyRef2 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + mapping?: Record + createColumns?: Array + timezone?: string +} + +type CreateTableImportResponseRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type CreateTableImportResponseRef1 = string + +type CreateTableImportResponseRef2 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: CreateTableImportResponseRef0 + target: + | { + type: 'new' + name: string + folderPath?: CreateTableImportResponseRef1 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +type CreateTableImportResponseRef3 = { + method: 'put' + url: string + headers: Record + expiresAt: string +} + +type CreateTableImportResponseRef4 = { + method: 'multipart' + partSize: number + partCount: number +} + +type CreateTableImportResponseRef5 = { + type: 'workspace_file' + fileId: string +} + +type CreateTableImportResponseRef6 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: CreateTableImportResponseRef5 + target: + | { + type: 'new' + name: string + folderPath?: CreateTableImportResponseRef1 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +type CreateTableImportResponseRef7 = + | { + session: CreateTableImportResponseRef2 + uploadToken: string + transfer: CreateTableImportResponseRef3 | CreateTableImportResponseRef4 + } + | { + session: CreateTableImportResponseRef6 + uploadToken: null + transfer: null + } + +export type CreateTableImportResponse = { + data: CreateTableImportResponseRef7 +} + +/** `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 +} + +type CreateTableImportPartUrlsResponseRef0 = { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +type CreateTableImportPartUrlsResponseRef1 = { + parts: Array +} + +export type CreateTableImportPartUrlsResponse = { + data: CreateTableImportPartUrlsResponseRef1 +} + +/** `POST /api/v2/tables/[tableId]/rows` */ +export type CreateTableRowsParams = { + tableId: string +} + +export type CreateTableRowsQuery = Record + +type CreateTableRowsBodyRef0 = Record + +export type CreateTableRowsBody = + | { + workspaceId: string + rows: Array + } + | { + workspaceId: string + data: CreateTableRowsBodyRef0 + afterRowId?: string + beforeRowId?: string + } + +type CreateTableRowsResponseRef0 = { + data: CreateTableRowsResponseRef2 +} + +type CreateTableRowsResponseRef1 = Record + +type CreateTableRowsResponseRef2 = { + id: string + data: CreateTableRowsResponseRef1 + createdAt: string + updatedAt: string +} + +type CreateTableRowsResponseRef3 = { + data: CreateTableRowsResponseRef4 +} + +type CreateTableRowsResponseRef4 = { + rows: Array + insertedCount: number +} + +export type CreateTableRowsResponse = CreateTableRowsResponseRef0 | CreateTableRowsResponseRef3 + +/** `POST /api/v2/tables/[tableId]/views` */ +export type CreateTableViewParams = { + tableId: string +} + +export type CreateTableViewQuery = Record + +type CreateTableViewBodyRef0 = + | { + all: Array< + | CreateTableViewBodyRef0 + | { + 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< + | CreateTableViewBodyRef0 + | { + 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 + } + > + } + | { + 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 CreateTableViewBody = { + workspaceId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: CreateTableViewBodyRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } +} + +type CreateTableViewResponseRef0 = { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null +} + +type CreateTableViewResponseRef1 = { + id: string + tableId: string + name: string + config: CreateTableViewResponseRef0 + isDefault: boolean + createdByEmail: string | null + createdAt: string + updatedAt: string +} + +export type CreateTableViewResponse = { + data: CreateTableViewResponseRef1 +} + +/** `POST /api/v2/workflows` */ +export type CreateWorkflowQuery = Record + +type CreateWorkflowBodyRef0 = string + +export type CreateWorkflowBody = { + workspaceId: string + name: string + description?: string | null + folderPath?: CreateWorkflowBodyRef0 +} + +type CreateWorkflowResponseRef0 = { + id: string + name: string + description: string | null + folderPath: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string +} + +export type CreateWorkflowResponse = { + data: CreateWorkflowResponseRef0 +} + +/** `POST /api/v2/workflows/folders` */ +export type CreateWorkflowFolderQuery = Record + +type CreateWorkflowFolderBodyRef0 = string + +export type CreateWorkflowFolderBody = { + workspaceId: string + path: CreateWorkflowFolderBodyRef0 +} + +type CreateWorkflowFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean +} + +export type CreateWorkflowFolderResponse = { + data: CreateWorkflowFolderResponseRef0 +} + +/** `DELETE /api/v2/credentials/[credentialId]` */ +export type DeleteCredentialParams = { + credentialId: string +} + +export type DeleteCredentialQuery = { + workspaceId: string +} + +type DeleteCredentialResponseRef0 = { + id: string + deleted: true +} + +export type DeleteCredentialResponse = { + data: DeleteCredentialResponseRef0 +} + +/** `DELETE /api/v2/custom-tools/[id]` */ +export type DeleteCustomToolParams = { + id: string +} + +export type DeleteCustomToolQuery = { + workspaceId: string +} + +type DeleteCustomToolResponseRef0 = { + id: string + deleted: true +} + +export type DeleteCustomToolResponse = { + data: DeleteCustomToolResponseRef0 +} + +/** `DELETE /api/v2/files/[fileId]` */ +export type DeleteFileParams = { + fileId: string +} + +export type DeleteFileQuery = { + workspaceId: string +} + +type DeleteFileResponseRef0 = { + id: string + deleted: true +} + +export type DeleteFileResponse = { + data: DeleteFileResponseRef0 +} + +/** `DELETE /api/v2/files/folders` */ +type DeleteFileFolderQueryRef0 = string + +export type DeleteFileFolderQuery = { + workspaceId: string + path: DeleteFileFolderQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type DeleteFileFolderResponseRef0 = { + path: string + deleted: true + deletedItems: { + folders: number + files: number + } +} + +export type DeleteFileFolderResponse = { + data: DeleteFileFolderResponseRef0 +} + +/** `DELETE /api/v2/knowledge/[id]` */ +export type DeleteKnowledgeBaseParams = { + id: string +} + +export type DeleteKnowledgeBaseQuery = { + workspaceId: string +} + +type DeleteKnowledgeBaseResponseRef0 = { + id: string + deleted: true +} + +export type DeleteKnowledgeBaseResponse = { + data: DeleteKnowledgeBaseResponseRef0 +} + +/** `DELETE /api/v2/knowledge/[id]/documents/[documentId]` */ +export type DeleteKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type DeleteKnowledgeDocumentQuery = { + workspaceId: string +} + +type DeleteKnowledgeDocumentResponseRef0 = { + id: string + deleted: true +} + +export type DeleteKnowledgeDocumentResponse = { + data: DeleteKnowledgeDocumentResponseRef0 +} + +/** `DELETE /api/v2/knowledge/folders` */ +type DeleteKnowledgeFolderQueryRef0 = string + +export type DeleteKnowledgeFolderQuery = { + workspaceId: string + path: DeleteKnowledgeFolderQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type DeleteKnowledgeFolderResponseRef0 = { + path: string + deleted: true + deletedItems: { + folders: number + knowledgeBases: number + } +} + +export type DeleteKnowledgeFolderResponse = { + data: DeleteKnowledgeFolderResponseRef0 +} + +/** `DELETE /api/v2/mcp-servers/[id]` */ +export type DeleteMcpServerParams = { + id: string +} + +export type DeleteMcpServerQuery = { + workspaceId: string +} + +type DeleteMcpServerResponseRef0 = { + id: string + deleted: true +} + +export type DeleteMcpServerResponse = { + data: DeleteMcpServerResponseRef0 +} + +/** `DELETE /api/v2/secrets/[name]` */ +export type DeleteSecretParams = { + name: string +} + +export type DeleteSecretQuery = { + workspaceId: string + scope: 'workspace' | 'personal' +} + +type DeleteSecretResponseRef0 = { + name: string + scope: 'workspace' | 'personal' + deleted: true +} + +export type DeleteSecretResponse = { + data: DeleteSecretResponseRef0 +} + +/** `DELETE /api/v2/skills/[id]` */ +export type DeleteSkillParams = { + id: string +} + +export type DeleteSkillQuery = { + workspaceId: string +} + +type DeleteSkillResponseRef0 = { + id: string + deleted: true +} + +export type DeleteSkillResponse = { + data: DeleteSkillResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]` */ +export type DeleteTableParams = { + tableId: string +} + +export type DeleteTableQuery = { + workspaceId: string +} + +type DeleteTableResponseRef0 = { + id: string + deleted: true +} + +export type DeleteTableResponse = { + data: DeleteTableResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/columns` */ +export type DeleteTableColumnParams = { + tableId: string +} + +export type DeleteTableColumnQuery = Record + +export type DeleteTableColumnBody = { + workspaceId: string + columnName: string +} + +type DeleteTableColumnResponseRef0 = { + 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?: string + }> +} + +export type DeleteTableColumnResponse = { + data: DeleteTableColumnResponseRef0 +} + +/** `DELETE /api/v2/tables/folders` */ +type DeleteTableFolderQueryRef0 = string + +export type DeleteTableFolderQuery = { + workspaceId: string + path: DeleteTableFolderQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type DeleteTableFolderResponseRef0 = { + path: string + deleted: true + deletedItems: { + folders: number + tables: number + } +} + +export type DeleteTableFolderResponse = { + data: DeleteTableFolderResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/rows/[rowId]` */ +export type DeleteTableRowParams = { + tableId: string + rowId: string +} + +export type DeleteTableRowQuery = { + workspaceId: string +} + +type DeleteTableRowResponseRef0 = { + id: string + deleted: true +} + +export type DeleteTableRowResponse = { + data: DeleteTableRowResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/rows` */ +export type DeleteTableRowsParams = { + tableId: string +} + +export type DeleteTableRowsQuery = Record + +type DeleteTableRowsBodyRef0 = + | { + all: Array< + | DeleteTableRowsBodyRef0 + | { + 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< + | DeleteTableRowsBodyRef0 + | { + 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 DeleteTableRowsBody = { + workspaceId: string + filter?: DeleteTableRowsBodyRef0 + limit?: number + rowIds?: Array +} + +type DeleteTableRowsResponseRef0 = { + deletedCount: number + deletedRowIds: Array + requestedCount?: number + missingRowIds?: Array +} + +export type DeleteTableRowsResponse = { + data: DeleteTableRowsResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/views/[viewId]` */ +export type DeleteTableViewParams = { + tableId: string + viewId: string +} + +export type DeleteTableViewQuery = { + workspaceId: string +} + +type DeleteTableViewResponseRef0 = { + id: string + deleted: true +} + +export type DeleteTableViewResponse = { + data: DeleteTableViewResponseRef0 +} + +/** `DELETE /api/v2/workflows/[id]` */ +export type DeleteWorkflowParams = { + id: string +} + +export type DeleteWorkflowQuery = Record + +type DeleteWorkflowResponseRef0 = { + id: string + deleted: true +} + +export type DeleteWorkflowResponse = { + data: DeleteWorkflowResponseRef0 +} + +/** `DELETE /api/v2/workflows/folders` */ +type DeleteWorkflowFolderQueryRef0 = string + +export type DeleteWorkflowFolderQuery = { + workspaceId: string + path: DeleteWorkflowFolderQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type DeleteWorkflowFolderResponseRef0 = { + path: string + deleted: true + deletedItems: { + folders: number + workflows: number + } +} + +export type DeleteWorkflowFolderResponse = { + data: DeleteWorkflowFolderResponseRef0 +} + +/** `DELETE /api/v2/tables/[tableId]/groups` */ +export type DeleteWorkflowGroupParams = { + tableId: string +} + +export type DeleteWorkflowGroupQuery = Record + +export type DeleteWorkflowGroupBody = { + workspaceId: string + groupId: string +} + +type DeleteWorkflowGroupResponseRef0 = { + 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?: string + }> +} + +export type DeleteWorkflowGroupResponse = { + data: DeleteWorkflowGroupResponseRef0 +} + +/** `POST /api/v2/workflows/[id]/deploy` */ +export type DeployWorkflowParams = { + id: string +} + +export type DeployWorkflowQuery = Record + +export type DeployWorkflowBody = { + name?: string + description?: string | null +} + +type DeployWorkflowResponseRef0 = { + deploymentVersionId: string + version: number + deployedAt: string +} + +type DeployWorkflowResponseRef1 = { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: DeployWorkflowResponseRef2 + requestedAt: string + activatedAt?: string | null + error?: DeployWorkflowResponseRef3 | null +} + +type DeployWorkflowResponseRef2 = { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' +} + +type DeployWorkflowResponseRef3 = { + code: string + message: string + retryable: boolean +} + +type DeployWorkflowResponseRef4 = { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: DeployWorkflowResponseRef0 | null + latestDeploymentAttempt: DeployWorkflowResponseRef1 | null + version?: number +} + +export type DeployWorkflowResponse = { + data: DeployWorkflowResponseRef4 +} + +/** `GET /api/v2/files/[fileId]` */ +export type DownloadFileParams = { + fileId: string +} + +export type DownloadFileQuery = { + workspaceId: string +} + +/** Non-JSON response (`binary`). */ +export type DownloadFileResponse = never + +/** `POST /api/v2/workflows/[id]/execute` */ +export type ExecuteWorkflowParams = { + id: string +} + +export type ExecuteWorkflowQuery = Record + +export type ExecuteWorkflowBody = { + input?: Record + async?: boolean + executionTimeoutSeconds?: number + stream?: boolean + selectedOutputs?: Array + includeThinking?: boolean + includeToolCalls?: boolean + includeFileBase64?: boolean + base64MaxBytes?: number +} + +export type ExecuteWorkflowHeaders = { + 'x-run-id'?: string + 'x-sim-via'?: string +} + +type ExecuteWorkflowResponseRef0 = { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string +} + +type ExecuteWorkflowResponseRef1 = { + runId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + output: unknown + error: ExecuteWorkflowResponseRef0 | null + startedAt?: string + endedAt?: string + durationMs?: number +} + +type ExecuteWorkflowResponseRef2 = { + runId: string + statusUrl: string +} + +export type ExecuteWorkflowResponse = + | { + data: ExecuteWorkflowResponseRef1 + } + | { + data: ExecuteWorkflowResponseRef2 + } + +/** `GET /api/v2/workflows/[id]/export` */ +export type ExportWorkflowParams = { + id: string +} + +export type ExportWorkflowQuery = Record + +type ExportWorkflowResponseRef0 = { + version: '1.0' + exportedAt: string + workflow: { + id: string + name: string + description: string | null + workspaceId: string | null + folderPath: string + } + state: Record +} + +export type ExportWorkflowResponse = { + data: ExportWorkflowResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/rows/find` */ +export type FindTableRowsParams = { + tableId: string +} + +export type FindTableRowsQuery = Record + +type FindTableRowsBodyRef0 = + | { + all: Array< + | FindTableRowsBodyRef0 + | { + 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< + | FindTableRowsBodyRef0 + | { + 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 FindTableRowsBody = { + workspaceId: string + q: string + predicate?: FindTableRowsBodyRef0 + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> +} + +type FindTableRowsResponseRef0 = { + ordinal: number + rowId: string + column: string +} + +type FindTableRowsResponseRef1 = { + matches: Array + truncated: boolean +} + +export type FindTableRowsResponse = { + data: FindTableRowsResponseRef1 +} + +/** `GET /api/v2/audit-logs/[id]` */ +export type GetAuditLogParams = { + id: string +} + +export type GetAuditLogQuery = { + organizationId: string +} + +type GetAuditLogResponseRef0 = { + id: string + workspaceId: 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 +} + +export type GetAuditLogResponse = { + data: GetAuditLogResponseRef0 +} + +/** `GET /api/v2/billing/status` */ +export type GetBillingStatusQuery = { + workspaceId?: string +} + +type GetBillingStatusResponseRef0 = { + workspaceId: string | null + period: { + start: string + end: string + } + plan: string + status: 'active' | 'limit_exceeded' | 'billing_blocked' + credits: { + used: number + limit: number + remaining: number + } | null + storage: { + usedBytes: number + limitBytes: number + percentUsed: number + } | null +} + +export type GetBillingStatusResponse = { + data: GetBillingStatusResponseRef0 +} + +/** `GET /api/v2/custom-tools/[id]` */ +export type GetCustomToolParams = { + id: string +} + +export type GetCustomToolQuery = { + workspaceId: string +} + +type GetCustomToolResponseRef0 = { + 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 +} + +export type GetCustomToolResponse = { + data: GetCustomToolResponseRef0 +} + +/** `GET /api/v2/files/[fileId]/metadata` */ +export type GetFileParams = { + fileId: string +} + +export type GetFileQuery = { + workspaceId: string + scope?: 'active' | 'archived' +} + +type GetFileResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +type GetFileResponseRef1 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + share: GetFileResponseRef0 | null +} + +export type GetFileResponse = { + data: GetFileResponseRef1 +} + +/** `GET /api/v2/files/[fileId]/share` */ +export type GetFileShareParams = { + fileId: string +} + +export type GetFileShareQuery = { + workspaceId: string +} + +type GetFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type GetFileShareResponse = { + data: GetFileShareResponseRef0 | null +} + +/** `GET /api/v2/knowledge/[id]` */ +export type GetKnowledgeBaseParams = { + id: string +} + +export type GetKnowledgeBaseQuery = { + workspaceId: string +} + +type GetKnowledgeBaseResponseRef0 = { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } +} + +type GetKnowledgeBaseResponseRef1 = { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: GetKnowledgeBaseResponseRef0 + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + ownerEmail: string + folderPath: string +} + +export type GetKnowledgeBaseResponse = { + data: GetKnowledgeBaseResponseRef1 +} + +/** `GET /api/v2/knowledge/[id]/documents/[documentId]` */ +export type GetKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type GetKnowledgeDocumentQuery = { + workspaceId: string +} + +type GetKnowledgeDocumentResponseRef0 = { + 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 + tags: Record + processingError: string | null + processingStartedAt: string | null + processingCompletedAt: string | null + connectorId: string | null + connectorType: string | null + sourceUrl: string | null +} + +export type GetKnowledgeDocumentResponse = { + data: GetKnowledgeDocumentResponseRef0 +} + +/** `GET /api/v2/logs/[runId]` */ +export type GetLogParams = { + runId: string +} + +export type GetLogQuery = Record + +type GetLogResponseRef0 = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: + | number + | { + total?: number + input?: number + output?: number + } + cost?: { + total?: number + input?: number + output?: number + toolCost?: number + } + relativeStartMs?: number + toolCalls?: Array<{ + id?: string + name?: string + arguments?: unknown + result?: unknown + error?: string + startTime?: string + endTime?: string + duration?: number + }> + children?: Array +} + +type GetLogResponseRef1 = { + runId: string + workflowId: string | null + deploymentVersionId: string | null + status: 'pending' | 'running' | 'paused' | 'redacting' | 'completed' | 'failed' | 'cancelled' + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + files: Array | null + workflow: { + id: string | null + name: string + description: string | null + folderPath: string | null + ownerEmail: string | null + workspaceId: string | null + createdAt: string | null + updatedAt: string | null + deleted: boolean + } + workflowState: Record | null + traceSpans: Array + finalOutput: unknown | null + cost: { + total: number + } | null + createdAt: string +} + +export type GetLogResponse = { + data: GetLogResponseRef1 +} + +/** `GET /api/v2/mcp-servers/[id]` */ +export type GetMcpServerParams = { + id: string +} + +export type GetMcpServerQuery = { + workspaceId: string +} + +type GetMcpServerResponseRef0 = { + 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 +} + +export type GetMcpServerResponse = { + data: GetMcpServerResponseRef0 +} + +/** `GET /api/v2/skills/[id]` */ +export type GetSkillParams = { + id: string +} + +export type GetSkillQuery = { + workspaceId: string +} + +type GetSkillResponseRef0 = { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string +} + +export type GetSkillResponse = { + data: GetSkillResponseRef0 +} + +/** `GET /api/v2/tables/[tableId]` */ +export type GetTableParams = { + tableId: string +} + +export type GetTableQuery = { + workspaceId: string +} + +type GetTableResponseRef0 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null +} + +type GetTableResponseRef1 = { + id: string + name: string + description: string | null + ownerEmail: string + 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?: string + }> + } + rowCount: number + maxRows: number + folderPath: string + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: GetTableResponseRef0 | null + createdAt: string + updatedAt: string +} + +export type GetTableResponse = { + data: GetTableResponseRef1 +} + +/** `GET /api/v2/tables/exports/[exportId]` */ +export type GetTableExportParams = { + exportId: string +} + +export type GetTableExportQuery = { + workspaceId: string +} + +type GetTableExportResponseRef0 = { + 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 +} + +export type GetTableExportResponse = { + data: GetTableExportResponseRef0 +} + +/** `GET /api/v2/tables/imports/[importId]` */ +export type GetTableImportParams = { + importId: string +} + +export type GetTableImportQuery = { + workspaceId: string +} + +export type GetTableImportHeaders = { + 'upload-token'?: string +} + +type GetTableImportResponseRef0 = { + type: 'upload' + name: string + contentType: string + size: number +} + +type GetTableImportResponseRef1 = { + type: 'workspace_file' + fileId: string +} + +type GetTableImportResponseRef2 = string + +type GetTableImportResponseRef3 = { + id: string + workspaceId: string + status: 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: GetTableImportResponseRef0 | GetTableImportResponseRef1 + target: + | { + type: 'new' + name: string + folderPath?: GetTableImportResponseRef2 + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null +} + +export type GetTableImportResponse = { + data: GetTableImportResponseRef3 +} + +/** `GET /api/v2/tables/[tableId]/rows/[rowId]` */ +export type GetTableRowParams = { + tableId: string + rowId: string +} + +export type GetTableRowQuery = { + workspaceId: string +} + +type GetTableRowResponseRef0 = Record + +type GetTableRowResponseRef1 = { + id: string + data: GetTableRowResponseRef0 + createdAt: string + updatedAt: string +} + +export type GetTableRowResponse = { + data: GetTableRowResponseRef1 +} + +/** `GET /api/v2/tables/[tableId]/views/[viewId]` */ +export type GetTableViewParams = { + tableId: string + viewId: string +} + +export type GetTableViewQuery = { + workspaceId: string +} + +type GetTableViewResponseRef0 = { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null +} + +type GetTableViewResponseRef1 = { + id: string + tableId: string + name: string + config: GetTableViewResponseRef0 + isDefault: boolean + createdByEmail: string | null + createdAt: string + updatedAt: string +} + +export type GetTableViewResponse = { + data: GetTableViewResponseRef1 +} + +/** `GET /api/v2/workflows/[id]` */ +export type GetWorkflowParams = { + id: string +} + +export type GetWorkflowQuery = Record + +type GetWorkflowResponseRef0 = { + name: string + type: string + description?: string +} + +type GetWorkflowResponseRef1 = { + id: string + name: string + description: string | null + folderPath: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + variables: Record + inputs: Array +} + +export type GetWorkflowResponse = { + data: GetWorkflowResponseRef1 +} + +/** `GET /api/v2/workflows/[id]/deployment` */ +export type GetWorkflowDeploymentParams = { + id: string +} + +export type GetWorkflowDeploymentQuery = Record + +type GetWorkflowDeploymentResponseRef0 = { + deploymentVersionId: string + version: number + deployedAt: string +} + +type GetWorkflowDeploymentResponseRef1 = { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: GetWorkflowDeploymentResponseRef2 + requestedAt: string + activatedAt?: string | null + error?: GetWorkflowDeploymentResponseRef3 | null +} + +type GetWorkflowDeploymentResponseRef2 = { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' +} + +type GetWorkflowDeploymentResponseRef3 = { + code: string + message: string + retryable: boolean +} + +type GetWorkflowDeploymentResponseRef4 = { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: GetWorkflowDeploymentResponseRef0 | null + latestDeploymentAttempt: GetWorkflowDeploymentResponseRef1 | null + needsRedeployment: boolean +} + +export type GetWorkflowDeploymentResponse = { + data: GetWorkflowDeploymentResponseRef4 +} + +/** `GET /api/v2/workflows/[id]/runs/[runId]` */ +export type GetWorkflowRunParams = { + id: string + runId: string +} + +export type GetWorkflowRunQuery = { + includeOutput?: boolean + selectedOutputs?: string +} + +type GetWorkflowRunResponseRef0 = { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string +} + +type GetWorkflowRunResponseRef1 = { + runId: string + workflowId: string + status: + | 'pending' + | 'running' + | 'paused' + | 'redacting' + | 'completed' + | 'failed' + | 'cancelled' + | 'queued' + trigger: string | null + startedAt: string | null + endedAt: string | null + durationMs: number | null + paused: { + contextId: string | null + pausedAt: string + resumeAt: string | null + pauseKind: 'time' | 'human' | null + blockedOnBlockId: string | null + automaticResumeWaitingReason: string | null + pausePointCount: number + resumedCount: number + } | null + cost: { + total: number + } | null + error: GetWorkflowRunResponseRef0 | null + output: unknown | null + blockOutputs: Record | null +} + +export type GetWorkflowRunResponse = { + data: GetWorkflowRunResponseRef1 +} + +/** `GET /api/v2/workflows/[id]/versions/[version]` */ +export type GetWorkflowVersionParams = { + id: string + version: number +} + +export type GetWorkflowVersionQuery = Record + +type GetWorkflowVersionResponseRef0 = Record + +type GetWorkflowVersionResponseRef1 = { + id: string + version: number + name: string | null + description: string | null + isActive: boolean + createdAt: string + state: GetWorkflowVersionResponseRef0 +} + +export type GetWorkflowVersionResponse = { + data: GetWorkflowVersionResponseRef1 +} + +/** `GET /api/v2/workspaces/[workspaceId]` */ +export type GetWorkspaceParams = { + workspaceId: string +} + +export type GetWorkspaceQuery = Record + +type GetWorkspaceResponseRef0 = { + id: string + name: string + color: string + logoUrl: string | null + memberCount: number + createdAt: string + updatedAt: string +} + +export type GetWorkspaceResponse = { + data: GetWorkspaceResponseRef0 +} + +/** `POST /api/v2/workflows/import` */ +export type ImportWorkflowQuery = Record + +type ImportWorkflowBodyRef0 = string + +export type ImportWorkflowBody = { + workspaceId: string + workflow: string | Record + folderPath?: ImportWorkflowBodyRef0 + name?: string + description?: string +} + +type ImportWorkflowResponseRef0 = { + id: string + name: string + description: string | null + workspaceId: string + folderPath: string + createdAt: string + updatedAt: string +} + +export type ImportWorkflowResponse = { + data: ImportWorkflowResponseRef0 +} + +/** `GET /api/v2/audit-logs` */ +export type ListAuditLogsQuery = { + action?: string + resourceType?: string + resourceId?: string + workspaceId?: string + startDate?: string + endDate?: string + includeDeparted?: boolean + limit?: number + cursor?: string + organizationId: string + actorEmail?: string +} + +type ListAuditLogsResponseRef0 = { + id: string + workspaceId: 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 +} + +export type ListAuditLogsResponse = { + data: Array + 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 +} + +type ListBillingLogsResponseRef0 = { + 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 + runId: string | null + creditCost: number +} + +export type ListBillingLogsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/credentials/providers` */ +export type ListCredentialProvidersQuery = { + workspaceId: string + search?: string +} + +type ListCredentialProvidersResponseRef0 = + | { + type: 'oauth' + serviceId: string + name: string + description: string + providerFamily: string + available: boolean + supportsReconnect: boolean + authorizationOptions: Array<{ + providerId: string + label: string + }> + } + | { + type: 'service_account' + serviceId: string + name: string + description: string + providerFamily: string + available: boolean + providerId: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId: boolean + fields: Array<{ + id: string + label: string + placeholder: string + required: boolean + secret: boolean + multiline: boolean + requiredForAuthMethods?: Array + options?: Array<{ + value: string + label: string + }> + hint?: string + }> + } + +export type ListCredentialProvidersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/credentials` */ +export type ListCredentialsQuery = { + workspaceId: string + type?: 'oauth' | 'service_account' + providerId?: string + search?: string + sortBy?: 'displayName' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListCredentialsResponseRef0 = { + id: string + type: 'oauth' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string +} + +export type ListCredentialsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/custom-tools` */ +export type ListCustomToolsQuery = { + workspaceId: string + search?: string + sortBy?: 'title' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListCustomToolsResponseRef0 = { + 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 +} + +export type ListCustomToolsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/files/folders` */ +type ListFileFoldersQueryRef0 = string + +export type ListFileFoldersQuery = { + workspaceId: string + parentPath?: ListFileFoldersQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListFileFoldersResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type ListFileFoldersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/files` */ +type ListFilesQueryRef0 = string + +export type ListFilesQuery = { + workspaceId: string + folderPath?: ListFilesQueryRef0 + scope?: 'active' | 'archived' + search?: string + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListFilesResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type ListFilesResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/knowledge` */ +type ListKnowledgeBasesQueryRef0 = string + +export type ListKnowledgeBasesQuery = { + workspaceId: string + folderPath?: ListKnowledgeBasesQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListKnowledgeBasesResponseRef0 = { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: ListKnowledgeBasesResponseRef1 + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + ownerEmail: string + folderPath: string +} + +type ListKnowledgeBasesResponseRef1 = { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } +} + +export type ListKnowledgeBasesResponse = { + data: Array + 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 + tagFilters?: string +} + +type ListKnowledgeDocumentsResponseRef0 = { + 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 + tags: Record +} + +export type ListKnowledgeDocumentsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/knowledge/folders` */ +type ListKnowledgeFoldersQueryRef0 = string + +export type ListKnowledgeFoldersQuery = { + workspaceId: string + parentPath?: ListKnowledgeFoldersQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListKnowledgeFoldersResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type ListKnowledgeFoldersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/knowledge/[id]/tags` */ +export type ListKnowledgeTagsParams = { + id: string +} + +export type ListKnowledgeTagsQuery = { + workspaceId: string +} + +type ListKnowledgeTagsResponseRef0 = { + displayName: string + tagSlot: string + fieldType: string +} + +export type ListKnowledgeTagsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/logs` */ +export type ListLogsQuery = { + workspaceId: string + workflowIds?: string + triggers?: string + level?: 'info' | 'error' + startDate?: string + endDate?: string + minDurationMs?: number + maxDurationMs?: number + minCost?: number + maxCost?: number + model?: string + details?: 'basic' | 'full' + includeTraceSpans?: boolean + includeFinalOutput?: boolean + limit?: number + cursor?: string + order?: 'asc' | 'desc' + runId?: string + folderPaths?: string +} + +type ListLogsResponseRef0 = { + runId: string + workflowId: string | null + deploymentVersionId: string | null + status: 'pending' | 'running' | 'paused' | 'redacting' | 'completed' | 'failed' | 'cancelled' + 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?: Array +} + +type ListLogsResponseRef1 = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: + | number + | { + total?: number + input?: number + output?: number + } + cost?: { + total?: number + input?: number + output?: number + toolCost?: 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 + nextCursor: string | null +} + +/** `GET /api/v2/mcp-servers` */ +export type ListMcpServersQuery = { + workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListMcpServersResponseRef0 = { + 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 +} + +export type ListMcpServersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/mcp-servers/[id]/tools` */ +export type ListMcpServerToolsParams = { + id: string +} + +export type ListMcpServerToolsQuery = { + workspaceId: string + refresh?: boolean +} + +type ListMcpServerToolsResponseRef0 = { + name: string + description?: string + inputSchema: { + type: 'object' + properties?: Record + required?: Array + } + serverId: string + serverName: string +} + +export type ListMcpServerToolsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/secrets` */ +export type ListSecretsQuery = { + workspaceId: string + scope?: 'workspace' | 'personal' + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListSecretsResponseRef0 = { + name: string + scope: 'workspace' | 'personal' + role: 'admin' | 'member' + createdAt: string + updatedAt: string +} + +export type ListSecretsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/skills` */ +export type ListSkillsQuery = { + workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListSkillsResponseRef0 = { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string +} + +export type ListSkillsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables/folders` */ +type ListTableFoldersQueryRef0 = string + +export type ListTableFoldersQuery = { + workspaceId: string + parentPath?: ListTableFoldersQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListTableFoldersResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type ListTableFoldersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/rows` */ +export type ListTableRowsParams = { + tableId: string +} + +export type ListTableRowsQuery = { + workspaceId: string + limit?: number + cursor?: string +} + +type ListTableRowsResponseRef0 = { + id: string + data: ListTableRowsResponseRef1 + createdAt: string + updatedAt: string +} + +type ListTableRowsResponseRef1 = Record + +export type ListTableRowsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables` */ +type ListTablesQueryRef0 = string + +export type ListTablesQuery = { + workspaceId: string + folderPath?: ListTablesQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListTablesResponseRef0 = { + id: string + name: string + description: string | null + ownerEmail: string + 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?: string + }> + } + rowCount: number + maxRows: number + folderPath: string + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: ListTablesResponseRef1 | null + createdAt: string + updatedAt: string +} + +type ListTablesResponseRef1 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null +} + +export type ListTablesResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/views` */ +export type ListTableViewsParams = { + tableId: string +} + +export type ListTableViewsQuery = { + workspaceId: string +} + +type ListTableViewsResponseRef0 = { + id: string + tableId: string + name: string + config: ListTableViewsResponseRef1 + isDefault: boolean + createdByEmail: string | null + createdAt: string + updatedAt: string +} + +type ListTableViewsResponseRef1 = { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null +} + +export type ListTableViewsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workflows/folders` */ +type ListWorkflowFoldersQueryRef0 = string + +export type ListWorkflowFoldersQuery = { + workspaceId: string + parentPath?: ListWorkflowFoldersQueryRef0 + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +type ListWorkflowFoldersResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean +} + +export type ListWorkflowFoldersResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/groups` */ +export type ListWorkflowGroupsParams = { + tableId: string +} + +export type ListWorkflowGroupsQuery = { + workspaceId: string +} + +type ListWorkflowGroupsResponseRef0 = { + 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 +} + +export type ListWorkflowGroupsResponse = { + data: Array + 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' +} + +type ListWorkflowRunsResponseRef0 = { + runId: string + workflowId: string + status: 'pending' | 'running' | 'paused' | 'redacting' | 'completed' | 'failed' | 'cancelled' + trigger: string + startedAt: string + endedAt: string | null + durationMs: number | null + cost: { + total: number + } | null +} + +export type ListWorkflowRunsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workflows` */ +type ListWorkflowsQueryRef0 = string + +export type ListWorkflowsQuery = { + workspaceId: string + folderPath?: ListWorkflowsQueryRef0 + deployedOnly?: boolean + limit?: number + cursor?: string + search?: string + sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' + sortOrder?: 'asc' | 'desc' +} + +type ListWorkflowsResponseRef0 = { + id: string + name: string + description: string | null + folderPath: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string +} + +export type ListWorkflowsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workflows/[id]/versions` */ +export type ListWorkflowVersionsParams = { + id: string +} + +export type ListWorkflowVersionsQuery = { + limit?: number + cursor?: string +} + +type ListWorkflowVersionsResponseRef0 = { + id: string + version: number + name?: string | null + description?: string | null + isActive: boolean + createdAt: string + deployedBy?: string | null + latestOperationStatus?: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' | null +} + +export type ListWorkflowVersionsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workspaces/[workspaceId]/members` */ +export type ListWorkspaceMembersParams = { + workspaceId: string +} + +export type ListWorkspaceMembersQuery = { + limit?: number + cursor?: string +} + +type ListWorkspaceMembersResponseRef0 = { + email: string + name: string + image: string | null + role: 'admin' | 'write' | 'read' + isExternal: boolean + joinedAt: string +} + +export type ListWorkspaceMembersResponse = { + data: Array + nextCursor: string | null +} + +/** `POST /api/v2/files/move` */ +export type MoveFileItemsQuery = Record + +type MoveFileItemsBodyRef0 = string + +export type MoveFileItemsBody = { + workspaceId: string + fileIds: Array + targetFolderPath?: MoveFileItemsBodyRef0 +} + +type MoveFileItemsResponseRef0 = { + movedItems: { + files: number + } +} + +export type MoveFileItemsResponse = { + data: MoveFileItemsResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/query` */ +export type QueryRowsParams = { + tableId: string +} + +export type QueryRowsQuery = Record + +type QueryRowsBodyRef0 = + | { + all: Array< + | QueryRowsBodyRef0 + | { + 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< + | QueryRowsBodyRef0 + | { + 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 QueryRowsBody = { + workspaceId: string + predicate?: QueryRowsBodyRef0 + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> + limit?: number + cursor?: string +} + +type QueryRowsResponseRef0 = { + id: string + data: QueryRowsResponseRef1 + createdAt: string + updatedAt: string +} + +type QueryRowsResponseRef1 = Record + +export type QueryRowsResponse = { + data: Array + nextCursor: string | null +} + +/** `POST /api/v2/tables/[tableId]/query/count` */ +export type QueryRowsCountParams = { + tableId: string +} + +export type QueryRowsCountQuery = Record + +type QueryRowsCountBodyRef0 = + | { + all: Array< + | QueryRowsCountBodyRef0 + | { + 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< + | QueryRowsCountBodyRef0 + | { + 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 QueryRowsCountBody = { + workspaceId: string + predicate?: QueryRowsCountBodyRef0 +} + +type QueryRowsCountResponseRef0 = { + totalCount: number +} + +export type QueryRowsCountResponse = { + data: QueryRowsCountResponseRef0 +} + +/** `PATCH /api/v2/files/folders` */ +export type RelocateFileFolderQuery = Record + +type RelocateFileFolderBodyRef0 = string + +export type RelocateFileFolderBody = { + workspaceId: string + path: RelocateFileFolderBodyRef0 + destinationPath: RelocateFileFolderBodyRef0 +} + +type RelocateFileFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type RelocateFileFolderResponse = { + data: RelocateFileFolderResponseRef0 +} + +/** `PATCH /api/v2/knowledge/folders` */ +export type RelocateKnowledgeFolderQuery = Record + +type RelocateKnowledgeFolderBodyRef0 = string + +export type RelocateKnowledgeFolderBody = { + workspaceId: string + path: RelocateKnowledgeFolderBodyRef0 + destinationPath: RelocateKnowledgeFolderBodyRef0 +} + +type RelocateKnowledgeFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type RelocateKnowledgeFolderResponse = { + data: RelocateKnowledgeFolderResponseRef0 +} + +/** `PATCH /api/v2/tables/folders` */ +export type RelocateTableFolderQuery = Record + +type RelocateTableFolderBodyRef0 = string + +export type RelocateTableFolderBody = { + workspaceId: string + path: RelocateTableFolderBodyRef0 + destinationPath: RelocateTableFolderBodyRef0 +} + +type RelocateTableFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +export type RelocateTableFolderResponse = { + data: RelocateTableFolderResponseRef0 +} + +/** `PATCH /api/v2/workflows/folders` */ +export type RelocateWorkflowFolderQuery = Record + +type RelocateWorkflowFolderBodyRef0 = string + +export type RelocateWorkflowFolderBody = { + workspaceId: string + path: RelocateWorkflowFolderBodyRef0 + destinationPath: RelocateWorkflowFolderBodyRef0 +} + +type RelocateWorkflowFolderResponseRef0 = { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean +} + +export type RelocateWorkflowFolderResponse = { + data: RelocateWorkflowFolderResponseRef0 +} + +/** `PATCH /api/v2/files/[fileId]` */ +export type RenameFileParams = { + fileId: string +} + +export type RenameFileQuery = Record + +export type RenameFileBody = { + workspaceId: string + name: string +} + +type RenameFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type RenameFileResponse = { + data: RenameFileResponseRef0 +} + +/** `POST /api/v2/files/[fileId]/restore` */ +export type RestoreFileParams = { + fileId: string +} + +export type RestoreFileQuery = Record + +export type RestoreFileBody = { + workspaceId: string +} + +type RestoreFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type RestoreFileResponse = { + data: RestoreFileResponseRef0 +} + +/** `POST /api/v2/workflows/[id]/runs/[runId]/resume` */ +export type ResumeWorkflowParams = { + id: string + runId: string +} + +export type ResumeWorkflowQuery = Record + +export type ResumeWorkflowBody = { + contextId: string + input?: unknown +} + +type ResumeWorkflowResponseRef0 = { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string +} + +type ResumeWorkflowResponseRef1 = { + runId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + output: unknown + error: ResumeWorkflowResponseRef0 | null + startedAt?: string + endedAt?: string + durationMs?: number +} + +type ResumeWorkflowResponseRef2 = { + runId: string + statusUrl: string + queuePosition?: number +} + +export type ResumeWorkflowResponse = + | { + data: ResumeWorkflowResponseRef1 + } + | { + data: ResumeWorkflowResponseRef2 + } + +/** `POST /api/v2/workflows/[id]/rollback` */ +export type RollbackWorkflowParams = { + id: string +} + +export type RollbackWorkflowQuery = Record + +export type RollbackWorkflowBody = { + version?: number +} + +type RollbackWorkflowResponseRef0 = { + deploymentVersionId: string + version: number + deployedAt: string +} + +type RollbackWorkflowResponseRef1 = { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: RollbackWorkflowResponseRef2 + requestedAt: string + activatedAt?: string | null + error?: RollbackWorkflowResponseRef3 | null +} + +type RollbackWorkflowResponseRef2 = { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' +} + +type RollbackWorkflowResponseRef3 = { + code: string + message: string + retryable: boolean +} + +type RollbackWorkflowResponseRef4 = { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: RollbackWorkflowResponseRef0 | null + latestDeploymentAttempt: RollbackWorkflowResponseRef1 | null + version: number +} + +export type RollbackWorkflowResponse = { + data: RollbackWorkflowResponseRef4 +} + +/** `POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ +export type RunRowEnrichmentParams = { + tableId: string + rowId: string + groupId: string +} + +export type RunRowEnrichmentQuery = Record + +export type RunRowEnrichmentBody = { + workspaceId: string +} + +type RunRowEnrichmentResponseRef0 = { + dispatchId: string | null +} + +export type RunRowEnrichmentResponse = { + data: RunRowEnrichmentResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/columns/run` */ +export type RunTableColumnParams = { + tableId: string +} + +export type RunTableColumnQuery = Record + +type RunTableColumnBodyRef0 = + | { + all: Array< + | RunTableColumnBodyRef0 + | { + 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< + | RunTableColumnBodyRef0 + | { + 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 RunTableColumnBody = { + workspaceId: string + groupIds: Array + runMode?: 'all' | 'incomplete' + rowIds?: Array + filter?: RunTableColumnBodyRef0 + excludeRowIds?: Array + limit?: { + type: 'rows' + max: number + } +} + +type RunTableColumnResponseRef0 = { + dispatchId: string | null +} + +export type RunTableColumnResponse = { + data: RunTableColumnResponseRef0 +} + +/** `POST /api/v2/knowledge/search` */ +export type SearchKnowledgeQuery = Record + +type SearchKnowledgeBodyRef0 = { + tagName: string + fieldType?: 'text' | 'number' | 'date' | 'boolean' + operator?: + | 'eq' + | 'neq' + | 'contains' + | 'not_contains' + | 'starts_with' + | 'ends_with' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'between' + value: string | number | boolean + valueTo?: string | number +} + +export type SearchKnowledgeBody = { + workspaceId: string + knowledgeBaseIds: string | Array + query?: string + topK?: number + tagFilters?: Array + searchMode?: 'vector' | 'hybrid' | null + rerankerEnabled?: boolean + rerankerModel?: 'rerank-v4.0-pro' | 'rerank-v4.0-fast' | 'rerank-v3.5' + rerankerInputCount?: number +} + +type SearchKnowledgeResponseRef0 = { + knowledgeBaseId: string + documentId: string + documentName: string | null + sourceUrl: string | null + content: string + chunkIndex: number + metadata: Record + similarity: number + rerankerScore?: number +} + +type SearchKnowledgeResponseRef1 = { + results: Array + query: string + knowledgeBaseIds: Array + topK: number + totalResults: number + rerankerStatus: 'not_requested' | 'skipped' | 'unavailable' | 'applied' +} + +export type SearchKnowledgeResponse = { + data: SearchKnowledgeResponseRef1 +} + +/** `PUT /api/v2/secrets/[name]` */ +export type SetSecretParams = { + name: string +} + +export type SetSecretQuery = Record + +export type SetSecretBody = { + workspaceId: string + scope: 'workspace' | 'personal' + value: string +} + +type SetSecretResponseRef0 = { + name: string + scope: 'workspace' | 'personal' + role: 'admin' | 'member' + createdAt: string + updatedAt: string +} + +export type SetSecretResponse = { + data: SetSecretResponseRef0 +} + +/** `GET /api/v2/tables/exports/[exportId]/download` */ +export type TableExportDownloadParams = { + exportId: string +} + +export type TableExportDownloadQuery = { + workspaceId: string +} + +type TableExportDownloadResponseRef0 = { + url: string + fileName: string + expiresAt: string +} + +export type TableExportDownloadResponse = { + data: TableExportDownloadResponseRef0 +} + +/** `DELETE /api/v2/workflows/[id]/deploy` */ +export type UndeployWorkflowParams = { + id: string +} + +export type UndeployWorkflowQuery = Record + +type UndeployWorkflowResponseRef0 = { + deploymentVersionId: string + version: number + deployedAt: string +} + +type UndeployWorkflowResponseRef1 = { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean + readiness: UndeployWorkflowResponseRef2 + requestedAt: string + activatedAt?: string | null + error?: UndeployWorkflowResponseRef3 | null +} + +type UndeployWorkflowResponseRef2 = { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' +} + +type UndeployWorkflowResponseRef3 = { + code: string + message: string + retryable: boolean +} + +type UndeployWorkflowResponseRef4 = { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: UndeployWorkflowResponseRef0 | null + latestDeploymentAttempt: UndeployWorkflowResponseRef1 | null +} + +export type UndeployWorkflowResponse = { + data: UndeployWorkflowResponseRef4 +} + +/** `PATCH /api/v2/custom-tools/[id]` */ +export type UpdateCustomToolParams = { + id: string +} + +export type UpdateCustomToolQuery = Record + +export type UpdateCustomToolBody = { + workspaceId: string + title?: string + schema?: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code?: string +} + +type UpdateCustomToolResponseRef0 = { + 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 +} + +export type UpdateCustomToolResponse = { + data: UpdateCustomToolResponseRef0 +} + +/** `PUT /api/v2/files/[fileId]/content` */ +export type UpdateFileContentParams = { + fileId: string +} + +export type UpdateFileContentQuery = Record + +export type UpdateFileContentBody = { + workspaceId: string + content: string + encoding?: 'utf-8' | 'base64' +} + +type UpdateFileContentResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + deletedAt: string | null +} + +export type UpdateFileContentResponse = { + data: UpdateFileContentResponseRef0 +} + +/** `PATCH /api/v2/knowledge/[id]` */ +export type UpdateKnowledgeBaseParams = { + id: string +} + +export type UpdateKnowledgeBaseQuery = Record + +type UpdateKnowledgeBaseBodyRef0 = { + maxSize?: number + minSize?: number + overlap?: number +} + +type UpdateKnowledgeBaseBodyRef1 = string + +export type UpdateKnowledgeBaseBody = { + workspaceId: string + name?: string + description?: string + chunkingConfig?: UpdateKnowledgeBaseBodyRef0 + folderPath?: UpdateKnowledgeBaseBodyRef1 +} + +type UpdateKnowledgeBaseResponseRef0 = { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } +} + +type UpdateKnowledgeBaseResponseRef1 = { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: UpdateKnowledgeBaseResponseRef0 + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + ownerEmail: string + folderPath: string +} + +export type UpdateKnowledgeBaseResponse = { + data: UpdateKnowledgeBaseResponseRef1 +} + +/** `PATCH /api/v2/knowledge/[id]/documents/[documentId]` */ +export type UpdateKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type UpdateKnowledgeDocumentQuery = Record + +export type UpdateKnowledgeDocumentBody = { + workspaceId: string + filename?: string + enabled?: boolean + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string + number1?: number + number2?: number + number3?: number + number4?: number + number5?: number + date1?: string + date2?: string + boolean1?: boolean + boolean2?: boolean + boolean3?: boolean + retryProcessing?: true +} + +type UpdateKnowledgeDocumentResponseRef0 = { + 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 + tags: Record +} + +type UpdateKnowledgeDocumentResponseRef1 = { + id: string + queued: true + processingStatus: string + message: string +} + +export type UpdateKnowledgeDocumentResponse = { + data: UpdateKnowledgeDocumentResponseRef0 | UpdateKnowledgeDocumentResponseRef1 +} + +/** `PATCH /api/v2/mcp-servers/[id]` */ +export type UpdateMcpServerParams = { + id: string +} + +export type UpdateMcpServerQuery = Record + +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 +} + +type UpdateMcpServerResponseRef0 = { + 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 +} + +export type UpdateMcpServerResponse = { + data: UpdateMcpServerResponseRef0 +} + +/** `PATCH /api/v2/tables/[tableId]/rows` */ +export type UpdateRowsByFilterParams = { + tableId: string +} + +export type UpdateRowsByFilterQuery = Record + +type UpdateRowsByFilterBodyRef0 = + | { + all: Array< + | UpdateRowsByFilterBodyRef0 + | { + 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< + | UpdateRowsByFilterBodyRef0 + | { + 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 + } + > + } + +type UpdateRowsByFilterBodyRef1 = Record + +export type UpdateRowsByFilterBody = { + workspaceId: string + filter: UpdateRowsByFilterBodyRef0 + data: UpdateRowsByFilterBodyRef1 + limit?: number +} + +type UpdateRowsByFilterResponseRef0 = { + updatedCount: number + updatedRowIds: Array +} + +export type UpdateRowsByFilterResponse = { + data: UpdateRowsByFilterResponseRef0 +} + +/** `PATCH /api/v2/skills/[id]` */ +export type UpdateSkillParams = { + id: string +} + +export type UpdateSkillQuery = Record + +export type UpdateSkillBody = { + workspaceId: string + name?: string + description?: string + content?: string +} + +type UpdateSkillResponseRef0 = { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string +} + +export type UpdateSkillResponse = { + data: UpdateSkillResponseRef0 +} + +/** `PATCH /api/v2/tables/[tableId]` */ +export type UpdateTableParams = { + tableId: string +} + +export type UpdateTableQuery = Record + +type UpdateTableBodyRef0 = string + +export type UpdateTableBody = { + workspaceId: string + name?: string + description?: string | null + folderPath?: UpdateTableBodyRef0 +} + +type UpdateTableResponseRef0 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null +} + +type UpdateTableResponseRef1 = { + id: string + name: string + description: string | null + ownerEmail: string + 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?: string + }> + } + rowCount: number + maxRows: number + folderPath: string + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: UpdateTableResponseRef0 | null + createdAt: string + updatedAt: string +} + +export type UpdateTableResponse = { + data: UpdateTableResponseRef1 +} + +/** `PATCH /api/v2/tables/[tableId]/columns` */ +export type UpdateTableColumnParams = { + tableId: string +} + +export type UpdateTableColumnQuery = Record + +export type UpdateTableColumnBody = { + workspaceId: string + columnName: string + updates: { + name?: string + type?: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: string + } +} + +type UpdateTableColumnResponseRef0 = { + 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?: string + }> +} + +export type UpdateTableColumnResponse = { + data: UpdateTableColumnResponseRef0 +} + +/** `PATCH /api/v2/tables/[tableId]/rows/[rowId]` */ +export type UpdateTableRowParams = { + tableId: string + rowId: string +} + +export type UpdateTableRowQuery = Record + +type UpdateTableRowBodyRef0 = Record + +export type UpdateTableRowBody = { + workspaceId: string + data: UpdateTableRowBodyRef0 +} + +type UpdateTableRowResponseRef0 = Record + +type UpdateTableRowResponseRef1 = { + id: string + data: UpdateTableRowResponseRef0 + createdAt: string + updatedAt: string +} + +export type UpdateTableRowResponse = { + data: UpdateTableRowResponseRef1 +} + +/** `PATCH /api/v2/tables/[tableId]/views/[viewId]` */ +export type UpdateTableViewParams = { + tableId: string + viewId: string +} + +export type UpdateTableViewQuery = Record + +type UpdateTableViewBodyRef0 = + | { + all: Array< + | UpdateTableViewBodyRef0 + | { + 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< + | UpdateTableViewBodyRef0 + | { + 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 + } + > + } + | { + 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 UpdateTableViewBody = { + workspaceId: string + name?: string + config?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: UpdateTableViewBodyRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + configPatch?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: UpdateTableViewBodyRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault?: boolean +} + +type UpdateTableViewResponseRef0 = { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null +} + +type UpdateTableViewResponseRef1 = { + id: string + tableId: string + name: string + config: UpdateTableViewResponseRef0 + isDefault: boolean + createdByEmail: string | null + createdAt: string + updatedAt: string +} + +export type UpdateTableViewResponse = { + data: UpdateTableViewResponseRef1 +} + +/** `PATCH /api/v2/workflows/[id]` */ +export type UpdateWorkflowParams = { + id: string +} + +export type UpdateWorkflowQuery = Record + +type UpdateWorkflowBodyRef0 = string + +export type UpdateWorkflowBody = { + name?: string + description?: string | null + folderPath?: UpdateWorkflowBodyRef0 +} + +type UpdateWorkflowResponseRef0 = { + id: string + name: string + description: string | null + folderPath: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string +} + +export type UpdateWorkflowResponse = { + data: UpdateWorkflowResponseRef0 +} + +/** `PATCH /api/v2/tables/[tableId]/groups` */ +export type UpdateWorkflowGroupParams = { + tableId: string +} + +export type UpdateWorkflowGroupQuery = Record + +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 +} + +type UpdateWorkflowGroupResponseRef0 = { + 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 +} + +type UpdateWorkflowGroupResponseRef1 = { + group: UpdateWorkflowGroupResponseRef0 + 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?: string + }> +} + +export type UpdateWorkflowGroupResponse = { + data: UpdateWorkflowGroupResponseRef1 +} + +/** `POST /api/v2/knowledge/[id]/documents` */ +export type UploadKnowledgeDocumentParams = { + id: string +} + +export type UploadKnowledgeDocumentQuery = { + workspaceId: string +} + +type UploadKnowledgeDocumentResponseRef0 = { + 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 +} + +export type UploadKnowledgeDocumentResponse = { + data: UploadKnowledgeDocumentResponseRef0 +} + +/** `PATCH /api/v2/files/[fileId]/share` */ +export type UpsertFileShareParams = { + fileId: string +} + +export type UpsertFileShareQuery = Record + +export type UpsertFileShareBody = { + workspaceId: string + isActive: boolean + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array +} + +type UpsertFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type UpsertFileShareResponse = { + data: UpsertFileShareResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/rows/upsert` */ +export type UpsertTableRowParams = { + tableId: string +} + +export type UpsertTableRowQuery = Record + +type UpsertTableRowBodyRef0 = Record + +export type UpsertTableRowBody = { + workspaceId: string + data: UpsertTableRowBodyRef0 + conflictTarget?: string +} + +type UpsertTableRowResponseRef0 = Record + +type UpsertTableRowResponseRef1 = { + id: string + data: UpsertTableRowResponseRef0 + createdAt: string + updatedAt: string +} + +type UpsertTableRowResponseRef2 = { + row: UpsertTableRowResponseRef1 + operation: 'insert' | 'update' +} + +export type UpsertTableRowResponse = { + data: UpsertTableRowResponseRef2 +} + +/** + * 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 = { + 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 }, + }, + }, + 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', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Add Column', + body: { + workspaceId: { kind: 'string', required: true }, + 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 }, + }, + }, + bulkDeleteFiles: { + method: 'POST', + path: '/api/v2/files/bulk-delete', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Files', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', required: true }, + }, + }, + bulkUpdateKnowledgeDocuments: { + method: 'PATCH', + path: '/api/v2/knowledge/[id]/documents', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Bulk Enable or Disable Documents', + body: { + workspaceId: { kind: 'string', required: true }, + operation: { kind: 'enum', required: true, values: ['enable', 'disable'] as const }, + documentIds: { kind: 'array' }, + selectAll: { kind: 'boolean' }, + enabledFilter: { kind: 'enum', values: ['all', 'enabled', 'disabled'] as const }, + }, + }, + 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 }, + scope: { kind: 'enum', required: true, values: ['all', 'row'] as const }, + rowId: { kind: 'string' }, + filter: { kind: 'unknown' }, + excludeRowIds: { kind: 'array' }, + }, + }, + cancelWorkflowRun: { + method: 'POST', + path: '/api/v2/workflows/[id]/runs/[runId]/cancel', + pathParams: ['id', 'runId'] as const, + responseMode: 'json', + summary: 'Cancel Workflow Run', + }, + 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 }, + }, + }, + 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 }, + }, + }, + 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 }, + }, + }, + createCredentialConnection: { + method: 'POST', + path: '/api/v2/credentials/connections', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Credential Connection', + body: { + workspaceId: { kind: 'string', required: true }, + }, + opaqueBody: true, + }, + 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 }, + }, + }, + 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' }, + 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', + 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 }, + folderPath: { 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 }, + }, + }, + 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' }, + folderPath: { kind: 'string' }, + }, + }, + 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 }, + }, + }, + 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', + 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, default: 'streamable-http' }, + url: { kind: 'string', required: true }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer', default: 30000 }, + retries: { kind: 'integer', default: 3 }, + enabled: { kind: 'boolean', default: true }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, + createServiceAccountCredential: { + method: 'POST', + path: '/api/v2/credentials', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Service-Account Credential', + body: { + workspaceId: { kind: 'string', required: true }, + type: { kind: 'string', required: true }, + providerId: { kind: 'string', required: true }, + displayName: { kind: 'string' }, + description: { kind: 'string' }, + id: { kind: 'string' }, + serviceAccountJson: { kind: 'string' }, + apiToken: { kind: 'string' }, + domain: { kind: 'string' }, + signingSecret: { kind: 'string' }, + botToken: { kind: 'string' }, + clientId: { kind: 'string' }, + clientSecret: { kind: 'string' }, + certificateId: { kind: 'string' }, + orgId: { kind: 'string' }, + dataCenter: { kind: 'string' }, + authMethod: { kind: 'string' }, + privateKey: { kind: 'string' }, + username: { 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', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Table', + body: { + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + workspaceId: { kind: 'string', required: true }, + schema: { kind: 'object', required: true }, + folderPath: { 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' }, + }, + }, + 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', + 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: 'object' }, + createColumns: { kind: 'array' }, + 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', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Create Rows', + body: { + workspaceId: { kind: 'string', required: true }, + }, + opaqueBody: true, + }, + 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' }, + folderPath: { kind: 'string' }, + }, + }, + createWorkflowFolder: { + method: 'POST', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Workflow Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, + deleteCredential: { + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]', + pathParams: ['credentialId'] as const, + responseMode: 'json', + summary: 'Disconnect 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]', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Delete File', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteFileFolder: { + method: 'DELETE', + path: '/api/v2/files/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: 'false', + }, + }, + }, + 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 }, + }, + }, + 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: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: 'false', + }, + }, + }, + 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 }, + }, + }, + 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]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Skill', + 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 }, + }, + }, + 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: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: 'false', + }, + }, + }, + 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' }, + }, + }, + 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', + }, + deleteWorkflowFolder: { + method: 'DELETE', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Workflow Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: 'false', + }, + }, + }, + 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', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Deploy Workflow', + body: { + name: { kind: 'string' }, + description: { kind: 'string' }, + }, + }, + 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 Workflow', + 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 }, + 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 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]', + 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' }, + }, + }, + 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 }, + }, + }, + getFile: { + method: 'GET', + path: '/api/v2/files/[fileId]/metadata', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Get File Metadata', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + }, + }, + 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 }, + }, + }, + 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/[runId]', + pathParams: ['runId'] as const, + 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]', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Get Table', + query: { + 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]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + summary: 'Get Row', + query: { + 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 }, + }, + }, + getWorkflow: { + method: 'GET', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Workflow', + }, + getWorkflowDeployment: { + method: 'GET', + path: '/api/v2/workflows/[id]/deployment', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Workflow Deployment', + }, + getWorkflowRun: { + method: 'GET', + path: '/api/v2/workflows/[id]/runs/[runId]', + pathParams: ['id', 'runId'] as const, + responseMode: 'json', + summary: 'Get Workflow Run', + query: { + includeOutput: { kind: 'boolean' }, + selectedOutputs: { kind: 'string' }, + }, + }, + getWorkflowVersion: { + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + pathParams: ['id', 'version'] as const, + 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', + pathParams: [] as const, + responseMode: 'json', + summary: 'Import Workflow', + body: { + workspaceId: { kind: 'string', required: true }, + workflow: { kind: 'unknown', required: true }, + folderPath: { kind: 'string' }, + name: { kind: 'string' }, + description: { kind: 'string' }, + }, + }, + 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' }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + includeDeparted: { kind: 'boolean' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + organizationId: { kind: 'string', required: true }, + actorEmail: { kind: 'string' }, + }, + }, + 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' }, + }, + }, + listCredentialProviders: { + method: 'GET', + path: '/api/v2/credentials/providers', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Credential Providers', + query: { + workspaceId: { kind: 'string', required: true }, + search: { 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', '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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { 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 }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['title', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + listFileFolders: { + method: 'GET', + path: '/api/v2/files/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' }, + }, + }, + listFiles: { + method: 'GET', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Files', + query: { + workspaceId: { kind: 'string', required: true }, + folderPath: { kind: 'string' }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + 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: 'integer', 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 }, + folderPath: { 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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + 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' }, + tagFilters: { 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' }, + }, + }, + listKnowledgeTags: { + method: 'GET', + path: '/api/v2/knowledge/[id]/tags', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List Tags', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + listLogs: { + method: 'GET', + path: '/api/v2/logs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Logs', + query: { + workspaceId: { kind: 'string', required: true }, + workflowIds: { kind: 'string' }, + triggers: { kind: 'string' }, + level: { kind: 'enum', values: ['info', 'error'] as const }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + minDurationMs: { kind: 'integer' }, + maxDurationMs: { kind: 'integer' }, + 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: 'integer', default: 100 }, + cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + runId: { kind: 'string' }, + folderPaths: { kind: 'string' }, + }, + }, + listMcpServers: { + method: 'GET', + path: '/api/v2/mcp-servers', + pathParams: [] as const, + responseMode: 'json', + 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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + listMcpServerTools: { + method: 'GET', + path: '/api/v2/mcp-servers/[id]/tools', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List MCP Server Tools', + query: { + workspaceId: { kind: 'string', required: true }, + refresh: { kind: 'boolean' }, + }, + }, + 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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + listSkills: { + method: 'GET', + path: '/api/v2/skills', + pathParams: [] as const, + responseMode: 'json', + 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' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, + 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', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Rows', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'integer', default: 100 }, + cursor: { kind: 'string' }, + }, + }, + listTables: { + method: 'GET', + path: '/api/v2/tables', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Tables', + query: { + workspaceId: { kind: 'string', required: true }, + folderPath: { 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' }, + limit: { kind: 'integer', default: 100 }, + cursor: { kind: 'string' }, + }, + }, + listTableViews: { + method: 'GET', + path: '/api/v2/tables/[tableId]/views', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Views', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + listWorkflowFolders: { + method: 'GET', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Workflow 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', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Workflow Groups', + query: { + 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', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Workflows', + query: { + workspaceId: { kind: 'string', required: true }, + folderPath: { kind: 'string' }, + deployedOnly: { kind: 'boolean' }, + limit: { kind: 'integer', 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' }, + }, + }, + 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', + pathParams: [] as const, + responseMode: 'json', + summary: 'Move Files', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', required: true }, + targetFolderPath: { 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' }, + }, + }, + queryRowsCount: { + method: 'POST', + path: '/api/v2/tables/[tableId]/query/count', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Count Rows', + body: { + workspaceId: { kind: 'string', required: true }, + predicate: { kind: 'unknown' }, + }, + }, + relocateFileFolder: { + method: 'PATCH', + path: '/api/v2/files/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 }, + }, + }, + relocateKnowledgeFolder: { + method: 'PATCH', + path: '/api/v2/knowledge/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 }, + }, + }, + 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 Workflow 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: '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 }, + }, + }, + resumeWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/runs/[runId]/resume', + pathParams: ['id', 'runId'] as const, + responseMode: 'json', + summary: 'Resume Workflow Run', + body: { + contextId: { kind: 'string', required: true }, + input: { kind: 'unknown' }, + }, + }, + rollbackWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Rollback Workflow', + body: { + version: { kind: 'integer' }, + }, + }, + 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', + 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' }, + rerankerEnabled: { kind: 'boolean' }, + rerankerModel: { + kind: 'enum', + values: ['rerank-v4.0-pro', 'rerank-v4.0-fast', 'rerank-v3.5'] as const, + default: 'rerank-v4.0-fast', + }, + rerankerInputCount: { kind: 'integer' }, + }, + }, + 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', + 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', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Undeploy Workflow', + }, + 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' }, + }, + }, + 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' }, + }, + }, + updateKnowledgeBase: { + method: 'PATCH', + 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' }, + folderPath: { kind: 'string' }, + }, + }, + updateKnowledgeDocument: { + method: 'PATCH', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + pathParams: ['id', 'documentId'] as const, + responseMode: 'json', + summary: 'Update Document', + body: { + workspaceId: { kind: 'string', required: true }, + filename: { kind: 'string' }, + enabled: { kind: 'boolean' }, + tag1: { kind: 'string' }, + tag2: { kind: 'string' }, + tag3: { kind: 'string' }, + tag4: { kind: 'string' }, + tag5: { kind: 'string' }, + tag6: { kind: 'string' }, + tag7: { kind: 'string' }, + number1: { kind: 'number' }, + number2: { kind: 'number' }, + number3: { kind: 'number' }, + number4: { kind: 'number' }, + number5: { kind: 'number' }, + date1: { kind: 'string' }, + date2: { kind: 'string' }, + boolean1: { kind: 'boolean' }, + boolean2: { kind: 'boolean' }, + boolean3: { kind: 'boolean' }, + retryProcessing: { kind: 'boolean' }, + }, + }, + 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, default: 'streamable-http' }, + url: { kind: 'string' }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer', default: 30000 }, + retries: { kind: 'integer', default: 3 }, + enabled: { kind: 'boolean', default: true }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, + updateRowsByFilter: { + method: 'PATCH', + 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: 'object', required: true }, + 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' }, + }, + }, + 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' }, + description: { kind: 'string' }, + folderPath: { kind: 'string' }, + }, + }, + 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: 'object', required: true }, + }, + }, + updateTableView: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, + responseMode: 'json', + 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' }, + folderPath: { 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', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Upload Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + upsertFileShare: { + method: 'PATCH', + 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', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Upsert Row', + body: { + workspaceId: { kind: 'string', required: true }, + data: { kind: 'object', required: true }, + conflictTarget: { kind: 'string' }, + }, + }, +} as const + +export type V2OperationName = keyof typeof V2_OPERATIONS diff --git a/packages/sim-cli/src/helpers.ts b/packages/sim-cli/src/helpers.ts new file mode 100644 index 00000000000..6b5f77fe373 --- /dev/null +++ b/packages/sim-cli/src/helpers.ts @@ -0,0 +1,12 @@ +/** + * Local copies of the shared helpers. + * + * `@sim/utils` is a private workspace package, so the published `sim` package + * cannot depend on it — importing it would resolve in the monorepo and fail for + * anyone installing from npm. + */ + +/** Resolves after `ms` milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} 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..39b5177e58e --- /dev/null +++ b/packages/sim-cli/src/http/client.test.ts @@ -0,0 +1,333 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { + formatApiErrorDetails, + requestAllPages, + resolvePath, + SimApiError, + SimClient, +} from './client' + +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({ request } as Pick, '/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( + '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('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 + 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( + '/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', + 'getBillingStatus', + 'listBillingLogs', + 'listWorkflowRuns', + 'getWorkflowRun', + 'resumeWorkflow', + '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) + } + }) +}) + +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', + // 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', + 'abortKnowledgeDocumentUpload', + 'cancelTableImport', + 'cancelTableExport', + ]) + + 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/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts new file mode 100644 index 00000000000..dbebfecc7d5 --- /dev/null +++ b/packages/sim-cli/src/http/client.ts @@ -0,0 +1,274 @@ +import type { ResolvedProfile } from '../config/index' + +/** + * 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, nextCursor }` — one page of a list. */ +export interface V2Page { + data: T[] + nextCursor: string | null +} + +export interface RequestAllPagesOptions extends Omit { + query?: Record + /** 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' + query?: Record + body?: unknown + /** Contract-declared headers, e.g. the `upload-token` a transfer is bound to. */ + headers?: Record + /** 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 { + 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)}…` +} + +/** 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) {} + + 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 + ) + } + return this.profile.apiKey + } + + /** + * The workspace every workspace-scoped command defaults to. + * + * 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, options: WorkspaceOptions = {}): string { + this.resolveApiKey(options.auth) + 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 + } + + /** + * 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 { + const apiKey = this.resolveApiKey(options.auth) + + 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: { + ...(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 + ) + } + + 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}` + } + throw error + } + + return response + } + + async request(path: string, options: RequestOptions = {}): Promise { + 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( + client: Pick, + path: string, + options: RequestAllPagesOptions +): Promise { + 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 = await client.request>(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. + * + * 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 new file mode 100644 index 00000000000..4e26c52ed1c --- /dev/null +++ b/packages/sim-cli/src/index.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import chalk from 'chalk' +import { Command, Option } from 'commander' +import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth' +import { configureCommand } from './commands/configure' +import { attachCredentialCommands } from './commands/credentials' +import { attachProtocolCommands } from './commands/protocol/index' +import { attachSecretCommands } from './commands/secrets' +import { OUTPUT_FORMATS, ProfileConfigError } from './config/index' +import { formatApiErrorDetails, SimApiError } from './http/client' +import { sanitize } from './output/render' +import { buildGeneratedCommands } from './runtime/build' + +const program = new Command() + +function readPackageVersion(): string { + const metadata: unknown = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) + if ( + typeof metadata !== 'object' || + metadata === null || + !('version' in metadata) || + typeof metadata.version !== 'string' + ) { + throw new Error('CLI package metadata is missing a valid version') + } + return metadata.version +} + +program + .name('sim') + .description('Talk to the Sim API from your terminal') + .version(readPackageVersion()) + .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()) +program.addCommand(whoamiCommand()) +program.addCommand(profilesCommand()) +program.addCommand(configureCommand()) + +for (const command of buildGeneratedCommands()) { + program.addCommand(command) +} + +attachCredentialCommands(program) +attachProtocolCommands(program) +attachSecretCommands(program) + +program.addHelpText( + 'after', + ` +Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in +~/.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 workflows list + $ sim logs list --level error --limit 20 + $ 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 + $ 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 ProfileConfigError) { + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + process.exit(1) + } + if (error instanceof SimApiError) { + 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))) + } + } + 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..a72caa2946b --- /dev/null +++ b/packages/sim-cli/src/output/render.test.ts @@ -0,0 +1,343 @@ +import chalk, { Chalk } from 'chalk' +import { load } from 'js-yaml' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + bytes, + type Column, + duration, + printList, + printRecord, + sanitize, + text, + timestamp, + visibleWidth, +} from './render' + +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 }) + +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' }]) + }) + + 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' }]) + }) + + 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', () => { + 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 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', + [ + ['ID', 'abc'], + ['Name', 'alpha'], + ], + {} + ) + expect(logged).toHaveLength(2) + expect(logged[0]).toContain('abc') + expect(logged[1]).toContain('alpha') + }) + + it.each(['text', 'table'] as const)('sanitizes API-controlled labels in %s output', (format) => { + printRecord(format, [[`${ESC}]0;pwned${BEL}safe\nlabel`, 'value']], {}) + + expect(logged.join('\n')).not.toContain(ESC) + expect(logged.join('\n')).not.toContain(BEL) + expect(logged).toHaveLength(1) + expect(logged[0]).toContain('safe label') + }) +}) + +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') + }) +}) + +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('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') + }) + + it('keeps tabs and newlines, which are legitimate content', () => { + 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') + }) + + it('is applied to values passing through text()', () => { + 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. + 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') + }) +}) + +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 new file mode 100644 index 00000000000..3c748b79bca --- /dev/null +++ b/packages/sim-cli/src/output/render.ts @@ -0,0 +1,307 @@ +import chalk from 'chalk' +import { dump } from 'js-yaml' +import type { OutputFormat } from '../config/index' +import { displayWidth } from './terminal-text' + +export interface Column { + header: string + 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(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; 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. + * + * 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 { + // 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 { + if (value === null || value === undefined || value === '') return EMPTY + return sanitize(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) + // 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) +} + +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. + */ +/** + * 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 displayWidth(value) +} + +/** + * 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))) +} + +/** + * 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.') + + // 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) => clampCell(oneLine(column.value(row))))) + const widths = columns.map((_column, index) => + Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index]))) + ) + + const header = headers + .map((label, index) => chalk.dim(pad(label.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') +} + +/** + * 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. + * + * `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[], + raw: unknown = rows +): void { + const machine = renderMachine(format, raw) + if (machine !== null) { + console.log(machine) + return + } + + if (format === 'text') { + for (const row of rows) { + console.log(columns.map((column) => oneLine(stripAnsi(column.value(row)))).join('\t')) + } + return + } + + 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) + if (machine !== null) { + console.log(machine) + return + } + + const safeFields = fields.map<[string, string]>(([label, value]) => [safeOneLine(label), value]) + + if (format === 'text') { + for (const [label, value] of safeFields) { + console.log(`${label}\t${oneLine(stripAnsi(value))}`) + } + return + } + + const width = Math.max(...safeFields.map(([label]) => visibleWidth(label))) + for (const [label, value] of safeFields) { + console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${oneLine(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/output/trace.ts b/packages/sim-cli/src/output/trace.ts new file mode 100644 index 00000000000..553da8a863a --- /dev/null +++ b/packages/sim-cli/src/output/trace.ts @@ -0,0 +1,115 @@ +import chalk from 'chalk' +import type { OutputFormat } from '../config/index' +import { duration, sanitize } from './render' + +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 run 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 new file mode 100644 index 00000000000..de9d2a939bb --- /dev/null +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -0,0 +1,1166 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from './build' + +/** + * 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, output, profileState } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, + profileState: { workspaceId: 'ws_local' as string | null }, +})) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + 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().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 + // test cannot assert on. + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +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(response) + vi.spyOn(console, 'log').mockImplementation(() => {}) + await program().parseAsync(['node', 'sim', ...argv]) + return mockRequest.mock.calls[0] +} + +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 () => { + // 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('registers singular aliases for every plural resource group', () => { + const aliases = { + 'audit-logs': 'audit-log', + credentials: 'credential', + 'custom-tools': 'custom-tool', + files: 'file', + 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)) { + expect( + program() + .commands.find((command) => command.name() === name) + ?.alias() + ).toBe(alias) + } + 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('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') + + 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('nests document commands under their knowledge base', async () => { + expect(program().commands.map((command) => command.name())).not.toContain('documents') + + const help = commandAt('knowledge', 'documents', 'get').helpInformation() + expect(help).toContain(' ') + expect(help).not.toContain('--kb') + + 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 [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(['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', + '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(['kb', 'documents', 'get', 'kb_1'])).rejects.toThrow(/documentId/) + expect(mockRequest).not.toHaveBeenCalled() + }) + + 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 ') + 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', 'status'], { + data: { + plan: 'pro', + status: 'active', + credits: { used: 10, limit: 100, remaining: 90 }, + }, + }) + 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', + '--period', + '7d', + '--source', + 'sim-chat', + ]) + 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 + ) + expect(mockRequest).not.toHaveBeenCalled() + } + }) + + 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', + '--run-id', + 'run_1', + ]) + expect(options.query).toMatchObject({ + minDurationMs: 10, + maxDurationMs: 20, + minCost: 1, + runId: 'run_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('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('describes file metadata and sharing without fetching content', async () => { + const [path, options] = await run(['file', 'describe', 'file_1'], { + data: { id: 'file_1', sharing: { enabled: false } }, + }) + expect(path).toBe('/api/v2/files/file_1/metadata') + expect(options.query).toEqual({ workspaceId: 'ws_local' }) + }) + + it('reads and writes sharing through one upsert', async () => { + const [sharePath, shareOptions] = await run([ + 'file', + 'share', + 'set', + 'file_1', + '--is-active', + 'true', + '--auth-type', + 'email', + '--allowed-emails', + 'ada@example.com', + ]) + expect(sharePath).toBe('/api/v2/files/file_1/share') + expect(shareOptions.method).toBe('PATCH') + expect(shareOptions.body).toEqual({ + workspaceId: 'ws_local', + isActive: true, + authType: 'email', + allowedEmails: ['ada@example.com'], + }) + + // v2 has no unshare operation; disabling is the same upsert. + const [offPath, offOptions] = await run([ + 'file', + 'share', + 'set', + 'file_1', + '--is-active', + 'false', + ]) + expect(offPath).toBe('/api/v2/files/file_1/share') + expect(offOptions.body).toMatchObject({ isActive: false }) + + const [getPath, getOptions] = await run(['file', 'share', 'get', 'file_1'], { + data: { sharing: { enabled: false } }, + }) + expect(getPath).toBe('/api/v2/files/file_1/share') + expect(getOptions.query).toEqual({ workspaceId: 'ws_local' }) + }) + + it('moves space-separated file ids to a folder path', async () => { + const [path, options] = await run([ + 'file', + 'mv', + '--file-ids', + 'file_1', + 'file_2', + '--to', + 'Archive', + ]) + expect(path).toBe('/api/v2/files/move') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + fileIds: ['file_1', 'file_2'], + targetFolderPath: '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 () => { + 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', 'Reports']) + expect(listPath).toBe('/api/v2/tables/folders') + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: 'Reports' }) + + const [deletePath, deleteOptions] = await run([ + 'table', + 'folders', + 'delete', + 'Archive/Reports', + '--recursive', + '--yes', + ]) + expect(deletePath).toBe('/api/v2/tables/folders') + expect(deleteOptions.query).toEqual({ + workspaceId: 'ws_local', + 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 named secrets separately from connected credentials', () => { + expect(commandAt('secrets', 'list').name()).toBe('list') + expect(commandAt('credentials', 'list').name()).toBe('list') + }) + + it('exposes workspace metadata and email-attributed members', async () => { + 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_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 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 }) + }) + + 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('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/ + ) + 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('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\s+value\s+per\s+line/s) + }) + + it('advertises the file-content encoding choices', () => { + expect(commandAt('files', 'set-content').helpInformation()).toMatch( + /--encoding.*utf-8.*base64/s + ) + }) + + it('offers expanded trace output without changing the default summary', () => { + expect(commandAt('logs', 'get').description()).toBe('Show run 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 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', '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', + 'runs', + 'get', + 'run_1', + '--workflow', + 'wf_1', + '--include-output', + '--select-output', + 'agent.content', + 'writer.text', + ]) + 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', 'runs', 'list', '--workflow', 'wf_1']) + expect(listPath).toBe('/api/v2/workflows/wf_1/runs') + + 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', 'runs', 'resume').helpInformation() + expect(resumeHelp).toContain('') + expect(resumeHelp).toMatch(/--workflow .*required/s) + expect(resumeHelp).toMatch(/--context .*required/s) + + const [resumePath, resumeOptions] = await run([ + 'workflows', + 'runs', + 'resume', + 'run_1', + '--workflow', + 'wf_1', + '--context', + 'ctx_1', + '--input', + '{"approved":true}', + ]) + expect(resumePath).toBe('/api/v2/workflows/wf_1/runs/run_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') + expect(help).toContain('--actor-email') + expect(help).not.toContain('--actor-id') + + 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([ + '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') + }) +}) + +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('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. + 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' }) + }) + + it('keeps sensitive run detail opt-in for human log output', async () => { + const log = { + runId: 'run_1', + status: 'completed', + workflow: { name: 'Billing' }, + level: 'info', + trigger: 'api', + startedAt: '2026-08-04T00:00:00.000Z', + endedAt: null, + totalDurationMs: 50, + cost: { total: 0.001 }, + files: [], + workflowState: { env: { SECRET_TOKEN: 'encrypted-value' } }, + finalOutput: { recipient: 'private@example.com' }, + traceSpans: [ + { + id: 'span_1', + name: 'Workflow Execution', + type: 'workflow', + children: [ + { + id: 'span_2', + name: 'Send email', + type: 'block', + status: 'completed', + durationMs: 25, + cost: { total: 0.0005 }, + input: { recipient: 'trace-secret@example.com' }, + output: { delivered: true }, + }, + ], + }, + ], + } + + 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') + expect(human.join('\n')).not.toContain('private@example.com') + 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', '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]') + 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', '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', 'run_1'], log, 'yaml') + expect(yaml.join('\n')).toContain('traceSpans:') + expect(yaml.join('\n')).toContain('span_2') + }) +}) + +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, credential, and secret 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') + + 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') + }) + + /** + * A field path that misses renders as an em-dash rather than failing, so a + * renamed response key is invisible until someone reads the output. v2 nests + * the share under `share` and calls the flag `isActive`; the CLI briefly read + * a `sharing` wrapper and silently showed nothing for all four columns. + */ + it('reads share fields from the v2 share object, not a sharing wrapper', async () => { + const described = ( + await lines(['files', 'describe', 'file_1'], { + id: 'file_1', + name: 'notes.txt', + uploadedByEmail: 'ada@example.com', + share: { + isActive: true, + url: 'https://sim.ai/s/tok_1', + authType: 'email', + hasPassword: false, + allowedEmails: ['ada@example.com'], + }, + }) + ).join('\n') + expect(described).toContain('https://sim.ai/s/tok_1') + expect(described).toContain('email') + expect(described).toContain('ada@example.com') + + const share = ( + await lines(['files', 'share', 'get', 'file_1'], { + isActive: true, + url: 'https://sim.ai/s/tok_2', + authType: 'sso', + hasPassword: true, + allowedEmails: ['ada@example.com', 'grace@example.com'], + }) + ).join('\n') + expect(share).toContain('https://sim.ai/s/tok_2') + expect(share).toContain('sso') + }) +}) + +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' }) + }) + + 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', () => { + 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') + }) + + 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', () => { + 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/ + ) + }) +}) + +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', + '--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('offers a direct single-row flag', async () => { + const [, options] = await run([ + 'tables', + 'rows', + 'create', + 'tbl_1', + '--data', + '{"city":"Paris"}', + ]) + 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('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, + // 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 new file mode 100644 index 00000000000..5753fb09cd7 --- /dev/null +++ b/packages/sim-cli/src/runtime/build.ts @@ -0,0 +1,245 @@ +import { Command } from 'commander' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec, CommandVariantSpec } from '../contract/types' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { deriveCommandPath } from './derive' +import { executeOperation } from './execute' +import { addOperationOptions } from './options' +import { flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD } from './request' +import type { OperationSpec } from './types' + +const GROUP_ALIASES: Readonly> = { + 'audit-logs': 'audit-log', + credentials: 'credential', + 'custom-tools': 'custom-tool', + files: 'file', + knowledge: 'kb', + logs: 'log', + 'mcp-servers': 'mcp-server', + secrets: 'secret', + skills: 'skill', + tables: 'table', + workflows: 'workflow', + workspaces: 'workspace', +} + +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, + spec: CommandSpec +): Command { + const operationSpec = V2_OPERATIONS[operation] as OperationSpec + command.allowExcessArguments(false) + + 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 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`) + } + 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] || isProfileWorkspacePath(spec, param)) continue + command.argument(`<${spec.pathArgumentNames?.[param] ?? 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`) + 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}` + ) + addOperationOptions(command, operation, spec, operationSpec) + command.action((...invocation: unknown[]) => + executeOperation(operation, spec, operationSpec, invocation) + ) + return command +} + +function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { + return addMissingArgumentExample(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).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).description( + `Manage ${resourceLabel(parent.name())} ${name.replaceAll('-', ' ')}` + ) + parent.addCommand(created) + 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() + + for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { + const spec = CLI_CONTRACT[operation] ?? {} + const operationSpec = V2_OPERATIONS[operation] as OperationSpec + if (spec.hidden || operationSpec.responseMode !== 'json') continue + + const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) + 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`) + 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`) + } + configureOperation(group, operation, spec) + } else { + addLeafCommand(groups, operation, spec, segments) + } + + for (const variant of spec.variants ?? []) { + addLeafCommand( + groups, + operation, + variantCommandSpec(spec, variant), + variant.command.split(' ') + ) + } + } + + 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..eb0d17a94e8 --- /dev/null +++ b/packages/sim-cli/src/runtime/derive.ts @@ -0,0 +1,70 @@ +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' + +/** + * 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()}`) +} + +/** + * `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/execute.ts b/packages/sim-cli/src/runtime/execute.ts new file mode 100644 index 00000000000..5b3ebd83e70 --- /dev/null +++ b/packages/sim-cli/src/runtime/execute.ts @@ -0,0 +1,107 @@ +import type { Command } from 'commander' +import { clientFrom } from '../context' +import type { CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { SimApiError, type V2Page } from '../http/client' +import { camel } from './derive' +import { DEFAULT_LIMIT } from './options' +import { + buildRequest, + flagNameFor, + isProfileWorkspacePath, + PROFILE_INJECTED_FIELD, +} from './request' +import { renderPage, renderResult } from './result' +import type { OperationSpec } from './types' + +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 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] && !isProfileWorkspacePath(commandSpec, param) + ).length + const positional = invocation.slice(0, pathPositionalCount) as string[] + const requestFlags: Record = { ...flags } + for (const [index, field] of (commandSpec.positionals ?? []).entries()) { + requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index] + } + + if (commandSpec.confirm && !requestFlags.yes) { + 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 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, + hasWorkspaceField && !omitsWorkspace ? client.requireWorkspace() : profile.workspaceId + ) + const paging = cursorSlot(operationSpec) + + if (paging) { + 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) + } + + 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, { + expandedTrace: requestFlags.trace === true, + }) +} diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts new file mode 100644 index 00000000000..9c2a1576ffc --- /dev/null +++ b/packages/sim-cli/src/runtime/options.ts @@ -0,0 +1,140 @@ +import { type Command, Option } from 'commander' +import type { CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { + type FieldSpec, + flagNameFor, + flagSpecFor, + PROFILE_INJECTED_FIELD, + pathFlagNameFor, + takesJson, +} from './request' +import type { OperationSpec } from './types' + +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' || flag.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}`) + if (!flag.boolean) 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 ?? `Set ${name.replaceAll('-', ' ')}`}${ + 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]) + 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 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) + } + } + + 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) { + 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) { + 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 new file mode 100644 index 00000000000..dc8295e70d6 --- /dev/null +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -0,0 +1,255 @@ +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' +import { deriveCommandPath } from './derive' +import { buildRequest, coerce, type FieldSpec } from './request' + +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('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' }) + }) + + 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') + }) + + // 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', [], { minDurationMs: '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('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') + }) + + it('fills a configured workspace path segment from the profile', () => { + expect(buildRequest('getWorkspace', [], {}, WORKSPACE).path).toBe( + `/api/v2/workspaces/${WORKSPACE}` + ) + }) + + 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, + }) + }) + + describe('failures, all before any network call', () => { + it('rejects a missing path arg', () => { + 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' + ) + }) + + it('names a missing nested parent path argument clearly', () => { + expect(() => buildRequest('getKnowledgeDocument', [], {}, WORKSPACE)).toThrow( + 'Missing ' + ) + }) + + 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', [], { minCost: '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']) + }) +}) + +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']) + }) + + 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', () => { + 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' } + + 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 new file mode 100644 index 00000000000..848acdadb1e --- /dev/null +++ b/packages/sim-cli/src/runtime/request.ts @@ -0,0 +1,385 @@ +import { existsSync, readFileSync, readSync } from 'node:fs' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec, FlagSpec } from '../contract/types' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { type QueryValue, SimApiError } from '../http/client' +import { camel, kebab } from './derive' + +/** 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' + +/** 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']) + +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) +} + +/** 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) +} + +/** + * 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 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. JSON never starts with `@`; primitive list + * flags reserve it for this explicit file-input form. + */ +function readArgumentSource(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) + } +} + +/** 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. + * + * `--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. + * + * 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. `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`, `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 + * right one (`knowledgeBaseIds`). Joining those produced a single bogus id + * or failed validation outright. + */ + if (flag.list) { + const values = readListValues(raw, flagName) + return field.kind === 'string' ? values.join(',') : values + } + + if (takesJson(field, flag)) { + if (typeof raw !== 'string') return raw + const source = readArgumentSource(raw, flagName) + try { + return JSON.parse(source.text) + } catch (error) { + throw new SimApiError( + `--${flagName} must be valid JSON${source.from}: ${(error as Error).message}${pathHint(raw)}`, + 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' || 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 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. + * + * 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, + positional: string[], + flags: Record, + workspaceId: string | null +): BuiltRequest { + const commandSpec: CommandSpec = CLI_CONTRACT[operation] ?? {} + const spec = V2_OPERATIONS[operation] as { + method: string + path: string + pathParams: readonly string[] + query?: Record + body?: Record + opaqueBody?: boolean + } + + let path = spec.path + 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 argumentName = commandSpec.pathArgumentNames?.[param] ?? param + 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 <${argumentName}>`, 0) + } + if (typeof value !== 'string' || value.length === 0) { + throw new SimApiError( + pathFlag ? `--${flagName} cannot be empty` : `<${argumentName}> 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 = {} + + 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) + // Commander stores `--min-duration-ms` as `minDurationMs`; reading by the + // flag's own name silently finds nothing. + 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) { + 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 + } + } + + // 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) { + 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') + 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, + /** + * 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/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts new file mode 100644 index 00000000000..e803f45934d --- /dev/null +++ b/packages/sim-cli/src/runtime/result.ts @@ -0,0 +1,196 @@ +import type { OutputFormat } from '../config/index' +import type { ColumnSpec, CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { + bool, + bytes, + type Column, + duration, + printDocument, + printList, + printRecord, + sanitize, + text, + timestamp, +} from '../output/render' +import { printTraceSpans } from '../output/trace' + +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 + .split('.') + .reduce( + (value, key) => (value && typeof value === 'object' ? (value as never)[key] : undefined), + row + ) +} + +function renderCell( + value: unknown, + format: ColumnSpec['format'], + options: RenderResultOptions = {} +): 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) + 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)) + } +} + +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[], + 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[] { + 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, + options: RenderResultOptions = {} +): 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, 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) + } +} diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts new file mode 100644 index 00000000000..ab9892aec8c --- /dev/null +++ b/packages/sim-cli/src/runtime/types.ts @@ -0,0 +1,13 @@ +import type { RequestOptions } from '../http/client' +import type { FieldSpec } from './request' + +export interface OperationSpec { + method: NonNullable + path: string + pathParams: readonly string[] + query?: Record + body?: Record + opaqueBody?: boolean + summary?: string + responseMode?: 'json' | 'binary' | 'stream' +} diff --git a/packages/sim-cli/src/terminal/secret-input.test.ts b/packages/sim-cli/src/terminal/secret-input.test.ts new file mode 100644 index 00000000000..c7068dd6745 --- /dev/null +++ b/packages/sim-cli/src/terminal/secret-input.test.ts @@ -0,0 +1,89 @@ +import { EventEmitter } from 'node:events' +import type { ReadStream } from 'node:tty' +import { describe, expect, it } from 'vitest' +import { promptSecret } from './secret-input' + +class FakeInput extends EventEmitter { + isTTY = true + isRaw = false + paused = true + readonly rawStates: boolean[] = [] + + isPaused(): boolean { + return this.paused + } + + setRawMode(value: boolean): this { + this.isRaw = value + this.rawStates.push(value) + return this + } + + resume(): this { + this.paused = false + return this + } + + pause(): this { + this.paused = true + return this + } +} + +class FakeOutput { + value = '' + + write(value: string): boolean { + this.value += value + return true + } +} + +describe('promptSecret', () => { + it('masks input and restores the terminal before returning it', async () => { + const input = new FakeInput() + const output = new FakeOutput() + const result = promptSecret(input as unknown as ReadStream, output) + + input.emit('keypress', 'hunter2', { name: 'h' }) + input.emit('keypress', '\r', { name: 'return' }) + + await expect(result).resolves.toBe('hunter2') + expect(output.value).toBe('Secret value: *******\n') + expect(input.rawStates).toEqual([true, false]) + expect(input.paused).toBe(true) + }) + + it('handles backspace without revealing the value', async () => { + const input = new FakeInput() + const output = new FakeOutput() + const result = promptSecret(input as unknown as ReadStream, output) + + input.emit('keypress', 'ab', { name: 'a' }) + input.emit('keypress', '', { name: 'backspace' }) + input.emit('keypress', 'c', { name: 'c' }) + input.emit('keypress', '\r', { name: 'return' }) + + await expect(result).resolves.toBe('ac') + expect(output.value).toBe('Secret value: **\b \b*\n') + }) + + it('requires --value when no interactive terminal is available', () => { + const input = new FakeInput() + input.isTTY = false + + expect(() => promptSecret(input as unknown as ReadStream, new FakeOutput())).toThrow( + 'Interactive secret input requires a terminal. Pass --value instead.' + ) + }) + + it('restores the terminal when input is cancelled', async () => { + const input = new FakeInput() + const result = promptSecret(input as unknown as ReadStream, new FakeOutput()) + + input.emit('keypress', '\u0003', { ctrl: true, name: 'c' }) + + await expect(result).rejects.toThrow('Secret input cancelled.') + expect(input.rawStates).toEqual([true, false]) + }) +}) diff --git a/packages/sim-cli/src/terminal/secret-input.ts b/packages/sim-cli/src/terminal/secret-input.ts new file mode 100644 index 00000000000..e8b365767d7 --- /dev/null +++ b/packages/sim-cli/src/terminal/secret-input.ts @@ -0,0 +1,81 @@ +import { emitKeypressEvents, type Key } from 'node:readline' +import type { ReadStream } from 'node:tty' +import { SimApiError } from '../http/client' + +const MAX_SECRET_LENGTH = 65_536 + +interface SecretOutput { + write(value: string): unknown +} + +/** Reads a secret from a TTY while rendering one mask character per entered character. */ +export function promptSecret( + input: ReadStream = process.stdin, + output: SecretOutput = process.stderr +): Promise { + if (!input.isTTY) { + throw new SimApiError('Interactive secret input requires a terminal. Pass --value instead.', 0) + } + + const wasPaused = input.isPaused() + const wasRaw = input.isRaw + let value = '' + let settled = false + + output.write('Secret value: ') + emitKeypressEvents(input) + input.setRawMode(true) + input.resume() + + return new Promise((resolve, reject) => { + const cleanup = () => { + input.removeListener('keypress', onKeypress) + input.setRawMode(wasRaw) + if (wasPaused) input.pause() + } + + const finish = (complete: () => void) => { + if (settled) return + settled = true + output.write('\n') + try { + cleanup() + complete() + } catch (error) { + reject(error) + } + } + + const fail = (message: string) => finish(() => reject(new SimApiError(message, 0))) + + function onKeypress(text: string, key: Key): void { + if (key.ctrl && (key.name === 'c' || key.name === 'd')) { + fail('Secret input cancelled.') + return + } + if (key.name === 'return' || key.name === 'enter') { + if (value.length === 0) fail('Secret value cannot be empty.') + else finish(() => resolve(value)) + return + } + if (key.name === 'backspace') { + const characters = Array.from(value) + if (characters.length > 0) { + characters.pop() + value = characters.join('') + output.write('\b \b') + } + return + } + if (!text || key.ctrl || key.meta || key.name === 'escape') return + if (value.length + text.length > MAX_SECRET_LENGTH) { + fail(`Secret value cannot exceed ${MAX_SECRET_LENGTH} characters.`) + return + } + value += text + output.write('*'.repeat(Array.from(text).length)) + } + + input.on('keypress', onKeypress) + }) +} 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..dfbd1d35e46 --- /dev/null +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -0,0 +1,57 @@ +import { stat } from 'node:fs/promises' +import { basename } from 'node:path' +import { SimApiError } from '../http/client' + +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', +} + +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.isFile()) throw new SimApiError(`${path} is not a regular file`, 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/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts new file mode 100644 index 00000000000..cc8e7d0566d --- /dev/null +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -0,0 +1,124 @@ +import { openAsBlob } from 'node:fs' +import { SimApiError, type SimClient } from '../http/client' + +interface UploadPartUrl { + partNumber: number + url: string + headers: Record +} + +export type UploadTransfer = + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } + +export interface UploadSession { + basePath: string + uploadToken: string + 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, + session: UploadSession, + transfer: Extract, + blob: Blob +): Promise { + 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}` + ) + } + + 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[] } }>( + `${session.basePath}/parts`, + { + method: 'POST', + query: { workspaceId }, + 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, session.size)) + + // 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, + body: chunk, + }) + if (!response.ok) { + throw new SimApiError( + `Part ${part.partNumber} failed with status ${response.status}`, + response.status + ) + } + } + } +} + +/** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ +export async function finishUploadSession( + client: SimClient, + workspaceId: string, + session: UploadSession, + path: string +): Promise { + try { + const blob = await openAsBlob(path) + if (session.transfer.method === 'put') { + await uploadPut(session.transfer, blob) + } else { + 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 }, + }) + return completed.data + } catch (error) { + await client + .request(session.basePath, { + method: 'DELETE', + query: { workspaceId }, + headers: { 'upload-token': session.uploadToken }, + }) + .catch(() => undefined) + throw error + } +} diff --git a/packages/sim-cli/tsconfig.json b/packages/sim-cli/tsconfig.json new file mode 100644 index 00000000000..98522576add --- /dev/null +++ b/packages/sim-cli/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/base.json", + "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-source-text.ts b/scripts/check-source-text.ts index 1052604eb32..c406242cc8d 100644 --- a/scripts/check-source-text.ts +++ b/scripts/check-source-text.ts @@ -56,7 +56,9 @@ const files = listed.stdout const offenders: string[] = [] for (const file of files) { - const bytes = await Bun.file(path.join(ROOT, file)).bytes() + const source = Bun.file(path.join(ROOT, file)) + if (!(await source.exists())) continue + const bytes = await source.bytes() if (bytes.includes(0)) offenders.push(file) } diff --git a/scripts/check-utils-enforcement.ts b/scripts/check-utils-enforcement.ts index 22565e4c781..857bab94901 100644 --- a/scripts/check-utils-enforcement.ts +++ b/scripts/check-utils-enforcement.ts @@ -29,6 +29,9 @@ const ALLOWLISTED_FILES = new Set([ 'packages/utils/src/id.test.ts', 'packages/utils/src/object.test.ts', 'packages/utils/src/retry.test.ts', + // Published standalone CLIs: `@sim/utils` is private, so they carry local + // copies rather than a dependency that only resolves inside the monorepo. + 'packages/sim-cli/src/helpers.ts', 'packages/cli/src/index.ts', 'packages/ts-sdk/src/index.ts', // CJS bundle — cannot use ES module imports diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts new file mode 100644 index 00000000000..a5ee67f9cd8 --- /dev/null +++ b/scripts/generate-v2-cli-api.ts @@ -0,0 +1,570 @@ +#!/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 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 + */ + +import { spawnSync } from 'node:child_process' +import { readdirSync, 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') + +/** + * 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. + * + * 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 specFiles()) { + 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 +} + +/** + * 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 + 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 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 + 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 `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, 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) + + 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, refs)).join(' | ') + } + + if (schema.allOf) { + return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent, refs)).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, refs)}>` : '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, refs) + : '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, refs)}` + }) + return `{\n${lines.join('\n')}\n${closePad}}` + } + } + + // `z.unknown()` / `z.any()` render as a schema carrying no constraints. A + // `.describe()` on one adds annotation keys without narrowing the type, so + // those are not constraints either. + const ANNOTATION_KEYS = new Set(['$schema', 'description', 'title', 'default', 'examples']) + if (Object.keys(schema).every((k) => ANNOTATION_KEYS.has(k))) return 'unknown' + + throw new Error(`Unhandled JSON Schema construct: ${JSON.stringify(schema).slice(0, 200)}`) +} + +/** + * 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 + 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']`. */ +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. + */ +/** + * 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 + 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 + // caller marks it `opaqueBody` so the runtime can offer the whole body as one + // JSON flag instead. + if (keys.length === 0) return null + + // A schema carrying `.meta({ id })` is lifted into `$defs` and referenced, so + // the property here is a bare `$ref` with no type to classify. Left + // unresolved every such field reads as `unknown` and the CLI demands JSON for + // what is really a plain string flag. + const defs = (json.$defs ?? {}) as Record + const deref = (schema: JsonSchema): JsonSchema => { + let current = schema + for (let depth = 0; typeof current.$ref === 'string' && depth < 10; depth++) { + const resolved = defs[current.$ref.replace('#/$defs/', '')] + if (!resolved) break + current = resolved + } + return current + } + + const lines = keys.map((key) => { + const property = deref(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.') + 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 + 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) { + 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`) + } + out.push('') + } + + 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) + 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}',`) + // 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},`) + // 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(' },') + } + out.push('} as const') + out.push('') + out.push('export type V2OperationName = keyof typeof V2_OPERATIONS') + out.push('') + + return out.join('\n') +} + +/** + * 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() + + const generated = format(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 ${contractModules().length} contract modules.` + ) +} + +main() From c269e883bf86b532e61cef94acd0c56409dc6857 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 09:46:59 -0700 Subject: [PATCH 085/103] fix(credentials): conceal inaccessible credential reads (#6730) --- .../app/api/credentials/[id]/route.test.ts | 58 +++++++++++++++++++ apps/sim/app/api/credentials/[id]/route.ts | 3 +- .../sim/lib/credentials/api/route-policies.ts | 9 +++ .../authorized-credential-use-case.test.ts | 19 +++++- .../authorized-credential-use-case.ts | 9 ++- 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/credentials/[id]/route.test.ts diff --git a/apps/sim/app/api/credentials/[id]/route.test.ts b/apps/sim/app/api/credentials/[id]/route.test.ts new file mode 100644 index 00000000000..f3c7ea98e94 --- /dev/null +++ b/apps/sim/app/api/credentials/[id]/route.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialAccessRequiredError } from '@/lib/credentials/application/authorized-credential-use-case' + +const mocks = vi.hoisted(() => ({ + read: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/credential-crud', () => ({ + CredentialProviderOperationError: class CredentialProviderOperationError extends Error {}, + getWorkspaceCredentialUseCase: { + operation: { id: 'credentials.read' }, + execute: mocks.read, + }, + updateWorkspaceCredentialUseCase: { + operation: { id: 'credentials.update' }, + execute: mocks.update, + }, +})) + +vi.mock('@/lib/credentials/application/service-account', () => ({ + deleteCredentialUseCase: { + operation: { id: 'credentials.delete' }, + execute: mocks.remove, + }, +})) + +import { GET } from '@/app/api/credentials/[id]/route' + +const CREDENTIAL_ID = 'credential-1' +const routeContext = { params: Promise.resolve({ id: CREDENTIAL_ID }) } + +describe('GET /api/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'writer-1' }, + session: { id: 'session-1' }, + }) + }) + + it('preserves the generic denial for a workspace writer without credential access', async () => { + mocks.read.mockRejectedValue(new CredentialAccessRequiredError()) + + const response = await GET( + createMockRequest('GET', undefined, {}, `http://localhost/api/credentials/${CREDENTIAL_ID}`), + routeContext + ) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Forbidden' }) + }) +}) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index d99ad8382c4..0010a8af252 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/api/server/routes' import { credentialValidationParseOptions, + internalCredentialDetailErrorPolicy, internalCredentialErrorPolicy, } from '@/lib/credentials/api/route-policies' import { @@ -27,7 +28,7 @@ export const GET = defineInternalJsonRoute({ auth: internalSessionAuth, operation: credentialOperations.read, rateLimit, - errorPolicy: internalCredentialErrorPolicy, + errorPolicy: internalCredentialDetailErrorPolicy, parseOptions: credentialValidationParseOptions, mapInput: ({ params }) => ({ credentialId: params.id }), useCase: getWorkspaceCredentialUseCase, diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts index 8e97f55c2ce..717766dec51 100644 --- a/apps/sim/lib/credentials/api/route-policies.ts +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -7,6 +7,7 @@ import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/se import { NoWorkspaceAccessError } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialAccessRequiredError } from '@/lib/credentials/application/authorized-credential-use-case' import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' export const credentialValidationParseOptions = { @@ -25,6 +26,14 @@ export const internalCredentialErrorPolicy = extendInternalErrorPolicy( } ) +export const internalCredentialDetailErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => + error instanceof CredentialAccessRequiredError + ? internalErrorResponse(403, { error: 'Forbidden' }) + : null +) + export const internalCredentialMemberListErrorPolicy = extendInternalErrorPolicy( internalCredentialErrorPolicy, (error) => { diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts index dbfe9f99157..b0a1939edfd 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -3,7 +3,10 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineWorkspaceOperation } from '@/lib/core/application' -import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { + CredentialAccessRequiredError, + defineAuthorizedCredentialUseCase, +} from '@/lib/credentials/application/authorized-credential-use-case' import { defineCredentialOperation } from '@/lib/credentials/application/operations' const mocks = vi.hoisted(() => ({ @@ -85,6 +88,20 @@ describe('defineAuthorizedCredentialUseCase', () => { expect(mocks.execute).toHaveBeenCalledOnce() }) + it('denies member-level reads without credential membership', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: null, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + createUseCase(memberOperation).execute({ principal, input: undefined }) + ).rejects.toBeInstanceOf(CredentialAccessRequiredError) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('requires credential admin independently of workspace read access', async () => { await expect( createUseCase(adminOperation).execute({ principal, input: undefined }) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts index 635fc9eb621..57e6d109993 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -17,6 +17,13 @@ export interface CredentialAuthorizationContext extends WorkspaceAuthorizationCo credentialAccess?: CredentialActorContext } +export class CredentialAccessRequiredError extends OrchestrationError { + constructor() { + super('forbidden', 'Credential access required') + this.name = 'CredentialAccessRequiredError' + } +} + export function requireCredentialAccess( context: CredentialAuthorizationContext ): CredentialActorContext { @@ -61,7 +68,7 @@ export function defineAuthorizedCredentialUseCase< switch (definition.operation.minimumCredentialRole) { case 'member': if (!actor.member && !actor.isAdmin) { - throw new OrchestrationError('forbidden', 'Credential access required') + throw new CredentialAccessRequiredError() } return case 'admin': From 95c3f260f7abce9dd9474dc2cbd8d0d1a6042612 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 10:54:53 -0700 Subject: [PATCH 086/103] Harden browser panel and chat cleanup --- .../src/main/browser-agent/panel.test.ts | 24 +++++++++++++++++++ apps/desktop/src/main/browser-agent/panel.ts | 8 +++++++ apps/desktop/src/test/electron-mock.ts | 2 ++ .../[workspaceId]/home/hooks/use-chat.ts | 15 ++++++++---- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/panel.test.ts b/apps/desktop/src/main/browser-agent/panel.test.ts index 4af126c5269..91fd776cd15 100644 --- a/apps/desktop/src/main/browser-agent/panel.test.ts +++ b/apps/desktop/src/main/browser-agent/panel.test.ts @@ -53,6 +53,30 @@ describe('panel chat scope', () => { panel = freshPanel() }) + it('returns keyboard focus to the renderer when attaching a view steals it mid-typing', () => { + const win = new BrowserWindow() + const view = new WebContentsView() + const active = { id: 'tab-1', scopeId: 'chat-test', view, pinned: false } + vi.mocked(win.webContents.isFocused).mockReturnValue(true) + panel.initPanel({ + getMainWindow: () => win, + activeTab: () => active, + backgroundColor: () => '#0c0c0c', + ensureInitialTab: () => {}, + onViewDetached: () => {}, + }) + panel.activatePanelScope('chat-test') + panel.setPanelBounds(PANEL_RECT, win) + expect(win.contentView.addChildView).toHaveBeenCalledWith(view) + expect(win.webContents.focus).toHaveBeenCalled() + }) + + it('leaves focus untouched when the renderer was not focused at attach time', () => { + const { win, view } = showPanel(panel) + expect(win.contentView.addChildView).toHaveBeenCalledWith(view) + expect(win.webContents.focus).not.toHaveBeenCalled() + }) + it('requires fresh bounds for the newly active chat and ignores stale reports', () => { const { win, view } = showPanel(panel) const previousScope = panel.getActivePanelScopeId() diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts index 992f2f2b669..293271921b5 100644 --- a/apps/desktop/src/main/browser-agent/panel.ts +++ b/apps/desktop/src/main/browser-agent/panel.ts @@ -379,9 +379,17 @@ export function layout(): void { } if (attachedView !== active.view) { + // addChildView hands keyboard focus to the newly attached WebContentsView. + // Agent-driven attaches happen while the user may be typing in the chat + // composer, so if the renderer held focus before the attach, give it back — + // automation drives the page over CDP and never needs OS focus. + const rendererHadFocus = !win.webContents.isDestroyed() && win.webContents.isFocused() win.contentView.addChildView(active.view) hostedWindow = win attachedView = active.view + if (rendererHadFocus) { + win.webContents.focus() + } } bindHostResize(win) const zoom = win.webContents.getZoomFactor() diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index fafa34ee4ac..d1d80f7cef5 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -222,6 +222,8 @@ export class BrowserWindow { getZoomFactor: vi.fn(() => 1), executeJavaScript: vi.fn(() => Promise.resolve(true)), focus: vi.fn(), + isFocused: vi.fn(() => false), + isDestroyed: vi.fn(() => false), send: vi.fn(), setWindowOpenHandler: vi.fn(), isDevToolsOpened: vi.fn(() => false), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 76ef88190cc..6ce4aac3a19 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1197,9 +1197,12 @@ export type ResourceEventHandler = (resourceId: string, options?: ResourceEventO /** * Whether a streamed resource event should activate its tab. Resources switch - * into view as the agent creates or edits them; only the background browser + * into view as the agent creates or edits them; only an already-open browser * session declines to replace an existing selection (it gets an attention - * marker instead), unless the event explicitly requests activation. + * marker instead), unless the event explicitly requests activation — which + * `openBrowserResource` does when it newly opens the browser tab, so agent + * browser work surfaces on first open and stays put once the user has + * deliberately switched away. */ export function shouldActivateResourceEvent( activeResourceId: string | null, @@ -1971,14 +1974,18 @@ export function useChat( const openBrowserResource = useCallback( (activate = false) => { - addResource({ + // A newly opened browser tab surfaces like any other agent-created + // resource. Only an ALREADY-open browser tab stays in the background + // behind another selection — the user saw it and switched away, so + // ongoing agent activity earns an attention marker, not a tab switch. + const newlyOpened = addResource({ type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser', }) onResourceEventRef.current?.( BROWSER_SESSION_RESOURCE_ID, - activate ? { activate: true } : undefined + activate || newlyOpened ? { activate: true } : undefined ) }, [addResource] From c565e1c840ef0a3e5e49ecfac7bd4e069ea2c8be Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 11:29:42 -0700 Subject: [PATCH 087/103] Descriptive, user-language tool titles across the board House rules applied everywhere: use every argument the call carries, never name internal machinery, and never lead with Getting (the Got rewrite is deleted so it cannot return). - Deployments name the workflow: Deploying {workflow} as API/chat app/MCP tool - Workflow reads name the part: Reading {workflow} meta/state/deployment/notes; generic reads always name the file (Reading {leaf}), never bare Reading file - Block runs name block and workflow: Running {block} in {workflow}, Running from {block} in {workflow}, Running {workflow} until {block}, and Enabling/Disabling {block} in {workflow} - The six split-table tools get per-operation verbs (Adding column {name}, Updating rows, Wiring automation, Creating view {name}) instead of a wall of Querying table - The manage quartet drops X-action system-speak for gerunds - get_* internal names become user language (Checking run settings, Tracing block inputs, Reading the deployed version); web_fetch says Fetching - Scheduled-task titles removed entirely (feature deleted from the Go catalog) - New verb rewrites: Fetched, Traced, Wired, Configured, Looked, Rotated --- .../message-content/message-content.test.ts | 6 +- .../lib/copilot/tools/tool-display.test.ts | 26 +-- apps/sim/lib/copilot/tools/tool-display.ts | 187 ++++++++++++------ 3 files changed, 146 insertions(+), 73 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 19f953fdacc..d50471c4337 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -483,9 +483,11 @@ describe('completed tool titles', () => { timestamp: 1, }, ]) - ).toBe('Undeployed API') + ).toBe('Undeployed as API') - expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_as_mcp')])).toBe('Deployed MCP tool') + expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_as_mcp')])).toBe( + 'Deployed as MCP tool' + ) }) it('renders Compared after the full diff_workflows wire lifecycle succeeds', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 5e7decd1596..e3677513bc7 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -72,9 +72,9 @@ describe('humanizeToolName', () => { describe('getToolDisplayTitle natural-language coverage', () => { it('gives gerund titles to tools that previously fell through to humanize', () => { - expect(getToolDisplayTitle('deploy_as_api')).toBe('Deploying API') + expect(getToolDisplayTitle('deploy_as_api')).toBe('Deploying as API') expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') - expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link') + expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Creating sign-in link') expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) @@ -107,16 +107,16 @@ describe('getToolDisplayTitle natural-language coverage', () => { it('resolves every catalog action and operation enum without a generic placeholder', () => { const genericPlaceholders = new Set([ - 'Credential action', - 'Custom tool action', + 'Managing credential', + 'Managing custom tool', 'Editing file', 'Folder action', - 'MCP server action', + 'Managing MCP server', 'Managing knowledge base', 'Managing table', 'Preparing file', 'Processing media', - 'Skill action', + 'Managing skill', ]) const unresolvedVariants: string[] = [] @@ -141,14 +141,14 @@ describe('getToolDisplayTitle natural-language coverage', () => { describe('getToolDisplayTitle for deployments', () => { it.each([ - ['deploy_as_api', undefined, 'Deploying API'], - ['deploy_as_api', { action: 'deploy' }, 'Deploying API'], - ['deploy_as_api', { action: 'undeploy' }, 'Undeploying API'], - ['deploy_as_chat', { action: 'deploy' }, 'Deploying chat'], - ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying chat'], + ['deploy_as_api', undefined, 'Deploying as API'], + ['deploy_as_api', { action: 'deploy' }, 'Deploying as API'], + ['deploy_as_api', { action: 'undeploy' }, 'Undeploying as API'], + ['deploy_as_chat', { action: 'deploy' }, 'Deploying as chat app'], + ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying as chat app'], ['publish_custom_block', { action: 'deploy' }, 'Publishing custom block'], ['publish_custom_block', { action: 'undeploy' }, 'Unpublishing custom block'], - ['deploy_as_mcp', undefined, 'Deploying MCP tool'], + ['deploy_as_mcp', undefined, 'Deploying as MCP tool'], ['redeploy', undefined, 'Redeploying API'], ])('uses the action and deployment type for %s', (toolName, args, expected) => { expect(getToolDisplayTitle(toolName, args)).toBe(expected) @@ -167,7 +167,7 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Creating workflow')).toBe('Created workflow') expect(getToolCompletedTitle('Running workflow')).toBe('Ran workflow') expect(getToolCompletedTitle('Reading file')).toBe('Read file') - expect(getToolCompletedTitle('Undeploying API')).toBe('Undeployed API') + expect(getToolCompletedTitle('Undeploying as API')).toBe('Undeployed as API') expect(getToolCompletedTitle('Duplicating workflow')).toBe('Duplicated workflow') expect(getToolCompletedTitle('Viewing custom tools')).toBe('Viewed custom tools') expect(getToolCompletedTitle('Saving report.pdf')).toBe('Saved report.pdf') diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 907535503f7..89b1e1bdd3c 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -54,8 +54,56 @@ function stringOrNumberArg(args: ToolArgs, key: string): string { return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '' } +/** + * Titles for the split table tools: each names its own action, refined by the + * operation and the named target when the args carry one — a card full of + * table work should read as adds, updates, and wiring, never a wall of + * identical "Queried table" rows. + */ +function splitTableTitle(name: string, args: ToolArgs): string { + const op = stringArg(args, 'operation') + const target = firstStringArg(args, 'name', 'columnName', 'viewName', 'tableName', 'title') + const suffix = target ? ` ${target}` : '' + switch (name) { + case 'table_manage': + if (op === 'create') return `Creating table${suffix}` + if (op === 'delete') return `Deleting table${suffix}` + if (op === 'read' || op === 'get' || op === 'list') return 'Reading table' + return 'Updating table' + case 'table_rows': + if (op === 'insert' || op === 'add' || op === 'create') return 'Adding rows' + if (op === 'update') return 'Updating rows' + if (op === 'delete') return 'Deleting rows' + if (op === 'read' || op === 'list' || op === 'query') return 'Reading rows' + return 'Editing rows' + case 'table_columns': + if (op === 'add' || op === 'create') return `Adding column${suffix}` + if (op === 'update') return `Updating column${suffix}` + if (op === 'delete') return `Deleting column${suffix}` + if (op === 'read' || op === 'list') return 'Reading columns' + return 'Editing columns' + case 'table_automations': + if (op === 'read' || op === 'list') return 'Reading automations' + if (op === 'delete') return 'Removing automation' + return 'Wiring automation' + case 'table_enrichments': + if (op === 'read' || op === 'list') return 'Reading enrichments' + if (op === 'delete') return 'Removing enrichment' + return 'Configuring enrichment' + case 'table_views': + if (op === 'create') return `Creating view${suffix}` + if (op === 'delete') return `Deleting view${suffix}` + if (op === 'read' || op === 'list') return 'Reading views' + return 'Editing views' + default: + return 'Updating table' + } +} + function deploymentTitle(args: ToolArgs, deploymentType: string): string { - return `${stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying'} ${deploymentType}` + const verb = stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying' + const workflow = firstStringArg(args, 'workflowName', 'name', 'title') + return workflow ? `${verb} ${workflow} as ${deploymentType}` : `${verb} as ${deploymentType}` } function resourceTypeLabel(type: string): string { @@ -253,19 +301,6 @@ function manageSandboxTitle(args: ToolArgs): string { return titles[stringArg(args, 'operation')] ?? 'Managing sandbox' } -function manageScheduledTaskTitle(args: ToolArgs): string { - const operationArgs = recordArg(args, 'args') - const title = stringArg(operationArgs, 'title') - const titles: Record = { - create: `Creating ${title || 'scheduled task'}`, - list: 'Listing scheduled tasks', - get: 'Reading scheduled task', - update: `Updating ${title || 'scheduled task'}`, - delete: 'Deleting scheduled task', - } - return titles[stringArg(args, 'operation')] ?? 'Managing scheduled task' -} - function userTableTitle(args: ToolArgs): string { const operation = stringArg(args, 'operation') const operationArgs = recordArg(args, 'args') @@ -445,14 +480,14 @@ const TOOL_TITLES: Record = { user_table: 'Managing table', run_code: 'Running code', query_user_table: 'Querying table', - table_manage: 'Managing table', - table_rows: 'Editing table rows', - table_columns: 'Editing table columns', - table_automations: 'Managing table automations', - table_enrichments: 'Managing table enrichments', - table_views: 'Managing table views', + table_manage: 'Updating table', + table_rows: 'Editing rows', + table_columns: 'Editing columns', + table_automations: 'Wiring automation', + table_enrichments: 'Configuring enrichment', + table_views: 'Editing views', prepare_file_edit: 'Editing file', - apply_file_edit: 'Applying file content', + apply_file_edit: 'Writing changes', create_workflow: 'Creating workflow', edit_workflow: 'Editing workflow', manage_knowledge_base: 'Managing knowledge base', @@ -467,23 +502,21 @@ const TOOL_TITLES: Record = { create_file_folder: 'Creating folder', create_workspace_mcp_server: 'Creating MCP server', delete_workspace_mcp_server: 'Deleting MCP server', - deploy_as_api: 'Deploying API', - deploy_as_chat: 'Deploying chat', + deploy_as_api: 'Deploying as API', + deploy_as_chat: 'Deploying as chat app', publish_custom_block: 'Publishing custom block', - deploy_as_mcp: 'Deploying MCP tool', + deploy_as_mcp: 'Deploying as MCP tool', diff_workflows: 'Comparing workflows', download_file: 'Downloading file', run_function: 'Running code', - complete_scheduled_task: 'Completing scheduled task', generate_api_key: 'Generating API key', - get_block_outputs: 'Getting block outputs', - get_block_upstream_references: 'Getting block references', - get_deployed_workflow_state: 'Getting deployed workflow', + get_block_outputs: 'Reading block outputs', + get_block_upstream_references: 'Tracing block inputs', + get_deployed_workflow_state: 'Reading the deployed version', list_deployment_versions: 'Listing deployment versions', get_ui_reference: 'Reading UI reference', - get_scheduled_task_logs: 'Reading scheduled task logs', - get_workflow_data: 'Getting workflow data', - get_workflow_run_options: 'Getting run options', + get_workflow_data: 'Reading workflow', + get_workflow_run_options: 'Checking run settings', list_file_folders: 'Listing folders', list_integration_tools: 'Listing integration tools', list_user_workspaces: 'Listing workspaces', @@ -492,11 +525,10 @@ const TOOL_TITLES: Record = { save_upload: 'Saving upload', connect_slack_bot: 'Connecting Slack bot', manage_sandbox: 'Managing sandbox', - manage_scheduled_task: 'Managing scheduled task', move_file: 'Moving file', move_file_folder: 'Moving folder', move_workflow: 'Moving workflow', - oauth_get_auth_link: 'Getting authorization link', + oauth_get_auth_link: 'Creating sign-in link', oauth_request_access: 'Requesting access', promote_to_live: 'Promoting to live', redeploy: 'Redeploying API', @@ -505,13 +537,11 @@ const TOOL_TITLES: Record = { rename_workflow: 'Renaming workflow', restore_resource: 'Restoring resource', run_block: 'Running block', - scheduled_task: 'Managing scheduled task', search_sim_docs: 'Searching Sim docs', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', set_global_workflow_variables: 'Setting workflow variables', update_deployment_version: 'Updating deployment', - update_scheduled_task_history: 'Updating scheduled task history', update_workspace_mcp_server: 'Updating MCP server', // Browser agent tools without an argument-aware title. browser_go_back: 'Going back', @@ -705,7 +735,7 @@ export function getToolDisplayTitle(name: string, args?: Record case 'deploy_as_api': return deploymentTitle(args, 'API') case 'deploy_as_chat': - return deploymentTitle(args, 'chat') + return deploymentTitle(args, 'chat app') case 'publish_custom_block': return `${stringArg(args, 'action') === 'undeploy' ? 'Unpublishing' : 'Publishing'} custom block` case 'ffmpeg': @@ -713,19 +743,18 @@ export function getToolDisplayTitle(name: string, args?: Record case 'manage_knowledge_base': return knowledgeBaseTitle(args) case 'query_user_table': + return queryUserTableTitle(args) case 'table_manage': case 'table_rows': case 'table_columns': case 'table_automations': case 'table_enrichments': case 'table_views': - return queryUserTableTitle(args) + return splitTableTitle(name, args) case 'search_knowledge_base': return searchKnowledgeBaseTitle(args) case 'manage_sandbox': return manageSandboxTitle(args) - case 'manage_scheduled_task': - return manageScheduledTaskTitle(args) case 'user_table': return userTableTitle(args) case 'save_upload': @@ -769,12 +798,6 @@ export function getToolDisplayTitle(name: string, args?: Record const type = stringArg(args, 'type') return `Restoring ${type ? resourceTypeLabel(type) : 'resource'}` } - case 'set_block_enabled': { - const enabled = args?.enabled - return typeof enabled === 'boolean' - ? `${enabled ? 'Enabling' : 'Disabling'} block` - : 'Toggling block' - } case 'load_deployment': { const version = stringOrNumberArg(args, 'version') if (!version) return 'Loading deployment' @@ -927,9 +950,9 @@ export function getToolDisplayTitle(name: string, args?: Record } case 'web_fetch': { const urls = stringArrayArg(args, 'urls') - if (urls.length === 1) return `Getting ${urls[0]}` - if (urls.length > 1) return `Getting ${urls.length} pages` - return 'Getting page contents' + if (urls.length === 1) return `Fetching ${urls[0]}` + if (urls.length > 1) return `Fetching ${urls.length} pages` + return 'Fetching page' } case 'manage_custom_tool': { const schema = args?.schema @@ -938,7 +961,7 @@ export function getToolDisplayTitle(name: string, args?: Record (schema && typeof schema === 'object' ? nestedStringArg(schema as Record, 'function', 'name') : '') - return namedOperationTitle(args, target, 'Custom tool action', { + return namedOperationTitle(args, target, 'Managing custom tool', { add: { verb: 'Creating', resource: 'custom tool' }, edit: { verb: 'Updating', resource: 'custom tool' }, delete: { verb: 'Deleting', resource: 'custom tool' }, @@ -949,7 +972,7 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'serverName', 'name', 'title') || nestedStringArg(args, 'config', 'name') - return namedOperationTitle(args, target, 'MCP server action', { + return namedOperationTitle(args, target, 'Managing MCP server', { add: { verb: 'Creating', resource: 'MCP server' }, edit: { verb: 'Updating', resource: 'MCP server' }, delete: { verb: 'Deleting', resource: 'MCP server' }, @@ -958,7 +981,7 @@ export function getToolDisplayTitle(name: string, args?: Record } case 'manage_skill': { const target = firstStringArg(args, 'name', 'skillName', 'title') - return namedOperationTitle(args, target, 'Skill action', { + return namedOperationTitle(args, target, 'Managing skill', { add: { verb: 'Creating', resource: 'skill' }, edit: { verb: 'Updating', resource: 'skill' }, delete: { verb: 'Deleting', resource: 'skill' }, @@ -974,14 +997,40 @@ export function getToolDisplayTitle(name: string, args?: Record return to ? `Renaming credential to ${to}` : 'Renaming credential' } const target = firstStringArg(args, 'credentialName', 'displayName', 'name', 'title') - return namedOperationTitle(args, target, 'Credential action', { + return namedOperationTitle(args, target, 'Managing credential', { delete: { verb: 'Deleting', resource: 'credential' }, }) } - case 'run_workflow': - case 'run_from_block': - case 'run_workflow_until_block': - return 'Running workflow' + case 'run_workflow': { + const workflow = firstStringArg(args, 'workflowName', 'name') + return workflow ? `Running ${workflow}` : 'Running workflow' + } + case 'run_from_block': { + const block = firstStringArg(args, 'blockName', 'block_name', 'startBlockName', 'blockId') + const workflow = firstStringArg(args, 'workflowName', 'name') + if (!block) return 'Running workflow' + return workflow ? `Running from ${block} in ${workflow}` : `Running from ${block}` + } + case 'run_workflow_until_block': { + const block = firstStringArg(args, 'blockName', 'block_name', 'untilBlockName', 'blockId') + const workflow = firstStringArg(args, 'workflowName', 'name') + if (!block) return workflow ? `Running ${workflow}` : 'Running workflow' + return `Running ${workflow || 'workflow'} until ${block}` + } + case 'run_block': { + const block = firstStringArg(args, 'blockName', 'block_name', 'blockId') + const workflow = firstStringArg(args, 'workflowName', 'name') + if (!block) return 'Running block' + return workflow ? `Running ${block} in ${workflow}` : `Running ${block}` + } + case 'set_block_enabled': { + const block = firstStringArg(args, 'blockName', 'block_name', 'blockId') + const workflow = firstStringArg(args, 'workflowName', 'name') + const verb = + args?.enabled === false ? 'Disabling' : args?.enabled === true ? 'Enabling' : 'Toggling' + if (!block) return `${verb} block` + return workflow ? `${verb} ${block} in ${workflow}` : `${verb} ${block}` + } case 'query_logs': { // The model narrates its own query; the per-view titles are fallbacks. const title = stringArg(args, 'title') @@ -1007,9 +1056,26 @@ export function getToolDisplayTitle(name: string, args?: Record } } case 'read': { - if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) { + const path = stringArg(args, 'path') + if (isWorkflowArtifactPath(path, 'lint.json')) { return 'Validating workflow state' } + // Workflow artifacts name BOTH the workflow and which part, so five + // reads in a row differentiate instead of all saying the same thing. + const workflowArtifact = path.match(/^workflows\/([^/]+)\/([^/]+)$/) + if (workflowArtifact) { + const part = + ( + { + 'meta.json': 'meta', + 'state.json': 'state', + 'deployment.json': 'deployment', + 'README.md': 'notes', + } as Record + )[workflowArtifact[2]] ?? decodePathSegment(workflowArtifact[2]) + return `Reading ${decodePathSegment(workflowArtifact[1])} ${part}` + } + if (path) return `Reading ${pathLeaf(path)}` break } case 'prepare_file_edit': @@ -1063,8 +1129,13 @@ const COMPLETED_VERB_REWRITES: Record = { Finding: 'Found', Gathering: 'Gathered', Generating: 'Generated', - Getting: 'Got', Going: 'Went', + Fetching: 'Fetched', + Tracing: 'Traced', + Wiring: 'Wired', + Configuring: 'Configured', + Looking: 'Looked', + Rotating: 'Rotated', Hovering: 'Hovered', Importing: 'Imported', Inspecting: 'Inspected', From 8f90e7cdeeb55875cffa1148b7577387ab879bd3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 11:31:23 -0700 Subject: [PATCH 088/103] Deploying {workflow} as chat, not as chat app --- apps/sim/lib/copilot/tools/tool-display.test.ts | 4 ++-- apps/sim/lib/copilot/tools/tool-display.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index e3677513bc7..7ac6bd49b31 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -144,8 +144,8 @@ describe('getToolDisplayTitle for deployments', () => { ['deploy_as_api', undefined, 'Deploying as API'], ['deploy_as_api', { action: 'deploy' }, 'Deploying as API'], ['deploy_as_api', { action: 'undeploy' }, 'Undeploying as API'], - ['deploy_as_chat', { action: 'deploy' }, 'Deploying as chat app'], - ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying as chat app'], + ['deploy_as_chat', { action: 'deploy' }, 'Deploying as chat'], + ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying as chat'], ['publish_custom_block', { action: 'deploy' }, 'Publishing custom block'], ['publish_custom_block', { action: 'undeploy' }, 'Unpublishing custom block'], ['deploy_as_mcp', undefined, 'Deploying as MCP tool'], diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 89b1e1bdd3c..73797cccadf 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -503,7 +503,7 @@ const TOOL_TITLES: Record = { create_workspace_mcp_server: 'Creating MCP server', delete_workspace_mcp_server: 'Deleting MCP server', deploy_as_api: 'Deploying as API', - deploy_as_chat: 'Deploying as chat app', + deploy_as_chat: 'Deploying as chat', publish_custom_block: 'Publishing custom block', deploy_as_mcp: 'Deploying as MCP tool', diff_workflows: 'Comparing workflows', @@ -735,7 +735,7 @@ export function getToolDisplayTitle(name: string, args?: Record case 'deploy_as_api': return deploymentTitle(args, 'API') case 'deploy_as_chat': - return deploymentTitle(args, 'chat app') + return deploymentTitle(args, 'chat') case 'publish_custom_block': return `${stringArg(args, 'action') === 'undeploy' ? 'Unpublishing' : 'Publishing'} custom block` case 'ffmpeg': From 471919c5b51e6fd1bc62abcf56dac25e77efdbb2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 11:36:59 -0700 Subject: [PATCH 089/103] Loader gerunds; mv names both ends; mkdir names the folder search_integration_tools -> Finding the right integration; load_integration_tool -> Loading {integration} tools; load_skill -> Loading skill {name}; run_enrichment -> Looking up {subject}. mv prefers the model's phrasing, else reads 'Moving {files} to {destination}'; mkdir reads 'Creating folder {name}' from the path. --- apps/sim/lib/copilot/tools/tool-display.ts | 25 ++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 73797cccadf..6c4230559e6 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -475,6 +475,9 @@ const TOOL_TITLES: Record = { // covers only the instant before the integration is known. The raw // humanized name ("Call Integration Tool") must never render. call_integration_tool: 'Calling integration', + search_integration_tools: 'Finding the right integration', + load_integration_tool: 'Loading integration tools', + load_skill: 'Loading skill', read: 'Reading file', search_library_docs: 'Searching library docs', user_table: 'Managing table', @@ -877,14 +880,24 @@ export function getToolDisplayTitle(name: string, args?: Record const destination = stringArg(args, 'destination') if (destination) return `Renaming ${pathLeaf(sources[0])} to ${pathLeaf(destination)}` } + // The model's own phrasing wins; otherwise name both ends of the + // move: "Moving apple.md to fruits". const target = firstStringArg(args, 'toolTitle', 'title') - return target ? `${verb} ${target}` : verb + if (target) return `${verb} ${target}` + const destination = stringArg(args, 'destination') + if (sources.length > 0 && destination) { + const what = summarizeTargets(sources.map(pathLeaf), 'files') + return `${verb} ${what} to ${pathLeaf(destination)}` + } + return verb } case 'cp': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Duplicating ${target}` : 'Duplicating workflow' } case 'mkdir': { + const path = stringArg(args, 'path') + if (path) return `Creating folder ${pathLeaf(path)}` const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Creating ${target}` : 'Creating folder' } @@ -896,6 +909,14 @@ export function getToolDisplayTitle(name: string, args?: Record summarizeTargets(stringArrayArg(args, 'paths').map(pathLeaf), 'resource') return target ? `Deleting ${target}` : 'Deleting' } + case 'load_integration_tool': { + const integration = firstStringArg(args, 'integration', 'service', 'toolId') + return integration ? `Loading ${integration} tools` : 'Loading integration tools' + } + case 'load_skill': { + const skill = firstStringArg(args, 'name', 'skillId', 'skill') + return skill ? `Loading skill ${skill}` : 'Loading skill' + } case 'run_enrichment': { const subject = nestedStringArg( args, @@ -906,7 +927,7 @@ export function getToolDisplayTitle(name: string, args?: Record 'email', 'companyDomain' ) - return subject ? `Searching for ${subject}` : 'Searching' + return subject ? `Looking up ${subject}` : 'Looking up data' } case 'web_scrape': { const url = stringArg(args, 'url') From 0cd87bad2f14a4865448a5e6082f3d44b40d6011 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 15 Aug 2026 11:53:21 -0700 Subject: [PATCH 090/103] feat(resources): multiselect on tables and knowledge, spring-loaded folders (#6721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(resources): multiselect on tables and knowledge, spring-loaded folders Tables and Knowledge lists get the checkbox multiselect Files already had — selection, shift-click ranges, select-all, and a shared bulk action bar for move and delete. Dragging a resource onto a folder row and resting there now opens that folder, so nested filing is one gesture (macOS Finder spring-loading). Works on Files, Tables, and Knowledge. Selection, the action bar, the drag payload, the drag ghost, and drag teardown are extracted to shared modules; Files migrates onto them rather than keeping its own copies. Bulk move and delete land as single authorized operations that take folders and resources together, so a mixed selection commits once instead of fanning out. Fixes two latent UI bugs: the drop-target outline referenced --accent, an HSL-channel token only valid via hsl(), so it silently rendered as currentColor; and rows painted hover and selected with the same surface token, making the two states indistinguishable. * chore(audits): re-record route ratchet after merging staging * fix(bulk): reject a move target inside the moving subtree and report contained folders deterministically * improvement(resources): neutral drop affordance, longer spring delay, and a body drop target * fix(resources): drop into the open folder on Files, return on an unused spring-open, and guard bulk caps * fix(drag): end a drag on pointer resume instead of an idle timer * feat(resources): drag onto breadcrumbs to move back up, and round the drop ring * fix(resources): tint the list region on drop instead of ringing it * refactor(folders): share the spring-navigation lifecycle so Files returns too * fix(breadcrumbs): accept a drag on the open-folder crumb too * fix(files): keep the view in a spring-opened folder when an OS upload lands there --- .../app/api/knowledge/bulk-delete/route.ts | 30 + apps/sim/app/api/knowledge/bulk-move/route.ts | 31 + apps/sim/app/api/table/bulk-delete/route.ts | 29 + apps/sim/app/api/table/bulk-move/route.ts | 30 + .../components/folders/drag-payload.ts | 41 ++ .../components/folders/folder-breadcrumbs.ts | 3 +- .../folders/folder-context-menu.tsx | 5 +- .../components/folders/folder-row-id.ts | 20 + .../components/folders/folders.test.ts | 113 ++++ .../[workspaceId]/components/folders/index.ts | 19 +- .../components/folders/move-options.tsx | 52 +- .../folders/use-drag-teardown.test.tsx | 118 ++++ .../components/folders/use-drag-teardown.ts | 61 ++ .../folders/use-folder-navigation.ts | 11 +- .../folders/use-folder-row-drag-drop.ts | 349 ++++++++--- .../folders/use-row-drag-ghost.test.tsx | 76 +++ .../components/folders/use-row-drag-ghost.ts | 66 ++ .../folders/use-spring-loaded-folder.test.tsx | 225 +++++++ .../folders/use-spring-loaded-folder.ts | 123 ++++ .../folders/use-spring-navigation.test.tsx | 179 ++++++ .../folders/use-spring-navigation.ts | 120 ++++ .../[workspaceId]/components/index.ts | 13 +- .../components/resource/bulk-outcome.ts | 53 ++ .../components/action-bar/action-bar.tsx | 163 +++++ .../resource/components/action-bar/index.ts | 2 + .../resource/components/owner-cell/index.ts | 3 +- .../components/owner-cell/owner-cell.tsx | 9 +- .../components/resource-header/index.ts | 1 + .../resource-header/resource-header.tsx | 81 ++- .../components/resource-options/index.ts | 6 +- .../resource-options/resource-options.tsx | 17 +- .../components/resource/resource.tsx | 61 +- .../components/resource/selection-label.ts | 9 + .../use-resource-row-selection.test.tsx | 173 ++++++ .../resource/use-resource-row-selection.ts | 210 +++++++ .../components/action-bar/action-bar.tsx | 126 ---- .../files/components/action-bar/index.ts | 1 - .../workspace/[workspaceId]/files/files.tsx | 588 +++++++++--------- .../[workspaceId]/knowledge/[id]/base.tsx | 8 +- .../[workspaceId]/knowledge/knowledge.tsx | 359 +++++++++-- .../workspace/[workspaceId]/tables/tables.tsx | 319 ++++++++-- apps/sim/hooks/queries/kb/knowledge.ts | 85 +++ apps/sim/hooks/queries/tables.ts | 83 +++ apps/sim/lib/api/contracts/knowledge/base.ts | 130 ++++ apps/sim/lib/api/contracts/tables.ts | 123 ++++ .../tools/server/knowledge/knowledge-base.ts | 6 +- apps/sim/lib/core/application/batch-policy.ts | 61 ++ .../lib/core/application/bulk-items.test.ts | 79 +++ apps/sim/lib/core/application/bulk-items.ts | 58 ++ apps/sim/lib/folders/bulk.test.ts | 95 +++ apps/sim/lib/folders/bulk.ts | 287 +++++++++ apps/sim/lib/folders/orchestration.ts | 35 +- apps/sim/lib/folders/subtree.test.ts | 50 +- apps/sim/lib/folders/subtree.ts | 44 +- apps/sim/lib/knowledge/api/route-policies.ts | 7 + .../lib/knowledge/application/batch-policy.ts | 60 +- .../lib/knowledge/application/bulk.test.ts | 335 ++++++++++ apps/sim/lib/knowledge/application/bulk.ts | 415 ++++++++++++ .../knowledge/application/knowledge-bases.ts | 17 +- .../knowledge/application/operations.test.ts | 2 + .../lib/knowledge/application/operations.ts | 12 + apps/sim/lib/knowledge/constants.ts | 7 + apps/sim/lib/table/api/route-policies.ts | 8 + .../sim/lib/table/application/batch-policy.ts | 57 ++ apps/sim/lib/table/application/bulk.test.ts | 408 ++++++++++++ apps/sim/lib/table/application/bulk.ts | 442 +++++++++++++ apps/sim/lib/table/application/operations.ts | 2 + apps/sim/lib/table/constants.ts | 7 + apps/sim/lib/table/service.ts | 12 +- .../emcn/src/components/chip/chip-chrome.ts | 10 + packages/emcn/src/components/index.ts | 1 + scripts/check-api-validation-contracts.ts | 4 +- 72 files changed, 6184 insertions(+), 661 deletions(-) create mode 100644 apps/sim/app/api/knowledge/bulk-delete/route.ts create mode 100644 apps/sim/app/api/knowledge/bulk-move/route.ts create mode 100644 apps/sim/app/api/table/bulk-delete/route.ts create mode 100644 apps/sim/app/api/table/bulk-move/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts create mode 100644 apps/sim/lib/core/application/batch-policy.ts create mode 100644 apps/sim/lib/core/application/bulk-items.test.ts create mode 100644 apps/sim/lib/core/application/bulk-items.ts create mode 100644 apps/sim/lib/folders/bulk.test.ts create mode 100644 apps/sim/lib/folders/bulk.ts create mode 100644 apps/sim/lib/knowledge/application/bulk.test.ts create mode 100644 apps/sim/lib/knowledge/application/bulk.ts create mode 100644 apps/sim/lib/table/application/batch-policy.ts create mode 100644 apps/sim/lib/table/application/bulk.test.ts create mode 100644 apps/sim/lib/table/application/bulk.ts diff --git a/apps/sim/app/api/knowledge/bulk-delete/route.ts b/apps/sim/app/api/knowledge/bulk-delete/route.ts new file mode 100644 index 00000000000..46632c54452 --- /dev/null +++ b/apps/sim/app/api/knowledge/bulk-delete/route.ts @@ -0,0 +1,30 @@ +import { bulkDeleteKnowledgeItemsContract } from '@/lib/api/contracts/knowledge' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { bulkDeleteKnowledgeItems } from '@/lib/knowledge/application/bulk' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: bulkDeleteKnowledgeItemsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.bulkDeleteItems, + rateLimit: internalRateLimits.none({ + reason: 'Matches the unlimited single-base and single-folder deletes it batches', + }), + errorPolicy: internalKnowledgeErrorPolicies.bulkDelete, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + knowledgeBaseIds: body.knowledgeBaseIds, + folderIds: body.folderIds, + source: 'ui', + }), + useCase: bulkDeleteKnowledgeItems, + present: ({ deleted, skipped, notFound, failed, deletedItems }) => ({ + success: true as const, + data: { deleted, skipped, notFound, failed, deletedItems }, + }), +}) diff --git a/apps/sim/app/api/knowledge/bulk-move/route.ts b/apps/sim/app/api/knowledge/bulk-move/route.ts new file mode 100644 index 00000000000..c06a5f9026e --- /dev/null +++ b/apps/sim/app/api/knowledge/bulk-move/route.ts @@ -0,0 +1,31 @@ +import { bulkMoveKnowledgeItemsContract } from '@/lib/api/contracts/knowledge' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { bulkMoveKnowledgeItems } from '@/lib/knowledge/application/bulk' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: bulkMoveKnowledgeItemsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.bulkMoveItems, + rateLimit: internalRateLimits.none({ + reason: 'Matches the unlimited single-base and single-folder moves it batches', + }), + errorPolicy: internalKnowledgeErrorPolicies.bulkMove, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + knowledgeBaseIds: body.knowledgeBaseIds, + folderIds: body.folderIds, + targetFolderId: body.targetFolderId, + source: 'ui', + }), + useCase: bulkMoveKnowledgeItems, + present: ({ moved, skipped, notFound, failed }) => ({ + success: true as const, + data: { moved, skipped, notFound, failed }, + }), +}) diff --git a/apps/sim/app/api/table/bulk-delete/route.ts b/apps/sim/app/api/table/bulk-delete/route.ts new file mode 100644 index 00000000000..8af204a51a8 --- /dev/null +++ b/apps/sim/app/api/table/bulk-delete/route.ts @@ -0,0 +1,29 @@ +import { bulkDeleteTablesContract } from '@/lib/api/contracts/tables' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalTableErrorPolicies } from '@/lib/table/api' +import { bulkDeleteTables } from '@/lib/table/application/bulk' +import { tableOperations } from '@/lib/table/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: bulkDeleteTablesContract, + auth: internalSessionAuth, + operation: tableOperations.bulkDelete, + rateLimit: internalRateLimits.none({ + reason: 'Matches the unlimited single-table and single-folder deletes it batches', + }), + errorPolicy: internalTableErrorPolicies.bulk, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + tableIds: body.tableIds, + folderIds: body.folderIds, + }), + useCase: bulkDeleteTables, + present: ({ deleted, skipped, notFound, failed, deletedItems }) => ({ + success: true as const, + data: { deleted, skipped, notFound, failed, deletedItems }, + }), +}) diff --git a/apps/sim/app/api/table/bulk-move/route.ts b/apps/sim/app/api/table/bulk-move/route.ts new file mode 100644 index 00000000000..1e8ac395fff --- /dev/null +++ b/apps/sim/app/api/table/bulk-move/route.ts @@ -0,0 +1,30 @@ +import { bulkMoveTablesContract } from '@/lib/api/contracts/tables' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalTableErrorPolicies } from '@/lib/table/api' +import { bulkMoveTables } from '@/lib/table/application/bulk' +import { tableOperations } from '@/lib/table/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: bulkMoveTablesContract, + auth: internalSessionAuth, + operation: tableOperations.bulkMove, + rateLimit: internalRateLimits.none({ + reason: 'Matches the unlimited single-table and single-folder moves it batches', + }), + errorPolicy: internalTableErrorPolicies.bulk, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + tableIds: body.tableIds, + folderIds: body.folderIds, + targetFolderId: body.targetFolderId, + }), + useCase: bulkMoveTables, + present: ({ moved, skipped, notFound, failed }) => ({ + success: true as const, + data: { moved, skipped, notFound, failed }, + }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts new file mode 100644 index 00000000000..227c17308fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts @@ -0,0 +1,41 @@ +/** + * The row ids a drag carries, written to and read from `dataTransfer` as JSON under a + * private MIME type. + * + * The payload has to live on the event rather than only in component state: a drag survives + * the source row unmounting — spring-loading navigates away mid-drag — and it can be released + * over a different mount of the same page. + * + * Each surface passes its own MIME so a drag from one list is never mistaken for a drag from + * another, and so an unrelated OS drag is ignored outright. + */ + +/** Writes `rowIds` under `mime`, plus a plain-text fallback for drops outside the app. */ +export function writeRowDragPayload( + dataTransfer: DataTransfer, + mime: string, + rowIds: string[] +): void { + dataTransfer.setData(mime, JSON.stringify(rowIds)) + dataTransfer.setData('text/plain', rowIds.join(',')) +} + +/** + * Reads the row ids back, returning `null` when the payload is absent (a foreign drag) or + * malformed (another writer on the same MIME) rather than throwing mid-drop. Callers fall back + * to their in-memory source for drags that never round-tripped through `dataTransfer`. + */ +export function readRowDragPayload(dataTransfer: DataTransfer, mime: string): string[] | null { + const raw = dataTransfer.getData(mime) + if (!raw) return null + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return null + const rowIds = parsed.filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ) + return rowIds.length > 0 ? rowIds : null + } catch { + return null + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts index 7a2b3d88f50..1ff57a3becb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -91,7 +91,7 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const trailing = options.trailing ?? NO_TRAILING_CRUMBS const items: BreadcrumbItem[] = [ - { label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) }, + { label: rootLabel, icon: rootIcon, folderId: null, onClick: () => onNavigate(null) }, ] breadcrumbs.forEach((folder, index) => { @@ -99,6 +99,7 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1 items.push({ label: folder.name, + folderId: folder.id, onClick: isOpenFolder ? undefined : () => onNavigate(folder.id), dropdownItems: isOpenFolder && options.currentFolderActions?.length diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx index 614786a1ed3..41b8682aef6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -35,9 +35,8 @@ interface FolderContextMenuProps { * Row context menu for a folder, shared by the resource lists built on the generic folder * engine — Knowledge and Tables — so a folder offers the same actions on both. * - * Files is deliberately not a consumer: its rows carry multi-select and bulk actions, so a - * folder there routes through `FileRowContextMenu` alongside the file rows it is selected - * with. Converging the two is follow-up work. + * Files is deliberately not a consumer: a folder there routes through `FileRowContextMenu` + * alongside the file rows it is selected with. Converging the two is follow-up work. * * Mirrors the resource-row menus (`KnowledgeBaseContextMenu`, `FileRowContextMenu`): a * `DropdownMenu` anchored to a one-pixel fixed trigger at the cursor, non-modal so the list diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts index f4561a3adef..82042473d51 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts @@ -26,3 +26,23 @@ export function parseFolderedRowId(rowId: string): ParsedFolderedRowId { } return { kind: 'resource', id: rowId } } + +/** + * Splits a selection of foldered row ids into the two id lists every bulk operation takes. + * + * A foldered list holds folder rows and resource rows in one selection (see + * {@link folderRowId}), so every consumer needs this same split before it can call an API. + */ +export function splitFolderedRowIds(rowIds: Iterable): { + folderIds: string[] + resourceIds: string[] +} { + const folderIds: string[] = [] + const resourceIds: string[] = [] + for (const rowId of rowIds) { + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') folderIds.push(parsed.id) + else resourceIds.push(parsed.id) + } + return { folderIds, resourceIds } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts index 994dfaf5e49..68e6b3b218f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts @@ -11,10 +11,12 @@ import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components import { folderRowId, parseFolderedRowId, + splitFolderedRowIds, } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, } from '@/app/workspace/[workspaceId]/components/folders/move-options' @@ -331,3 +333,114 @@ describe('folderAncestorChain', () => { expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a']) }) }) + +describe('splitFolderedRowIds', () => { + it('separates folder rows from resource rows', () => { + const { folderIds, resourceIds } = splitFolderedRowIds([ + folderRowId('f-1'), + 'res-1', + folderRowId('f-2'), + 'res-2', + ]) + + expect(folderIds).toEqual(['f-1', 'f-2']) + expect(resourceIds).toEqual(['res-1', 'res-2']) + }) + + it('returns empty lists for an empty selection', () => { + expect(splitFolderedRowIds([])).toEqual({ folderIds: [], resourceIds: [] }) + }) + + it('accepts a Set, which is how a selection is actually held', () => { + const { folderIds, resourceIds } = splitFolderedRowIds(new Set([folderRowId('f-1'), 'res-1'])) + expect(folderIds).toEqual(['f-1']) + expect(resourceIds).toEqual(['res-1']) + }) +}) + +describe('buildMoveOptionsExcludingSubtrees', () => { + /** `a` holds `a1`, which holds `a1x`; `b` is an unrelated sibling. */ + const folders = [makeFolder('a'), makeFolder('a1', 'a'), makeFolder('a1x', 'a1'), makeFolder('b')] + const descendantsByFolderId = buildDescendantIndex(folders) + const valuesOf = (nodes: ReturnType): string[] => + nodes.flatMap((node) => [node.value, ...valuesOf(node.children)]) + + it('offers every folder when nothing is excluded', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: [], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a', 'a1', 'a1x', 'b']) + }) + + it('excludes a moving folder and its whole subtree, never offering a cycle', () => { + // The invariant this helper exists to hold: a folder can never be filed into itself or + // anything beneath it, at any depth. + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'b']) + }) + + it('excludes the union of several selected subtrees', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a1', 'b'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a']) + }) + + it('always keeps the workspace root as a destination', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a', 'b'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE]) + }) +}) + +describe('folderBreadcrumbItems drag destinations', () => { + const chain = [makeFolder('a'), makeFolder('a1', 'a')] + + it('names the folder each crumb points at, so the header can accept a drop on it', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: chain, + onNavigate: vi.fn(), + }) + + expect(items.map((item) => item.folderId)).toEqual([null, 'a', 'a1']) + }) + + it('leaves a trailing crumb without a folder id, so it stays inert', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: chain, + onNavigate: vi.fn(), + trailing: [{ label: 'report.md', terminal: true }], + }) + + expect(items.at(-1)).toMatchObject({ label: 'report.md' }) + expect(items.at(-1)?.folderId).toBeUndefined() + }) + + it('gives the root crumb null rather than omitting it — the root is a real destination', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: [], + onNavigate: vi.fn(), + }) + + expect(items).toHaveLength(1) + expect(items[0]).toHaveProperty('folderId', null) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index aee06919742..20c6a275243 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -1,3 +1,4 @@ +export { readRowDragPayload, writeRowDragPayload } from './drag-payload' export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs' export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs' export { FolderContextMenu } from './folder-context-menu' @@ -5,16 +6,17 @@ export { nextUntitledFolderName } from './folder-naming' export type { FolderRowOptions } from './folder-row' export { folderRow } from './folder-row' export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id' -export { folderRowId, parseFolderedRowId } from './folder-row-id' +export { folderRowId, parseFolderedRowId, splitFolderedRowIds } from './folder-row-id' export type { FolderedHeaderResourceType, FolderedResourceHeaderMeta, } from './foldered-resources' export { FOLDERED_RESOURCE_HEADERS, folderedResourceListHref } from './foldered-resources' -export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options' +export type { BuildMoveOptionsParams, MoveOptionFolder, MoveOptionNode } from './move-options' export { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, renderMoveOption, @@ -23,9 +25,20 @@ export { export type { SortableResource } from './resource-sort' export { sortResources } from './resource-sort' export { folderNavParsers, folderNavUrlKeys } from './search-params' +export { useDragTeardown } from './use-drag-teardown' export type { FolderAncestors, UseFolderAncestorsOptions } from './use-folder-ancestors' export { useFolderAncestors } from './use-folder-ancestors' export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation' export { useFolderNavigation } from './use-folder-navigation' -export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' +export type { FolderedRowMove, UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' export { useFolderRowDragDrop } from './use-folder-row-drag-drop' +export type { RowDragGhost } from './use-row-drag-ghost' +export { useRowDragGhost } from './use-row-drag-ghost' +export type { + SpringLoadedFolder, + SpringOpenOptions, + UseSpringLoadedFolderOptions, +} from './use-spring-loaded-folder' +export { SPRING_LOAD_DELAY_MS, useSpringLoadedFolder } from './use-spring-loaded-folder' +export type { SpringNavigation, UseSpringNavigationOptions } from './use-spring-navigation' +export { useSpringNavigation } from './use-spring-navigation' diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx index 66f0b2e455a..865c50bca74 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx @@ -7,7 +7,6 @@ import { DropdownMenuSubTrigger, } from '@sim/emcn' import { Folder } from '@sim/emcn/icons' -import type { WorkflowFolder } from '@/stores/folders/types' export interface MoveOptionNode { value: string @@ -26,8 +25,16 @@ export function parseMoveOptionValue(optionValue: string): string | null { return optionValue === ROOT_MOVE_OPTION_VALUE ? null : optionValue } +/** The folder fields the move-option builders actually read, so any folder tree can use them. */ +export interface MoveOptionFolder { + id: string + name: string + parentId: string | null + sortOrder: number +} + export interface BuildMoveOptionsParams { - folders: WorkflowFolder[] + folders: readonly MoveOptionFolder[] rootLabel: string /** * Folder ids that must not appear as destinations — the folder being moved and every @@ -53,7 +60,7 @@ export function buildMoveOptions({ rootLabel, excludedFolderIds, }: BuildMoveOptionsParams): MoveOptionNode[] { - const childrenByParent = new Map() + const childrenByParent = new Map() for (const folder of folders) { if (excludedFolderIds?.has(folder.id)) continue const parentId = folder.parentId ?? null @@ -80,7 +87,9 @@ export function buildMoveOptions({ * candidate instead of re-walking the tree. `seen` terminates a cycle, which the DB permits * between constraint checks. */ -export function buildDescendantIndex(folders: WorkflowFolder[]): Map> { +export function buildDescendantIndex( + folders: readonly { id: string; parentId: string | null }[] +): Map> { const childrenByParent = new Map() for (const folder of folders) { if (!folder.parentId) continue @@ -167,3 +176,38 @@ export function renderMoveOptions( ) } + +/** + * Move destinations for a selection, with every selected folder and its subtree excluded — a + * folder cannot be filed into itself or anything beneath it. + * + * Shared because that exclusion is a correctness invariant, not a preference: hand-copying it + * per surface is how one list eventually offers a cyclic destination. Covers the single-folder + * case too — pass a one-element array. + * + * Expanding each selection to its descendants is deliberately belt-and-braces: {@link + * buildMoveOptions} descends from the root, so an excluded folder already takes its subtree out + * of the walk. The explicit expansion keeps the invariant true of the exclusion set itself, so + * it survives that walk ever being replaced by a flat render. + */ +export function buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel, + excludeFolderIds, + descendantsByFolderId, +}: { + folders: readonly MoveOptionFolder[] + rootLabel: string + excludeFolderIds: readonly string[] + descendantsByFolderId: Map> +}): MoveOptionNode[] { + if (excludeFolderIds.length === 0) return buildMoveOptions({ folders, rootLabel }) + + const excludedFolderIds = new Set(excludeFolderIds) + for (const folderId of excludeFolderIds) { + for (const descendantId of descendantsByFolderId.get(folderId) ?? []) { + excludedFolderIds.add(descendantId) + } + } + return buildMoveOptions({ folders, rootLabel, excludedFolderIds }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx new file mode 100644 index 00000000000..d107519d7d9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' + +const mountedRoots: Root[] = [] + +function renderDragTeardown(teardown: () => void) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + function Probe() { + useDragTeardown(teardown) + return null + } + + act(() => { + root.render() + }) +} + +function fire(type: string) { + act(() => { + window.dispatchEvent(new Event(type, { bubbles: true })) + }) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useDragTeardown', () => { + it('tears down on dragend', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('dragend') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('tears down on drop', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('drop') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('tears down on the first pointermove after a drag, which dragend can miss', () => { + // Spring-loading unmounts the source row, so `dragend` — dispatched at that node — never + // reaches window. Browsers suppress pointer events during a drag, so the first one after + // is an exact signal that the drag ended. + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('pointermove') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('never tears down from the passage of time alone', () => { + // Regression: an idle-timeout version of this tore the drag down whenever the user rested + // on a folder waiting for it to spring open — the drag model reports only about every + // 350ms while the pointer is still, so any timeout in that range trips on a held drag. + // Nothing here may depend on a timer, so advancing the clock must change nothing. + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + act(() => { + vi.advanceTimersByTime(30_000) + }) + + expect(teardown).not.toHaveBeenCalled() + + // And the drag is still live, so a real end signal still lands. + fire('dragend') + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('ignores pointer movement when no drag is in flight', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('pointermove') + fire('pointermove') + + expect(teardown).not.toHaveBeenCalled() + }) + + it('tears down once per drag, not on every event after it ends', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('dragend') + fire('pointermove') + fire('pointermove') + + expect(teardown).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts new file mode 100644 index 00000000000..a28ca2057a5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts @@ -0,0 +1,61 @@ +'use client' + +import { useEffect, useRef } from 'react' + +/** + * Runs a drag's teardown wherever the drag actually ends. + * + * Three signals, because no single one is reliable here: + * + * 1. `drop` on `window` — a release over any valid target, wherever it bubbles from. + * 2. `dragend` on `window` — the normal end of a drag whose source row still exists. + * 3. `pointermove` on `window` — the case the first two miss. `dragend` is dispatched *at the + * source node*, so once spring-loading navigates the list and unmounts that row, the event + * has no path to `window` and neither listener above ever runs. Cancelling with Escape or + * releasing over nothing then leaves the ghost on the page, every row frozen at drag + * opacity, and the spring-open set uncleared so those folders refuse to open again. + * + * The third signal works because browsers suppress mouse and pointer events for the duration of + * a native drag: the first `pointermove` after one starts can only mean it is over. That makes + * it exact, where a timer is not — the drag model fires `dragover` on roughly a 350ms cadence + * while the pointer is stationary, so an idle-timeout version of this tore down mid-drag + * whenever the user rested on a folder waiting for it to spring open. + * + * `teardown` is read through a ref and the listeners bind once, deliberately. Depending on the + * callback would re-run this effect on every render, and the teardown wired into it would then + * abort drags that are still in progress — a bug this exact hook already shipped once. + */ +export function useDragTeardown(teardown: () => void): void { + const teardownRef = useRef<() => void>(teardown) + teardownRef.current = teardown + + useEffect(() => { + /** + * Set from `dragover` rather than `dragstart` so the flag only turns on once a drag is + * genuinely under way, and so a stray `pointermove` before the drag engages cannot tear + * down a drag that never started. + */ + let isDragging = false + + const markDragging = () => { + isDragging = true + } + + const endDrag = () => { + if (!isDragging) return + isDragging = false + teardownRef.current() + } + + window.addEventListener('dragover', markDragging) + window.addEventListener('dragend', endDrag) + window.addEventListener('drop', endDrag) + window.addEventListener('pointermove', endDrag) + return () => { + window.removeEventListener('dragover', markDragging) + window.removeEventListener('dragend', endDrag) + window.removeEventListener('drop', endDrag) + window.removeEventListener('pointermove', endDrag) + } + }, []) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index 19eb8f3e7d2..a739111c83a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -20,7 +20,12 @@ export interface UseFolderNavigationOptions { export interface FolderNavigation extends FolderAncestors { /** The open folder, or `null` at the workspace root. */ currentFolderId: string | null - setCurrentFolderId: (folderId: string | null) => void + /** + * Opens a folder. Defaults to the param group's `history: 'push'` — a folder the user chose + * to open is a destination. Pass `{ history: 'replace' }` for a write that is not a chosen + * navigation, such as the second and later spring-opens within a single drag. + */ + setCurrentFolderId: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void } /** @@ -49,8 +54,8 @@ export function useFolderNavigation({ const { folderById, foldersResolved } = ancestry const setCurrentFolderId = useCallback( - (folderId: string | null) => { - void setFolderParams({ folderId }) + (folderId: string | null, options?: { history?: 'push' | 'replace' }) => { + void setFolderParams({ folderId }, options) }, [setFolderParams] ) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index b6a746b2606..30769d0c264 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -1,21 +1,29 @@ 'use client' -import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react' +import { + readRowDragPayload, + writeRowDragPayload, +} from '@/app/workspace/[workspaceId]/components/folders/drag-payload' import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' +import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' +import { useRowDragGhost } from '@/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost' +import type { SpringOpenOptions } from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' +import { useSpringNavigation } from '@/app/workspace/[workspaceId]/components/folders/use-spring-navigation' import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' -/** - * Private drag payload, namespaced so a drag started on another Sim surface (or an external - * drag) is never mistaken for a foldered list row. - */ +/** The foldered-list drag MIME — see {@link writeRowDragPayload} for why each surface owns one. */ const DRAG_ROW_MIME = 'application/x-sim-foldered-row' -const DRAG_GHOST_STYLE = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' - /** Shared empty set so an idle drag state keeps a stable identity across renders. */ const EMPTY_ROW_IDS = new Set() +/** Rows carried by one drag, already split by kind and stripped of no-op moves. */ +export interface FolderedRowMove { + folderIds: string[] + resourceIds: string[] +} + export interface UseFolderRowDragDropOptions { /** Drag and drop are edits; a reader gets neither draggable rows nor drop targets. */ canEdit: boolean @@ -29,10 +37,37 @@ export interface UseFolderRowDragDropOptions { getResourceFolderId: (resourceId: string) => string | null | undefined /** Label shown in the drag ghost. */ getRowLabel: (rowId: string) => string - /** Reparents a folder into `targetFolderId`. */ - onMoveFolder: (folderId: string, targetFolderId: string) => void - /** Files a resource into `targetFolderId`. */ - onMoveResource: (resourceId: string, targetFolderId: string) => void + /** + * Moves every row of the drag into `targetFolderId` in one call (`null` is the workspace + * root). Rows already sitting directly in the target are filtered out before this fires, and + * it is never called with both lists empty — so the consumer maps it straight onto its + * bulk-move operations. + */ + onMoveRows: (rows: FolderedRowMove, targetFolderId: string | null) => void + /** + * Checkbox selection, when the list has one. Dragging a selected row carries the whole + * selection; dragging an unselected row collapses the selection onto it first, matching + * every file manager. Omit on a list without selection to keep drags single-row. + */ + selection?: { + selectedRowIds: Set + /** Row ids in display order, so the drag carries them in the order they are read. */ + visibleRowIds: string[] + /** Collapses the selection onto a single row dragged from outside it. */ + replaceSelection: (rowIds: string[]) => void + } + /** + * Opens a folder the drag has rested on, so the user can file into a nested folder without + * dropping first. Forward `options` to the folder-navigation setter so one drag leaves one + * back-stack entry. Omit to disable spring-loading. See {@link useSpringNavigation}. + */ + onSpringOpenFolder?: (folderId: string | null, options: SpringOpenOptions) => void + /** + * The folder the list is currently showing (`null` at the workspace root). Enables dropping + * onto the list body to file into it — the only way to land a drag that spring-opened into an + * empty folder, which has no row to drop on. + */ + currentFolderId?: string | null } /** @@ -40,9 +75,9 @@ export interface UseFolderRowDragDropOptions { * Tables behave exactly like Files: only folder rows accept a drop, a folder cannot land in * itself or its own subtree, and a row already sitting directly in the target is a no-op. * - * Single-row only, which is what the resource lists that use it support. The Files page - * keeps its own configuration because it additionally drags multi-selections and accepts - * external OS file drops. + * Carries a whole checkbox selection when `selection` is supplied, and a single row otherwise. + * The Files page keeps its own configuration because it additionally accepts external OS file + * drops, which need a second drag protocol this hook deliberately does not know about. */ export function useFolderRowDragDrop({ canEdit, @@ -51,62 +86,104 @@ export function useFolderRowDragDrop({ getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, + onSpringOpenFolder, + currentFolderId = null, }: UseFolderRowDragDropOptions): RowDragDropConfig { const [activeDropTargetId, setActiveDropTargetId] = useState(null) + const [isBodyDropActive, setIsBodyDropActive] = useState(false) + const [activeBreadcrumbIndex, setActiveBreadcrumbIndex] = useState(null) const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_ROW_IDS) /** * The in-flight drag source, mirrored outside React state because `onDragOver` fires far * faster than a re-render and must decide drop validity against the current source * synchronously. */ - const draggedRowIdRef = useRef(null) - const dragGhostRef = useRef(null) + const draggedRowIdsRef = useRef([]) const optionsRef = useRef({ descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, }) optionsRef.current = { descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, } + const springNav = useSpringNavigation({ currentFolderId, onNavigate: onSpringOpenFolder }) + + const currentFolderIdRef = useRef(currentFolderId) + currentFolderIdRef.current = currentFolderId + + const dragGhost = useRowDragGhost() + + /** Returns the list to its resting state once a drag is over, however it ended. */ + const endDrag = useCallback(() => { + springNav.end() + dragGhost.remove() + draggedRowIdsRef.current = [] + setDraggedRowIds(EMPTY_ROW_IDS) + setActiveDropTargetId(null) + setIsBodyDropActive(false) + setActiveBreadcrumbIndex(null) + }, [dragGhost, springNav]) + + useDragTeardown(endDrag) + /** - * The ghost lives on `document.body`, but the only thing that removes it is `dragend`, which - * fires on the SOURCE ROW. `Resource.Table` is virtualized, so scrolling the source out of - * view mid-drag unmounts that row and the event never arrives — leaving the ghost stuck on - * the page and every row frozen at drag opacity. Clean up on unmount as the backstop. + * Splits the drag into the rows that would actually move into `targetFolderId`, dropping any + * row already sitting directly there. `null` when the drop is illegal outright — the target is + * one of the dragged folders or inside one, which would orphan a subtree into itself — or when + * nothing would actually change. + * + * Takes a folder id rather than a row id because the destination is not always a row: the + * list body files into the folder currently open, which has no row of its own, and `null` + * addresses the workspace root. */ - useEffect( - () => () => { - dragGhostRef.current?.remove() - dragGhostRef.current = null + const resolveMoveToFolder = useCallback( + (targetFolderId: string | null, sourceRowIds: string[]): FolderedRowMove | null => { + const { descendantsByFolderId, getFolderParentId, getResourceFolderId } = optionsRef.current + const folderIds: string[] = [] + const resourceIds: string[] = [] + + for (const sourceRowId of sourceRowIds) { + const source = parseFolderedRowId(sourceRowId) + if (source.kind === 'folder') { + if (source.id === targetFolderId) return null + if (targetFolderId !== null && descendantsByFolderId.get(source.id)?.has(targetFolderId)) + return null + if ((getFolderParentId(source.id) ?? null) === targetFolderId) continue + folderIds.push(source.id) + continue + } + if ((getResourceFolderId(source.id) ?? null) === targetFolderId) continue + resourceIds.push(source.id) + } + + if (folderIds.length === 0 && resourceIds.length === 0) return null + return { folderIds, resourceIds } }, [] ) - const isInvalidDropTarget = useCallback((targetRowId: string, sourceRowId: string) => { - const target = parseFolderedRowId(targetRowId) - if (target.kind !== 'folder') return true - - const source = parseFolderedRowId(sourceRowId) - if (source.kind === 'folder') { - if (source.id === target.id) return true - if (optionsRef.current.descendantsByFolderId.get(source.id)?.has(target.id)) return true - return (optionsRef.current.getFolderParentId(source.id) ?? null) === target.id - } - return (optionsRef.current.getResourceFolderId(source.id) ?? null) === target.id - }, []) + /** Row-targeted drop: only a folder row can receive one. */ + const resolveMove = useCallback( + (targetRowId: string, sourceRowIds: string[]): FolderedRowMove | null => { + const target = parseFolderedRowId(targetRowId) + if (target.kind !== 'folder') return null + return resolveMoveToFolder(target.id, sourceRowIds) + }, + [resolveMoveToFolder] + ) return useMemo( () => ({ @@ -121,29 +198,31 @@ export function useFolderRowDragDrop({ return } - draggedRowIdRef.current = rowId - setDraggedRowIds(new Set([rowId])) + springNav.rememberOrigin() + const { selection } = optionsRef.current + /** + * Read the selection in display order rather than insertion order, so a shift-range + * drag carries its rows the way the user sees them. + */ + const sourceRowIds = selection?.selectedRowIds.has(rowId) + ? selection.visibleRowIds.filter((visibleRowId) => + selection.selectedRowIds.has(visibleRowId) + ) + : [rowId] + if (selection && !selection.selectedRowIds.has(rowId)) selection.replaceSelection([rowId]) + + draggedRowIdsRef.current = sourceRowIds + setDraggedRowIds(new Set(sourceRowIds)) e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData(DRAG_ROW_MIME, rowId) - e.dataTransfer.setData('text/plain', rowId) - - const ghost = document.createElement('div') - ghost.style.cssText = DRAG_GHOST_STYLE - const text = document.createElement('span') - text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' - text.textContent = optionsRef.current.getRowLabel(rowId) - ghost.appendChild(text) - document.body.appendChild(ghost) - // Force a layout pass so the drag image is measurable before it is captured. - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost + writeRowDragPayload(e.dataTransfer, DRAG_ROW_MIME, sourceRowIds) + + dragGhost.attach(e, optionsRef.current.getRowLabel(sourceRowIds[0]), sourceRowIds.length) }, onDragOver: (e: DragEvent, rowId) => { - const sourceRowId = draggedRowIdRef.current - if (sourceRowId) { - if (isInvalidDropTarget(rowId, sourceRowId)) return + const sourceRowIds = draggedRowIdsRef.current + if (sourceRowIds.length > 0) { + if (!resolveMove(rowId, sourceRowIds)) return } else if (!e.dataTransfer.types.includes(DRAG_ROW_MIME)) { /** * No local source and no payload of ours — an external or foreign drag. Returning @@ -165,38 +244,150 @@ export function useFolderRowDragDrop({ * would light up as a valid target — including the dragged folder itself and its own * descendants — and the drop would then silently do nothing. */ - if (sourceRowId) setActiveDropTargetId(rowId) + if (sourceRowIds.length > 0) { + setActiveDropTargetId(rowId) + /** + * The row is inside the scroll container, so moving onto it fires `dragleave` there + * with a contained `relatedTarget` — which that handler deliberately ignores. Without + * clearing here the row and the body would both render as the target at once. + */ + setIsBodyDropActive(false) + setActiveBreadcrumbIndex(null) + /** + * Armed on the same condition as the highlight, so a folder only springs open where a + * drop was already possible. A folder the drag cannot legally enter never opens. + */ + springNav.arm(parseFolderedRowId(rowId).id) + } }, onDragLeave: (e: DragEvent, rowId) => { const relatedTarget = e.relatedTarget if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + springNav.disarm() setActiveDropTargetId((current) => (current === rowId ? null : current)) }, onDrop: (e: DragEvent, rowId) => { e.preventDefault() e.stopPropagation() - setActiveDropTargetId(null) const target = parseFolderedRowId(rowId) - if (target.kind !== 'folder') return - // Prefer the dataTransfer payload over the ref so a drag that started in another - // mount of this page still resolves to a real row id. - const sourceRowId = e.dataTransfer.getData(DRAG_ROW_MIME) || draggedRowIdRef.current - if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return + // mount of this page still resolves to real row ids. + const sourceRowIds = + readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current + const move = + target.kind === 'folder' && sourceRowIds.length > 0 + ? resolveMove(rowId, sourceRowIds) + : null + if (move) springNav.markDropHandled() - const source = parseFolderedRowId(sourceRowId) - if (source.kind === 'folder') optionsRef.current.onMoveFolder(source.id, target.id) - else optionsRef.current.onMoveResource(source.id, target.id) + /** + * Ends the drag here rather than leaving it to `dragend`. This handler stops + * propagation, so the window-level backstop never sees this drop, and the source row + * may already have unmounted — after a spring-open it always has. + */ + endDrag() + + if (move) optionsRef.current.onMoveRows(move, target.id) }, - onDragEnd: () => { - dragGhostRef.current?.remove() - dragGhostRef.current = null - draggedRowIdRef.current = null - setDraggedRowIds(EMPTY_ROW_IDS) - setActiveDropTargetId(null) + onDragEnd: endDrag, + /** + * The breadcrumb is how a drag walks back UP. Spring-loading only ever goes deeper, so + * without this a drag that entered a folder can only leave it by being abandoned. + * Hovering a crumb navigates to it on the same timer a folder row uses, and releasing on + * one files the drag there directly. + */ + breadcrumb: { + activeIndex: activeBreadcrumbIndex, + onDragOver: (e: DragEvent, folderId: string | null, index: number) => { + const sourceRowIds = draggedRowIdsRef.current + const canDrop = + sourceRowIds.length > 0 && resolveMoveToFolder(folderId, sourceRowIds) !== null + /** + * Armed even when the drop itself would be a no-op — walking back through a crumb the + * rows already live in is exactly how a user returns to where they started, and + * refusing to navigate there would strand them. + */ + if (sourceRowIds.length > 0 && folderId !== currentFolderIdRef.current) { + springNav.arm(folderId) + } + setActiveBreadcrumbIndex(canDrop ? index : null) + setIsBodyDropActive(false) + if (!canDrop) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (_e: DragEvent, index: number) => { + springNav.disarm() + setActiveBreadcrumbIndex((current) => (current === index ? null : current)) + }, + onDrop: (e: DragEvent, folderId: string | null) => { + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current + const move = sourceRowIds.length > 0 ? resolveMoveToFolder(folderId, sourceRowIds) : null + if (move) springNav.markDropHandled() + endDrag() + if (move) optionsRef.current.onMoveRows(move, folderId) + }, + }, + body: { + isActive: isBodyDropActive, + onDragOver: (e: DragEvent) => { + const sourceRowIds = draggedRowIdsRef.current + /** + * Recomputed on every event rather than latched, because a spring-open changes the + * destination mid-drag: the folder just entered may not accept this drag, and an + * early return would leave the body overlay showing from the previous folder. Setting + * the same value repeatedly is free — React bails on an unchanged state write. + */ + const canDrop = + sourceRowIds.length > 0 && + resolveMoveToFolder(currentFolderIdRef.current, sourceRowIds) !== null + setIsBodyDropActive(canDrop) + if (!canDrop) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (e: DragEvent) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setIsBodyDropActive(false) + }, + onDrop: (e: DragEvent) => { + e.preventDefault() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current + e.stopPropagation() + /** + * Read from the ref, not the closure. This config is memoized, and during a drag the + * only dep that routinely changes is the hovered row — so after a spring-open into an + * empty folder, which has no rows to hover, a captured `currentFolderId` would still + * name the folder the drag came FROM and file the rows back into it. + */ + const targetFolderId = currentFolderIdRef.current + const move = + sourceRowIds.length > 0 ? resolveMoveToFolder(targetFolderId, sourceRowIds) : null + if (move) springNav.markDropHandled() + endDrag() + if (move) optionsRef.current.onMoveRows(move, targetFolderId) + }, }, }), - [activeDropTargetId, draggedRowIds, canEdit, editingRowId, isInvalidDropTarget] + [ + activeDropTargetId, + isBodyDropActive, + activeBreadcrumbIndex, + draggedRowIds, + canEdit, + editingRowId, + resolveMove, + resolveMoveToFolder, + springNav, + endDrag, + dragGhost, + ] ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx new file mode 100644 index 00000000000..6aa96a1d3e3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { + type RowDragGhost, + useRowDragGhost, +} from '@/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost' + +const mountedRoots: Root[] = [] + +function renderGhost() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + let result: RowDragGhost | undefined + + function Probe() { + result = useRowDragGhost() + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + document.body.innerHTML = '' +}) + +describe('useRowDragGhost', () => { + it('keeps a stable handle identity across renders', () => { + // Consumers list this handle in the deps of the `endDrag` callback that `useDragTeardown` + // binds and the drag config memo depends on. A fresh object per render makes `endDrag` + // unstable and rebuilds the whole drag config every render. + const ghost = renderGhost() + + const before = ghost.get() + ghost.rerender() + ghost.rerender() + + expect(ghost.get()).toBe(before) + }) + + it('removes the ghost node on unmount', () => { + const ghost = renderGhost() + const dataTransfer = { setDragImage: () => {} } as unknown as DataTransfer + + act(() => { + ghost.get().attach({ dataTransfer } as never, 'Report.md', 1) + }) + expect(document.body.children.length).toBe(1) + + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + expect(document.body.children.length).toBe(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts new file mode 100644 index 00000000000..ee37ca45a8c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts @@ -0,0 +1,66 @@ +'use client' + +import type { DragEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' + +/** + * Inline chrome for the drag image. It is set on a detached DOM node handed to `setDragImage`, + * so it cannot be a Tailwind class list. + * + * `font-family` is deliberately absent: the node is appended to ``, so omitting it lets + * the label inherit the app font and match the row it was lifted from. + */ +const DRAG_GHOST_STYLE = + 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' + +const DRAG_GHOST_LABEL_STYLE = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' + +export interface RowDragGhost { + /** Builds the drag image for this drag and attaches it to the event. */ + attach: (e: DragEvent, label: string, count: number) => void + /** Removes the ghost node. Safe to call when none is attached. */ + remove: () => void +} + +/** + * The drag image shown while dragging resource rows — the first row's label, plus a count when + * the drag carries a multi-row selection. + * + * Shared so every foldered list lifts rows the same way. The node lives on `document.body` + * rather than in the React tree because `setDragImage` snapshots a real, laid-out element; the + * unmount cleanup is the backstop for a drag whose source row disappears before it ends. + */ +export function useRowDragGhost(): RowDragGhost { + const ghostRef = useRef(null) + + const remove = useCallback(() => { + ghostRef.current?.remove() + ghostRef.current = null + }, []) + + useEffect(() => remove, [remove]) + + const attach = useCallback((e: DragEvent, label: string, count: number) => { + const ghost = document.createElement('div') + ghost.style.cssText = DRAG_GHOST_STYLE + const text = document.createElement('span') + text.style.cssText = DRAG_GHOST_LABEL_STYLE + text.textContent = count > 1 ? `${label} +${count - 1} more` : label + ghost.appendChild(text) + document.body.appendChild(ghost) + // Force a layout pass so the drag image is measurable before it is captured. + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + ghostRef.current = ghost + }, []) + + /** + * Stable identity, not a fresh object per render — the same requirement + * {@link useSpringLoadedFolder} documents. Consumers list this handle in the deps of the + * `endDrag` callback that `useDragTeardown` binds and that the drag-config memo depends on, + * so a new object each render makes `endDrag` unstable and rebuilds the whole drag config + * every render, re-rendering every memoized row. The inner callbacks are already stable, so + * this memo never invalidates. + */ + return useMemo(() => ({ attach, remove }), [attach, remove]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx new file mode 100644 index 00000000000..fd20ab72c35 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx @@ -0,0 +1,225 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SPRING_LOAD_DELAY_MS, + type SpringLoadedFolder, + type SpringOpenOptions, + useSpringLoadedFolder, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' + +const mountedRoots: Root[] = [] + +interface SpringLoadHarness { + get: () => SpringLoadedFolder + /** Re-renders the probe with a new inline callback, as a parent re-render would. */ + rerender: () => void +} + +function renderSpringLoad( + onSpringOpen: (folderId: string, options: SpringOpenOptions) => void +): SpringLoadHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + let result: SpringLoadedFolder | undefined + + function Probe() { + // A fresh arrow each render, mirroring how every real consumer passes this. + result = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => onSpringOpen(folderId, options), + }) + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +/** Advances past the spring delay, flushing the timer callback inside React's act scope. */ +function rest(ms = SPRING_LOAD_DELAY_MS) { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useSpringLoadedFolder', () => { + it('opens the folder after the drag rests on it', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 1) + expect(onSpringOpen).not.toHaveBeenCalled() + + rest(1) + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-a', { history: 'push' }) + }) + + it('does not restart the countdown while the drag stays on one folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + // `dragover` fires continuously; re-arming the same folder must not push the deadline back. + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 100) + act(() => harness.get().arm('folder-a')) + rest(100) + + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-a', { history: 'push' }) + }) + + it('restarts the countdown when the drag moves to another folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 100) + act(() => harness.get().arm('folder-b')) + rest(100) + expect(onSpringOpen).not.toHaveBeenCalled() + + rest() + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-b', { history: 'push' }) + }) + + it('cancels the pending open when the drag leaves the row', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + act(() => harness.get().disarm()) + rest() + + expect(onSpringOpen).not.toHaveBeenCalled() + }) + + it('opens a folder at most once per drag', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + expect(onSpringOpen).toHaveBeenCalledTimes(1) + + // Dragging back out and returning must not re-open it, which would loop at a boundary. + act(() => harness.get().arm('folder-b')) + act(() => harness.get().arm('folder-a')) + rest() + expect(onSpringOpen).toHaveBeenCalledTimes(1) + }) + + it('pushes the first spring-open of a drag and replaces the rest', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().arm('folder-b')) + rest() + + // One gesture, one back-stack entry: Back returns to where the drag started rather than + // replaying every level it passed through, and without eating the entry it started on. + expect(onSpringOpen.mock.calls).toEqual([ + ['folder-a', { history: 'push' }], + ['folder-b', { history: 'replace' }], + ]) + + // A new drag is a new gesture, so its first open pushes again. + act(() => harness.get().reset()) + act(() => harness.get().arm('folder-c')) + rest() + expect(onSpringOpen).toHaveBeenLastCalledWith('folder-c', { history: 'push' }) + }) + + it('keeps a stable handle identity across renders', () => { + // Regression guard: consumers feed this handle into a `useCallback` that a drag-lifecycle + // effect depends on. A fresh object per render re-runs that effect continuously, which tore + // down in-flight drags and silently disabled spring-loading entirely. + const harness = renderSpringLoad(vi.fn()) + + const before = harness.get() + harness.rerender() + harness.rerender() + + expect(harness.get()).toBe(before) + }) + + it('allows the same folder again after the drag ends', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().reset()) + + act(() => harness.get().arm('folder-a')) + rest() + expect(onSpringOpen).toHaveBeenCalledTimes(2) + }) + + it('springs to the workspace root, which a breadcrumb targets as null', () => { + // Walking a drag back UP goes through the breadcrumb, whose first crumb is the root — so + // null has to be a real destination here, distinct from "nothing armed". + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm(null)) + rest() + + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith(null, { history: 'push' }) + }) + + it('opens the root at most once per drag, like any other folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm(null)) + rest() + act(() => harness.get().arm('folder-a')) + act(() => harness.get().arm(null)) + rest() + + expect(onSpringOpen).toHaveBeenCalledTimes(1) + }) + + it('never opens a folder after unmount', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + rest() + + expect(onSpringOpen).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts new file mode 100644 index 00000000000..c4d07122d7f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts @@ -0,0 +1,123 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef } from 'react' + +/** + * How long a drag must rest on a folder before it opens. + * + * Deliberately slower than the workflow sidebar's 400ms hover-to-expand: that one opens a tree + * node in place and is trivially reversible, while this one navigates the whole list view out + * from under the drag. At 700ms a drag merely crossing a folder on its way elsewhere kept + * triggering it; the cost of waiting is far lower than the cost of an unwanted navigation. + */ +export const SPRING_LOAD_DELAY_MS = 1000 + +/** How a spring-open writes the newly opened folder to the browser history. */ +export interface SpringOpenOptions { + history: 'push' | 'replace' +} + +export interface UseSpringLoadedFolderOptions { + /** + * Opens the folder mid-drag. The drag continues in the newly opened folder. + * + * `options.history` is `'push'` for the first folder a drag opens and `'replace'` for every + * one after, so one gesture leaves exactly one back-stack entry and Back returns to the + * folder the drag started in. Pushing every level would record folders the user only rested + * over while deciding where to drop; replacing every level would overwrite the entry they + * were actually standing on, so Back would leave the page instead of returning to it. + */ + onSpringOpen: (folderId: string | null, options: SpringOpenOptions) => void + delayMs?: number +} + +export interface SpringLoadedFolder { + /** + * Starts (or continues) the timer for `folderId`. Safe to call on every `dragover`, which + * fires continuously: re-arming the folder already being timed does not restart it, so the + * countdown reflects how long the drag has actually rested there. + */ + arm: (folderId: string | null) => void + /** Cancels the pending open — the drag left the row, or the row stopped being a valid target. */ + disarm: () => void + /** Cancels the pending open and forgets which folders already opened. Call when the drag ends. */ + reset: () => void +} + +/** + * Spring-loaded folders: resting a drag on a folder row opens it, so a resource can be filed + * into a nested folder in one gesture instead of being dropped, navigated, and dragged again. + * + * The dragged rows unmount when the list re-renders into the newly opened folder, which is why + * the drag payload has to live in `dataTransfer` rather than only in the source row's state. + * + * A folder opens at most once per drag. Without that, dragging back out to a parent and + * returning would re-open it on a loop, and a drag that rests near a boundary would flicker + * between two levels. + */ +export function useSpringLoadedFolder({ + onSpringOpen, + delayMs = SPRING_LOAD_DELAY_MS, +}: UseSpringLoadedFolderOptions): SpringLoadedFolder { + const timerRef = useRef | null>(null) + /** + * Folder the timer is counting down for, so re-arming it is a no-op. `undefined` means + * nothing is armed — `null` is a real destination here, the workspace root. + */ + const armedFolderIdRef = useRef(undefined) + /** Folders already opened during this drag; each may only spring once. */ + const openedFolderIdsRef = useRef | null>(null) + const openedFolderIds = (openedFolderIdsRef.current ??= new Set()) + + const onSpringOpenRef = useRef(onSpringOpen) + onSpringOpenRef.current = onSpringOpen + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + timerRef.current = null + armedFolderIdRef.current = undefined + }, []) + + /** A drag can outlive the list that started it; never leave a timer pointing at a dead tree. */ + useEffect(() => clearTimer, [clearTimer]) + + const arm = useCallback( + (folderId: string | null) => { + if (armedFolderIdRef.current === folderId) return + + /** + * The drag has moved to a different row, so any countdown started on the previous one is + * stale — cancel it before deciding whether this row can spring. Returning early without + * this would let the folder the drag just left open behind the cursor. + */ + clearTimer() + if (openedFolderIds.has(folderId)) return + + armedFolderIdRef.current = folderId + timerRef.current = setTimeout(() => { + timerRef.current = null + armedFolderIdRef.current = undefined + /** Read before the add: an empty set means nothing has opened in this drag yet. */ + const isFirstOpenOfDrag = openedFolderIds.size === 0 + openedFolderIds.add(folderId) + onSpringOpenRef.current(folderId, { + history: isFirstOpenOfDrag ? 'push' : 'replace', + }) + }, delayMs) + }, + [clearTimer, delayMs, openedFolderIds] + ) + + const reset = useCallback(() => { + clearTimer() + openedFolderIds.clear() + }, [clearTimer, openedFolderIds]) + + /** + * Stable identity, not a fresh object per render. Consumers feed this handle into a + * `useCallback` that a drag-lifecycle effect depends on; a new object each render re-runs + * that effect continuously, and its cleanup then tears down the drag that is still in + * progress. The inner callbacks are already stable, so this memo never invalidates. + */ + return useMemo(() => ({ arm, disarm: clearTimer, reset }), [arm, clearTimer, reset]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx new file mode 100644 index 00000000000..8b49b7fc3c2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx @@ -0,0 +1,179 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SPRING_LOAD_DELAY_MS } from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' +import { + type SpringNavigation, + useSpringNavigation, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-navigation' + +const mountedRoots: Root[] = [] + +/** + * Drives the hook the way a drag does, re-rendering with the folder the navigation callback + * just moved to — the hook compares the origin against the *current* folder, so a probe that + * never advances would never exercise the return path. + */ +function renderSpringNavigation(startFolderId: string | null) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + const navigate = vi.fn<(folderId: string | null, history: 'push' | 'replace') => void>() + let currentFolderId = startFolderId + let result: SpringNavigation | undefined + + function Probe({ folderId }: { folderId: string | null }) { + result = useSpringNavigation({ + currentFolderId: folderId, + onNavigate: (nextFolderId, options) => { + navigate(nextFolderId, options.history) + currentFolderId = nextFolderId + }, + }) + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + navigate, + currentFolderId: () => currentFolderId, + } +} + +/** + * Advances past the spring delay, then re-renders with the folder the navigation moved to — + * the real list does the same, and the hook compares the origin against the *current* folder, + * so a probe stuck on the old one would never exercise the return path. + */ +function rest(harness: { rerender: () => void }) { + act(() => { + vi.advanceTimersByTime(SPRING_LOAD_DELAY_MS) + }) + harness.rerender() +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useSpringNavigation', () => { + it('opens a folder the drag rests on', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push') + }) + + it('returns to the origin when the drag ends without a drop', () => { + // The whole point: spring-loading only goes deeper, so a cancelled drag would otherwise + // strand the user in a folder they never chose to open. + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate).toHaveBeenLastCalledWith(null, 'replace') + expect(nav.currentFolderId()).toBeNull() + }) + + it('stays put when a drop actually landed', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().markDropHandled()) + act(() => nav.get().end()) + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push') + expect(nav.currentFolderId()).toBe('folder-a') + }) + + it('returns in one hop from several levels deep', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().arm('folder-b')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-a', 'push'], + ['folder-b', 'replace'], + [null, 'replace'], + ]) + expect(nav.currentFolderId()).toBeNull() + }) + + it('returns to a subfolder origin, not the root', () => { + const nav = renderSpringNavigation('origin-folder') + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.currentFolderId()).toBe('origin-folder') + }) + + it('does not navigate when the drag never opened anything', () => { + const nav = renderSpringNavigation('origin-folder') + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().end()) + + expect(nav.navigate).not.toHaveBeenCalled() + }) + + it('does not carry drop state into the next drag', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().markDropHandled()) + act(() => nav.get().end()) + nav.navigate.mockClear() + + // A second drag that lands nowhere must still return, despite the first one having dropped. + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-b')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-b', 'push'], + ['folder-a', 'replace'], + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts new file mode 100644 index 00000000000..adbe048d26c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts @@ -0,0 +1,120 @@ +'use client' + +import { useCallback, useMemo, useRef } from 'react' +import { + type SpringOpenOptions, + useSpringLoadedFolder, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' + +export interface UseSpringNavigationOptions { + /** The folder the list is currently showing (`null` at the workspace root). */ + currentFolderId: string | null + /** + * Navigates the list. Called both to spring a folder open mid-drag and to return to where the + * drag began. Omit to disable spring navigation entirely. + */ + onNavigate?: (folderId: string | null, options: SpringOpenOptions) => void +} + +export interface SpringNavigation { + /** Records where this drag began. Call from `dragstart`. */ + rememberOrigin: () => void + /** Starts (or continues) the timer that opens `folderId`. Safe to call on every `dragover`. */ + arm: (folderId: string | null) => void + /** Cancels a pending open — the drag left the target. */ + disarm: () => void + /** Marks that a drop actually moved something, so the destination stays on screen. */ + markDropHandled: () => void + /** Ends the drag, returning to the origin when the spring-opens went unused. */ + end: () => void +} + +/** + * Spring-loaded folder navigation for a drag, including the way back out. + * + * Opening a folder mid-drag is only half a gesture. A drag that springs its way several levels + * deep and is then cancelled — or dropped somewhere else — would otherwise leave the user in a + * folder they never chose to open, looking at a list they did not ask for. So the navigation is + * treated as part of the drag: unless a drop actually landed, ending the drag returns to where + * it started. The workflow sidebar collapses its own spring-opened folders for the same reason. + * + * Shared by every foldered list. Files keeps its own drag configuration for OS file drops, but + * this lifecycle is identical everywhere. + */ +export function useSpringNavigation({ + currentFolderId, + onNavigate, +}: UseSpringNavigationOptions): SpringNavigation { + const currentFolderIdRef = useRef(currentFolderId) + currentFolderIdRef.current = currentFolderId + + const onNavigateRef = useRef(onNavigate) + onNavigateRef.current = onNavigate + + const originFolderIdRef = useRef(null) + /** Whether this drag has an origin yet. A drag of OS files fires no `dragstart` of ours. */ + const hasOriginRef = useRef(false) + const didSpringOpenRef = useRef(false) + const dropHandledRef = useRef(false) + + const springLoad = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => { + didSpringOpenRef.current = true + onNavigateRef.current?.(folderId, options) + }, + }) + + const rememberOrigin = useCallback(() => { + originFolderIdRef.current = currentFolderIdRef.current + hasOriginRef.current = true + }, []) + + /** + * Seeds the origin for a drag that never reached {@link SpringNavigation.rememberOrigin} — a + * drag of OS files starts outside the page, so there is no `dragstart` of ours to record it. + * Without this the return lands on whatever folder the PREVIOUS drag began in. + */ + const arm = useCallback( + (folderId: string | null) => { + if (!hasOriginRef.current) { + originFolderIdRef.current = currentFolderIdRef.current + hasOriginRef.current = true + } + springLoad.arm(folderId) + }, + [springLoad.arm] + ) + + const markDropHandled = useCallback(() => { + dropHandledRef.current = true + }, []) + + const end = useCallback(() => { + /** + * `replace`, not `push`: the spring-opens are being undone, so they should leave no trace in + * the back stack rather than a trail the user has to walk back out of. + */ + if ( + didSpringOpenRef.current && + !dropHandledRef.current && + originFolderIdRef.current !== currentFolderIdRef.current + ) { + onNavigateRef.current?.(originFolderIdRef.current, { history: 'replace' }) + } + didSpringOpenRef.current = false + dropHandledRef.current = false + hasOriginRef.current = false + springLoad.reset() + }, [springLoad]) + + return useMemo( + () => ({ + rememberOrigin, + arm, + disarm: springLoad.disarm, + markDropHandled, + end, + }), + [rememberOrigin, arm, springLoad.disarm, markDropHandled, end] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 16570ad0070..3aeaaeebbeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -4,8 +4,11 @@ export { ErrorShell, ErrorState } from './error' export { InlineRenameInput } from './inline-rename-input' export { IntegrationTabsHeader } from './integration-tabs-header' export { MessageActions } from './message-actions' +export type { BulkOutcome } from './resource/bulk-outcome' +export { reportBulkOutcome } from './resource/bulk-outcome' export { FloatingOverflowText } from './resource/components/floating-overflow-text' -export { ownerCell } from './resource/components/owner-cell' +export type { OwnerAvatarProps } from './resource/components/owner-cell' +export { OwnerAvatar, ownerCell } from './resource/components/owner-cell' export { type ChromeActionSpec, ResourceChromeFallback, @@ -24,7 +27,10 @@ export type { SearchTag, SortConfig, } from './resource/components/resource-options' -export { SortDropdown } from './resource/components/resource-options' +export { + FILTER_SECTION_LABEL_CLASS, + SortDropdown, +} from './resource/components/resource-options' export { timeCell } from './resource/components/time-cell' export type { PaginationConfig, @@ -37,5 +43,8 @@ export type { SelectableConfig, } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' +export { selectionLabel } from './resource/selection-label' +export type { ResourceRowSelection } from './resource/use-resource-row-selection' +export { useResourceRowSelection } from './resource/use-resource-row-selection' export { ResourceTile } from './resource-tile' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts new file mode 100644 index 00000000000..2d2817a8bd0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts @@ -0,0 +1,53 @@ +import { toast } from '@sim/emcn' + +/** An item the batch reached but could not act on, with a reason worth showing. */ +interface BulkFailure { + name: string + reason: string +} + +/** An id that resolved to nothing active — deleted, or not visible to this user. */ +interface BulkMissing { + id: string +} + +export interface BulkOutcome { + failed: BulkFailure[] + notFound: BulkMissing[] +} + +/** + * Reports the parts of a bulk operation that did not happen. + * + * A bulk request succeeds as a whole while individual items are refused (a delete lock, a + * folder cycle) or have vanished since the list was rendered. Those items are the difference + * between what the user selected and what actually changed, so they have to be said out loud — + * the list simply refetching leaves the user to notice a row survived. + * + * Success stays silent, matching the single-item move and delete paths. + * + * @param verb Past-tense verb for the failure sentence, e.g. `'moved'` or `'deleted'`. + */ +export function reportBulkOutcome(outcome: BulkOutcome, verb: string): void { + const { failed, notFound } = outcome + + if (failed.length > 0) { + const [first] = failed + toast.error( + failed.length === 1 + ? `${first.name} could not be ${verb}: ${first.reason}` + : `${failed.length} items could not be ${verb}. ${first.name}: ${first.reason}`, + { duration: 5000 } + ) + return + } + + if (notFound.length > 0) { + toast.error( + notFound.length === 1 + ? `One item was no longer available and was not ${verb}.` + : `${notFound.length} items were no longer available and were not ${verb}.`, + { duration: 5000 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx new file mode 100644 index 00000000000..afbe7593240 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -0,0 +1,163 @@ +'use client' + +import type { ComponentType } from 'react' +import { + Button, + chipFilledFillTokens, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Folder, + Tooltip, + Trash, +} from '@sim/emcn' +import { Download } from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' + +/** Shared chrome for every action button, so the bar reads as one control strip. */ +const ACTION_BUTTON_CLASS = cn( + chipFilledFillTokens, + 'hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]' +) + +interface ActionButtonProps { + icon: ComponentType<{ className?: string }> + label: string + onClick: () => void + disabled?: boolean +} + +function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProps) { + return ( + + + + + {label} + + ) +} + +export interface ResourceActionBarProps { + /** The bar is mounted only while this is above zero; it animates in and out on the edges. */ + selectedCount: number + /** Omit on lists with nothing to download (tables, knowledge bases). */ + onDownload?: () => void + /** Both `onMove` and `moveOptions` are required for the move menu to appear. */ + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] + onDelete?: () => void + /** Disables every action while a bulk mutation is in flight. */ + isLoading?: boolean + /** + * Largest selection the bulk endpoints accept. Past it the server rejects the whole request, + * so the bar says so and disables the actions rather than letting the user confirm something + * that cannot succeed. + */ + maxSelectable?: number + className?: string +} + +/** + * Floating bulk-action bar for a `Resource.Table` with checkbox selection, shared so Files, + * Tables, and Knowledge present the same strip in the same place. + * + * Actions are ordered to mirror the row context menu — move before delete, destructive last. + * Each action is opt-in: a list that cannot perform one simply omits its handler, and a reader + * omits the ones they lack permission for. + * + * The entrance is a CSS animation rather than framer-motion: this bar is reachable from three + * list pages, and an animation library on that path costs every one of them ~40 modules of page + * weight for one fade. The trade is that dismissal is instant — an exit animation needs presence + * tracking, which is the part that pulls the library back in. + */ +export function ResourceActionBar({ + selectedCount, + onDownload, + onMove, + moveOptions, + onDelete, + isLoading = false, + maxSelectable, + className, +}: ResourceActionBarProps) { + if (selectedCount === 0) return null + + const exceedsLimit = maxSelectable !== undefined && selectedCount > maxSelectable + const actionsDisabled = isLoading || exceedsLimit + + return ( +
+
+ + {exceedsLimit + ? `${selectedCount} selected · select ${maxSelectable} or fewer` + : `${selectedCount} selected`} + +
+ {onDownload && ( + + )} + {onMove && moveOptions && ( + + + + + + + + Move + + + {renderMoveOptions(moveOptions, onMove)} + + + )} + {onDelete && ( + + )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts new file mode 100644 index 00000000000..c2c5faaeb72 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts @@ -0,0 +1,2 @@ +export type { ResourceActionBarProps } from './action-bar' +export { ResourceActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts index fa102e05d3a..22f3365aa43 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts @@ -1 +1,2 @@ -export { ownerCell } from './owner-cell' +export type { OwnerAvatarProps } from './owner-cell' +export { OwnerAvatar, ownerCell } from './owner-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx index 23a845af1b6..ebbf3bb9e9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx @@ -2,12 +2,17 @@ import { memo } from 'react' import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' import type { WorkspaceMember } from '@/hooks/queries/workspace' -interface OwnerAvatarProps { +export interface OwnerAvatarProps { name: string image: string | null } -const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { +/** + * The canonical 14px workspace-member avatar — a photo, or the member's initial on a neutral + * disc. Shared so a member reads identically in a resource row's owner cell and in the + * owner/uploaded-by filter options on every list. + */ +export const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { if (image) { return ( void dropdownItems?: DropdownOption[] @@ -95,6 +103,19 @@ export interface ResourceAction { disabled?: boolean } +/** + * Makes breadcrumb crumbs drag destinations, so a drag can walk back up the tree it walked + * into. Hovering a crumb navigates to it after the same delay a folder row uses, and releasing + * on one files the drag there — the counterpart to spring-loading, which only ever goes deeper. + */ +export interface BreadcrumbDropConfig { + /** Index of the crumb currently under the drag, or `null`. Indexed because `null` is a folder. */ + activeIndex: number | null + onDragOver: (e: DragEvent, folderId: string | null, index: number) => void + onDragLeave: (e: DragEvent, index: number) => void + onDrop: (e: DragEvent, folderId: string | null) => void +} + interface ResourceHeaderProps { icon?: React.ElementType title?: string @@ -109,6 +130,7 @@ interface ResourceHeaderProps { * in `actions`; never stuff primary actions in here. */ aside?: ReactNode + breadcrumbDrop?: BreadcrumbDropConfig } export const ResourceHeader = memo(function ResourceHeader({ @@ -117,6 +139,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, actions, aside, + breadcrumbDrop, }: ResourceHeaderProps) { const headerRef = useRef(null) /** @@ -164,6 +187,22 @@ export const ResourceHeader = memo(function ResourceHeader({ */ const showLocationPopover = LocationIcon != null + /** + * Only a crumb that names a folder is a destination; a trailing detail segment + * has no `folderId` and stays inert. + */ + const crumbDrag = + breadcrumbDrop && crumb.folderId !== undefined + ? { + isActive: breadcrumbDrop.activeIndex === i, + onDragOver: (e: DragEvent) => + breadcrumbDrop.onDragOver(e, crumb.folderId as string | null, i), + onDragLeave: (e: DragEvent) => breadcrumbDrop.onDragLeave(e, i), + onDrop: (e: DragEvent) => + breadcrumbDrop.onDrop(e, crumb.folderId as string | null), + } + : undefined + return ( {i > 0 && ( @@ -177,6 +216,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs={breadcrumbs} className={segmentClassName} veilBoundaryRef={headerRef} + drag={crumbDrag} /> ) : ( )} @@ -269,6 +310,13 @@ interface BreadcrumbSegmentProps { dropdownItems?: DropdownOption[] editing?: BreadcrumbEditing className?: string + /** Drag handlers plus the active flag, when this crumb is a drag destination. */ + drag?: { + isActive: boolean + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void + onDrop: (e: DragEvent) => void + } } const BreadcrumbSegment = memo(function BreadcrumbSegment({ @@ -278,6 +326,7 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ dropdownItems, editing, className, + drag, }: BreadcrumbSegmentProps) { const { ref: labelRef, node: labelNode, isOverflowing } = useIsOverflowing() const { state: tooltipState, handlers: tooltipHandlers } = useFloatingTooltip((target) => @@ -318,7 +367,18 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ - @@ -345,8 +405,11 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
{search.tags?.length || search.value ? ( ) : null} {search.dropdown && (
{search.dropdown}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 87c463c3a63..0d52e9afc35 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -16,8 +16,11 @@ import { Button, Checkbox, cellIconNodeClass, + chipActiveSurfaceClass, chipContentGap, chipContentLabelClass, + chipDropTargetSurfaceClass, + chipHoverSurfaceClass, cn, Loader, } from '@sim/emcn' @@ -25,6 +28,7 @@ import { ChevronLeft, ChevronRight, Pin } from '@sim/emcn/icons' import { useVirtualizer } from '@tanstack/react-virtual' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import type { BreadcrumbDropConfig } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' @@ -83,6 +87,20 @@ export interface SelectableConfig { disabled?: boolean } +/** + * Drop onto the list body, which files into the folder currently open. + * + * Rows alone are not enough: a drag that spring-opens into an empty folder has nothing to land + * on, so without this the gesture dead-ends and the item cannot be moved there at all. + */ +export interface BodyDropConfig { + /** The drag is over the body and releasing would move something. */ + isActive: boolean + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void + onDrop: (e: DragEvent) => void +} + export interface RowDragDropConfig { activeDropTargetId?: string | null draggedRowIds?: Set @@ -94,6 +112,9 @@ export interface RowDragDropConfig { onDragLeave?: (e: DragEvent, rowId: string) => void onDrop?: (e: DragEvent, rowId: string) => void onDragEnd?: (e: DragEvent, rowId: string) => void + body?: BodyDropConfig + /** Passed to `Resource.Header` so the breadcrumb can receive the same drag. */ + breadcrumb?: BreadcrumbDropConfig } export interface PaginationConfig { @@ -291,6 +312,7 @@ const ResourceTable = memo(function ResourceTable({ }, [onLoadMore, hasMore]) const hasCheckbox = selectable != null + const bodyDrop = rowDragDrop?.body const handleSelectAll = useCallback( (checked: boolean | 'indeterminate') => { @@ -334,7 +356,13 @@ const ResourceTable = memo(function ResourceTable({ return (
-
+
)}
+ {bodyDrop?.isActive && ( + /** + * A soft tint over the whole list region, not a line around it. This is the workflow + * sidebar's own drop-inside affordance (`bg-[var(--text-subtle)] opacity-10`), and it + * is the right weight here: a hairline stretched around the entire pane reads as a + * window border rather than a drop target, and being painted at the scrollport edge it + * also got its corners shaved by the parent's `overflow-hidden`. A fill has no corners + * to clip and no edge to fight the surrounding chrome. + */ +
+ )} {overlay} {pagination && pagination.totalPages > 1 && ( 0 + /** Hover and active are mutually exclusive, so a selected row holds its surface through hover. */ + const isRowActive = selectedRowId === row.id || isSelected || isContextMenuTarget const handleClick = useCallback( (e: React.MouseEvent) => { @@ -664,16 +708,21 @@ const DataRow = memo(function DataRow({ className={cn( 'grid w-full transition-colors', isWindowed && 'absolute top-0 left-0', - !isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]', + !isAnyDragActive && !isRowActive && chipHoverSurfaceClass, onRowClick && 'cursor-pointer', isDraggable && 'cursor-grab active:cursor-grabbing', - isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]', - (selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]', - isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]', + isRowActive && chipActiveSurfaceClass, + /** + * Neutral, matching the workflow sidebar's own drop-inside affordance + * (`bg-[var(--text-subtle)] opacity-10` there, and `--text-subtle` for its reorder + * line). A brand colour here would be the only place in the app that signals "release + * here" with hue rather than weight. Drawn inside the row's own box + * (`outline-offset-[-1px]`) so the ring never overlaps the rows above and below. + */ + isActiveDropTarget && chipDropTargetSurfaceClass, (isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50' )} style={rowStyle} - data-drop-target={isDropTarget || undefined} draggable={isDraggable} onClick={onRowClick || selectable ? handleClick : undefined} onMouseEnter={handleMouseEnter} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts new file mode 100644 index 00000000000..fc812dbee55 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts @@ -0,0 +1,9 @@ +/** + * Names a multi-row selection for a confirmation prompt: one row reads as itself, several read + * as a count. Shared so the wording stays identical across every resource list — the phrasing + * appears in destructive confirms, where an inconsistency reads as a different action. + */ +export function selectionLabel(count: number, firstName: string | undefined): string { + if (count === 1) return firstName ?? 'selected item' + return `${count} selected items` +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx new file mode 100644 index 00000000000..827b8edc235 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx @@ -0,0 +1,173 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type ResourceRowSelection, + type UseResourceRowSelectionOptions, + useResourceRowSelection, +} from '@/app/workspace/[workspaceId]/components/resource/use-resource-row-selection' + +/** Trees rendered by a test, torn down in afterEach so listeners do not leak across tests. */ +const mountedRoots: Root[] = [] + +interface Harness { + getResult: () => ResourceRowSelection + /** Re-renders with new options, as a parent would when its rows change. */ + rerender: (options: UseResourceRowSelectionOptions) => void +} + +function renderSelection(initialOptions: UseResourceRowSelectionOptions): Harness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + mountedRoots.push(root) + let result: ResourceRowSelection | undefined + + function Probe({ options }: { options: UseResourceRowSelectionOptions }) { + result = useResourceRowSelection(options) + return null + } + + const render = (options: UseResourceRowSelectionOptions) => { + act(() => { + root.render() + }) + } + + render(initialOptions) + + return { + getResult: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +function pressKey(key: string, init: KeyboardEventInit = {}) { + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })) + }) +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + document.body.innerHTML = '' +}) + +const ROWS = ['a', 'b', 'c', 'd'] + +describe('useResourceRowSelection', () => { + it('adds and removes a single row', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('b', true)) + expect([...getResult().selectedRowIds]).toEqual(['b']) + + act(() => getResult().selectable.onSelectRow('b', false)) + expect(getResult().selectedRowIds.size).toBe(0) + }) + + it('extends a shift-click range from the last anchor', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('a', true)) + act(() => getResult().selectable.onSelectRow('c', true, true)) + + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'b', 'c']) + }) + + it('treats a shift-click with no anchor as a plain click', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('c', true, true)) + + expect([...getResult().selectedRowIds]).toEqual(['c']) + }) + + it('reports isAllSelected only once every visible row is selected', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'b', 'c', 'd']) + expect(getResult().selectable.isAllSelected).toBe(true) + + act(() => getResult().selectable.onSelectRow('b', false)) + expect(getResult().selectable.isAllSelected).toBe(false) + }) + + it('drops rows that are no longer visible', () => { + const { getResult, rerender } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + rerender({ visibleRowIds: ['a', 'c'] }) + + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'c']) + }) + + it('replaceSelection collapses onto the given rows and re-anchors a single row', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + act(() => getResult().replaceSelection(['b'])) + expect([...getResult().selectedRowIds]).toEqual(['b']) + + // 'b' became the anchor, so a shift-click on 'd' fills the range from there. + act(() => getResult().selectable.onSelectRow('d', true, true)) + expect([...getResult().selectedRowIds].sort()).toEqual(['b', 'c', 'd']) + }) + + it('selects every visible row on Cmd+A and clears on Escape', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(ROWS.length) + + pressKey('Escape') + expect(getResult().selectedRowIds.size).toBe(0) + }) + + it('calls onDeleteSelected for Delete only while rows are selected', () => { + const onDeleteSelected = vi.fn() + const { getResult } = renderSelection({ visibleRowIds: ROWS, onDeleteSelected }) + + pressKey('Delete') + expect(onDeleteSelected).not.toHaveBeenCalled() + + act(() => getResult().selectable.onSelectRow('a', true)) + pressKey('Delete') + expect(onDeleteSelected).toHaveBeenCalledTimes(1) + }) + + it('ignores shortcuts while blocked or while a text field has focus', () => { + const onDeleteSelected = vi.fn() + const blocked = { current: true } + const { getResult } = renderSelection({ + visibleRowIds: ROWS, + isKeyboardBlocked: () => blocked.current, + onDeleteSelected, + }) + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(0) + + blocked.current = false + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(0) + + input.blur() + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(ROWS.length) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts new file mode 100644 index 00000000000..a4852fcb709 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts @@ -0,0 +1,210 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { SelectableConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' + +/** Shared empty set so an empty selection keeps a stable identity across renders. */ +const EMPTY_ROW_IDS = new Set() + +/** Sentinel for "no shift-range anchor", so index 0 stays a usable anchor. */ +const NO_ANCHOR = -1 + +/** + * True while a text-entry surface owns the keystroke, so the list shortcuts never eat a + * character the user is typing into a rename field, a search box, or an editor. + */ +function isTypingTarget(): boolean { + const active = document.activeElement + if (!active) return false + return ( + active.tagName === 'INPUT' || + active.tagName === 'TEXTAREA' || + (active as HTMLElement).isContentEditable + ) +} + +export interface UseResourceRowSelectionOptions { + /** + * Row ids currently rendered, in display order. Selection is pruned to this list whenever it + * changes (navigating into a folder, applying a filter) and shift-ranges walk it, so it must + * be the same array identity across renders that do not change the rows. + */ + visibleRowIds: string[] + /** + * Blocks the keyboard shortcuts while another surface owns the keystroke — a detail view open + * over the list, an inline rename in progress, a modal. Text inputs are already excluded. + */ + isKeyboardBlocked?: () => boolean + /** Bound to Delete/Backspace on a non-empty selection. Omit to leave those keys unbound. */ + onDeleteSelected?: () => void +} + +export interface ResourceRowSelection { + selectedRowIds: Set + /** Passed straight to `Resource.Table`'s `selectable` prop. */ + selectable: SelectableConfig + /** Collapses the selection to exactly these rows, e.g. a plain row click or a drag start. */ + replaceSelection: (rowIds: Iterable) => void + clearSelection: () => void +} + +/** + * Checkbox selection for a `Resource.Table` list: click, shift-click ranges, select-all, and the + * Cmd/Ctrl+A · Escape · Delete shortcuts, shared so Files, Tables, and Knowledge select + * identically rather than each re-deriving the same state machine. + * + * Selection is keyed by *row* id, not resource id, so a foldered list can hold folder rows and + * resource rows in one selection; consumers split it back out with `parseFolderedRowId`. + */ +export function useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked, + onDeleteSelected, +}: UseResourceRowSelectionOptions): ResourceRowSelection { + const [selectedRowIds, setSelectedRowIds] = useState>(() => EMPTY_ROW_IDS) + + /** Anchor for shift-click ranges — an index into `visibleRowIds`, not a row id. */ + const anchorIndexRef = useRef(NO_ANCHOR) + + const visibleRowIdsRef = useRef(visibleRowIds) + visibleRowIdsRef.current = visibleRowIds + const isKeyboardBlockedRef = useRef(isKeyboardBlocked) + isKeyboardBlockedRef.current = isKeyboardBlocked + const onDeleteSelectedRef = useRef(onDeleteSelected) + onDeleteSelectedRef.current = onDeleteSelected + + const clearSelection = useCallback(() => { + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds((prev) => (prev.size === 0 ? prev : EMPTY_ROW_IDS)) + }, []) + + const replaceSelection = useCallback((rowIds: Iterable) => { + const next = new Set(rowIds) + /** + * A single row becomes the next shift anchor; a multi-row replacement has no meaningful + * anchor, so the following shift-click starts a fresh range instead of extending from a + * row the user never clicked. + */ + let anchor = NO_ANCHOR + if (next.size === 1) { + for (const rowId of next) anchor = visibleRowIdsRef.current.indexOf(rowId) + } + anchorIndexRef.current = anchor + setSelectedRowIds(next) + }, []) + + /** + * Rows that left the list — navigating into a folder, applying a filter — are gone as far as + * selection is concerned, otherwise a bulk action would silently operate on rows the user can + * no longer see. Compared by identity because `visibleRowIds` is memoized upstream and only + * changes when the rows really change. + */ + const prevVisibleRowIdsRef = useRef(visibleRowIds) + useEffect(() => { + if (prevVisibleRowIdsRef.current === visibleRowIds) return + /** + * Identity is only a cheap first test — it changes for reasons that are not list changes. + * Both foldered pages rebuild every row on each inline-rename keystroke (the edit value + * lives in the row memo), so a rename would otherwise clear the shift anchor mid-edit and + * the next shift-click would start a fresh range instead of extending the user's. + */ + const unchanged = + prevVisibleRowIdsRef.current.length === visibleRowIds.length && + prevVisibleRowIdsRef.current.every((rowId, index) => rowId === visibleRowIds[index]) + prevVisibleRowIdsRef.current = visibleRowIds + if (unchanged) return + anchorIndexRef.current = NO_ANCHOR + const visible = new Set(visibleRowIds) + setSelectedRowIds((prev) => { + if (prev.size === 0) return prev + const next = new Set() + for (const rowId of prev) if (visible.has(rowId)) next.add(rowId) + return next.size === prev.size ? prev : next + }) + }, [visibleRowIds]) + + /** + * The size check short-circuits the common case (a selection smaller than the list) in O(1); + * this runs on every render of the page, including each one a drag triggers. + */ + const isAllSelected = + visibleRowIds.length > 0 && + selectedRowIds.size >= visibleRowIds.length && + visibleRowIds.every((rowId) => selectedRowIds.has(rowId)) + + const selectable = useMemo( + () => ({ + selectedIds: selectedRowIds, + isAllSelected, + onSelectRow: (rowId, checked, shiftKey) => { + const currentIndex = visibleRowIds.indexOf(rowId) + if (shiftKey && anchorIndexRef.current !== NO_ANCHOR && currentIndex !== NO_ANCHOR) { + const start = Math.min(anchorIndexRef.current, currentIndex) + const end = Math.max(anchorIndexRef.current, currentIndex) + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) + return next + }) + anchorIndexRef.current = currentIndex + return + } + setSelectedRowIds((prev) => { + const next = new Set(prev) + if (checked) next.add(rowId) + else next.delete(rowId) + return next + }) + anchorIndexRef.current = checked ? currentIndex : NO_ANCHOR + }, + onSelectAll: (checked) => { + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (const rowId of visibleRowIds) { + if (checked) next.add(rowId) + else next.delete(rowId) + } + return next + }) + }, + disabled: false, + }), + [selectedRowIds, isAllSelected, visibleRowIds] + ) + + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isKeyboardBlockedRef.current?.()) return + if (isTypingTarget()) return + + const hasSelection = selectedRowIdsRef.current.size > 0 + + if ((e.key === 'Delete' || e.key === 'Backspace') && hasSelection) { + if (!onDeleteSelectedRef.current) return + e.preventDefault() + onDeleteSelectedRef.current() + return + } + + if (e.key === 'Escape' && hasSelection) { + e.preventDefault() + clearSelection() + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { + e.preventDefault() + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds(new Set(visibleRowIdsRef.current)) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [clearSelection]) + + return { selectedRowIds, selectable, replaceSelection, clearSelection } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx deleted file mode 100644 index 53ebe27ba84..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client' -import { - Button, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Folder, - Tooltip, - Trash, -} from '@sim/emcn' -import { Download } from '@sim/emcn/icons' -import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' -import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders' - -interface FilesActionBarProps { - selectedCount: number - onDownload?: () => void - onMove?: (optionValue: string) => void - moveOptions?: MoveOptionNode[] - onDelete?: () => void - isLoading?: boolean - className?: string -} - -export function FilesActionBar({ - selectedCount, - onDownload, - onMove, - moveOptions, - onDelete, - isLoading = false, - className, -}: FilesActionBarProps) { - return ( - - - {selectedCount > 0 && ( - -
- - {selectedCount} selected - -
- {onDownload && ( - - - - - Download - - )} - {onMove && moveOptions && ( - - - - - - - - Move - - - {moveOptions.length > 0 && ( - onMove(moveOptions[0].value)}> - - {moveOptions[0].label} - - )} - {moveOptions.length > 1 && } - {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} - - - )} - {onDelete && ( - - - - - Delete - - )} -
-
-
- )} -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts deleted file mode 100644 index aa19162a077..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FilesActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..6e43ec3d53f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -57,9 +57,13 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -67,14 +71,20 @@ import type { } from '@/app/workspace/[workspaceId]/components/folders' import { breadcrumbFolderChain, + buildDescendantIndex, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, folderedResourceListHref, parseMoveOptionValue, - ROOT_MOVE_OPTION_VALUE, + readRowDragPayload, sortResources, + useDragTeardown, + useRowDragGhost, + useSpringNavigation, + writeRowDragPayload, } from '@/app/workspace/[workspaceId]/components/folders' -import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -141,6 +151,15 @@ type FileListEntry = const logger = createLogger('Files') +/** + * Private drag payload for file rows, kept distinct from the foldered-list MIME so a drag + * started on Tables or Knowledge is never mistaken for one of these rows. + */ +const FILE_ROW_DRAG_MIME = 'application/x-sim-workspace-file-rows' + +/** Shared empty set so an idle drag state keeps a stable identity across renders. */ +const EMPTY_DRAGGED_ROW_IDS = new Set() + const FILES_HEADER = FOLDERED_RESOURCE_HEADERS.file const FOLDER_ICON = @@ -296,15 +315,28 @@ export function Files() { const justCreatedFileIdRef = useRef(null) const filesRef = useRef(files) filesRef.current = files + /** + * Indexed once. `isInvalidFolderTarget` resolves each dragged row's placement inside + * `dragover`, which fires continuously — a linear scan there is O(selection x resources) + * per event. + */ + const fileById = useMemo(() => { + const byId = new Map() + for (const file of files) byId.set(file.id, file) + return byId + }, [files]) + const fileByIdRef = useRef(fileById) + fileByIdRef.current = fileById const foldersRef = useRef(folders) foldersRef.current = folders - const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0, currentPercent: 0, }) + /** An upload batch is in flight exactly while a total is set — matches the Tables page. */ + const uploading = uploadProgress.total > 0 const [isDraggingOver, setIsDraggingOver] = useState(false) const dragCounterRef = useRef(0) const [ @@ -347,9 +379,10 @@ export function Files() { const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') - const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set()) const [activeDropTargetId, setActiveDropTargetId] = useState(null) - const [draggedRowIds, setDraggedRowIds] = useState>(() => new Set()) + const [isBodyDropActive, setIsBodyDropActive] = useState(false) + const [activeBreadcrumbIndex, setActiveBreadcrumbIndex] = useState(null) + const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_DRAGGED_ROW_IDS) const [previewMode, setPreviewMode] = useState(() => { if (isNewFile) return 'editor' if (fileIdFromRoute) { @@ -362,9 +395,7 @@ export function Files() { const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const contextMenuItemRef = useRef(null) - const lastSelectedIndexRef = useRef(-1) const draggedRowIdsRef = useRef([]) - const dragGhostRef = useRef(null) const [deleteTarget, setDeleteTarget] = useState<{ fileIds: string[] folderIds: string[] @@ -440,6 +471,8 @@ export function Files() { ) : null const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) + const folderByIdRef = useRef(folderById) + folderByIdRef.current = folderById const folderSizeMap = useMemo(() => { const directSize = new Map() @@ -676,21 +709,17 @@ export function Files() { const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) - const prevVisibleRowIdsRef = useRef(visibleRowIds) - useEffect(() => { - if (prevVisibleRowIdsRef.current === visibleRowIds) return - prevVisibleRowIdsRef.current = visibleRowIds - lastSelectedIndexRef.current = -1 - const visible = new Set(visibleRowIds) - setSelectedRowIds((prev) => { - if (prev.size === 0) return prev - const next = new Set(Array.from(prev).filter((id) => visible.has(id))) - return next.size === prev.size ? prev : next - }) - }, [visibleRowIds]) + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => Boolean(fileIdFromRoute) || listRename.editingId !== null, + onDeleteSelected: () => handleBulkDelete(), + }) - const isAllSelected = - visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id)) const { selectedFileIds, selectedFolderIds } = useMemo(() => { const fileIds: string[] = [] const folderIds: string[] = [] @@ -702,110 +731,56 @@ export function Files() { return { selectedFileIds: fileIds, selectedFolderIds: folderIds } }, [selectedRowIds]) - const selectableConfig = useMemo( - () => ({ - selectedIds: selectedRowIds, - isAllSelected, - onSelectRow: (rowId: string, checked: boolean, shiftKey?: boolean) => { - const currentIndex = visibleRowIds.indexOf(rowId) - if (shiftKey && lastSelectedIndexRef.current !== -1 && currentIndex !== -1) { - const start = Math.min(lastSelectedIndexRef.current, currentIndex) - const end = Math.max(lastSelectedIndexRef.current, currentIndex) - setSelectedRowIds((prev) => { - const next = new Set(prev) - for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) - return next - }) - lastSelectedIndexRef.current = currentIndex - } else { - setSelectedRowIds((prev) => { - const next = new Set(prev) - if (checked) next.add(rowId) - else next.delete(rowId) - return next - }) - if (checked) lastSelectedIndexRef.current = currentIndex - else lastSelectedIndexRef.current = -1 - } - }, - onSelectAll: (checked: boolean) => { - lastSelectedIndexRef.current = -1 - setSelectedRowIds((prev) => { - const next = new Set(prev) - for (const rowId of visibleRowIds) { - if (checked) next.add(rowId) - else next.delete(rowId) - } - return next - }) - }, - disabled: false, - }), - [selectedRowIds, isAllSelected, visibleRowIds] - ) - - const descendantFolderIdsByFolderId = useMemo(() => { - const childrenByParent = new Map() - for (const folder of folders) { - if (!folder.parentId) continue - const children = childrenByParent.get(folder.parentId) ?? [] - children.push(folder.id) - childrenByParent.set(folder.parentId, children) - } - - const result = new Map>() - const collect = (folderId: string, seen = new Set()): Set => { - const cached = result.get(folderId) - if (cached) return cached - if (seen.has(folderId)) return new Set() - - const nextSeen = new Set(seen) - nextSeen.add(folderId) - const descendants = new Set() - for (const childId of childrenByParent.get(folderId) ?? []) { - if (nextSeen.has(childId)) continue - descendants.add(childId) - for (const nestedId of collect(childId, nextSeen)) { - descendants.add(nestedId) - } - } - result.set(folderId, descendants) - return descendants - } - - for (const folder of folders) { - collect(folder.id) - } - return result - }, [folders]) - - const isInvalidDropTarget = useCallback( - (targetRowId: string, sourceRowIds: string[]) => { - const target = parseRowId(targetRowId) - if (target.kind !== 'folder') return true + const descendantFolderIdsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) + /** + * Whether dropping `sourceRowIds` into `targetFolderId` would move anything. + * + * Takes a folder id rather than a row id because the destination is not always a row: the + * list body files into the folder currently open, which has no row of its own, and a drag + * that spring-opened into an empty folder has nothing else to land on. + */ + const isInvalidFolderTarget = useCallback( + (targetFolderId: string | null, sourceRowIds: string[]) => { for (const sourceRowId of sourceRowIds) { const source = parseRowId(sourceRowId) if (source.kind !== 'folder') continue - if (source.id === target.id) return true - if (descendantFolderIdsByFolderId.get(source.id)?.has(target.id)) return true + if (source.id === targetFolderId) return true + if ( + targetFolderId !== null && + descendantFolderIdsByFolderId.get(source.id)?.has(targetFolderId) + ) + return true } - // Reject drop if every dragged item is already a direct child of the target const allAlreadyInTarget = sourceRowIds.every((sourceRowId) => { const source = parseRowId(sourceRowId) if (source.kind === 'file') { - return filesRef.current.find((f) => f.id === source.id)?.folderId === target.id + return ( + (filesRef.current.find((f) => f.id === source.id)?.folderId ?? null) === targetFolderId + ) } - return (foldersRef.current.find((f) => f.id === source.id)?.parentId ?? null) === target.id + return (folderByIdRef.current.get(source.id)?.parentId ?? null) === targetFolderId }) - if (allAlreadyInTarget) return true - - return false + return allAlreadyInTarget }, [descendantFolderIdsByFolderId] ) + /** + * Row-targeted drop: only a folder row can receive one. Delegates so the cycle and + * already-there rules live in exactly one place — the two had already drifted on whether a + * file's `folderId` was normalised with `?? null` before comparing. + */ + const isInvalidDropTarget = useCallback( + (targetRowId: string, sourceRowIds: string[]) => { + const target = parseRowId(targetRowId) + if (target.kind !== 'folder') return true + return isInvalidFolderTarget(target.id, sourceRowIds) + }, + [isInvalidFolderTarget] + ) + const uploadFiles = useCallback( async (filesToUpload: File[], targetFolderId = currentFolderId) => { if (!workspaceId || filesToUpload.length === 0 || !canEdit) return @@ -841,7 +816,6 @@ export function Files() { if (allowedFiles.length === 0) return try { - setUploading(true) setUploadProgress({ completed: 0, total: allowedFiles.length, currentPercent: 0 }) for (let i = 0; i < allowedFiles.length; i++) { @@ -872,13 +846,36 @@ export function Files() { } catch (err) { logger.error('Error uploading file:', err) } finally { - setUploading(false) setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) } }, [workspaceId, canEdit, currentFolderId, notifyLimit] ) + const dragGhost = useRowDragGhost() + + const springNav = useSpringNavigation({ + currentFolderId, + onNavigate: (folderId, options) => { + void setFilesParams({ folderId, new: null }, options) + }, + }) + + /** Returns the list to its resting state once a drag is over, however it ended. */ + const endDrag = useCallback(() => { + springNav.end() + dragGhost.remove() + dragCounterRef.current = 0 + draggedRowIdsRef.current = [] + setDraggedRowIds(EMPTY_DRAGGED_ROW_IDS) + setIsDraggingOver(false) + setActiveDropTargetId(null) + setIsBodyDropActive(false) + setActiveBreadcrumbIndex(null) + }, [dragGhost, springNav]) + + useDragTeardown(endDrag) + const rowDragDropConfig = useMemo( () => ({ activeDropTargetId, @@ -892,6 +889,7 @@ export function Files() { return } + springNav.rememberOrigin() const sourceRowIds = selectedRowIds.has(rowId) ? visibleRowIds.filter((visibleRowId) => selectedRowIds.has(visibleRowId)) : [rowId] @@ -899,35 +897,18 @@ export function Files() { draggedRowIdsRef.current = sourceRowIds setDraggedRowIds(new Set(sourceRowIds)) if (!selectedRowIds.has(rowId)) { - setSelectedRowIds(new Set([rowId])) + replaceSelection([rowId]) } e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData( - 'application/x-sim-workspace-file-rows', - JSON.stringify(sourceRowIds) - ) - e.dataTransfer.setData('text/plain', sourceRowIds.join(',')) + writeRowDragPayload(e.dataTransfer, FILE_ROW_DRAG_MIME, sourceRowIds) - const count = sourceRowIds.length const firstParsed = parseRowId(sourceRowIds[0]) const firstName = firstParsed.kind === 'file' ? filesRef.current.find((f) => f.id === firstParsed.id)?.name : foldersRef.current.find((f) => f.id === firstParsed.id)?.name - const ghostLabel = - count > 1 ? `${firstName ?? 'Items'} +${count - 1} more` : (firstName ?? 'Item') - const ghost = document.createElement('div') - ghost.style.cssText = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' - const text = document.createElement('span') - text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' - text.textContent = ghostLabel - ghost.appendChild(text) - document.body.appendChild(ghost) - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost + dragGhost.attach(e, firstName ?? 'Item', sourceRowIds.length) }, onDragOver: (e: DragEvent, rowId) => { const sourceRowIds = draggedRowIdsRef.current @@ -938,43 +919,59 @@ export function Files() { e.stopPropagation() e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move' setActiveDropTargetId(rowId) + // The row sits inside the scroll container, whose `dragleave` ignores contained + // targets — clear it here so the row and the body never both read as the target. + setIsBodyDropActive(false) + setActiveBreadcrumbIndex(null) + /** + * Armed for OS file drags too: dropping an upload into a nested folder is the same + * gesture, and `onDragOver` only fires on folder rows. + */ + springNav.arm(parseRowId(rowId).id) }, onDragLeave: (e: DragEvent, rowId) => { const relatedTarget = e.relatedTarget if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + springNav.disarm() setActiveDropTargetId((current) => (current === rowId ? null : current)) }, onDrop: (e: DragEvent, rowId) => { e.preventDefault() e.stopPropagation() - dragCounterRef.current = 0 - setIsDraggingOver(false) - setActiveDropTargetId(null) - const target = parseRowId(rowId) - if (target.kind !== 'folder') return + const target = parseRowId(rowId) const droppedFiles = Array.from(e.dataTransfer.files ?? []) - if (droppedFiles.length > 0) { + const sourceRowIds = + readRowDragPayload(e.dataTransfer, FILE_ROW_DRAG_MIME) ?? draggedRowIdsRef.current + + const isFolderDrop = target.kind === 'folder' + const canUpload = isFolderDrop && droppedFiles.length > 0 + const canMove = + isFolderDrop && droppedFiles.length === 0 && !isInvalidDropTarget(rowId, sourceRowIds) + + /** + * Marked BEFORE `endDrag`, which is what consumes it. Ending the drag first runs the + * return navigation, bouncing the list out of the folder the drop just landed in — and + * because `end` clears the flag, setting it afterwards leaves it armed for the NEXT + * drag, whose return then never happens. The upload branch marks it too: a file dropped + * into a spring-opened folder must leave the view in that folder, not snap away from it. + */ + if (canUpload || canMove) springNav.markDropHandled() + + /** + * Ends the drag before dispatching, but only after the payload has been read off the + * event and the source ref. This handler stops propagation, so the window-level + * backstop never sees this drop, and the source row may already have unmounted — after + * a spring-open it always has. + */ + endDrag() + + if (canUpload) { void uploadFiles(droppedFiles, target.id) return } - let sourceRowIds = draggedRowIdsRef.current - const rawSource = e.dataTransfer.getData('application/x-sim-workspace-file-rows') - if (rawSource) { - try { - const parsedSource = JSON.parse(rawSource) - if (Array.isArray(parsedSource)) { - sourceRowIds = parsedSource.filter( - (source): source is string => typeof source === 'string' && source.length > 0 - ) - } - } catch { - sourceRowIds = draggedRowIdsRef.current - } - } - - if (isInvalidDropTarget(rowId, sourceRowIds)) return + if (!canMove) return const fileIds = sourceRowIds .map(parseRowId) @@ -995,22 +992,112 @@ export function Files() { targetFolderId: target.id, }) .then(() => { - setSelectedRowIds(new Set()) + clearSelection() }) .catch((error) => { logger.error('Failed to move items via drag and drop:', error) }) }, - onDragEnd: () => { - if (dragGhostRef.current) { - dragGhostRef.current.remove() - dragGhostRef.current = null - } - dragCounterRef.current = 0 - draggedRowIdsRef.current = [] - setDraggedRowIds(new Set()) - setIsDraggingOver(false) - setActiveDropTargetId(null) + onDragEnd: endDrag, + /** + * The breadcrumb is how a drag walks back UP; spring-loading only ever goes deeper. + * Hovering a crumb navigates to it on the same timer a folder row uses, and releasing on + * one files the drag there directly. + */ + breadcrumb: { + activeIndex: activeBreadcrumbIndex, + onDragOver: (e: DragEvent, folderId: string | null, index: number) => { + if (hasExternalFiles(e.dataTransfer)) return + const sourceRowIds = draggedRowIdsRef.current + if (sourceRowIds.length === 0) return + /** Armed even for a no-op drop: walking back to where the drag started is the point. */ + if (folderId !== currentFolderId) springNav.arm(folderId) + const canDrop = !isInvalidFolderTarget(folderId, sourceRowIds) + setActiveBreadcrumbIndex(canDrop ? index : null) + setIsBodyDropActive(false) + if (!canDrop) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (_e: DragEvent, index: number) => { + springNav.disarm() + setActiveBreadcrumbIndex((current) => (current === index ? null : current)) + }, + onDrop: (e: DragEvent, folderId: string | null) => { + if (hasExternalFiles(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, FILE_ROW_DRAG_MIME) ?? draggedRowIdsRef.current + const canMove = sourceRowIds.length > 0 && !isInvalidFolderTarget(folderId, sourceRowIds) + if (canMove) springNav.markDropHandled() + endDrag() + if (!canMove) return + + const fileIds: string[] = [] + const folderIds: string[] = [] + for (const sourceRowId of sourceRowIds) { + const source = parseRowId(sourceRowId) + if (source.kind === 'file') fileIds.push(source.id) + else folderIds.push(source.id) + } + void moveItems + .mutateAsync({ workspaceId, fileIds, folderIds, targetFolderId: folderId }) + .then(() => clearSelection()) + .catch((error) => logger.error('Failed to move items via the breadcrumb:', error)) + }, + }, + body: { + isActive: isBodyDropActive, + onDragOver: (e: DragEvent) => { + /** + * Internal row drags only. An OS file drag is already owned by the page-level + * handler, which paints the full "Drop to upload" overlay and uploads into this same + * folder — claiming it here would double the affordance and, without stopping + * propagation, upload every dropped file twice. + */ + if (hasExternalFiles(e.dataTransfer)) return + const sourceRowIds = draggedRowIdsRef.current + // Recomputed every event: a spring-open changes the destination mid-drag. + const canDrop = + sourceRowIds.length > 0 && !isInvalidFolderTarget(currentFolderId, sourceRowIds) + setIsBodyDropActive(canDrop) + if (!canDrop) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (e: DragEvent) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setIsBodyDropActive(false) + }, + onDrop: (e: DragEvent) => { + // Left to the page-level handler, which uploads into this folder already. + if (hasExternalFiles(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, FILE_ROW_DRAG_MIME) ?? draggedRowIdsRef.current + const canMove = + sourceRowIds.length > 0 && !isInvalidFolderTarget(currentFolderId, sourceRowIds) + + if (canMove) springNav.markDropHandled() + endDrag() + if (!canMove) return + + const fileIds: string[] = [] + const folderIds: string[] = [] + for (const sourceRowId of sourceRowIds) { + const source = parseRowId(sourceRowId) + if (source.kind === 'file') fileIds.push(source.id) + else folderIds.push(source.id) + } + void moveItems + .mutateAsync({ workspaceId, fileIds, folderIds, targetFolderId: currentFolderId }) + .then(() => clearSelection()) + .catch((error) => logger.error('Failed to move items into the open folder:', error)) + }, }, }), [ @@ -1021,6 +1108,11 @@ export function Files() { selectedRowIds, visibleRowIds, isInvalidDropTarget, + isInvalidFolderTarget, + isBodyDropActive, + activeBreadcrumbIndex, + currentFolderId, + clearSelection, uploadFiles, workspaceId, ] @@ -1055,6 +1147,12 @@ export function Files() { const handleDrop = async (e: React.DragEvent) => { if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() + /** + * The upload lands in the folder currently open, so the view must stay there. Without this + * the window-level teardown treats the drag as unconsumed and returns to the folder it + * began in — pulling the user out of the folder they just spring-opened to receive it. + */ + springNav.markDropHandled() dragCounterRef.current = 0 setIsDraggingOver(false) const dropped = Array.from(e.dataTransfer.files) @@ -1106,7 +1204,7 @@ export function Files() { } setShowDeleteConfirm(false) setDeleteTarget(null) - setSelectedRowIds(new Set()) + clearSelection() if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) { setIsDirty(false) setSaveStatus('idle') @@ -1179,12 +1277,11 @@ export function Files() { setDeleteTarget({ fileIds: selectedFileIds, folderIds: selectedFolderIds, - name: - selectedFileIds.length + selectedFolderIds.length === 1 - ? (files.find((file) => file.id === selectedFileIds[0])?.name ?? - folders.find((folder) => folder.id === selectedFolderIds[0])?.name ?? - 'selected item') - : `${selectedFileIds.length + selectedFolderIds.length} selected items`, + name: selectionLabel( + selectedFileIds.length + selectedFolderIds.length, + files.find((file) => file.id === selectedFileIds[0])?.name ?? + folders.find((folder) => folder.id === selectedFolderIds[0])?.name + ), }) setShowDeleteConfirm(true) }, [selectedFileIds, selectedFolderIds, files, folders]) @@ -1363,12 +1460,11 @@ export function Files() { ? { kind: 'folder', id: parsed.id, folder: item as WorkspaceFileFolderApi } : { kind: 'file', id: parsed.id, file: item as WorkspaceFileRecord } if (!selectedRowIds.has(rowId)) { - lastSelectedIndexRef.current = visibleRowIds.indexOf(rowId) - setSelectedRowIds(new Set([rowId])) + replaceSelection([rowId]) } openContextMenu(e) }, - [folders, openContextMenu, selectedRowIds, visibleRowIds] + [folders, openContextMenu, selectedRowIds] ) const handleContextMenuOpen = useCallback(() => { @@ -1459,7 +1555,7 @@ export function Files() { folderIds: selectedFolderIds, targetFolderId, }) - setSelectedRowIds(new Set()) + clearSelection() closeContextMenu() } catch (error) { logger.error('Failed to move items:', error) @@ -1532,49 +1628,6 @@ export function Files() { return () => window.removeEventListener('keydown', handleKeyDown) }, [handleSave]) - const selectedRowIdsRef = useRef(selectedRowIds) - selectedRowIdsRef.current = selectedRowIds - const visibleRowIdsRef = useRef(visibleRowIds) - visibleRowIdsRef.current = visibleRowIds - const listRenameActiveRef = useRef(listRename.editingId) - listRenameActiveRef.current = listRename.editingId - const handleBulkDeleteRef = useRef(handleBulkDelete) - handleBulkDeleteRef.current = handleBulkDelete - - useEffect(() => { - const handleListKeyDown = (e: KeyboardEvent) => { - if (fileIdFromRouteRef.current) return - const active = document.activeElement - if ( - active && - (active.tagName === 'INPUT' || - active.tagName === 'TEXTAREA' || - (active as HTMLElement).isContentEditable) - ) - return - if (listRenameActiveRef.current) return - - if ((e.key === 'Delete' || e.key === 'Backspace') && selectedRowIdsRef.current.size > 0) { - e.preventDefault() - handleBulkDeleteRef.current() - return - } - - if (e.key === 'Escape' && selectedRowIdsRef.current.size > 0) { - e.preventDefault() - setSelectedRowIds(new Set()) - return - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { - e.preventDefault() - setSelectedRowIds(new Set(visibleRowIdsRef.current)) - } - } - window.addEventListener('keydown', handleListKeyDown) - return () => window.removeEventListener('keydown', handleListKeyDown) - }, []) - const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { if (prev === 'editor') return 'split' @@ -1692,21 +1745,21 @@ export function Files() { { id: 'file-delete', handler: () => handleDeleteSelected() }, ]) - const searchConfig: SearchConfig = { - value: urlSearchTerm, - onChange: setSearchTerm, - onClearAll: () => setSearchTerm(''), - placeholder: 'Search files...', - } + const searchConfig: SearchConfig = useMemo( + () => ({ + value: urlSearchTerm, + onChange: setSearchTerm, + onClearAll: () => setSearchTerm(''), + placeholder: 'Search files...', + }), + [urlSearchTerm, setSearchTerm] + ) - const uploadButtonLabel = - uploading && uploadProgress.total > 0 - ? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100 - ? `${uploadProgress.completed}/${uploadProgress.total} · ${uploadProgress.currentPercent}%` - : `${uploadProgress.completed}/${uploadProgress.total}` - : uploading - ? 'Uploading...' - : 'Upload' + const uploadButtonLabel = uploading + ? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100 + ? `${uploadProgress.completed}/${uploadProgress.total} · ${uploadProgress.currentPercent}%` + : `${uploadProgress.completed}/${uploadProgress.total}` + : 'Upload' const headerActionsConfig = useMemo( () => [ @@ -1827,45 +1880,21 @@ export function Files() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) - const contextMenuMoveOptions = useMemo((): MoveOptionNode[] => { - // Index children by parent ONCE (the same pattern used for folder sizes + descendant maps above), - // so building the tree is O(N) instead of a full `folders.filter` scan at every node (O(N²)). - const childrenByParent = new Map() - for (const f of folders) { - const key = f.parentId ?? null - const arr = childrenByParent.get(key) - if (arr) arr.push(f) - else childrenByParent.set(key, [f]) - } - const buildSubtree = (parentId: string | null): MoveOptionNode[] => - (childrenByParent.get(parentId) ?? []) - .filter((f) => { - if (selectedFolderIds.includes(f.id)) return false - return selectedFolderIds.every( - (sid) => !descendantFolderIdsByFolderId.get(sid)?.has(f.id) - ) - }) - .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) - .map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) })) - - return [{ value: ROOT_MOVE_OPTION_VALUE, label: 'Files', children: [] }, ...buildSubtree(null)] - }, [folders, selectedFolderIds, descendantFolderIdsByFolderId]) + const contextMenuMoveOptions = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Files', + excludeFolderIds: selectedFolderIds, + descendantsByFolderId: descendantFolderIdsByFolderId, + }), + [folders, selectedFolderIds, descendantFolderIdsByFolderId] + ) const sortConfig: SortConfig = useMemo( () => ({ @@ -1921,7 +1950,7 @@ export function Files() { return (
- File Type + File Type
- Size + Size {memberOptions.length > 0 && (
- Uploaded By + Uploaded By ({ content: filterContent }), [filterContent]) + const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (typeFilter.length > 0) { @@ -2124,13 +2156,14 @@ export function Files() { icon={FILES_HEADER.rootIcon} title={FILES_HEADER.rootLabel} breadcrumbs={listBreadcrumbs} + breadcrumbDrop={rowDragDropConfig.breadcrumb} actions={headerActionsConfig} /> - {isDraggingOver ? ( -
+
-
-

Drop to upload

-

- Release files here to add them to this workspace -

-
+

Drop to upload

) : null} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 3d71ef5e63b..b64b4642abb 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -50,7 +50,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' +import { + FILTER_SECTION_LABEL_CLASS, + FloatingOverflowText, + Resource, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -125,8 +129,6 @@ const STATUS_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'disabled', label: 'Disabled' }, ] -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' - interface KnowledgeBaseProps { id: string knowledgeBaseName?: string diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index e01bee4bd83..b9a4518127a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { @@ -22,9 +23,14 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + reportBulkOutcome, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -33,6 +39,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, @@ -42,9 +49,11 @@ import { parseFolderedRowId, parseMoveOptionValue, sortResources, + splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -65,7 +74,12 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' -import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { + useBulkDeleteKnowledgeBases, + useBulkMoveKnowledgeBases, + useDeleteKnowledgeBase, + useUpdateKnowledgeBase, +} from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' @@ -110,8 +124,6 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'empty', label: 'Empty' }, ] -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' - const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const const ROOT_BREADCRUMB_LABEL = FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootLabel @@ -200,9 +212,14 @@ export function Knowledge() { }, [error]) const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true + const canEditRef = useRef(canEdit) + canEditRef.current = canEdit const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) - const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) + const deleteKnowledgeBase = useDeleteKnowledgeBase(workspaceId) + const bulkMoveKnowledgeBases = useBulkMoveKnowledgeBases(workspaceId) + const bulkDeleteKnowledgeBases = useBulkDeleteKnowledgeBases(workspaceId) const { currentFolderId, @@ -268,8 +285,8 @@ export function Knowledge() { ) const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false) const [isTagsModalOpen, setIsTagsModalOpen] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) const [activeFolder, setActiveFolder] = useState(null) const [folderPendingDelete, setFolderPendingDelete] = useState(null) @@ -310,6 +327,22 @@ export function Knowledge() { const activeFolderRef = useRef(activeFolder) activeFolderRef.current = activeFolder + /** + * Indexed once. These resolve a dragged row's current placement and run per dragged row inside + * `dragover`, which fires continuously — a linear scan there is O(selection x resources) per + * event, and the worst case (hesitating over the folder the selection already lives in) does + * not short-circuit. + */ + const knowledgeBaseById = useMemo(() => { + const byId = new Map() + for (const base of knowledgeBases) byId.set(base.id, base as KnowledgeBaseWithDocCount) + return byId + }, [knowledgeBases]) + const knowledgeBaseByIdRef = useRef(knowledgeBaseById) + knowledgeBaseByIdRef.current = knowledgeBaseById + const folderByIdRef = useRef(folderById) + folderByIdRef.current = folderById + const foldersRef = useRef(folders) foldersRef.current = folders @@ -400,10 +433,11 @@ export function Knowledge() { const handleDeleteKnowledgeBase = useCallback( async (id: string) => { - await deleteKnowledgeBaseMutation({ knowledgeBaseId: id }) + await deleteKnowledgeBase.mutateAsync({ knowledgeBaseId: id }) logger.info(`Knowledge base deleted: ${id}`) }, - [deleteKnowledgeBaseMutation] + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + [] ) /** @@ -613,6 +647,58 @@ export function Knowledge() { listRename.cancelRename, ]) + /** + * A dialog owns the keyboard while it is open. Without this, Escape closes the dialog AND + * clears the selection behind it, so a bulk-delete confirm submits against a selection the + * user just emptied; Delete and Cmd/Ctrl+A leak through the same way. + */ + const isAnyDialogOpen = () => + isCreateModalOpen || + isEditModalOpen || + isDeleteModalOpen || + isBulkDeleteModalOpen || + isTagsModalOpen || + folderPendingDelete !== null + + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) + + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => + !canEdit || listRenameRef.current.editingId !== null || isAnyDialogOpen(), + onDeleteSelected: () => handleBulkDelete(), + }) + + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + + /** + * A context menu opened on a multi-row selection acts on the whole selection. Resolved inside + * the menu handlers rather than at each menu prop, so the menus stay unaware selection exists. + */ + const hasMultiSelection = selectedRowIds.size > 1 + const hasMultiSelectionRef = useRef(hasMultiSelection) + hasMultiSelectionRef.current = hasMultiSelection + + const { folderIds: selectedFolderIds, resourceIds: selectedKnowledgeBaseIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] + ) + + const bulkDeleteLabel = useMemo(() => { + const count = selectedKnowledgeBaseIds.length + selectedFolderIds.length + const firstName = + selectedKnowledgeBaseIds.length > 0 + ? knowledgeBasesRef.current.find((kb) => kb.id === selectedKnowledgeBaseIds[0])?.name + : foldersRef.current.find((folder) => folder.id === selectedFolderIds[0])?.name + return selectionLabel(count, firstName) + }, [selectedKnowledgeBaseIds, selectedFolderIds]) + const handleRowClick = useCallback( (rowId: string) => { if (isRowContextMenuOpenRef.current || isFolderContextMenuOpenRef.current) return @@ -634,6 +720,13 @@ export function Knowledge() { const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { + /** + * Right-clicking outside the selection retargets it, so the menu always acts on what is + * highlighted. Right-clicking inside it leaves the selection alone and the menu switches + * its move/delete entries to the bulk handlers. + */ + if (canEditRef.current && !selectedRowIdsRef.current.has(rowId)) replaceSelection([rowId]) + const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { const folder = foldersRef.current.find((item) => item.id === parsed.id) @@ -655,14 +748,9 @@ export function Knowledge() { const handleConfirmDelete = useCallback(async () => { const kb = activeKnowledgeBaseRef.current if (!kb) return - setIsDeleting(true) - try { - await handleDeleteKnowledgeBase(kb.id) - setIsDeleteModalOpen(false) - setActiveKnowledgeBase(null) - } finally { - setIsDeleting(false) - } + await handleDeleteKnowledgeBase(kb.id) + setIsDeleteModalOpen(false) + setActiveKnowledgeBase(null) }, [handleDeleteKnowledgeBase]) const handleCloseDeleteModal = useCallback(() => { @@ -696,8 +784,6 @@ export function Knowledge() { setIsDeleteModalOpen(true) }, []) - const canEdit = userPermissions.canEdit === true - const handleCreateFolder = useCallback(async () => { if (!workspaceId) return const parentId = currentFolderIdRef.current @@ -800,16 +886,18 @@ export function Knowledge() { }, [workspaceId, pinnedFolderIds, closeFolderContextMenu]) /** Move targets for the folder under the cursor: itself and its subtree are unreachable. */ - const folderMoveOptions: MoveOptionNode[] = useMemo(() => { - if (!activeFolder) return [] - const excluded = new Set([activeFolder.id]) - for (const id of descendantsByFolderId.get(activeFolder.id) ?? []) excluded.add(id) - return buildMoveOptions({ - folders, - rootLabel: ROOT_BREADCRUMB_LABEL, - excludedFolderIds: excluded, - }) - }, [folders, activeFolder, descendantsByFolderId]) + const folderMoveOptions: MoveOptionNode[] = useMemo( + () => + activeFolder + ? buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludeFolderIds: [activeFolder.id], + descendantsByFolderId, + }) + : [], + [folders, activeFolder, descendantsByFolderId] + ) /** Move targets for a knowledge base: every folder, since a base has no subtree. */ const knowledgeBaseMoveOptions: MoveOptionNode[] = useMemo( @@ -855,8 +943,7 @@ export function Knowledge() { if (!folder) return const parentId = parseMoveOptionValue(optionValue) // Live placement, not the snapshot taken when the menu opened — a refetch or concurrent - // move in between would otherwise skip the write the user just chose. Matches the - // knowledge-base move below and both Tables handlers. + // move in between would otherwise skip the write the user just chose. const current = foldersRef.current.find((item) => item.id === folder.id) ?? folder if ((current.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId) closeFolderContextMenu() @@ -869,8 +956,7 @@ export function Knowledge() { const kb = activeKnowledgeBaseRef.current if (!kb) return const folderId = parseMoveOptionValue(optionValue) - // Re-read placement from the live list: `activeKnowledgeBase` is a snapshot from when - // the menu opened, and a refetch since then would make the no-op check wrong. + // Same reasoning as `handleMoveFolder`: compare against the live row, not the snapshot. const current = knowledgeBasesRef.current.find((item) => item.id === kb.id) ?? kb if ((current.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId) closeRowContextMenu() @@ -878,22 +964,137 @@ export function Knowledge() { [moveKnowledgeBaseTo, closeRowContextMenu] ) + /** + * The one move path for every multi-row gesture — dropping a selection onto a folder row and + * the action bar's "Move to" menu both land here, so a mixed selection of knowledge bases and + * folders commits as a single operation instead of one request per row. + */ + const moveRowsTo = useCallback( + (rows: { knowledgeBaseIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { + if (rows.knowledgeBaseIds.length === 0 && rows.folderIds.length === 0) return + if (rows.knowledgeBaseIds.length + rows.folderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS) { + toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to move at once`) + return + } + bulkMoveKnowledgeBases.mutate( + { ...rows, targetFolderId }, + { + onSuccess: (result) => { + clearSelection() + reportBulkOutcome(result, 'moved') + }, + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [clearSelection] + ) + + const handleBulkMove = useCallback( + (optionValue: string) => { + moveRowsTo( + { knowledgeBaseIds: selectedKnowledgeBaseIds, folderIds: selectedFolderIds }, + parseMoveOptionValue(optionValue) + ) + }, + [moveRowsTo, selectedKnowledgeBaseIds, selectedFolderIds] + ) + + /** + * Enforced here rather than only on the action bar: the row context menu and the Delete key + * reach the same operation, and the server rejects an over-cap request outright — so without + * this the user confirms a delete that cannot succeed. + */ + const exceedsBatchCap = + selectedKnowledgeBaseIds.length + selectedFolderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS + + const handleBulkDelete = useCallback(() => { + if (selectedKnowledgeBaseIds.length === 0 && selectedFolderIds.length === 0) return + if (exceedsBatchCap) { + toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to delete at once`) + return + } + setIsBulkDeleteModalOpen(true) + }, [selectedKnowledgeBaseIds, selectedFolderIds, exceedsBatchCap]) + + const confirmBulkDelete = useCallback(async () => { + try { + const result = await bulkDeleteKnowledgeBases.mutateAsync({ + knowledgeBaseIds: selectedKnowledgeBaseIds, + folderIds: selectedFolderIds, + }) + setIsBulkDeleteModalOpen(false) + clearSelection() + reportBulkOutcome(result, 'deleted') + } catch (deleteError) { + // The mutation toasts the request failure itself; the modal stays open to allow a retry. + logger.error('Failed to delete selected items', deleteError) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + }, [selectedKnowledgeBaseIds, selectedFolderIds, clearSelection]) + + /** + * Destinations for the action bar's move menu. Every selected folder — and everything beneath + * it — is excluded, since a folder cannot be filed into itself or its own subtree. + */ + const bulkMoveOptions: MoveOptionNode[] = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludeFolderIds: selectedFolderIds, + descendantsByFolderId, + }), + [selectedFolderIds, folders, descendantsByFolderId] + ) + + const activeMoveOptions = hasMultiSelection ? bulkMoveOptions : knowledgeBaseMoveOptions + const activeFolderMoveOptions = hasMultiSelection ? bulkMoveOptions : folderMoveOptions + + const handleDeleteFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) return handleBulkDelete() + return handleDelete() + }, [handleBulkDelete, handleDelete]) + + const handleFolderDeleteFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) return handleBulkDelete() + return handleRequestFolderDelete() + }, [handleBulkDelete, handleRequestFolderDelete]) + + const handleMoveKnowledgeBaseFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveKnowledgeBase(optionValue) + }, + [handleBulkMove, handleMoveKnowledgeBase] + ) + + const handleMoveFolderFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveFolder(optionValue) + }, + [handleBulkMove, handleMoveFolder] + ) + const rowDragDropConfig = useFolderRowDragDrop({ canEdit, editingRowId: listRename.editingId, descendantsByFolderId, - getFolderParentId: (folderId) => foldersRef.current.find((f) => f.id === folderId)?.parentId, + getFolderParentId: (folderId) => folderByIdRef.current.get(folderId)?.parentId ?? null, getResourceFolderId: (knowledgeBaseId) => - knowledgeBasesRef.current.find((kb) => kb.id === knowledgeBaseId)?.folderId ?? null, + knowledgeBaseByIdRef.current.get(knowledgeBaseId)?.folderId ?? null, getRowLabel: (rowId) => { const parsed = parseFolderedRowId(rowId) return parsed.kind === 'folder' - ? (foldersRef.current.find((f) => f.id === parsed.id)?.name ?? 'Folder') - : (knowledgeBasesRef.current.find((kb) => kb.id === parsed.id)?.name ?? 'Knowledge base') + ? (folderByIdRef.current.get(parsed.id)?.name ?? 'Folder') + : (knowledgeBaseByIdRef.current.get(parsed.id)?.name ?? 'Knowledge base') }, - onMoveFolder: (folderId, targetFolderId) => void moveFolderTo(folderId, targetFolderId), - onMoveResource: (knowledgeBaseId, targetFolderId) => - void moveKnowledgeBaseTo(knowledgeBaseId, targetFolderId), + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => + moveRowsTo({ folderIds, knowledgeBaseIds: resourceIds }, targetFolderId), + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: setCurrentFolderId, + currentFolderId, }) const headerActions: ResourceAction[] = useMemo( @@ -996,18 +1197,7 @@ export function Knowledge() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) @@ -1089,6 +1279,36 @@ export function Knowledge() { [connectorFilter, contentFilter, ownerFilter, memberOptions] ) + /** Stable identity so the memoized `Resource.Options` can bail; an inline object cannot. */ + const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) + + /** + * Memoized element, not inline JSX: `Resource.Table` is `memo`'d, and a fresh overlay element + * every render would fail its shallow compare and re-render the whole list on any parent + * render — during an upload or a drag, that is every frame. + */ + const actionBar = useMemo( + () => ( + + ), + [ + selectedRowIds.size, + canEdit, + handleBulkMove, + bulkMoveOptions, + handleBulkDelete, + bulkMoveKnowledgeBases.isPending, + bulkDeleteKnowledgeBases.isPending, + ] + ) + const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (connectorFilter.length > 0) { @@ -1123,19 +1343,22 @@ export function Knowledge() { title={ROOT_BREADCRUMB_LABEL} breadcrumbs={listBreadcrumbs} actions={headerActions} + breadcrumbDrop={rowDragDropConfig.breadcrumb} /> @@ -1160,9 +1383,9 @@ export function Knowledge() { onTogglePin={handleToggleBasePin} pinned={pinnedBaseIds.has(activeKnowledgeBase.id)} onEdit={handleEdit} - onDelete={handleDelete} - onMove={handleMoveKnowledgeBase} - moveOptions={knowledgeBaseMoveOptions} + onDelete={handleDeleteFromMenu} + onMove={handleMoveKnowledgeBaseFromMenu} + moveOptions={activeMoveOptions} showOpenInNewTab showViewTags showEdit @@ -1179,12 +1402,12 @@ export function Knowledge() { onClose={closeFolderContextMenu} onOpen={handleOpenFolder} onRename={handleRenameFolder} - onDelete={handleRequestFolderDelete} + onDelete={handleFolderDeleteFromMenu} onCopyId={handleCopyFolderId} onTogglePin={handleToggleFolderPin} pinned={pinnedFolderIds.has(activeFolder.id)} - onMove={handleMoveFolder} - moveOptions={folderMoveOptions} + onMove={handleMoveFolderFromMenu} + moveOptions={activeFolderMoveOptions} canEdit={canEdit} /> )} @@ -1209,6 +1432,26 @@ export function Knowledge() { }} /> + 0 + ? '? This also deletes the knowledge bases and folders inside the selected folders. You can restore them from Recently Deleted in Settings.' + : '? You can restore them from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: confirmBulkDelete, + pending: bulkDeleteKnowledgeBases.isPending, + pendingLabel: 'Deleting...', + }} + /> + {activeKnowledgeBase && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index cb2f0fdd6b7..a1672cd0b19 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -9,7 +9,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' -import { generateUniqueTableName } from '@/lib/table/constants' +import { generateUniqueTableName, MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { DropdownOption, @@ -22,9 +22,14 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + reportBulkOutcome, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -33,6 +38,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, @@ -42,9 +48,11 @@ import { parseFolderedRowId, parseMoveOptionValue, sortResources, + splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -64,6 +72,8 @@ import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hoo import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { exportTable, + useBulkDeleteTables, + useBulkMoveTables, useCreateTable, useDeleteTable, useImportCsv, @@ -154,6 +164,8 @@ export function Tables() { const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) const moveTable = useMoveTable(workspaceId) + const bulkMoveTables = useBulkMoveTables(workspaceId) + const bulkDeleteTables = useBulkDeleteTables(workspaceId) const importCsv = useImportCsv() const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() @@ -203,6 +215,7 @@ export function Tables() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] = useState(false) + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) const [activeFolder, setActiveFolder] = useState(null) @@ -241,6 +254,20 @@ export function Tables() { const uploading = uploadProgress.total > 0 const csvInputRef = useRef(null) + /** + * Indexed once. These resolve a dragged row's current placement and run per dragged row inside + * `dragover`, which fires continuously — a linear scan there is O(selection x resources) per + * event, and the worst case (hesitating over the folder the selection already lives in) does + * not short-circuit. + */ + const tableById = useMemo(() => { + const byId = new Map() + for (const table of tables) byId.set(table.id, table) + return byId + }, [tables]) + const tableByIdRef = useRef(tableById) + tableByIdRef.current = tableById + const tablesRef = useRef(tables) tablesRef.current = tables @@ -258,7 +285,8 @@ export function Tables() { closeMenu: closeRowContextMenu, } = useContextMenu() - const [contextMenuKind, setContextMenuKind] = useState<'table' | 'folder'>('table') + /** Which row kind the row context menu acts on — whichever active slot the handler filled. */ + const contextMenuKind: 'table' | 'folder' = activeFolder ? 'folder' : 'table' /** * Descendants of every folder, so a move destination that sits inside the moved folder can @@ -459,6 +487,41 @@ export function Tables() { [listRename.startRename] ) + /** + * A dialog owns the keyboard while it is open. Without this, Escape closes the dialog AND + * clears the selection behind it, so a bulk-delete confirm submits against a selection the + * user just emptied; Delete and Cmd/Ctrl+A leak through the same way. + */ + const isAnyDialogOpen = () => + isDeleteDialogOpen || isDeleteFolderDialogOpen || isBulkDeleteDialogOpen || isImportDialogOpen + + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) + + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => !canEdit || listRename.editingId !== null || isAnyDialogOpen(), + onDeleteSelected: () => handleBulkDelete(), + }) + + const { folderIds: selectedFolderIds, resourceIds: selectedTableIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] + ) + + const bulkDeleteLabel = useMemo(() => { + const count = selectedTableIds.length + selectedFolderIds.length + const firstName = + selectedTableIds.length > 0 + ? tables.find((table) => table.id === selectedTableIds[0])?.name + : folderById.get(selectedFolderIds[0])?.name + return selectionLabel(count, firstName) + }, [selectedTableIds, selectedFolderIds, tables, folderById]) + const currentFolderActions: DropdownOption[] | undefined = useMemo(() => { if (!currentFolderId) return undefined const folder = folderById.get(currentFolderId) @@ -570,18 +633,7 @@ export function Tables() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) @@ -592,7 +644,7 @@ export function Tables() { () => (
- Row Count + Row Count {memberOptions.length > 0 && (
- Owner + Owner { const item = resolveRowItem(rowId) if (!item) return + /** + * Right-clicking outside the selection retargets it, so the menu always acts on what is + * highlighted. Right-clicking inside it leaves the selection alone and the menu switches + * its move/delete entries to the bulk handlers. + */ + if (canEdit && !selectedRowIds.has(rowId)) replaceSelection([rowId]) if (item.kind === 'folder') { setActiveFolder(item.folder) setActiveTable(null) - setContextMenuKind('folder') } else { setActiveTable(item.table) setActiveFolder(null) - setContextMenuKind('table') } handleRowCtxMenu(e) }, - [resolveRowItem, handleRowCtxMenu] + [resolveRowItem, handleRowCtxMenu, canEdit, selectedRowIds, replaceSelection] ) + /** Move targets for a table: every folder, since a table has no subtree. */ const tableMoveOptions: MoveOptionNode[] = useMemo( () => buildMoveOptions({ folders, rootLabel: ROOT_LABEL }), [folders] ) - const folderMoveOptions: MoveOptionNode[] = useMemo(() => { - if (!activeFolder) return [] - const excluded = new Set([activeFolder.id]) - for (const id of descendantFolderIds.get(activeFolder.id) ?? []) excluded.add(id) - return buildMoveOptions({ folders, rootLabel: ROOT_LABEL, excludedFolderIds: excluded }) - }, [activeFolder, folders, descendantFolderIds]) + const folderMoveOptions: MoveOptionNode[] = useMemo( + () => + activeFolder + ? buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_LABEL, + excludeFolderIds: [activeFolder.id], + descendantsByFolderId: descendantFolderIds, + }) + : [], + [activeFolder, folders, descendantFolderIds] + ) + + /** + * Destinations for the action bar's move menu. Every selected folder — and everything beneath + * it — is excluded, since a folder cannot be filed into itself or its own subtree. + */ + const bulkMoveOptions: MoveOptionNode[] = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_LABEL, + excludeFolderIds: selectedFolderIds, + descendantsByFolderId: descendantFolderIds, + }), + [selectedFolderIds, folders, descendantFolderIds] + ) const handleMoveTable = useCallback( (optionValue: string) => { @@ -753,7 +831,7 @@ export function Tables() { * Placement is re-read from the live list rather than trusted from `activeTable`, which * is a snapshot taken when the menu opened. A refetch or a concurrent move since then * would make the no-op check compare against a stale location and skip a write the user - * asked for. Matches the knowledge-base move. + * asked for. */ const current = tablesRef.current.find((table) => table.id === activeTable.id) ?? activeTable if ((current.folderId ?? null) === folderId) { @@ -794,22 +872,134 @@ export function Tables() { [activeFolder, folderById, moveFolderTo, closeRowContextMenu] ) + /** + * The one move path for every multi-row gesture — dropping a selection onto a folder row and + * the action bar's "Move to" menu both land here, so a mixed selection of tables and folders + * commits as a single operation instead of one request per row. + */ + const moveRowsTo = useCallback( + (rows: { tableIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { + if (rows.tableIds.length === 0 && rows.folderIds.length === 0) return + if (rows.tableIds.length + rows.folderIds.length > MAX_TABLE_BATCH_ITEMS) { + toast.error(`Select ${MAX_TABLE_BATCH_ITEMS} or fewer items to move at once`) + return + } + bulkMoveTables.mutate( + { ...rows, targetFolderId }, + { + onSuccess: (result) => { + clearSelection() + reportBulkOutcome(result, 'moved') + }, + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [clearSelection] + ) + + const handleBulkMove = useCallback( + (optionValue: string) => { + moveRowsTo( + { tableIds: selectedTableIds, folderIds: selectedFolderIds }, + parseMoveOptionValue(optionValue) + ) + }, + [moveRowsTo, selectedTableIds, selectedFolderIds] + ) + + /** + * Enforced here rather than only on the action bar: the row context menu and the Delete key + * reach the same operation, and the server rejects an over-cap request outright — so without + * this the user confirms a delete that cannot succeed. + */ + const exceedsBatchCap = selectedTableIds.length + selectedFolderIds.length > MAX_TABLE_BATCH_ITEMS + + const handleBulkDelete = useCallback(() => { + if (selectedTableIds.length === 0 && selectedFolderIds.length === 0) return + if (exceedsBatchCap) { + toast.error(`Select ${MAX_TABLE_BATCH_ITEMS} or fewer items to delete at once`) + return + } + setIsBulkDeleteDialogOpen(true) + }, [selectedTableIds, selectedFolderIds, exceedsBatchCap]) + + const confirmBulkDelete = useCallback(async () => { + try { + const result = await bulkDeleteTables.mutateAsync({ + tableIds: selectedTableIds, + folderIds: selectedFolderIds, + }) + setIsBulkDeleteDialogOpen(false) + clearSelection() + reportBulkOutcome(result, 'deleted') + } catch (err) { + // The mutation toasts the request failure itself; the modal stays open to allow a retry. + logger.error('Failed to delete selected items:', err) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + }, [selectedTableIds, selectedFolderIds, clearSelection]) + + /** + * A context menu opened on a multi-row selection acts on the whole selection. Resolved inside + * these handlers rather than at each menu prop, so the menus stay unaware selection exists. + */ + const hasMultiSelection = selectedRowIds.size > 1 + const hasMultiSelectionRef = useRef(hasMultiSelection) + hasMultiSelectionRef.current = hasMultiSelection + + const activeMoveOptions = hasMultiSelection ? bulkMoveOptions : tableMoveOptions + const activeFolderMoveOptions = hasMultiSelection ? bulkMoveOptions : folderMoveOptions + + const handleMoveTableFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveTable(optionValue) + }, + [handleBulkMove, handleMoveTable] + ) + + const handleMoveFolderFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveFolder(optionValue) + }, + [handleBulkMove, handleMoveFolder] + ) + + const handleDeleteTableFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) { + handleBulkDelete() + return + } + setIsDeleteDialogOpen(true) + }, [handleBulkDelete]) + + const handleDeleteFolderFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) { + handleBulkDelete() + return + } + setIsDeleteFolderDialogOpen(true) + }, [handleBulkDelete]) + const rowDragDropConfig = useFolderRowDragDrop({ canEdit, editingRowId: listRename.editingId, descendantsByFolderId: descendantFolderIds, getFolderParentId: (folderId) => folderById.get(folderId)?.parentId ?? null, - getResourceFolderId: (tableId) => - tablesRef.current.find((table) => table.id === tableId)?.folderId ?? null, + getResourceFolderId: (tableId) => tableByIdRef.current.get(tableId)?.folderId ?? null, getRowLabel: (rowId) => { const parsed = parseFolderedRowId(rowId) return parsed.kind === 'folder' ? (folderById.get(parsed.id)?.name ?? 'Folder') - : (tablesRef.current.find((table) => table.id === parsed.id)?.name ?? 'Table') + : (tableByIdRef.current.get(parsed.id)?.name ?? 'Table') }, - onMoveFolder: (folderId, targetFolderId) => moveFolderTo(folderId, targetFolderId), - onMoveResource: (tableId, targetFolderId) => - moveTable.mutate({ tableId, folderId: targetFolderId }), + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => + moveRowsTo({ folderIds, tableIds: resourceIds }, targetFolderId), + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: setCurrentFolderId, + currentFolderId, }) const handleDelete = async () => { @@ -1027,9 +1217,31 @@ export function Tables() { ] ) - // Stable identities so the memoized Resource.Header / Resource.Options can + // Stable identities so the memoized Resource.Header / Resource.Options / Resource.Table can // actually bail — inline object/element props would defeat their memo. const headerAside = useMemo(() => , [workspaceId]) + + const actionBar = useMemo( + () => ( + + ), + [ + selectedRowIds.size, + canEdit, + handleBulkMove, + bulkMoveOptions, + handleBulkDelete, + bulkMoveTables.isPending, + bulkDeleteTables.isPending, + ] + ) const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) return ( @@ -1041,6 +1253,7 @@ export function Tables() { breadcrumbs={breadcrumbs} actions={headerActions} aside={headerAside} + breadcrumbDrop={rowDragDropConfig.breadcrumb} /> @@ -1086,7 +1301,7 @@ export function Tables() { onCopyId={() => { if (activeTable) navigator.clipboard.writeText(activeTable.id) }} - onDelete={() => setIsDeleteDialogOpen(true)} + onDelete={handleDeleteTableFromMenu} onRename={() => { if (activeTable) listRename.startRename(activeTable.id, activeTable.name) }} @@ -1103,8 +1318,8 @@ export function Tables() { }} onTogglePin={handleTogglePin} pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false} - onMove={canEdit ? handleMoveTable : undefined} - moveOptions={canEdit ? tableMoveOptions : undefined} + onMove={canEdit ? handleMoveTableFromMenu : undefined} + moveOptions={canEdit ? activeMoveOptions : undefined} disableDelete={!canEdit} disableRename={!canEdit} disableImport={!canEdit} @@ -1124,11 +1339,11 @@ export function Tables() { onCopyId={() => { if (activeFolder) navigator.clipboard.writeText(activeFolder.id) }} - onDelete={() => setIsDeleteFolderDialogOpen(true)} + onDelete={handleDeleteFolderFromMenu} onTogglePin={handleTogglePin} pinned={activeFolder ? pinnedFolderIds.has(activeFolder.id) : false} - onMove={canEdit ? handleMoveFolder : undefined} - moveOptions={canEdit ? folderMoveOptions : undefined} + onMove={canEdit ? handleMoveFolderFromMenu : undefined} + moveOptions={canEdit ? activeFolderMoveOptions : undefined} canEdit={canEdit} /> @@ -1189,6 +1404,32 @@ export function Tables() { pendingLabel: 'Deleting...', }} /> + + 0 + ? 'Every table and subfolder inside the selected folders will be deleted too.' + : 'All of their rows will be removed.', + error: true, + }, + ' You can restore those tables from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: confirmBulkDelete, + pending: bulkDeleteTables.isPending, + pendingLabel: 'Deleting...', + }} + /> ) } diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index f46457282fc..5902e0d7be1 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -5,9 +5,13 @@ import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { type BulkChunkOperationData, + type BulkDeleteKnowledgeItemsBody, type BulkDocumentOperationData, + type BulkMoveKnowledgeItemsBody, + bulkDeleteKnowledgeItemsContract, bulkKnowledgeChunksContract, bulkKnowledgeDocumentsContract, + bulkMoveKnowledgeItemsContract, type ChunkData, type ChunksPagination, createKnowledgeBaseContract, @@ -47,6 +51,7 @@ import { } from '@/lib/api/contracts/knowledge' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { KNOWLEDGE_BASE_LIST_STALE_TIME, type KnowledgeQueryScope, @@ -1045,3 +1050,83 @@ export function useDeleteDocumentTagDefinitions() { }, }) } + +/** + * Move a mixed selection of knowledge bases and knowledge folders into one + * folder, or to the workspace root with `targetFolderId: null`. + * + * One request, one authorized operation: the Knowledge list interleaves folder + * and knowledge base rows in a single grid, so a selection is routinely mixed + * and must not be split into a resource call plus a per-folder fan-out. + * + * No optimistic patch, unlike the single-base move: a folder move re-parents + * rows the list renders at a different level, and the response reports per-item + * outcomes (`skipped`, `notFound`, `failed`) the client cannot predict. + */ +export function useBulkMoveKnowledgeBases(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + knowledgeBaseIds = [], + folderIds = [], + targetFolderId, + }: Omit) => { + const result = await requestJson(bulkMoveKnowledgeItemsContract, { + body: { workspaceId, knowledgeBaseIds, folderIds, targetFolderId }, + }) + return result.data + }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { knowledgeBaseIds = [] }) => { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('knowledge_base') }) + /** + * `exact` because `detail` is the prefix for the base's documents, chunks, and tag + * queries — a move only re-parents the base record itself, so a prefix invalidation would + * refetch every cached document and chunk page for nothing. The sibling delete hook + * deliberately stays non-exact, since there the children really must go. + */ + for (const knowledgeBaseId of knowledgeBaseIds) { + queryClient.invalidateQueries({ + queryKey: knowledgeKeys.detail(knowledgeBaseId), + exact: true, + }) + } + }, + }) +} + +/** + * Delete a mixed selection of knowledge bases and knowledge folders. + * + * Deleting a folder cascades to every knowledge base and subfolder inside it, + * so the response's `deletedItems` totals exceed the explicitly selected count. + */ +export function useBulkDeleteKnowledgeBases(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + knowledgeBaseIds = [], + folderIds = [], + }: Omit) => { + const result = await requestJson(bulkDeleteKnowledgeItemsContract, { + body: { workspaceId, knowledgeBaseIds, folderIds }, + }) + return result.data + }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { knowledgeBaseIds = [] }) => { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('knowledge_base') }) + for (const knowledgeBaseId of knowledgeBaseIds) { + queryClient.removeQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }) + } + }, + }) +} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7423d6b09de..c03356af9d1 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -38,8 +38,12 @@ import { addWorkflowGroupContract, type BatchInsertTableRowsBodyInput, type BatchUpdateTableRowsBodyInput, + type BulkDeleteTablesBody, + type BulkMoveTablesBody, batchCreateTableRowsContract, batchUpdateTableRowsContract, + bulkDeleteTablesContract, + bulkMoveTablesContract, type CreateTableBodyInput, type CreateTableColumnBodyInput, cancelTableRunsContract, @@ -114,6 +118,7 @@ import { sanitizeName } from '@/lib/table/import' import type { UploadProgressEvent } from '@/lib/uploads/client/types' import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { useTimezone } from '@/hooks/queries/general-settings' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { TABLE_LIST_STALE_TIME, TABLE_VIEWS_STALE_TIME, @@ -2510,3 +2515,81 @@ export function useDeleteWorkflowGroup({ workspaceId, tableId }: RowMutationCont }, }) } + +/** + * Move a mixed selection of tables and table folders into one folder, or to the + * workspace root with `targetFolderId: null`. + * + * One request, one authorized operation: the Tables list interleaves folder and + * table rows in a single grid, so a selection is routinely mixed and must not be + * split into a resource call plus a per-folder fan-out. + * + * No optimistic patch. A folder move re-parents rows that the list renders at a + * different level, and the response reports per-item outcomes (`skipped`, + * `notFound`, `failed`) the client cannot predict, so the caller reads the + * result rather than guessing it. + */ +export function useBulkMoveTables(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + tableIds = [], + folderIds = [], + targetFolderId, + }: Omit) => { + const result = await requestJson(bulkMoveTablesContract, { + body: { workspaceId, tableIds, folderIds, targetFolderId }, + }) + return result.data + }, + onError: (error) => { + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { tableIds = [] }) => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) + for (const tableId of tableIds) { + queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + } + }, + }) +} + +/** + * Archive a mixed selection of tables and table folders. + * + * Deleting a folder cascades to every table and subfolder inside it, so the + * response's `deletedItems` totals exceed the explicitly selected count. Cached + * detail and row entries are removed only for the tables named in the request — + * a cascaded table's detail cache is left to the list invalidation, since the + * request never named it. + */ +export function useBulkDeleteTables(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + tableIds = [], + folderIds = [], + }: Omit) => { + const result = await requestJson(bulkDeleteTablesContract, { + body: { workspaceId, tableIds, folderIds }, + }) + return result.data + }, + onError: (error) => { + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { tableIds = [] }) => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) + for (const tableId of tableIds) { + queryClient.removeQueries({ queryKey: tableKeys.detail(tableId) }) + queryClient.removeQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + } + }, + }) +} diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 475c31e737e..2998aa83b26 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -5,11 +5,17 @@ import { successResponseSchema, wireDateSchema, } from '@/lib/api/contracts/knowledge/shared' +import { + folderIdSchema, + requiredFieldSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import type { StrategyOptions } from '@/lib/chunkers/types' import { DEFAULT_CHUNKING_CONFIG, KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, + MAX_KNOWLEDGE_BATCH_ITEMS, } from '@/lib/knowledge/constants' export const knowledgeScopeSchema = z.enum(['active', 'archived', 'all']) @@ -195,3 +201,127 @@ export const restoreKnowledgeBaseContract = defineRouteContract({ schema: z.object({ success: z.literal(true) }).passthrough(), }, }) + +const bulkKnowledgeIdListSchema = z + .array(requiredFieldSchema('id entries cannot be empty')) + .max(MAX_KNOWLEDGE_BATCH_ITEMS, `cannot contain more than ${MAX_KNOWLEDGE_BATCH_ITEMS} ids`) + .default([]) + +/** + * Bounds a mixed selection from the Knowledge list, which interleaves folder + * rows and knowledge base rows in one grid. Both lists travel in one request so + * a mixed selection commits as one authorized operation instead of a + * client-sequenced fan-out. + * + * The cap is on the combined count: each list is individually bounded first so + * an oversized array is rejected before the combined arithmetic, and folders + * cost more than bases because they cascade. + */ +function refineBoundedKnowledgeSelection( + selection: { knowledgeBaseIds: string[]; folderIds: string[] }, + ctx: z.RefinementCtx +): void { + const total = selection.knowledgeBaseIds.length + selection.folderIds.length + if (total === 0) { + ctx.addIssue({ + code: 'custom', + path: ['knowledgeBaseIds'], + message: 'At least one knowledge base or folder must be selected', + }) + return + } + if (total > MAX_KNOWLEDGE_BATCH_ITEMS) { + ctx.addIssue({ + code: 'custom', + path: ['knowledgeBaseIds'], + message: `knowledgeBaseIds and folderIds cannot contain more than ${MAX_KNOWLEDGE_BATCH_ITEMS} ids combined`, + }) + } +} + +export const bulkMoveKnowledgeItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + knowledgeBaseIds: bulkKnowledgeIdListSchema, + folderIds: bulkKnowledgeIdListSchema, + /** Destination folder in the `knowledge_base` tree. `null` is the workspace root. */ + targetFolderId: folderIdSchema.nullable(), + }) + .superRefine(refineBoundedKnowledgeSelection) +export type BulkMoveKnowledgeItemsBody = z.input + +export const bulkDeleteKnowledgeItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + knowledgeBaseIds: bulkKnowledgeIdListSchema, + /** Folders to delete. Each cascades to every knowledge base and subfolder inside it. */ + folderIds: bulkKnowledgeIdListSchema, + }) + .superRefine(refineBoundedKnowledgeSelection) +export type BulkDeleteKnowledgeItemsBody = z.input + +const bulkKnowledgeItemKindSchema = z.enum(['knowledgeBase', 'folder']) + +const bulkKnowledgeItemSchema = z.object({ + kind: bulkKnowledgeItemKindSchema, + id: z.string(), + name: z.string(), +}) + +/** An id nothing active resolved to. Carries no name, because nothing was found to name. */ +const bulkKnowledgeMissingSchema = z.object({ + kind: bulkKnowledgeItemKindSchema, + id: z.string(), +}) + +/** + * An item the batch reached but could not act on for a reason the caller can + * act on in turn. Distinct from `notFound`, which also absorbs the items the + * caller may not write to. + */ +const bulkKnowledgeFailureSchema = bulkKnowledgeItemSchema.extend({ reason: z.string() }) + +/** + * Items dropped because a selected folder already carries them: a knowledge + * base filed inside a selected folder, or a subfolder of another selected one. + */ +const bulkKnowledgeSkippedSchema = z.array(bulkKnowledgeItemSchema) + +export const bulkMoveKnowledgeItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/bulk-move', + body: bulkMoveKnowledgeItemsBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + moved: z.array(bulkKnowledgeItemSchema), + skipped: bulkKnowledgeSkippedSchema, + notFound: z.array(bulkKnowledgeMissingSchema), + failed: z.array(bulkKnowledgeFailureSchema), + }) + ), + }, +}) + +export const bulkDeleteKnowledgeItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/bulk-delete', + body: bulkDeleteKnowledgeItemsBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + deleted: z.array(bulkKnowledgeItemSchema), + skipped: bulkKnowledgeSkippedSchema, + notFound: z.array(bulkKnowledgeMissingSchema), + failed: z.array(bulkKnowledgeFailureSchema), + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: z.object({ + knowledgeBases: z.number().int(), + folders: z.number().int(), + }), + }) + ), + }, +}) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index f93a6d5438a..0ed44ed4543 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -32,6 +32,7 @@ import { FILTER_OPS, MAX_RUN_TARGET_ROW_IDS, MAX_SELECT_OPTIONS, + MAX_TABLE_BATCH_ITEMS, NAME_PATTERN, SORT_DIRECTIONS, TABLE_LIMITS, @@ -2212,3 +2213,125 @@ export type TableViewWire = z.output export type TableViewConfigInput = z.input export type CreateTableViewBody = z.input export type UpdateTableViewBody = z.input + +const bulkTableIdListSchema = z + .array(requiredFieldSchema('id entries cannot be empty')) + .max(MAX_TABLE_BATCH_ITEMS, `cannot contain more than ${MAX_TABLE_BATCH_ITEMS} ids`) + .default([]) + +/** + * Bounds a mixed selection from the Tables list, which interleaves folder rows + * and table rows in one grid. Both lists travel in one request so a mixed + * selection commits as one authorized operation instead of a client-sequenced + * fan-out. + * + * The cap is on the combined count: each list is individually bounded first so + * a 10,000-entry array is rejected before the combined arithmetic, and folders + * cost more than tables because they cascade. + */ +function refineBoundedTableSelection( + selection: { tableIds: string[]; folderIds: string[] }, + ctx: z.RefinementCtx +): void { + const total = selection.tableIds.length + selection.folderIds.length + if (total === 0) { + ctx.addIssue({ + code: 'custom', + path: ['tableIds'], + message: 'At least one table or folder must be selected', + }) + return + } + if (total > MAX_TABLE_BATCH_ITEMS) { + ctx.addIssue({ + code: 'custom', + path: ['tableIds'], + message: `tableIds and folderIds cannot contain more than ${MAX_TABLE_BATCH_ITEMS} ids combined`, + }) + } +} + +export const bulkMoveTablesBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns every selected item.'), + tableIds: bulkTableIdListSchema.describe('Tables to move, by identifier.'), + folderIds: bulkTableIdListSchema.describe('Table folders to re-parent, by identifier.'), + targetFolderId: folderIdSchema + .nullable() + .describe('Destination folder in the table folder tree. `null` is the workspace root.'), + }) + .superRefine(refineBoundedTableSelection) +export type BulkMoveTablesBody = z.input + +export const bulkDeleteTablesBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns every selected item.'), + tableIds: bulkTableIdListSchema.describe('Tables to archive, by identifier.'), + folderIds: bulkTableIdListSchema.describe( + 'Table folders to delete, by identifier. Each cascades to everything inside it.' + ), + }) + .superRefine(refineBoundedTableSelection) +export type BulkDeleteTablesBody = z.input + +const bulkTableItemKindSchema = z.enum(['table', 'folder']) + +const bulkTableItemSchema = z.object({ + kind: bulkTableItemKindSchema, + id: z.string(), + name: z.string(), +}) + +/** An id nothing active resolved to. Carries no name, because nothing was found to name. */ +const bulkTableMissingSchema = z.object({ kind: bulkTableItemKindSchema, id: z.string() }) + +/** + * An item the batch reached but could not act on for a reason the caller can + * act on in turn — a delete lock, a folder cycle. Distinct from `notFound`, + * which also absorbs the items the caller may not write to. + */ +const bulkTableFailureSchema = bulkTableItemSchema.extend({ reason: z.string() }) + +/** + * Items dropped because a selected folder already carries them: a table filed + * inside a selected folder, or a subfolder of another selected folder. + */ +const bulkTableSkippedSchema = z.array(bulkTableItemSchema) + +export const bulkMoveTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/table/bulk-move', + body: bulkMoveTablesBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + moved: z.array(bulkTableItemSchema), + skipped: bulkTableSkippedSchema, + notFound: z.array(bulkTableMissingSchema), + failed: z.array(bulkTableFailureSchema), + }) + ), + }, +}) +export const bulkDeleteTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/table/bulk-delete', + body: bulkDeleteTablesBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + deleted: z.array(bulkTableItemSchema), + skipped: bulkTableSkippedSchema, + notFound: z.array(bulkTableMissingSchema), + failed: z.array(bulkTableFailureSchema), + /** Totals across the explicit archives and every folder cascade they triggered. */ + deletedItems: z.object({ + tables: z.number().int(), + folders: z.number().int(), + }), + }) + ), + }, +}) 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 90115a49ce0..1695c5aa3e1 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -20,7 +20,6 @@ import { import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' -import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/application/batch-policy' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { createKnowledgeConnector, @@ -46,7 +45,10 @@ import { readKnowledgeTagUsage, updateKnowledgeTag, } from '@/lib/knowledge/application/tags' -import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, + MAX_KNOWLEDGE_BATCH_ITEMS, +} from '@/lib/knowledge/constants' import { captureServerEvent } from '@/lib/posthog/server' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' diff --git a/apps/sim/lib/core/application/batch-policy.ts b/apps/sim/lib/core/application/batch-policy.ts new file mode 100644 index 00000000000..dc1eab9d420 --- /dev/null +++ b/apps/sim/lib/core/application/batch-policy.ts @@ -0,0 +1,61 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** The failure that ended a `sequential_best_effort` batch early. */ +export interface BatchTerminalFailure { + error: unknown +} + +export interface BatchExecutionResult { + terminalFailure?: BatchTerminalFailure +} + +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export function rethrowBatchTerminalFailure(result: BatchExecutionResult): void { + if (result.terminalFailure) throw result.terminalFailure.error +} + +/** A deduplicated, bounded mixed selection of resources and folders. */ +export interface BoundedResourceSelection { + resourceIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed resource/folder selection before any + * protected row is loaded. + * + * The cap is on the **combined** count, not on each list: a request naming 100 + * resources and 100 folders costs twice what the ceiling is meant to allow, and + * a folder costs more than a resource because it cascades. + * + * Returns neutral key names; each domain renames them so its own selection type + * stays self-describing. + */ +export function requireBoundedResourceSelection( + resourceIds: readonly string[], + folderIds: readonly string[], + maxItems: number, + noun: { singular: string; plural: string } +): BoundedResourceSelection { + const selection = { + resourceIds: [...new Set(resourceIds)], + folderIds: [...new Set(folderIds)], + } + const total = selection.resourceIds.length + selection.folderIds.length + if (total === 0) { + throw new OrchestrationError( + 'validation', + `At least one ${noun.singular} or folder is required` + ) + } + if (total > maxItems) { + throw new OrchestrationError( + 'validation', + `Too many items (${total}). Maximum is ${maxItems} ${noun.plural} and folders combined.` + ) + } + return selection +} diff --git a/apps/sim/lib/core/application/bulk-items.test.ts b/apps/sim/lib/core/application/bulk-items.test.ts new file mode 100644 index 00000000000..60338c9aa6d --- /dev/null +++ b/apps/sim/lib/core/application/bulk-items.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { type BulkItemDisposition, classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +describe('classifyBulkItemError', () => { + /** + * The invariant this function exists to hold: an id the caller may not reach + * and an id that does not exist must be indistinguishable in the response, + * or a bulk request becomes a membership oracle for a workspace the caller + * can read but not write to. + */ + it.each(['not_found', 'forbidden', 'unauthorized'] as const)( + 'conceals %s as notFound, carrying no message', + (code) => { + const disposition = classifyBulkItemError( + new OrchestrationError(code, `secret detail for ${code}`) + ) + + expect(disposition).toEqual({ kind: 'notFound' }) + } + ) + + it('reports any other classified code as an actionable per-item failure', () => { + expect( + classifyBulkItemError(new OrchestrationError('validation', 'Bad target folder')) + ).toEqual({ kind: 'failed', reason: 'Bad target folder' }) + expect(classifyBulkItemError(new OrchestrationError('conflict', 'Name taken'))).toEqual({ + kind: 'failed', + reason: 'Name taken', + }) + }) + + it('ends the batch on an internal or unclassified error', () => { + const internal = new OrchestrationError('internal', 'connection reset') + expect(classifyBulkItemError(internal)).toEqual({ kind: 'terminal', error: internal }) + + const unclassified = new Error('socket hang up') + expect(classifyBulkItemError(unclassified)).toEqual({ + kind: 'terminal', + error: unclassified, + }) + }) + + it('lets a domain verdict rule on an error the shared classification cannot see', () => { + class DomainLockError extends Error {} + const verdict = (error: unknown): BulkItemDisposition | undefined => + error instanceof DomainLockError ? { kind: 'failed', reason: error.message } : undefined + + expect(classifyBulkItemError(new DomainLockError('Table is locked'), verdict)).toEqual({ + kind: 'failed', + reason: 'Table is locked', + }) + // Without the verdict the same error is an unclassified fault that ends the batch. + expect(classifyBulkItemError(new DomainLockError('Table is locked'))).toMatchObject({ + kind: 'terminal', + }) + }) + + /** + * A verdict must not be able to reopen the probe the concealment closes: the + * concealed codes are decided before the hook could widen them. + */ + it('keeps concealment ahead of a domain verdict that would widen it', () => { + const leakyVerdict = (error: unknown): BulkItemDisposition => ({ + kind: 'failed', + reason: `leaked: ${(error as Error).message}`, + }) + + for (const code of ['not_found', 'forbidden', 'unauthorized'] as const) { + expect( + classifyBulkItemError(new OrchestrationError(code, 'no write access'), leakyVerdict) + ).toEqual({ kind: 'notFound' }) + } + }) +}) diff --git a/apps/sim/lib/core/application/bulk-items.ts b/apps/sim/lib/core/application/bulk-items.ts new file mode 100644 index 00000000000..65c10ec58cf --- /dev/null +++ b/apps/sim/lib/core/application/bulk-items.ts @@ -0,0 +1,58 @@ +import { asOrchestrationError } from '@/lib/core/orchestration/types' + +/** + * Outcome of one item in a best-effort batch. + * + * `not_found`, `forbidden`, and `unauthorized` collapse into `notFound` so a + * caller cannot use a bulk request to probe which identifiers exist in a + * workspace it can see but may not write to — the same concealment the + * single-item operations apply. Anything the orchestration layer did not + * classify (or classified `internal`) ends the batch: it is an infrastructure + * failure, not a per-item verdict. + */ +export type BulkItemDisposition = + | { kind: 'notFound' } + | { kind: 'failed'; reason: string } + | { kind: 'terminal'; error: unknown } + +/** + * A domain's chance to rule on an error the shared classification cannot see. + * Return `undefined` to defer to the shared rules. + * + * Only for errors that are genuinely a per-item verdict and carry no + * orchestration code — a table's delete lock is the motivating case. A hook + * must never widen `notFound` into a distinguishable outcome, or it reopens the + * probe the concealment closes. + */ +export type BulkItemVerdict = (error: unknown) => BulkItemDisposition | undefined + +/** + * Classifies one item's error in a best-effort batch. One implementation for + * every domain, so the concealment rule above cannot drift between them. + */ +export function classifyBulkItemError( + error: unknown, + verdict?: BulkItemVerdict +): BulkItemDisposition { + const classified = asOrchestrationError(error) + /** + * Concealment is decided BEFORE the domain hook runs, so no hook can widen a + * concealed code back into a distinguishable outcome. A hook exists for + * errors the orchestration layer never classified at all. + */ + if ( + classified?.code === 'not_found' || + classified?.code === 'forbidden' || + classified?.code === 'unauthorized' + ) { + return { kind: 'notFound' } + } + + const domainVerdict = verdict?.(error) + if (domainVerdict) return domainVerdict + + if (classified && classified.code !== 'internal') { + return { kind: 'failed', reason: classified.message } + } + return { kind: 'terminal', error } +} diff --git a/apps/sim/lib/folders/bulk.test.ts b/apps/sim/lib/folders/bulk.test.ts new file mode 100644 index 00000000000..08bb9092973 --- /dev/null +++ b/apps/sim/lib/folders/bulk.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListActiveFolderRows } = vi.hoisted(() => ({ + mockListActiveFolderRows: vi.fn(), +})) + +vi.mock('@/lib/folders/queries', () => ({ + listActiveFolderRows: mockListActiveFolderRows, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + deleteFolder: vi.fn(), + updateFolder: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyFolderResourceChanged: vi.fn(), +})) + +import { planFolderSelection } from '@/lib/folders/bulk' + +/** + * `a` holds `a1`, which holds `a1x`. `b` is a sibling with nothing inside it, so a plan can + * distinguish "carried by an ancestor" from "selected in its own right". + */ +const TREE = [ + { id: 'a', name: 'A', parentId: null }, + { id: 'a1', name: 'A1', parentId: 'a' }, + { id: 'a1x', name: 'A1X', parentId: 'a1' }, + { id: 'b', name: 'B', parentId: null }, +] + +describe('planFolderSelection', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListActiveFolderRows.mockResolvedValue(TREE) + }) + + const plan = (folderIds: string[]) => planFolderSelection('ws-1', 'table', folderIds) + + it('selects a folder and reports nothing contained', async () => { + const result = await plan(['a']) + expect(result.selected).toEqual([{ id: 'a', name: 'A' }]) + expect(result.contained).toEqual([]) + expect([...result.covered].sort()).toEqual(['a', 'a1', 'a1x']) + }) + + it('reports an explicitly selected descendant as contained, not as a second selection', async () => { + const result = await plan(['a1', 'a']) + expect(result.selected).toEqual([{ id: 'a', name: 'A' }]) + expect(result.contained).toEqual([{ id: 'a1', name: 'A1' }]) + }) + + it('reports the same selection identically whichever order the ids arrive in', async () => { + // Regression: the ancestor marked its descendants covered, and testing `covered` before + // containment then dropped an explicitly requested descendant from every outcome + // category — so reversing the input silently changed what the API reported. + const ancestorFirst = await plan(['a', 'a1']) + const descendantFirst = await plan(['a1', 'a']) + + expect(ancestorFirst.selected).toEqual(descendantFirst.selected) + expect(ancestorFirst.contained).toEqual(descendantFirst.contained) + expect(ancestorFirst.contained).toEqual([{ id: 'a1', name: 'A1' }]) + }) + + it('never drops a requested folder from every outcome category', async () => { + for (const order of [ + ['a', 'a1', 'a1x'], + ['a1x', 'a1', 'a'], + ['a1', 'a1x', 'a'], + ]) { + const result = await plan(order) + const accounted = new Set([ + ...result.selected.map((f) => f.id), + ...result.contained.map((f) => f.id), + ...result.notFound, + ]) + expect([...accounted].sort()).toEqual(['a', 'a1', 'a1x']) + } + }) + + it('reports ids that resolve to nothing as notFound', async () => { + const result = await plan(['b', 'ghost']) + expect(result.selected).toEqual([{ id: 'b', name: 'B' }]) + expect(result.notFound).toEqual(['ghost']) + }) + + it('accounts for a duplicated id exactly once', async () => { + const result = await plan(['b', 'b']) + expect(result.selected).toEqual([{ id: 'b', name: 'B' }]) + }) +}) diff --git a/apps/sim/lib/folders/bulk.ts b/apps/sim/lib/folders/bulk.ts new file mode 100644 index 00000000000..2e32c343132 --- /dev/null +++ b/apps/sim/lib/folders/bulk.ts @@ -0,0 +1,287 @@ +import { createLogger } from '@sim/logger' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { deleteFolder, updateFolder } from '@/lib/folders/orchestration' +import { listActiveFolderRows } from '@/lib/folders/queries' +import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree' +import { notifyFolderResourceChanged } from '@/lib/realtime/notify' + +const logger = createLogger('FolderBulk') + +export interface BulkFolderAffected { + id: string + name: string +} + +export interface BulkFolderFailure extends BulkFolderAffected { + reason: string +} + +export interface FolderSelectionPlan { + /** Selected folders that resolve to an active folder of this resource type, request order preserved. */ + selected: BulkFolderAffected[] + /** Selected ids with no active folder of this resource type in the workspace. */ + notFound: string[] + /** + * Selected folders that sit inside another selected folder. They travel with + * their ancestor — moving or deleting them again would either rip a subfolder + * out of the parent it is moving with, or archive it under a second + * timestamp that the parent's restore could never bring back. + */ + contained: BulkFolderAffected[] + /** + * Every folder id the selection covers, including descendants. A resource + * filed in one of these is already handled by its folder and must not be + * acted on a second time. + */ + covered: Set +} + +/** + * Resolves a bulk folder selection against the workspace's live folder tree + * once, before anything is written. + * + * Reads the whole active tree in a single query rather than one lookup per + * selected id: the containment questions ("is this folder inside another + * selected one", "is this resource inside a selected folder") need the tree + * anyway, and a workspace's folder count is already bounded. + */ +export async function planFolderSelection( + workspaceId: string, + resourceType: FolderResourceType, + folderIds: readonly string[] +): Promise { + if (folderIds.length === 0) { + return { selected: [], notFound: [], contained: [], covered: new Set() } + } + + const rows = await listActiveFolderRows(workspaceId, resourceType, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const rowsById = new Map(rows.map((row) => [row.id, row])) + + const selected: BulkFolderAffected[] = [] + const notFound: string[] = [] + const contained: BulkFolderAffected[] = [] + const covered = new Set() + + const requested = new Set() + for (const folderId of folderIds) { + if (!rowsById.has(folderId)) { + notFound.push(folderId) + continue + } + requested.add(folderId) + } + + /** + * One `childrenByParent` index for the whole plan. Building it per requested + * folder would be O(rows) each time — up to `MAX_FOLDERS_PER_WORKSPACE` Map + * writes per selected folder, all but the first thrown away. + */ + const childrenByParent = indexFolderChildren(rows) + const descendantsOf = new Map() + for (const folderId of requested) { + descendantsOf.set(folderId, collectDescendantFolderIdsFrom(childrenByParent, folderId)) + } + + const insideAnotherSelection = new Set() + for (const [folderId, descendants] of descendantsOf) { + for (const descendantId of descendants) { + if (requested.has(descendantId)) insideAnotherSelection.add(descendantId) + } + } + + const reported = new Set() + for (const folderId of folderIds) { + const row = rowsById.get(folderId) + if (!row || reported.has(folderId)) continue + reported.add(folderId) + const entry = { id: row.id, name: row.name } + /** + * Containment is tested before `covered`, and that order matters: an explicitly requested + * folder that sits inside another selected folder must always be reported. Testing + * `covered` first made the outcome order-dependent — an ancestor processed earlier marked + * the descendant covered, so the descendant fell out of every outcome category and the + * same id set produced different results depending on the order it arrived in. + */ + if (insideAnotherSelection.has(folderId)) { + contained.push(entry) + continue + } + if (covered.has(folderId)) continue + selected.push(entry) + covered.add(folderId) + for (const descendantId of descendantsOf.get(folderId) ?? []) covered.add(descendantId) + } + + /** + * A contained folder's subtree is covered by its ancestor, but record it + * anyway: the ancestor's descendant walk and this one are the same set, and + * an explicit add keeps `covered` correct even if the tree contains a cycle + * the walk had to cut short. + */ + for (const folder of contained) { + covered.add(folder.id) + for (const descendantId of descendantsOf.get(folder.id) ?? []) covered.add(descendantId) + } + + return { selected, notFound, contained, covered } +} + +/** + * The halves of a plan a caller's bulk outcome absorbs verbatim: ids nothing + * resolved to, and folders a selected ancestor already carries. + * + * Declared as push sinks rather than concrete arrays so each domain can pass + * its own outcome arrays, whose element unions (`'table' | 'folder'`, + * `'knowledgeBase' | 'folder'`) are wider than the folder entries written into + * them. + */ +export interface FolderPlanSink { + notFound: { push(entry: { kind: 'folder'; id: string }): unknown } + skipped: { push(entry: { kind: 'folder'; id: string; name: string }): unknown } +} + +/** Projects a plan's unactionable halves into a bulk outcome. */ +export function foldFolderPlan(plan: FolderSelectionPlan, outcome: FolderPlanSink): void { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) +} + +export interface BulkFolderOutcome { + succeeded: BulkFolderAffected[] + failed: BulkFolderFailure[] +} + +export interface BulkFolderDeleteOutcome extends BulkFolderOutcome { + /** Folders archived across every cascade, including the selected folders themselves. */ + folderCount: number + /** Resources of `resourceType` archived by the cascades. */ + resourceCount: number +} + +/** + * Re-parents each selected folder under `targetParentId`. + * + * Best-effort per folder, matching the resource half of the same request. + * `updateFolder` owns the invariants a caller must not be trusted with — a + * folder cannot become its own parent, cannot move under one of its own + * descendants, and cannot cross into another workspace — and reports them as + * `validation` failures, which surface here as a per-folder reason. + * + * Per-folder realtime notification is suppressed and one batch notification is + * sent instead: every per-item notify carries an identical body and triggers an + * identical workspace-wide invalidation, so a 100-folder move would otherwise + * cost 100 sequential internal round trips and make every connected client + * refetch the same list 100 times for one gesture. The batch notify runs in a + * `finally`, so a batch that ends early on an internal fault still tells the + * clients about the folders it did move. + */ +export async function bulkMoveFolders(params: { + workspaceId: string + resourceType: FolderResourceType + userId: string + folders: readonly BulkFolderAffected[] + targetParentId: string | null +}): Promise { + const succeeded: BulkFolderAffected[] = [] + const failed: BulkFolderFailure[] = [] + + try { + for (const folder of params.folders) { + const result = await updateFolder( + { + resourceType: params.resourceType, + folderId: folder.id, + workspaceId: params.workspaceId, + userId: params.userId, + parentId: params.targetParentId, + }, + { notify: false } + ) + if (result.success && result.folder) { + succeeded.push({ id: result.folder.id, name: result.folder.name }) + continue + } + if (result.errorCode === 'internal') throw new Error(result.error ?? 'Failed to move folder') + failed.push({ ...folder, reason: result.error ?? 'Failed to move folder' }) + } + } finally { + if (succeeded.length > 0) { + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + } + } + + logger.info('Bulk moved folders', { + workspaceId: params.workspaceId, + resourceType: params.resourceType, + succeeded: succeeded.length, + failed: failed.length, + }) + return { succeeded, failed } +} + +/** + * Archives each selected folder and everything under it. + * + * `projectAudit: false` — the calling application use case projects + * `FOLDER_DELETED` from the authoritative result with full principal + * attribution, which the orchestration's own `actorId: userId` entry cannot + * express for a non-human principal. + * + * `notify: false` for the same reason as {@link bulkMoveFolders}: one batch + * notification replaces a per-folder storm of identical invalidations, and it + * fires from a `finally` so a batch cut short by an internal fault still + * announces the folders it did archive. + */ +export async function bulkDeleteFolders(params: { + workspaceId: string + resourceType: FolderResourceType + userId: string + folders: readonly BulkFolderAffected[] + countKey: 'tables' | 'knowledgeBases' +}): Promise { + const succeeded: BulkFolderAffected[] = [] + const failed: BulkFolderFailure[] = [] + let folderCount = 0 + let resourceCount = 0 + + try { + for (const folder of params.folders) { + const result = await deleteFolder( + { + resourceType: params.resourceType, + folderId: folder.id, + workspaceId: params.workspaceId, + userId: params.userId, + folderName: folder.name, + }, + { projectAudit: false, notify: false } + ) + if (result.success) { + succeeded.push(folder) + folderCount += result.deletedItems?.folders ?? 0 + resourceCount += result.deletedItems?.[params.countKey] ?? 0 + continue + } + if (result.errorCode === 'internal') + throw new Error(result.error ?? 'Failed to delete folder') + failed.push({ ...folder, reason: result.error ?? 'Failed to delete folder' }) + } + } finally { + if (succeeded.length > 0) { + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + } + } + + logger.info('Bulk deleted folders', { + workspaceId: params.workspaceId, + resourceType: params.resourceType, + succeeded: succeeded.length, + failed: failed.length, + folderCount, + resourceCount, + }) + return { succeeded, failed, folderCount, resourceCount } +} diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index a91a8a75e3c..cb4f2c3cbe1 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -665,7 +665,17 @@ export async function createFolder(params: CreateFolderParams): Promise { +export async function updateFolder( + params: UpdateFolderParams, + /** + * `notify: false` for a caller that mutates several folders in one gesture + * and sends a single batch notification of its own. Every per-folder notify + * carries an identical body and triggers an identical workspace-wide + * invalidation, so a batch would otherwise fan out one internal round trip + * per item. Omitted, the orchestration notifies as before. + */ + options?: { notify?: boolean } +): Promise { const config = folderResourceConfig(params.resourceType) try { @@ -748,7 +758,9 @@ export async function updateFolder(params: UpdateFolderParams): Promise { +export async function deleteFolder( + params: DeleteFolderParams, + /** + * `projectAudit: false` for a caller that projects `FOLDER_DELETED` itself — + * an application use case attributes the entry to the acting `Principal`, + * which the `actorId: userId` entry below cannot express for a non-human + * principal. Omitted, the orchestration keeps recording its own entry, so + * every existing caller is unchanged. + * + * `notify: false` for a caller deleting several folders in one gesture that + * sends a single batch notification of its own — see {@link bulkDeleteFolders}. + */ + options?: { projectAudit?: boolean; notify?: boolean } +): Promise { const existing = await withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { const [row] = await tx .select({ deletedAt: folderTable.deletedAt }) @@ -790,8 +815,8 @@ export async function deleteFolder(params: DeleteFolderParams): Promise { expect(collectDescendantFolderIds([], 'x')).toEqual([]) }) }) + +describe('collectDescendantFolderIdsFrom', () => { + /** + * The index-once path is what a bulk plan walks, so it must answer exactly + * what the rebuild-per-call path answers — including for the cycle case. + */ + it('matches the rebuild-per-call helper for every node in a tree', () => { + const index = indexFolderChildren(tree) + + for (const node of [...tree, { id: 'missing', parentId: null }]) { + expect(collectDescendantFolderIdsFrom(index, node.id).sort()).toEqual( + collectDescendantFolderIds(tree, node.id).sort() + ) + } + }) + + it('is reusable across folders without being rebuilt', () => { + const index = indexFolderChildren(tree) + + expect(collectDescendantFolderIdsFrom(index, 'a').sort()).toEqual(['a1', 'a1x', 'a2']) + expect(collectDescendantFolderIdsFrom(index, 'a').sort()).toEqual(['a1', 'a1x', 'a2']) + expect(collectDescendantFolderIdsFrom(index, 'b')).toEqual([]) + }) + + it('terminates on a parent cycle', () => { + const cyclic: FolderNode[] = [ + { id: 'x', parentId: 'y' }, + { id: 'y', parentId: 'x' }, + ] + + expect(collectDescendantFolderIdsFrom(indexFolderChildren(cyclic), 'x')).toEqual(['y']) + }) +}) + +describe('indexFolderChildren', () => { + it('keys children by parent and drops roots', () => { + const index = indexFolderChildren(tree) + + expect(index.get('root')).toEqual(['a', 'b']) + expect(index.get('a')).toEqual(['a1', 'a2']) + expect(index.has('other')).toBe(false) + }) +}) diff --git a/apps/sim/lib/folders/subtree.ts b/apps/sim/lib/folders/subtree.ts index 87cc3ec3285..42e727dd9cc 100644 --- a/apps/sim/lib/folders/subtree.ts +++ b/apps/sim/lib/folders/subtree.ts @@ -4,16 +4,18 @@ export interface FolderNode { parentId: string | null } +/** Child ids keyed by parent id — the shape a descendant walk reads. */ +export type FolderChildrenIndex = ReadonlyMap + /** - * Returns every descendant of `folderId` from a flat folder list, excluding `folderId` - * itself. The caller supplies the rows, so this stays a pure function usable against a - * query result, a transaction snapshot, or test fixtures. + * Indexes a flat folder list by parent id. * - * Indexes children by parent once up front rather than rescanning the list per level, and - * tracks `seen` so a cycle (which the DB permits between constraint checks) terminates the - * walk instead of recursing forever. + * Exported so a caller walking many folders of the same list builds the index + * once instead of once per folder: {@link collectDescendantFolderIds} rebuilds + * it on every call, which is O(rows) each time, and a workspace's tree is + * bounded only by `MAX_FOLDERS_PER_WORKSPACE`. */ -export function collectDescendantFolderIds(folders: FolderNode[], folderId: string): string[] { +export function indexFolderChildren(folders: readonly FolderNode[]): FolderChildrenIndex { const childrenByParent = new Map() for (const folder of folders) { @@ -23,6 +25,20 @@ export function collectDescendantFolderIds(folders: FolderNode[], folderId: stri else childrenByParent.set(folder.parentId, [folder.id]) } + return childrenByParent +} + +/** + * Returns every descendant of `folderId` from a prebuilt {@link FolderChildrenIndex}, + * excluding `folderId` itself. + * + * Tracks `seen` so a cycle (which the DB permits between constraint checks) terminates the + * walk instead of recursing forever. + */ +export function collectDescendantFolderIdsFrom( + childrenByParent: FolderChildrenIndex, + folderId: string +): string[] { const descendants: string[] = [] const seen = new Set([folderId]) @@ -38,3 +54,17 @@ export function collectDescendantFolderIds(folders: FolderNode[], folderId: stri return descendants } + +/** + * Returns every descendant of `folderId` from a flat folder list, excluding `folderId` + * itself. The caller supplies the rows, so this stays a pure function usable against a + * query result, a transaction snapshot, or test fixtures. + * + * Indexes children by parent once up front rather than rescanning the list per level. A + * caller resolving descendants for several folders of the SAME list should index once with + * {@link indexFolderChildren} and walk with {@link collectDescendantFolderIdsFrom} instead, + * so the index is not rebuilt and discarded per folder. + */ +export function collectDescendantFolderIds(folders: FolderNode[], folderId: string): string[] { + return collectDescendantFolderIdsFrom(indexFolderChildren(folders), folderId) +} diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index b9ae9031575..d07c1c86c27 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -77,6 +77,13 @@ export const internalKnowledgeErrorPolicies = { update: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to update knowledge base')), delete: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to delete knowledge base')), restore: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')), + /** + * Workspace-scoped bulk routes. Deliberately not concealed: the request names + * a workspace, not one knowledge base, and per-item authorization failures + * are already folded into the response's `notFound` list by the use case. + */ + bulkMove: internalKnowledgeErrorPolicy('Failed to move knowledge bases'), + bulkDelete: internalKnowledgeErrorPolicy('Failed to delete knowledge bases'), default: internalKnowledgeErrorPolicy('Internal server error'), documents: concealKnowledgeBase( internalKnowledgeErrorPolicy('Failed to process knowledge document request') diff --git a/apps/sim/lib/knowledge/application/batch-policy.ts b/apps/sim/lib/knowledge/application/batch-policy.ts index 3e995996c01..03fb857c2d2 100644 --- a/apps/sim/lib/knowledge/application/batch-policy.ts +++ b/apps/sim/lib/knowledge/application/batch-policy.ts @@ -1,12 +1,27 @@ +import { + type BatchExecutionResult, + type BatchTerminalFailure, + requireBoundedResourceSelection, + rethrowBatchTerminalFailure, +} from '@/lib/core/application/batch-policy' import { OrchestrationError } from '@/lib/core/orchestration/types' - -export const MAX_KNOWLEDGE_BATCH_ITEMS = 100 +import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' export const ADD_WORKSPACE_FILES_COST_POLICY = { maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, usageAdmission: 'once_before_processing', } as const +export const BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +export const BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + export const BULK_DELETE_KNOWLEDGE_BASES_COST_POLICY = { maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, execution: 'sequential_best_effort', @@ -17,17 +32,16 @@ export const BULK_DELETE_KNOWLEDGE_DOCUMENTS_COST_POLICY = { execution: 'sequential_best_effort', } as const -export interface KnowledgeBatchTerminalFailure { - error: unknown -} +/** Domain names for the shared batch shapes, so call sites read in knowledge terms. */ +export type KnowledgeBatchTerminalFailure = BatchTerminalFailure +export type KnowledgeBatchExecutionResult = BatchExecutionResult -export interface KnowledgeBatchExecutionResult { - terminalFailure?: KnowledgeBatchTerminalFailure -} - -export function rethrowKnowledgeBatchTerminalFailure(result: KnowledgeBatchExecutionResult): void { - if (result.terminalFailure) throw result.terminalFailure.error -} +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export const rethrowKnowledgeBatchTerminalFailure: (result: KnowledgeBatchExecutionResult) => void = + rethrowBatchTerminalFailure export function requireBoundedKnowledgeBatch( items: readonly string[], @@ -45,3 +59,25 @@ export function requireBoundedKnowledgeBatch( } return [...new Set(items)] } + +export interface BoundedKnowledgeSelection { + knowledgeBaseIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed knowledge-base/folder selection before any + * protected row is loaded. The cap is on the combined count — see + * {@link requireBoundedResourceSelection}. + */ +export function requireBoundedKnowledgeSelection( + knowledgeBaseIds: readonly string[], + folderIds: readonly string[], + maxItems: number +): BoundedKnowledgeSelection { + const selection = requireBoundedResourceSelection(knowledgeBaseIds, folderIds, maxItems, { + singular: 'knowledge base', + plural: 'knowledge bases', + }) + return { knowledgeBaseIds: selection.resourceIds, folderIds: selection.folderIds } +} diff --git a/apps/sim/lib/knowledge/application/bulk.test.ts b/apps/sim/lib/knowledge/application/bulk.test.ts new file mode 100644 index 00000000000..077a9fd8f14 --- /dev/null +++ b/apps/sim/lib/knowledge/application/bulk.test.ts @@ -0,0 +1,335 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + bulkDeleteFolders: vi.fn(), + bulkMoveFolders: vi.fn(), + deleteRecord: vi.fn(), + findActiveFolder: vi.fn(), + knowledgeBaseDeleted: vi.fn(), + planFolderSelection: vi.fn(), + resolveKnowledgeBase: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspace: vi.fn(), + updateRecord: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base', FOLDER: 'folder' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDeleted: mocks.knowledgeBaseDeleted }, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/folders/bulk', () => ({ + planFolderSelection: mocks.planFolderSelection, + bulkMoveFolders: mocks.bulkMoveFolders, + bulkDeleteFolders: mocks.bulkDeleteFolders, + /** Pure projection — mirrored here rather than mocked, so outcomes stay realistic. */ + foldFolderPlan: ( + plan: { notFound: string[]; contained: { id: string; name: string }[] }, + outcome: { + notFound: { kind: string; id: string }[] + skipped: { kind: string; id: string; name: string }[] + } + ) => { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) + }, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, + resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, +})) +vi.mock('@/lib/knowledge/service', () => ({ + updateKnowledgeBase: mocks.updateRecord, + deleteKnowledgeBase: mocks.deleteRecord, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { bulkDeleteKnowledgeItems, bulkMoveKnowledgeItems } from '@/lib/knowledge/application/bulk' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + +function knowledgeContext(id: string, folderId: string | null = null) { + return { + ...workspaceContext, + knowledgeBaseId: id, + knowledgeBase: { id, name: `Base ${id}`, workspaceId: 'workspace-1', folderId }, + } +} + +const emptyPlan = { selected: [], notFound: [], contained: [], covered: new Set() } + +describe('knowledge bulk application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mocks.resolveKnowledgeBase.mockImplementation( + async ({ knowledgeBaseId }: { knowledgeBaseId: string }) => knowledgeContext(knowledgeBaseId) + ) + mocks.updateRecord.mockImplementation(async (id: string) => ({ id, name: `Base ${id}` })) + mocks.deleteRecord.mockResolvedValue(undefined) + mocks.bulkMoveFolders.mockResolvedValue({ succeeded: [], failed: [] }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [], + failed: [], + folderCount: 0, + resourceCount: 0, + }) + }) + + it('rejects an empty selection before the canonical workspace load', async () => { + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', knowledgeBaseIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('bounds knowledge bases and folders against one combined cap', async () => { + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: Array.from({ length: 60 }, (_, index) => `knowledge-${index}`), + folderIds: Array.from({ length: 60 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + }) + + it('deletes knowledge bases and folders in one operation and audits every affected item', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Policies' }], + notFound: [], + contained: [], + covered: new Set(['folder-1']), + }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Policies' }], + failed: [], + folderCount: 3, + resourceCount: 4, + }) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.deleted).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + { kind: 'folder', id: 'folder-1', name: 'Policies' }, + ]) + expect(result.deletedItems).toEqual({ knowledgeBases: 5, folders: 3 }) + expect(mocks.audit).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'knowledge_base.deleted', resourceId: 'knowledge-1' }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'folder.deleted', resourceId: 'folder-1' }) + ) + expect(mocks.knowledgeBaseDeleted).toHaveBeenCalledExactlyOnceWith({ + knowledgeBaseId: 'knowledge-1', + }) + }) + + /** + * The whole point of taking both id lists in one request: a knowledge base + * that is also inside a selected folder must be deleted exactly once, by the + * folder's cascade. + */ + it('skips a knowledge base that a selected folder already carries', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Policies' }], + notFound: [], + contained: [], + covered: new Set(['folder-1', 'folder-child']), + }) + mocks.resolveKnowledgeBase.mockImplementation( + async ({ knowledgeBaseId }: { knowledgeBaseId: string }) => + knowledgeContext(knowledgeBaseId, 'folder-child') + ) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.skipped).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + ]) + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('conceals an inaccessible knowledge base as not-found rather than naming it', async () => { + mocks.resolveKnowledgeBase.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Knowledge base not found') + ) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['workspace-2-knowledge'], + folderIds: [], + }, + }) + + expect(result.notFound).toEqual([{ kind: 'knowledgeBase', id: 'workspace-2-knowledge' }]) + expect(result.failed).toEqual([]) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination folder is not in the workspace', async () => { + mocks.findActiveFolder.mockResolvedValue(null) + + await expect( + bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: [], + targetFolderId: 'foreign-folder', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.updateRecord).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination sits inside the moving subtree', async () => { + // `covered` is the selected folders plus their descendants. Without an up-front check the + // knowledge bases move, the folders then fail their own cycle check, and the caller is left + // with a half-applied selection. + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: [], + contained: [], + covered: new Set(['folder-2', 'folder-2-child']), + }) + + for (const targetFolderId of ['folder-2', 'folder-2-child']) { + await expect( + bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-2'], + targetFolderId, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + + expect(mocks.updateRecord).not.toHaveBeenCalled() + expect(mocks.bulkMoveFolders).not.toHaveBeenCalled() + }) + + it('moves knowledge bases and folders in one operation', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: ['ghost-folder'], + contained: [{ id: 'folder-3', name: 'Nested' }], + covered: new Set(['folder-2', 'folder-3']), + }) + mocks.bulkMoveFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-2', name: 'Archive' }], + failed: [], + }) + + const result = await bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-2', 'folder-3', 'ghost-folder'], + targetFolderId: 'folder-1', + }, + }) + + expect(result.moved).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + { kind: 'folder', id: 'folder-2', name: 'Archive' }, + ]) + expect(result.skipped).toEqual([{ kind: 'folder', id: 'folder-3', name: 'Nested' }]) + expect(result.notFound).toEqual([{ kind: 'folder', id: 'ghost-folder' }]) + expect(mocks.updateRecord).toHaveBeenCalledWith( + 'knowledge-1', + { folderId: 'folder-1' }, + 'request-1', + { assertedWorkspaceId: 'workspace-1' } + ) + }) + + it('records audit for the committed prefix before rethrowing an infrastructure failure', async () => { + mocks.deleteRecord.mockImplementation(async (knowledgeBaseId: string) => { + if (knowledgeBaseId === 'knowledge-2') throw new Error('connection reset') + }) + + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1', 'knowledge-2', 'knowledge-3'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ action: 'knowledge_base.deleted', resourceId: 'knowledge-1' }) + ) + expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/bulk.ts b/apps/sim/lib/knowledge/application/bulk.ts new file mode 100644 index 00000000000..3f8aaf2d776 --- /dev/null +++ b/apps/sim/lib/knowledge/application/bulk.ts @@ -0,0 +1,415 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { + bulkDeleteFolders, + bulkMoveFolders, + foldFolderPlan, + planFolderSelection, +} from '@/lib/folders/bulk' +import { findActiveFolder } from '@/lib/folders/queries' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + type BoundedKnowledgeSelection, + BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY, + BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY, + type KnowledgeBatchExecutionResult, + requireBoundedKnowledgeSelection, + rethrowKnowledgeBatchTerminalFailure, +} from '@/lib/knowledge/application/batch-policy' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeBaseContext, + type KnowledgeWorkspaceContext, + resolveActiveKnowledgeBaseContext, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' + +const logger = createLogger('KnowledgeBulkApplication') + +const KNOWLEDGE_FOLDER_RESOURCE_TYPE = 'knowledge_base' as const + +export type BulkKnowledgeItemKind = 'knowledgeBase' | 'folder' + +export interface BulkKnowledgeItem { + kind: BulkKnowledgeItemKind + id: string + name: string +} + +export interface BulkKnowledgeFailure extends BulkKnowledgeItem { + reason: string +} + +/** An id the batch could not resolve. No name, because nothing was found to name. */ +export interface BulkKnowledgeMissing { + kind: BulkKnowledgeItemKind + id: string +} + +interface BulkKnowledgeContext extends KnowledgeWorkspaceContext, BoundedKnowledgeSelection {} + +export interface BulkMoveKnowledgeItemsInput { + assertedWorkspaceId: string + knowledgeBaseIds: string[] + folderIds: string[] + targetFolderId: string | null + source?: string +} + +export interface BulkDeleteKnowledgeItemsInput { + assertedWorkspaceId: string + knowledgeBaseIds: string[] + folderIds: string[] + source?: string +} + +interface BulkKnowledgeOutcome { + skipped: BulkKnowledgeItem[] + notFound: BulkKnowledgeMissing[] + failed: BulkKnowledgeFailure[] +} + +export interface BulkMoveKnowledgeItemsResult extends BulkKnowledgeOutcome { + moved: BulkKnowledgeItem[] +} + +export interface BulkDeleteKnowledgeItemsResult extends BulkKnowledgeOutcome { + deleted: BulkKnowledgeItem[] + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: { knowledgeBases: number; folders: number } +} + +interface BulkMoveKnowledgeItemsExecutionResult + extends BulkMoveKnowledgeItemsResult, + KnowledgeBatchExecutionResult {} +interface BulkDeleteKnowledgeItemsExecutionResult + extends BulkDeleteKnowledgeItemsResult, + KnowledgeBatchExecutionResult {} + +async function resolveBulkKnowledgeContext( + input: { assertedWorkspaceId: string; knowledgeBaseIds: string[]; folderIds: string[] }, + maxItems: number +): Promise { + const selection = requireBoundedKnowledgeSelection( + input.knowledgeBaseIds, + input.folderIds, + maxItems + ) + return { + ...(await resolveKnowledgeWorkspaceContext({ workspaceId: input.assertedWorkspaceId })), + ...selection, + } +} + +/** + * Walks the knowledge-base half of the selection. + * + * A base filed inside one of the selected folders is skipped: the folder + * operation already carries it, and acting on it separately would either pull + * it out of the folder it is travelling with or archive it under a second + * timestamp its folder's restore could never recover. + */ +async function runKnowledgeItems( + knowledgeBaseIds: readonly string[], + workspaceId: string, + covered: ReadonlySet, + authorize: (canonical: ActiveKnowledgeBaseContext) => Promise, + apply: (canonical: ActiveKnowledgeBaseContext) => Promise, + succeeded: BulkKnowledgeItem[], + outcome: BulkKnowledgeOutcome +): Promise { + for (const knowledgeBaseId of knowledgeBaseIds) { + let knowledgeBaseName = knowledgeBaseId + try { + const canonical = await resolveActiveKnowledgeBaseContext({ + knowledgeBaseId, + assertedWorkspaceId: workspaceId, + }) + knowledgeBaseName = canonical.knowledgeBase.name + const folderId = canonical.knowledgeBase.folderId + if (folderId && covered.has(folderId)) { + outcome.skipped.push({ + kind: 'knowledgeBase', + id: canonical.knowledgeBaseId, + name: knowledgeBaseName, + }) + continue + } + await authorize(canonical) + succeeded.push({ + kind: 'knowledgeBase', + id: canonical.knowledgeBaseId, + name: await apply(canonical), + }) + } catch (error) { + const disposition = classifyBulkItemError(error) + if (disposition.kind === 'notFound') { + outcome.notFound.push({ kind: 'knowledgeBase', id: knowledgeBaseId }) + continue + } + if (disposition.kind === 'failed') { + outcome.failed.push({ + kind: 'knowledgeBase', + id: knowledgeBaseId, + name: knowledgeBaseName, + reason: disposition.reason, + }) + continue + } + return disposition.error + } + } + return undefined +} + +export const bulkMoveKnowledgeItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkMoveItems, + resolveContext: ({ input }: { input: BulkMoveKnowledgeItemsInput }) => + resolveBulkKnowledgeContext(input, BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY.maxItems), + async execute({ principal, input, context }): Promise { + /** + * The destination check and the folder plan read different rows and share + * no data, so they overlap rather than serialize. Both still complete + * before anything is written: an invalid target must fail the whole request + * rather than leave half the selection moved. + */ + const [targetFolder, plan] = await Promise.all([ + input.targetFolderId === null + ? null + : findActiveFolder( + input.targetFolderId, + context.workspaceId, + KNOWLEDGE_FOLDER_RESOURCE_TYPE + ), + planFolderSelection(context.workspaceId, KNOWLEDGE_FOLDER_RESOURCE_TYPE, context.folderIds), + ]) + if (input.targetFolderId !== null && !targetFolder) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + + /** + * The target must not be inside the subtree that is moving. `plan.covered` is exactly the + * selected folders plus their descendants, so this rejects both "into itself" and "into its + * own child" before anything is written. Without it the resources move, the folders then fail + * their cycle check, and the caller is left with a half-applied selection. + * + * This is a fast-fail optimization, not the enforcement point. It reads a snapshot taken + * outside the folder mutation lock, so a concurrent reparent can invalidate it between the + * check and the write. The invariant itself is enforced where it must be — `updateFolder` + * re-checks `wouldCreateFolderCycle` inside `acquireFolderMutationLock`, so a cycle is never + * created. Losing that race costs a reported per-folder `failed` alongside resources that + * did move, which is the batch's documented `sequential_best_effort` outcome, not corruption. + */ + if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into itself or one of its own subfolders' + ) + } + + const moved: BulkKnowledgeItem[] = [] + const outcome: BulkKnowledgeOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + const terminalError = await runKnowledgeItems( + context.knowledgeBaseIds, + context.workspaceId, + plan.covered, + (canonical) => + authorizeWorkspaceOperation(principal, knowledgeOperations.bulkMoveItems, canonical, { + delegation: knowledgeDelegationPolicy, + }), + async (canonical) => + ( + await updateKnowledgeBase( + canonical.knowledgeBaseId, + { folderId: input.targetFolderId }, + generateRequestId(), + { assertedWorkspaceId: context.workspaceId } + ) + ).name, + moved, + outcome + ) + + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkMoveFolders({ + workspaceId: context.workspaceId, + resourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + userId: resolveKnowledgeAttributedUserId(principal, context), + folders: plan.selected, + targetParentId: input.targetFolderId, + }) + for (const folder of folders.succeeded) moved.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + } + + logger.info('Bulk moved knowledge bases and folders', { + workspaceId: context.workspaceId, + moved: moved.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + }) + return { + moved, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + }, + projectAudit: ({ input, result }) => + result.moved.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved knowledge base folder "${item.name}" to the workspace root` + : `Moved knowledge base folder "${item.name}" into another folder`, + metadata: { + source: input.source, + folderResourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + parentId: input.targetFolderId, + bulk: true, + }, + } + : { + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved knowledge base "${item.name}" to the workspace root` + : `Moved knowledge base "${item.name}" into a folder`, + metadata: { + source: input.source, + updatedFields: ['folderId'], + folderId: input.targetFolderId, + bulk: true, + }, + } + ), + afterSuccess: ({ result }) => { + rethrowKnowledgeBatchTerminalFailure(result) + }, +}) + +export const bulkDeleteKnowledgeItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkDeleteItems, + resolveContext: ({ input }: { input: BulkDeleteKnowledgeItemsInput }) => + resolveBulkKnowledgeContext(input, BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY.maxItems), + async execute({ principal, context }): Promise { + const plan = await planFolderSelection( + context.workspaceId, + KNOWLEDGE_FOLDER_RESOURCE_TYPE, + context.folderIds + ) + + const deleted: BulkKnowledgeItem[] = [] + const outcome: BulkKnowledgeOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + const terminalError = await runKnowledgeItems( + context.knowledgeBaseIds, + context.workspaceId, + plan.covered, + (canonical) => + authorizeWorkspaceOperation(principal, knowledgeOperations.bulkDeleteItems, canonical, { + delegation: knowledgeDelegationPolicy, + }), + async (canonical) => { + await deleteKnowledgeBase(canonical.knowledgeBaseId, generateRequestId(), { + assertedWorkspaceId: context.workspaceId, + }) + return canonical.knowledgeBase.name + }, + deleted, + outcome + ) + + const deletedItems = { knowledgeBases: deleted.length, folders: 0 } + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkDeleteFolders({ + workspaceId: context.workspaceId, + resourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + userId: resolveKnowledgeAttributedUserId(principal, context), + folders: plan.selected, + countKey: 'knowledgeBases', + }) + for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + deletedItems.folders = folders.folderCount + deletedItems.knowledgeBases += folders.resourceCount + } + + logger.info('Bulk deleted knowledge bases and folders', { + workspaceId: context.workspaceId, + deleted: deleted.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + deletedItems, + }) + return { + deleted, + deletedItems, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + }, + /** + * One entry per item the batch actually deleted. A folder's entry carries the + * cascade counts rather than one entry per cascaded knowledge base, matching + * what `DELETE /api/folders/[id]` already records for a single folder — a + * cascade is unbounded, and per-resource entries would let one request write + * thousands of audit rows. + */ + projectAudit: ({ input, result }) => + result.deleted.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: `Deleted knowledge base folder "${item.name}"`, + metadata: { + source: input.source, + folderResourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + affected: result.deletedItems, + bulk: true, + }, + } + : { + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: item.id, + resourceName: item.name, + description: `Deleted knowledge base "${item.name}"`, + metadata: { source: input.source, knowledgeBaseName: item.name, bulk: true }, + } + ), + afterSuccess: ({ result }) => { + try { + for (const item of result.deleted) { + if (item.kind === 'knowledgeBase') { + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: item.id }) + } + } + } finally { + rethrowKnowledgeBatchTerminalFailure(result) + } + }, +}) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 88d80335a64..392e0ada0dd 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -11,7 +11,8 @@ import { PrincipalKindAuthorizationError, type WorkspaceOperation, } from '@/lib/core/application' -import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' @@ -553,24 +554,20 @@ export const bulkDeleteKnowledgeBases = defineAuthorizedKnowledgeUseCase({ if (input.cancellationSignal?.aborted) break deleted.push(await executeDeleteKnowledgeBase({ context: canonical })) } catch (error) { - const classified = asOrchestrationError(error) - if ( - classified?.code === 'not_found' || - classified?.code === 'forbidden' || - classified?.code === 'unauthorized' - ) { + const disposition = classifyBulkItemError(error) + if (disposition.kind === 'notFound') { notFound.push(knowledgeBaseId) continue } - if (classified && classified.code !== 'internal') { + if (disposition.kind === 'failed') { failed.push({ id: knowledgeBaseId, name: knowledgeBaseName, - reason: classified.message, + reason: disposition.reason, }) continue } - terminalFailure = { error } + terminalFailure = { error: disposition.error } break } } diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 64a1c433876..69b9fb11f8d 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -16,6 +16,8 @@ describe('knowledge operation registry', () => { 'knowledge.create', 'knowledge.update', 'knowledge.delete', + 'knowledge.bulk_move_items', + 'knowledge.bulk_delete_items', 'knowledge.bulk_delete', 'knowledge.vfs.rename', 'knowledge.vfs.delete', diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 40c58d8b40d..e0931460074 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -65,6 +65,18 @@ export const knowledgeOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + bulkMoveItems: defineWorkspaceOperation({ + id: 'knowledge.bulk_move_items', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), + bulkDeleteItems: defineWorkspaceOperation({ + id: 'knowledge.bulk_delete_items', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), bulkDelete: defineWorkspaceOperation({ id: 'knowledge.bulk_delete', minimumRole: 'write', diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 45e4aa1cc60..7b75bc6ae17 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -6,6 +6,13 @@ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 export const MAX_KNOWLEDGE_BASES_PER_WORKSPACE = 10_000 /** Hard bound for path-indexed knowledge folder trees and recursive cascades. */ export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = MAX_FOLDERS_PER_WORKSPACE + +/** + * Maximum items a knowledge bulk request may address by identifier. Lives here + * rather than in the application batch policy so the boundary contracts can + * bound their id arrays without pulling a server-only module into client code. + */ +export const MAX_KNOWLEDGE_BATCH_ITEMS = 100 /** Hard bound for connector-type rows projected onto one knowledge-base list. */ export const MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST = 100_000 /** Maximum documents accepted by one internal bulk-create command. */ diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index ab0303bb014..679c0deb676 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -72,6 +72,14 @@ const internalTableGroupErrorPolicy = extendInternalErrorPolicy( * authorization failures behind the same not-found wording. */ export const internalTableErrorPolicies = { + /** + * Workspace-scoped bulk routes. They name a workspace, not one table, so + * there is no table whose existence a 403 could betray — per-item + * authorization failures are already folded into the response's `notFound` + * list by the use case. A lock that escapes the per-item classifier still + * renders as 423. + */ + bulk: internalTableGroupErrorPolicy, concealTableAuthorization: createInternalResourceConcealmentPolicy({ base: internalOrchestrationErrorPolicy, notFoundMessage: 'Table not found', diff --git a/apps/sim/lib/table/application/batch-policy.ts b/apps/sim/lib/table/application/batch-policy.ts new file mode 100644 index 00000000000..5c5e22c2d45 --- /dev/null +++ b/apps/sim/lib/table/application/batch-policy.ts @@ -0,0 +1,57 @@ +import { + type BatchExecutionResult, + type BatchTerminalFailure, + requireBoundedResourceSelection, + rethrowBatchTerminalFailure, +} from '@/lib/core/application/batch-policy' +import { MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' + +/** + * Bulk table operations run one authorized single-table mutation per item and + * report a per-item outcome, matching the knowledge domain's + * `sequential_best_effort` bulk policy. There is no single-statement archive or + * re-parent primitive that could make the batch atomic: archiving a table + * cascades, and each item is authorized against its own canonical row. + */ +export const BULK_MOVE_TABLES_COST_POLICY = { + maxItems: MAX_TABLE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +export const BULK_DELETE_TABLES_COST_POLICY = { + maxItems: MAX_TABLE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +/** Domain names for the shared batch shapes, so call sites read in table terms. */ +export type TableBatchTerminalFailure = BatchTerminalFailure +export type TableBatchExecutionResult = BatchExecutionResult + +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export const rethrowTableBatchTerminalFailure: (result: TableBatchExecutionResult) => void = + rethrowBatchTerminalFailure + +export interface BoundedTableSelection { + tableIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed table/folder selection before any protected + * row is loaded. The cap is on the combined count — see + * {@link requireBoundedResourceSelection}. + */ +export function requireBoundedTableSelection( + tableIds: readonly string[], + folderIds: readonly string[], + maxItems: number +): BoundedTableSelection { + const selection = requireBoundedResourceSelection(tableIds, folderIds, maxItems, { + singular: 'table', + plural: 'tables', + }) + return { tableIds: selection.resourceIds, folderIds: selection.folderIds } +} diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts new file mode 100644 index 00000000000..221ef7be4b0 --- /dev/null +++ b/apps/sim/lib/table/application/bulk.test.ts @@ -0,0 +1,408 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + bulkDeleteFolders: vi.fn(), + bulkMoveFolders: vi.fn(), + deleteTable: vi.fn(), + findActiveFolder: vi.fn(), + moveTableToFolder: vi.fn(), + planFolderSelection: vi.fn(), + resolvePermission: vi.fn(), + resolveTableContext: vi.fn(), + resolveWorkspaceContext: vi.fn(), + signal: vi.fn(), + notifyTables: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + TABLE_DELETED: 'table.deleted', + TABLE_UPDATED: 'table.updated', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + }, + AuditResourceType: { TABLE: 'table', FOLDER: 'folder' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/folders/bulk', () => ({ + planFolderSelection: mocks.planFolderSelection, + bulkMoveFolders: mocks.bulkMoveFolders, + bulkDeleteFolders: mocks.bulkDeleteFolders, + /** Pure projection — mirrored here rather than mocked, so outcomes stay realistic. */ + foldFolderPlan: ( + plan: { notFound: string[]; contained: { id: string; name: string }[] }, + outcome: { + notFound: { kind: string; id: string }[] + skipped: { kind: string; id: string; name: string }[] + } + ) => { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) + }, +})) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceTablesChanged: mocks.notifyTables, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/table', () => ({ + deleteTable: mocks.deleteTable, + moveTableToFolder: mocks.moveTableToFolder, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { bulkDeleteTables, bulkMoveTables } from '@/lib/table/application/bulk' +import { TableLockedError } from '@/lib/table/mutation-locks' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + +function tableContext(id: string, folderId: string | null = null) { + return { + ...workspaceContext, + tableId: id, + table: { id, name: `Table ${id}`, workspaceId: 'workspace-1', folderId }, + } +} + +const emptyPlan = { selected: [], notFound: [], contained: [], covered: new Set() } + +describe('table bulk application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mocks.resolveTableContext.mockImplementation(async ({ tableId }: { tableId: string }) => + tableContext(tableId) + ) + mocks.moveTableToFolder.mockResolvedValue({ name: 'Moved' }) + mocks.deleteTable.mockResolvedValue({ + archived: { name: 'Archived', workspaceId: 'workspace-1' }, + }) + mocks.bulkMoveFolders.mockResolvedValue({ succeeded: [], failed: [] }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [], + failed: [], + folderCount: 0, + resourceCount: 0, + }) + }) + + it('rejects an empty selection before the canonical workspace load', async () => { + await expect( + bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.deleteTable).not.toHaveBeenCalled() + }) + + it('bounds tables and folders against one combined cap', async () => { + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: Array.from({ length: 60 }, (_, index) => `table-${index}`), + folderIds: Array.from({ length: 60 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + }) + + it('deletes tables and folders in one operation and audits every affected item', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Reports' }], + notFound: [], + contained: [], + covered: new Set(['folder-1']), + }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Reports' }], + failed: [], + folderCount: 2, + resourceCount: 5, + }) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.deleted).toEqual([ + { kind: 'table', id: 'table-1', name: 'Archived' }, + { kind: 'folder', id: 'folder-1', name: 'Reports' }, + ]) + expect(result.deletedItems).toEqual({ tables: 6, folders: 2 }) + expect(mocks.audit).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'table.deleted', resourceId: 'table-1' }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'folder.deleted', resourceId: 'folder-1' }) + ) + }) + + /** + * The whole point of taking both id lists in one request: a table that is + * also inside a selected folder must be archived exactly once, under the + * folder's cascade timestamp, or the folder's restore could never recover it. + */ + it('skips a table that a selected folder already carries', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Reports' }], + notFound: [], + contained: [], + covered: new Set(['folder-1', 'folder-child']), + }) + mocks.resolveTableContext.mockImplementation(async ({ tableId }: { tableId: string }) => + tableContext(tableId, 'folder-child') + ) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.skipped).toEqual([{ kind: 'table', id: 'table-1', name: 'Table table-1' }]) + expect(mocks.deleteTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalledWith( + expect.objectContaining({ action: 'table.deleted' }) + ) + }) + + it('reports a locked table as a per-item failure without stranding the rest', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-locked') throw new TableLockedError('delete') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-locked', 'table-2'], + folderIds: [], + }, + }) + + expect(result.failed).toHaveLength(1) + expect(result.failed[0]).toMatchObject({ kind: 'table', id: 'table-locked' }) + expect(result.deleted).toEqual([{ kind: 'table', id: 'table-2', name: 'Archived' }]) + }) + + it('conceals an inaccessible table as not-found rather than naming it', async () => { + mocks.resolveTableContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Table not found') + ) + + const result = await bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: ['other-workspace'], folderIds: [] }, + }) + + expect(result.notFound).toEqual([{ kind: 'table', id: 'other-workspace' }]) + expect(result.failed).toEqual([]) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination folder is not in the workspace', async () => { + mocks.findActiveFolder.mockResolvedValue(null) + + await expect( + bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: [], + targetFolderId: 'foreign-folder', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.moveTableToFolder).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination sits inside the moving subtree', async () => { + // `covered` is the selected folders plus their descendants. Without an up-front check the + // tables move, the folders then fail their own cycle check, and the caller is left with a + // half-applied selection. + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: [], + contained: [], + covered: new Set(['folder-2', 'folder-2-child']), + }) + + for (const targetFolderId of ['folder-2', 'folder-2-child']) { + await expect( + bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-2'], + targetFolderId, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + + expect(mocks.moveTableToFolder).not.toHaveBeenCalled() + expect(mocks.bulkMoveFolders).not.toHaveBeenCalled() + }) + + it('moves tables and folders in one operation', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: ['ghost-folder'], + contained: [{ id: 'folder-3', name: 'Nested' }], + covered: new Set(['folder-2', 'folder-3']), + }) + mocks.bulkMoveFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-2', name: 'Archive' }], + failed: [], + }) + + const result = await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-2', 'folder-3', 'ghost-folder'], + targetFolderId: 'folder-1', + }, + }) + + expect(result.moved).toEqual([ + { kind: 'table', id: 'table-1', name: 'Moved' }, + { kind: 'folder', id: 'folder-2', name: 'Archive' }, + ]) + expect(result.skipped).toEqual([{ kind: 'folder', id: 'folder-3', name: 'Nested' }]) + expect(result.notFound).toEqual([{ kind: 'folder', id: 'ghost-folder' }]) + expect(mocks.bulkMoveFolders).toHaveBeenCalledWith( + expect.objectContaining({ targetParentId: 'folder-1' }) + ) + expect(mocks.signal).toHaveBeenCalledExactlyOnceWith('table-1') + }) + + /** + * One gesture, one live-list broadcast. A per-item notify is an internal HTTP + * round trip with an identical body, so a 100-item batch would otherwise make + * every connected client refetch the same list 100 times. + */ + it('suppresses the per-table notify and sends exactly one for the batch', async () => { + await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2', 'table-3'], + folderIds: [], + targetFolderId: 'folder-1', + }, + }) + + expect(mocks.moveTableToFolder).toHaveBeenCalledTimes(3) + for (const call of mocks.moveTableToFolder.mock.calls) { + expect(call[4]).toEqual({ notify: false }) + } + expect(mocks.notifyTables).toHaveBeenCalledExactlyOnceWith('workspace-1') + }) + + it('still notifies for the prefix a batch committed before it failed', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-2') throw new Error('connection reset') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.notifyTables).toHaveBeenCalledExactlyOnceWith('workspace-1') + }) + + it('sends no notify when the batch archived nothing', async () => { + mocks.resolveTableContext.mockRejectedValue( + new OrchestrationError('not_found', 'Table not found') + ) + + await bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: ['ghost'], folderIds: [] }, + }) + + expect(mocks.notifyTables).not.toHaveBeenCalled() + }) + + it('records audit for the committed prefix before rethrowing an infrastructure failure', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-2') throw new Error('connection reset') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2', 'table-3'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ action: 'table.deleted', resourceId: 'table-1' }) + ) + expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts new file mode 100644 index 00000000000..5b895d92662 --- /dev/null +++ b/apps/sim/lib/table/application/bulk.ts @@ -0,0 +1,442 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { type BulkItemDisposition, classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + bulkDeleteFolders, + bulkMoveFolders, + foldFolderPlan, + planFolderSelection, +} from '@/lib/folders/bulk' +import { findActiveFolder } from '@/lib/folders/queries' +import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' +import { deleteTable, moveTableToFolder } from '@/lib/table' +import { authorizeTableOperation } from '@/lib/table/application/authorization' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + type BoundedTableSelection, + BULK_DELETE_TABLES_COST_POLICY, + BULK_MOVE_TABLES_COST_POLICY, + requireBoundedTableSelection, + rethrowTableBatchTerminalFailure, + type TableBatchExecutionResult, +} from '@/lib/table/application/batch-policy' +import { + type ActiveTableContext, + resolveActiveTableContext, + resolveTableWorkspaceContext, + type TableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { TableLockedError } from '@/lib/table/mutation-locks' + +const logger = createLogger('TableBulkApplication') + +const TABLE_FOLDER_RESOURCE_TYPE = 'table' as const + +export type BulkTableItemKind = 'table' | 'folder' + +export interface BulkTableItem { + kind: BulkTableItemKind + id: string + name: string +} + +export interface BulkTableFailure extends BulkTableItem { + reason: string +} + +/** An id the batch could not resolve. No name, because nothing was found to name. */ +export interface BulkTableMissing { + kind: BulkTableItemKind + id: string +} + +interface BulkTablesContext extends TableWorkspaceContext, BoundedTableSelection {} + +export interface BulkMoveTablesInput { + assertedWorkspaceId: string + tableIds: string[] + folderIds: string[] + targetFolderId: string | null +} + +export interface BulkDeleteTablesInput { + assertedWorkspaceId: string + tableIds: string[] + folderIds: string[] +} + +interface BulkTablesOutcome { + skipped: BulkTableItem[] + notFound: BulkTableMissing[] + failed: BulkTableFailure[] +} + +export interface BulkMoveTablesResult extends BulkTablesOutcome { + moved: BulkTableItem[] +} + +export interface BulkDeleteTablesResult extends BulkTablesOutcome { + deleted: BulkTableItem[] + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: { tables: number; folders: number } +} + +interface BulkMoveTablesExecutionResult extends BulkMoveTablesResult, TableBatchExecutionResult {} +interface BulkDeleteTablesExecutionResult + extends BulkDeleteTablesResult, + TableBatchExecutionResult {} + +async function resolveBulkTablesContext( + input: { assertedWorkspaceId: string; tableIds: string[]; folderIds: string[] }, + maxItems: number +): Promise { + const selection = requireBoundedTableSelection(input.tableIds, input.folderIds, maxItems) + return { + ...(await resolveTableWorkspaceContext(input.assertedWorkspaceId)), + ...selection, + } +} + +/** + * A lock is a per-table verdict, not an infrastructure fault: one locked table + * must not strand the rest of the selection. `TableLockedError` is an + * `HttpError`, so it never carries an orchestration code of its own and the + * shared classification cannot see it. + */ +function tableLockVerdict(error: unknown): BulkItemDisposition | undefined { + if (error instanceof TableLockedError) return { kind: 'failed', reason: error.message } + return undefined +} + +/** + * Resolves the destination folder once, before anything is written, so an + * invalid target fails the whole request rather than leaving half the selection + * moved. Scoped to `resourceType: 'table'` so a folder id from another + * resource's tree cannot file tables somewhere the Tables list never renders. + */ +async function requireTableFolder(workspaceId: string, folderId: string | null): Promise { + if (folderId === null) return + if (!(await findActiveFolder(folderId, workspaceId, TABLE_FOLDER_RESOURCE_TYPE))) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } +} + +/** + * Sends ONE live-list notification for the whole batch. + * + * Every per-table notify is an internal HTTP round trip with an identical body + * and broadcasts an identical workspace-wide invalidation, so a per-item + * fan-out would make every connected client refetch the same list once per + * item, and — with a 2s timeout each — could stall a 100-item request for + * minutes when the socket pod is unreachable. The per-item notifies are + * therefore suppressed at the mutation and replaced by this one call, made from + * a `finally` so a batch that ends early still announces what it did commit. + * + * Folder items are excluded: `bulkMoveFolders`/`bulkDeleteFolders` send their + * own single folder-resource notification, which fans out to the same room. + */ +async function notifyBatchedTableChanges( + workspaceId: string, + items: readonly BulkTableItem[] +): Promise { + if (items.some((item) => item.kind === 'table')) { + await notifyWorkspaceTablesChanged(workspaceId) + } +} + +/** + * Walks the table half of the selection. + * + * A table filed inside one of the selected folders is skipped: the folder + * operation already carries it, and acting on it separately would either pull + * it out of the folder it is travelling with or archive it under a second + * timestamp its folder's restore could never recover. + */ +async function runTableItems( + tableIds: readonly string[], + workspaceId: string, + covered: ReadonlySet, + authorize: (canonical: ActiveTableContext) => Promise, + /** Runs against an already-authorized canonical table. Returns its authoritative name. */ + apply: (canonical: ActiveTableContext) => Promise, + succeeded: BulkTableItem[], + outcome: BulkTablesOutcome +): Promise { + for (const tableId of tableIds) { + let tableName = tableId + try { + const canonical = await resolveActiveTableContext({ + tableId, + assertedWorkspaceId: workspaceId, + }) + tableName = canonical.table.name + if (canonical.table.folderId && covered.has(canonical.table.folderId)) { + outcome.skipped.push({ kind: 'table', id: canonical.table.id, name: tableName }) + continue + } + await authorize(canonical) + succeeded.push({ + kind: 'table', + id: canonical.table.id, + name: await apply(canonical), + }) + } catch (error) { + const disposition = classifyBulkItemError(error, tableLockVerdict) + if (disposition.kind === 'notFound') { + outcome.notFound.push({ kind: 'table', id: tableId }) + continue + } + if (disposition.kind === 'failed') { + outcome.failed.push({ + kind: 'table', + id: tableId, + name: tableName, + reason: disposition.reason, + }) + continue + } + return disposition.error + } + } + return undefined +} + +export const bulkMoveTables = defineAuthorizedTableUseCase({ + operation: tableOperations.bulkMove, + resolveContext: ({ input }: { input: BulkMoveTablesInput }) => + resolveBulkTablesContext(input, BULK_MOVE_TABLES_COST_POLICY.maxItems), + async execute({ principal, input, context }): Promise { + /** + * The destination check and the folder plan read different rows and share + * no data, so they overlap rather than serialize. Both still complete + * before anything is written: an invalid target must fail the whole request + * rather than leave half the selection moved. + */ + const [, plan] = await Promise.all([ + requireTableFolder(context.workspaceId, input.targetFolderId), + planFolderSelection(context.workspaceId, TABLE_FOLDER_RESOURCE_TYPE, context.folderIds), + ]) + + /** + * The target must not be inside the subtree that is moving. `plan.covered` is exactly the + * selected folders plus their descendants, so this rejects both "into itself" and "into its + * own child" before anything is written. Without it the tables move, the folders then fail + * their cycle check, and the caller is left with a half-applied selection. + * + * This is a fast-fail optimization, not the enforcement point. It reads a snapshot taken + * outside the folder mutation lock, so a concurrent reparent can invalidate it between the + * check and the write. The invariant itself is enforced where it must be — `updateFolder` + * re-checks `wouldCreateFolderCycle` inside `acquireFolderMutationLock`, so a cycle is never + * created. Losing that race costs a reported per-folder `failed` alongside resources that + * did move, which is the batch's documented `sequential_best_effort` outcome, not corruption. + */ + if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into itself or one of its own subfolders' + ) + } + + const moved: BulkTableItem[] = [] + const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + try { + const terminalError = await runTableItems( + context.tableIds, + context.workspaceId, + plan.covered, + (canonical) => authorizeTableOperation(principal, tableOperations.bulkMove, canonical), + async (canonical) => + ( + await moveTableToFolder( + canonical.table.id, + context.workspaceId, + input.targetFolderId, + generateRequestId(), + { notify: false } + ) + ).name, + moved, + outcome + ) + + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkMoveFolders({ + workspaceId: context.workspaceId, + resourceType: TABLE_FOLDER_RESOURCE_TYPE, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + folders: plan.selected, + targetParentId: input.targetFolderId, + }) + for (const folder of folders.succeeded) moved.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + } + + logger.info('Bulk moved tables and folders', { + workspaceId: context.workspaceId, + moved: moved.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + }) + return { + moved, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + } finally { + await notifyBatchedTableChanges(context.workspaceId, moved) + } + }, + projectAudit: ({ input, result }) => + result.moved.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved table folder "${item.name}" to the workspace root` + : `Moved table folder "${item.name}" into another folder`, + metadata: { + folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, + parentId: input.targetFolderId, + bulk: true, + }, + } + : { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved table "${item.name}" to the workspace root` + : `Moved table "${item.name}" into a folder`, + metadata: { op: 'move', folderId: input.targetFolderId, bulk: true }, + } + ), + afterSuccess: ({ result }) => { + try { + for (const item of result.moved) { + if (item.kind === 'table') signalTableSchemaChanged(item.id) + } + } finally { + rethrowTableBatchTerminalFailure(result) + } + }, +}) + +export const bulkDeleteTables = defineAuthorizedTableUseCase({ + operation: tableOperations.bulkDelete, + resolveContext: ({ input }: { input: BulkDeleteTablesInput }) => + resolveBulkTablesContext(input, BULK_DELETE_TABLES_COST_POLICY.maxItems), + async execute({ principal, context }): Promise { + const plan = await planFolderSelection( + context.workspaceId, + TABLE_FOLDER_RESOURCE_TYPE, + context.folderIds + ) + + const deleted: BulkTableItem[] = [] + const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + try { + const terminalError = await runTableItems( + context.tableIds, + context.workspaceId, + plan.covered, + (canonical) => authorizeTableOperation(principal, tableOperations.bulkDelete, canonical), + async (canonical) => { + const { archived } = await deleteTable(canonical.table.id, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + skipNotify: true, + }) + if (!archived) throw new OrchestrationError('not_found', 'Table not found') + return archived.name + }, + deleted, + outcome + ) + + const deletedItems = { tables: deleted.length, folders: 0 } + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkDeleteFolders({ + workspaceId: context.workspaceId, + resourceType: TABLE_FOLDER_RESOURCE_TYPE, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + folders: plan.selected, + countKey: 'tables', + }) + for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + deletedItems.folders = folders.folderCount + deletedItems.tables += folders.resourceCount + } + + logger.info('Bulk archived tables and folders', { + workspaceId: context.workspaceId, + deleted: deleted.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + deletedItems, + }) + return { + deleted, + deletedItems, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + } finally { + await notifyBatchedTableChanges(context.workspaceId, deleted) + } + }, + /** + * One entry per item the batch actually archived. A folder's entry carries + * the cascade counts rather than one entry per cascaded table, matching what + * `DELETE /api/folders/[id]` already records for a single folder — a cascade + * is unbounded, and per-resource entries would let one request write + * thousands of audit rows. + */ + projectAudit: ({ result }) => + result.deleted.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: `Deleted table folder "${item.name}"`, + metadata: { + folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, + affected: result.deletedItems, + bulk: true, + }, + } + : { + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: item.id, + resourceName: item.name, + description: `Archived table "${item.name}"`, + metadata: { bulk: true }, + } + ), + afterSuccess: ({ result }) => { + rethrowTableBatchTerminalFailure(result) + }, +}) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index dd590edd4f7..42476b3783a 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -80,6 +80,8 @@ export const tableOperations = { create: writeOperation('tables.create'), update: writeOperation('tables.update'), delete: writeOperation('tables.delete'), + bulkMove: writeOperation('tables.bulk_move'), + bulkDelete: writeOperation('tables.bulk_delete'), renameByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.rename', minimumRole: 'write', diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 7ab2e7aa098..b53a1faecee 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -5,6 +5,13 @@ import { randomInt, randomItem } from '@sim/utils/random' import { env, envNumber } from '@/lib/core/config/env' +/** + * Maximum tables addressable by identifier in one bulk request. Matches the + * knowledge domain's `MAX_KNOWLEDGE_BATCH_ITEMS` so a multi-select on either + * list page is capped the same way. + */ +export const MAX_TABLE_BATCH_ITEMS = 100 + export const TABLE_LIMITS = { MAX_TABLES_PER_WORKSPACE: 100, MAX_ROWS_PER_TABLE: 10000, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 842a0a84d75..95d3ff94939 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -945,7 +945,15 @@ export async function moveTableToFolder( tableId: string, workspaceId: string, folderId: string | null, - requestId: string + requestId: string, + /** + * `notify: false` for a caller moving several tables in one gesture that + * sends a single batch notification of its own. Each notify is an internal + * HTTP round trip with an identical body and triggers an identical + * workspace-wide invalidation, so a per-item fan-out makes every connected + * client refetch the same list once per moved table. + */ + options?: { notify?: boolean } ): Promise<{ name: string }> { const updates: Partial = { folderId, @@ -981,7 +989,7 @@ export async function moveTableToFolder( logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) // Live tables list: a move changes each table's folder placement in the list result. - await notifyWorkspaceTablesChanged(workspaceId) + if (options?.notify ?? true) await notifyWorkspaceTablesChanged(workspaceId) return { name } } diff --git a/packages/emcn/src/components/chip/chip-chrome.ts b/packages/emcn/src/components/chip/chip-chrome.ts index fa8d4754819..e8318f98343 100644 --- a/packages/emcn/src/components/chip/chip-chrome.ts +++ b/packages/emcn/src/components/chip/chip-chrome.ts @@ -73,6 +73,16 @@ export const chipContentLabelClass = 'min-w-0 truncate text-[var(--text-body)] t export const chipHoverSurfaceClass = 'hover-hover:bg-[var(--surface-hover)]' /** @see {@link chipHoverSurfaceClass} — the selected half of the same pair. */ export const chipActiveSurfaceClass = 'bg-[var(--surface-active)]' +/** + * The third row surface: a drag is over this row and releasing would file into it. + * + * Neutral by design — hue is not how this app signals "release here"; the workflow sidebar's + * own drop affordance is a `--text-subtle` tint. Drawn inside the element's own box so the ring + * never overlaps its neighbours. Hand-rolled rows and breadcrumb crumbs import this rather than + * restating the literal, so every drop destination reads identically. + */ +export const chipDropTargetSurfaceClass = + 'bg-[var(--surface-4)] outline outline-1 outline-[var(--text-subtle)] outline-offset-[-1px]' /** * The disclosure chevron that rotates to expand or collapse a sidebar section or a * tree row: 14px at `--text-icon`, animating on the same 150ms curve the section diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 1b23439bf8b..1f4f62aba98 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -24,6 +24,7 @@ export { chipContentGap, chipContentIconClass, chipContentLabelClass, + chipDropTargetSurfaceClass, chipFieldSurfaceClass, chipFieldTextClass, chipFilledFillTokens, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 95effcf86d2..5d0c704a75e 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: 1121, - zodRoutes: 1121, + totalRoutes: 1125, + zodRoutes: 1125, nonZodRoutes: 0, } as const From 54c5922c47f22e92f8417c59591b8d9032fe4f40 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 11:53:42 -0700 Subject: [PATCH 091/103] Overflow counts read '+ n', dropping 'more' --- .../message-content/components/agent-group/agent-group.tsx | 2 +- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index b0aa7beeaac..00b3a81fe37 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -135,7 +135,7 @@ export function AgentGroup({ runningCount += 1 } } - if (running) return runningCount > 1 ? `${running} + ${runningCount - 1} more` : running + if (running) return runningCount > 1 ? `${running} + ${runningCount - 1}` : running return lastAny })() const headerText = status ? `${agentLabel} — ${status}` : agentLabel diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 6c4230559e6..71f4cddcdc5 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -659,7 +659,7 @@ function waitAgentsTitle(args: ToolArgs): string { const anyMode = stringArg(args, 'mode') === 'any' if (names.length === 1) return `Waiting for ${names[0]}` if (names.length > 1) { - const listed = `${names[0]} + ${names.length - 1} more` + const listed = `${names[0]} + ${names.length - 1}` return anyMode ? `Waiting for the first of ${listed}` : `Waiting for ${listed}` } return 'Waiting for agents' From 95aa84d5ee364ca1ec47d6ce77ccd25456cf316c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 12:00:52 -0700 Subject: [PATCH 092/103] Unify workspace find and search --- apps/desktop/src/main/menu.test.ts | 26 ++- apps/desktop/src/main/menu.ts | 50 ++-- .../components/find-bar/find-bar.test.tsx | 183 +++++++++++++++ .../components/find-bar/find-bar.tsx | 153 ++++++++++++ .../[workspaceId]/components/index.ts | 3 + .../components/resource/resource.tsx | 14 +- .../search-highlight/search-highlight.tsx | 0 .../workspace-chrome/workspace-chrome.tsx | 52 ++++- .../workspace/[workspaceId]/files/files.tsx | 116 +++++++++- .../[workspaceId]/home/hooks/use-chat.test.ts | 28 +++ .../[workspaceId]/home/hooks/use-chat.ts | 63 ++++- .../knowledge/[id]/[documentId]/document.tsx | 8 +- .../[workspaceId]/knowledge/[id]/base.tsx | 7 +- .../knowledge/[id]/components/index.ts | 1 - .../[id]/components/search-highlight/index.ts | 1 - .../components/table-grid/constants.ts | 15 +- .../components/table-grid/data-row.tsx | 34 ++- .../components/table-grid/table-find.tsx | 107 --------- .../components/table-grid/table-grid.tsx | 219 +++++++++++++++--- .../tables/[tableId]/search-params.ts | 9 + apps/sim/hooks/queries/tables.ts | 17 +- apps/sim/hooks/use-smooth-text.test.tsx | 36 ++- apps/sim/hooks/use-smooth-text.ts | 41 +++- .../workflows/search-replace/indexer.test.ts | 40 ++++ .../lib/workflows/search-replace/indexer.ts | 15 +- .../search-replace/resources/references.ts | 18 +- .../search-replace/resources/resolvers.ts | 6 +- 27 files changed, 1074 insertions(+), 188 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx rename apps/sim/app/workspace/[workspaceId]/{knowledge/[id] => }/components/search-highlight/search-highlight.tsx (100%) delete mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index faf1648b413..7cd6257f6e9 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -62,20 +62,27 @@ describe('buildMenuTemplate', () => { 'separator', 'quit', ]) - expect(submenu(template, 'File').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + const file = submenu(template, 'File') + expect( + file.filter((item) => item.visible !== false).map((item) => item.label ?? item.type) + ).toEqual(['New Window', 'New Chat', 'separator', 'Close Window']) + // Resource-scoped shortcuts stay registered but never appear in the menu. + expect(file.filter((item) => item.visible === false).map((item) => item.label)).toEqual([ 'New Tab', - 'New Window', - 'New Chat', - 'separator', 'Reopen Closed Tab', 'Focus Address Bar', - 'separator', 'Next Tab', 'Previous Tab', - 'Select Tab', - 'separator', + 'Tab 1', + 'Tab 2', + 'Tab 3', + 'Tab 4', + 'Tab 5', + 'Tab 6', + 'Tab 7', + 'Tab 8', + 'Last Tab', 'Close Tab', - 'Close Window', ]) expect(submenu(template, 'View').map((item) => item.label ?? item.role ?? item.type)).toEqual([ 'Search', @@ -152,7 +159,6 @@ describe('buildMenuTemplate', () => { }) ) const file = submenu(template, 'File') - const selectTabs = submenu(file, 'Select Tab') const focusedWindow = new BrowserWindow() const invoke = (item: MenuItemConstructorOptions | undefined) => (item?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)( @@ -162,7 +168,7 @@ describe('buildMenuTemplate', () => { invoke(file.find((item) => item.accelerator === 'Ctrl+Tab')) invoke(file.find((item) => item.accelerator === 'Ctrl+Shift+Tab')) - invoke(selectTabs.find((item) => item.accelerator === 'CmdOrCtrl+9')) + invoke(file.find((item) => item.accelerator === 'CmdOrCtrl+9')) expect(handleFocusedResourceShortcut).toHaveBeenNthCalledWith(1, focusedWindow, 'next-tab') expect(handleFocusedResourceShortcut).toHaveBeenNthCalledWith(2, focusedWindow, 'previous-tab') diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 25d4a4c6025..c747447f091 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -63,6 +63,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] return { label: number === 9 ? 'Last Tab' : `Tab ${number}`, accelerator: `CmdOrCtrl+${number}`, + visible: false, click: resourceShortcut(shortcut), } }) @@ -153,13 +154,6 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'File', submenu: [ - { - label: 'New Tab', - accelerator: 'CmdOrCtrl+T', - click: (_item, focusedWindow) => { - deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'new-tab') - }, - }, { label: 'New Window', accelerator: 'CmdOrCtrl+Shift+N', @@ -167,9 +161,35 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] }, { label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: deps.newChat }, { type: 'separator' }, + { + label: 'Close Window', + accelerator: 'CmdOrCtrl+Shift+W', + click: (_item, focusedWindow) => { + const win = focusedOrMain(focusedWindow) + if (win && !win.isDestroyed()) win.close() + }, + }, + /** + * Resource-scoped shortcuts: these act on whichever Browser/Terminal + * panel is focused, not on the app, so they stay out of the visible + * File menu. The accelerators still fire — macOS registers a hidden + * item's accelerator (`acceleratorWorksWhenHidden` defaults to true). + * The numbered tab items sit flat here rather than under a "Select + * Tab" submenu because children of a hidden submenu do not reliably + * register their accelerators. + */ + { + label: 'New Tab', + accelerator: 'CmdOrCtrl+T', + visible: false, + click: (_item, focusedWindow) => { + deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'new-tab') + }, + }, { label: 'Reopen Closed Tab', accelerator: 'CmdOrCtrl+Shift+T', + visible: false, click: (_item, focusedWindow) => { deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'reopen-closed-tab') }, @@ -177,37 +197,31 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'Focus Address Bar', accelerator: 'CmdOrCtrl+L', + visible: false, click: resourceShortcut('focus-omnibox'), }, - { type: 'separator' }, { label: 'Next Tab', accelerator: 'Ctrl+Tab', + visible: false, click: resourceShortcut('next-tab'), }, { label: 'Previous Tab', accelerator: 'Ctrl+Shift+Tab', + visible: false, click: resourceShortcut('previous-tab'), }, - { label: 'Select Tab', submenu: numberedTabItems }, - { type: 'separator' }, + ...numberedTabItems, { label: 'Close Tab', accelerator: 'CmdOrCtrl+W', + visible: false, click: (_item, focusedWindow) => { const win = focusedOrMain(focusedWindow) deps.handleFocusedResourceShortcut(win, 'close-tab') }, }, - { - label: 'Close Window', - accelerator: 'CmdOrCtrl+Shift+W', - click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (win && !win.isDestroyed()) win.close() - }, - }, ], }, { role: 'editMenu' }, diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.test.tsx new file mode 100644 index 00000000000..6c2e74dbee7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.test.tsx @@ -0,0 +1,183 @@ +/** + * @vitest-environment jsdom + * + * The find bar's contract with the user: results follow typing (no Enter to + * discover), the counter says which state the search is in, and Enter navigates + * rather than submits. + */ +import { act, createRef, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + Button: ({ children, ...props }: { children: ReactNode } & Record) => ( + + ), + ChipInput: ({ + endAdornment, + icon: _icon, + ...props + }: { endAdornment?: ReactNode } & Record) => ( + <> + + {endAdornment} + + ), +})) + +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => , + ChevronUp: () => , + Loader: () => , + Search: () => , + X: () => , +})) + +import { + FindBar, + type FindBarProps, +} from '@/app/workspace/[workspaceId]/components/find-bar/find-bar' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function render(overrides: Partial = {}) { + const props: FindBarProps = { + ariaLabel: 'Find in table', + query: '', + onQueryChange: vi.fn(), + onNext: vi.fn(), + onPrev: vi.fn(), + onClose: vi.fn(), + count: 0, + currentIndex: 0, + truncated: false, + isLoading: false, + inputRef: createRef(), + ...overrides, + } + act(() => root.render()) + return props +} + +function input(): HTMLInputElement { + const el = container.querySelector('input') + if (!el) throw new Error('find input not rendered') + return el +} + +function counterText(): string | null { + return container.querySelector('[aria-live="polite"]')?.textContent ?? null +} + +function buttonByLabel(label: string): HTMLButtonElement { + const el = container.querySelector(`button[aria-label="${label}"]`) + if (!el) throw new Error(`no button labelled ${label}`) + return el as HTMLButtonElement +} + +function press(key: string, init: KeyboardEventInit = {}) { + act(() => { + input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })) + }) +} + +describe('FindBar counter', () => { + it('shows nothing before the user has typed', () => { + render({ query: '' }) + expect(counterText()).toBe('') + }) + + it('counts matches as 1-based', () => { + render({ query: 'a', count: 12, currentIndex: 0 }) + expect(counterText()).toBe('1 of 12') + render({ query: 'a', count: 12, currentIndex: 11 }) + expect(counterText()).toBe('12 of 12') + }) + + it('marks a server-capped result set', () => { + render({ query: 'a', count: 1000, currentIndex: 0, truncated: true }) + expect(counterText()).toBe('1 of 1000+') + }) + + it('says No results only once the search has settled', () => { + render({ query: 'zzz', count: 0, isLoading: true }) + expect(counterText()).toBe('') + expect(container.querySelector('[data-icon="loader"]')).not.toBeNull() + + render({ query: 'zzz', count: 0, isLoading: false }) + expect(counterText()).toBe('No results') + }) + + // Blanking the tally on each keystroke reads as the search breaking; the + // previous term's count holds until the new one lands. + it('keeps the previous count visible while the next result set loads', () => { + render({ query: 'ab', count: 3, currentIndex: 1, isLoading: true }) + expect(counterText()).toBe('2 of 3') + }) + + it('keeps the counter mounted and width-reserved before the user types', () => { + render({ query: '' }) + const region = container.querySelector('[aria-live="polite"]') + expect(region).not.toBeNull() + expect(region?.className).toContain('min-w-[64px]') + }) +}) + +describe('FindBar keyboard', () => { + it('navigates on Enter rather than submitting a search', () => { + const props = render({ query: 'a', count: 3 }) + press('Enter') + expect(props.onNext).toHaveBeenCalledTimes(1) + expect(props.onPrev).not.toHaveBeenCalled() + }) + + it('steps backwards on Shift+Enter', () => { + const props = render({ query: 'a', count: 3 }) + press('Enter', { shiftKey: true }) + expect(props.onPrev).toHaveBeenCalledTimes(1) + expect(props.onNext).not.toHaveBeenCalled() + }) + + it('closes on Escape', () => { + const props = render({ query: 'a', count: 3 }) + press('Escape') + expect(props.onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe('FindBar controls', () => { + it('offers a clear button only once there is text', () => { + render({ query: '' }) + expect(container.querySelector('button[aria-label="Clear search"]')).toBeNull() + + const props = render({ query: 'abc' }) + act(() => buttonByLabel('Clear search').click()) + expect(props.onQueryChange).toHaveBeenCalledWith('') + }) + + it('disables navigation while there is nothing to navigate', () => { + render({ query: 'zzz', count: 0 }) + expect(buttonByLabel('Next match').disabled).toBe(true) + expect(buttonByLabel('Previous match').disabled).toBe(true) + + render({ query: 'a', count: 2 }) + expect(buttonByLabel('Next match').disabled).toBe(false) + expect(buttonByLabel('Previous match').disabled).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx new file mode 100644 index 00000000000..b3ed292379c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx @@ -0,0 +1,153 @@ +'use client' + +import type React from 'react' +import { memo } from 'react' +import { Button, ChipInput } from '@sim/emcn' +import { ChevronDown, ChevronUp, Loader, Search, X } from '@sim/emcn/icons' + +export interface FindBarProps { + /** Accessible name for the input, naming the surface: "Find in table", "Find in files". */ + ariaLabel: string + query: string + onQueryChange: (query: string) => void + onNext: () => void + onPrev: () => void + onClose: () => void + /** Number of matches after dropping any the current view cannot show. */ + count: number + /** 0-based index of the active match. Ignored when `count` is 0. */ + currentIndex: number + /** Whether the producer capped the match set. */ + truncated: boolean + isLoading: boolean + inputRef: React.RefObject +} + +/** + * The find bar every Cmd/Ctrl+F surface shares (tables, files, ...). Purely + * presentational and fully controlled: the surface owns the query, the match + * model and the stepping; this renders the input, the tally and the + * next/prev/close controls. Positioned absolutely against the nearest + * relative container, top-right, the way an in-page find sits in Chrome. + * + * Memoized: while the bar is open it is a child of a view that re-renders on + * scroll, hover and selection. Every prop is a primitive or a stable + * identity, so this collapses to renders where a find value actually changed. + */ +export const FindBar = memo(function FindBar({ + ariaLabel, + query, + onQueryChange, + onNext, + onPrev, + onClose, + count, + currentIndex, + truncated, + isLoading, + inputRef, +}: FindBarProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + if (e.shiftKey) onPrev() + else onNext() + return + } + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + const hasQuery = query.trim().length > 0 + const hasMatches = count > 0 + + /** The tally holds its last value while the next result set loads — blanking + * it on every keystroke reads as the feature breaking rather than working. */ + function counterContent() { + if (!hasQuery) return null + if (hasMatches) return `${currentIndex + 1} of ${count}${truncated ? '+' : ''}` + return isLoading ? : 'No results' + } + + return ( +
+ onQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + // Untrimmed on purpose: whitespace searches nothing, but it is still + // text the user may want cleared. + endAdornment={ + query.length > 0 ? ( + + ) : undefined + } + /> + {/* Always mounted, reserving its width: rendering it only once there is a + query would resize the bar on the first keystroke, and a live region + inserted together with its text is announced unreliably. */} + + {counterContent()} + + + + +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 16570ad0070..2fc2f841d16 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,6 +1,8 @@ export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' +export type { FindBarProps } from './find-bar/find-bar' +export { FindBar } from './find-bar/find-bar' export { InlineRenameInput } from './inline-rename-input' export { IntegrationTabsHeader } from './integration-tabs-header' export { MessageActions } from './message-actions' @@ -38,4 +40,5 @@ export type { } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' export { ResourceTile } from './resource-tile' +export { SearchHighlight } from './search-highlight/search-highlight' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 87c463c3a63..4723142c047 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -27,6 +27,7 @@ import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inli import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' +import { SearchHighlight } from '@/app/workspace/[workspaceId]/components/search-highlight/search-highlight' export interface ResourceColumn { id: string @@ -68,6 +69,12 @@ export interface ResourceCell { * layout, and the rename field replaces the label entirely while it is open. */ pinned?: boolean + /** + * Find term to tint inside the label (Cmd/Ctrl+F match). Honoured only on the + * plain label cell, like `pinned` — a `content` cell owns its own rendering + * and the rename field replaces the label while open. + */ + highlight?: string } export interface ResourceRow { @@ -499,6 +506,7 @@ interface CellContentProps { content?: ReactNode editing?: ResourceCellEditing pinned?: boolean + highlight?: string } const CellContent = memo(function CellContent({ @@ -507,6 +515,7 @@ const CellContent = memo(function CellContent({ content, editing, pinned, + highlight, }: CellContentProps) { if (editing) { return ( @@ -526,7 +535,9 @@ const CellContent = memo(function CellContent({ return ( {icon && {icon}} - + + {highlight ? : undefined} + {pinned && (
) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx b/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx rename to apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index 38caceaca27..bd2b0dbbf6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' -import { PanelLeft } from '@sim/emcn/icons' +import { ArrowLeft, ArrowRight, PanelLeft } from '@sim/emcn/icons' import { usePathname } from 'next/navigation' import { getDesktopBridge } from '@/lib/desktop' import { applyDesktopTitleBarMode, type DesktopTitleBarMode } from '@/app/_shell/desktop-title-bar' @@ -86,6 +86,55 @@ interface WorkspaceChromeProps { initialSidebarCollapsed?: boolean } +/** Chromium Navigation API slice (absent from TS lib.dom). */ +type ChromiumNavigation = EventTarget & { canGoBack: boolean; canGoForward: boolean } + +const LANE_NAV_BUTTON = + 'flex size-[var(--desktop-title-bar-control-size)] items-center justify-center rounded-lg transition-colors disabled:pointer-events-none disabled:opacity-40 hover-hover:bg-[var(--surface-active)]' +const LANE_NAV_ICON = 'size-[var(--desktop-title-bar-control-icon-size)] text-[var(--text-icon)]' + +/** + * Back/forward history arrows in the desktop title-bar lane, right of the + * sidebar toggle. Only the macOS shell sets the inset attribute, so the web + * app never shows them; the shell's renderer is Chromium, so the Navigation + * API is always there for the arrow state. + */ +function TitleBarHistoryNav() { + const [can, setCan] = useState({ back: false, forward: false }) + + useEffect(() => { + const nav = (window as { navigation?: ChromiumNavigation }).navigation + if (!nav) return + const sync = () => setCan({ back: nav.canGoBack, forward: nav.canGoForward }) + sync() + nav.addEventListener('currententrychange', sync) + return () => nav.removeEventListener('currententrychange', sync) + }, []) + + return ( +
+ + +
+ ) +} + function isFullscreenPath(pathname: string | null): boolean { return FULLSCREEN_SUFFIXES.some((s) => pathname?.endsWith(s)) } @@ -374,6 +423,7 @@ export function WorkspaceChrome({
)} + {!isFullscreen && }
) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..da1fea89382 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -51,12 +51,14 @@ import type { ResourceAction, ResourceColumn, ResourceRow, + ResourceTableHandle, RowDragDropConfig, SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FindBar, ownerCell, Resource, timeCell, @@ -191,6 +193,7 @@ const MIME_TYPE_LABELS: Record = { const EMPTY_WORKSPACE_FILES: WorkspaceFileRecord[] = [] const EMPTY_WORKSPACE_FILE_FOLDERS: WorkspaceFileFolderApi[] = [] +const EMPTY_FIND_MATCH_IDS: readonly string[] = Object.freeze([]) const fileRowId = (id: string) => `file:${id}` const folderRowId = (id: string) => `folder:${id}` @@ -674,6 +677,79 @@ export function Files() { }) }, [baseRows, listRename.editingId, listRename.editValue, listRename.isSaving]) + // Find (Cmd/Ctrl+F): the shared find bar over the visible list, stepping + // through rows whose name matches. The list is client-side, so matching is + // synchronous — no debounce or loading states. + const [findOpen, setFindOpen] = useState(false) + const [findQuery, setFindQuery] = useState('') + const [findIndex, setFindIndex] = useState(0) + const findInputRef = useRef(null) + const tableApiRef = useRef(null) + + const trimmedFindQuery = findQuery.trim().toLowerCase() + const findMatchIds = useMemo(() => { + if (!findOpen || trimmedFindQuery.length === 0) return EMPTY_FIND_MATCH_IDS + const ids = rows + .filter((row) => (row.cells.name?.label ?? '').toLowerCase().includes(trimmedFindQuery)) + .map((row) => row.id) + return ids.length > 0 ? ids : EMPTY_FIND_MATCH_IDS + }, [rows, findOpen, trimmedFindQuery]) + const findMatchIdsRef = useRef(findMatchIds) + findMatchIdsRef.current = findMatchIds + const findIndexRef = useRef(findIndex) + findIndexRef.current = findIndex + + const goToFindMatch = useCallback((index: number) => { + const matches = findMatchIdsRef.current + if (matches.length === 0) return + const wrapped = ((index % matches.length) + matches.length) % matches.length + setFindIndex(wrapped) + tableApiRef.current?.scrollToRow(matches[wrapped]) + }, []) + + /** + * A new term resets to and reveals its first match. Keyed on the term, not + * the match set: rows regenerate on renames, uploads and SSE refreshes, and + * re-revealing then would yank a user who has stepped elsewhere back to + * match one. + */ + useEffect(() => { + setFindIndex(0) + if (trimmedFindQuery.length === 0) return + const first = findMatchIdsRef.current[0] + if (first) tableApiRef.current?.scrollToRow(first) + }, [trimmedFindQuery]) + + const handleFindNext = useCallback(() => { + goToFindMatch(findIndexRef.current + 1) + }, [goToFindMatch]) + + const handleFindPrev = useCallback(() => { + goToFindMatch(findIndexRef.current - 1) + }, [goToFindMatch]) + + /** Closing clears the search: term, highlights and cursor all go. */ + const handleFindClose = useCallback(() => { + setFindOpen(false) + setFindQuery('') + setFindIndex(0) + }, []) + + /** + * Rows for the table, with the active term tinted into matching name cells. + * Layered over `rows` so selection, drag-drop and keyboard nav keep reading + * the canonical list. + */ + const displayRows: ResourceRow[] = useMemo(() => { + if (findMatchIds.length === 0) return rows + const matchSet = new Set(findMatchIds) + return rows.map((row) => + matchSet.has(row.id) + ? { ...row, cells: { ...row.cells, name: { ...row.cells.name, highlight: findQuery } } } + : row + ) + }, [rows, findMatchIds, findQuery]) + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) const prevVisibleRowIdsRef = useRef(visibleRowIds) @@ -1575,6 +1651,28 @@ export function Files() { return () => window.removeEventListener('keydown', handleListKeyDown) }, []) + /** + * Overrides the browser's Cmd/Ctrl+F with the in-list find while the list is + * showing. Skipped when a file is open — its editor owns the shortcut there — + * and when another surface already claimed the press. + */ + useEffect(() => { + const handleFindShortcut = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return + if (e.key.toLowerCase() !== 'f') return + if (fileIdFromRouteRef.current) return + if (e.defaultPrevented) return + e.preventDefault() + setFindOpen(true) + requestAnimationFrame(() => { + findInputRef.current?.focus() + findInputRef.current?.select() + }) + } + document.addEventListener('keydown', handleFindShortcut) + return () => document.removeEventListener('keydown', handleFindShortcut) + }, []) + const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { if (prev === 'editor') return 'split' @@ -2134,13 +2232,29 @@ export function Files() { /> + {findOpen && ( + + )} ({ }), })) +describe('selectDeletedWorkflowResources', () => { + const resource = (id: string) => ({ type: 'workflow' as const, id, title: id }) + const cached = (id: string) => ({ + id, + name: id, + lastModified: new Date(0), + createdAt: new Date(0), + sortOrder: 0, + }) + + it('selects a hydrated workflow the server no longer has', () => { + expect(selectDeletedWorkflowResources([resource('wf-gone')], new Set(), [])).toEqual([ + resource('wf-gone'), + ]) + }) + + it('keeps a workflow present in the fetched list', () => { + expect(selectDeletedWorkflowResources([resource('wf-1')], new Set(['wf-1']), [])).toEqual([]) + }) + + it('keeps a workflow the stream inserted into the cache after the list snapshot', () => { + expect( + selectDeletedWorkflowResources([resource('wf-new')], new Set(), [cached('wf-new')]) + ).toEqual([]) + }) +}) + describe('shouldActivateResourceEvent', () => { it('keeps background browser activity from replacing another selected resource', () => { expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(false) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 6ce4aac3a19..7dec6c4ef60 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -126,8 +126,10 @@ import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { workflowKeys } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' +import { snapAllSmoothText } from '@/hooks/use-smooth-text' import { useExecutionStore } from '@/stores/execution/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' import type { @@ -1189,6 +1191,23 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId return true } +/** + * Hydrated workflow resources whose workflow exists neither in the fetched + * server list nor in the local cache. The cache term protects a workflow the + * agent created after the list snapshot was taken — the stream's registry + * insert lands it in the cache before any refetch does. + */ +export function selectDeletedWorkflowResources( + workflowResources: MothershipResource[], + fetchedWorkflowIds: ReadonlySet, + cachedWorkflows: readonly WorkflowMetadata[] +): MothershipResource[] { + const cachedIds = new Set(cachedWorkflows.map((workflow) => workflow.id)) + return workflowResources.filter( + (resource) => !fetchedWorkflowIds.has(resource.id) && !cachedIds.has(resource.id) + ) +} + export interface ResourceEventOptions { activate?: boolean } @@ -1863,6 +1882,37 @@ export function useChat( } }, []) + /** + * Drops hydrated workflow tabs whose workflow no longer exists, so an old + * chat cannot resurrect a deleted workflow. The check is against a fetched + * workflow list rather than the cache: seeding the registry from the chat's + * persisted resources (what hydration previously did unconditionally) put + * phantom entries in the sidebar that 404 on click. Removal also deletes the + * resource from the chat's persisted set, so the tab stays gone next open. + */ + const reconcileHydratedWorkflowResources = useCallback( + async (chatId: string, workflowResources: MothershipResource[]) => { + let existing: WorkflowMetadata[] + try { + existing = await getQueryClient().fetchQuery(getWorkflowListQueryOptions(workspaceId)) + } catch { + // Existence is unknowable right now; keep the tabs rather than delete + // resources on a network failure. The next hydration retries. + return + } + const deleted = selectDeletedWorkflowResources( + workflowResources, + new Set(existing.map((workflow) => workflow.id)), + getWorkflows(workspaceId) + ) + for (const resource of deleted) { + if ((chatIdRef.current ?? selectedChatIdRef.current) !== chatId) return + removeResource('workflow', resource.id) + } + }, + [workspaceId, removeResource] + ) + const reorderResources = useCallback((newOrder: MothershipResource[]) => { setResources(newOrder) const persistChatId = chatIdRef.current ?? selectedChatIdRef.current @@ -2418,9 +2468,12 @@ export function useChat( setActiveResourceId(hydratedActiveResourceId) } - for (const resource of persistedResources) { - if (resource.type !== 'workflow') continue - ensureWorkflowInRegistry(resource.id, resource.title, workspaceId) + // Restored workflow tabs are verified against the server instead of + // seeded into the registry: a chat can outlive its workflows, and + // fabricating entries for deleted ones polluted the sidebar. + const workflowResources = persistedResources.filter((r) => r.type === 'workflow') + if (workflowResources.length > 0) { + void reconcileHydratedWorkflowResources(chatHistory.id, workflowResources) } } else if (hasPersistedStreamingFile) { activeResourceIdRef.current = null @@ -2505,6 +2558,7 @@ export function useChat( flushPendingResources, openBrowserResource, openTerminalResource, + reconcileHydratedWorkflowResources, recoverPendingClientWorkflowTools, seedPreviewSessions, setTransportIdle, @@ -4619,6 +4673,9 @@ export function useChat( abortControllerRef.current?.abort('user_stop:client_stopGeneration') abortControllerRef.current = null setTransportIdle() + // The paced reveal may still hold up to a drain-horizon of buffered text; + // after an explicit Stop it must not keep typing itself out. + snapAllSmoothText() try { if (activeChatId) { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 974a0896848..00487096bb4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -20,7 +20,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + Resource, + SearchHighlight, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -38,7 +42,7 @@ import { documentParsers, documentUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params' -import { ActionBar, SearchHighlight } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' +import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 3d71ef5e63b..9927879f586 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -50,7 +50,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' +import { + FloatingOverflowText, + Resource, + SearchHighlight, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -66,7 +70,6 @@ import { ConnectorsSection, DocumentContextMenu, RenameDocumentModal, - SearchHighlight, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { addConnectorParam, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts index 12e32ebf736..d26e85dc9e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts @@ -6,4 +6,3 @@ export { ConnectorsSection } from './connectors-section' export { DocumentContextMenu } from './document-context-menu' export { EditConnectorModal } from './edit-connector-modal' export { RenameDocumentModal } from './rename-document-modal' -export { SearchHighlight } from './search-highlight' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts deleted file mode 100644 index 1144ed165cd..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchHighlight } from './search-highlight' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts index f369e93d363..af00af4b441 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts @@ -1,6 +1,15 @@ /** Tailwind class applied to selected rows / columns / cells. */ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]' +/** + * Fill marking every cell matching the active find query. Reuses the app's + * search-highlight token (the knowledge-base search highlight paints with the + * same one) rather than inventing a third match colour, so the two stay + * theme-tuned together. The ACTIVE match is told apart by the selection + * outline drawn over it, not by a different fill. + */ +export const FIND_MATCH_TINT_BG = 'bg-[var(--highlight-match-bg)]' + /** Default column width in pixels. Used as a fallback when a column hasn't * been measured yet and as the initial width for newly-added columns. */ export const COL_WIDTH = 160 @@ -23,5 +32,7 @@ export const CELL_HEADER_CHECKBOX = /** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */ export const CELL_CONTENT = 'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small' -export const SELECTION_OVERLAY = - 'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]' +/** Inset shared by every full-cell overlay, so the tints and the selection + * outline can't drift apart on a border-geometry change. */ +export const CELL_OVERLAY_INSET = 'pointer-events-none absolute -top-px -right-px -bottom-px' +export const SELECTION_OVERLAY = `${CELL_OVERLAY_INSET} z-[5] border-[2px] border-[var(--selection)]` diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index acf192e3002..077f73fb2e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -12,6 +12,8 @@ import { CELL, CELL_CHECKBOX, CELL_CONTENT, + CELL_OVERLAY_INSET, + FIND_MATCH_TINT_BG, SELECTION_OVERLAY, SELECTION_TINT_BG, } from './constants' @@ -67,6 +69,13 @@ export interface DataRowProps { pinnedOffsets?: Map /** Key of the rightmost pinned column, used to render a separator shadow. */ lastPinnedColKey?: string | null + /** + * Column keys in this row matching the active find query, tinted so every hit + * is visible at once rather than only the one being navigated to. Absent when + * the row has no match, which is the common case and keeps this row's memo + * from re-running for a search elsewhere in the table. + */ + findMatchColumns?: ReadonlySet } function cellRangeRowChanged( @@ -128,7 +137,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.workflowGroups !== next.workflowGroups || prev.activeDispatches !== next.activeDispatches || prev.pinnedOffsets !== next.pinnedOffsets || - prev.lastPinnedColKey !== next.lastPinnedColKey + prev.lastPinnedColKey !== next.lastPinnedColKey || + prev.findMatchColumns !== next.findMatchColumns ) { return false } @@ -177,6 +187,7 @@ export const DataRow = React.memo(function DataRow({ activeDispatches, pinnedOffsets, lastPinnedColKey, + findMatchColumns, }: DataRowProps) { const sel = normalizedSelection /** @@ -299,6 +310,7 @@ export const DataRow = React.memo(function DataRow({ const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol const isEditing = editingColumnName === column.key const isHighlighted = inRange || isRowChecked + const isFindMatch = findMatchColumns?.has(column.key) const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked @@ -323,7 +335,7 @@ export const DataRow = React.memo(function DataRow({ data-pinned={isPinnedCell ? '' : undefined} className={cn( CELL, - (isHighlighted || isAnchor || isEditing) && 'relative', + (isHighlighted || isAnchor || isEditing || isFindMatch) && 'relative', isPinnedCell && 'z-[6] bg-[var(--bg)]', isPinnedSeparator && '[box-shadow:2px_0_0_0_var(--border)]' )} @@ -342,10 +354,26 @@ export const DataRow = React.memo(function DataRow({ } onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)} > + {/* No z-index on purpose: with `auto` it paints in DOM order, so it + sits above the cell background but BELOW the cell text, the + selection tint (z-4) and the anchor outline (z-5). The active + match therefore still reads as the selected cell, and the wash + never dims the value it is pointing at. */} + {isFindMatch && ( +
+ )} {isHighlighted && (isMultiCell || isRowChecked) && (
void - /** Run the search (dirty Enter / search button). */ - onSubmit: () => void - onNext: () => void - onPrev: () => void - onClose: () => void - /** Number of matches after dropping columns not in the current view. */ - count: number - /** 0-based index of the active match, or -1 when there are none. */ - currentIndex: number - /** Whether the server capped the match set. */ - truncated: boolean - isLoading: boolean - /** Whether the input differs from the last submitted term. */ - isDirty: boolean - inputRef: React.RefObject -} - -export function TableFind({ - query, - onQueryChange, - onSubmit, - onNext, - onPrev, - onClose, - count, - currentIndex, - truncated, - isLoading, - isDirty, - inputRef, -}: TableFindProps) { - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - if (e.shiftKey) { - onPrev() - } else if (isDirty) { - onSubmit() - } else { - onNext() - } - return - } - if (e.key === 'Escape') { - e.preventDefault() - onClose() - } - } - - const hasMatches = count > 0 - const label = - count === 0 ? 'No results' : `${currentIndex + 1} of ${count}${truncated ? '+' : ''}` - - return ( -
- onQueryChange(e.target.value)} - onKeyDown={handleKeyDown} - /> - - {isLoading ? : label} - - - - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index c2a2e36e3a6..d6d41fc812e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -27,6 +27,8 @@ import { getColumnId } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { FindBar } from '@/app/workspace/[workspaceId]/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' @@ -62,7 +64,6 @@ import { ADD_COL_WIDTH, COL_WIDTH, SELECTION_TINT_BG } from './constants' import { DataRow } from './data-row' import { ColumnHeaderMenu, WorkflowGroupMetaCell } from './headers' import { RemoteSelectionOverlay } from './remote-selection-overlay' -import { TableFind } from './table-find' import { AddRowButton, SelectAllCheckbox, TableColGroup } from './table-primitives' import type { DisplayColumn } from './types' import { @@ -95,6 +96,7 @@ const logger = createLogger('TableView') const EMPTY_RUNNING_BY_ROW: Readonly> = Object.freeze({}) const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([]) +const EMPTY_FIND_MATCH_COLUMNS: ReadonlyMap> = Object.freeze(new Map()) const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([]) const COL_WIDTH_MIN = 80 @@ -484,11 +486,9 @@ export function TableGrid({ const [selectionFocus, setSelectionFocus] = useState(null) const [rowSelection, setRowSelection] = useState(ROW_SELECTION_NONE) const [isColumnSelection, setIsColumnSelection] = useState(false) - // Find (Cmd/Ctrl+F): `findQuery` is the live input, `submittedQuery` is the - // last Enter/search-triggered term the query hook runs on. + // Find (Cmd/Ctrl+F): `findQuery` is the live input. const [findOpen, setFindOpen] = useState(false) const [findQuery, setFindQuery] = useState('') - const [submittedQuery, setSubmittedQuery] = useState('') const [currentMatchIndex, setCurrentMatchIndex] = useState(0) const [isJumping, setIsJumping] = useState(false) // Bumped on every navigation so the reveal effect re-runs even when the target @@ -496,6 +496,20 @@ export function TableGrid({ const [pendingMatchTick, setPendingMatchTick] = useState(0) const findInputRef = useRef(null) const pendingMatchRef = useRef(null) + /** Cell selected when find was opened, restored on close. */ + const preFindAnchorRef = useRef(null) + /** Last cell find itself moved the selection to, so close can tell a match + * cursor apart from a selection the user made while the bar was open. */ + const lastRevealedAnchorRef = useRef(null) + /** Monotonic id for the in-flight match jump; see `goToMatch`. */ + const goToMatchSeqRef = useRef(0) + /** Term the auto-reveal has already run for, so a background refetch of the + * same term doesn't re-jump the viewport. */ + const autoRevealedTermRef = useRef('') + /** Whether the selection currently sits on the match at `currentMatchIndex`. + * False when the auto-reveal was skipped, so next/prev knows to land on that + * index rather than step past it. */ + const cursorIsOnMatchRef = useRef(false) const lastCheckboxRowRef = useRef(null) const isColumnSelectionRef = useRef(false) const [columnWidths, setColumnWidths] = useState>({}) @@ -1098,7 +1112,36 @@ export function TableGrid({ emitCellSelection({ anchor, focus, editing: editingCell !== null }) }, [selectionAnchor, selectionFocus, editingCell, rows, displayColumns, emitCellSelection]) - const { data: findData, isFetching: isFindFetching } = useFindTableRows({ + /** + * The term the search actually runs on: the live input, debounced so results + * follow typing without a request per keystroke. + * + * Owned here rather than via `useDebounce` because closing or clearing has to + * take effect IMMEDIATELY and cancel anything pending. `useDebounce` is + * trailing-edge and keeps serving its last value until the next timer fires, + * so after Esc it still holds the old term — and a guard on the *input* can't + * mask that, because the first keystroke of the next search makes the input + * non-empty again while the debounce is still holding the previous term. The + * result would be the old search replayed from cache (highlights, count and a + * viewport jump) under a box showing one fresh character. Cmd+F, Esc, Cmd+F + * is an ordinary correction, so that window gets hit. + */ + const trimmedFindQuery = findQuery.trim() + const [submittedQuery, setSubmittedQuery] = useState('') + useEffect(() => { + if (!findOpen || trimmedFindQuery.length === 0) { + setSubmittedQuery('') + return + } + const timer = setTimeout(() => setSubmittedQuery(trimmedFindQuery), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [findOpen, trimmedFindQuery]) + + const { + data: findData, + isFetching: isFindFetching, + isPlaceholderData: isFindPlaceholder, + } = useFindTableRows({ workspaceId, tableId, q: submittedQuery, @@ -1113,6 +1156,11 @@ export function TableGrid({ * to a cell that isn't rendered. */ const findMatches = useMemo(() => { + // `keepPreviousData` serves the previous term's matches while a new term + // loads, which is what keeps the counter steady mid-typing — but with an + // empty term the query is disabled, so that placeholder would otherwise + // linger as highlights over a cleared search box. + if (submittedQuery.length === 0) return EMPTY_FIND_MATCHES const raw = findData?.matches if (!raw || raw.length === 0) return EMPTY_FIND_MATCHES // `m.column` is the stable column id (the JSONB storage key); index display @@ -1125,7 +1173,24 @@ export function TableGrid({ a.ordinal - b.ordinal || (colIndexByKey.get(a.column) ?? 0) - (colIndexByKey.get(b.column) ?? 0) ) - }, [findData, displayColumns]) + }, [findData, displayColumns, submittedQuery]) + + /** + * Match column ids grouped by row id, so a row can mark its matching cells in + * O(1) without scanning the whole match list. Rebuilt only when the match set + * changes; `DataRow` is memoized on the per-row `Set`, so rows without a match + * keep the same `undefined` and never re-render for a search. + */ + const findMatchColumnsByRowId = useMemo>>(() => { + if (findMatches.length === 0) return EMPTY_FIND_MATCH_COLUMNS + const byRow = new Map>() + for (const match of findMatches) { + const existing = byRow.get(match.rowId) + if (existing) existing.add(match.column) + else byRow.set(match.rowId, new Set([match.column])) + } + return byRow + }, [findMatches]) const findMatchesRef = useRef(findMatches) findMatchesRef.current = findMatches @@ -1142,11 +1207,16 @@ export function TableGrid({ const match = matches[wrapped] setCurrentMatchIndex(wrapped) setIsJumping(true) + // Paging to a distant match can outlast the next keystroke now that the + // search runs as the user types. Stamp this jump and drop it on return if a + // newer one started, or the grid would land on a superseded term's match. + const seq = ++goToMatchSeqRef.current try { await ensureRowsLoadedUpToRef.current(match.ordinal + 1) } finally { - setIsJumping(false) + if (seq === goToMatchSeqRef.current) setIsJumping(false) } + if (seq !== goToMatchSeqRef.current) return // Defer the anchor set to the reveal effect: it must run after the freshly // loaded rows have committed, else scrollToIndex clamps to the stale count. pendingMatchRef.current = match @@ -1171,35 +1241,124 @@ export function TableGrid({ setIsColumnSelection(false) setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setSelectionFocus(null) + lastRevealedAnchorRef.current = { rowIndex, colIndex } + cursorIsOnMatchRef.current = true setSelectionAnchor({ rowIndex, colIndex }) }, [rows, displayColumns, pendingMatchTick]) - /** New result set (new submitted term) → reset to and reveal the first match. */ + /** + * A new TERM resets to its first match and reveals it. + * + * Keyed on the term, not on `findMatches` identity: the find query hangs off + * the rows cache, so any row write or SSE update refetches it, and keying on + * the result set would yank a user reading match 7 back to match 1 whenever + * a workflow cell landed. + * + * The reveal is skipped when the match is outside the loaded window. + * `ensureRowsLoadedUpTo` pages sequentially, so a selective term whose first + * hit is 50k rows down would fire ~50 serial round trips — per typing pause, + * now that the search is live. Highlights and the count still cover the whole + * table; only the viewport jump waits for a deliberate Enter or next-click. + * + * That deliberate path still runs the same unbounded, uncancellable paging it + * always has; this only stops typing from triggering it. Bounding it properly + * wants a fetch-at-offset on the rows endpoint, which is a server change. + */ useEffect(() => { + if (submittedQuery.length === 0) { + // Clearing the box has to un-latch, or retyping the same term — the + // ordinary "did I typo that?" correction — would match the stale latch + // and neither reset the cursor nor reveal anything. It also cancels an + // in-flight jump, exactly as closing does: otherwise a Next still paging + // when the term is cleared lands on a match whose highlight is gone. + autoRevealedTermRef.current = '' + goToMatchSeqRef.current++ + pendingMatchRef.current = null + cursorIsOnMatchRef.current = false + setIsJumping(false) + return + } + // Wait for THIS term's own result set. `keepPreviousData` leaves + // `findMatches` describing the previous term while the new one loads, and + // on the session's first search there is no previous data at all — so + // `isPlaceholderData` is false while the query is still pending. Latching + // in either window would burn the one auto-reveal this term gets. + if (isFindPlaceholder || isFindFetching) return + if (autoRevealedTermRef.current === submittedQuery) return + autoRevealedTermRef.current = submittedQuery setCurrentMatchIndex(0) - if (findMatches.length > 0) goToMatch(0) - }, [findMatches, goToMatch]) - - const handleFindSubmit = useCallback(() => { - setSubmittedQuery(findQuery.trim()) - }, [findQuery]) + cursorIsOnMatchRef.current = false + const first = findMatches[0] + if (!first) return + if (!rowsRef.current.some((r) => r.id === first.rowId)) return + goToMatch(0) + }, [submittedQuery, findMatches, isFindPlaceholder, isFindFetching, goToMatch]) + /** + * Step to the next/previous match — or, when the cursor is not on a match + * yet, to the current index itself. That second case is the term whose first + * hit the auto-reveal skipped because its row wasn't loaded: `+1` there would + * silently step over the very match the user pressed Enter to reach, and it + * would only come back around after wrapping the whole list. + */ const handleFindNext = useCallback(() => { - goToMatch(currentMatchIndexRef.current + 1) + const index = currentMatchIndexRef.current + goToMatch(cursorIsOnMatchRef.current ? index + 1 : index) }, [goToMatch]) const handleFindPrev = useCallback(() => { - goToMatch(currentMatchIndexRef.current - 1) + const index = currentMatchIndexRef.current + goToMatch(cursorIsOnMatchRef.current ? index - 1 : index) }, [goToMatch]) + /** + * Closes the bar and leaves no trace of the search: the term, the highlights + * (via the emptied term), and the match cursor all go. + * + * The cell the user was on before opening find is restored, so an abandoned + * search does not relocate them — Sheets parks the cursor on the last match + * instead, which is a standing complaint there. Restoring is skipped once the + * user has selected a cell themselves: at that point the selection is their + * own work, not find's, and yanking it back would lose their place. + */ const handleFindClose = useCallback(() => { setFindOpen(false) setFindQuery('') - setSubmittedQuery('') + setCurrentMatchIndex(0) pendingMatchRef.current = null + // Strands any jump still paging toward a match, so it can't reveal a cell + // after the bar is gone. + goToMatchSeqRef.current++ + autoRevealedTermRef.current = '' + cursorIsOnMatchRef.current = false + setIsJumping(false) + const origin = preFindAnchorRef.current + const lastRevealed = lastRevealedAnchorRef.current + preFindAnchorRef.current = null + lastRevealedAnchorRef.current = null + const anchor = selectionAnchorRef.current + // A revealed match is a single cell: find sets the anchor and clears the + // focus. A non-null focus means the user extended a range from it + // (Shift+Arrow, Shift+click, drag), which makes the selection theirs even + // though the anchor still sits on the match — restoring would delete it. + const stillOnMatch = + lastRevealed !== null && + anchor !== null && + selectionFocusRef.current === null && + anchor.rowIndex === lastRevealed.rowIndex && + anchor.colIndex === lastRevealed.colIndex + if (stillOnMatch) { + setSelectionFocus(null) + setSelectionAnchor(origin) + } scrollRef.current?.focus({ preventScroll: true }) }, []) + /** The grid's own Escape handler is bound once and closes find through the + * same path as the bar's Escape, so the two can't drift. */ + const handleFindCloseRef = useRef(handleFindClose) + handleFindCloseRef.current = handleFindClose + const columnRename = useInlineRename({ // `columnName` is the column id; record the prior display name + id so undo // restores the label (not the id) and targets the right column. @@ -1507,6 +1666,10 @@ export function TableGrid({ setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setIsColumnSelection(false) lastCheckboxRowRef.current = null + // Any deliberate click hands the selection back to the user, so closing + // find must not restore over it — including a click on the very cell find + // had revealed, which leaves the anchor and focus looking find-owned. + lastRevealedAnchorRef.current = null if (shiftKey && selectionAnchorRef.current) { setSelectionFocus({ rowIndex, colIndex }) } else { @@ -2566,10 +2729,7 @@ export function TableGrid({ if (e.key === 'Escape') { e.preventDefault() if (findOpenRef.current) { - setFindOpen(false) - setFindQuery('') - setSubmittedQuery('') - pendingMatchRef.current = null + handleFindCloseRef.current() return } if (dragColumnNameRef.current) { @@ -3460,6 +3620,10 @@ export function TableGrid({ if (!(e.metaKey || e.ctrlKey) || e.key !== 'f') return if (!containerRef.current) return e.preventDefault() + // Remember where the user was, but only on the transition into find — + // Cmd+F pressed again while the bar is open (to refocus it) must not + // overwrite the origin cell with the match they are currently on. + if (!findOpenRef.current) preFindAnchorRef.current = selectionAnchorRef.current setFindOpen(true) requestAnimationFrame(() => { findInputRef.current?.focus() @@ -4315,18 +4479,20 @@ export function TableGrid({
{findOpen && ( - )} @@ -4634,6 +4800,7 @@ export function TableGrid({ activeDispatches={activeDispatches} pinnedOffsets={pinnedOffsets.size > 0 ? pinnedOffsets : undefined} lastPinnedColKey={lastPinnedColKey} + findMatchColumns={findMatchColumnsByRowId.get(row.id)} /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts index 5b510dc38e3..dd6776ded92 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts @@ -17,6 +17,15 @@ export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc' * recursive, arbitrarily-nested object (`$or`/`$and` combinators, per-column * operator objects); serializing it would put a large structured blob in the * URL, which the URL-state doctrine forbids. It stays in local `useState`. + * + * The in-grid `find` (Cmd+F) is likewise absent, for a different reason: it is + * a viewport cursor, not a destination. Two things rule it out. It is not one + * value but a cluster — the term, the match cursor, and the cell the user was + * on before opening find — and only the term is serializable; closing restores + * that pre-find cell from an in-memory ref, so a term that survived a reload + * would arrive with no origin to return to. And the search runs on every + * debounced keystroke rather than on submit, which is the write frequency this + * doctrine keeps out of the URL. Same call the browser's own Cmd+F makes. */ export const tableDetailParsers = { sort: parseAsString, diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7423d6b09de..8610e82d923 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -129,6 +129,14 @@ const logger = createLogger('TableQueries') export const TABLE_DETAIL_STALE_TIME = 30 * 1000 export const TABLE_RUN_STATE_STALE_TIME = 30 * 1000 export const TABLE_FIND_STALE_TIME = 30 * 1000 +/** + * Shorter than the 5-minute default: the grid searches as the user types, so + * each typing pause mints its own cache entry holding up to + * `TABLE_LIMITS.MAX_FIND_MATCHES` matches. Long enough that backspacing to a + * recent term is still instant, short enough that a typed-through term set + * doesn't sit resident. + */ +export const TABLE_FIND_GC_TIME = 60 * 1000 export const TABLE_ROWS_STALE_TIME = 30 * 1000 export const TABLE_EXPORT_JOBS_STALE_TIME = 5 * 1000 @@ -469,9 +477,11 @@ async function fetchTableRowMatches({ } /** - * Server-side find across all cells. `q` is the *submitted* term (search is - * Enter-triggered), so React Query caches each submitted term and re-searching - * a prior one is instant. Disabled while `q` is empty. + * Server-side find across all cells. `q` is the term the caller has settled on + * — the grid debounces the live input before passing it — so React Query caches + * each settled term and backspacing to a prior one is instant. Disabled while + * `q` is empty; `keepPreviousData` holds the last result set so the match count + * doesn't blank between terms. */ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: FindTableRowsParams) { const paramsKey = JSON.stringify({ q, filter: filter ?? null, sort: sort ?? null }) @@ -481,6 +491,7 @@ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: Find fetchTableRowMatches({ workspaceId, tableId, q, filter, sort, signal }), enabled: Boolean(workspaceId && tableId) && q.trim().length > 0, staleTime: TABLE_FIND_STALE_TIME, + gcTime: TABLE_FIND_GC_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/use-smooth-text.test.tsx b/apps/sim/hooks/use-smooth-text.test.tsx index b4fe89d9911..c80221ddcef 100644 --- a/apps/sim/hooks/use-smooth-text.test.tsx +++ b/apps/sim/hooks/use-smooth-text.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useSmoothText } from '@/hooks/use-smooth-text' +import { snapAllSmoothText, useSmoothText } from '@/hooks/use-smooth-text' interface ProbeProps { content: string @@ -89,3 +89,37 @@ describe('useSmoothText — streaming that begins on an already-open document', h.unmount() }) }) + +describe('snapAllSmoothText — user Stop must end the paced reveal instantly', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reveals the full backlog immediately when snapped mid-stream', () => { + const probe = renderSmoothText({ content: '', isStreaming: true }) + probe.rerender({ content: 'The quick brown fox jumps over the lazy dog. '.repeat(4) }) + // Paced reveal has not caught up (fake timers hold the frame loop). + expect(probe.value().length).toBeLessThan(180) + + act(() => { + snapAllSmoothText() + }) + expect(probe.value()).toBe('The quick brown fox jumps over the lazy dog. '.repeat(4)) + probe.unmount() + }) + + it('is one-shot: a later stream paces normally again', () => { + const probe = renderSmoothText({ content: '', isStreaming: true }) + act(() => { + snapAllSmoothText() + }) + probe.rerender({ + content: 'Fresh streaming text that should reveal gradually, not snap. '.repeat(3), + }) + expect(probe.value().length).toBeLessThan(180) + probe.unmount() + }) +}) diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts index 0411e8365d8..0b38aa11b47 100644 --- a/apps/sim/hooks/use-smooth-text.ts +++ b/apps/sim/hooks/use-smooth-text.ts @@ -1,4 +1,29 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' + +/** + * Global snap signal: bumping the epoch makes every mounted smooth-text reveal + * jump to its full content immediately. Used by the user Stop path — after an + * explicit abort, watching the buffered tail keep typing itself out reads as + * "my stop didn't work", so the paced reveal must end NOW, not over the drain + * horizon. One-shot per bump: subsequent streams pace normally again. + */ +let snapEpoch = 0 +const snapListeners = new Set<() => void>() + +function subscribeToSnapEpoch(listener: () => void): () => void { + snapListeners.add(listener) + return () => snapListeners.delete(listener) +} + +function getSnapEpoch(): number { + return snapEpoch +} + +/** Snap every mounted smooth-text reveal to its full content immediately. */ +export function snapAllSmoothText(): void { + snapEpoch++ + for (const listener of [...snapListeners]) listener() +} /** * Time-based paced reveal of a growing string. A per-frame loop earns a @@ -111,8 +136,22 @@ export function useSmoothText( const prevContentRef = useRef(content) const prevIsStreamingRef = useRef(isStreaming) + const currentSnapEpoch = useSyncExternalStore(subscribeToSnapEpoch, getSnapEpoch, getSnapEpoch) + const [prevSnapEpoch, setPrevSnapEpoch] = useState(currentSnapEpoch) + let effectiveRevealed = revealed + // A user Stop bumped the snap epoch: reveal everything now instead of + // draining the backlog at the paced cadence. + if (prevSnapEpoch !== currentSnapEpoch) { + setPrevSnapEpoch(currentSnapEpoch) + if (revealed < content.length) { + effectiveRevealed = content.length + revealedRef.current = content.length + setRevealed(content.length) + } + } + if ( isStreaming && !prevIsStreamingRef.current && diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 8b8509107fc..5edcc6bdac4 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -114,6 +114,46 @@ describe('indexWorkflowSearchMatches', () => { expect(blockNameMatches[0]?.fieldTitle).toBe('Block name') }) + it('matches a block name containing a non-breaking space against a typed space', () => { + const workflow = { + blocks: { + 'nbsp-1': { + id: 'nbsp-1', + type: 'function', + name: 'Load\u00a0Prompt', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + } as ReturnType + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'load prompt', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + const blockNameMatches = matches.filter((match) => match.target.kind === 'block-name') + // The raw value keeps the original characters so replacements stay exact. + expect(blockNameMatches.map((match) => match.rawValue)).toEqual(['Load\u00a0Prompt']) + }) + + it('ignores accidental leading/trailing whitespace in the query', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + query: ' agent ', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches.some((match) => match.target.kind === 'block-name')).toBe(true) + }) + it('does not include block-name matches in resource-only mode', () => { const workflow = createSearchReplaceWorkflowFixture() diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 5b5dccf4307..6bbcd11e975 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -10,6 +10,7 @@ import { shouldParseSerializedSubBlockValue, } from '@/lib/workflows/search-replace/json-value-fields' import { + foldSearchWhitespace, getResourceKindForSubBlock, matchesSearchText, parseInlineReferences, @@ -54,8 +55,14 @@ import { type ToolParameterConfig, } from '@/tools/params' +/** + * Whitespace is folded before comparison (see {@link foldSearchWhitespace}): + * the fold is one-to-one, so ranges found in the normalized string index the + * original text correctly. + */ function normalizeForSearch(value: string, caseSensitive: boolean): string { - return caseSensitive ? value : value.toLowerCase() + const folded = foldSearchWhitespace(value) + return caseSensitive ? folded : folded.toLowerCase() } function findTextRanges(value: string, query: string, caseSensitive: boolean) { @@ -1233,7 +1240,7 @@ export function indexWorkflowSearchMatches( ): WorkflowSearchMatch[] { const { workflow, - query, + query: rawQuery, mode = 'all', caseSensitive = false, includeResourceMatchesWithoutQuery = false, @@ -1248,6 +1255,10 @@ export function indexWorkflowSearchMatches( mcpToolNamesById, } = options + // Match on the trimmed query: an accidental leading/trailing space (easy to + // type, impossible to see in the search box) must not hide every match. + const query = rawQuery?.trim() + const matches: WorkflowSearchMatch[] = [] const resourceQueryEnabled = includeResourceMatchesWithoutQuery || Boolean(query) diff --git a/apps/sim/lib/workflows/search-replace/resources/references.ts b/apps/sim/lib/workflows/search-replace/resources/references.ts index ad2619f9e66..d0ba97ce4ee 100644 --- a/apps/sim/lib/workflows/search-replace/resources/references.ts +++ b/apps/sim/lib/workflows/search-replace/resources/references.ts @@ -75,13 +75,27 @@ export function parseStructuredResourceReferences( return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext) } +/** + * Maps every Unicode whitespace character to a plain space, one-to-one. + * Agent-authored block names and values routinely carry non-breaking or + * narrow spaces that render identically to " " but never equal a typed + * space, silently hiding matches. The replacement is length-preserving + * (every `\s` character is a single UTF-16 unit), so indexes into the + * folded string remain valid ranges into the original. + */ +export function foldSearchWhitespace(value: string): string { + return value.replace(/\s/g, ' ') +} + export function matchesSearchText( candidate: string, query: string | undefined, caseSensitive = false ): boolean { if (!query) return true - const source = caseSensitive ? candidate : candidate.toLowerCase() - const target = caseSensitive ? query : query.toLowerCase() + const foldedCandidate = foldSearchWhitespace(candidate) + const foldedQuery = foldSearchWhitespace(query) + const source = caseSensitive ? foldedCandidate : foldedCandidate.toLowerCase() + const target = caseSensitive ? foldedQuery : foldedQuery.toLowerCase() return source.includes(target) } diff --git a/apps/sim/lib/workflows/search-replace/resources/resolvers.ts b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts index 96464f3bd89..29368d41859 100644 --- a/apps/sim/lib/workflows/search-replace/resources/resolvers.ts +++ b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts @@ -1,3 +1,4 @@ +import { foldSearchWhitespace } from '@/lib/workflows/search-replace/resources/references' import type { WorkflowSearchMatch, WorkflowSearchMatchKind, @@ -241,7 +242,10 @@ export function workflowSearchMatchMatchesQuery( if (!trimmedQuery) return false if (match.kind === 'text') return true - const normalize = (value: string) => (caseSensitive ? value : value.toLowerCase()) + const normalize = (value: string) => { + const folded = foldSearchWhitespace(value) + return caseSensitive ? folded : folded.toLowerCase() + } const searchable = match.resource?.kind === 'workflow-reference' || match.resource?.kind === 'environment' ? [match.displayLabel, match.rawValue, match.searchText] From f0fd48c2a4847c0d5bd2c4805ad29fdfd3fc0004 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 15 Aug 2026 12:06:04 -0700 Subject: [PATCH 093/103] fix(knowledge): bound chunking separators so one config can't stall processing (#6735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(knowledge): bound chunking separators so one config can't stall processing `chunkingStrategyOptionsSchema.separators` accepted an arbitrary-length array of arbitrary-length strings, next to a `pattern` field already capped at 500 chars. `RecursiveChunker` splits the whole document once per separator and walks the list from the top for every oversized fragment, so a persisted config with thousands of non-matching separators cost seconds of synchronous CPU on every later document upload — work neither the processing `Promise.race` timeout nor the after-the-fact chunk-count cap can interrupt. Measured on a 21.3 MB document: 632 ms at 100 separators, 6.1 s at 1000, 36.8 s at 5000. - Bound `separators` to 32 entries of at most 100 characters on the write path. The largest built-in recipe (markdown) uses 16, so hand-tuned lists still fit. - Keep the stored/read shape tolerant, so a config written before the bound still lists instead of failing response validation. - Clamp in `RecursiveChunker` too, with a warning, so an already-persisted oversized list cannot reach the split loop. An over-long separator is dropped rather than truncated: a truncated separator matches where the configured one never did, silently re-cutting the document, while dropping it behaves like a separator that finds no match. A list left empty falls back to the recipe. - Walk non-matching separators iteratively instead of recursing, so stack depth no longer tracks the separator count. Verified behavior-preserving against the previous implementation over 4000 randomized configs — byte-identical output. - Validate in the create-base modal so the limit surfaces inline. After the fix the same 21.3 MB document costs ~300 ms at every separator count. * fix(knowledge): gate separator validation on the recursive strategy - The separator refines ran for every strategy, but the field only renders for `recursive` and only that strategy submits it, so a value left behind by a strategy switch could block submit with no visible field to clear. Gated the same way the regex-pattern refine already is. - Use absolute imports in the chunker test, per the repo convention. --- .../create-base-modal/create-base-modal.tsx | 44 +++++++++-- .../lib/api/contracts/knowledge/base.test.ts | 77 +++++++++++++++++++ apps/sim/lib/api/contracts/knowledge/base.ts | 34 +++++++- apps/sim/lib/chunkers/constants.ts | 17 ++++ .../lib/chunkers/recursive-chunker.test.ts | 60 ++++++++++++++- apps/sim/lib/chunkers/recursive-chunker.ts | 63 +++++++++++---- 6 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 apps/sim/lib/api/contracts/knowledge/base.test.ts create mode 100644 apps/sim/lib/chunkers/constants.ts diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx index d9f123c4ea7..9722c695371 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx @@ -25,6 +25,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { type FieldErrors, useForm } from 'react-hook-form' import { z } from 'zod' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' import type { StrategyOptions } from '@/lib/chunkers/types' import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' import { @@ -57,6 +58,14 @@ const STRATEGY_OPTIONS = [ { value: 'regex', label: 'Regex (custom pattern)' }, ] as const +/** Splits the comma-separated separator field into the list the API receives. */ +function parseSeparators(value: string | undefined): string[] { + if (!value?.trim()) return [] + return value + .split(',') + .map((separator) => separator.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')) +} + const STRATEGY_COMBOBOX_OPTIONS: ComboboxOption[] = STRATEGY_OPTIONS.map((o) => ({ label: o.label, value: o.value, @@ -124,6 +133,31 @@ const FormSchema = z path: ['regexPattern'], } ) + /** + * Gated on the strategy for the same reason the regex pattern is: the field only + * renders for `recursive` and only that strategy submits it, so an out-of-bound + * value left behind by a strategy switch must not block a submit that drops it. + */ + .refine( + (data) => + data.strategy !== 'recursive' || + parseSeparators(data.customSeparators).length <= MAX_CHUNKING_SEPARATORS, + { + message: `At most ${MAX_CHUNKING_SEPARATORS} separators are allowed`, + path: ['customSeparators'], + } + ) + .refine( + (data) => + data.strategy !== 'recursive' || + parseSeparators(data.customSeparators).every( + (separator) => separator.length <= MAX_CHUNKING_SEPARATOR_LENGTH + ), + { + message: `Each separator must be ${MAX_CHUNKING_SEPARATOR_LENGTH} characters or less`, + path: ['customSeparators'], + } + ) type FormInputValues = z.input type FormValues = z.output @@ -265,11 +299,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({ ...(data.regexStrictBoundaries && { strictBoundaries: true }), } : data.strategy === 'recursive' && data.customSeparators?.trim() - ? { - separators: data.customSeparators - .split(',') - .map((s) => s.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')), - } + ? { separators: parseSeparators(data.customSeparators) } : undefined const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({ @@ -465,11 +495,13 @@ export const CreateBaseModal = memo(function CreateBaseModal({ diff --git a/apps/sim/lib/api/contracts/knowledge/base.test.ts b/apps/sim/lib/api/contracts/knowledge/base.test.ts new file mode 100644 index 00000000000..6d9034bb6c6 --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/base.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + chunkingStrategyOptionsSchema, + createKnowledgeBaseBodySchema, + knowledgeBaseDataSchema, +} from '@/lib/api/contracts/knowledge/base' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' + +const separators = (count: number) => Array.from({ length: count }, (_, i) => `@@sep${i}@@`) + +describe('chunkingStrategyOptionsSchema.separators', () => { + it('accepts a separator list at the bound', () => { + const parsed = chunkingStrategyOptionsSchema.parse({ + separators: separators(MAX_CHUNKING_SEPARATORS), + }) + expect(parsed.separators).toHaveLength(MAX_CHUNKING_SEPARATORS) + }) + + it('rejects more separators than the bound', () => { + const result = chunkingStrategyOptionsSchema.safeParse({ + separators: separators(MAX_CHUNKING_SEPARATORS + 1), + }) + expect(result.success).toBe(false) + }) + + it('rejects a separator longer than the per-item bound', () => { + const result = chunkingStrategyOptionsSchema.safeParse({ + separators: ['|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1)], + }) + expect(result.success).toBe(false) + }) + + it('rejects an oversized list on the knowledge base create body', () => { + const result = createKnowledgeBaseBodySchema.safeParse({ + name: 'kb', + workspaceId: 'ws', + chunkingConfig: { + maxSize: 1024, + minSize: 100, + overlap: 200, + strategy: 'recursive', + strategyOptions: { separators: separators(5000) }, + }, + }) + expect(result.success).toBe(false) + }) +}) + +describe('knowledgeBaseDataSchema.chunkingConfig', () => { + it('still reads a stored config written before the separator bound', () => { + const result = knowledgeBaseDataSchema.safeParse({ + id: 'kb-1', + userId: 'u-1', + name: 'kb', + description: null, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { + maxSize: 1024, + minSize: 100, + overlap: 200, + strategy: 'recursive', + strategyOptions: { separators: separators(5000) }, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + workspaceId: 'ws', + folderId: null, + }) + expect(result.success).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 2998aa83b26..8d6c83288ad 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -11,6 +11,7 @@ import { workspaceIdSchema, } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' import type { StrategyOptions } from '@/lib/chunkers/types' import { DEFAULT_CHUNKING_CONFIG, @@ -26,7 +27,13 @@ export const listKnowledgeBasesQuerySchema = z.object({ scope: knowledgeScopeSchema.default('active'), }) -export const chunkingStrategyOptionsSchema = z +/** + * Strategy options as they are stored. Reads stay tolerant of a `separators` + * list written before {@link chunkingStrategyOptionsSchema} bounded it, so an + * oversized legacy config lists instead of failing response validation. The + * chunker clamps such a list at construction, so nothing reprocesses unbounded. + */ +export const storedChunkingStrategyOptionsSchema = z .object({ pattern: z .string() @@ -48,6 +55,29 @@ export const chunkingStrategyOptionsSchema = z }) .strict() satisfies z.ZodType +/** + * Strategy options accepted on writes. `separators` is bounded in both length + * and item size: the recursive chunker rescans the whole document once per + * separator, synchronously, so an unbounded list turns one persisted config + * into seconds of uninterruptible CPU on every later document upload. + */ +export const chunkingStrategyOptionsSchema = storedChunkingStrategyOptionsSchema + .extend({ + separators: z + .array( + z + .string() + .max( + MAX_CHUNKING_SEPARATOR_LENGTH, + `Each separator must be ${MAX_CHUNKING_SEPARATOR_LENGTH} characters or less` + ) + ) + .max(MAX_CHUNKING_SEPARATORS, `At most ${MAX_CHUNKING_SEPARATORS} separators are allowed`) + .optional() + .describe('Ordered separators used to split content into chunks.'), + }) + .strict() satisfies z.ZodType + export const chunkingConfigSchema = z .object({ maxSize: z.number().min(100).max(4000), @@ -116,7 +146,7 @@ const knowledgeChunkingConfigSchema = z minSize: z.number(), overlap: z.number(), strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).optional(), - strategyOptions: chunkingStrategyOptionsSchema.optional(), + strategyOptions: storedChunkingStrategyOptionsSchema.optional(), }) .passthrough() diff --git a/apps/sim/lib/chunkers/constants.ts b/apps/sim/lib/chunkers/constants.ts new file mode 100644 index 00000000000..0e67a927407 --- /dev/null +++ b/apps/sim/lib/chunkers/constants.ts @@ -0,0 +1,17 @@ +/** + * Bounds on the separator list a recursive chunking config may carry. + * + * `RecursiveChunker` scans the whole document once per separator and walks the + * list from the top for every oversized fragment, so the separator count is a + * direct multiplier on synchronous CPU per document. The work happens inside a + * split loop, which neither the processing `Promise.race` timeout nor the + * after-the-fact chunk-count cap can interrupt — the list has to be bounded on + * the way in instead. + * + * The largest built-in recipe (`markdown`) uses 16 separators, so 32 leaves room + * for a hand-tuned list without letting one config stall the processing tier. + */ +export const MAX_CHUNKING_SEPARATORS = 32 + +/** Max characters in a single chunking separator. Real delimiters are a few characters. */ +export const MAX_CHUNKING_SEPARATOR_LENGTH = 100 diff --git a/apps/sim/lib/chunkers/recursive-chunker.test.ts b/apps/sim/lib/chunkers/recursive-chunker.test.ts index 345da36aaf3..441666ad5d3 100644 --- a/apps/sim/lib/chunkers/recursive-chunker.test.ts +++ b/apps/sim/lib/chunkers/recursive-chunker.test.ts @@ -3,7 +3,8 @@ */ import { describe, expect, it } from 'vitest' -import { RecursiveChunker } from './recursive-chunker' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' +import { RecursiveChunker } from '@/lib/chunkers/recursive-chunker' describe('RecursiveChunker', () => { describe('empty and whitespace input', () => { @@ -101,6 +102,63 @@ describe('RecursiveChunker', () => { }) }) + describe('separator bounds', () => { + it.concurrent('ignores separators past the list bound', async () => { + const separators = [ + ...Array.from({ length: MAX_CHUNKING_SEPARATORS }, (_, i) => `@@nomatch${i}@@`), + '---', + ] + const chunker = new RecursiveChunker({ chunkSize: 15, separators }) + const text = + 'Section one content here with words.---Section two content here with words.---Section three content here.' + + const chunks = await chunker.chunk(text) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.some((chunk) => chunk.text.includes('---'))).toBe(true) + }) + + it.concurrent('splits on a separator that survives the clamp', async () => { + const separators = [ + '---', + ...Array.from({ length: MAX_CHUNKING_SEPARATORS }, (_, i) => `@@nomatch${i}@@`), + ] + const chunker = new RecursiveChunker({ chunkSize: 15, separators }) + const text = + 'Section one content here with words.---Section two content here with words.---Section three content here.' + + const chunks = await chunker.chunk(text) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.every((chunk) => !chunk.text.includes('---'))).toBe(true) + }) + + it.concurrent('drops a separator longer than the per-item bound', async () => { + const oversized = '|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1) + const chunker = new RecursiveChunker({ chunkSize: 15, separators: [oversized, '---'] }) + const text = `Section one content here.${oversized}Section two content.---Section three content.` + + const chunks = await chunker.chunk(text) + + expect(chunks.some((chunk) => chunk.text.includes('|'))).toBe(true) + expect(chunks.every((chunk) => !chunk.text.includes('---'))).toBe(true) + }) + + it.concurrent('falls back to the recipe when every separator is over the bound', async () => { + const oversized = '|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1) + const text = + 'Section one content here with words.\n\nSection two content here with words.\n\nSection three content.' + + const chunks = await new RecursiveChunker({ chunkSize: 15, separators: [oversized] }).chunk( + text + ) + const defaultChunks = await new RecursiveChunker({ chunkSize: 15 }).chunk(text) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks).toEqual(defaultChunks) + }) + }) + describe('recipe: plain', () => { it.concurrent('should use plain recipe by default', async () => { const chunker = new RecursiveChunker({ chunkSize: 20 }) diff --git a/apps/sim/lib/chunkers/recursive-chunker.ts b/apps/sim/lib/chunkers/recursive-chunker.ts index 0dba2240987..c60933787a6 100644 --- a/apps/sim/lib/chunkers/recursive-chunker.ts +++ b/apps/sim/lib/chunkers/recursive-chunker.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' import type { Chunk, RecursiveChunkerOptions } from '@/lib/chunkers/types' import { addOverlap, @@ -62,8 +63,29 @@ export class RecursiveChunker { this.chunkSize = resolved.chunkSize this.chunkOverlap = resolved.chunkOverlap - if (options.separators && options.separators.length > 0) { - this.separators = options.separators + /** + * Bounded here as well as at the API boundary: a config persisted before the + * boundary bound existed would otherwise still cost one full document scan + * per separator, synchronously, on every document it processes. + * + * An over-long separator is dropped rather than truncated — a truncated + * separator matches where the configured one never did, silently re-cutting + * the document, whereas dropping it behaves like a separator that finds no + * match, which the split already handles. + */ + const requested = options.separators ?? [] + const usable = requested + .filter((separator) => separator.length <= MAX_CHUNKING_SEPARATOR_LENGTH) + .slice(0, MAX_CHUNKING_SEPARATORS) + + if (usable.length < requested.length) { + logger.warn( + `Chunking config carries ${requested.length} separators; using ${usable.length} within the ${MAX_CHUNKING_SEPARATORS} × ${MAX_CHUNKING_SEPARATOR_LENGTH}-character bound` + ) + } + + if (usable.length > 0) { + this.separators = usable } else { const recipe = options.recipe ?? 'plain' this.separators = [...RECIPES[recipe]] @@ -77,21 +99,34 @@ export class RecursiveChunker { return text.trim() ? [text] : [] } - if (separatorIndex >= this.separators.length) { - const chunkSizeChars = tokensToChars(this.chunkSize) - return splitAtWordBoundaries(text, chunkSizeChars) - } + /** + * Advance past separators that do not split this text. Iterating rather + * than recursing keeps stack depth independent of the separator count. + */ + let index = separatorIndex + let separator = '' + let parts: string[] = [] - const separator = this.separators[separatorIndex] + while (index < this.separators.length) { + separator = this.separators[index] - if (separator === '') { - return this.splitRecursively(text, this.separators.length) - } + if (separator === '') { + index = this.separators.length + break + } - const parts = text.split(separator).filter((part) => part.trim()) + parts = text.split(separator).filter((part) => part.trim()) - if (parts.length <= 1) { - return this.splitRecursively(text, separatorIndex + 1) + if (parts.length > 1) { + break + } + + index++ + } + + if (index >= this.separators.length) { + const chunkSizeChars = tokensToChars(this.chunkSize) + return splitAtWordBoundaries(text, chunkSizeChars) } const chunks: string[] = [] @@ -108,7 +143,7 @@ export class RecursiveChunker { } if (estimateTokens(part) > this.chunkSize) { - const subChunks = this.splitRecursively(part, separatorIndex + 1) + const subChunks = this.splitRecursively(part, index + 1) for (const subChunk of subChunks) { chunks.push(subChunk) } From e3b428e502412b094eb5d39633923a85aa1d7f84 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 15 Aug 2026 12:10:05 -0700 Subject: [PATCH 094/103] fix(docker): prune the app package by manifest name (#6736) --- docker/app.Dockerfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 28f6391b31d..c8920c96c96 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -43,7 +43,11 @@ RUN bun install -g turbo@2.9.6 COPY . . -RUN turbo prune sim --docker +# Read the package name from the app manifest. The published CLI also owns the +# `sim` package name, so a hard-coded historical name can silently prune the CLI +# instead of the application after either package is renamed. +RUN APP_PACKAGE_NAME="$(bun -e "console.log(require('./apps/sim/package.json').name)")" && \ + turbo prune "$APP_PACKAGE_NAME" --docker # ======================================== # Dependencies Stage: Install Dependencies @@ -65,7 +69,7 @@ COPY --from=pruner /app/bun.lock ./bun.lock # JOBS=4 caps node-gyp parallelism — higher values OOM isolated-vm (laverdet/isolated-vm#428). # # node-gyp comes from the lockfile, not `npx`. It is a devDependency of apps/sim -# purely so `turbo prune sim` keeps it: the only other copy is transitive through +# purely so `turbo prune` keeps it: the only other copy is transitive through # `@electron/rebuild`, which belongs to apps/desktop and is pruned away. `npx` # resolved it from the registry at build time, which pulled a different major # (13.x vs the pinned 12.4.0) and bypassed the `minimumReleaseAge` supply-chain From 838ce06a4fe10280aff8e4ddd83b7dbb957dc51f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 13:24:04 -0700 Subject: [PATCH 095/103] Scale desktop title bar with page zoom --- apps/sim/app/_styles/globals.css | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index ae53a936065..cdbf904c647 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -89,23 +89,24 @@ } /** - * Electron's `titleBarOverlay` publishes the window controls' geometry as these - * `titlebar-area-*` env vars, and Chromium rescales them under page zoom so the - * lane holds its physical size. Fallbacks are the platform's measured values, for - * a shell predating the overlay; the `:root` zeros cover the different case of the - * attribute being absent entirely, where this block never matches. + * The lane scales with page zoom like the rest of the UI (Codex-style): the + * 38px/81px terms are CSS px, so zooming in grows the lane and its controls + * with the content while the OS-drawn traffic lights hold their physical size + * inside it. The `env(titlebar-area-*)` terms — which Chromium rescales under + * zoom to keep physical geometry — act only as a floor, so zooming OUT can + * never shrink the lane under the lights or slide the controls beneath them. + * Fallbacks are the platform's measured values, for a shell predating the + * overlay; the `:root` zeros cover the different case of the attribute being + * absent entirely, where this block never matches. */ html[data-sim-desktop-title-bar="inset"] { --sidebar-collapsed-width: 0px; - --desktop-title-bar-height: env(titlebar-area-height, 38px); - --desktop-title-bar-inset-x: env(titlebar-area-x, 81px); - /* 0.79 = 30px of the 38px lane, and 0.53 of that = 16px. Proportions rather - than px because px would scale with page zoom while the OS-drawn lights - would not — and calc cannot divide a length by a length to get a scale. - `navigator.windowControlsOverlay` could, but reading it in JS would race - first paint for a value the blocking script needs. */ - --desktop-title-bar-control-size: calc(var(--desktop-title-bar-height) * 0.79); - --desktop-title-bar-control-icon-size: calc(var(--desktop-title-bar-control-size) * 0.53); + --desktop-title-bar-height: max(env(titlebar-area-height, 38px), 38px); + --desktop-title-bar-inset-x: max(env(titlebar-area-x, 81px), 81px); + /* 30px of the 38px lane, and 16px of that — CSS px on purpose, so the + controls zoom with the page instead of staying pinned to physical size. */ + --desktop-title-bar-control-size: 30px; + --desktop-title-bar-control-icon-size: 16px; --desktop-title-bar-control-offset: calc( (var(--desktop-title-bar-height) - var(--desktop-title-bar-control-size)) / 2 From 2ed51e702fad38a4ce84589ed15050dadfd455fa Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 14:31:09 -0700 Subject: [PATCH 096/103] Serialize account and organization truth into the copilot VFS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace standing, membership, billing, org role, access-control restrictions, published-block provenance, and fork topology were reachable only through three parameterless tools (or not at all). They are ambient read-only facts, so they belong in the VFS where they are greppable, cost no tool round-trip, and every agent that can read gets them — the same move that retired get_blocks_and_tools and list_user_workflows. Adds account/{workspace,workspaces,members,billing}.json (always mounted) and organization/{organization,access-control,custom-blocks,forks}.json (only when the workspace is org-hosted). Every file projects an existing use case or util after getOrMaterializeVFS's access assert — no new queries, no new authorization. One relation per file, cross-referenced by id-and-name stub, so overlapping facts cannot disagree. Volatile content (billing, access control, forks) is lazy, so numbers are read-time fresh and unasked-for reads cost nothing. Projection follows the viewer: member emails are admin-only, fork detail requires workspace admin on a forking-enabled org, and the whole organization/ namespace is absent for a personal workspace — which is itself the answer. Retires get_account_billing, get_enterprise_context, and list_user_workspaces along with their handlers; display titles stay for transcript replay. --- apps/sim/lib/copilot/entitlements.ts | 14 + .../lib/copilot/generated/tool-catalog-v1.ts | 33 -- .../lib/copilot/generated/tool-schemas-v1.ts | 21 - .../tool-executor/register-handlers.ts | 9 - .../copilot/tools/handlers/account.test.ts | 107 ----- .../sim/lib/copilot/tools/handlers/account.ts | 27 -- .../tools/handlers/enterprise-context.test.ts | 367 ------------------ .../tools/handlers/enterprise-context.ts | 34 -- .../tools/handlers/workflow/queries.test.ts | 52 +-- .../tools/handlers/workflow/queries.ts | 21 - apps/sim/lib/copilot/tools/tool-display.ts | 2 + apps/sim/lib/copilot/vfs/serializers.test.ts | 198 ++++++++++ apps/sim/lib/copilot/vfs/serializers.ts | 304 +++++++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 260 ++++++++++++- 14 files changed, 778 insertions(+), 671 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/handlers/account.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/account.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.ts diff --git a/apps/sim/lib/copilot/entitlements.ts b/apps/sim/lib/copilot/entitlements.ts index f35e356b8d0..99219bc8372 100644 --- a/apps/sim/lib/copilot/entitlements.ts +++ b/apps/sim/lib/copilot/entitlements.ts @@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { LRUCache } from 'lru-cache' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { isCustomBlocksEligible } from '@/lib/workflows/custom-blocks/operations' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotEntitlements') @@ -12,6 +13,7 @@ const logger = createLogger('CopilotEntitlements') */ export const CUSTOM_BLOCKS_ENTITLEMENT = 'custom-blocks' export const SIM_SANDBOXES_ENTITLEMENT = 'sim-sandboxes' +export const ORGANIZATION_CONTEXT_ENTITLEMENT = 'organization-context' /** * Workspace entitlements — plan/flag-gated org capabilities sent to the @@ -36,6 +38,18 @@ const ENTITLEMENT_EVALUATORS: Record< > = { [CUSTOM_BLOCKS_ENTITLEMENT]: isCustomBlocksEligible, [SIM_SANDBOXES_ENTITLEMENT]: hasWorkspaceSandboxAccess, + [ORGANIZATION_CONTEXT_ENTITLEMENT]: isOrganizationContextAvailable, +} + +/** + * True when this workspace belongs to an organization, which is exactly when + * the copilot's `organization/` VFS namespace has anything in it. Advertising + * it keeps a personal workspace's agents from ever hearing that org standing, + * access-control groups, or fork topology exist. + */ +async function isOrganizationContextAvailable(workspaceId: string): Promise { + const workspace = await getWorkspaceWithOwner(workspaceId) + return Boolean(workspace?.organizationId) } const entitlementsCache = new LRUCache>({ diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index b31faaad985..8164a205757 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -55,12 +55,10 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' - | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_status' - | 'get_enterprise_context' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -69,7 +67,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' - | 'list_user_workspaces' | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' @@ -186,12 +183,10 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' - | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_status' - | 'get_enterprise_context' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -200,7 +195,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' - | 'list_user_workspaces' | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' @@ -3004,14 +2998,6 @@ export const GenerateVideo: ToolCatalogEntry = { capabilities: ['file_input', 'file_output', 'generated_media'], } -export const GetAccountBilling: ToolCatalogEntry = { - id: 'get_account_billing', - name: 'get_account_billing', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, -} - export const GetBlockOutputs: ToolCatalogEntry = { id: 'get_block_outputs', name: 'get_block_outputs', @@ -3089,14 +3075,6 @@ export const GetDeploymentStatus: ToolCatalogEntry = { }, } -export const GetEnterpriseContext: ToolCatalogEntry = { - id: 'get_enterprise_context', - name: 'get_enterprise_context', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, -} - export const GetWorkflowData: ToolCatalogEntry = { id: 'get_workflow_data', name: 'get_workflow_data', @@ -3287,14 +3265,6 @@ export const ListIntegrationTools: ToolCatalogEntry = { }, } -export const ListUserWorkspaces: ToolCatalogEntry = { - id: 'list_user_workspaces', - name: 'list_user_workspaces', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, -} - export const ListWorkspaceMcpServers: ToolCatalogEntry = { id: 'list_workspace_mcp_servers', name: 'list_workspace_mcp_servers', @@ -7245,12 +7215,10 @@ export const TOOL_CATALOG: Record = { [GenerateAudio.id]: GenerateAudio, [GenerateImage.id]: GenerateImage, [GenerateVideo.id]: GenerateVideo, - [GetAccountBilling.id]: GetAccountBilling, [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentStatus.id]: GetDeploymentStatus, - [GetEnterpriseContext.id]: GetEnterpriseContext, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, @@ -7259,7 +7227,6 @@ export const TOOL_CATALOG: Record = { [Knowledge.id]: Knowledge, [ListDeploymentVersions.id]: ListDeploymentVersions, [ListIntegrationTools.id]: ListIntegrationTools, - [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, [LoadDeployment.id]: LoadDeployment, [LoadIntegrationTool.id]: LoadIntegrationTool, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 06300581869..b4339923a66 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2959,13 +2959,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_account_billing: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, get_block_outputs: { parameters: { type: 'object', @@ -3034,13 +3027,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_enterprise_context: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, get_workflow_data: { parameters: { type: 'object', @@ -3209,13 +3195,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, list_workspace_mcp_servers: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 8baf0178d44..a598d4135e7 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -10,19 +10,16 @@ import { DeployAsMcp, DiffWorkflows, GenerateApiKey, - GetAccountBilling, GetBlockOutputs, GetBlockUpstreamReferences, GetDeployedWorkflowState, GetDeploymentStatus, - GetEnterpriseContext, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, Grep as GrepTool, ListDeploymentVersions, ListIntegrationTools, - ListUserWorkspaces, ListWorkspaceMcpServers, LoadDeployment, ManageCredential, @@ -53,8 +50,6 @@ import { UpdateDeploymentVersion, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' -import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' -import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' @@ -114,7 +109,6 @@ import { executeGetDeployedWorkflowState, executeGetWorkflowData, executeGetWorkflowRunOptions, - executeListUserWorkspaces, } from '../tools/handlers/workflow/queries' import { registerHandlers } from './executor' import type { ToolHandler } from './types' @@ -139,9 +133,6 @@ function h(fn: (params: any, context: any) => Promise): ToolHandler { function buildHandlerMap(): Record { return { - [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), - [GetAccountBilling.id]: h((_p, c) => executeGetAccountBilling(c)), - [GetEnterpriseContext.id]: h((_p, c) => executeGetEnterpriseContext(c)), [GetWorkflowData.id]: h(executeGetWorkflowData), [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), [GetBlockOutputs.id]: h(executeGetBlockOutputs), diff --git a/apps/sim/lib/copilot/tools/handlers/account.test.ts b/apps/sim/lib/copilot/tools/handlers/account.test.ts deleted file mode 100644 index c8ac0435a45..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/account.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - loadWorkspace: vi.fn(), - resolvePermission: vi.fn(), - getAccountBillingSnapshot: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: (actual: string | null, required: string) => { - const rank = { read: 1, write: 2, admin: 3 } as const - return ( - actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] - ) - }, - resolveEffectiveWorkspacePermission: mocks.resolvePermission, -})) - -vi.mock('@/lib/workspaces/application/workspace-context', () => ({ - loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, -})) - -vi.mock('@/lib/billing/core/account-billing-snapshot', () => ({ - getAccountBillingSnapshot: mocks.getAccountBillingSnapshot, -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' - -const context = { - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - chatId: 'chat-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, - copilotInteractionMode: 'interactive', -} as const satisfies ExecutionContext - -const snapshot = { - plan: 'team', - billingScope: 'organization' as const, - organizationId: 'org-1', - usage: { - currentPeriodCost: 18.5, - limit: 40, - remaining: 21.5, - percentUsed: 46.25, - isExceeded: false, - billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), - }, - credits: { balance: 25, scope: 'organization' as const }, -} - -describe('executeGetAccountBilling', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.loadWorkspace.mockResolvedValue({ - workspaceId: 'workspace-1', - workspaceOrganizationId: 'org-1', - allowPersonalApiKeys: true, - billedAccountUserId: 'owner-1', - }) - mocks.resolvePermission.mockResolvedValue('read') - mocks.getAccountBillingSnapshot.mockResolvedValue(snapshot) - }) - - it('returns the existing account billing tool result shape after authorization', async () => { - await expect(executeGetAccountBilling(context)).resolves.toEqual({ - success: true, - output: snapshot, - }) - expect(mocks.getAccountBillingSnapshot).toHaveBeenCalledWith('user-1') - }) - - it.each(['headless' as const, undefined])( - 'fails closed for a non-interactive lifecycle (%s) before protected lookup', - async (copilotInteractionMode) => { - const result = await executeGetAccountBilling({ - ...context, - copilotInteractionMode, - }) - - expect(result).toEqual({ - success: false, - error: 'Live platform context is available only in an interactive Copilot session.', - }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() - expect(mocks.resolvePermission).not.toHaveBeenCalled() - expect(mocks.getAccountBillingSnapshot).not.toHaveBeenCalled() - } - ) - - it('does not expose an underlying billing failure', async () => { - mocks.getAccountBillingSnapshot.mockRejectedValue( - new Error('connection secret from billing database') - ) - - await expect(executeGetAccountBilling(context)).resolves.toEqual({ - success: false, - error: 'The operation failed due to a system error. Please retry.', - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/account.ts b/apps/sim/lib/copilot/tools/handlers/account.ts deleted file mode 100644 index 051b46f931f..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/account.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - executeCopilotPlatformContextUseCase, - messageForCopilotPlatformContextError, -} from '@/lib/copilot/application/execute-platform-context-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' - -/** - * Live billing snapshot for the requesting user: plan, current-period usage - * against its limit, and purchased credit balance. All three sources are - * org-aware — a member whose subscription lives on an organization gets the - * org's plan, limit, and credit pool, with `billingScope`/`organizationId` - * saying which applied. - */ -export async function executeGetAccountBilling(context: ExecutionContext): Promise { - try { - const output = await executeCopilotPlatformContextUseCase(context, readAccountBilling, { - workspaceId: context.workspaceId ?? '', - }) - return { - success: true, - output, - } - } catch (error) { - return { success: false, error: messageForCopilotPlatformContextError(error) } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts deleted file mode 100644 index 231692c6a76..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetWorkspaceHostContextForViewer, - mockResolveVerifiedUserAccessControlContext, - mockLoadWorkspace, - mockResolvePermission, -} = vi.hoisted(() => ({ - mockGetWorkspaceHostContextForViewer: vi.fn(), - mockResolveVerifiedUserAccessControlContext: vi.fn(), - mockLoadWorkspace: vi.fn(), - mockResolvePermission: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: (actual: string | null, required: string) => { - const rank = { read: 1, write: 2, admin: 3 } as const - return ( - actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] - ) - }, - resolveEffectiveWorkspacePermission: mockResolvePermission, -})) - -vi.mock('@/lib/workspaces/application/workspace-context', () => ({ - loadActiveWorkspaceApplicationContext: mockLoadWorkspace, -})) - -vi.mock('@/lib/workspaces/host-context', () => ({ - getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - resolveVerifiedUserAccessControlContext: mockResolveVerifiedUserAccessControlContext, -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' - -const context = { - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - chatId: 'chat-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, - copilotInteractionMode: 'interactive', -} as const satisfies ExecutionContext - -function enterpriseHost(permission: 'read' | 'write' | 'admin') { - return { - workspace: { - id: 'workspace-1', - name: 'Customer Support', - workspaceMode: 'collaborative', - billedAccountUserId: 'owner-1', - }, - hostOrganizationId: 'org-1', - ownerBilling: { - plan: 'enterprise', - status: 'active', - isPaid: true, - isPro: true, - isTeam: true, - isEnterprise: true, - isOrgScoped: true, - organizationId: 'org-1', - billingInterval: 'year', - billingBlocked: false, - billingBlockedReason: null, - }, - viewer: { - permission, - isHostOrganizationMember: false, - isHostOrganizationAdmin: false, - organizationRole: null, - }, - } -} - -describe('executeGetEnterpriseContext', () => { - beforeEach(() => { - vi.clearAllMocks() - mockLoadWorkspace.mockResolvedValue({ - workspaceId: 'workspace-1', - workspaceOrganizationId: 'org-1', - allowPersonalApiKeys: true, - billedAccountUserId: 'owner-1', - }) - mockResolvePermission.mockResolvedValue('read') - }) - - it('requires a current workspace', async () => { - const result = await executeGetEnterpriseContext({ userId: 'user-1' } as ExecutionContext) - - expect(result).toEqual({ - success: false, - error: 'A current workspace is required to resolve enterprise access.', - }) - expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() - }) - - it('rejects headless execution before loading workspace or enterprise context', async () => { - const result = await executeGetEnterpriseContext({ - ...context, - copilotInteractionMode: 'headless', - }) - - expect(result).toEqual({ - success: false, - error: 'Live platform context is available only in an interactive Copilot session.', - }) - expect(mockLoadWorkspace).not.toHaveBeenCalled() - expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() - expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() - }) - - it('keeps external workspace administration separate from organization authority', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: 'org-1', - entitled: true, - permissionGroup: { - id: 'group-1', - name: 'Contractors', - resolution: 'all-members', - }, - config: { - ...DEFAULT_PERMISSION_GROUP_CONFIG, - allowedIntegrations: ['slack'], - deniedTools: ['slack_delete_message'], - disableMcpTools: true, - disableInvitations: true, - }, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( - 'user-1', - 'workspace-1', - 'org-1' - ) - expect(result).toMatchObject({ - success: true, - output: { - workspace: { - id: 'workspace-1', - permission: 'admin', - capabilities: { - canRead: true, - canEdit: true, - canRun: true, - canDeploy: true, - canManageWorkspace: true, - }, - }, - organization: { - id: 'org-1', - relationship: 'external', - role: null, - canManageOrganization: false, - canManageBilling: false, - plan: 'enterprise', - isEnterprise: true, - }, - accessControl: { - entitled: true, - governingPermissionGroup: { - id: 'group-1', - name: 'Contractors', - resolution: 'all-members', - }, - effectiveConfig: expect.objectContaining({ disableMcpTools: true }), - activeRestrictions: expect.arrayContaining([ - expect.objectContaining({ key: 'allowedIntegrations' }), - expect.objectContaining({ key: 'deniedTools' }), - expect.objectContaining({ key: 'disableMcpTools' }), - expect.objectContaining({ key: 'disableInvitations' }), - ]), - }, - }, - }) - }) - - it('reports an internal member role without granting organization administration', async () => { - const host = enterpriseHost('write') - mockGetWorkspaceHostContextForViewer.mockResolvedValue({ - ...host, - viewer: { - ...host.viewer, - isHostOrganizationMember: true, - organizationRole: 'member', - }, - }) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: 'org-1', - entitled: true, - permissionGroup: null, - config: null, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toMatchObject({ - success: true, - output: { - workspace: { - permission: 'write', - capabilities: { - canRead: true, - canEdit: true, - canRun: true, - canDeploy: false, - canManageWorkspace: false, - }, - }, - organization: { - relationship: 'internal', - role: 'member', - canManageOrganization: false, - canManageBilling: false, - }, - }, - }) - }) - - it('reports read access without write, run, deployment, or administration capabilities', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('read')) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: 'org-1', - entitled: true, - permissionGroup: null, - config: null, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toMatchObject({ - success: true, - output: { - workspace: { - permission: 'read', - capabilities: { - canRead: true, - canEdit: false, - canRun: true, - canDeploy: false, - canManageWorkspace: false, - }, - }, - }, - }) - }) - - it('does not advertise deployment when every deployment surface is hidden', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: 'org-1', - entitled: true, - permissionGroup: null, - config: { - ...DEFAULT_PERMISSION_GROUP_CONFIG, - hideDeployApi: true, - hideDeployMcp: true, - hideDeployChatbot: true, - }, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toMatchObject({ - success: true, - output: { - workspace: { - capabilities: { - canRun: true, - canDeploy: false, - }, - }, - }, - }) - }) - - it('returns a personal-workspace context without looking up organization membership', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue({ - ...enterpriseHost('write'), - hostOrganizationId: null, - ownerBilling: { - ...enterpriseHost('write').ownerBilling, - plan: 'pro', - isEnterprise: false, - isOrgScoped: false, - organizationId: null, - }, - }) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: null, - entitled: false, - permissionGroup: null, - config: null, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toMatchObject({ - success: true, - output: { - workspace: { permission: 'write' }, - organization: null, - accessControl: { - entitled: false, - governingPermissionGroup: null, - effectiveConfig: null, - activeRestrictions: [], - }, - }, - }) - expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( - 'user-1', - 'workspace-1', - null - ) - }) - - it('does not expose enterprise context when workspace access cannot be resolved', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(null) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toEqual({ - success: false, - error: 'Workspace not found or you do not have access.', - }) - expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() - }) - - it('returns a failure when workspace context resolution fails', async () => { - mockGetWorkspaceHostContextForViewer.mockRejectedValue(new Error('workspace lookup failed')) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toEqual({ - success: false, - error: 'The operation failed due to a system error. Please retry.', - }) - expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() - }) - - it('returns a failure when access-control resolution fails', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('write')) - mockResolveVerifiedUserAccessControlContext.mockRejectedValue( - new Error('access-control lookup failed') - ) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toEqual({ - success: false, - error: 'The operation failed due to a system error. Please retry.', - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts deleted file mode 100644 index d72f7ae1db0..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - executeCopilotPlatformContextUseCase, - messageForCopilotPlatformContextError, -} from '@/lib/copilot/application/execute-platform-context-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' - -/** - * Resolves the authenticated user's effective Enterprise access in the current - * workspace. This is an explanatory snapshot; every later mutation must still - * perform its normal server-side authorization at execution time. - */ -export async function executeGetEnterpriseContext( - context: ExecutionContext -): Promise { - if (!context.workspaceId) { - return { - success: false, - error: 'A current workspace is required to resolve enterprise access.', - } - } - - try { - const output = await executeCopilotPlatformContextUseCase(context, readEnterpriseContext, { - workspaceId: context.workspaceId, - }) - return { - success: true, - output, - } - } catch (error) { - return { success: false, error: messageForCopilotPlatformContextError(error) } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts index 801372da6f1..54d47e30f24 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts @@ -2,9 +2,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' -const { executeWorkflowUseCaseMock, listUserWorkspacesMock } = vi.hoisted(() => ({ +const { executeWorkflowUseCaseMock } = vi.hoisted(() => ({ executeWorkflowUseCaseMock: vi.fn(), - listUserWorkspacesMock: vi.fn(), })) vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ @@ -13,54 +12,7 @@ vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ getErrorMessage(error, 'Workflow operation failed'), })) -vi.mock('@/lib/workspaces/utils', () => ({ - listUserWorkspaces: listUserWorkspacesMock, -})) - -import { - executeGetBlockOutputs, - executeListUserWorkspaces, -} from '@/lib/copilot/tools/handlers/workflow/queries' - -describe('executeListUserWorkspaces', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('marks the current workspace in the accessible workspace list', async () => { - listUserWorkspacesMock.mockResolvedValue([ - { workspaceId: 'workspace-1', workspaceName: 'One', role: 'owner' }, - { workspaceId: 'workspace-2', workspaceName: 'Two', role: 'read' }, - ]) - - const result = await executeListUserWorkspaces({ - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-2', - }) - - expect(listUserWorkspacesMock).toHaveBeenCalledWith('user-1') - expect(result).toEqual({ - success: true, - output: { - workspaces: [ - { - workspaceId: 'workspace-1', - workspaceName: 'One', - role: 'owner', - isCurrent: false, - }, - { - workspaceId: 'workspace-2', - workspaceName: 'Two', - role: 'read', - isCurrent: true, - }, - ], - }, - }) - }) -}) +import { executeGetBlockOutputs } from '@/lib/copilot/tools/handlers/workflow/queries' describe('executeGetBlockOutputs', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 0291fdee8fc..2129fc747bc 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' @@ -18,7 +17,6 @@ import { } from '@/lib/workflows/application/read-workflow-copilot-metadata' import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' -import { listUserWorkspaces } from '@/lib/workspaces/utils' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' import type { GetBlockOutputsParams, @@ -30,25 +28,6 @@ import type { const logger = createLogger('WorkflowQueries') -export async function executeListUserWorkspaces( - context: ExecutionContext -): Promise { - try { - const workspaces = (await listUserWorkspaces(context.userId)).map((workspace) => ({ - ...workspace, - isCurrent: workspace.workspaceId === context.workspaceId, - })) - - return { success: true, output: { workspaces } } - } catch (error) { - logger.error('Failed to list user workspaces for Copilot', { error }) - return { - success: false, - error: messageForCopilotApplicationError(error, 'Failed to list workspaces'), - } - } -} - export async function executeGetWorkflowRunOptions( params: GetWorkflowRunOptionsParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 567143fdf1f..7c3f11c0cd5 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -513,6 +513,8 @@ const TOOL_TITLES: Record = { download_file: 'Downloading file', run_function: 'Running code', generate_api_key: 'Generating API key', + // Retired in favor of the account/ and organization/ VFS namespaces. Kept so + // a replayed transcript from before the switch still renders its rows. get_account_billing: 'Checking plan and usage', get_block_outputs: 'Reading block outputs', get_block_upstream_references: 'Tracing block inputs', diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 7c36001d71c..2a10feef36d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -11,6 +11,11 @@ import type { BlockConfig } from '@/blocks/types' import { hostedKeyEnabledWhen } from '@/tools/hosting' import type { ToolConfig } from '@/tools/types' import { + serializeAccessControl, + serializeAccountBilling, + serializeAccountMembers, + serializeAccountWorkspace, + serializeAccountWorkspaces, serializeApiKeyIntegrations, serializeBlockSchema, serializeConnectors, @@ -19,10 +24,13 @@ import { serializeFileMeta, serializeIntegrationSchema, serializeKBMeta, + serializeOrganization, + serializeOrganizationCustomBlocks, serializeSandbox, serializeSandboxCatalog, serializeTableMeta, serializeWorkflowMeta, + serializeWorkspaceForks, } from './serializers' function hostedTool(id: string, conditional = false): ToolConfig { @@ -585,3 +593,193 @@ describe('serializeConnectors — cloneable references, never key material', () expect(json[0].sourceConfig).toMatchObject({ repository: 'simstudioai/sim' }) }) }) + +describe('account and organization namespace serializers', () => { + it('references the files that own org and fork detail instead of restating them', () => { + const workspace = JSON.parse( + serializeAccountWorkspace({ + workspace: { id: 'ws-1', name: 'Elder', workspaceMode: 'standard' }, + viewer: { permission: 'admin', organizationRole: 'owner' }, + organization: { id: 'org-1', name: 'Acme' }, + forkedFrom: { id: 'ws-0', name: 'Elder (parent)' }, + entitlements: ['custom-blocks'], + }) + ) + + expect(workspace.yourPermission).toBe('admin') + expect(workspace.organization).toEqual({ + id: 'org-1', + name: 'Acme', + yourRole: 'owner', + detail: 'organization/organization.json', + }) + expect(workspace.forkedFrom.detail).toBe('organization/forks.json') + // The org record itself (plan, restrictions, members) must not be inlined — + // one relation per file is what keeps the two from disagreeing. + expect(workspace.organization.plan).toBeUndefined() + }) + + it('omits organization and fork stubs for a personal, unforked workspace', () => { + const workspace = JSON.parse( + serializeAccountWorkspace({ + workspace: { id: 'ws-1', name: 'Personal' }, + viewer: { permission: 'admin' }, + organization: null, + forkedFrom: null, + entitlements: [], + }) + ) + + expect(workspace.organization).toBeNull() + expect(workspace.forkedFrom).toBeNull() + }) + + it('withholds member emails from a non-admin viewer and says so', () => { + const members = [ + { userId: 'u-1', name: 'Ada', email: 'ada@example.com', permissionType: 'admin' }, + { + userId: 'u-2', + name: 'Grace', + email: 'grace@example.com', + permissionType: 'read', + isExternal: true, + }, + ] + + const asAdmin = JSON.parse(serializeAccountMembers(members, { includeContactDetails: true })) + expect(asAdmin.members[0].email).toBe('ada@example.com') + expect(asAdmin.note).toBeUndefined() + + const asMember = JSON.parse(serializeAccountMembers(members, { includeContactDetails: false })) + expect(asMember.members.map((m: { email?: string }) => m.email)).toEqual([undefined, undefined]) + expect(asMember.members[0].name).toBe('Ada') + expect(asMember.members[1].isExternal).toBe(true) + expect(asMember.note).toContain('admins only') + }) + + it('keeps money and usage numbers in billing.json alone', () => { + const billing = JSON.parse( + serializeAccountBilling({ + plan: 'team', + billingScope: 'organization', + organizationId: 'org-1', + usage: { + currentPeriodCost: 12.5, + limit: 100, + remaining: 87.5, + percentUsed: 12.5, + isExceeded: false, + billingPeriodEnd: new Date('2026-09-01T00:00:00.000Z'), + }, + credits: { balance: 40, scope: 'organization' }, + }) + ) + + expect(billing.plan).toBe('team') + expect(billing.billedTo).toBe('organization') + expect(billing.usage.billingPeriodEnd).toBe('2026-09-01T00:00:00.000Z') + expect(billing.credits.balance).toBe(40) + + const organization = JSON.parse( + serializeOrganization({ + organization: { id: 'org-1', relationship: 'internal', role: 'admin' }, + capabilities: { canManageOrganization: true, canManageBilling: true }, + plan: 'team', + isEnterprise: false, + }) + ) + expect(organization.usage).toBeUndefined() + expect(organization.credits).toBeUndefined() + expect(organization.note).toContain('account/billing.json') + }) + + it('describes access control as this viewer’s own binding restrictions', () => { + const accessControl = JSON.parse( + serializeAccessControl({ + entitled: true, + permissionGroup: { id: 'pg-1', name: 'Contractors', resolution: 'explicit-member' }, + restrictions: [{ key: 'hideDeployApi', description: 'Cannot deploy workflows as APIs' }], + }) + ) + + expect(accessControl.governingPermissionGroup.appliedBecause).toBe('explicit-member') + expect(accessControl.activeRestrictions).toEqual([ + { key: 'hideDeployApi', description: 'Cannot deploy workflows as APIs' }, + ]) + expect(accessControl.note).toContain('THIS user') + }) + + it('points custom-block provenance at the schema rather than copying fields', () => { + const blocks = JSON.parse( + serializeOrganizationCustomBlocks([ + { + type: 'acme_scorer', + name: 'Acme Scorer', + description: 'Scores a lead', + enabled: true, + workflowId: 'wf-1', + workflowName: 'Scorer', + workspaceId: 'ws-9', + workspaceName: 'Platform', + }, + { + type: 'acme_retired', + name: 'Retired', + enabled: false, + workflowId: 'wf-2', + workspaceId: null, + }, + ]) + ) + + expect(blocks.customBlocks[0].schema).toBe('components/blocks/acme_scorer.json') + expect(blocks.customBlocks[0].publishedFrom.workspaceName).toBe('Platform') + expect(blocks.customBlocks[0].inputFields).toBeUndefined() + // A disabled block cannot be added, so it gets no schema pointer. + expect(blocks.customBlocks[1].schema).toBeUndefined() + expect(blocks.customBlocks[1].publishedFrom.workspaceId).toBeUndefined() + }) + + it('summarizes fork mappings by resource type and omits them at the root', () => { + const forked = JSON.parse( + serializeWorkspaceForks({ + parent: { id: 'ws-0', name: 'Template' }, + children: [{ id: 'ws-2', name: 'Child', createdAt: new Date('2026-08-01T00:00:00.000Z') }], + resourceMappingCounts: { workflow: 3, table: 1 }, + blockMappingCount: 12, + }) + ) + expect(forked.mappedFromParent).toEqual({ resources: { workflow: 3, table: 1 }, blocks: 12 }) + expect(forked.children[0].createdAt).toBe('2026-08-01T00:00:00.000Z') + + const root = JSON.parse( + serializeWorkspaceForks({ + parent: null, + children: [], + resourceMappingCounts: {}, + blockMappingCount: 0, + }) + ) + expect(root.mappedFromParent).toBeUndefined() + }) + + it('marks the current workspace and never implies the others are readable', () => { + const roster = JSON.parse( + serializeAccountWorkspaces([ + { id: 'ws-1', name: 'Elder', role: 'admin', isCurrent: true, organizationId: 'org-1' }, + { + id: 'ws-2', + name: 'Other', + role: 'read', + isCurrent: false, + forkedFromWorkspaceId: 'ws-1', + }, + ]) + ) + + expect(roster.workspaces[0].isCurrent).toBe(true) + expect(roster.workspaces[1].isCurrent).toBeUndefined() + expect(roster.workspaces[1].forkedFromWorkspaceId).toBe('ws-1') + expect(roster.note).toContain('isCurrent') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 032ff549b11..00637955749 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1326,3 +1326,307 @@ export function serializeTableViews( 2 ) } + +/** + * `account/workspace.json` — the current workspace as this viewer sees it: + * identity, the viewer's effective permission, org linkage, and fork parentage. + * + * Owns the current-workspace record. Org detail lives in + * `organization/organization.json` and fork topology in + * `organization/forks.json`; both are referenced here by id-and-name stub only, + * so a fact can never disagree with the file that owns it. + */ +export function serializeAccountWorkspace(input: { + workspace: { id: string; name: string; workspaceMode?: string | null } + viewer: { permission: string | null; organizationRole?: string | null } + organization: { id: string; name?: string | null } | null + forkedFrom: { id: string; name: string } | null + entitlements: string[] +}): string { + return JSON.stringify( + { + id: input.workspace.id, + name: input.workspace.name, + ...(input.workspace.workspaceMode ? { mode: input.workspace.workspaceMode } : {}), + yourPermission: input.viewer.permission, + organization: input.organization + ? { + id: input.organization.id, + ...(input.organization.name ? { name: input.organization.name } : {}), + ...(input.viewer.organizationRole ? { yourRole: input.viewer.organizationRole } : {}), + detail: 'organization/organization.json', + } + : null, + forkedFrom: input.forkedFrom + ? { + id: input.forkedFrom.id, + name: input.forkedFrom.name, + detail: 'organization/forks.json', + } + : null, + entitlements: input.entitlements, + note: 'Read-only. Your accessible workspaces are in account/workspaces.json; members in account/members.json; plan and usage in account/billing.json.', + }, + null, + 2 + ) +} + +/** + * `account/workspaces.json` — every workspace the viewer can reach, as stubs. + * + * Deliberately a roster, not a set of records: id, name, the viewer's role, and + * org/fork parentage by id. Anything richer about the *current* workspace is in + * `account/workspace.json`; other workspaces are not readable from here at all. + */ +export function serializeAccountWorkspaces( + workspaces: Array<{ + id: string + name: string + role: string + organizationId?: string | null + forkedFromWorkspaceId?: string | null + isCurrent: boolean + }> +): string { + return JSON.stringify( + { + workspaces: workspaces.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + yourRole: workspace.role, + ...(workspace.organizationId ? { organizationId: workspace.organizationId } : {}), + ...(workspace.forkedFromWorkspaceId + ? { forkedFromWorkspaceId: workspace.forkedFromWorkspaceId } + : {}), + ...(workspace.isCurrent ? { isCurrent: true } : {}), + })), + note: 'Only the current workspace (isCurrent) is mounted in this VFS — the others are listed so you can name them, not read them. Switching workspaces is the user’s action, not yours.', + }, + null, + 2 + ) +} + +/** + * `account/members.json` — who is in the current workspace, with roles. + * + * `includeContactDetails` is the viewer's own admin bit: emails and pending + * invitations are the same privilege as the members settings page, so a + * non-admin viewer gets names and roles without contact details. + */ +export function serializeAccountMembers( + members: Array<{ + userId: string + name: string | null + email: string | null + permissionType: string + isExternal?: boolean + roleSource?: string + }>, + options: { includeContactDetails: boolean } +): string { + return JSON.stringify( + { + members: members.map((member) => ({ + userId: member.userId, + name: member.name ?? null, + ...(options.includeContactDetails && member.email ? { email: member.email } : {}), + role: member.permissionType, + ...(member.isExternal ? { isExternal: true } : {}), + ...(member.roleSource && member.roleSource !== 'explicit' + ? { roleSource: member.roleSource } + : {}), + })), + total: members.length, + ...(options.includeContactDetails + ? {} + : { note: 'Email addresses are shown to workspace admins only.' }), + }, + null, + 2 + ) +} + +/** + * `account/billing.json` — the acting user's live plan, usage, and credits. + * + * The only file that carries money and usage numbers; `organization.json` links + * here rather than repeating them. Read at request time, so the numbers are + * current rather than as-of-materialization. + */ +export function serializeAccountBilling(snapshot: { + plan: string + billingScope: 'user' | 'organization' + organizationId: string | null + usage: { + currentPeriodCost: number + limit: number + remaining: number + percentUsed: number + isExceeded: boolean + billingPeriodEnd: Date | string | null + } + credits: { balance: number; scope: 'user' | 'organization' } +}): string { + const periodEnd = snapshot.usage.billingPeriodEnd + return JSON.stringify( + { + plan: snapshot.plan, + billedTo: snapshot.billingScope, + ...(snapshot.organizationId ? { organizationId: snapshot.organizationId } : {}), + usage: { + currentPeriodCost: snapshot.usage.currentPeriodCost, + limit: snapshot.usage.limit, + remaining: snapshot.usage.remaining, + percentUsed: snapshot.usage.percentUsed, + isExceeded: snapshot.usage.isExceeded, + billingPeriodEnd: periodEnd instanceof Date ? periodEnd.toISOString() : periodEnd, + }, + credits: { balance: snapshot.credits.balance, scope: snapshot.credits.scope }, + note: 'Live values for the acting user, read at access time. What the plan tiers and credits mean is a documentation question, not a value in this file.', + }, + null, + 2 + ) +} + +/** + * `organization/organization.json` — the org that hosts this workspace and the + * viewer's standing in it. Owns the organization record; plan economics stay in + * `account/billing.json`. + */ +export function serializeOrganization(input: { + organization: { id: string; relationship: string; role: string | null } + capabilities: { canManageOrganization: boolean; canManageBilling: boolean } + plan: string | null + isEnterprise: boolean +}): string { + return JSON.stringify( + { + id: input.organization.id, + yourRelationship: input.organization.relationship, + yourRole: input.organization.role, + canManageOrganization: input.capabilities.canManageOrganization, + canManageBilling: input.capabilities.canManageBilling, + ...(input.plan ? { plan: input.plan } : {}), + isEnterprise: input.isEnterprise, + note: 'Plan usage and credits are in account/billing.json. Your effective restrictions are in organization/access-control.json.', + }, + null, + 2 + ) +} + +/** + * `organization/access-control.json` — who can see and do what, from the + * viewer's vantage: the permission group governing them and the restrictions it + * actually imposes. + * + * Scoped to the viewer on purpose. The full group roster is an org-admin + * settings surface, not workspace context. + */ +export function serializeAccessControl(input: { + entitled: boolean + permissionGroup: { id: string; name: string; resolution: string } | null + restrictions: Array<{ key: string; description: string }> +}): string { + return JSON.stringify( + { + entitled: input.entitled, + governingPermissionGroup: input.permissionGroup + ? { + id: input.permissionGroup.id, + name: input.permissionGroup.name, + appliedBecause: input.permissionGroup.resolution, + } + : null, + activeRestrictions: input.restrictions.map((restriction) => ({ + key: restriction.key, + description: restriction.description, + })), + note: 'These restrictions are enforced server-side on every action, so a blocked request fails no matter how it is phrased. They describe THIS user; other members may be governed by different groups.', + }, + null, + 2 + ) +} + +/** + * `organization/custom-blocks.json` — provenance for org-published blocks: who + * published each one and from which workflow. + * + * The block's callable schema stays at `components/blocks/{type}.json`; this + * file points at it rather than restating fields. + */ +export function serializeOrganizationCustomBlocks( + blocks: Array<{ + type: string + name: string + description?: string | null + enabled: boolean + workflowId: string + workflowName?: string | null + workspaceId: string | null + workspaceName?: string | null + }> +): string { + return JSON.stringify( + { + customBlocks: blocks.map((block) => ({ + type: block.type, + name: block.name, + ...(block.description ? { description: block.description } : {}), + enabled: block.enabled, + publishedFrom: { + workflowId: block.workflowId, + ...(block.workflowName ? { workflowName: block.workflowName } : {}), + ...(block.workspaceId ? { workspaceId: block.workspaceId } : {}), + ...(block.workspaceName ? { workspaceName: block.workspaceName } : {}), + }, + ...(block.enabled ? { schema: `components/blocks/${block.type}.json` } : {}), + })), + note: 'Org-wide blocks published from a deployed workflow. Configure one from its schema under components/blocks/; a disabled block cannot be added to a workflow.', + }, + null, + 2 + ) +} + +/** + * `organization/forks.json` — this workspace's place in the fork tree plus the + * parent/child resource and block mappings. + * + * Owns fork topology; rosters elsewhere carry only `forkedFromWorkspaceId`. + * Mapping counts are summarized per resource type — the raw id pairs are an + * implementation detail of promote/rollback, not workspace context. + */ +export function serializeWorkspaceForks(input: { + parent: { id: string; name: string } | null + children: Array<{ id: string; name: string; createdAt: Date | string }> + resourceMappingCounts: Record + blockMappingCount: number +}): string { + return JSON.stringify( + { + parent: input.parent, + children: input.children.map((child) => ({ + id: child.id, + name: child.name, + createdAt: + child.createdAt instanceof Date ? child.createdAt.toISOString() : child.createdAt, + })), + ...(input.parent + ? { + mappedFromParent: { + resources: input.resourceMappingCounts, + blocks: input.blockMappingCount, + }, + } + : {}), + note: 'A forked workspace keeps a mapping back to the resources it was copied from, which is what promote and rollback follow. Forking, promoting, and rolling back are workspace-admin actions in the UI — you cannot perform them.', + }, + null, + 2 + ) +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index e774db56099..9286097081f 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -16,12 +16,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, desc, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' +import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { buildWorkspaceContextMd, buildWorkspaceMd, type WorkspaceMdData, } from '@/lib/copilot/chat/workspace-context' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { @@ -67,6 +69,11 @@ import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' import type { DeploymentData, VfsServiceAccountAuth } from '@/lib/copilot/vfs/serializers' import { describeServiceAccountForOAuthProvider, + serializeAccessControl, + serializeAccountBilling, + serializeAccountMembers, + serializeAccountWorkspace, + serializeAccountWorkspaces, serializeApiKeyIntegrations, serializeApiKeys, serializeBlockSchema, @@ -83,6 +90,8 @@ import { serializeIntegrationSchema, serializeKBMeta, serializeMcpServer, + serializeOrganization, + serializeOrganizationCustomBlocks, serializeRecentExecutions, serializeSandbox, serializeSandboxCatalog, @@ -93,6 +102,7 @@ import { serializeTriggerSchema, serializeVersions, serializeWorkflowMeta, + serializeWorkspaceForks, } from '@/lib/copilot/vfs/serializers' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { @@ -126,6 +136,7 @@ import { } from '@/lib/knowledge/application/knowledge-bases' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { listTables } from '@/lib/table/service' import { @@ -152,18 +163,27 @@ import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-wo import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { assertActiveWorkspaceAccess, getUsersWithPermissions, getWorkspaceWithOwner, hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' import { buildCustomBlockConfig, isCustomBlockType } from '@/blocks/custom/build-config' import { BLOCK_REGISTRY } from '@/blocks/registry-maps' import type { BlockConfig, BlockIcon } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { + getUserPermissionConfig, + resolveVerifiedUserAccessControlContext, +} from '@/ee/access-control/utils/permission-check' +import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' +import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' +import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' +import { getEdgeMappingRows } from '@/ee/workspace-forking/lib/mapping/mapping-store' import type { ToolConfig } from '@/tools/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' @@ -590,6 +610,14 @@ function getStaticComponentFiles(): Map { * custom-tools/{name}.json * agent/sandboxes/README.md * agent/sandboxes/{name}.json + * account/workspace.json (this workspace + your role; always present) + * account/workspaces.json (every workspace you can reach) + * account/members.json (workspace members; emails admin-only) + * account/billing.json (plan/usage/credits; lazy, read fresh) + * organization/organization.json (org standing; only when org-hosted) + * organization/access-control.json (your governing group + restrictions) + * organization/custom-blocks.json (org-published block provenance) + * organization/forks.json (fork topology; workspace admins only) * environment/credentials.json * environment/api-keys.json * environment/variables.json @@ -827,6 +855,13 @@ export class WorkspaceVFS { 'sandbox_entitlement', hasWorkspaceSandboxAccess(workspaceId) ) + // Shared with the account/ and organization/ namespaces so the + // roster and host context are each read once per materialization. + const membersPromise = timed('members', getUsersWithPermissions(workspaceId)) + const hostContextPromise = timed( + 'host_context', + getWorkspaceHostContextForViewer(workspaceId, userId).catch(() => null) + ) const [ wfSummary, kbSummary, @@ -868,10 +903,19 @@ export class WorkspaceVFS { ) ), timed('workspace_row', getWorkspaceWithOwner(workspaceId)), - timed('members', getUsersWithPermissions(workspaceId)), + membersPromise, permissionConfigPromise, sandboxEntitlementPromise, ]) + + // account/ and organization/ describe the viewer's standing rather + // than workspace resources, so they are materialized after the + // resource pass and contribute nothing to WORKSPACE.md. + const hostContext = await hostContextPromise + await Promise.all([ + timed('account', this.materializeAccount(workspaceId, userId, hostContext, members)), + timed('organization', this.materializeOrganization(workspaceId, userId, hostContext)), + ]) const workspaceMdData: WorkspaceMdData = { workspace: wsRow, members, @@ -2332,6 +2376,218 @@ export class WorkspaceVFS { } } + /** + * Materialize `account/` — the acting user's vantage: this workspace and + * their role in it, the workspaces they can reach, who else is here, and + * their live plan. + * + * Read-only and always mounted. `billing.json` is registered lazily because + * usage ticks between requests: materializing it would freeze the numbers at + * snapshot time and pay for a billing read on every turn that never asks. + * Membership reuses the roster already loaded for WORKSPACE.md rather than + * issuing a second query. + */ + private async materializeAccount( + workspaceId: string, + userId: string, + hostContext: Awaited>, + members: Awaited> + ): Promise { + try { + const [rows, entitlements] = await Promise.all([ + listAccessibleWorkspaceRowsForUser(userId).catch(() => []), + computeWorkspaceEntitlements(workspaceId, userId).catch(() => [] as string[]), + ]) + + const current = rows.find((row) => row.workspace.id === workspaceId) + const parentId = current?.workspace.forkedFromWorkspaceId ?? null + // Name the parent only when the viewer can reach it; otherwise the id + // stands alone rather than leaking a workspace name they cannot open. + const parentRow = parentId ? rows.find((row) => row.workspace.id === parentId) : undefined + const isAdmin = hostContext?.viewer.permission === 'admin' + + this.files.set( + 'account/workspace.json', + serializeAccountWorkspace({ + workspace: { + id: workspaceId, + name: hostContext?.workspace.name ?? current?.workspace.name ?? '', + workspaceMode: hostContext?.workspace.workspaceMode ?? null, + }, + viewer: { + permission: hostContext?.viewer.permission ?? current?.permissionType ?? null, + organizationRole: hostContext?.viewer.organizationRole ?? null, + }, + organization: hostContext?.hostOrganizationId + ? { id: hostContext.hostOrganizationId } + : null, + forkedFrom: parentId + ? { id: parentId, name: parentRow?.workspace.name ?? parentId } + : null, + entitlements, + }) + ) + + this.files.set( + 'account/workspaces.json', + serializeAccountWorkspaces( + rows.map((row) => ({ + id: row.workspace.id, + name: row.workspace.name, + role: row.permissionType, + organizationId: row.workspace.organizationId, + forkedFromWorkspaceId: row.workspace.forkedFromWorkspaceId, + isCurrent: row.workspace.id === workspaceId, + })) + ) + ) + + this.files.set( + 'account/members.json', + serializeAccountMembers(members, { includeContactDetails: isAdmin }) + ) + + this.registerLazy('account/billing.json', async () => { + try { + return serializeAccountBilling(await getAccountBillingSnapshot(userId)) + } catch (err) { + logger.warn('Failed to load account billing', { + workspaceId, + error: toError(err).message, + }) + return null + } + }) + } catch (err) { + logger.warn('Failed to materialize account namespace', { + workspaceId, + error: toError(err).message, + }) + } + } + + /** + * Materialize `organization/` — org standing, the access-control rules that + * actually bind this viewer, org-published block provenance, and fork + * topology. + * + * The namespace exists only when the workspace belongs to an organization, so + * its absence is itself the answer for a personal workspace. Fork detail is + * mounted only for a workspace admin of a forking-enabled org, matching the + * gate the fork routes apply. + */ + private async materializeOrganization( + workspaceId: string, + userId: string, + hostContext: Awaited> + ): Promise { + const organizationId = hostContext?.hostOrganizationId + if (!hostContext || !organizationId) return + + try { + this.files.set( + 'organization/organization.json', + serializeOrganization({ + organization: { + id: organizationId, + relationship: hostContext.viewer.isHostOrganizationMember ? 'internal' : 'external', + role: hostContext.viewer.organizationRole ?? null, + }, + capabilities: { + canManageOrganization: hostContext.viewer.isHostOrganizationAdmin, + canManageBilling: hostContext.viewer.isHostOrganizationAdmin, + }, + plan: hostContext.ownerBilling.plan, + isEnterprise: hostContext.ownerBilling.isEnterprise, + }) + ) + + this.registerLazy('organization/access-control.json', async () => { + try { + const accessControl = await resolveVerifiedUserAccessControlContext( + userId, + workspaceId, + organizationId + ) + return serializeAccessControl({ + entitled: accessControl.entitled, + permissionGroup: accessControl.permissionGroup, + restrictions: getActivePermissionGroupRestrictions(accessControl.config), + }) + } catch (err) { + logger.warn('Failed to load access control context', { + workspaceId, + error: toError(err).message, + }) + return null + } + }) + + this.registerLazy('organization/custom-blocks.json', async () => { + try { + const blocks = await listCustomBlocksWithInputsForWorkspace(workspaceId) + if (blocks.length === 0) return null + return serializeOrganizationCustomBlocks(blocks) + } catch (err) { + logger.warn('Failed to load org custom blocks', { + workspaceId, + error: toError(err).message, + }) + return null + } + }) + + if (hostContext.viewer.permission !== 'admin') return + if (!(await isForkingAvailableForWorkspace(organizationId, userId).catch(() => false))) return + + this.registerLazy('organization/forks.json', async () => { + try { + const [parent, children] = await Promise.all([ + getForkParent(workspaceId), + getForkChildren(workspaceId), + ]) + if (!parent && children.length === 0) return null + + const resourceMappingCounts: Record = {} + let blockMappingCount = 0 + if (parent) { + const [resourceRows, blockMap] = await Promise.all([ + getEdgeMappingRows(db, workspaceId), + loadForkBlockMap(db, workspaceId), + ]) + for (const row of resourceRows) { + resourceMappingCounts[row.resourceType] = + (resourceMappingCounts[row.resourceType] ?? 0) + 1 + } + blockMappingCount = blockMap.parentToChild.size + } + + return serializeWorkspaceForks({ + parent: parent ? { id: parent.id, name: parent.name } : null, + children: children.map((child) => ({ + id: child.id, + name: child.name, + createdAt: child.createdAt, + })), + resourceMappingCounts, + blockMappingCount, + }) + } catch (err) { + logger.warn('Failed to load fork topology', { + workspaceId, + error: toError(err).message, + }) + return null + } + }) + } catch (err) { + logger.warn('Failed to materialize organization namespace', { + workspaceId, + error: toError(err).message, + }) + } + } + /** * Materialize external MCP server connections using the mcpServers table. */ From 38fadd445eb481a9c9282c3facdb713ab3d5bbc6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 17:10:38 -0700 Subject: [PATCH 097/103] Fix insert_text refusing an editable field focused inside a frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describeFocusedEditable descended shadow roots but not frames, while activeElementReadback descends both. Focus inside a same-origin frame therefore surfaced to the first as the FRAME element — not an input, not contentEditable, not a canvas, no textbox role — so it fell through to 'not-editable' and insert_text refused a field that press_key had just typed a character into. Two functions answering 'what is focused' with different answers is the bug; the descent loops now match exactly. The refusal also names what actually held focus (tag, role, contenteditable). A bare 'not-editable' gave the agent nothing to act on, so it guessed at the cause — a real run spent twenty rounds on the wrong theory and had to be stopped by the user. --- apps/desktop/src/main/browser-agent/driver.ts | 18 ++++++++++- .../main/browser-agent/page-functions.test.ts | 32 +++++++++++++++++++ .../src/main/browser-agent/page-functions.ts | 31 +++++++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 7f659bdebce..bbd3e650660 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -3224,10 +3224,26 @@ async function executeToolInner( ) if (!isRecordLike(focusState) || focusState.editable !== true) { const reason = isRecordLike(focusState) ? String(focusState.reason || '') : '' + // Name the element that actually held focus. Without it the agent + // cannot tell "I focused the wrong thing" from "this tool cannot type + // here", and it retries variations of the same failing approach. + const focused = isRecordLike(focusState) + ? [ + focusState.focusedTag ? `<${String(focusState.focusedTag)}>` : '', + focusState.focusedRole ? `role="${String(focusState.focusedRole)}"` : '', + focusState.contentEditable && focusState.contentEditable !== 'unset' + ? `contenteditable="${String(focusState.contentEditable)}"` + : '', + ] + .filter(Boolean) + .join(' ') + : '' throw new ToolError( reason === 'none' ? 'No element is focused. Click the field first (browser_click or browser_click_at), then insert text.' - : `The focused element does not accept text${reason ? ` (${reason})` : ''}. Focus an editable field first.` + : `The focused element does not accept text${reason ? ` (${reason})` : ''}${ + focused ? `; focus is on ${focused}` : '' + }. Click the field you want to type into, then insert text.` ) } const beforePage = await pageActionState(target, true) diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index a8f6b8816e0..5084f208e62 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -1428,6 +1428,38 @@ describe('describeFocusedEditable', () => { expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'none' }) }) + // The bug this pins: focus inside a same-origin frame surfaces on the outer + // document as the FRAME element, which is not an input, not contentEditable, + // not a canvas, and carries no textbox role — so a composer that press-key + // typed into fine was reported `not-editable` and insert_text refused. The + // descent here must match activeElementReadback's exactly. + it('descends a same-origin frame to the editable that really holds focus', () => { + document.body.innerHTML = '' + const frame = document.createElement('iframe') + document.body.append(frame) + const inner = frame.contentDocument as Document + inner.body.innerHTML = '
composer
' + const composer = inner.querySelector('div') as HTMLElement + Object.defineProperty(composer, 'isContentEditable', { value: true, configurable: true }) + setActiveElement(inner, composer) + setActiveElement(document, frame) + + expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'contenteditable' }) + }) + + it('names the focused element when it refuses, so the agent can recover', () => { + document.body.innerHTML = '
Send
' + setActiveElement(document, document.querySelector('div')) + + expect(describeFocusedEditable()).toEqual({ + editable: false, + reason: 'not-editable', + focusedTag: 'div', + focusedRole: 'button', + contentEditable: 'unset', + }) + }) + it('reports a writable input as insertable', () => { document.body.innerHTML = '' setActiveElement(document, document.querySelector('input')) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 33af0d6f964..2f5e9b0d5bd 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -2782,6 +2782,13 @@ export function describePointTarget(x: number, y: number): unknown { * activeElementSecrecy before any insertion. */ export function describeFocusedEditable(): unknown { + // Descend shadow roots AND same-origin frames, matching activeElementReadback + // exactly. Focus inside a frame surfaces on the outer document as the FRAME + // element, which is not an input, not contentEditable, not a canvas and has no + // textbox role — so stopping here reported a perfectly writable field as + // `not-editable`, while press-key (which does descend) typed into it fine. + // Any divergence between these two loops is a tool that refuses what its + // sibling accepts on the same page state. let active = document.activeElement as HTMLElement | null for (let depth = 0; active && depth < 10; depth++) { const shadow = active.shadowRoot @@ -2789,6 +2796,19 @@ export function describeFocusedEditable(): unknown { active = shadow.activeElement as HTMLElement continue } + const activeTag = String(active.tagName || '').toUpperCase() + if (activeTag === 'IFRAME' || activeTag === 'FRAME') { + try { + const inner = (active as HTMLIFrameElement).contentDocument + if (inner?.activeElement && inner.activeElement !== inner.body) { + active = inner.activeElement as HTMLElement + continue + } + } catch { + // Cross-origin frame — not inspectable. The caller refuses separately on + // opaque secrecy, so report the frame itself rather than guessing. + } + } break } if (!active || active === document.body) return { editable: false, reason: 'none' } @@ -2826,5 +2846,14 @@ export function describeFocusedEditable(): unknown { if (tag === 'CANVAS' || active.getAttribute('role') === 'textbox') { return { editable: true, kind: tag === 'CANVAS' ? 'canvas' : 'textbox-role' } } - return { editable: false, reason: 'not-editable' } + // Describe what actually held focus. A bare "not-editable" tells the agent + // nothing it can act on, so it guesses at the cause and burns rounds on the + // wrong recovery; naming the element lets it click the real field instead. + return { + editable: false, + reason: 'not-editable', + focusedTag: tag.toLowerCase(), + ...(active.getAttribute('role') ? { focusedRole: active.getAttribute('role') } : {}), + contentEditable: String(active.getAttribute('contenteditable') ?? 'unset'), + } } From 50aa8f9e900b5d0962b38f19c977f962e211956a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 17:15:32 -0700 Subject: [PATCH 098/103] Keep retired browser takeover renderable in history The tool is gone from the catalog, so its generated constant went with it and every path that referenced it stopped compiling. Deleting those paths instead would have silently downgraded every past transcript containing a takeover card to a generic tool row, and dropped the no-timeout budget that an in-flight takeover still needs while a rolling deploy finishes. retired-tools.ts gives the literal a documented home that says what it is and why it survives its tool. --- .../components/agent-group/agent-group.tsx | 4 +-- .../components/agent-group/tool-call-item.tsx | 4 +-- .../mothership-chat/mothership-chat.tsx | 35 +++++++++++++++++-- .../sim/lib/copilot/chat/persisted-message.ts | 4 +-- .../lib/copilot/generated/tool-catalog-v1.ts | 28 --------------- .../lib/copilot/generated/tool-schemas-v1.ts | 20 ----------- apps/sim/lib/copilot/request/handlers/tool.ts | 4 +-- .../sim/lib/copilot/request/tools/executor.ts | 4 +-- apps/sim/lib/copilot/tools/retired-tools.ts | 19 ++++++++++ 9 files changed, 61 insertions(+), 61 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/retired-tools.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 00b3a81fe37..e72b469184b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' import { ShimmerText } from '@/components/ui' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' -import { BrowserRequestTakeover } from '@/lib/copilot/generated/tool-catalog-v1' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { useSmoothText } from '@/hooks/use-smooth-text' import { type ToolCallData, ToolCallStatus } from '../../../../types' import { getAgentIcon, isToolDone } from '../../utils' @@ -63,7 +63,7 @@ function getActiveBrowserTakeover(items: AgentGroupItem[]): ActiveBrowserTakeove const item = items[index] if (item.type !== 'tool') continue if ( - item.data.toolName === BrowserRequestTakeover.id && + item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && item.data.status === ToolCallStatus.executing ) { const reason = item.data.params?.reason diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 994ee466628..ccda6ce73ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react' import { isPlainRecord } from '@sim/utils/object' import { ShimmerText } from '@/components/ui' import { - BrowserRequestTakeover, CallIntegrationTool, PrepareFileEdit, Read as ReadTool, @@ -10,6 +9,7 @@ import { Wait as WaitTool, } from '@/lib/copilot/generated/tool-catalog-v1' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display' import { getBareIconStyle } from '@/blocks/brand-icon-style' @@ -168,7 +168,7 @@ export function ToolCallItem({ const displayState = resolveToolDisplayState(status) const isExecuting = displayState === 'spinner' - const isBrowserTakeover = toolName === BrowserRequestTakeover.id + const isBrowserTakeover = toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID const isCountingDown = toolName === WaitTool.id && isExecuting const elapsedMs = useElapsedMs(isCountingDown, startedAt) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 8d4bccd9c9f..84025228547 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -664,6 +664,28 @@ export function MothershipChat({ handleEditQueued(tail.id) }, [handleEditQueued]) + /** + * A drag-selection that overshoots a message's last line crosses the row + * wrappers' block boundaries, which the clipboard serializer renders as + * trailing newlines — every copied response pasted with blank lines + * appended. Rewrite the plain-text flavor trimmed; the rich flavor is + * re-serialized from the selection so formatted pastes keep working. + */ + const handleCopy = useCallback((event: React.ClipboardEvent) => { + const selection = window.getSelection() + if (!selection || selection.isCollapsed || !event.clipboardData) return + const text = selection.toString() + const trimmed = text.replace(/\s+$/, '') + if (trimmed === text) return + event.preventDefault() + event.clipboardData.setData('text/plain', trimmed) + const html = document.createElement('div') + for (let i = 0; i < selection.rangeCount; i++) { + html.appendChild(selection.getRangeAt(i).cloneContents()) + } + event.clipboardData.setData('text/html', html.innerHTML) + }, []) + /** * Land at the most recent message once per chat — on open and when switching * chats. The ref tracks which `chatId` we last scrolled for (seeded with @@ -696,7 +718,7 @@ export function MothershipChat({ onWorkspaceResourceSelect={onWorkspaceResourceSelect} >
-
+
{isLoading && !hasMessages ? ( ) : ( @@ -714,8 +736,15 @@ export function MothershipChat({ key={virtualItem.key} data-index={index} ref={virtualizer.measureElement} - className='absolute top-0 left-0 w-full' - style={{ transform: `translateY(${virtualItem.start}px)` }} + /* Positioned with a real `top`, NOT `top-0` + translateY: + text selection maps a drag's start point to a text + position via the rows' LAYOUT boxes, and with every row + laid out at y=0 a drag starting in the gutter anchors in + the wrong row — selections ran upward from a downward + drag. Transforms move paint and hit-testing but not the + layout box that mapping falls back to. */ + className='absolute left-0 w-full' + style={{ top: virtualItem.start }} > {msg.role === 'user' ? ( interactionPairing.hiddenUserByIndex[index] ? null : ( diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index dac5cbd67aa..fb8a30ba5bb 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -15,12 +15,12 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' -import { BrowserRequestTakeover } from '@/lib/copilot/generated/tool-catalog-v1' import type { ContentBlock, LocalToolCallStatus, OrchestratorResult, } from '@/lib/copilot/request/types' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types' export type PersistedToolState = LocalToolCallStatus | MothershipStreamV1ToolOutcome | 'interrupted' @@ -152,7 +152,7 @@ export function stripToolResultOutput(message: PersistedMessage): PersistedMessa if (!toolCall || !result || typeof result !== 'object' || !('output' in result)) return block const output = result.output const userInstruction = - toolCall.name === BrowserRequestTakeover.id && isPlainRecord(output) + toolCall.name === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && isPlainRecord(output) ? output.userInstruction : undefined const normalizedInstruction = typeof userInstruction === 'string' ? userInstruction.trim() : '' diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 8164a205757..a52c890090e 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -26,7 +26,6 @@ export interface ToolCatalogEntry { | 'browser_open_url' | 'browser_press_key' | 'browser_read_text' - | 'browser_request_takeover' | 'browser_screenshot' | 'browser_scroll' | 'browser_select_option' @@ -154,7 +153,6 @@ export interface ToolCatalogEntry { | 'browser_open_url' | 'browser_press_key' | 'browser_read_text' - | 'browser_request_takeover' | 'browser_screenshot' | 'browser_scroll' | 'browser_select_option' @@ -1223,31 +1221,6 @@ export const BrowserReadText: ToolCatalogEntry = { clientExecutable: true, } -export const BrowserRequestTakeover: ToolCatalogEntry = { - id: 'browser_request_takeover', - name: 'browser_request_takeover', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - purpose: { - type: 'string', - description: - 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', - enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], - }, - reason: { - type: 'string', - description: - "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", - }, - }, - required: ['reason'], - }, - clientExecutable: true, -} - export const BrowserScreenshot: ToolCatalogEntry = { id: 'browser_screenshot', name: 'browser_screenshot', @@ -7186,7 +7159,6 @@ export const TOOL_CATALOG: Record = { [BrowserOpenUrl.id]: BrowserOpenUrl, [BrowserPressKey.id]: BrowserPressKey, [BrowserReadText.id]: BrowserReadText, - [BrowserRequestTakeover.id]: BrowserRequestTakeover, [BrowserScreenshot.id]: BrowserScreenshot, [BrowserScroll.id]: BrowserScroll, [BrowserSelectOption.id]: BrowserSelectOption, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index b4339923a66..f98dd73b488 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1103,26 +1103,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, - browser_request_takeover: { - parameters: { - type: 'object', - properties: { - purpose: { - type: 'string', - description: - 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', - enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], - }, - reason: { - type: 'string', - description: - "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", - }, - }, - required: ['reason'], - }, - resultSchema: undefined, - }, browser_screenshot: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 31974d1b489..371fa28894f 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -15,7 +15,6 @@ import { MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolResultPayload, } from '@/lib/copilot/generated/mothership-stream-v1' -import { BrowserRequestTakeover } from '@/lib/copilot/generated/tool-catalog-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' @@ -46,6 +45,7 @@ import type { import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' @@ -807,7 +807,7 @@ async function dispatchToolExecution( */ function waitForClientExecution(): Promise { toolCall.status = 'executing' - const waitsForHuman = toolName === BrowserRequestTakeover.id + const waitsForHuman = toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID const timeoutMs = waitsForHuman ? null : options.timeout || STREAM_TIMEOUT_MS return withCopilotSpan( TraceSpan.CopilotToolWaitForClientResult, diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 7a181bfa4f0..127267555dc 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -21,7 +21,6 @@ import { } from '@/lib/copilot/generated/mothership-stream-v1' import { ApplyFileEdit, - BrowserRequestTakeover, CreateEmptyFile, CreateWorkflow, DeployAsApi, @@ -78,6 +77,7 @@ import { type ToolCallState, } from '@/lib/copilot/request/types' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { isMcpTool } from '@/executor/constants' export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' @@ -259,7 +259,7 @@ export function toolWatchdogTimeoutMs(toolName: string | undefined): number { export function pendingToolWaitBudgetMs( toolCall: Pick | undefined ): number | null { - if (toolCall?.name === BrowserRequestTakeover.id && toolCall.status === 'executing') { + if (toolCall?.name === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && toolCall?.status === 'executing') { return null } if (toolCall?.status === 'awaiting_approval') return TOOL_WATCHDOG_LONG_RUNNING_MS diff --git a/apps/sim/lib/copilot/tools/retired-tools.ts b/apps/sim/lib/copilot/tools/retired-tools.ts new file mode 100644 index 00000000000..97790d4c434 --- /dev/null +++ b/apps/sim/lib/copilot/tools/retired-tools.ts @@ -0,0 +1,19 @@ +/** + * Ids of tools that no longer exist in the catalog but still appear in + * persisted chat history. + * + * A retired tool stops being generated into `tool-catalog-v1`, so any render + * path that referenced its generated constant would fail to compile — and + * deleting those paths instead would silently downgrade every historical + * transcript that contains one. These literals keep replay intact without + * implying the tool is callable: nothing dispatches them, and no agent is + * offered them. + */ + +/** + * Retired with the browser takeover flow. The browser panel is live and shared + * — the user can act in it whenever they want — so there was never anything to + * hand over, and the agent no longer has a concept of taking or returning + * control. Chats from before the removal still contain takeover cards. + */ +export const RETIRED_BROWSER_REQUEST_TAKEOVER_ID = 'browser_request_takeover' From 4e6e53df2ec79609e9da19620f448a0c38a5e5bd Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 17:17:57 -0700 Subject: [PATCH 099/103] Follow the agent into a tab it opened to work in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser_open_tab created the page with activate: false, so the agent worked in a tab the user could not see while the panel sat on a page where nothing was happening. The panel now follows a tab the agent deliberately opened. Scoped to that tool only. A page spawning its own tab (popup, target=_blank) is the site grabbing the view rather than the agent choosing a workspace, and stays in the background as before — two existing tests pin that and caught the first version of this change, which moved both. A tab the user claimed still wins over both: the work starts in the background instead of pulling the page out from under them mid-read. --- apps/desktop/src/main/browser-agent/driver.ts | 3 ++- .../src/main/browser-agent/session.test.ts | 15 ++++++++++++++ .../desktop/src/main/browser-agent/session.ts | 20 ++++++++++++++++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index bbd3e650660..5d4b199aa91 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -1910,7 +1910,8 @@ async function executeToolInner( } } assertCurrentExecution() - const tab = session.addAutomationTab() + // The agent chose to open this page to work in, so the panel follows it. + const tab = session.addAutomationTab({ reveal: true }) const contents = tab.view.webContents if (url) { assertCurrentExecution() diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index f846cb90749..4c6a0247c50 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -1715,6 +1715,21 @@ describe('browser-agent session', () => { expect(contents.loadURL).not.toHaveBeenCalled() }) + it('brings an agent-opened working tab into view, unless the user claimed the visible one', () => { + // browser_open_tab is the agent choosing a page to work in — the panel + // follows it so the work is visible, which a popup deliberately does not. + const first = session.ensureTab() + const working = session.addAutomationTab({ reveal: true }) + expect(session.activeTab()).toBe(working) + expect(working.id).not.toBe(first.id) + + // Once the user claims what they are looking at, the next agent tab opens + // behind it rather than yanking the page out from under them. + session.claimActiveTabForUser() + const background = session.addAutomationTab({ reveal: true }) + expect(session.activeTab()).not.toBe(background) + }) + it('keeps agent popups in the background and context-menu links user-owned', () => { const onTabCreated = vi.fn() session = freshSession(win, { onTabCreated }) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 97ee4e547eb..5b77f6939ed 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -1590,10 +1590,24 @@ export function addTab(): AgentTab { return addTabInternal() } -/** Opens a tab for agent work without replacing the page the user is viewing. */ -export function addAutomationTab(): AgentTab { +/** + * Opens a tab for agent work. + * + * `reveal` is for the agent deliberately opening a page to work in + * (`browser_open_tab`): the panel follows it, so the user watches the work + * instead of staring at a page where nothing is happening. It is NOT set when a + * page spawns a tab on its own (popups, `target="_blank"`) — that is the site + * grabbing the view, not the agent choosing a workspace. + * + * Even with `reveal`, a tab the user claimed themselves wins: pulling the view + * off the page they are reading is the same interruption as a window stealing + * focus mid-sentence. The work still starts, just in the background, and the + * tab strip shows it arriving. + */ +export function addAutomationTab({ reveal = false }: { reveal?: boolean } = {}): AgentTab { restoreBrowserSession() - const tab = addTabInternal({ activate: false, notify: false }) + const followTheWork = reveal && !currentScope.visibleTabUserSelected + const tab = addTabInternal({ activate: followTheWork, notify: false }) currentScope.automationTabId = tab.id applyActiveTabThrottling() persistBrowserSession() From b26072f4e99347c731a00d060737499c9cae862a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 17:22:47 -0700 Subject: [PATCH 100/103] Make the browser tools agree with each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the module found the frame-descent bug was one instance of a pattern: six independent definitions of 'is this editable' and seven of 'what is focused', disagreeing with each other. A tool refusing what its sibling accepts on identical page state is invisible at runtime — the agent follows a snapshot that says one thing into a tool that says another. - browser_type now accepts role="textbox" like browser_insert_text does. The snapshot advertises those elements as [textbox] with a ref, so refusing them meant rejecting exactly what the outline told the model to type into. Both the native and synthetic paths, and their descendant scans. - pressKeyOnPage descends shadow roots and frames like every other focus reader. It was dispatching synthetic keys at the shadow host or