diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index 824fa951318..3502b5f6243 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -75,6 +75,20 @@ This is an observability projection only. Secret resolution and workflow behavio Masking is activated only when Sim successfully resolves a value from **Settings → Secrets** through `{{KEY}}`. A hardcoded literal, direct `environmentVariables['KEY']` read, or shell `$KEY` read does not activate it by itself. Once activated, every exact occurrence of that value in the run's log-facing content is masked. Encoded, hashed, or otherwise transformed versions are not matched. Do not deliberately return or print secrets. +### Copilot code execution + +Copilot's Function and code-execution tools receive a saved secret only when their code explicitly contains a valid `{{KEY}}` reference. Direct `environmentVariables.KEY` access, shell `$KEY`, dynamic names, literals, and configured-but-unused secrets do not mount a value. Code execution requires workspace write access, and the caller must also be allowed to view the raw value: your own Personal secrets, any secret for which you are a Credential Admin, and Workspace secrets when you are a workspace admin. Credential Members can continue using shared secrets through normal workflow and tool resolution, but cannot mount their plaintext into arbitrary Copilot code. + +Headless surfaces use their saved **Secret access** setting: + +- **Sim Chat block** — under **Show additional fields** +- **Scheduled Tasks** — in the task modal +- **Inbox** — under **Settings → Inbox → Secrets** + +Choose **All secrets** or **Selected secrets**. Existing configurations default to **All secrets** for compatibility. **All secrets** still means only secrets explicitly referenced with `{{KEY}}` that the execution actor may view; it never injects the full environment. Inbox messages from allowed external senders do not receive raw-secret access. + +Code receives the real authorized value at runtime. Before any Copilot-visible tool result is returned, exact occurrences of activated secret values are replaced with `{{KEY}}`; local side effects and runtime results are not rewritten. Encoded, hashed, URL-encoded, otherwise transformed, or network-exfiltrated values cannot be inferred and masked reliably, so code should not deliberately return, transform, print, or transmit secrets to unintended destinations. + ## Secret Details Click **Details** on any secret row to open its detail view. @@ -122,8 +136,8 @@ When a workflow runs, secrets resolve in this order: ({ getAsyncToolCall: vi.fn(), getRunSegment: vi.fn(), - upsertAsyncToolCall: vi.fn(), completeAsyncToolCall: vi.fn(), + detachAsyncToolCall: vi.fn(), publishToolConfirmation: vi.fn(), + encryptSecret: vi.fn(), + getTrustedWorkflowToolExecution: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -24,14 +28,24 @@ vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/copilot/async-runs/repository', () => ({ getAsyncToolCall, getRunSegment, - upsertAsyncToolCall, completeAsyncToolCall, + detachAsyncToolCall, + getClaimedWorkflowExecutionId: (claimedBy?: string | null) => + claimedBy?.startsWith('workflow:') ? claimedBy.slice('workflow:'.length) : undefined, })) vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ publishToolConfirmation, })) +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret, +})) + +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getTrustedWorkflowToolExecution, +})) + import { POST } from './route' describe('Copilot Confirm API Route', () => { @@ -41,6 +55,8 @@ describe('Copilot Confirm API Route', () => { checkpointId: 'checkpoint-1', toolName: 'client_tool', args: { foo: 'bar' }, + status: 'running', + claimedBy: 'workflow:execution-1', } beforeEach(() => { @@ -50,9 +66,15 @@ describe('Copilot Confirm API Route', () => { isAuthenticated: true, }) getAsyncToolCall.mockResolvedValue(existingRow) - getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1' }) - upsertAsyncToolCall.mockResolvedValue(existingRow) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'workflow-from-run', + }) completeAsyncToolCall.mockResolvedValue(existingRow) + detachAsyncToolCall.mockResolvedValue(existingRow) + encryptSecret.mockResolvedValue({ encrypted: 'sealed-client-result', iv: 'iv' }) + getTrustedWorkflowToolExecution.mockResolvedValue({ status: 'completed' }) }) function createMockPostRequest(body: Record): NextRequest { @@ -122,15 +144,15 @@ describe('Copilot Confirm API Route', () => { expect(completeAsyncToolCall).toHaveBeenCalledWith({ toolCallId: 'tool-call-123', status: 'completed', - result: { ok: true }, + result: { __sealedClientToolCompletionV1: 'sealed-client-result' }, error: null, }) - expect(upsertAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', status: 'success', - data: { ok: true }, + data: { __sealedClientToolCompletionV1: 'sealed-client-result' }, }) ) }) @@ -149,19 +171,58 @@ describe('Copilot Confirm API Route', () => { expect(completeAsyncToolCall).toHaveBeenCalledWith({ toolCallId: 'tool-call-123', status: 'completed', - result: 'done', + result: { __sealedClientToolCompletionV1: 'sealed-client-result' }, error: null, }) expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', status: 'success', - data: 'done', + data: { __sealedClientToolCompletionV1: 'sealed-client-result' }, }) ) }) - it('keeps background as a live pending detach confirmation', async () => { + it('keeps generic client content sealed in durable and pubsub payloads', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + result: { __sealedClientToolContextV1: 'sealed-context' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'failed near resolved-secret', + data: { output: 'resolved-secret' }, + }) + ) + + expect(response.status).toBe(200) + const sealedResult = { + __sealedClientToolContextV1: 'sealed-context', + __sealedClientToolCompletionV1: 'sealed-client-result', + } + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: sealedResult, + error: 'Tool failed', + }) + expect(publishToolConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'Tool failed', + data: sealedResult, + }) + ) + expect(await response.json()).toMatchObject({ message: 'Tool failed' }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('resolved-secret') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('resolved-secret') + }) + + it('atomically detaches a live background confirmation', async () => { const response = await POST( createMockPostRequest({ toolCallId: 'tool-call-123', @@ -170,8 +231,8 @@ describe('Copilot Confirm API Route', () => { ) expect(response.status).toBe(200) - expect(upsertAsyncToolCall).not.toHaveBeenCalled() expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123') expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', @@ -180,6 +241,749 @@ describe('Copilot Confirm API Route', () => { ) }) + it('rejects a native confirmation before the desktop authorization claim', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'browser_snapshot', + status: 'pending', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + data: { text: 'forged renderer result' }, + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(encryptSecret).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('rejects a workflow confirmation before the server starts the tool call', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'forged-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('rejects a workflow success before its bound execution is terminal', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it.each(['error', 'cancelled'] as const)( + 'accepts a structural %s when the bound execution has no terminal log', + async (status) => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status, + message: 'untrusted client detail', + data: { output: 'untrusted client output' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: status === 'cancelled' ? 'cancelled' : 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + ...(status === 'cancelled' ? { reason: 'user_cancelled', cancelledByUser: true } : {}), + }, + error: + status === 'cancelled' + ? 'Workflow execution was cancelled.' + : 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('untrusted client') + } + ) + + it('accepts a trusted completion for an approved call created by the previous release', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + permissionDecision: 'allow', + claimedBy: null, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'legacy-execution', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'legacy-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'legacy-execution', + }, + error: null, + }) + }) + + it('accepts a verified terminal execution created before workflow claims existed', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'legacy-execution', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'legacy-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'legacy-execution', + }, + error: null, + }) + }) + + it('rejects a workflow confirmation claimed by another executor', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + claimedBy: 'sim-stream', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + }) + ) + + expect(response.status).toBe(404) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).not.toHaveBeenCalled() + }) + + it('rejects a workflow confirmation for a different claimed execution', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'different-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('preserves a canonical preflight failure before an execution is bound', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'untrusted client detail', + }) + ) + + expect(response.status).toBe(200) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { success: false, workflowId: 'workflow-1' }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain( + 'untrusted client detail' + ) + }) + + it('downgrades an unverifiable success from a stale client to a structural failure', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + message: 'untrusted success detail', + data: { output: 'untrusted output' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { success: false, workflowId: 'workflow-1' }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('untrusted') + }) + + it('preserves an approved cancellation before an execution is bound', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'cancelled', + message: 'untrusted cancellation detail', + }) + ) + + expect(response.status).toBe(200) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'cancelled', + result: { + success: false, + workflowId: 'workflow-1', + reason: 'user_cancelled', + cancelledByUser: true, + }, + error: 'Workflow execution was cancelled.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain( + 'untrusted cancellation detail' + ) + }) + + it('does not publish when another terminal confirmation already won', async () => { + completeAsyncToolCall.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + }) + ) + + expect(response.status).toBe(500) + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('does not publish a background replay after the call was finalized', async () => { + detachAsyncToolCall.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'background', + }) + ) + + expect(response.status).toBe(500) + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('acknowledges an idempotent terminal workflow retry without publishing again', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'completed', + claimedBy: null, + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ status: 'success' }) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('acknowledges an idempotent background workflow retry from durable state', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'delivered', + claimedBy: 'workflow:execution-1', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ status: 'background' }) + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('treats a workflow success as a notification and persists only canonical structure', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Completed with resolved-secret', + data: { + success: true, + output: { token: 'prefix-resolved-secret-suffix' }, + logs: ['resolved-secret'], + }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: null, + }) + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Workflow execution completed.', + timestamp: expect.any(String), + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(await response.json()).toEqual({ + success: true, + message: 'Workflow execution completed.', + toolCallId: 'tool-call-123', + status: 'success', + }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('resolved-secret') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('resolved-secret') + }) + + it('persists workflow failure structure without accepting client errors', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_block', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ status: 'failed' }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'error', + message: 'Function failed with resolved-secret', + data: { success: false, error: 'resolved-secret is invalid' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + const published = publishToolConfirmation.mock.calls[0][0] + expect(published.data).toEqual(completeAsyncToolCall.mock.calls[0][0].result) + expect(published.message).toBe(completeAsyncToolCall.mock.calls[0][0].error) + expect(JSON.stringify(published)).not.toContain('resolved-secret') + }) + + it('uses structural workflow data without accepting execution content', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ status: 'failed' }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'error', + message: 'Failed with unresolved-secret-value', + data: { + success: false, + output: 'unresolved-secret-value', + reason: 'provider_failure', + }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls[0][0])).not.toContain( + 'unresolved-secret-value' + ) + }) + + it('binds output identity to the stored workflow target', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_from_block', + args: { workflowId: 'stored-workflow' }, + claimedBy: 'workflow:submitted-execution', + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'run-workflow', + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'submitted-execution', + status: 'success', + data: { workflowId: 'submitted-workflow', output: 'raw-output' }, + }) + ) + + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + result: { + success: true, + workflowId: 'stored-workflow', + executionId: 'submitted-execution', + }, + }) + ) + }) + + it('keeps workflow completion structural even for ordinary client content', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: {}, + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'workflow-from-run', + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Workflow returned a normal value', + data: { output: { value: 'normal-value' }, logs: [] }, + }) + ) + + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-from-run', + executionId: 'execution-1', + }, + error: null, + }) + expect(publishToolConfirmation.mock.calls[0][0].message).toBe('Workflow execution completed.') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('normal-value') + }) + + it('keeps workflow background confirmations structural without loading provenance', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + message: 'Raw background detail', + data: { lastEventId: 7 }, + }) + ) + + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123', { + preserveClaim: true, + }) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + }) + + it('detaches a background confirmation while its execution request is still binding', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'unbound-execution', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123', { + preserveClaim: true, + }) + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'unbound-execution', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1', executionId: 'unbound-execution' }, + }) + }) + + it('accepts a legacy unbound background confirmation without an execution ID', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123') + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1' }, + }) + }) + + it('derives workflow outcome from a content-unavailable trusted terminal execution', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: false, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + expect(publishToolConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ status: 'error', message: 'Workflow execution failed.' }) + ) + expect(await response.json()).toMatchObject({ status: 'error' }) + }) + + it.each(['error', 'cancelled'] as const)( + 'uses a completed server execution instead of the submitted %s status', + async (submittedStatus) => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'execution-1', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: submittedStatus, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: null, + }) + expect(await response.json()).toMatchObject({ status: 'success' }) + } + ) + it('rejects unsupported accepted and rejected confirmation statuses', async () => { const acceptedResponse = await POST( createMockPostRequest({ diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index 1dd5bd98a12..07ca1052dc2 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -1,4 +1,6 @@ +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 NextRequest, NextResponse } from 'next/server' import { copilotConfirmContract } from '@/lib/api/contracts/copilot' @@ -8,12 +10,16 @@ import { ASYNC_TOOL_STATUS, type AsyncCompletionData, type AsyncConfirmationStatus, + isDeliveredAsyncStatus, + isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from '@/lib/copilot/async-runs/lifecycle' import { completeAsyncToolCall, + detachAsyncToolCall, getAsyncToolCall, + getClaimedWorkflowExecutionId, getRunSegment, - upsertAsyncToolCall, } from '@/lib/copilot/async-runs/repository' import { CopilotConfirmOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -27,69 +33,83 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { + retainSealedClientToolContext, + sealClientToolCompletion, +} from '@/lib/copilot/request/tools/client-completion-seal.server' +import { + createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionExecutionId, + getWorkflowToolCompletionMessage, + getWorkflowToolConfirmationStatus, + isWorkflowToolName, + resolveWorkflowToolTargetId, +} from '@/lib/copilot/tools/workflow-tools' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' const logger = createLogger('CopilotConfirmAPI') -/** - * Persist terminal durable tool status, then publish a wakeup event. - * - * `background` remains a live detach signal in the current browser workflow - * runtime, so it should not rewrite the durable async row. - */ +function getClientToolCompletionMessage(status: AsyncConfirmationStatus): string { + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) return 'Tool completed' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) return 'Tool is running in background' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) return 'Tool cancelled' + return 'Tool failed' +} + +function createConfirmationResponse( + toolCallId: string, + status: AsyncConfirmationStatus, + message: string +): NextResponse { + return NextResponse.json({ success: true, message, toolCallId, status }) +} + +/** Atomically finalize or detach a client tool before publishing its wakeup event. */ async function updateToolCallStatus( existing: NonNullable>>, status: AsyncConfirmationStatus, message?: string, - data?: AsyncCompletionData + data?: AsyncCompletionData, + executionId?: string ): Promise { const toolCallId = existing.toolCallId - if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - publishToolConfirmation({ - toolCallId, - status, - message: message || undefined, - timestamp: new Date().toISOString(), - data, - }) - return true - } - const durableStatus = - status === 'success' - ? ASYNC_TOOL_STATUS.completed - : status === 'cancelled' - ? ASYNC_TOOL_STATUS.cancelled - : status === 'error' - ? ASYNC_TOOL_STATUS.failed - : ASYNC_TOOL_STATUS.pending try { - if ( - durableStatus === ASYNC_TOOL_STATUS.completed || - durableStatus === ASYNC_TOOL_STATUS.failed || - durableStatus === ASYNC_TOOL_STATUS.cancelled - ) { - await completeAsyncToolCall({ + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + const detached = executionId + ? await detachAsyncToolCall(toolCallId, { preserveClaim: true }) + : await detachAsyncToolCall(toolCallId) + if (!detached) return false + publishToolConfirmation({ toolCallId, - status: durableStatus, - result: data ?? null, - error: status === 'success' ? null : message || status, - }) - } else if (existing.runId) { - await upsertAsyncToolCall({ - runId: existing.runId, - checkpointId: existing.checkpointId ?? null, - toolCallId, - toolName: existing.toolName || 'client_tool', - args: (existing.args as Record | null) ?? {}, - status: durableStatus, + status, + message: message || undefined, + timestamp: new Date().toISOString(), + data, + ...(executionId ? { executionId } : {}), }) + return true } + const durableStatus = + status === ASYNC_TOOL_CONFIRMATION_STATUS.success + ? ASYNC_TOOL_STATUS.completed + : status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + ? ASYNC_TOOL_STATUS.cancelled + : ASYNC_TOOL_STATUS.failed + const completed = await completeAsyncToolCall({ + toolCallId, + status: durableStatus, + result: data ?? null, + error: status === 'success' ? null : message || status, + }) + if (!completed) return false publishToolConfirmation({ toolCallId, status, message: message || undefined, timestamp: new Date().toISOString(), data, + ...(executionId ? { executionId } : {}), }) return true } catch (error) { @@ -140,7 +160,13 @@ export const POST = withRouteHandler((req: NextRequest) => { } ) if (!parsed.success) return parsed.response - const { toolCallId, status, message, data } = parsed.data.body + const { + toolCallId, + executionId: submittedExecutionId, + status, + message, + data, + } = parsed.data.body span.setAttributes({ [TraceAttr.ToolCallId]: toolCallId, [TraceAttr.ToolConfirmationStatus]: status, @@ -178,15 +204,172 @@ export const POST = withRouteHandler((req: NextRequest) => { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const updated = await updateToolCallStatus(existing, status, message, data) + const isWorkflowTool = isWorkflowToolName(existing.toolName || '') + const workflowId = isWorkflowTool + ? resolveWorkflowToolTargetId(existing.args, run.workflowId) + : undefined + + if (isWorkflowTool && isTerminalAsyncStatus(existing.status)) { + const executionId = getWorkflowToolCompletionExecutionId(existing.result) + if ( + executionId && + submittedExecutionId !== undefined && + submittedExecutionId !== executionId + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Completed workflow execution not found') + } + + const terminalStatus = getWorkflowToolConfirmationStatus(existing.status) + span.setAttributes({ + [TraceAttr.ToolConfirmationStatus]: terminalStatus, + [TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered, + }) + return createConfirmationResponse( + toolCallId, + terminalStatus, + getWorkflowToolCompletionMessage(terminalStatus) + ) + } + + if (isWorkflowTool && isDeliveredAsyncStatus(existing.status)) { + const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy) + if ( + claimedExecutionId && + submittedExecutionId !== undefined && + submittedExecutionId !== claimedExecutionId + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Bound workflow tool call not found') + } + + span.setAttributes({ + [TraceAttr.ToolConfirmationStatus]: ASYNC_TOOL_CONFIRMATION_STATUS.background, + [TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered, + }) + return createConfirmationResponse( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.background, + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background) + ) + } + + const isUnboundTerminalWorkflowOutcome = + status === ASYNC_TOOL_CONFIRMATION_STATUS.error || + status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + const isMutableClientToolCall = isWorkflowTool + ? isWorkflowToolExecutionClaimable(existing.status, existing.permissionDecision) + : existing.status === ASYNC_TOOL_STATUS.running + if ( + (isBrowserToolName(existing.toolName) || + isTerminalToolName(existing.toolName) || + isWorkflowTool) && + !isMutableClientToolCall + ) { + span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound) + return createNotFoundResponse('Running client tool call not found') + } + + let effectiveStatus = status + let executionId = submittedExecutionId + + if (isWorkflowTool) { + const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy) + const hasForeignClaim = + existing.claimedBy !== null && existing.claimedBy !== undefined && !claimedExecutionId + + if ( + hasForeignClaim || + (claimedExecutionId && + submittedExecutionId !== undefined && + submittedExecutionId !== claimedExecutionId) + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Bound workflow tool call not found') + } + + const candidateExecutionId = claimedExecutionId ?? submittedExecutionId + const trustedExecution = + status !== ASYNC_TOOL_CONFIRMATION_STATUS.background && + candidateExecutionId && + workflowId + ? await getTrustedWorkflowToolExecution(candidateExecutionId, workflowId, toolCallId) + : null + + if (claimedExecutionId) { + executionId = claimedExecutionId + if (status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) { + if (trustedExecution) { + effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status) + } else if (!isUnboundTerminalWorkflowOutcome) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Completed workflow execution not found') + } + } + } else if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + executionId = submittedExecutionId + } else if (trustedExecution) { + executionId = trustedExecution.executionId + effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status) + } else if (!isUnboundTerminalWorkflowOutcome) { + effectiveStatus = ASYNC_TOOL_CONFIRMATION_STATUS.error + executionId = undefined + } else { + executionId = undefined + } + } + + span.setAttribute(TraceAttr.ToolConfirmationStatus, effectiveStatus) + const projected = isWorkflowTool + ? { + message: getWorkflowToolCompletionMessage(effectiveStatus), + data: createStructuralWorkflowToolCompletionData( + effectiveStatus, + workflowId, + executionId + ), + } + : { + message: getClientToolCompletionMessage(status), + data: { + ...retainSealedClientToolContext(existing.result), + ...(await sealClientToolCompletion({ + toolCallId, + runId: existing.runId, + userId: authenticatedUserId, + ...(message !== undefined ? { message } : {}), + ...(data !== undefined ? { data } : {}), + })), + }, + } + + const updated = await updateToolCallStatus( + existing, + effectiveStatus, + projected.message, + projected.data, + isWorkflowTool ? executionId : undefined + ) if (!updated) { logger.error(`[${tracker.requestId}] Failed to update tool call status`, { userId: authenticatedUserId, toolCallId, - status, - internalStatus: status, - message, + status: effectiveStatus, + internalStatus: effectiveStatus, + message: projected.message, }) span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.UpdateFailed) // DB write failed — 500, not 400. 400 is a client-shape error. @@ -194,12 +377,11 @@ export const POST = withRouteHandler((req: NextRequest) => { } span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Delivered) - return NextResponse.json({ - success: true, - message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`, + return createConfirmationResponse( toolCallId, - status, - }) + effectiveStatus, + projected.message || `Tool call ${toolCallId} has been ${effectiveStatus.toLowerCase()}` + ) } catch (error) { const duration = tracker.getDuration() diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts new file mode 100644 index 00000000000..bdc42bd79c1 --- /dev/null +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ + +import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + getAsyncToolCall, + getRunSegment, + recordToolPermissionDecision, + publishToolPermissionDecision, + addAutoAllowedTool, + addChatAutoAllowedTool, +} = vi.hoisted(() => ({ + getAsyncToolCall: vi.fn(), + getRunSegment: vi.fn(), + recordToolPermissionDecision: vi.fn(), + publishToolPermissionDecision: vi.fn(), + addAutoAllowedTool: vi.fn(), + addChatAutoAllowedTool: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getAsyncToolCall, + getRunSegment, + recordToolPermissionDecision, +})) + +vi.mock('@/lib/copilot/persistence/tool-permission', () => ({ + publishToolPermissionDecision, + TOOL_PERMISSION_DECISION: { + allow: 'allow', + allow_chat: 'allow_chat', + always_allow: 'always_allow', + skip: 'skip', + }, +})) + +vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ + addAutoAllowedTool, + addChatAutoAllowedTool, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isCopilotToolPermissionsEnabled: true, +})) + +import { POST } from './route' + +describe('Copilot tool permission API', () => { + beforeEach(() => { + vi.clearAllMocks() + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: 'user-1', + isAuthenticated: true, + }) + getAsyncToolCall.mockResolvedValue({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: null, + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + chatId: 'chat-1', + }) + recordToolPermissionDecision.mockResolvedValue({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: 'allow', + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + addAutoAllowedTool.mockResolvedValue(undefined) + addChatAutoAllowedTool.mockResolvedValue(undefined) + }) + + function createRequest(decision: 'allow' | 'allow_chat' | 'always_allow' | 'skip') { + return new NextRequest('http://localhost:3000/api/copilot/tool-permission', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decisions: [{ toolCallId: 'tool-1', decision }] }), + }) + } + + it.each(['allow', 'allow_chat', 'always_allow', 'skip'] as const)( + 'records the generic %s decision without changing execution state', + async (decision) => { + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: decision, + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest(decision)) + + expect(response.status).toBe(200) + expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision) + expect(publishToolPermissionDecision).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'tool-1', decision }) + ) + } + ) + + it('uses the same decision path for non-workflow tools', async () => { + const toolName = 'function_execute' + const decision = 'allow' + getAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName, + status: 'pending', + permissionDecision: null, + }) + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName, + status: 'pending', + permissionDecision: decision, + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest(decision)) + + expect(response.status).toBe(200) + expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision) + }) +}) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index e9a4a57e8e4..6127ba4b162 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -3,12 +3,14 @@ * * @vitest-environment node */ +import { credential } from '@sim/db/schema' import { auditMock, authMockFns, createMockRequest, dbChainMockFns, posthogServerMock, + queueTableRows, resetDbChainMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -52,10 +54,66 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ ServiceAccountSecretError: class ServiceAccountSecretError extends Error {}, })) -import { POST } from '@/app/api/credentials/route' +import { GET, POST } from '@/app/api/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +describe('GET /api/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + }) + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + }) + + it('reports an owned personal secret as raw-view admin without a membership row', async () => { + queueTableRows(credential, [ + { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'env_personal', + displayName: 'MY_API_KEY', + description: null, + providerId: null, + accountId: null, + envKey: 'MY_API_KEY', + envOwnerUserId: 'user-1', + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + memberRole: null, + }, + ]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/credentials?workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.credentials).toEqual([ + expect.objectContaining({ + id: 'credential-1', + type: 'env_personal', + envKey: 'MY_API_KEY', + envOwnerUserId: 'user-1', + role: 'admin', + }), + ]) + }) +}) + describe('POST /api/credentials', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 4efe507fa9a..74b3b6337f1 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -270,7 +270,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const credentials = rows.map(({ memberRole, ...rest }) => ({ ...rest, role: - isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), + (rest.type === 'env_personal' && rest.envOwnerUserId === session.user.id) || + (isWorkspaceAdmin && isSharedCredentialType(rest.type)) + ? 'admin' + : (memberRole ?? 'member'), })) return NextResponse.json({ credentials }) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index f78dd26b928..6f2647f9e83 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -816,6 +816,34 @@ describe('Function Execute API Route', () => { expect(data.__resolvedSecretNames).toEqual(['API_KEY']) }) + it('does not report a reference when validation rejects before code resolution', async () => { + const response = await POST( + createMockRequest( + 'POST', + { + code: 'return {{API_KEY}}', + envVars: { API_KEY: 'secret-value' }, + outputs: { + files: Array.from({ length: 21 }, (_, index) => ({ + path: `files/output-${index}.json`, + sandboxPath: `/home/user/output-${index}.json`, + })), + }, + }, + { + 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1', + } + ) + ) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('Too many sandbox output files requested') + expect(data.__resolvedSecretNames).toEqual([]) + expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + }) + it('reports only successful references sourced from scoped environment variables', async () => { const envResponse = await POST( createMockRequest( @@ -907,6 +935,26 @@ describe('Function Execute API Route', () => { expect((await response.json()).__resolvedSecretNames).toEqual(['ALLOWED']) }) + it('resolves a selected __proto__ secret as an own environment key', async () => { + const response = await POST( + createMockRequest( + 'POST', + { + code: 'return "{{__proto__}}"', + envVars: Object.fromEntries([['__proto__', 'secret-value']]), + secretScope: 'selected', + mountedSecrets: ['__proto__'], + }, + { + 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1', + } + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).__resolvedSecretNames).toEqual(['__proto__']) + }) + it.concurrent('should resolve tag variables with syntax', async () => { const req = createMockRequest('POST', { code: 'return ', diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 4f72d777a38..e6e35431b7c 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -17,6 +17,7 @@ import { writeWorkspaceFileByPath, } from '@/lib/copilot/vfs/resource-writer' import { isRemoteSandboxEnabled } from '@/lib/core/config/env-flags' +import { setRecordValue } from '@/lib/core/utils/records' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' @@ -55,6 +56,7 @@ import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { formatLiteralForCode } from '@/executor/utils/code-formatting' +import { createCodeEnvVarPattern } from '@/executor/utils/code-secret-references' import { createEnvVarPattern, createReferencePattern, @@ -579,7 +581,7 @@ function scopeEnvironmentVariables( const scoped: Record = {} const missing: string[] = [] for (const name of allowed) { - if (name in envVars) scoped[name] = envVars[name] + if (Object.hasOwn(envVars, name)) setRecordValue(scoped, name, envVars[name]) else missing.push(name) } if (missing.length > 0) { @@ -607,19 +609,19 @@ function resolveEnvironmentVariables( const resolverVars: Record = {} Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { - resolverVars[key] = String(value) + setRecordValue(resolverVars, key, String(value)) } }) Object.entries(envVars).forEach(([key, value]) => { if (value !== undefined && value !== null) { - resolverVars[key] = value + setRecordValue(resolverVars, key, value) } }) while ((match = regex.exec(code)) !== null) { const varName = match[1].trim() - if (!(varName in resolverVars)) { + if (!Object.hasOwn(resolverVars, varName)) { continue } @@ -1595,7 +1597,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (lang === CodeLanguage.Shell) { // For shell, env vars are injected as OS env vars via shellEnvs. // Replace {{VAR}} placeholders with $VAR so the shell can access them natively. - resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, (_match, name) => { + resolvedCode = code.replace(createCodeEnvVarPattern(lang), (_match, name) => { if (Object.hasOwn(envVars, name)) { routeContext?.resolvedSecretNames.add(name) } diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 350e85bd8b1..b052d2cfa79 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -190,7 +190,9 @@ describe('mothership private trace provenance transport', () => { } function activateSecret(options: CopilotLifecycleOptions): void { - options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value') + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? options.resolvedSecretTraceRegistry + registry?.recordResolved('API_KEY', 'secret-value') } it('does not expose private provenance unless the internal caller requests it', async () => { @@ -218,15 +220,48 @@ describe('mothership private trace provenance transport', () => { expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled() expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ resolvedSecretTraceRegistry: undefined }) + expect.objectContaining({ environmentContext: undefined }) ) }) + it('keeps headless secret policy server-only', async () => { + mockRunHeadlessCopilotLifecycle.mockImplementation( + async (payload: Record, options: CopilotLifecycleOptions) => { + expect(payload).not.toHaveProperty('secretScope') + expect(payload).not.toHaveProperty('mountedSecrets') + expect(options).toMatchObject({ + secretActorUserId: 'user-1', + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + }, + }) + return successResult() + } + ) + + const response = await POST( + createMockRequest( + 'POST', + { + ...requestBody, + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + }, + { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, + 'http://localhost:3000/api/mothership/execute' + ) + ) + + expect(response.status).toBe(200) + }) + it('keeps execution functional and fails trace provenance closed when catalog setup fails', async () => { mockGetPersonalAndWorkspaceEnv.mockRejectedValueOnce(new Error('catalog unavailable')) mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false) + expect(options.environmentContext).toBeUndefined() return successResult() } ) @@ -258,9 +293,10 @@ describe('mothership private trace provenance transport', () => { it('fails provenance closed without changing a runtime value that rotated after catalog load', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { - expect( - options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'rotated-secret-value') - ).toBe(false) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.recordResolved('API_KEY', 'rotated-secret-value')).toBe(false) return { ...successResult(), content: 'rotated-secret-value' } } ) @@ -292,6 +328,9 @@ describe('mothership private trace provenance transport', () => { it('returns encrypted provenance on a marker-gated successful request', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { + expect(options.environmentContext).not.toHaveProperty('decryptedEnvVars') + expect(options.environmentContext?.resolvedSecretTraceRegistry).toBeDefined() + expect(options.resolvedSecretTraceRegistry).toBeUndefined() activateSecret(options) return successResult() } @@ -322,6 +361,7 @@ describe('mothership private trace provenance transport', () => { scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) expect(JSON.stringify(body.__resolvedSecretTraceProvenance)).not.toContain('secret-value') + expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1) }) it('imports MCP schema-discovery provenance before starting the lifecycle', async () => { @@ -344,7 +384,10 @@ describe('mothership private trace provenance transport', () => { ) mockRunHeadlessCopilotLifecycle.mockImplementation( async (payload: Record, options: CopilotLifecycleOptions) => { - expect(options.resolvedSecretTraceRegistry?.exportProvenance()).toEqual(provenance) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.exportProvenance()).toEqual(provenance) expect(JSON.stringify(payload)).not.toContain('encrypted-secret') expect(JSON.stringify(payload)).not.toContain('__resolvedSecretTraceProvenance') return successResult() @@ -388,7 +431,10 @@ describe('mothership private trace provenance transport', () => { ) mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { - expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.isComplete()).toBe(false) return successResult() } ) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 1febf5e513e..c3e9d23f77a 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -10,6 +10,10 @@ import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { processContextsServer } from '@/lib/copilot/chat/process-contents' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { + type CopilotEnvironmentContext, + createCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, @@ -18,6 +22,7 @@ import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/co import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { StreamEvent } from '@/lib/copilot/request/types' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' @@ -33,7 +38,6 @@ import { } from '@/lib/workspaces/permissions/utils' import { createIncompleteResolvedSecretTraceRegistry, - createResolvedSecretTraceRegistry, ResolvedSecretTraceProvenanceAccumulator, type ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' @@ -135,6 +139,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let messageId: string | undefined let requestId: string | undefined let resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined + let environmentContext: CopilotEnvironmentContext | undefined const includePrivateProvenance = requestsPrivateToolMetadata( req.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1 @@ -162,7 +167,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workflowId, executionId, userMetadata, + secretScope, + mountedSecrets, } = validation.data.body + const secretMountPolicy = normalizeSecretMountPolicy({ secretScope, mountedSecrets }) /** * Bind actor attribution to the authenticated identity. The executor mints @@ -192,14 +200,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const environment = await getPersonalAndWorkspaceEnv(userId, workspaceId, { workspaceAccess, }) - resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ - personalEncrypted: environment.personalEncrypted, - workspaceEncrypted: environment.workspaceEncrypted, - personalDecrypted: environment.personalDecrypted, - workspaceDecrypted: environment.workspaceDecrypted, - decryptionFailures: environment.decryptionFailures, - scope, - }) + environmentContext = await createCopilotEnvironmentContext(userId, workspaceId, environment) + resolvedSecretTraceRegistry = environmentContext.resolvedSecretTraceRegistry } catch (error) { logger.warn('Failed to build Mothership trace secret catalog', { error: getErrorMessage(error), @@ -376,7 +378,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { interactive: false, abortSignal: lifecycleAbortController.signal, billingAttribution, - resolvedSecretTraceRegistry, + ...(userPermission ? { userPermission } : {}), + secretActorUserId: userId, + secretMountPolicy, + environmentContext, + ...(!environmentContext && resolvedSecretTraceRegistry + ? { resolvedSecretTraceRegistry } + : {}), onEvent, }) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 56949815250..6c3f6248c4c 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -205,12 +205,21 @@ export const PUT = withRouteHandler( time: validatedBody.time, endsAt: validatedBody.endsAt, contexts: validatedBody.contexts, + secretScope: validatedBody.secretScope, + mountedSecrets: validatedBody.mountedSecrets, request, }) if (!updateResult.success) { return NextResponse.json( { error: updateResult.error || 'Failed to update schedule' }, - { status: updateResult.errorCode === 'validation' ? 400 : 500 } + { + status: + updateResult.errorCode === 'forbidden' + ? 403 + : updateResult.errorCode === 'validation' + ? 400 + : 500, + } ) } diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index ca25b2fe946..f2741fc4908 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -226,6 +226,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { endsAt, startDate, contexts, + secretScope, + mountedSecrets, } = parsed.data.body const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) @@ -248,6 +250,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { endsAt, startDate, contexts, + secretScope, + mountedSecrets, request: req, }) if (!result.success || !result.schedule) { 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 c55a9c1030d..6da3b5368fc 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 @@ -9,6 +9,7 @@ import { executionPreprocessingMockFns, hybridAuthMockFns, loggingSessionMock, + loggingSessionMockFns, queueTableRows, requestUtilsMockFns, resetDbChainMock, @@ -28,14 +29,21 @@ import { AsyncJobEnqueueError } from '@/lib/core/async-jobs/types' const { mockAssertBillingAttributionSnapshot, mockClaimExecutionId, + mockClaimWorkflowToolExecution, mockEnqueue, mockExecuteWorkflowCore, mockGenerateId, mockGetWorkspaceBillingSettings, + mockGetAsyncToolCall, + mockGetRunSegment, + mockCreateExecutionEventWriter, + mockFlushExecutionStreamReplayBuffer, mockHandlePostExecutionPauseState, mockHasDurableExecutionOwner, + mockInitializeExecutionStreamMeta, mockReleaseExecutionIdClaim, mockReleaseExecutionSlot, + mockReleaseWorkflowToolExecutionClaim, mockRequireBillingAttributionHeader, mockValidatePublicApiAllowed, } = vi.hoisted(() => ({ @@ -46,14 +54,21 @@ const { return value }), mockClaimExecutionId: vi.fn(), + mockClaimWorkflowToolExecution: vi.fn(), mockEnqueue: vi.fn().mockResolvedValue('job-123'), mockExecuteWorkflowCore: vi.fn(), mockGenerateId: vi.fn(() => 'execution-123'), mockGetWorkspaceBillingSettings: vi.fn(), + mockGetAsyncToolCall: vi.fn(), + mockGetRunSegment: vi.fn(), + mockCreateExecutionEventWriter: vi.fn(), + mockFlushExecutionStreamReplayBuffer: vi.fn(), mockHandlePostExecutionPauseState: vi.fn(), mockHasDurableExecutionOwner: vi.fn(), + mockInitializeExecutionStreamMeta: vi.fn(), mockReleaseExecutionIdClaim: vi.fn(), mockReleaseExecutionSlot: vi.fn(), + mockReleaseWorkflowToolExecutionClaim: vi.fn(), mockRequireBillingAttributionHeader: vi.fn(), mockValidatePublicApiAllowed: vi.fn(), })) @@ -102,6 +117,20 @@ vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({ releaseExecutionIdClaim: mockReleaseExecutionIdClaim, })) +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + claimWorkflowToolExecution: mockClaimWorkflowToolExecution, + getAsyncToolCall: mockGetAsyncToolCall, + getRunSegment: mockGetRunSegment, + releaseWorkflowToolExecutionClaim: mockReleaseWorkflowToolExecutionClaim, +})) + +vi.mock('@/lib/execution/event-buffer', () => ({ + createExecutionEventWriter: mockCreateExecutionEventWriter, + flushExecutionStreamReplayBuffer: mockFlushExecutionStreamReplayBuffer, + initializeExecutionStreamMeta: mockInitializeExecutionStreamMeta, + LIVE_ONLY_EXECUTION_EVENT_TYPES: new Set(), +})) + vi.mock('@/lib/execution/payloads/store', () => ({ storeLargeValue: vi.fn(async (_value, _json, size: number) => ({ __simLargeValueRef: true, @@ -174,6 +203,24 @@ function createSessionReplayRequest(executionId: string): NextRequest { ) } +function createBoundCopilotExecutionRequest(overrides: Record = {}): NextRequest { + return createMockRequest( + 'POST', + { + input: { hello: 'world' }, + stream: true, + isClientSession: true, + triggerType: 'copilot', + copilotToolCallId: 'copilot-tool-1', + ...overrides, + }, + { + 'Content-Type': 'application/json', + Cookie: 'session=value', + } + ) +} + interface ExecutionCallerCase { caseName: string authResult: Record @@ -289,7 +336,23 @@ describe('workflow execute async route', () => { key: `workflow-execution-id:${executionId}`, token: `token-${executionId}`, })) + mockClaimWorkflowToolExecution.mockResolvedValue({ + toolCallId: 'copilot-tool-1', + claimedBy: 'workflow:execution-123', + }) mockHasDurableExecutionOwner.mockResolvedValue(false) + mockGetAsyncToolCall.mockReset().mockResolvedValue({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + }) + mockGetRunSegment.mockReset().mockResolvedValue({ + id: 'copilot-run-1', + userId: 'session-user-1', + workflowId: 'workflow-1', + }) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-12345678') workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValue(false) @@ -328,7 +391,7 @@ describe('workflow execute async route', () => { }) workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState.mockResolvedValue(null) workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) - mockExecuteWorkflowCore.mockResolvedValue({ + mockExecuteWorkflowCore.mockReset().mockResolvedValue({ success: true, status: 'completed', output: { ok: true }, @@ -339,6 +402,244 @@ describe('workflow execute async route', () => { }, }) mockHandlePostExecutionPauseState.mockResolvedValue(undefined) + mockInitializeExecutionStreamMeta.mockReset().mockResolvedValue(true) + mockFlushExecutionStreamReplayBuffer.mockReset().mockResolvedValue(true) + mockCreateExecutionEventWriter.mockReset().mockReturnValue({ + write: vi.fn(async (event: unknown) => ({ event, eventId: '1' })), + writeTerminal: vi.fn(async (event: unknown) => ({ event, eventId: '2' })), + close: vi.fn().mockResolvedValue(undefined), + }) + loggingSessionMockFns.mockWaitForPostExecution.mockReset().mockResolvedValue(undefined) + }) + + it('binds a Copilot workflow tool only to its server log and waits before terminal SSE', async () => { + let releasePostExecution: (() => void) | undefined + loggingSessionMockFns.mockWaitForPostExecution.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePostExecution = resolve + }) + ) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + const bodyPromise = response.text() + + await vi.waitFor(() => { + expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalledTimes(1) + }) + let streamCompleted = false + void bodyPromise.then(() => { + streamCompleted = true + }) + await Promise.resolve() + + expect(response.status).toBe(200) + expect(streamCompleted).toBe(false) + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).toHaveBeenCalledWith({ + executionId: 'execution-123', + requestId: 'req-12345678', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'copilot-tool-1', + }) + const executionArgs = mockExecuteWorkflowCore.mock.calls[0][0] + expect(executionArgs).not.toHaveProperty('copilotToolCallId') + expect(executionArgs.snapshot.metadata).not.toHaveProperty('copilotToolCallId') + + releasePostExecution?.() + const body = await bodyPromise + expect(body).toContain('execution:completed') + }) + + it('rejects a competing Copilot workflow execution before logging starts', async () => { + mockClaimWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Copilot workflow tool is already bound to another execution', + }) + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() + expect(mockPreprocessExecution).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) + + it('releases a bound Copilot workflow claim when preprocessing rejects the run', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { message: 'Not admitted', statusCode: 402 }, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(402) + expect(mockReleaseWorkflowToolExecutionClaim).toHaveBeenCalledWith( + 'copilot-tool-1', + 'execution-123' + ) + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) + + it('retains a bound Copilot workflow claim when preprocessing created a durable error log', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { message: 'Not admitted', statusCode: 402 }, + }) + mockHasDurableExecutionOwner.mockResolvedValueOnce(true) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(402) + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() + expect(mockReleaseExecutionIdClaim).not.toHaveBeenCalled() + }) + + it('binds a workflow execution after its page-hide confirmation detached the waiter', async () => { + mockGetAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'delivered', + claimedBy: null, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(200) + await response.text() + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + }) + + it('binds an approved pending workflow call created by the previous release', async () => { + mockGetAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + permissionDecision: 'allow', + claimedBy: null, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(200) + await response.text() + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + }) + + it.each([ + [ + 'pending tool row', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], + [ + 'terminal tool row', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'completed', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], + [ + 'different workflow target', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-2' }, + status: 'running', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], + [ + 'different execution actor', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + }, + { id: 'copilot-run-1', userId: 'other-user', workflowId: 'workflow-1' }, + ], + ])('rejects a Copilot binding owned by a %s', async (_caseName, toolCall, run) => { + mockGetAsyncToolCall.mockResolvedValueOnce(toolCall) + mockGetRunSegment.mockResolvedValueOnce(run) + + 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() + }) + + it('rejects Copilot workflow bindings outside the interactive SSE surface', async () => { + const response = await POST(createBoundCopilotExecutionRequest({ stream: false }), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(400) + expect(mockGetAsyncToolCall).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'cancelled', + { + success: false, + status: 'cancelled', + output: {}, + logs: [], + metadata: { duration: 1 }, + }, + ], + ['error', new Error('execution failed')], + ])('waits for bound post-execution work on %s terminal paths', async (_caseName, outcome) => { + if (outcome instanceof Error) { + mockExecuteWorkflowCore.mockRejectedValueOnce(outcome) + } else { + mockExecuteWorkflowCore.mockResolvedValueOnce(outcome) + } + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + await response.text() + + expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalledTimes(1) }) it('reuses raw workflow input by execution ID without returning it to the client', async () => { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index d9712918d9e..73854085f0c 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -19,6 +19,14 @@ import { type BillingAttributionSnapshot, requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { isWorkflowToolExecutionClaimable } from '@/lib/copilot/async-runs/lifecycle' +import { + claimWorkflowToolExecution, + getAsyncToolCall, + getRunSegment, + releaseWorkflowToolExecutionClaim, +} from '@/lib/copilot/async-runs/repository' +import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' @@ -145,6 +153,27 @@ const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' +async function isValidCopilotWorkflowToolBinding(params: { + toolCallId: string + userId: string + workflowId: string +}): 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 + ) +} + function createExecutionJsonResponse( body: Record, init: ResponseInit | undefined, @@ -593,6 +622,8 @@ async function handleExecutePost( let executionId = '' let executionIdClaim: ExecutionIdClaim | null = null let executionIdClaimCommitted = false + let workflowToolClaimAcquired = false + let copilotToolCallId: string | undefined try { const auth = await checkHybridAuth(req, { requireWorkflowId: false }) @@ -730,13 +761,19 @@ async function handleExecutePost( workflowStateOverride, deploymentVersionId: admittedDeploymentVersionId, executionId: rawBodyExecutionId, + copilotToolCallId: parsedCopilotToolCallId, triggerBlockId: parsedTriggerBlockId, startBlockId, stopAfterBlockId, runFromBlock: rawRunFromBlock, parentWorkspaceId, } = validation.data + copilotToolCallId = parsedCopilotToolCallId const triggerBlockId = parsedTriggerBlockId ?? startBlockId + const streamHeader = req.headers.get('X-Stream-Response') === 'true' + const enableSSE = streamHeader || streamParam === true + const executionModeHeader = req.headers.get('X-Execution-Mode') + const isAsyncMode = executionModeHeader === 'async' if (admittedDeploymentVersionId && !isMcpBridgeRequest) { return NextResponse.json( { error: 'deploymentVersionId is reserved for internal MCP execution' }, @@ -786,6 +823,20 @@ async function handleExecutePost( ) } + if ( + copilotToolCallId && + (auth.authType !== AuthType.SESSION || + !isClientSession || + triggerType !== 'copilot' || + !enableSSE || + isAsyncMode) + ) { + return NextResponse.json( + { error: 'Copilot tool execution binding is invalid for this request' }, + { status: 400 } + ) + } + if (auth.authType === 'api_key') { if (isClientSession) { return NextResponse.json( @@ -900,6 +951,7 @@ async function handleExecutePost( triggerBlockId: _triggerBlockId, stopAfterBlockId: _stopAfterBlockId, runFromBlock: _runFromBlock, + copilotToolCallId: _copilotToolCallId, workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth parentWorkspaceId: _parentWorkspaceId, ...rest @@ -916,10 +968,6 @@ async function handleExecutePost( const shouldUseDraftState = isPublicApiAccess ? false : (useDraftState ?? auth.authType === AuthType.SESSION) - const streamHeader = req.headers.get('X-Stream-Response') === 'true' - const enableSSE = streamHeader || streamParam === true - const executionModeHeader = req.headers.get('X-Execution-Mode') - const isAsyncMode = executionModeHeader === 'async' const requiresWriteExecutionAccess = Boolean( useDraftState || workflowStateOverride || rawRunFromBlock ) @@ -1027,6 +1075,20 @@ async function handleExecutePost( ) } + if ( + copilotToolCallId && + !(await isValidCopilotWorkflowToolBinding({ + toolCallId: copilotToolCallId, + userId, + workflowId, + })) + ) { + return NextResponse.json( + { error: 'Copilot workflow tool binding was not found' }, + { status: 403 } + ) + } + if (inputFromExecutionId) { const { getExecutionInputForWorkflow } = await import( '@/lib/workflows/executor/execution-state' @@ -1094,12 +1156,33 @@ async function handleExecutePost( ) } + if (copilotToolCallId) { + const boundToolCall = await claimWorkflowToolExecution(copilotToolCallId, executionId) + if (!boundToolCall) { + return NextResponse.json( + { error: 'Copilot workflow tool is already bound to another execution' }, + { status: 409 } + ) + } + workflowToolClaimAcquired = true + } + const loggingSession = new LoggingSession( workflowId, executionId, loggingTriggerType, requestId ) + if (copilotToolCallId) { + loggingSession.setTrustedExecutionCorrelation({ + executionId, + requestId, + source: 'workflow', + workflowId, + triggerType, + copilotToolCallId, + }) + } /** The pre-fetched record avoids a redundant initial workflow lookup. */ const preprocessResult = await preprocessExecution({ @@ -1654,6 +1737,13 @@ async function handleExecutePost( const stream = new ReadableStream({ async start(controller) { let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null + let postExecutionAwaited = false + + const awaitBoundCopilotPostExecution = async () => { + if (!copilotToolCallId || postExecutionAwaited) return + await loggingSession.waitForPostExecution() + postExecutionAwaited = true + } registerManualExecutionAborter(executionId, timeoutController.abort) isManualAbortRegistered = true @@ -2029,6 +2119,8 @@ async function handleExecutePost( runFromBlock: resolvedRunFromBlock, }) + await awaitBoundCopilotPostExecution() + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) /** @@ -2167,6 +2259,7 @@ async function handleExecutePost( ) } } catch (error: unknown) { + await awaitBoundCopilotPostExecution() const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut() const errorMessage = isTimeout ? getTimeoutErrorMessage(error, timeoutController.timeoutMs) @@ -2299,6 +2392,18 @@ async function handleExecutePost( } } + if (copilotToolCallId && workflowToolClaimAcquired && !executionIdClaimCommitted) { + try { + await releaseWorkflowToolExecutionClaim(copilotToolCallId, executionId) + } catch (error) { + reqLogger.warn('Failed to release pre-start Copilot workflow tool claim', { + error: toError(error).message, + executionId, + copilotToolCallId, + }) + } + } + if (executionIdClaim && !executionIdClaimCommitted) { try { await releaseExecutionIdClaim(executionIdClaim) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts index 759abd0b1e5..7f0f121d319 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts @@ -4,12 +4,17 @@ import { authMockFns, createMockRequest, environmentUtilsMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceById, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess } = - vi.hoisted(() => ({ - mockGetWorkspaceById: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), - })) +const { + mockGetPersonalEnvKeyRawAccess, + mockGetWorkspaceById, + mockGetUserEntityPermissions, + mockGetWorkspaceEnvKeyAdminAccess, +} = vi.hoisted(() => ({ + mockGetPersonalEnvKeyRawAccess: vi.fn(), + mockGetWorkspaceById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), +})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceById: mockGetWorkspaceById, @@ -19,6 +24,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv vi.mock('@/lib/credentials/environment', () => ({ + getPersonalEnvKeyRawAccess: mockGetPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, createWorkspaceEnvCredentials: vi.fn(), deleteWorkspaceEnvCredentials: vi.fn(), @@ -47,9 +53,14 @@ describe('GET /api/workspaces/[id]/environment', () => { mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID }) mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ workspaceDecrypted: { OPENAI_API_KEY: 'sk-secret', DATABASE_URL: 'postgres://secret' }, - personalDecrypted: { PERSONAL: { value: 'p' } }, + personalDecrypted: { PERSONAL: 'personal-secret', SHARED_PERSONAL: 'shared-secret' }, + personalOwners: { PERSONAL: 'u-1', SHARED_PERSONAL: 'owner-2' }, conflicts: [], }) + mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ + ownedKeys: new Set(['PERSONAL']), + adminKeys: new Set(), + }) }) it('returns 401 when the caller has no workspace permission', async () => { @@ -116,7 +127,7 @@ describe('GET /api/workspaces/[id]/environment', () => { expect(body.data.workspace.DATABASE_URL).toBe('') }) - it('always returns personal values untouched', async () => { + it('reveals own personal values and masks shared personal values without an admin grant', async () => { mockGetUserEntityPermissions.mockResolvedValue('read') mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ adminKeys: new Set(), @@ -125,6 +136,25 @@ describe('GET /api/workspaces/[id]/environment', () => { const { body } = await callGet() - expect(body.data.personal).toEqual({ PERSONAL: { value: 'p' } }) + expect(body.data.personal).toEqual({ PERSONAL: 'personal-secret', SHARED_PERSONAL: '' }) + }) + + it('reveals shared personal values to an active credential admin', async () => { + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']), + }) + mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ + ownedKeys: new Set(['PERSONAL']), + adminKeys: new Set(['SHARED_PERSONAL']), + }) + + const { body } = await callGet() + + expect(body.data.personal).toEqual({ + PERSONAL: 'personal-secret', + SHARED_PERSONAL: 'shared-secret', + }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index f7aad4aba15..98194c979b0 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -18,6 +18,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceEnvCredentials, deleteWorkspaceEnvCredentials, + getPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess, } from '@/lib/credentials/environment' import { @@ -74,6 +75,32 @@ async function maskWorkspaceEnvForViewer({ return masked } +async function maskPersonalEnvForViewer({ + personalDecrypted, + personalOwners, + workspaceId, + userId, +}: { + personalDecrypted: Record + personalOwners: Record + workspaceId: string + userId: string +}): Promise> { + const personalKeys = Object.keys(personalDecrypted) + const { ownedKeys, adminKeys } = await getPersonalEnvKeyRawAccess({ + workspaceId, + personalOwners, + userId, + }) + + return Object.fromEntries( + personalKeys.map((key) => [ + key, + ownedKeys.has(key) || adminKeys.has(key) ? personalDecrypted[key] : '', + ]) + ) +} + export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -98,10 +125,8 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv( - userId, - workspaceId - ) + const { workspaceDecrypted, personalDecrypted, personalOwners, conflicts } = + await getPersonalAndWorkspaceEnv(userId, workspaceId) const workspace = await maskWorkspaceEnvForViewer({ workspaceDecrypted, @@ -109,12 +134,18 @@ export const GET = withRouteHandler( userId, permission, }) + const personal = await maskPersonalEnvForViewer({ + personalDecrypted, + personalOwners, + workspaceId, + userId, + }) return NextResponse.json( { data: { workspace, - personal: personalDecrypted, + personal, conflicts, }, }, diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts new file mode 100644 index 00000000000..a3eebf6272c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEntityPermissions, mockHasWorkspaceInboxAccess } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), + mockHasWorkspaceInboxAccess: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess, +})) + +vi.mock('@/lib/mothership/inbox/lifecycle', () => ({ + disableInbox: vi.fn(), + enableInbox: vi.fn(), + updateInboxAddress: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { PATCH } from '@/app/api/workspaces/[id]/inbox/route' + +const context = { params: Promise.resolve({ id: 'workspace-1' }) } + +describe('Inbox config secret policy', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockHasWorkspaceInboxAccess.mockResolvedValue(true) + }) + + it('updates policy without requiring an inbox lifecycle mutation', async () => { + queueTableRows(schemaMock.workspace, [ + { + inboxEnabled: true, + inboxAddress: 'tasks@example.com', + inboxProviderId: 'provider-1', + inboxSecretScope: 'all', + inboxMountedSecrets: [], + }, + ]) + + const response = await PATCH( + createMockRequest( + 'PATCH', + { secretScope: 'selected', mountedSecrets: [' B ', 'A', 'B'] }, + undefined, + 'http://localhost:3000/api/workspaces/workspace-1/inbox' + ), + context + ) + + const body = await response.json() + expect({ status: response.status, body }).toMatchObject({ + status: 200, + body: { + enabled: true, + address: 'tasks@example.com', + secretScope: 'selected', + mountedSecrets: ['B', 'A'], + }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + inboxSecretScope: 'selected', + inboxMountedSecrets: ['B', 'A'], + }) + ) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index bbaa2594986..0bcc27b959d 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -7,6 +7,7 @@ import { updateInboxConfigContract } from '@/lib/api/contracts/inbox' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -31,6 +32,8 @@ export const GET = withRouteHandler( .select({ inboxEnabled: workspace.inboxEnabled, inboxAddress: workspace.inboxAddress, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, }) .from(workspace) .where(eq(workspace.id, workspaceId)) @@ -68,6 +71,10 @@ export const GET = withRouteHandler( return NextResponse.json({ enabled: ws.inboxEnabled, address: ws.inboxAddress, + ...normalizeSecretMountPolicy({ + secretScope: ws.inboxSecretScope, + mountedSecrets: ws.inboxMountedSecrets, + }), entitled, taskStats: stats, }) @@ -92,9 +99,57 @@ export const PATCH = withRouteHandler( const body = parsed.data.body try { + const [current] = await db + .select({ + inboxEnabled: workspace.inboxEnabled, + inboxAddress: workspace.inboxAddress, + inboxProviderId: workspace.inboxProviderId, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + if (!current) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const hasPolicyUpdate = body.secretScope !== undefined || body.mountedSecrets !== undefined + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: body.secretScope ?? current.inboxSecretScope, + mountedSecrets: body.mountedSecrets ?? current.inboxMountedSecrets, + }) + const persistPolicy = async () => { + if (!hasPolicyUpdate) return + await db + .update(workspace) + .set({ + inboxSecretScope: secretMountPolicy.secretScope, + inboxMountedSecrets: secretMountPolicy.mountedSecrets, + updatedAt: new Date(), + }) + .where(eq(workspace.id, workspaceId)) + } + if (body.enabled === false) { await disableInbox(workspaceId) - return NextResponse.json({ enabled: false, address: null }) + await persistPolicy() + return NextResponse.json({ + enabled: false, + address: null, + providerId: null, + ...secretMountPolicy, + }) + } + + if (body.enabled === undefined && body.username === undefined && hasPolicyUpdate) { + await persistPolicy() + return NextResponse.json({ + enabled: current.inboxEnabled, + address: current.inboxAddress, + providerId: current.inboxProviderId, + ...secretMountPolicy, + }) } if (!(await hasWorkspaceInboxAccess(workspaceId))) { @@ -102,21 +157,18 @@ export const PATCH = withRouteHandler( } if (body.enabled === true) { - const [current] = await db - .select({ inboxEnabled: workspace.inboxEnabled }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - if (current?.inboxEnabled) { + if (current.inboxEnabled) { return NextResponse.json({ error: 'Inbox is already enabled' }, { status: 409 }) } const config = await enableInbox(workspaceId, { username: body.username }) - return NextResponse.json(config) + await persistPolicy() + return NextResponse.json({ ...config, ...secretMountPolicy }) } if (body.username) { const config = await updateInboxAddress(workspaceId, body.username) - return NextResponse.json(config) + await persistPolicy() + return NextResponse.json({ ...config, ...secretMountPolicy }) } return NextResponse.json({ error: 'No valid update provided' }, { status: 400 }) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx index f7bf16ba921..b9afc65ea72 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx @@ -16,6 +16,7 @@ interface TaskContextMenuProps { onClose: () => void /** The right-clicked task; its status decides which actions render. */ task: ScheduledTask | null + canEdit: boolean onEdit: () => void /** Opens a new-task modal pre-filled from this task. */ onDuplicate: () => void @@ -37,6 +38,7 @@ export function TaskContextMenu({ position, onClose, task, + canEdit, onEdit, onDuplicate, onPause, @@ -72,10 +74,12 @@ export function TaskContextMenu({ > {isUpcoming ? ( <> - - - Edit - + {canEdit && ( + + + Edit + + )} {canPauseResume && (task?.disabled ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx index 2666b89186e..b52cb043e54 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx @@ -39,8 +39,8 @@ interface TaskDetailsModalProps { } /** - * Read-only record modal for tasks that are running or already finished — - * pending tasks open the edit `TaskModal` instead. Three plaintext fields: + * Read-only record modal for tasks that are running, finished, or owned by + * another execution actor. Three plaintext fields: * Status and the run time as copy fields, the prompt as a view-only chip editor. */ export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx new file mode 100644 index 00000000000..a4b14c8adb7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx @@ -0,0 +1,67 @@ +'use client' + +import { ChipModalField, ChipModalSeparator, ChipSelect } from '@sim/emcn' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import { useRawMountableSecretOptions } from '@/hooks/queries/secret-mount-options' + +const SECRET_SCOPE_OPTIONS = [ + { value: 'all', label: 'All secrets' }, + { value: 'selected', label: 'Selected secrets' }, +] + +interface SecretAccessSectionProps extends SecretMountPolicy { + workspaceId: string + onChange: (policy: SecretMountPolicy) => void +} + +export function SecretAccessSection({ + workspaceId, + secretScope, + mountedSecrets, + onChange, +}: SecretAccessSectionProps) { + const { options, isPending } = useRawMountableSecretOptions(workspaceId) + + return ( +
+ +
+ + + onChange({ + secretScope: value === 'selected' ? 'selected' : 'all', + mountedSecrets, + }) + } + options={SECRET_SCOPE_OPTIONS} + /> + + + {secretScope === 'selected' && ( + + + onChange({ secretScope: 'selected', mountedSecrets: values }) + } + disabled={isPending} + /> + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx index de88be597bf..fad48a1bd31 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx @@ -13,12 +13,17 @@ import { import { Calendar } from '@sim/emcn/icons' import { format } from 'date-fns' import { useParams } from 'next/navigation' +import { + DEFAULT_SECRET_MOUNT_POLICY, + type SecretMountPolicy, +} from '@/lib/copilot/secret-mount-policy' import { wallClockNow, zonedWallClockToUtc } from '@/lib/core/utils/timezone' import { PromptEditor, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components' import { RecurrenceSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section' +import { SecretAccessSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section' import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' import { DEFAULT_RECURRENCE, @@ -69,7 +74,7 @@ function defaultLaunch( } /** The data a task create or edit captures. */ -export interface TaskDraft { +export interface TaskDraft extends SecretMountPolicy { prompt: string /** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */ contexts?: ChatContext[] @@ -80,7 +85,7 @@ export interface TaskDraft { } /** Pre-filled fields shared by the edit and duplicate flows. */ -export interface TaskPrefill { +export interface TaskPrefill extends SecretMountPolicy { prompt: string /** Stored `@`-mention contexts, re-registered so they carry over. */ contexts?: ChatContext[] @@ -223,6 +228,10 @@ function TaskModalContent({ const [recurrence, setRecurrence] = useState( () => source?.recurrence ?? DEFAULT_RECURRENCE ) + const [secretPolicy, setSecretPolicy] = useState(() => ({ + secretScope: source?.secretScope ?? DEFAULT_SECRET_MOUNT_POLICY.secretScope, + mountedSecrets: source?.mountedSecrets ?? DEFAULT_SECRET_MOUNT_POLICY.mountedSecrets, + })) const launchEditedRef = useRef(false) /** * Synchronous mirror of `submitting` that gates {@link handleSubmit}. The @@ -286,6 +295,7 @@ function TaskModalContent({ launchTime, timezone, recurrence, + ...secretPolicy, }) ) .then(() => true) @@ -331,6 +341,7 @@ function TaskModalContent({ /> + maxRuns: fields.maxRuns ?? null, endsAt: fields.endsAt ?? null, contexts: draft.contexts ?? [], + secretScope: draft.secretScope, + mountedSecrets: draft.mountedSecrets, } } @@ -212,6 +216,8 @@ export function useScheduledTasks({ launchTime, timezone: schedule.timezone, recurrence, + secretScope: schedule.secretScope, + mountedSecrets: schedule.mountedSecrets, } }, [schedules] diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx index 8f3410fe014..4d8f1c86cd1 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react' import { Calendar, Plus } from '@sim/emcn/icons' import { useParams } from 'next/navigation' +import { useSession } from '@/lib/auth/auth-client' import type { ResourceAction } from '@/app/workspace/[workspaceId]/components' import { Resource } from '@/app/workspace/[workspaceId]/components' import { ScheduleCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar' @@ -23,6 +24,7 @@ import { useTimezone } from '@/hooks/queries/general-settings' export function ScheduledTasks() { const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: session } = useSession() const timezone = useTimezone() const calendar = useCalendar(timezone) @@ -32,9 +34,12 @@ export function ScheduledTasks() { ) const tasks = useScheduledTasks({ workspaceId, rangeStart: range.start, rangeEnd: range.end }) - /** Pending tasks open the editable TaskModal; running/finished open the record. */ - const editTask = tasks.selectedTask?.status === 'pending' ? tasks.selectedTask : null - const recordTask = tasks.selectedTask?.status !== 'pending' ? tasks.selectedTask : null + /** Only the execution actor may edit task contents; every other view is read-only. */ + const selectedTaskIsEditable = + tasks.selectedTask?.status === 'pending' && + tasks.selectedTask.sourceUserId === session?.user?.id + const editTask = selectedTaskIsEditable ? tasks.selectedTask : null + const recordTask = tasks.selectedTask && !selectedTaskIsEditable ? tasks.selectedTask : null const editSeed = editTask ? tasks.editSeedFor(editTask) : null const { @@ -183,6 +188,7 @@ export function ScheduledTasks() { position={taskContextMenuPosition} onClose={closeTaskContextMenu} task={contextTask} + canEdit={contextTask?.sourceUserId === session?.user?.id} onEdit={openContextTask} onDuplicate={handleDuplicate} onPause={handlePauseContextTask} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts index 313af910c11..e2c08c48b30 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts @@ -15,6 +15,7 @@ function makeTask(overrides: Partial): ScheduledTask { return { id: 't1', scheduleId: 's1', + sourceUserId: 'user-1', prompt: 'Summarize yesterday', runAt: new Date('2026-06-10T14:30:00.000Z'), timezone: 'UTC', @@ -93,13 +94,18 @@ describe('taskToCalendarEvent', () => { describe('scheduleToTasks', () => { it('renders an active one-time task as a single pending occurrence at its next run', () => { const tasks = scheduleToTasks( - makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z' }), + makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z', sourceUserId: 'creator-1' }), RANGE_START, RANGE_END, NOW ) expect(tasks).toHaveLength(1) - expect(tasks[0]).toMatchObject({ scheduleId: 's1', status: 'pending', recurring: false }) + expect(tasks[0]).toMatchObject({ + scheduleId: 's1', + sourceUserId: 'creator-1', + status: 'pending', + recurring: false, + }) expect(tasks[0].runAt.toISOString()).toBe('2026-06-11T09:00:00.000Z') }) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts index 141a5d7c32c..0361e6325b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts @@ -20,6 +20,8 @@ export interface ScheduledTask { id: string /** The persisted schedule id, used to edit or delete the task. */ scheduleId: string + /** The user whose authority executes the task and who may edit its contents. */ + sourceUserId: string | null /** The instruction Sim runs. Doubles as the calendar title. */ prompt: string /** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */ @@ -100,6 +102,7 @@ export function scheduleToTasks( const contexts = (row.contexts ?? undefined) as unknown as ChatContext[] | undefined const base = { scheduleId: row.id, + sourceUserId: row.sourceUserId, prompt: row.prompt ?? '', contexts, timezone: row.timezone, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx index d34e1036307..b9491208fe9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx @@ -11,6 +11,8 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + ChipSelect, + Label, Tooltip, useCopyToClipboard, } from '@sim/emcn' @@ -24,7 +26,16 @@ import { useInboxSenders, useRemoveInboxSender, useUpdateInboxAddress, + useUpdateInboxSecretPolicy, } from '@/hooks/queries/inbox' +import { useRawMountableSecretOptions } from '@/hooks/queries/secret-mount-options' + +const SECRET_SCOPE_OPTIONS = [ + { value: 'all', label: 'All secrets' }, + { value: 'selected', label: 'Selected secrets' }, +] + +const DROPDOWN_TRIGGER_CLASS = 'w-[240px] flex-shrink-0' export function InboxSettingsTab() { const params = useParams() @@ -33,6 +44,7 @@ export function InboxSettingsTab() { const { data: config } = useInboxConfig(workspaceId) const { data: sendersData, isLoading: sendersLoading } = useInboxSenders(workspaceId) const updateAddress = useUpdateInboxAddress() + const updateSecretPolicy = useUpdateInboxSecretPolicy() const addSender = useAddInboxSender() const removeSender = useRemoveInboxSender() @@ -47,6 +59,11 @@ export function InboxSettingsTab() { const [removeSenderError, setRemoveSenderError] = useState(null) const { copied: copiedAddress, copy } = useCopyToClipboard() + const { options: secretOptions, isPending: secretOptionsPending } = + useRawMountableSecretOptions(workspaceId) + + const secretScope = config?.secretScope ?? 'all' + const mountedSecrets = config?.mountedSecrets ?? [] const handleCopyAddress = useCallback(() => { if (config?.address) void copy(config.address) @@ -228,6 +245,60 @@ export function InboxSettingsTab() { + + +
+
+ +
+ + updateSecretPolicy.mutate({ + workspaceId, + secretScope: value === 'selected' ? 'selected' : 'all', + mountedSecrets, + }) + } + options={SECRET_SCOPE_OPTIONS} + disabled={updateSecretPolicy.isPending} + /> +
+
+ + {secretScope === 'selected' && ( +
+ +
+ + updateSecretPolicy.mutate({ + workspaceId, + secretScope: 'selected', + mountedSecrets: values, + }) + } + disabled={secretOptionsPending || updateSecretPolicy.isPending} + /> +
+
+ )} +
+
0 ? { contexts: jobRecord.contexts } : {}), diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 74e81cb07d8..7bf9e17fe7f 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -1,4 +1,5 @@ import { Blimp } from '@sim/emcn' +import { fetchWorkspaceRawSecretNameOptions } from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' @@ -72,6 +73,31 @@ export const MothershipBlock: BlockConfig = { type: 'skill-input', defaultValue: [], }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + mode: 'advanced', + hideFromCopilot: true, + options: [ + { label: 'All secrets', id: 'all' }, + { label: 'Selected secrets', id: 'selected' }, + ], + value: () => 'all', + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + mode: 'advanced', + hideFromCopilot: true, + multiSelect: true, + searchable: true, + preserveLabelCase: true, + options: [], + condition: { field: 'secretScope', value: 'selected' }, + fetchOptions: () => fetchWorkspaceRawSecretNameOptions(), + }, ], tools: { access: [], @@ -91,6 +117,8 @@ export const MothershipBlock: BlockConfig = { }, tools: { type: 'json', description: 'MCP tools available to Sim for this request' }, skills: { type: 'json', description: 'Skills activated for this request' }, + secretScope: { type: 'string', description: 'Secret access mode: all or selected' }, + mountedSecrets: { type: 'json', description: 'Secret names available to Sim code execution' }, }, outputs: { content: { type: 'string', description: 'Generated response content' }, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 716625d7102..10b2917c3c1 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -337,6 +337,8 @@ export interface SubBlockConfig { connectionDroppable?: boolean hidden?: boolean hideFromPreview?: boolean // Hide this subblock from the workflow block preview + /** Excludes server-only lifecycle configuration from Copilot workflow state and schemas. */ + hideFromCopilot?: boolean hideDividerBefore?: boolean // Visually group this field with the preceding visible subblock showWhenEnvSet?: string // Show this subblock only when a named NEXT_PUBLIC_ env var is truthy; comma-separated means any of them hideWhenHosted?: boolean // Hide this subblock when running on hosted sim diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index e267183e144..d37e71b8887 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -440,6 +440,72 @@ describe('BlockExecutor', () => { expect(output).not.toEqual({ content: '' }) }) + it('keeps Sim Chat secret policy in runtime inputs and out of trace inputs', async () => { + const block = createBlock() + block.id = 'mothership-block-1' + block.metadata = { id: BlockType.MOTHERSHIP, name: 'Sim Chat' } + block.config = { + tool: BlockType.MOTHERSHIP, + params: { + prompt: 'Run the task', + secretScope: 'selected', + mountedSecrets: ['OPENAI_API_KEY'], + }, + } + block.privateInputIds = ['secretScope', 'mountedSecrets'] + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const handler: BlockHandler = { + canHandle: () => true, + execute: async (_ctx, _block, inputs) => { + expect(inputs).toMatchObject({ + prompt: 'Run the task', + secretScope: 'selected', + mountedSecrets: ['OPENAI_API_KEY'], + }) + return { content: 'done' } + }, + } + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + const ctx = createContext(state) + + await executor.execute(ctx, createNode(block), block) + + expect(ctx.blockLogs[0]?.input).toEqual({ prompt: 'Run the task' }) + const { traceSpans } = buildTraceSpans({ + success: true, + output: { content: 'done' }, + logs: ctx.blockLogs, + }) + expect(traceSpans[0]?.input).toEqual({ prompt: 'Run the task' }) + }) + it('projects a resolved secret out of Function syntax-error TraceSpans only', async () => { const secret = 'function-secret-literal-7f3a91' const block = createBlock() diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 105eb517863..b4f90fcc1c7 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -160,7 +160,7 @@ export class BlockExecutor { } if (blockLog) { - blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(inputsForLog, block) } } catch (error) { cleanupSelfReference?.() @@ -300,7 +300,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(inputsForLog, block.metadata?.id), + this.sanitizeInputsForLog(inputsForLog, block), displayOutput, duration, blockLog.startedAt, @@ -413,7 +413,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = true blockLog.error = undefined - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(input, block) blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) } @@ -428,7 +428,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, block), filterOutputForLog(block.metadata?.id || '', softOutput, { block }), duration, blockLog.startedAt, @@ -480,7 +480,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = false blockLog.error = errorMessage - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(input, block) blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) { @@ -507,7 +507,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, block), displayOutput, duration, blockLog.startedAt, @@ -631,8 +631,10 @@ export class BlockExecutor { */ private sanitizeInputsForLog( inputs: Record, - blockType?: string + block?: SerializedBlock ): Record { + const blockType = block?.metadata?.id + const privateInputIds = new Set(block?.privateInputIds ?? []) // Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the // baked `workflowId`/`inputMapping` wrapper is plumbing. Log the mapped input // field values (the inputMapping contents) instead. @@ -658,7 +660,8 @@ export class BlockExecutor { SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode' || key === FUNCTION_BLOCK_CONTEXT_VARS_KEY || - key === FUNCTION_BLOCK_DISPLAY_CODE_KEY + key === FUNCTION_BLOCK_DISPLAY_CODE_KEY || + privateInputIds.has(key) ) { continue } diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index c70776aba80..63d4750ba5f 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -377,6 +377,8 @@ describe('MothershipBlockHandler', () => { chatId: 'chat-uuid', messageId: 'message-uuid', requestId: 'request-uuid', + secretScope: 'all', + mountedSecrets: [], workflowId: 'workflow-1', executionId: 'execution-1', }) @@ -443,6 +445,8 @@ describe('MothershipBlockHandler', () => { chatId: 'existing-chat-id', messageId: 'message-uuid', requestId: 'request-uuid', + secretScope: 'all', + mountedSecrets: [], workflowId: 'workflow-1', executionId: 'execution-1', }) diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index b12dea62f66..cbc288f6e44 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -5,6 +5,7 @@ import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { env } from '@/lib/core/config/env' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' @@ -397,6 +398,10 @@ export class MothershipBlockHandler implements BlockHandler { const chatId = providedConversationId || generateId() const messageId = generateId() const requestId = generateId() + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: inputs.secretScope, + mountedSecrets: inputs.mountedSecrets, + }) const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId) const mcpTools = Array.isArray(inputs.tools) ? inputs.tools.filter( @@ -442,6 +447,8 @@ export class MothershipBlockHandler implements BlockHandler { chatId, messageId, requestId, + secretScope: secretMountPolicy.secretScope, + mountedSecrets: secretMountPolicy.mountedSecrets, ...(fileAttachments && { fileAttachments }), ...(mcpTools.length > 0 ? { mcpTools } : {}), ...(skillContexts.length > 0 ? { contexts: skillContexts } : {}), diff --git a/apps/sim/executor/utils/code-secret-references.test.ts b/apps/sim/executor/utils/code-secret-references.test.ts new file mode 100644 index 00000000000..4ebf81a0ebe --- /dev/null +++ b/apps/sim/executor/utils/code-secret-references.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' + +describe('Copilot code secret declarations', () => { + it.each(['javascript', 'python'])( + 'matches trimmed and embedded references for %s', + (language) => { + expect( + extractCodeSecretNames( + 'const first = "prefix-{{ API_KEY }}"\nreturn "{{TOKEN}}/{{API_KEY}}"', + language + ) + ).toEqual(['API_KEY', 'TOKEN']) + } + ) + + it('matches only runtime-valid shell identifiers without trimming', () => { + expect( + extractCodeSecretNames( + 'echo {{API_KEY}} {{ API_KEY }} {{9INVALID}} {{WITH-DASH}} {{_TOKEN}}', + 'shell' + ) + ).toEqual(['API_KEY', '_TOKEN']) + }) + + it('ignores direct environment access, shell variables, literals, and malformed references', () => { + expect( + extractCodeSecretNames( + 'return environmentVariables.API_KEY + "$TOKEN" + "literal" + "{{}}" + "{{MISSING"', + 'javascript' + ) + ).toEqual([]) + }) +}) diff --git a/apps/sim/executor/utils/code-secret-references.ts b/apps/sim/executor/utils/code-secret-references.ts new file mode 100644 index 00000000000..3e3bfc19edc --- /dev/null +++ b/apps/sim/executor/utils/code-secret-references.ts @@ -0,0 +1,38 @@ +import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +function resolveCodeLanguage(language: unknown): CodeLanguage { + return typeof language === 'string' && isValidCodeLanguage(language) + ? language + : DEFAULT_CODE_LANGUAGE +} + +export function createCodeEnvVarPattern(language?: unknown): RegExp { + return resolveCodeLanguage(language) === CodeLanguage.Shell + ? /\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g + : createEnvVarPattern() +} + +/** + * Extracts only environment references the Function runtime can resolve for the selected language. + * The returned order follows the code, with duplicate names removed after their first occurrence. + */ +export function extractCodeSecretNames(code: unknown, language?: unknown): string[] { + if (typeof code !== 'string') return [] + + const resolvedLanguage = resolveCodeLanguage(language) + const pattern = createCodeEnvVarPattern(resolvedLanguage) + const names: string[] = [] + const seen = new Set() + let match: RegExpExecArray | null + + while ((match = pattern.exec(code)) !== null) { + const name = resolvedLanguage === CodeLanguage.Shell ? match[1] : match[1].trim() + if (name.length > 0 && !seen.has(name)) { + seen.add(name) + names.push(name) + } + } + + return names +} diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts new file mode 100644 index 00000000000..bb04d6e98f2 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts @@ -0,0 +1,427 @@ +import { isPlainRecord } from '@sim/utils/object' +import { LARGE_ARRAY_MANIFEST_MARKER } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/materialization.server' +import type { ResolvedSecretTraceMatch } from '@/executor/utils/resolved-secret-trace-registry' + +const MAX_CONTENT_NODES = 100_000 +const MAX_CONTENT_DEPTH = 100 +const MAX_MATCHER_NODES = 250_000 +const MAX_SECRET_LITERAL_LENGTH = 64 * 1024 +const MAX_MATCH_EVENTS = 1_000_000 + +interface SecretReplacement { + plaintext: string + replacement: string +} + +interface SecretTrieNode { + children: Map + failure?: SecretTrieNode + outputLink?: SecretTrieNode + replacement?: SecretReplacement +} + +export interface ResolvedSecretMatcher { + root: SecretTrieNode + maxPatternLength: number +} + +interface ProjectionState { + nodes: number + ancestors: WeakSet + outputBytes: number + maxBytes: number +} + +export interface ResolvedSecretContentProjectionOptions { + /** Values already materialized and verified by a boundary-specific projector. */ + isOpaqueSafeObject?: (value: object) => boolean +} + +export type ResolvedSecretContentProjection = { safe: true; value: unknown } | { safe: false } + +class ResolvedSecretContentProjectionError extends Error { + constructor(message: string) { + super(message) + this.name = 'ResolvedSecretContentProjectionError' + } +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function createMatcherFromReplacements( + replacements: readonly SecretReplacement[] +): ResolvedSecretMatcher { + const root: SecretTrieNode = { children: new Map() } + root.failure = root + let nodeCount = 1 + let maxPatternLength = 0 + + for (const replacement of replacements) { + if (replacement.plaintext.length > MAX_SECRET_LITERAL_LENGTH) { + throw new ResolvedSecretContentProjectionError( + 'Secret literal exceeds the matcher size limit' + ) + } + maxPatternLength = Math.max(maxPatternLength, replacement.plaintext.length) + let node = root + for (let index = 0; index < replacement.plaintext.length; index += 1) { + const character = replacement.plaintext[index] + let child = node.children.get(character) + if (!child) { + child = { children: new Map() } + node.children.set(character, child) + nodeCount += 1 + if (nodeCount > MAX_MATCHER_NODES) { + throw new ResolvedSecretContentProjectionError('Secret matcher node limit exceeded') + } + } + node = child + } + node.replacement = replacement + } + + const queue: SecretTrieNode[] = [] + for (const child of root.children.values()) { + child.failure = root + queue.push(child) + } + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const node = queue[cursor] + for (const [character, child] of node.children) { + let fallback = node.failure ?? root + while (fallback !== root && !fallback.children.has(character)) { + fallback = fallback.failure ?? root + } + const transition = fallback.children.get(character) + child.failure = transition && transition !== child ? transition : root + child.outputLink = child.failure.replacement ? child.failure : child.failure.outputLink + queue.push(child) + } + } + + return { root, maxPatternLength } +} + +function advanceMatcher( + matcher: ResolvedSecretMatcher, + node: SecretTrieNode, + character: string +): SecretTrieNode { + let current = node + while (current !== matcher.root && !current.children.has(character)) { + current = current.failure ?? matcher.root + } + return current.children.get(character) ?? matcher.root +} + +export function containsResolvedSecret(value: string, matcher: ResolvedSecretMatcher): boolean { + let node = matcher.root + for (let index = 0; index < value.length; index += 1) { + node = advanceMatcher(matcher, node, value[index]) + if (node.replacement || node.outputLink) return true + } + return false +} + +export function sanitizeResolvedSecretString( + value: string, + matcher: ResolvedSecretMatcher, + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES +): string { + if (maxBytes < 0) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized secret-bearing string exceeds the size limit' + ) + } + if (Buffer.byteLength(value, 'utf8') > maxBytes) { + throw new ResolvedSecretContentProjectionError('Secret-bearing string exceeds the size limit') + } + if (matcher.maxPatternLength === 0 || value.length === 0) return value + + let emitCursor = 0 + let literalStart = 0 + let outputBytes = 0 + let matchEvents = 0 + const chunks: string[] = [] + const windowSize = matcher.maxPatternLength + const slotStarts = new Int32Array(windowSize) + const slotEnds = new Int32Array(windowSize) + slotStarts.fill(-1) + const slotReplacements = new Array(windowSize) + + const append = (chunk: string): void => { + if (!chunk) return + outputBytes += Buffer.byteLength(chunk, 'utf8') + if (outputBytes > maxBytes) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized secret-bearing string exceeds the size limit' + ) + } + const lastIndex = chunks.length - 1 + if (lastIndex >= 0 && chunks[lastIndex].length + chunk.length <= 64 * 1024) { + chunks[lastIndex] += chunk + } else { + chunks.push(chunk) + } + } + + const finalizeThrough = (limit: number): void => { + while (emitCursor <= limit && emitCursor < value.length) { + const slot = emitCursor % windowSize + if (slotStarts[slot] === emitCursor && slotReplacements[slot] !== undefined) { + append(value.slice(literalStart, emitCursor)) + append(slotReplacements[slot] ?? '') + emitCursor = slotEnds[slot] + literalStart = emitCursor + } else { + emitCursor += 1 + } + } + } + + let node = matcher.root + for (let index = 0; index < value.length; index += 1) { + node = advanceMatcher(matcher, node, value[index]) + let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink + while (outputNode?.replacement) { + matchEvents += 1 + if (matchEvents > MAX_MATCH_EVENTS) { + throw new ResolvedSecretContentProjectionError('Secret matcher event limit exceeded') + } + const start = index - outputNode.replacement.plaintext.length + 1 + if (start >= emitCursor) { + const slot = start % windowSize + const end = index + 1 + if (slotStarts[slot] !== start || end > slotEnds[slot]) { + slotStarts[slot] = start + slotEnds[slot] = end + slotReplacements[slot] = outputNode.replacement.replacement + } + } + outputNode = outputNode.outputLink + } + finalizeThrough(index - matcher.maxPatternLength + 1) + } + + finalizeThrough(value.length - 1) + append(value.slice(literalStart)) + const sanitized = chunks.join('') + if (containsResolvedSecret(sanitized, matcher)) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized content still contains an active secret' + ) + } + return sanitized +} + +export function createResolvedSecretMatcher( + matches: readonly ResolvedSecretTraceMatch[] +): ResolvedSecretMatcher | undefined { + const replacementByPlaintext = new Map() + + for (const match of matches) { + if (!match.plaintext) continue + const current = replacementByPlaintext.get(match.plaintext) + if (current === undefined || compareStrings(match.replacement, current) < 0) { + replacementByPlaintext.set(match.plaintext, match.replacement) + } + } + + const provisional = [...replacementByPlaintext.keys()] + .map((plaintext) => ({ + plaintext, + replacement: replacementByPlaintext.get(plaintext) ?? '', + })) + .sort( + (left, right) => + right.plaintext.length - left.plaintext.length || + compareStrings(left.replacement, right.replacement) || + compareStrings(left.plaintext, right.plaintext) + ) + + if (provisional.length === 0) return undefined + + const detector = createMatcherFromReplacements( + provisional.map(({ plaintext }) => ({ plaintext, replacement: '' })) + ) + return createMatcherFromReplacements( + provisional.map(({ plaintext, replacement }) => ({ + plaintext, + replacement: containsResolvedSecret(replacement, detector) ? '' : replacement, + })) + ) +} + +function visitNode(state: ProjectionState, depth: number): void { + state.nodes += 1 + if (state.nodes > MAX_CONTENT_NODES) { + throw new ResolvedSecretContentProjectionError('Secret-bearing content exceeds node limit') + } + if (depth > MAX_CONTENT_DEPTH) { + throw new ResolvedSecretContentProjectionError('Secret-bearing content exceeds depth limit') + } +} + +function* enumerableDataEntries(value: object): Generator<[string, unknown]> { + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') { + throw new ResolvedSecretContentProjectionError('Content cannot contain symbol properties') + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new ResolvedSecretContentProjectionError('Content accessors are not supported') + } + yield [key, descriptor.value] + } +} + +function* arrayDataEntries(value: readonly unknown[]): Generator<[number, unknown]> { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length') + if ( + !lengthDescriptor || + !('value' in lengthDescriptor) || + typeof lengthDescriptor.value !== 'number' || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 + ) { + throw new ResolvedSecretContentProjectionError('Content array length is invalid') + } + + for (const key of Reflect.ownKeys(value)) { + if (key === 'length') continue + if (typeof key !== 'string') { + throw new ResolvedSecretContentProjectionError('Content arrays cannot contain symbols') + } + const index = Number(key) + if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) { + throw new ResolvedSecretContentProjectionError('Content array has custom properties') + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new ResolvedSecretContentProjectionError('Content array accessors are unsupported') + } + yield [index, descriptor.value] + } +} + +function sanitizeContent( + value: unknown, + matcher: ResolvedSecretMatcher, + state: ProjectionState, + options: ResolvedSecretContentProjectionOptions, + depth = 0 +): unknown { + visitNode(state, depth) + if (typeof value === 'string') { + const sanitized = sanitizeResolvedSecretString( + value, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitized, 'utf8') + return sanitized + } + if (value === null || typeof value === 'number' || typeof value === 'boolean') { + const rendered = String(value) + if (!containsResolvedSecret(rendered, matcher)) return value + const sanitized = sanitizeResolvedSecretString( + rendered, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitized, 'utf8') + return sanitized + } + if (value === undefined) return value + if (typeof value !== 'object') { + throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') + } + if (options.isOpaqueSafeObject?.(value)) return value + if (!Array.isArray(value) && !isPlainRecord(value)) { + throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') + } + if ( + !Array.isArray(value) && + (Object.hasOwn(value, LARGE_VALUE_REF_MARKER) || + Object.hasOwn(value, LARGE_ARRAY_MANIFEST_MARKER)) + ) { + throw new ResolvedSecretContentProjectionError( + 'Offloaded secret-bearing content cannot cross this boundary' + ) + } + if (state.ancestors.has(value)) { + throw new ResolvedSecretContentProjectionError('Cyclic secret-bearing content is unsupported') + } + + state.ancestors.add(value) + try { + if (Array.isArray(value)) { + if (value.length > MAX_CONTENT_NODES - state.nodes) { + throw new ResolvedSecretContentProjectionError('Content array exceeds traversal limit') + } + const sanitized = new Array(value.length) + for (const [index, item] of arrayDataEntries(value)) { + sanitized[index] = sanitizeContent(item, matcher, state, options, depth + 1) + } + return sanitized + } + + const sanitized = Object.create(Object.getPrototypeOf(value)) as Record + const sanitizedKeys = new Set() + for (const [key, item] of enumerableDataEntries(value)) { + const sanitizedKey = sanitizeResolvedSecretString( + key, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') + if (sanitizedKeys.has(sanitizedKey)) { + throw new ResolvedSecretContentProjectionError( + 'Secret replacement caused an object-key collision' + ) + } + sanitizedKeys.add(sanitizedKey) + Object.defineProperty(sanitized, sanitizedKey, { + value: sanitizeContent(item, matcher, state, options, depth + 1), + enumerable: true, + configurable: true, + writable: true, + }) + } + return sanitized + } finally { + state.ancestors.delete(value) + } +} + +export function projectResolvedSecretContent( + value: unknown, + matcher: ResolvedSecretMatcher, + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES, + options: ResolvedSecretContentProjectionOptions = {} +): ResolvedSecretContentProjection { + try { + return { + safe: true, + value: sanitizeContent( + value, + matcher, + { + nodes: 0, + ancestors: new WeakSet(), + outputBytes: 0, + maxBytes, + }, + options + ), + } + } catch { + return { safe: false } + } +} diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index 6b435afff6d..1498c98f45a 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -164,6 +164,34 @@ describe('ResolvedSecretTraceRegistry', () => { ]) }) + it('fails closed while one or more secret activations are pending', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' }, + ]) + const completeFirst = registry.beginPendingActivation() + const completeSecond = registry.beginPendingActivation() + + expect(registry.isComplete()).toBe(false) + expect(registry.exportProvenance()).toEqual({ + version: 1, + complete: false, + entries: [], + }) + + registry.recordResolved('API_KEY', 'secret-value') + completeFirst() + expect(registry.isComplete()).toBe(false) + + completeSecond() + completeSecond() + expect(registry.isComplete()).toBe(true) + expect(registry.exportProvenance()).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-value' }], + }) + }) + it('uses the workspace catalog entry when personal and workspace names conflict', async () => { const registry = await createResolvedSecretTraceRegistry({ personalEncrypted: { SHARED: 'personal-encrypted' }, diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 632a1a56872..f96694e76ec 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -415,6 +415,7 @@ export class ResolvedSecretTraceRegistry { private readonly activeEntries = new Map() private activeProvenanceEntryBytes = 0 private complete = true + private pendingActivations = 0 private readonly scope?: ResolvedSecretTraceScopeV1 private readonly completeProvenanceEnvelopeBytes: number @@ -540,22 +541,40 @@ export class ResolvedSecretTraceRegistry { } isComplete(): boolean { - return this.complete + return this.complete && this.pendingActivations === 0 + } + + isPermanentlyIncomplete(): boolean { + return !this.complete } markIncomplete(): void { this.complete = false } + /** + * Makes projections fail closed while an exact runtime substitution is being established. + * The returned completion callback is idempotent so every exit path can safely release it. + */ + beginPendingActivation(): () => void { + this.pendingActivations += 1 + let completed = false + + return () => { + if (completed) return + completed = true + this.pendingActivations = Math.max(0, this.pendingActivations - 1) + } + } + /** Serializes only encrypted active values; plaintext never enters execution state. */ exportProvenance(): ResolvedSecretTraceProvenanceV1 { - const entries = this.complete - ? this.buildProvenanceEntries([...this.activeEntries.values()]) - : [] + const complete = this.isComplete() + const entries = complete ? this.buildProvenanceEntries([...this.activeEntries.values()]) : [] return { version: 1, - complete: this.complete, + complete, entries, ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}), } @@ -569,7 +588,7 @@ export class ResolvedSecretTraceRegistry { value: unknown, options: ExportResolvedSecretTraceProvenanceForValueOptions = {} ): ResolvedSecretTraceProvenanceV1 { - if (!this.complete) return { version: 1, complete: false, entries: [] } + if (!this.isComplete()) return { version: 1, complete: false, entries: [] } const candidatesByPlaintext = new Map() const sortedActiveEntries = [...this.activeEntries.values()].sort( diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 25daaf06179..a284acaeb12 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -21,7 +21,10 @@ import { } from '@/lib/api/contracts' import { environmentKeys } from '@/hooks/queries/environment' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' -import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' /** * Key prefix for OAuth credential queries. @@ -29,7 +32,6 @@ import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-worksp */ const OAUTH_CREDENTIALS_KEY = ['oauthCredentials'] as const -export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 export const WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000 export const WORKSPACE_CREDENTIAL_MEMBER_LIST_STALE_TIME = 30 * 1000 diff --git a/apps/sim/hooks/queries/inbox.ts b/apps/sim/hooks/queries/inbox.ts index 9577ddeee6a..6b2a7d4f1f8 100644 --- a/apps/sim/hooks/queries/inbox.ts +++ b/apps/sim/hooks/queries/inbox.ts @@ -12,6 +12,7 @@ import { listInboxSendersContract, listInboxTasksContract, removeInboxSenderContract, + type SecretMountPolicyInput, updateInboxConfigContract, } from '@/lib/api/contracts' @@ -140,6 +141,37 @@ export function useUpdateInboxAddress() { }) } +export function useUpdateInboxSecretPolicy() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + workspaceId, + ...policy + }: { workspaceId: string } & Required) => { + return requestJson(updateInboxConfigContract, { + params: { id: workspaceId }, + body: policy, + }) + }, + onMutate: async ({ workspaceId, ...policy }) => { + const queryKey = inboxKeys.config(workspaceId) + await queryClient.cancelQueries({ queryKey }) + const previous = queryClient.getQueryData(queryKey) + if (previous) queryClient.setQueryData(queryKey, { ...previous, ...policy }) + return { previous } + }, + onError: (_error, variables, context) => { + if (context?.previous) { + queryClient.setQueryData(inboxKeys.config(variables.workspaceId), context.previous) + } + }, + onSettled: (_data, _error, variables) => { + return queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) }) + }, + }) +} + export function useAddInboxSender() { const queryClient = useQueryClient() diff --git a/apps/sim/hooks/queries/secret-mount-options.ts b/apps/sim/hooks/queries/secret-mount-options.ts new file mode 100644 index 00000000000..6e678c470d7 --- /dev/null +++ b/apps/sim/hooks/queries/secret-mount-options.ts @@ -0,0 +1,19 @@ +'use client' + +import { useMemo } from 'react' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +export function useRawMountableSecretOptions(workspaceId?: string) { + const query = useWorkspaceCredentials({ workspaceId }) + const options = useMemo( + () => + selectRawMountableSecretNames(query.data ?? []).map((name) => ({ + value: name, + label: name, + })), + [query.data] + ) + + return { options, isPending: query.isPending } +} diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index 9fd8efd7b6f..bf1dccfe9d3 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,6 +1,8 @@ import { requestJson } from '@/lib/api/client/request' import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' +export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 + /** * Fetches the workspace credential list. * diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index e7136c78dc7..8da3f338c30 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -47,6 +47,7 @@ export const copilotCredentialsQuerySchema = z.object({}) export const copilotConfirmBodySchema = z.object({ toolCallId: z.string().min(1, 'Tool call ID is required'), + executionId: z.string().min(1, 'Execution ID is required').max(255).optional(), status: z.enum( Object.values(ASYNC_TOOL_CONFIRMATION_STATUS) as [ AsyncConfirmationStatus, diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index eaa75f8f430..75acb8f86de 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { customPatternSchema, unknownRecordSchema } from '@/lib/api/contracts/primitives' +import { + customPatternSchema, + stringRecordSchema, + unknownRecordSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' export const guardrailsValidateContract = defineRouteContract({ @@ -175,9 +179,9 @@ export const functionExecuteContract = defineRouteContract({ }) .strict() .optional(), - envVars: z.record(z.string(), z.string()).optional().default({}), + envVars: stringRecordSchema.optional().default({}), blockData: unknownRecordSchema.optional().default({}), - blockNameMapping: z.record(z.string(), z.string()).optional().default({}), + blockNameMapping: stringRecordSchema.optional().default({}), blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}), workflowVariables: unknownRecordSchema.optional().default({}), contextVariables: unknownRecordSchema.optional().default({}), diff --git a/apps/sim/lib/api/contracts/inbox.ts b/apps/sim/lib/api/contracts/inbox.ts index 34152f3346b..a3e8387c866 100644 --- a/apps/sim/lib/api/contracts/inbox.ts +++ b/apps/sim/lib/api/contracts/inbox.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' export const inboxWorkspaceParamsSchema = z.object({ @@ -17,6 +21,8 @@ export const inboxTaskStatusSchema = z.enum([ export const inboxConfigSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, entitled: z.boolean(), taskStats: z.object({ total: z.number(), @@ -32,12 +38,16 @@ export type InboxTaskStatus = z.output export const updateInboxConfigBodySchema = z.object({ enabled: z.boolean().optional(), username: z.string().min(1).max(64).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) export const updateInboxConfigResponseSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), providerId: z.string().nullable().optional(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, }) export const inboxSenderSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 2001b85b8c2..10ad693347c 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -22,6 +22,7 @@ export * from './permission-groups' export * from './pinned-items' export * from './primitives' export * from './sandboxes' +export * from './secret-mount-policy' export * from './selectors' export * from './skills' export * from './storage-transfer' diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 70a297a486d..275f3c80bf5 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -1,5 +1,9 @@ import { z } from 'zod' import { scheduleContextSchema } from '@/lib/api/contracts/schedules' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' const dateStringSchema = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { @@ -119,6 +123,8 @@ export const mothershipExecuteBodySchema = z.object({ mcpTools: z.array(mothershipExecuteMcpToolSchema).optional(), workflowId: z.string().optional(), executionId: z.string().optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), userMetadata: z .object({ name: z.string().optional(), diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index a0bfff57299..f899a27174f 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -1,9 +1,25 @@ +import { isPlainRecord } from '@sim/utils/object' import { z } from 'zod' +import { setRecordValue } from '@/lib/core/utils/records' import { PII_LANGUAGE_CODES, stripNerEntities } from '@/lib/guardrails/pii-entities' import { validateRegexPattern } from '@/lib/guardrails/validate_regex' export const unknownRecordSchema = z.record(z.string(), z.unknown()) +export const stringRecordSchema = z + .custom>( + (value) => + isPlainRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'), + { error: 'Expected a record of string values' } + ) + .transform((value) => { + const record: Record = {} + for (const [key, entry] of Object.entries(value)) { + setRecordValue(record, key, entry) + } + return record + }) + export function flattenFieldErrors( error: z.ZodError ): Partial> { diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts index d4eaf3d5e11..3a707e59232 100644 --- a/apps/sim/lib/api/contracts/schedules.ts +++ b/apps/sim/lib/api/contracts/schedules.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' export const scheduleStatusSchema = z.enum(['active', 'disabled', 'completed']) @@ -74,6 +78,8 @@ export const workflowScheduleRowSchema = z.object({ sourceTaskName: z.string().nullable(), sourceUserId: z.string().nullable(), sourceWorkspaceId: z.string().nullable(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, jobHistory: z.array(z.object({ timestamp: z.string(), summary: z.string() })).nullable(), contexts: z.array(scheduleContextSchema).nullable(), excludedDates: z.array(z.string()).nullable(), @@ -113,6 +119,8 @@ export const createScheduleBodySchema = z endsAt: z.string().optional(), startDate: z.string().optional(), contexts: z.array(scheduleContextSchema).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) .superRefine((body, ctx) => { if (!body.cronExpression && !body.time) { @@ -150,6 +158,8 @@ export const updateScheduleBodySchema = z.object({ maxRuns: z.number().int().positive().nullable().optional(), endsAt: z.string().nullable().optional(), contexts: z.array(scheduleContextSchema).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) export type UpdateScheduleBody = z.input diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.test.ts b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts new file mode 100644 index 00000000000..f1898448ebc --- /dev/null +++ b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { mountedSecretNamesSchema } from '@/lib/api/contracts/secret-mount-policy' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' + +describe('mountedSecretNamesSchema', () => { + it('accepts the bounded names-only policy shape', () => { + expect(mountedSecretNamesSchema.parse([' API_KEY ', 'name-with-dashes'])).toEqual([ + 'API_KEY', + 'name-with-dashes', + ]) + }) + + it('rejects too many names', () => { + expect(() => + mountedSecretNamesSchema.parse( + Array.from({ length: MAX_SECRET_MOUNT_NAMES + 1 }, (_, index) => `SECRET_${index}`) + ) + ).toThrow() + }) + + it('rejects an overlong name without narrowing the runtime name grammar', () => { + expect(() => + mountedSecretNamesSchema.parse(['S'.repeat(MAX_SECRET_MOUNT_NAME_LENGTH + 1)]) + ).toThrow() + }) +}) diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.ts b/apps/sim/lib/api/contracts/secret-mount-policy.ts new file mode 100644 index 00000000000..d3306ef7ea9 --- /dev/null +++ b/apps/sim/lib/api/contracts/secret-mount-policy.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' + +export const secretMountScopeSchema = z.enum(['all', 'selected']) + +export const mountedSecretNameSchema = z.string().trim().min(1).max(MAX_SECRET_MOUNT_NAME_LENGTH) + +export const mountedSecretNamesSchema = z.array(mountedSecretNameSchema).max(MAX_SECRET_MOUNT_NAMES) + +export const secretMountPolicySchema = z.object({ + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, +}) + +export const secretMountPolicyInputSchema = secretMountPolicySchema.partial() + +export type SecretMountPolicyInput = z.input +export type SecretMountPolicyOutput = z.output diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index c254daf74ff..89a926caf38 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -369,6 +369,7 @@ export const executeWorkflowBodySchema = z.object({ /** Internal MCP bridge pin for calls admitted before a deployment cutover. */ deploymentVersionId: z.string().min(1).optional(), executionId: z.unknown().optional(), + copilotToolCallId: z.string().min(1).max(255).optional(), triggerBlockId: z.string().optional(), startBlockId: z.string().optional(), stopAfterBlockId: z.string().optional(), diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts b/apps/sim/lib/copilot/async-runs/lifecycle.test.ts index 8cd4fd872e7..ecf31930c50 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.test.ts @@ -10,6 +10,7 @@ import { isAsyncTerminalConfirmationStatus, isDeliveredAsyncStatus, isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from './lifecycle' describe('async tool lifecycle helpers', () => { @@ -26,6 +27,17 @@ describe('async tool lifecycle helpers', () => { expect(isDeliveredAsyncStatus(ASYNC_TOOL_STATUS.delivered)).toBe(true) }) + it('claims only dispatched or explicitly approved workflow calls', () => { + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.running, null)).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.delivered, null)).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'allow')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'allow_chat')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'always_allow')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'skip')).toBe(false) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, null)).toBe(false) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.completed, 'allow')).toBe(false) + }) + it('distinguishes background from terminal completion statuses', () => { expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.background)).toBe(true) expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.success)).toBe(false) diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.ts b/apps/sim/lib/copilot/async-runs/lifecycle.ts index e54b2f1900a..d86ae06442a 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.ts @@ -1,4 +1,4 @@ -import type { CopilotAsyncToolStatus } from '@sim/db/schema' +import type { CopilotAsyncToolStatus, CopilotToolPermissionDecision } from '@sim/db/schema' import { MothershipStreamV1AsyncToolRecordStatus, MothershipStreamV1ToolOutcome, @@ -6,6 +6,12 @@ import { export const ASYNC_TOOL_STATUS = MothershipStreamV1AsyncToolRecordStatus +export const EXECUTABLE_TOOL_PERMISSION_DECISIONS = [ + 'allow', + 'allow_chat', + 'always_allow', +] as const satisfies readonly CopilotToolPermissionDecision[] + export type AsyncLifecycleStatus = | typeof ASYNC_TOOL_STATUS.pending | typeof ASYNC_TOOL_STATUS.running @@ -81,6 +87,23 @@ export interface AsyncCompletionSignal { data?: AsyncCompletionData } +export function isExecutableToolPermissionDecision( + decision: CopilotToolPermissionDecision | null | undefined +): boolean { + return decision !== null && decision !== undefined && decision !== 'skip' +} + +export function isWorkflowToolExecutionClaimable( + status: CopilotAsyncToolStatus, + permissionDecision: CopilotToolPermissionDecision | null | undefined +): boolean { + return ( + status === ASYNC_TOOL_STATUS.running || + status === ASYNC_TOOL_STATUS.delivered || + (status === ASYNC_TOOL_STATUS.pending && isExecutableToolPermissionDecision(permissionDecision)) + ) +} + export function isTerminalAsyncStatus( status: CopilotAsyncToolStatus | AsyncLifecycleStatus | string | null | undefined ): status is AsyncTerminalStatus { diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index 50e36eaecd1..fcd9c01a4e7 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -7,8 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { claimCompletedAsyncToolCall, claimPendingAsyncToolCall, + claimWorkflowToolExecution, completeAsyncToolCall, - markAsyncToolDelivered, + detachAsyncToolCall, + getClaimedWorkflowExecutionId, + recordToolPermissionDecision, + releaseWorkflowToolExecutionClaim, + replaceTerminalAsyncToolCallResult, + upsertAsyncToolCall, } from './repository' describe('async tool repository single-row semantics', () => { @@ -17,27 +23,48 @@ describe('async tool repository single-row semantics', () => { resetDbChainMock() }) - it('does not overwrite a delivered row on late completion', async () => { - const deliveredRow = { + it('atomically completes a live row', async () => { + const completedRow = { toolCallId: 'tool-1', - status: 'delivered', + status: 'completed', result: { ok: true }, error: null, } - dbChainMockFns.limit.mockResolvedValueOnce([deliveredRow]) + dbChainMockFns.returning.mockResolvedValueOnce([completedRow]) const result = await completeAsyncToolCall({ toolCallId: 'tool-1', status: 'completed', - result: { ok: false }, + result: { ok: true }, error: null, }) - expect(result).toEqual(deliveredRow) - expect(dbChainMockFns.returning).not.toHaveBeenCalled() + expect(result).toEqual(completedRow) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + result: { ok: true }, + completedAt: expect.any(Date), + }) + ) + expect(dbChainMockFns.where).toHaveBeenCalled() }) - it('marks a row delivered and clears the claim fields', async () => { + it('returns null when another terminal transition already won', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await completeAsyncToolCall({ + toolCallId: 'tool-1', + status: 'failed', + result: null, + error: 'late error', + }) + + expect(result).toBeNull() + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('atomically detaches a live background call and clears the claim fields', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { toolCallId: 'tool-1', @@ -45,7 +72,7 @@ describe('async tool repository single-row semantics', () => { }, ]) - await markAsyncToolDelivered('tool-1') + await detachAsyncToolCall('tool-1') expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ @@ -54,6 +81,7 @@ describe('async tool repository single-row semantics', () => { claimedAt: null, }) ) + expect(dbChainMockFns.where).toHaveBeenCalled() }) it('claims only completed rows for delivery handoff', async () => { @@ -103,4 +131,149 @@ describe('async tool repository single-row semantics', () => { }) ) }) + + it('atomically binds an eligible workflow tool to one execution', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'running', + claimedBy: 'workflow:execution-1', + }, + ]) + + const result = await claimWorkflowToolExecution('workflow-tool', 'execution-1') + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + claimedBy: 'workflow:execution-1', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: expect.anything(), + claimedBy: 'workflow:execution-1', + claimedAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + expect(getClaimedWorkflowExecutionId(result?.claimedBy)).toBe('execution-1') + }) + + it('returns null when a workflow tool execution claim loses the race', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull() + }) + + it('releases a matching pre-start workflow claim without changing its lifecycle status', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: null, + }, + ]) + + const result = await releaseWorkflowToolExecutionClaim('workflow-tool', 'execution-1') + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: null, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + claimedBy: null, + claimedAt: null, + updatedAt: expect.any(Date), + }) + }) + + it('detaches a bound workflow waiter without releasing its execution claim', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: 'workflow:execution-1', + }, + ]) + + await detachAsyncToolCall('workflow-tool', { preserveClaim: true }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'delivered', + claimedBy: undefined, + claimedAt: undefined, + }) + ) + }) + + it('records an approved workflow decision without changing execution state', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'pending', + permissionDecision: 'allow', + }, + ]) + + await recordToolPermissionDecision('workflow-tool', 'allow') + + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + permissionDecision: 'allow', + permissionDecidedAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + }) + + it('replaces only terminal payload fields after trusted projection', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'completed', + result: { output: '{{SECRET}}' }, + }, + ]) + + const result = await replaceTerminalAsyncToolCallResult({ + toolCallId: 'workflow-tool', + status: 'completed', + result: { output: '{{SECRET}}' }, + error: null, + }) + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + status: 'completed', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: 'completed', + result: { output: '{{SECRET}}' }, + error: null, + updatedAt: expect.any(Date), + }) + expect(dbChainMockFns.where).toHaveBeenCalled() + }) + + it.each(['pending', 'running'] as const)( + 'keeps the first finalized call identity immutable after it reaches %s', + async (status) => { + const existingRow = { + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' }, + status, + } + dbChainMockFns.limit.mockResolvedValueOnce([existingRow]) + + const result = await upsertAsyncToolCall({ + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' }, + status: 'pending', + }) + + expect(result).toEqual(existingRow) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 257bfdaec2d..3497be4e987 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -11,18 +11,19 @@ import { import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' import { sanitizeValueForJsonb } from '@sim/utils/string' -import { and, desc, eq, inArray, isNull } from 'drizzle-orm' +import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { markSpanForError } from '@/lib/copilot/request/otel' import { ASYNC_TOOL_STATUS, type AsyncCompletionData, - isDeliveredAsyncStatus, - isTerminalAsyncStatus, + type AsyncTerminalStatus, + EXECUTABLE_TOOL_PERMISSION_DECISIONS, } from './lifecycle' const logger = createLogger('CopilotAsyncRunsRepo') +const WORKFLOW_EXECUTION_CLAIM_PREFIX = 'workflow:' // Resolve the tracer lazily per-call to avoid capturing the NoOp tracer // before NodeSDK installs the global TracerProvider (Next.js 16/Turbopack // can evaluate modules before instrumentation-node.ts finishes). @@ -193,6 +194,7 @@ export async function getRunSegment(runId: string) { id: copilotRuns.id, userId: copilotRuns.userId, status: copilotRuns.status, + workflowId: copilotRuns.workflowId, // Needed to scope an "allow for this chat" decision to its chat. chatId: copilotRuns.chatId, }) @@ -243,6 +245,7 @@ export async function upsertAsyncToolCall(input: { toolName: string args?: Record status?: CopilotAsyncToolStatus + sealedContext?: AsyncCompletionData }) { return withDbSpan( TraceSpan.CopilotAsyncRunsUpsertAsyncToolCall, @@ -256,21 +259,10 @@ export async function upsertAsyncToolCall(input: { }, async () => { const existing = await getAsyncToolCall(input.toolCallId) + if (existing) return existing + const incomingStatus = input.status ?? 'pending' - if ( - existing && - (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) && - !isTerminalAsyncStatus(incomingStatus) && - !isDeliveredAsyncStatus(incomingStatus) - ) { - logger.info('Ignoring async tool upsert that would downgrade terminal state', { - toolCallId: input.toolCallId, - existingStatus: existing.status, - incomingStatus, - }) - return existing - } - const effectiveRunId = input.runId ?? existing?.runId ?? null + const effectiveRunId = input.runId ?? null if (!effectiveRunId) { logger.warn('upsertAsyncToolCall missing runId and no existing row', { toolCallId: input.toolCallId, @@ -282,6 +274,7 @@ export async function upsertAsyncToolCall(input: { const now = new Date() const args = sanitizeValueForJsonb(input.args ?? {}) + const sealedContext = sanitizeValueForJsonb(input.sealedContext) const [row] = await db .insert(copilotAsyncToolCalls) .values({ @@ -291,22 +284,13 @@ export async function upsertAsyncToolCall(input: { toolName: input.toolName, args, status: incomingStatus, + ...(sealedContext !== undefined ? { result: sealedContext } : {}), updatedAt: now, }) - .onConflictDoUpdate({ - target: copilotAsyncToolCalls.toolCallId, - set: { - runId: effectiveRunId, - checkpointId: input.checkpointId ?? null, - toolName: input.toolName, - args, - status: incomingStatus, - updatedAt: now, - }, - }) + .onConflictDoNothing() .returning() - return row + return row ?? getAsyncToolCall(input.toolCallId) } ) } @@ -337,7 +321,8 @@ async function markAsyncToolStatus( result?: AsyncCompletionData | null error?: string | null completedAt?: Date | null - } = {} + } = {}, + expectedStatuses?: CopilotAsyncToolStatus[] ) { return withDbSpan( TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, @@ -370,7 +355,14 @@ async function markAsyncToolStatus( completedAt: updates.completedAt, updatedAt: new Date(), }) - .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + .where( + expectedStatuses + ? and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + inArray(copilotAsyncToolCalls.status, expectedStatuses) + ) + : eq(copilotAsyncToolCalls.toolCallId, toolCallId) + ) .returning() return row ?? null @@ -382,6 +374,90 @@ export async function markAsyncToolRunning(toolCallId: string, claimedBy: string return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) } +export function getClaimedWorkflowExecutionId(claimedBy: string | null | undefined) { + if (!claimedBy?.startsWith(WORKFLOW_EXECUTION_CLAIM_PREFIX)) return undefined + const executionId = claimedBy.slice(WORKFLOW_EXECUTION_CLAIM_PREFIX.length) + return executionId.length > 0 ? executionId : undefined +} + +export async function claimWorkflowToolExecution(toolCallId: string, executionId: string) { + const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}` + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const now = new Date() + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: sql`CASE WHEN ${copilotAsyncToolCalls.status} = ${ASYNC_TOOL_STATUS.pending} THEN ${ASYNC_TOOL_STATUS.running} ELSE ${copilotAsyncToolCalls.status} END`, + claimedBy, + claimedAt: now, + updatedAt: now, + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + isNull(copilotAsyncToolCalls.claimedBy), + or( + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]), + and( + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending), + inArray(copilotAsyncToolCalls.permissionDecision, [ + ...EXECUTABLE_TOOL_PERMISSION_DECISIONS, + ]) + ) + ) + ) + ) + .returning() + return row ?? null + } + ) +} + +export async function releaseWorkflowToolExecutionClaim(toolCallId: string, executionId: string) { + const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}` + return withDbSpan( + TraceSpan.CopilotAsyncRunsReleaseClaim, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + claimedBy: null, + claimedAt: null, + updatedAt: new Date(), + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + eq(copilotAsyncToolCalls.claimedBy, claimedBy), + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]) + ) + ) + .returning() + return row ?? null + } + ) +} + /** * Atomically claims a pending client tool exactly once. Native browser actions * use this before crossing the Electron boundary so a replayed renderer event @@ -425,27 +501,79 @@ export async function completeAsyncToolCall(input: { result?: AsyncCompletionData | null error?: string | null }) { - const existing = await getAsyncToolCall(input.toolCallId) - - if (!existing) { - logger.warn('completeAsyncToolCall called before pending row existed', { - toolCallId: input.toolCallId, - status: input.status, - }) - return null - } + return markAsyncToolStatus( + input.toolCallId, + input.status, + { + claimedBy: null, + claimedAt: null, + result: input.result ?? null, + error: input.error ?? null, + completedAt: new Date(), + }, + [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] + ) +} - if (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) { - return existing - } +/** + * Atomically detaches a live client tool after the browser reports that it is + * continuing in the background. Whichever terminal or detach transition wins + * is the only result eligible for publication. + */ +export async function detachAsyncToolCall( + toolCallId: string, + options?: { preserveClaim?: boolean } +) { + return markAsyncToolStatus( + toolCallId, + ASYNC_TOOL_STATUS.delivered, + options?.preserveClaim ? {} : { claimedBy: null, claimedAt: null }, + [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] + ) +} - return markAsyncToolStatus(input.toolCallId, input.status, { - claimedBy: null, - claimedAt: null, - result: input.result ?? null, - error: input.error ?? null, - completedAt: new Date(), - }) +/** + * Replaces an already-terminal async tool call from a trusted producer. + * + * Client workflow confirmations are persisted structurally first. The live + * Copilot waiter uses this guarded update only after it has restored and + * projected the server-owned workflow result. + */ +export async function replaceTerminalAsyncToolCallResult(input: { + toolCallId: string + status: AsyncTerminalStatus + result: AsyncCompletionData | null + error: string | null +}) { + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: input.toolCallId, + [TraceAttr.CopilotAsyncToolStatus]: input.status, + [TraceAttr.CopilotAsyncToolHasError]: !!input.error, + }, + async () => { + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: input.status, + result: sanitizeValueForJsonb(input.result), + error: input.error, + updatedAt: new Date(), + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, input.toolCallId), + eq(copilotAsyncToolCalls.status, input.status) + ) + ) + .returning() + + return row ?? null + } + ) } /** @@ -480,7 +608,8 @@ export async function recordToolPermissionDecision( .where( and( eq(copilotAsyncToolCalls.toolCallId, toolCallId), - isNull(copilotAsyncToolCalls.permissionDecision) + isNull(copilotAsyncToolCalls.permissionDecision), + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending) ) ) .returning() @@ -489,13 +618,6 @@ export async function recordToolPermissionDecision( ) } -export async function markAsyncToolDelivered(toolCallId: string) { - return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.delivered, { - claimedBy: null, - claimedAt: null, - }) -} - async function listAsyncToolCallsForRun(runId: string) { return withDbSpan( TraceSpan.CopilotAsyncRunsListForRun, diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 1de5ea17dcb..cfd555b6db8 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -14,11 +14,12 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -const getEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv +const getEffectiveEnvironmentSnapshot = environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot const { generateWorkspaceSnapshot, @@ -134,7 +135,14 @@ describe('handleUnifiedChatPost', () => { }) getUserEntityPermissions.mockResolvedValue('write') resolveBillingAttribution.mockResolvedValue(billingAttribution) - getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' }) + getEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { API_KEY: 'encrypted-secret' }, + workspaceEncrypted: {}, + personalDecrypted: { API_KEY: 'secret' }, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) generateWorkspaceSnapshot.mockResolvedValue({ markdown: 'workspace context', snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] }, @@ -197,6 +205,7 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', + resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), }) @@ -238,6 +247,7 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', + resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), }) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 4b802c1f27f..27855a7d911 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -32,6 +32,7 @@ import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { CopilotChatFinalizeOutcome, CopilotChatPersistOutcome, @@ -52,7 +53,6 @@ import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request import { persistChatResources } from '@/lib/copilot/resources/persistence' import { isEphemeralResource } from '@/lib/copilot/resources/types' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { captureServerEvent } from '@/lib/posthog/server' import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { @@ -561,8 +561,8 @@ async function buildInitialExecutionContext(params: { } } - const [decryptedEnvVars, billingAttribution] = await Promise.all([ - getEffectiveDecryptedEnv(userId, workspaceId), + const [environmentContext, billingAttribution] = await Promise.all([ + prepareCopilotEnvironmentContext(userId, workspaceId), workspaceId ? resolveBillingAttribution({ actorUserId: userId, workspaceId }) : Promise.resolve(undefined), @@ -572,7 +572,7 @@ async function buildInitialExecutionContext(params: { workflowId: workflowId ?? '', workspaceId, chatId, - decryptedEnvVars, + ...environmentContext, billingAttribution, messageId, userTimezone, diff --git a/apps/sim/lib/copilot/environment-context.test.ts b/apps/sim/lib/copilot/environment-context.test.ts new file mode 100644 index 00000000000..e4cee310987 --- /dev/null +++ b/apps/sim/lib/copilot/environment-context.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' + +describe('prepareCopilotEnvironmentContext', () => { + afterEach(() => { + resetEnvironmentUtilsMock() + }) + + it('keeps decrypted values only in the inert provenance registry', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { + SHARED_SECRET: 'personal-encrypted', + PERSONAL_ONLY: 'personal-only-encrypted', + }, + workspaceEncrypted: { + SHARED_SECRET: 'workspace-encrypted', + WORKSPACE_ONLY: 'workspace-only-encrypted', + }, + personalDecrypted: { + SHARED_SECRET: 'personal-value', + PERSONAL_ONLY: 'personal-only-value', + }, + workspaceDecrypted: { + SHARED_SECRET: 'workspace-value', + WORKSPACE_ONLY: 'workspace-only-value', + }, + conflicts: ['SHARED_SECRET'], + decryptionFailures: [], + }) + + const context = await prepareCopilotEnvironmentContext('user-1', 'workspace-1') + + expect(context).not.toHaveProperty('decryptedEnvVars') + expect(context.resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect( + context.resolvedSecretTraceRegistry.recordResolved('SHARED_SECRET', 'workspace-value') + ).toBe(true) + expect(context.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'workspace-value', replacement: '{{SHARED_SECRET}}' }, + ]) + expect( + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot + ).toHaveBeenCalledExactlyOnceWith('user-1', 'workspace-1') + }) +}) diff --git a/apps/sim/lib/copilot/environment-context.ts b/apps/sim/lib/copilot/environment-context.ts new file mode 100644 index 00000000000..d939d60a69a --- /dev/null +++ b/apps/sim/lib/copilot/environment-context.ts @@ -0,0 +1,35 @@ +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { + type EnvironmentResolutionSnapshot, + getEffectiveEnvironmentSnapshot, +} from '@/lib/environment/utils' +import { createResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export type CopilotEnvironmentContext = Pick + +export async function createCopilotEnvironmentContext( + userId: string, + workspaceId: string | undefined, + environment: EnvironmentResolutionSnapshot +): Promise { + const resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ + personalEncrypted: environment.personalEncrypted, + workspaceEncrypted: environment.workspaceEncrypted, + personalDecrypted: environment.personalDecrypted, + workspaceDecrypted: environment.workspaceDecrypted, + decryptionFailures: environment.decryptionFailures, + scope: { userId, workspaceId }, + }) + + return { + resolvedSecretTraceRegistry, + } +} + +export async function prepareCopilotEnvironmentContext( + userId: string, + workspaceId?: string +): Promise { + const environment = await getEffectiveEnvironmentSnapshot(userId, workspaceId) + return createCopilotEnvironmentContext(userId, workspaceId, environment) +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index cda8d3097ea..292b1492232 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1768,7 +1768,7 @@ export const FunctionExecute: ToolCatalogEntry = { 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 access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + '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. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -3489,21 +3489,27 @@ export const QueryUserTable: ToolCatalogEntry = { type: 'object', description: 'Arguments for the operation', properties: { - filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, - limit: { - type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, + filter: { + type: 'object', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, - offset: { + limit: { type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + 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).', }, - rowId: { type: 'string', description: 'Row ID (required for get_row)' }, - sort: { - type: 'object', + order: { + type: 'array', description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, }, }, @@ -3766,7 +3772,7 @@ export const RunCode: ToolCatalogEntry = { 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 access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + '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. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -4742,17 +4748,17 @@ export const UserTable: ToolCatalogEntry = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - order: { - type: 'array', - description: - 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', - }, options: { type: 'array', description: 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', items: { type: 'string' }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 825443e2447..421ddcadb25 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1464,7 +1464,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 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 access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + '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. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -3148,27 +3148,30 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Arguments for the operation', properties: { + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, filter: { type: 'object', - description: 'MongoDB-style filter for query_rows', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + 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).', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, rowId: { type: 'string', description: 'Row ID (required for get_row)', }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: 'Table ID (required for all operations)', @@ -3435,7 +3438,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 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 access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + '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. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -4247,6 +4250,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, data: { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', @@ -4289,7 +4297,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -4326,7 +4334,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows to return or affect (optional, default 100). Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.', + '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). On update_rows_by_filter / delete_rows_by_filter, caps affected rows; omit to act on every match.', }, mapping: { type: 'object', @@ -4388,10 +4396,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', - }, options: { type: 'array', description: @@ -4400,6 +4404,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: @@ -4492,11 +4501,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", enum: ['all', 'row'], }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts index 09f5fd2ee93..dd1a41006b2 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { + ASYNC_TOOL_CONFIRMATION_STATUS, ASYNC_TOOL_STATUS, type AsyncCompletionEnvelope, type AsyncConfirmationState, @@ -46,10 +47,10 @@ export async function getToolConfirmation( }) if (!row) return null if (row.status === ASYNC_TOOL_STATUS.delivered) { - logger.warn('Delivered async tool rows are outside request confirmation flow', { - toolCallId, - }) - return null + return { + status: ASYNC_TOOL_CONFIRMATION_STATUS.background, + timestamp: row.updatedAt?.toISOString?.(), + } } return { status: diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts index efe38c759ab..7b72bce8255 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts @@ -82,7 +82,7 @@ describe('copilot orchestrator persistence', () => { }) }) - it('ignores delivered rows in request confirmation flow', async () => { + it('reconstructs background from a delivered durable row', async () => { row = { status: 'delivered', result: { ok: true }, @@ -90,7 +90,10 @@ describe('copilot orchestrator persistence', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - await expect(getToolConfirmation('tool-1')).resolves.toBeNull() + await expect(getToolConfirmation('tool-1')).resolves.toEqual({ + status: 'background', + timestamp: '2026-01-01T00:00:00.000Z', + }) }) it('ignores background when waiting for a foreground terminal status', async () => { @@ -163,4 +166,22 @@ describe('copilot orchestrator persistence', () => { timestamp: '2026-01-01T00:00:01.000Z', }) }) + + it('resolves background when detach completes before the waiter subscribes', async () => { + row = { + status: 'delivered', + error: null, + result: null, + updatedAt: new Date('2026-01-01T00:00:01.000Z'), + } + + await expect( + waitForToolConfirmation('tool-1', 5_000, undefined, { + acceptStatus: (status) => status === 'background', + }) + ).resolves.toEqual({ + status: 'background', + timestamp: '2026-01-01T00:00:01.000Z', + }) + }) }) diff --git a/apps/sim/lib/copilot/persistence/tool-permission/index.ts b/apps/sim/lib/copilot/persistence/tool-permission/index.ts index 718b6e9e3e6..083d11004d2 100644 --- a/apps/sim/lib/copilot/persistence/tool-permission/index.ts +++ b/apps/sim/lib/copilot/persistence/tool-permission/index.ts @@ -1,6 +1,7 @@ import type { CopilotToolPermissionDecision } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isExecutableToolPermissionDecision } from '@/lib/copilot/async-runs/lifecycle' import { getAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' @@ -26,7 +27,7 @@ export interface ToolPermissionEnvelope { /** Every allow variant runs the tool; they differ only in what gets remembered. */ export function decisionAllowsExecution(decision: ToolPermissionDecision): boolean { - return decision !== TOOL_PERMISSION_DECISION.skip + return isExecutableToolPermissionDecision(decision) } /** True for the decisions that suppress future prompts for the same tool. */ diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index ebc2ce9f1be..1947b635512 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -32,7 +32,10 @@ function makeContext(): StreamingContext { wasAborted: false, errors: [], trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 33979504936..efa9d8ef7d8 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -109,7 +109,10 @@ function createStreamingContext(): StreamingContext { errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 5ebe3be2c4b..471904c16fe 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -377,7 +377,7 @@ export async function runStreamLoop( state: filePreviewAdapterState, }) - await prePersistClientExecutableToolCall(streamEvent, context, options) + await prePersistClientExecutableToolCall(streamEvent, context, options, execContext) try { await options.onEvent?.(streamEvent) diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index fb595791464..c3f92fe3c06 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -15,16 +15,21 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } = +const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), +})) + +const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), - markAsyncToolDelivered: vi.fn(), + waitForClientToolCompletion: vi.fn(), + waitForToolCompletion: vi.fn(), + waitForWorkflowToolCompletion: vi.fn(), })) -const { waitForToolCompletion } = vi.hoisted(() => ({ - waitForToolCompletion: vi.fn(), +const { sealClientToolContext } = vi.hoisted(() => ({ + sealClientToolContext: vi.fn(), })) vi.mock('@/lib/copilot/tool-executor', () => ({ @@ -50,12 +55,17 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ releaseCompletedAsyncToolClaim: vi.fn(), upsertAsyncToolCall, markAsyncToolRunning, - markAsyncToolDelivered, completeAsyncToolCall, })) vi.mock('@/lib/copilot/request/tools/client', () => ({ + waitForClientToolCompletion, waitForToolCompletion, + waitForWorkflowToolCompletion, +})) + +vi.mock('@/lib/copilot/request/tools/client-completion-seal.server', () => ({ + sealClientToolContext, })) import { @@ -70,13 +80,14 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' -import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { prePersistClientExecutableToolCall, sseHandlers, subAgentHandlers, } from '@/lib/copilot/request/handlers' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' describe('sse-handlers tool lifecycle', () => { let context: StreamingContext @@ -88,8 +99,12 @@ describe('sse-handlers tool lifecycle', () => { upsertAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) - markAsyncToolDelivered.mockResolvedValue(null) waitForToolCompletion.mockResolvedValue(null) + waitForClientToolCompletion.mockResolvedValue(null) + waitForWorkflowToolCompletion.mockResolvedValue(null) + sealClientToolContext.mockResolvedValue({ + __sealedClientToolContextV1: 'sealed-context', + }) context = { chatId: undefined, messageId: 'msg-1', @@ -109,11 +124,15 @@ describe('sse-handlers tool lifecycle', () => { streamComplete: false, wasAborted: false, errors: [], - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } execContext = { userId: 'user-1', workflowId: 'workflow-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]), } }) @@ -133,7 +152,9 @@ describe('sse-handlers tool lifecycle', () => { phase: MothershipStreamV1ToolPhase.call, }, } satisfies StreamEvent, - context + context, + {}, + execContext ) expect(upsertAsyncToolCall).toHaveBeenCalledWith({ @@ -141,14 +162,24 @@ describe('sse-handlers tool lifecycle', () => { toolCallId: 'browser-tool-1', toolName: 'browser_list_tabs', args: {}, + sealedContext: { __sealedClientToolContextV1: 'sealed-context' }, status: MothershipStreamV1AsyncToolRecordStatus.pending, }) + expect(sealClientToolContext).toHaveBeenCalledWith({ + toolCallId: 'browser-tool-1', + runId: 'run-1', + userId: 'user-1', + registry: execContext.resolvedSecretTraceRegistry, + }) }) it('persists a gated sim tool and stamps the frame so a reload can still answer it', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -182,7 +213,10 @@ describe('sse-handlers tool lifecycle', () => { // answer into a disabled endpoint. toolRequiresApproval.mockReturnValue(false) context.runId = 'run-1' - context.toolPermissions = { enabled: false, autoAllowed: new Set() } + context.toolPermissions = { + enabled: false, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -206,7 +240,10 @@ describe('sse-handlers tool lifecycle', () => { it('clears a Go-stamped approval frame on an internal tool', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -232,7 +269,10 @@ describe('sse-handlers tool lifecycle', () => { it('leaves an already always-allowed tool ungated', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set(['deploy_api']) } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(['deploy_api']), + } const event = { type: MothershipStreamV1EventType.tool, @@ -431,8 +471,82 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.result?.output).toBe('done') }) - it('marks background client workflow tools delivered after synthetic result emission', async () => { - waitForToolCompletion.mockResolvedValueOnce({ + it('projects resolved Function secrets before every Copilot-visible result sink', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ]) + registry.recordResolved('SECRET', 'secret-value') + execContext.resolvedSecretTraceRegistry = registry + execContext.chatId = 'chat-1' + executeTool.mockResolvedValueOnce({ + success: true, + output: { + result: 'secret-value', + stdout: 'prefix secret-value', + }, + resources: [{ type: 'file', id: 'file-1', title: 'secret-value.txt' }], + }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-function', + toolName: FunctionExecute.id, + arguments: { code: 'return {{SECRET}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + await sleep(0) + + const safeOutput = { + result: '{{SECRET}}', + stdout: 'prefix {{SECRET}}', + } + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-function', + result: safeOutput, + }) + ) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + toolCallId: 'tool-function', + output: safeOutput, + }), + }) + ) + expect(context.toolCalls.get('tool-function')?.result?.output).toEqual(safeOutput) + expect(onEvent).toHaveBeenCalledWith({ + type: MothershipStreamV1EventType.resource, + payload: { + op: MothershipStreamV1ResourceOp.upsert, + resource: { + type: 'file', + id: 'file-1', + title: '{{SECRET}}.txt', + }, + }, + }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('secret-value') + }) + + it('emits a structural result for a detached background workflow tool', async () => { + waitForWorkflowToolCompletion.mockResolvedValueOnce({ status: 'background', data: { detached: true }, }) @@ -458,7 +572,13 @@ describe('sse-handlers tool lifecycle', () => { await sleep(0) await Promise.allSettled(context.pendingToolPromises.values()) - expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-background') + expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith({ + toolCallId: 'tool-background', + workflowId: 'workflow-1', + timeoutMs: 1000, + abortSignal: undefined, + registry: execContext.resolvedSecretTraceRegistry, + }) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ type: MothershipStreamV1EventType.tool, @@ -477,10 +597,12 @@ describe('sse-handlers tool lifecycle', () => { }) it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { - waitForToolCompletion.mockResolvedValueOnce({ + waitForClientToolCompletion.mockResolvedValueOnce({ status: 'success', - data: { content: 'hello', totalLines: 1 }, + message: 'Read {{SECRET}}', + data: { content: '{{SECRET}}', totalLines: 1 }, }) + const onEvent = vi.fn() await sseHandlers.tool( { @@ -496,12 +618,32 @@ describe('sse-handlers tool lifecycle', () => { } satisfies StreamEvent, context, execContext, - { onEvent: vi.fn(), interactive: true, timeout: 1000 } + { onEvent, interactive: true, timeout: 1000 } ) await Promise.allSettled(context.pendingToolPromises.values()) - expect(waitForToolCompletion).toHaveBeenCalledWith('tool-user-local-read', 1000, undefined) + expect(waitForClientToolCompletion).toHaveBeenCalledWith({ + toolCallId: 'tool-user-local-read', + runId: context.runId, + userId: 'user-1', + timeoutMs: 1000, + abortSignal: undefined, + registry: execContext.resolvedSecretTraceRegistry, + }) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + phase: MothershipStreamV1ToolPhase.result, + output: { content: '{{SECRET}}', totalLines: 1 }, + }), + }) + ) + expect(JSON.stringify(context.toolCalls.get('tool-user-local-read'))).not.toContain( + 'resolved-secret' + ) + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('resolved-secret') expect(executeTool).not.toHaveBeenCalled() }) @@ -645,6 +787,53 @@ describe('sse-handlers tool lifecycle', () => { expect(context.toolCalls.has('glob-generating')).toBe(false) }) + it('executes finalized main-tool arguments instead of a generating snapshot', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-finalized-args', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return {{STALE_SECRET}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-finalized-args', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return 1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sleep(0) + + expect(executeTool).toHaveBeenCalledWith( + FunctionExecute.id, + { language: 'javascript', code: 'return 1' }, + expect.any(Object) + ) + }) + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.toolCalls.set('parent-1', { @@ -665,6 +854,7 @@ describe('sse-handlers tool lifecycle', () => { mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.call, status: 'generating', + arguments: { name: 'Stale Workflow' }, }, } satisfies StreamEvent, context, @@ -1035,10 +1225,22 @@ describe('sse-handlers tool lifecycle', () => { const firstPromise = context.pendingToolPromises.get('tool-inflight') expect(firstPromise).toBeDefined() - await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false }) + await sseHandlers.tool( + { + ...event, + payload: { + ...event.payload, + arguments: { workflowId: 'workflow-2' }, + }, + } as StreamEvent, + context, + execContext, + { interactive: false } + ) expect(executeTool).toHaveBeenCalledTimes(1) expect(context.pendingToolPromises.get('tool-inflight')).toBe(firstPromise) + expect(context.toolCalls.get('tool-inflight')?.params).toEqual({ workflowId: 'workflow-1' }) resolveTool?.({ success: true, output: { ok: true } }) await sleep(0) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 880edae7433..6634a625308 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -2,11 +2,8 @@ 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 { - ASYNC_TOOL_CONFIRMATION_STATUS, - type AsyncCompletionSignal, -} from '@/lib/copilot/async-runs/lifecycle' -import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, @@ -26,7 +23,12 @@ 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 { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor' +import { + waitForClientToolCompletion, + waitForWorkflowToolCompletion, +} 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 { runGatedToolExecution, TOOL_AWAITING_APPROVAL_STATUS, @@ -44,7 +46,7 @@ import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' -import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { getBlockByToolName } from '@/blocks/registry' import type { ToolScope } from './types' import { @@ -169,7 +171,8 @@ function rebindResolvedIntegrationCall( export async function prePersistClientExecutableToolCall( event: StreamEvent, context: StreamingContext, - options?: OrchestratorOptions + options?: OrchestratorOptions, + execContext?: ExecutionContext ): Promise { if (event.type !== 'tool') return if (!isToolCallStreamEvent(event)) return @@ -221,11 +224,30 @@ export async function prePersistClientExecutableToolCall( if (!context.runId) return + let sealedContext: Awaited> | undefined + if (execContext?.resolvedSecretTraceRegistry) { + try { + sealedContext = await sealClientToolContext({ + toolCallId: data.toolCallId, + runId: context.runId, + userId: execContext.userId, + registry: execContext.resolvedSecretTraceRegistry, + }) + } catch (error) { + execContext.resolvedSecretTraceRegistry.markIncomplete() + logger.warn('Failed to seal client tool provenance', { + toolCallId: data.toolCallId, + error: getErrorMessage(error), + }) + } + } + await upsertAsyncToolCall({ runId: context.runId, toolCallId: data.toolCallId, toolName: data.toolName, args: data.arguments, + sealedContext, // Browser and terminal actions cross a second, native authorization // boundary. Leave those rows pending until Electron atomically claims // them — the authorize endpoint only hands over a pending call, so a row @@ -399,11 +421,20 @@ async function handleCallPhase( if (isPartial && shouldDelayVfsPlaceholder(toolName, args)) return + if ( + existing && + (context.pendingToolPromises.has(toolCallId) || + existing.status === 'awaiting_approval' || + existing.status === 'executing') + ) { + applyToolDisplay(existing) + return + } + if (isSubagent) { if (wasToolResultSeen(toolCallId) || existing?.endTime) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (existing && !existing.name && toolName) existing.name = toolName - if (existing && !existing.params && args) existing.params = args + if (existing) updateToolCallFromFrame(existing, toolName, args, !isPartial) } applyToolDisplay(existing) return @@ -414,8 +445,7 @@ async function handleCallPhase( (existing && existing.status !== 'pending' && existing.status !== 'executing') ) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (!existing.name && toolName) existing.name = toolName - if (!existing.params && args) existing.params = args + updateToolCallFromFrame(existing, toolName, args, !isPartial) } applyToolDisplay(existing) return @@ -430,10 +460,11 @@ async function handleCallPhase( args, parentToolCallId!, ui, - spanIdentity + spanIdentity, + !isPartial ) } else { - registerMainToolCall(context, toolCallId, toolName, args, existing, ui) + registerMainToolCall(context, toolCallId, toolName, args, existing, ui, !isPartial) } if (isPartial) return @@ -507,6 +538,16 @@ function removeToolCallContentBlock(context: StreamingContext, toolCallId: strin } } +function updateToolCallFromFrame( + toolCall: ToolCallState, + toolName: string, + args: Record | undefined, + finalized: boolean +): void { + if (!toolCall.name && toolName) toolCall.name = toolName + if (finalized || args !== undefined) toolCall.params = args +} + function registerSubagentToolCall( context: StreamingContext, toolCallId: string, @@ -514,7 +555,8 @@ function registerSubagentToolCall( args: Record | undefined, parentToolCallId: string, ui: { title?: string; phaseLabel?: string; hidden?: boolean }, - spanIdentity: { spanId?: string; parentSpanId?: string } + spanIdentity: { spanId?: string; parentSpanId?: string }, + finalized: boolean ): void { if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] @@ -523,8 +565,7 @@ function registerSubagentToolCall( let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { if (!rebindResolvedIntegrationCall(toolCall, toolName, args)) { - if (!toolCall.name && toolName) toolCall.name = toolName - if (args && !toolCall.params) toolCall.params = args + updateToolCallFromFrame(toolCall, toolName, args, finalized) } applyToolDisplay(toolCall) if (hideFromUi) removeToolCallContentBlock(context, toolCallId) @@ -554,8 +595,7 @@ function registerSubagentToolCall( const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId) if (existingSubagentToolCall) { if (!rebindResolvedIntegrationCall(existingSubagentToolCall, toolName, args)) { - if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName - if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args + updateToolCallFromFrame(existingSubagentToolCall, toolName, args, finalized) } applyToolDisplay(existingSubagentToolCall) } else { @@ -569,12 +609,13 @@ function registerMainToolCall( toolName: string, args: Record | undefined, existing: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string; hidden?: boolean } + ui: { title?: string; phaseLabel?: string; hidden?: boolean }, + finalized: boolean ): void { const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { - if (!rebindResolvedIntegrationCall(existing, toolName, args) && args && !existing.params) { - existing.params = args + if (!rebindResolvedIntegrationCall(existing, toolName, args)) { + updateToolCallFromFrame(existing, toolName, args, finalized) } applyToolDisplay(existing) if (hideFromUi) { @@ -699,25 +740,27 @@ async function dispatchToolExecution( ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), }, async (span) => { - const completion = await waitForToolCompletion( - toolCallId, - options.timeout || STREAM_TIMEOUT_MS, - options.abortSignal - ) + const completion = isWorkflowToolName(toolName) + ? await waitForWorkflowToolCompletion({ + toolCallId, + workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), + timeoutMs: options.timeout || STREAM_TIMEOUT_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) + : await waitForClientToolCompletion({ + toolCallId, + runId: context.runId, + userId: execContext.userId, + timeoutMs: options.timeout || STREAM_TIMEOUT_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) if (completion) { span.setAttribute(TraceAttr.ToolOutcome, completion.status) } handleClientCompletion(toolCall, toolCallId, completion) - if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - await markAsyncToolDelivered(toolCallId).catch((err) => { - logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { - toolCallId, - toolName, - error: toError(err).message, - }) - }) - } await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return ( completion ?? { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 46cfd2f19c2..de4782347ba 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -2,18 +2,11 @@ * @vitest-environment node */ -import { - environmentUtilsMockFns, - resetEnvFlagsMock, - resetEnvironmentUtilsMock, - setEnvFlags, -} from '@sim/testing' +import { resetEnvFlagsMock, resetEnvironmentUtilsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const mockGetEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv - afterAll(resetEnvironmentUtilsMock) const { @@ -21,6 +14,7 @@ const { mockForceFailHungToolCall, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, + mockPrepareCopilotEnvironmentContext, mockPrepareExecutionContext, mockRunStreamLoop, mockPendingToolWaitBudgetMs, @@ -32,6 +26,7 @@ const { mockForceFailHungToolCall: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), + mockPrepareCopilotEnvironmentContext: vi.fn(), mockPrepareExecutionContext: vi.fn(), mockRunStreamLoop: vi.fn(), mockPendingToolWaitBudgetMs: vi.fn(() => 60_000), @@ -108,6 +103,10 @@ vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ addChatAutoAllowedTool: vi.fn(), })) +vi.mock('@/lib/copilot/environment-context', () => ({ + prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, +})) + vi.mock('@/lib/copilot/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) @@ -147,6 +146,7 @@ describe('runCopilotLifecycle', () => { mockGetAutoAllowedTools.mockResolvedValue(new Set()) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) + mockPrepareCopilotEnvironmentContext.mockResolvedValue({}) }) it('threads trace provenance through server execution context only', async () => { @@ -155,7 +155,6 @@ describe('runCopilotLifecycle', () => { userId: 'user-1', workflowId: '', workspaceId: 'ws-1', - decryptedEnvVars: {}, } let capturedExecutionContext: ExecutionContext | undefined let capturedRequestBody = '' @@ -203,7 +202,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, }, } ) @@ -257,7 +255,6 @@ describe('runCopilotLifecycle', () => { workflowId: 'wf-1', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, }, } ) @@ -277,7 +274,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -349,7 +345,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -402,7 +397,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -444,7 +438,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -480,9 +473,8 @@ describe('runCopilotLifecycle', () => { ) }) - it('propagates payload userPermission into the generated execution context', async () => { + it('does not trust payload userPermission when building the execution context', async () => { let capturedExecContext: ExecutionContext | undefined - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -508,9 +500,35 @@ describe('runCopilotLifecycle', () => { userId: 'user-1', workspaceId: 'ws-1', chatId: 'chat-1', - userPermission: 'write', }) ) + expect(capturedExecContext).not.toHaveProperty('userPermission') + }) + + it('uses only the trusted lifecycle userPermission option', async () => { + let capturedExecContext: ExecutionContext | undefined + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + _context: StreamingContext, + execContext: ExecutionContext + ): Promise => { + capturedExecContext = execContext + } + ) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1', userPermission: 'admin' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + userPermission: 'read', + } + ) + + expect(capturedExecContext?.userPermission).toBe('read') }) it('uses one server billing identity and immutable attribution on initial and resume legs', async () => { @@ -529,7 +547,6 @@ describe('runCopilotLifecycle', () => { setEnvFlags({ isHosted: true }) setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -591,7 +608,6 @@ describe('runCopilotLifecycle', () => { it('runs legacy-v0 during Sim-first deployment without guessed billing aliases', async () => { setEnvFlags({ isHosted: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1' }, @@ -626,7 +642,6 @@ describe('runCopilotLifecycle', () => { it('runs modern hosted work without legacy compatibility storage', async () => { setEnvFlags({ isHosted: true }) setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1' }, @@ -654,7 +669,6 @@ describe('runCopilotLifecycle', () => { it('does not emit trusted billing headers for a non-hosted lifecycle', async () => { mockEnv.COPILOT_API_KEY = 'user-or-self-hosted-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1', billingRequestId: 'caller-controlled' }, @@ -685,7 +699,6 @@ describe('runCopilotLifecycle', () => { it('normalizes the initial request body with workspaceId from lifecycle options', async () => { let requestBody: Record | undefined - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async (_fetchUrl: string, fetchOptions: RequestInit): Promise => { requestBody = JSON.parse(String(fetchOptions.body)) @@ -716,7 +729,6 @@ describe('runCopilotLifecycle', () => { workflowId: 'workflow-1', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -775,7 +787,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // 1) Initial stream pauses on an async tool checkpoint with a resolved @@ -857,7 +868,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // Initial leg pauses on a resolved async tool checkpoint → enters resume. @@ -927,7 +937,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -990,7 +999,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -1052,7 +1060,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -1092,7 +1099,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // Mirror the real helper: settle the tool call into a terminal error diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 6eb5888b630..aa22adc8829 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1,5 +1,6 @@ import type { Context } from '@opentelemetry/api' import { createLogger } from '@sim/logger' +import type { PermissionType } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -12,6 +13,10 @@ import { import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/copilot/constants' +import { + type CopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import { COPILOT_BILLING_PROTOCOL, COPILOT_BILLING_PROTOCOL_HEADER, @@ -53,6 +58,7 @@ import type { StreamEvent, StreamingContext, } from '@/lib/copilot/request/types' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' @@ -61,7 +67,6 @@ import { isCopilotToolPermissionsEnabled, isHosted, } from '@/lib/core/config/env-flags' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotLifecycle') @@ -97,6 +102,10 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { executionContext?: ExecutionContext billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + environmentContext?: CopilotEnvironmentContext + userPermission?: PermissionType + secretMountPolicy?: SecretMountPolicy + secretActorUserId?: string | null } /** @@ -163,9 +172,14 @@ export async function runCopilotLifecycle( abortSignal: options.abortSignal, billingAttribution: options.billingAttribution ?? options.executionContext.billingAttribution, + ...(options.userPermission ? { userPermission: options.userPermission } : {}), ...(options.resolvedSecretTraceRegistry ? { resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry } : {}), + ...(options.secretMountPolicy ? { secretMountPolicy: options.secretMountPolicy } : {}), + ...(options.secretActorUserId !== undefined + ? { secretActorUserId: options.secretActorUserId } + : {}), }, } : {}), @@ -183,6 +197,10 @@ export async function runCopilotLifecycle( abortSignal: lifecycleOptions.abortSignal, billingAttribution: lifecycleOptions.billingAttribution, resolvedSecretTraceRegistry: lifecycleOptions.resolvedSecretTraceRegistry, + environmentContext: lifecycleOptions.environmentContext, + userPermission: lifecycleOptions.userPermission, + secretMountPolicy: lifecycleOptions.secretMountPolicy, + secretActorUserId: lifecycleOptions.secretActorUserId, })) const shouldUseHostedBillingProtocol = isHosted && isCopilotBillingAttributionV1Enabled if ( @@ -1000,6 +1018,10 @@ async function buildExecutionContext( abortSignal?: AbortSignal billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + environmentContext?: CopilotEnvironmentContext + userPermission?: PermissionType + secretMountPolicy?: SecretMountPolicy + secretActorUserId?: string | null } ): Promise { const { @@ -1012,27 +1034,31 @@ async function buildExecutionContext( abortSignal, billingAttribution, resolvedSecretTraceRegistry, + environmentContext, + userPermission, + secretMountPolicy, + secretActorUserId, } = params const userTimezone = typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined const requestMode = typeof requestPayload?.mode === 'string' ? requestPayload.mode : undefined - const userPermission = - typeof requestPayload?.userPermission === 'string' ? requestPayload.userPermission : undefined let execContext: ExecutionContext if (workflowId) { execContext = await prepareExecutionContext(userId, workflowId, chatId, { workspaceId, billingAttribution, + environmentContext, }) } else { - const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) + const activeEnvironmentContext = + environmentContext ?? (await prepareCopilotEnvironmentContext(userId, workspaceId)) execContext = { userId, workflowId: '', workspaceId, chatId, - decryptedEnvVars, + ...activeEnvironmentContext, billingAttribution, } } @@ -1050,6 +1076,8 @@ async function buildExecutionContext( if (resolvedSecretTraceRegistry) { execContext.resolvedSecretTraceRegistry = resolvedSecretTraceRegistry } + if (secretMountPolicy) execContext.secretMountPolicy = secretMountPolicy + if (secretActorUserId !== undefined) execContext.secretActorUserId = secretActorUserId return execContext } diff --git a/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts new file mode 100644 index 00000000000..1204c5f7b92 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts @@ -0,0 +1,136 @@ +import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' +import type { AsyncCompletionData } from '@/lib/copilot/async-runs/lifecycle' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' + +export const SEALED_CLIENT_TOOL_COMPLETION_FIELD = '__sealedClientToolCompletionV1' +export const SEALED_CLIENT_TOOL_CONTEXT_FIELD = '__sealedClientToolContextV1' + +interface ClientToolBinding { + toolCallId: string + runId: string + userId: string +} + +interface ClientToolCompletionContent extends ClientToolBinding { + message?: string + data?: AsyncCompletionData +} + +interface ClientToolContext extends ClientToolBinding { + registryInstanceId: string + provenance: ResolvedSecretTraceProvenanceV1 +} + +interface SealClientToolContextInput extends ClientToolBinding { + registry: ResolvedSecretTraceRegistry +} + +type ClientCompletionSealGlobal = typeof globalThis & { + _clientToolRegistryInstanceIds?: WeakMap +} + +const sealGlobal = globalThis as ClientCompletionSealGlobal +sealGlobal._clientToolRegistryInstanceIds ??= new WeakMap() +const registryInstanceIds = sealGlobal._clientToolRegistryInstanceIds + +function getRegistryInstanceId(registry: ResolvedSecretTraceRegistry): string { + const existing = registryInstanceIds.get(registry) + if (existing) return existing + + const created = generateId() + registryInstanceIds.set(registry, created) + return created +} + +function bindingMatches(value: Record, expected: ClientToolBinding): boolean { + return ( + value.toolCallId === expected.toolCallId && + value.runId === expected.runId && + value.userId === expected.userId + ) +} + +export async function sealClientToolCompletion( + content: ClientToolCompletionContent +): Promise> { + const { encrypted } = await encryptSecret(JSON.stringify(content)) + return { [SEALED_CLIENT_TOOL_COMPLETION_FIELD]: encrypted } +} + +export async function unsealClientToolCompletion( + value: unknown, + expected: ClientToolBinding +): Promise { + if (!isPlainRecord(value)) return null + const sealed = value[SEALED_CLIENT_TOOL_COMPLETION_FIELD] + if (typeof sealed !== 'string' || sealed.length === 0) return null + + try { + const { decrypted } = await decryptSecret(sealed) + const content: unknown = JSON.parse(decrypted) + if (!isPlainRecord(content)) return null + if (!bindingMatches(content, expected)) return null + if (content.message !== undefined && typeof content.message !== 'string') return null + return { + ...expected, + ...(content.message !== undefined ? { message: content.message } : {}), + ...(Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } + } catch { + return null + } +} + +export async function sealClientToolContext( + input: SealClientToolContextInput +): Promise> { + const { registry, ...binding } = input + const context: ClientToolContext = { + ...binding, + registryInstanceId: getRegistryInstanceId(registry), + provenance: registry.exportProvenance(), + } + const { encrypted } = await encryptSecret(JSON.stringify(context)) + return { [SEALED_CLIENT_TOOL_CONTEXT_FIELD]: encrypted } +} + +export function retainSealedClientToolContext( + value: unknown +): Partial> { + if (!isPlainRecord(value)) return {} + const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] + return typeof sealed === 'string' && sealed.length > 0 + ? { [SEALED_CLIENT_TOOL_CONTEXT_FIELD]: sealed } + : {} +} + +export async function unsealClientToolContext( + value: unknown, + expected: ClientToolBinding, + registry: ResolvedSecretTraceRegistry +): Promise { + if (!isPlainRecord(value)) return null + const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] + if (typeof sealed !== 'string' || sealed.length === 0) return null + + try { + const { decrypted } = await decryptSecret(sealed) + const context: unknown = JSON.parse(decrypted) + if (!isPlainRecord(context) || !bindingMatches(context, expected)) return null + if (context.registryInstanceId !== getRegistryInstanceId(registry)) return null + if (!isResolvedSecretTraceProvenanceV1(context.provenance)) return null + return { + ...expected, + registryInstanceId: context.registryInstanceId, + provenance: context.provenance, + } + } catch { + return null + } +} diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/copilot/request/tools/client.test.ts new file mode 100644 index 00000000000..16e798fe1ea --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client.test.ts @@ -0,0 +1,764 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + encryptSecret, + decryptSecret, + waitForToolConfirmation, + replaceTerminalAsyncToolCallResult, + getTrustedWorkflowToolExecution, +} = vi.hoisted(() => ({ + encryptSecret: vi.fn(), + decryptSecret: vi.fn(), + waitForToolConfirmation: vi.fn(), + replaceTerminalAsyncToolCallResult: vi.fn(), + getTrustedWorkflowToolExecution: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret, + decryptSecret, +})) + +vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ + waitForToolConfirmation, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + replaceTerminalAsyncToolCallResult, +})) + +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getTrustedWorkflowToolExecution, +})) + +import { + waitForClientToolCompletion, + waitForWorkflowToolCompletion, +} from '@/lib/copilot/request/tools/client' +import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const TRACE_SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } + +function createParentRegistry(): ResolvedSecretTraceRegistry { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'PARENT_SECRET', + plaintext: 'parent-secret-value', + encryptedValue: 'encrypted-parent-secret', + }, + ], + TRACE_SCOPE + ) + registry.recordResolved('PARENT_SECRET', 'parent-secret-value') + return registry +} + +function createClientRegistry(): ResolvedSecretTraceRegistry { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'SECRET', + plaintext: 'resolved-secret', + encryptedValue: 'encrypted-secret', + }, + ], + TRACE_SCOPE + ) + registry.recordResolved('SECRET', 'resolved-secret') + return registry +} + +function trustedExecution(executionId: string) { + return { + executionId, + workflowId: 'workflow-1', + status: 'completed' as const, + contentAvailable: true as const, + finalOutput: { value: `child read parent-secret-value from ${executionId}` }, + blockLogs: [], + provenance: { + version: 1 as const, + complete: true, + entries: [], + scope: TRACE_SCOPE, + }, + } +} + +describe('workflow client tool completion', () => { + beforeEach(() => { + vi.clearAllMocks() + decryptSecret.mockResolvedValue({ decrypted: 'child-secret-value' }) + replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' }) + }) + + it('projects a parent secret laundered through a child workflow before every live sink', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(trustedExecution('execution-1')) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(getTrustedWorkflowToolExecution).toHaveBeenCalledWith( + 'execution-1', + 'workflow-1', + 'tool-1' + ) + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + output: { value: 'child read {{PARENT_SECRET}} from execution-1' }, + logs: [], + }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: completion?.data, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('parent-secret-value') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'parent-secret-value' + ) + }) + + it('preserves the server-confirmed status while omitting unavailable execution content', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1', output: 'untrusted' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(null) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted') + }) + + it('uses compacted terminal status without exposing unavailable execution content', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: false, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('preserves cancellation when the bound terminal execution is not yet readable', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'cancelled', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(null) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'cancelled', + message: 'Workflow execution was cancelled.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + reason: 'user_cancelled', + cancelledByUser: true, + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('rejects a legacy success without a trusted execution identity', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', output: 'untrusted' }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { success: false, workflowId: 'workflow-1' }, + }) + expect(registry.isComplete()).toBe(false) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted') + }) + + it('uses the bound execution status when provenance is incomplete', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + ...trustedExecution('execution-1'), + status: 'failed', + error: 'trusted failure', + provenance: { + version: 1, + complete: false, + entries: [], + scope: TRACE_SCOPE, + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('imports and projects a secret activated only inside the child workflow', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: true, + finalOutput: { value: 'child-secret-value' }, + blockLogs: [], + provenance: { + version: 1, + complete: true, + entries: [{ name: 'CHILD_SECRET', encryptedValue: 'encrypted-child-secret' }], + scope: TRACE_SCOPE, + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(decryptSecret).toHaveBeenCalledWith('encrypted-child-secret') + expect(completion?.data).toEqual({ + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + output: { value: '{{CHILD_SECRET}}' }, + logs: [], + }) + expect(JSON.stringify(completion)).not.toContain('child-secret-value') + }) + + it('corrects the client terminal status from the bound execution log', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: true, + error: 'trusted failure', + blockLogs: [], + provenance: { version: 1, complete: true, entries: [], scope: TRACE_SCOPE }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toMatchObject({ + status: 'error', + message: 'trusted failure', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + error: 'trusted failure', + }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'failed', + result: completion?.data, + error: 'trusted failure', + }) + }) + + it('treats background completion as structural and incomplete', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'background', + data: { + workflowId: 'workflow-1', + executionId: 'execution-1', + output: 'untrusted-background-output', + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'background', + message: 'Workflow execution is continuing in the background.', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + expect(registry.isComplete()).toBe(false) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('fails structurally when trusted child provenance cannot be imported', async () => { + const registry = createParentRegistry() + vi.spyOn(registry, 'importCrossingProvenance').mockRejectedValueOnce( + new Error('decryption unavailable') + ) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(trustedExecution('execution-1')) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(JSON.stringify(completion)).not.toContain('parent-secret-value') + }) + + it('keeps parallel workflow results safe while sibling provenance is unresolved', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockImplementation((toolCallId: string) => + Promise.resolve({ + status: 'success', + data: { + workflowId: 'workflow-1', + executionId: toolCallId === 'tool-1' ? 'execution-1' : 'execution-2', + }, + }) + ) + + const resolvers = new Map) => void>() + getTrustedWorkflowToolExecution.mockImplementation( + (executionId: string) => + new Promise((resolve) => { + resolvers.set(executionId, resolve) + }) + ) + + const firstPromise = waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + const secondPromise = waitForWorkflowToolCompletion({ + toolCallId: 'tool-2', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + await vi.waitFor(() => expect(resolvers.size).toBe(2)) + resolvers.get('execution-1')?.(trustedExecution('execution-1')) + const first = await firstPromise + + expect(first).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + + resolvers.get('execution-2')?.(trustedExecution('execution-2')) + const second = await secondPromise + + expect(second).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-2', + output: { value: 'child read {{PARENT_SECRET}} from execution-2' }, + logs: [], + }, + }) + expect(JSON.stringify([first, second])).not.toContain('parent-secret-value') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'parent-secret-value' + ) + }) +}) + +describe('generic client tool completion', () => { + beforeEach(() => { + vi.clearAllMocks() + encryptSecret.mockImplementation(async (plaintext: string) => ({ + encrypted: plaintext, + iv: 'iv', + })) + decryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted === 'encrypted-secret' ? 'resolved-secret' : encrypted, + })) + replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' }) + }) + + it('unseals exact-bound content and provenance, then persists only the projected result', async () => { + const registry = createClientRegistry() + const sealedContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + message: 'Read resolved-secret', + data: { content: 'prefix-resolved-secret-suffix' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Read {{SECRET}}', + data: { content: 'prefix-{{SECRET}}-suffix' }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: { content: 'prefix-{{SECRET}}-suffix' }, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('resolved-secret') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'resolved-secret' + ) + }) + + it('does not invalidate later tool results while a sibling activation is pending', async () => { + const registry = createClientRegistry() + const firstContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValueOnce({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...firstContext, + }, + }) + + const finishSiblingActivation = registry.beginPendingActivation() + const first = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(first).toEqual({ status: 'success', message: 'Tool completed' }) + expect(registry.isPermanentlyIncomplete()).toBe(false) + finishSiblingActivation() + expect(registry.isComplete()).toBe(true) + + const secondContext = await sealClientToolContext({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValueOnce({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...secondContext, + }, + }) + + const second = await waitForClientToolCompletion({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(second).toEqual({ + status: 'success', + message: 'Tool completed', + data: { content: '{{SECRET}}' }, + }) + }) + + it('fails structurally without an execution registry', async () => { + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: 'sealed-completion', + __sealedClientToolContextV1: 'sealed-context', + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(decryptSecret).not.toHaveBeenCalled() + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + }) + + it.each([ + ['wrong tool', { toolCallId: 'other-tool', runId: 'run-1', userId: 'user-1' }], + ['wrong run', { toolCallId: 'tool-1', runId: 'other-run', userId: 'user-1' }], + ['wrong user', { toolCallId: 'tool-1', runId: 'run-1', userId: 'other-user' }], + ])('fails structurally for a completion bound to the %s', async (_label, sealedBinding) => { + const registry = createClientRegistry() + const sealedContext = await sealClientToolContext({ ...sealedBinding, registry }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + ...sealedBinding, + data: { content: 'untrusted-secret' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('untrusted-secret') + }) + + it('fails structurally when a restarted execution uses a new registry instance', async () => { + const sourceRegistry = createClientRegistry() + const resumedRegistry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + const sealedContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry: sourceRegistry, + }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry: resumedRegistry, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(resumedRegistry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('resolved-secret') + }) + + it('fails structurally for a legacy raw confirmation without sealed provenance', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'error', + message: 'raw error secret', + data: { content: 'raw result secret' }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ status: 'error', message: 'Tool result omitted' }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'failed', + result: null, + error: 'Tool result omitted', + }) + expect(JSON.stringify(completion)).not.toContain('raw') + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index 43c42de6a8c..f6c7ebede0f 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -1,10 +1,29 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncTerminalCompletionSnapshot, isAsyncTerminalConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' +import { replaceTerminalAsyncToolCallResult } from '@/lib/copilot/async-runs/repository' import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' +import { + unsealClientToolCompletion, + unsealClientToolContext, +} from '@/lib/copilot/request/tools/client-completion-seal.server' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import { + createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionExecutionId, + getWorkflowToolCompletionMessage, + getWorkflowToolConfirmationStatus, +} from '@/lib/copilot/tools/workflow-tools' +import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const logger = createLogger('CopilotClientToolWaiter') /** * Wait for a client-executable workflow tool to report back. @@ -31,3 +50,307 @@ export async function waitForToolCompletion( } return null } + +interface WaitForClientToolCompletionOptions { + toolCallId: string + runId?: string + userId: string + timeoutMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry +} + +function getGenericCompletionMessage(status: AsyncTerminalCompletionSnapshot['status']): string { + if (status === MothershipStreamV1ToolOutcome.success) return 'Tool completed' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) return 'Tool is running in background' + if (status === MothershipStreamV1ToolOutcome.cancelled) return 'Tool cancelled' + return 'Tool failed' +} + +/** + * Restores a generic browser/terminal result from its sealed transport envelope, + * projects active Secrets values, then replaces the durable row before delivery. + */ +export async function waitForClientToolCompletion({ + toolCallId, + runId, + userId, + timeoutMs, + abortSignal, + registry, +}: WaitForClientToolCompletionOptions): Promise { + const completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) + if (!completion) return null + + const genericMessage = getGenericCompletionMessage(completion.status) + const binding = runId ? { toolCallId, runId, userId } : undefined + const registryCanImport = registry !== undefined && !registry.isPermanentlyIncomplete() + const finishPendingActivation = registry?.beginPendingActivation() + let content: Awaited> = null + try { + const [sealedContent, sealedContext] = + binding && registry && registryCanImport + ? await Promise.all([ + unsealClientToolCompletion(completion.data, binding), + unsealClientToolContext(completion.data, binding, registry), + ]) + : [null, null] + if (registry && registryCanImport) { + if (!sealedContent || !sealedContext) { + registry.markIncomplete() + } else { + const imported = await registry.importProvenance(sealedContext.provenance, { + trusted: true, + }) + if (!imported || !sealedContext.provenance.complete) { + registry.markIncomplete() + } else { + content = sealedContent + } + } + } + } catch { + registry?.markIncomplete() + } finally { + finishPendingActivation?.() + } + if (!registry?.isComplete()) content = null + + const rawOutput: Record = { + ...(content?.message !== undefined ? { message: content.message } : {}), + ...(content && Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } + const succeeded = completion.status === MothershipStreamV1ToolOutcome.success + const projected = projectToolResultForCopilot( + { + success: succeeded, + output: rawOutput, + ...(!succeeded ? { error: content?.message ?? genericMessage } : {}), + }, + registry + ) + const projectedOutput = isPlainRecord(projected.output) ? projected.output : undefined + const message = + typeof projectedOutput?.message === 'string' + ? projectedOutput.message + : !succeeded && projected.error + ? projected.error + : genericMessage + const data = + projectedOutput && Object.hasOwn(projectedOutput, 'data') ? projectedOutput.data : undefined + + if (completion.status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) { + const status = + completion.status === MothershipStreamV1ToolOutcome.success + ? 'completed' + : completion.status === MothershipStreamV1ToolOutcome.cancelled + ? 'cancelled' + : 'failed' + try { + const updated = await replaceTerminalAsyncToolCallResult({ + toolCallId, + status, + result: data ?? null, + error: succeeded ? null : message, + }) + if (!updated) { + logger.warn('Client tool row was no longer terminal during safe payload update', { + toolCallId, + }) + } + } catch (error) { + logger.warn('Failed to persist projected client tool result', { + toolCallId, + error: getErrorMessage(error), + }) + } + } + + return { + status: completion.status, + message, + ...(data !== undefined ? { data } : {}), + } +} + +interface WaitForWorkflowToolCompletionOptions { + toolCallId: string + workflowId?: string + timeoutMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry +} + +function structuralWorkflowCompletion( + status: AsyncTerminalCompletionSnapshot['status'], + workflowId?: string, + executionId?: string +): AsyncTerminalCompletionSnapshot { + return { + status, + message: getWorkflowToolCompletionMessage(status), + data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } +} + +/** + * Restores a client-run workflow result from the bound server execution log. + * The browser confirmation is only a wakeup and structural identity carrier. + */ +export async function waitForWorkflowToolCompletion({ + toolCallId, + workflowId, + timeoutMs, + abortSignal, + registry, +}: WaitForWorkflowToolCompletionOptions): Promise { + const finishPendingActivation = registry?.beginPendingActivation() + let completion: AsyncTerminalCompletionSnapshot | null = null + let trustedExecution: Awaited> = null + + try { + completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) + if (!completion) { + registry?.markIncomplete() + return null + } + + const executionId = getWorkflowToolCompletionExecutionId(completion.data) + if (completion.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + registry?.markIncomplete() + return structuralWorkflowCompletion(completion.status, workflowId, executionId) + } + if (!workflowId || !executionId) { + registry?.markIncomplete() + const structuralStatus = + completion.status === MothershipStreamV1ToolOutcome.success + ? MothershipStreamV1ToolOutcome.error + : completion.status + return structuralWorkflowCompletion(structuralStatus, workflowId, executionId) + } + + try { + trustedExecution = await getTrustedWorkflowToolExecution(executionId, workflowId, toolCallId) + } catch (error) { + logger.warn('Failed to restore bound workflow tool execution', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + + if (!trustedExecution) { + registry?.markIncomplete() + return structuralWorkflowCompletion(completion.status, workflowId, executionId) + } + + if (!trustedExecution.contentAvailable) { + registry?.markIncomplete() + return structuralWorkflowCompletion( + getWorkflowToolConfirmationStatus(trustedExecution.status), + workflowId, + executionId + ) + } + + if (!registry || registry.isPermanentlyIncomplete() || !trustedExecution.provenance.complete) { + if (!trustedExecution.provenance.complete) registry?.markIncomplete() + return structuralWorkflowCompletion( + getWorkflowToolConfirmationStatus(trustedExecution.status), + workflowId, + executionId + ) + } + + try { + const imported = await registry.importCrossingProvenance( + trustedExecution.provenance, + { + ...(Object.hasOwn(trustedExecution, 'finalOutput') + ? { finalOutput: trustedExecution.finalOutput } + : {}), + blockLogs: trustedExecution.blockLogs, + ...(trustedExecution.error !== undefined ? { error: trustedExecution.error } : {}), + }, + { trusted: true } + ) + if (!imported) registry.markIncomplete() + } catch (error) { + registry.markIncomplete() + logger.warn('Failed to import bound workflow provenance', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + } finally { + finishPendingActivation?.() + } + + if (!completion || !trustedExecution || !workflowId) return completion + + const executionId = trustedExecution.executionId + const status = getWorkflowToolConfirmationStatus(trustedExecution.status) + const genericMessage = getWorkflowToolCompletionMessage(status) + const rawData: Record = { + success: status === MothershipStreamV1ToolOutcome.success, + workflowId, + executionId, + ...(Object.hasOwn(trustedExecution, 'finalOutput') + ? { output: trustedExecution.finalOutput } + : {}), + logs: trustedExecution.blockLogs, + ...(trustedExecution.error !== undefined ? { error: trustedExecution.error } : {}), + ...(status === MothershipStreamV1ToolOutcome.cancelled + ? { reason: 'user_cancelled', cancelledByUser: true } + : {}), + } + const projected = projectToolResultForCopilot( + { + success: status === MothershipStreamV1ToolOutcome.success, + output: rawData, + ...(status !== MothershipStreamV1ToolOutcome.success + ? { error: trustedExecution.error ?? genericMessage } + : {}), + }, + registry + ) + const projectedData = isPlainRecord(projected.output) ? projected.output : {} + const data = { + ...projectedData, + ...createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } + const message = + status === MothershipStreamV1ToolOutcome.success + ? genericMessage + : Object.hasOwn(projected, 'output') && projected.error + ? projected.error + : genericMessage + + try { + const updated = await replaceTerminalAsyncToolCallResult({ + toolCallId, + status: trustedExecution.status, + result: data, + error: status === MothershipStreamV1ToolOutcome.success ? null : message, + }) + if (!updated) { + logger.warn('Bound workflow tool row was no longer terminal during safe payload update', { + toolCallId, + workflowId, + executionId, + }) + } + } catch (error) { + logger.warn('Failed to persist projected workflow tool result', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + + return { status, message, data } +} diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index fd59ba2fa8b..17106c64ce3 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -53,6 +53,7 @@ import { setTerminalToolCallState, } from '@/lib/copilot/request/tool-call-state' import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import { maybeWriteOutputToTable, @@ -558,6 +559,10 @@ async function executeToolAndReportInner( return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { + const copilotResult = projectToolResultForCopilot( + result, + execContext.resolvedSecretTraceRegistry + ) markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) await completeAsyncToolCall({ @@ -579,7 +584,7 @@ async function executeToolAndReportInner( }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution', - error: result.success === false ? result.error : undefined, + error: copilotResult.success === false ? copilotResult.error : undefined, }) return cancelledCompletion('Request aborted during tool execution') } @@ -655,17 +660,22 @@ async function executeToolAndReportInner( endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) return cancelledCompletion('Request aborted during tool post-processing') } + const copilotResult = projectToolResultForCopilot( + result, + execContext.resolvedSecretTraceRegistry + ) + toolSpan.attributes = { ...toolSpan.attributes, - ...summarizeToolResultForSpan(result), + ...summarizeToolResultForSpan(copilotResult), } setTerminalToolCallState(toolCall, { - status: result.success + status: copilotResult.success ? MothershipStreamV1ToolOutcome.success : MothershipStreamV1ToolOutcome.error, - ...(hasOutputValue(result) ? { output: result.output } : {}), - ...(result.success ? {} : { error: result.error || 'Tool failed' }), + ...(hasOutputValue(copilotResult) ? { output: copilotResult.output } : {}), + ...(copilotResult.success ? {} : { error: copilotResult.error || 'Tool failed' }), }) if (result.success) { @@ -688,7 +698,7 @@ async function executeToolAndReportInner( logger.warn('Tool execution failed', { toolCallId: toolCall.id, toolName: toolCall.name, - error: result.error, + error: copilotResult.error, params: toolCall.params, }) } @@ -741,7 +751,7 @@ async function executeToolAndReportInner( mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, success: result.success, - output: result.output, + output: copilotResult.output, ...(result.success ? { status: MothershipStreamV1ToolOutcome.success } : { status: MothershipStreamV1ToolOutcome.error }), @@ -760,6 +770,7 @@ async function executeToolAndReportInner( toolCall.name, toolCall.params, result, + copilotResult, execContext.chatId, options?.onEvent, () => abortRequested(context, execContext, options) @@ -776,6 +787,11 @@ async function executeToolAndReportInner( }) } catch (error) { const thrownMessage = toError(error).message + const copilotError = projectToolResultForCopilot( + { success: false, error: thrownMessage }, + execContext.resolvedSecretTraceRegistry + ) + const safeThrownMessage = copilotError.error || 'Tool failed' if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) @@ -798,13 +814,13 @@ async function executeToolAndReportInner( }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution_catch', - error: thrownMessage, + error: safeThrownMessage, }) return cancelledCompletion('Request aborted during tool execution') } setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.error, - error: thrownMessage, + error: safeThrownMessage, }) logger.error('Tool execution threw', { @@ -848,7 +864,7 @@ async function executeToolAndReportInner( }, } await options?.onEvent?.(errorEvent) - endToolSpan('error', { error: thrownMessage }) + endToolSpan('error', { error: safeThrownMessage }) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.error, message: toolCall.error, diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 5fe59ef72a2..048fff75196 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -7,6 +7,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' @@ -342,14 +343,18 @@ export async function maybeWriteOutputToFile( } } catch (err) { const message = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + message, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write tool output to file', { toolName, outputPaths: outputFiles.map((file) => file.path), - error: message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed) span.addEvent(TraceEvent.CopilotOutputFileError, { - [TraceAttr.ErrorMessage]: message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 78096cdc61b..4cd2d3f7140 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -33,7 +33,10 @@ import type { StreamEvent, ToolCallState } from '@/lib/copilot/request/types' function makeContext() { const context = createStreamingContext({ runId: 'run-1' }) - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } context.trace = new TraceCollector() return context } @@ -91,6 +94,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('applies the normal saved permission to code with a secret reference', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('function_execute') + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(false) + }) + it('never gates a non-interactive run, which has nobody to answer the prompt', () => { expect( toolCallNeedsApproval('terminal', makeContext(), { interactive: false }, false, runCall) @@ -103,6 +118,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('does not add a secret-specific gate when the permission feature is off', () => { + const context = makeContext() + context.toolPermissions.enabled = false + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(false) + }) + it('gates a resolved integration operation off the frame Go stamped', () => { // gmail_read_v2 is request-local: it is not in the catalog at all, so the // only thing marking it is the awaiting_approval status on the frame. @@ -281,6 +308,23 @@ describe('runGatedToolExecution', () => { expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) }) + 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.params = { language: 'javascript', code: 'return {{API_KEY}}' } + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'allow_chat', + }) + + await gate(context, toolCall, execute, []) + + expect(execute).toHaveBeenCalledTimes(1) + expect(context.toolPermissions.autoAllowed.has('function_execute')).toBe(true) + }) + it('does not suppress later prompts for a one-off allow', async () => { const context = makeContext() const toolCall = makeToolCall() 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 new file mode 100644 index 00000000000..fc8a307b05b --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { FunctionExecute, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import { + projectToolResultForCopilot, + TOOL_RESULT_OMITTED_ERROR, +} from '@/lib/copilot/request/tools/resolved-secret-result' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +function createRegistry(): ResolvedSecretTraceRegistry { + return new ResolvedSecretTraceRegistry([ + { + name: 'SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ]) +} + +describe('projectToolResultForCopilot', () => { + it.each([FunctionExecute.id, RunCode.id])( + 'projects active exact and embedded secrets for %s without mutating runtime output', + (toolName) => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const runtimeResult = { + success: true, + output: { + result: 'secret-value', + stdout: 'prefix-secret-value-suffix', + values: ['safe', 'secret-value'], + }, + } + const runtimeSnapshot = structuredClone(runtimeResult) + + expect(projectToolResultForCopilot(runtimeResult, registry)).toEqual({ + success: true, + output: { + result: '{{SECRET}}', + stdout: 'prefix-{{SECRET}}-suffix', + values: ['safe', '{{SECRET}}'], + }, + }) + expect(runtimeResult).toEqual(runtimeSnapshot) + } + ) + + it('projects both output and error from a failed Function execution', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + + expect( + projectToolResultForCopilot( + { + success: false, + output: { stdout: 'printed secret-value' }, + error: 'Function failed near secret-value', + }, + registry + ) + ).toEqual({ + success: false, + output: { stdout: 'printed {{SECRET}}' }, + error: 'Function failed near {{SECRET}}', + }) + }) + + it('projects secret-bearing object keys and omits content when replacement collides', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + + expect( + projectToolResultForCopilot( + { + success: true, + output: { 'prefix-secret-value': 'safe' }, + }, + registry + ) + ).toEqual({ + success: true, + output: { 'prefix-{{SECRET}}': 'safe' }, + }) + + expect( + projectToolResultForCopilot( + { + success: true, + output: { 'secret-value': 'first', '{{SECRET}}': 'second' }, + }, + registry + ) + ).toEqual({ + success: true, + }) + }) + + it('omits content when one replacement creates another active literal', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'MIDDLE', plaintext: 'B', encryptedValue: 'encrypted-b' }, + { name: 'BRACE', plaintext: '{', encryptedValue: 'encrypted-brace' }, + { name: 'JOINED', plaintext: 'ac', encryptedValue: 'encrypted-ac' }, + ]) + registry.recordResolved('MIDDLE', 'B') + registry.recordResolved('BRACE', '{') + registry.recordResolved('JOINED', 'ac') + + expect(projectToolResultForCopilot({ success: true, output: 'aBc' }, registry)).toEqual({ + success: true, + }) + }) + + it('keeps the control error safe from active one-character values', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'F_SECRET', plaintext: 'F', encryptedValue: 'encrypted-f' }, + ]) + registry.recordResolved('F_SECRET', 'F') + + const projected = projectToolResultForCopilot( + { + success: false, + output: { F: 'first', '': 'second' }, + error: 'F', + }, + registry + ) + + expect(projected.success).toBe(false) + expect(projected).not.toHaveProperty('output') + expect(projected.error).toBeTruthy() + expect(projected.error).not.toContain('F') + }) + + it('does not project transformed values', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const encoded = Buffer.from('secret-value').toString('base64') + + expect( + projectToolResultForCopilot({ success: true, output: { result: encoded } }, registry) + ).toEqual({ success: true, output: { result: encoded } }) + }) + + it('leaves configured but unused values unchanged', () => { + const registry = createRegistry() + const result = { + success: true, + output: { result: 'secret-value', stdout: '' }, + } + + expect(projectToolResultForCopilot(result, registry)).toEqual(result) + }) + + it.each([ + ['missing', undefined], + [ + 'incomplete', + (() => { + const registry = createRegistry() + registry.markIncomplete() + return registry + })(), + ], + ])('fails closed for %s provenance without changing structural fields', (_label, registry) => { + expect( + projectToolResultForCopilot( + { + success: false, + output: { result: 'possibly-secret' }, + error: 'possibly-secret-error', + resources: [{ type: 'file', id: 'file-1', title: 'report.txt' }], + }, + registry + ) + ).toEqual({ + success: false, + error: TOOL_RESULT_OMITTED_ERROR, + }) + }) + + it('projects Copilot-visible resource metadata without changing the runtime result', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const result = { + success: true, + resources: [{ type: 'file' as const, id: 'file-secret-value', title: 'secret-value.txt' }], + } + + expect(projectToolResultForCopilot(result, registry)).toEqual({ + success: true, + resources: [{ type: 'file', id: 'file-secret-value', title: '{{SECRET}}.txt' }], + }) + expect(result.resources[0]).toEqual({ + type: 'file', + id: 'file-secret-value', + title: 'secret-value.txt', + }) + }) + + it('projects every tool result once provenance is active', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const result = { success: true, output: 'secret-value' } + + expect(projectToolResultForCopilot(result, registry)).toEqual({ + success: true, + output: '{{SECRET}}', + }) + expect(result).toEqual({ success: true, output: 'secret-value' }) + }) + + it('omits every tool result when no trusted provenance registry exists', () => { + expect( + projectToolResultForCopilot({ success: true, output: 'possibly-secret' }, undefined) + ).toEqual({ success: true }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts new file mode 100644 index 00000000000..b7a3b1f4e89 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts @@ -0,0 +1,139 @@ +import { isPlainRecord, omit } from '@sim/utils/object' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + projectResolvedSecretContent, + type ResolvedSecretMatcher, + sanitizeResolvedSecretString, +} from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export const TOOL_RESULT_OMITTED_ERROR = 'Tool result omitted' + +function omitContent(result: ToolExecutionResult): ToolExecutionResult { + return omit(result, ['output', 'error', 'resources']) +} + +function resourceContent(resources: MothershipResource[]): Array<{ title: string; path?: string }> { + return resources.map((resource) => ({ + title: resource.title, + ...(resource.path !== undefined ? { path: resource.path } : {}), + })) +} + +function restoreProjectedResources( + resources: MothershipResource[], + projectedContent: unknown +): MothershipResource[] | undefined { + if (!Array.isArray(projectedContent) || projectedContent.length !== resources.length) { + return undefined + } + + const projectedResources: MothershipResource[] = [] + for (let index = 0; index < resources.length; index += 1) { + const content = projectedContent[index] + if ( + !isPlainRecord(content) || + typeof content.title !== 'string' || + (content.path !== undefined && typeof content.path !== 'string') + ) { + return undefined + } + + const resource = resources[index] + projectedResources.push({ + type: resource.type, + id: resource.id, + title: content.title, + ...(content.path !== undefined ? { path: content.path } : {}), + }) + } + + return projectedResources +} + +/** Returns a nonempty control error that cannot contain any active literal. */ +function createSafeControlError(matcher: ResolvedSecretMatcher | undefined): string { + if (!matcher) return TOOL_RESULT_OMITTED_ERROR + + try { + const projected = sanitizeResolvedSecretString(TOOL_RESULT_OMITTED_ERROR, matcher) + if (projected.length > 0 && !containsResolvedSecret(projected, matcher)) return projected + } catch {} + + for (let codePoint = 0x21; codePoint <= 0x10ffff; codePoint += 1) { + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + codePoint = 0xdfff + continue + } + const candidate = String.fromCodePoint(codePoint) + if (!containsResolvedSecret(candidate, matcher)) return candidate + } + + throw new Error('Active secret matcher covers every Unicode scalar') +} + +function omittedResult( + result: ToolExecutionResult, + matcher: ResolvedSecretMatcher | undefined +): ToolExecutionResult { + const structural = omitContent(result) + return result.success ? structural : { ...structural, error: createSafeControlError(matcher) } +} + +/** + * Projects terminal tool content before it can cross back into Copilot. + * Runtime output remains unchanged for raw post-processing and context updates. + */ +export function projectToolResultForCopilot( + result: ToolExecutionResult, + registry: ResolvedSecretTraceRegistry | undefined +): ToolExecutionResult { + if (!registry?.isComplete()) return omittedResult(result, undefined) + + let matcher: ResolvedSecretMatcher | undefined + try { + matcher = createResolvedSecretMatcher(registry.getActiveMatches()) + if (!matcher) return result + + const content: Record = {} + if (Object.hasOwn(result, 'output')) content.output = result.output + if (Object.hasOwn(result, 'error')) content.error = result.error + if (result.resources !== undefined) content.resources = resourceContent(result.resources) + const projection = projectResolvedSecretContent(content, matcher) + if (!projection.safe || !projection.value || typeof projection.value !== 'object') { + return omittedResult(result, matcher) + } + + const projectedContent = projection.value as Record + const projected = omitContent(result) + if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output + if (Object.hasOwn(projectedContent, 'error')) { + projected.error = String(projectedContent.error) + } + if (result.resources !== undefined) { + const resources = restoreProjectedResources(result.resources, projectedContent.resources) + if (!resources) return omittedResult(result, matcher) + projected.resources = resources + } + if (!projected.success && !projected.error) { + projected.error = createSafeControlError(matcher) + } + return projected + } catch { + return omittedResult(result, matcher) + } +} + +/** Projects an error before post-processing can attach it to application logs or OTel events. */ +export function projectToolErrorMessageForCopilot( + error: string, + registry: ResolvedSecretTraceRegistry | undefined +): string { + return ( + projectToolResultForCopilot({ success: false, error }, registry).error ?? + TOOL_RESULT_OMITTED_ERROR + ) +} diff --git a/apps/sim/lib/copilot/request/tools/resources.ts b/apps/sim/lib/copilot/request/tools/resources.ts index 361f4105201..88ee1f01a07 100644 --- a/apps/sim/lib/copilot/request/tools/resources.ts +++ b/apps/sim/lib/copilot/request/tools/resources.ts @@ -29,6 +29,7 @@ export async function handleResourceSideEffects( toolName: string, params: Record | undefined, result: ToolCallResult, + projectedResult: ToolCallResult, chatId: string, onEvent: ((event: StreamEvent) => void | Promise) | undefined, isAborted: () => boolean @@ -57,6 +58,11 @@ export async function handleResourceSideEffects( if (hasDeleteCapability(toolName)) { const deleted = extractDeletedResourcesFromToolResult(toolName, params, result.output) + const projectedDeleted = extractDeletedResourcesFromToolResult( + toolName, + params, + projectedResult.output + ) if (deleted.length > 0) { isDeleteOp = true removedCount = deleted.length @@ -71,13 +77,19 @@ export async function handleResourceSideEffects( }) }) - for (const resource of deleted) { + for (let index = 0; index < deleted.length; index += 1) { if (isAborted()) break + const resource = deleted[index] + const projected = projectedDeleted[index] await onEvent?.({ type: MothershipStreamV1EventType.resource, payload: { op: MothershipStreamV1ResourceOp.remove, - resource: { type: resource.type, id: resource.id, title: resource.title }, + resource: { + type: resource.type, + id: resource.id, + title: projected?.title ?? '', + }, }, }) } @@ -85,12 +97,29 @@ export async function handleResourceSideEffects( } if (!isDeleteOp && !isAborted()) { - const resources = + const rawResources = result.resources && result.resources.length > 0 ? result.resources : isResourceToolName(toolName) ? extractResourcesFromToolResult(toolName, params, result.output) : [] + const projectedResources = + result.resources && result.resources.length > 0 + ? (projectedResult.resources ?? []) + : isResourceToolName(toolName) + ? extractResourcesFromToolResult(toolName, params, projectedResult.output) + : [] + const resources = + projectedResources.length === rawResources.length + ? rawResources.map((resource, index) => ({ + type: resource.type, + id: resource.id, + title: projectedResources[index].title, + ...(projectedResources[index].path !== undefined + ? { path: projectedResources[index].path } + : {}), + })) + : [] if (resources.length > 0) { upsertedCount = resources.length diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index c9ab80ae41b..f90edf31e66 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -2,12 +2,14 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({ +const { mockGetTableById, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ mockGetTableById: vi.fn(), mockReplaceTableRows: vi.fn(), + mockSpanAddEvent: vi.fn(), })) vi.mock('@/lib/table/service', () => ({ @@ -23,7 +25,7 @@ vi.mock('@/lib/copilot/request/otel', () => ({ _name: string, _attrs: Record | undefined, fn: (span: unknown) => Promise - ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }), + ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: mockSpanAddEvent }), })) import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' @@ -32,6 +34,13 @@ import { maybeWriteReadCsvToTable, } from '@/lib/copilot/request/tools/tables' import type { ExecutionContext } from '@/lib/copilot/request/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const tableLogger = vi.mocked(loggerMock.createLogger).mock.results[ + vi + .mocked(loggerMock.createLogger) + .mock.calls.findIndex(([name]) => name === 'CopilotToolResultTables') +]?.value function buildTable(overrides: Partial = {}): TableDefinition { return { @@ -172,6 +181,27 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('Row 1: name is required') }) + + it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + ]) + registry.recordResolved('SECRET', 'secret-value') + mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'secret-value' }] } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.error).toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + }) }) describe('maybeWriteReadCsvToTable', () => { @@ -251,4 +281,25 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('Row 1: name is required') }) + + it('projects active secret literals in CSV-import log and OTel errors', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + ]) + registry.recordResolved('SECRET', 'secret-value') + mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name\nsecret-value' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.error).toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 5d1aaf310a3..053c37de141 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -9,6 +9,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import type { RowData, TableDefinition } from '@/lib/table' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' @@ -151,18 +152,23 @@ export async function maybeWriteOutputToTable( }, } } catch (err) { + const rawMessage = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + rawMessage, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write tool output to table', { toolName, outputTable, - error: toError(err).message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed) span.addEvent(TraceEvent.CopilotTableError, { - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, - error: `Failed to write to table: ${toError(err).message}`, + error: `Failed to write to table: ${rawMessage}`, } } } @@ -281,18 +287,23 @@ export async function maybeWriteReadCsvToTable( }, } } catch (err) { + const rawMessage = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + rawMessage, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write read output to table', { toolName, outputTable, - error: toError(err).message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed) span.addEvent(TraceEvent.CopilotTableError, { - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, - error: `Failed to import into table: ${toError(err).message}`, + error: `Failed to import into table: ${rawMessage}`, } } } diff --git a/apps/sim/lib/copilot/secret-mount-policy.test.ts b/apps/sim/lib/copilot/secret-mount-policy.test.ts new file mode 100644 index 00000000000..49d355cc39d --- /dev/null +++ b/apps/sim/lib/copilot/secret-mount-policy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + applySecretMountPolicy, + normalizeSecretMountPolicy, +} from '@/lib/copilot/secret-mount-policy' + +describe('normalizeSecretMountPolicy', () => { + it('defaults missing legacy policy data to all and fails malformed scopes closed', () => { + expect(normalizeSecretMountPolicy()).toEqual({ secretScope: 'all', mountedSecrets: [] }) + expect( + normalizeSecretMountPolicy({ secretScope: 'unknown', mountedSecrets: ['SECRET'] }) + ).toEqual({ secretScope: 'selected', mountedSecrets: [] }) + }) + + it('canonicalizes a selected names-only allowlist', () => { + expect( + normalizeSecretMountPolicy({ + secretScope: 'selected', + mountedSecrets: [' B ', 'A', 'B', '', 42], + }) + ).toEqual({ secretScope: 'selected', mountedSecrets: ['B', 'A'] }) + }) + + it('preserves selected with an empty list as no access', () => { + expect(normalizeSecretMountPolicy({ secretScope: 'selected' })).toEqual({ + secretScope: 'selected', + mountedSecrets: [], + }) + }) +}) + +describe('applySecretMountPolicy', () => { + it('allows every explicit reference under the all policy', () => { + expect(applySecretMountPolicy(['B', ' A ', 'B'])).toEqual(['B', 'A']) + }) + + it('returns exact explicit references under a selected policy', () => { + expect( + applySecretMountPolicy(['B'], { + secretScope: 'selected', + mountedSecrets: ['A', 'B'], + }) + ).toEqual(['B']) + }) + + it('fails atomically when selected policy denies any reference', () => { + expect(() => + applySecretMountPolicy(['A', 'B'], { + secretScope: 'selected', + mountedSecrets: ['A'], + }) + ).toThrow('Secret access is not allowed for: B') + }) +}) diff --git a/apps/sim/lib/copilot/secret-mount-policy.ts b/apps/sim/lib/copilot/secret-mount-policy.ts new file mode 100644 index 00000000000..3a7305a8e8b --- /dev/null +++ b/apps/sim/lib/copilot/secret-mount-policy.ts @@ -0,0 +1,75 @@ +export type SecretMountScope = 'all' | 'selected' + +export interface SecretMountPolicy { + secretScope: SecretMountScope + mountedSecrets: string[] +} + +export const MAX_SECRET_MOUNT_NAMES = 100 +export const MAX_SECRET_MOUNT_NAME_LENGTH = 1024 + +export const DEFAULT_SECRET_MOUNT_POLICY: SecretMountPolicy = { + secretScope: 'all', + mountedSecrets: [], +} + +interface SecretMountPolicyInput { + secretScope?: unknown + mountedSecrets?: unknown +} + +function normalizeSecretNames(value: unknown): string[] { + if (!Array.isArray(value)) return [] + + const names = new Set() + for (const candidate of value) { + if (typeof candidate !== 'string') continue + const name = candidate.trim() + if (name) names.add(name) + } + return [...names] +} + +/** + * Normalizes persisted or legacy policy data. A missing scope uses the backwards-compatible + * `all` policy; an explicit invalid scope fails closed. Selected policies keep a canonical, + * de-duplicated names-only allowlist. + */ +export function normalizeSecretMountPolicy( + input?: SecretMountPolicyInput | null +): SecretMountPolicy { + if (input?.secretScope === undefined || input.secretScope === 'all') { + return { ...DEFAULT_SECRET_MOUNT_POLICY } + } + + if (input.secretScope !== 'selected') { + return { secretScope: 'selected', mountedSecrets: [] } + } + + return { + secretScope: 'selected', + mountedSecrets: normalizeSecretNames(input.mountedSecrets), + } +} + +/** + * Applies a normalized headless allowlist to explicitly referenced secret + * names. A selected policy denies the whole request when any reference is not + * listed so code never runs with a surprising partial environment. + */ +export function applySecretMountPolicy( + requestedNames: readonly string[], + input?: SecretMountPolicyInput | null +): string[] { + const policy = normalizeSecretMountPolicy(input) + const requested = normalizeSecretNames(requestedNames) + if (policy.secretScope === 'all') return requested + + const allowed = new Set(policy.mountedSecrets) + const denied = requested.filter((name) => !allowed.has(name)) + if (denied.length > 0) { + throw new Error(`Secret access is not allowed for: ${denied.join(', ')}`) + } + + return requested +} diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 2342f31efbe..30b5d8d4f17 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -2,11 +2,13 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const { isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ +const { getToolEntry, isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ + getToolEntry: vi.fn(), isKnownTool: vi.fn(), isSimExecuted: vi.fn(), isClientExecuted: vi.fn(), @@ -17,6 +19,7 @@ const { executeAppTool } = vi.hoisted(() => ({ })) vi.mock('./router', () => ({ + getToolEntry, isKnownTool, isSimExecuted, isClientExecuted, @@ -28,10 +31,87 @@ vi.mock('@/tools', () => ({ import { clearHandlers, executeTool, registerHandler } from './executor' +const toolExecutorLogger = vi.mocked(loggerMock.createLogger).mock.results[ + vi.mocked(loggerMock.createLogger).mock.calls.findIndex(([name]) => name === 'ToolExecutor') +]?.value + describe('copilot tool executor fallback', () => { beforeEach(() => { vi.clearAllMocks() clearHandlers() + getToolEntry.mockReturnValue(undefined) + }) + + it('enforces catalog-required permissions before dispatch and fails closed when absent', async () => { + getToolEntry.mockReturnValue({ requiredPermission: 'write' }) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('function_execute', handler) + + await expect( + executeTool('function_execute', { code: 'return 1' }, { userId: 'user-1', workflowId: '' }) + ).resolves.toEqual({ + success: false, + error: + "Permission denied: function_execute requires write access. You have 'none' permission.", + }) + await expect( + executeTool( + 'function_execute', + { 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.", + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('dispatches catalog-protected tools when the current permission satisfies the requirement', async () => { + getToolEntry.mockReturnValue({ requiredPermission: 'write' }) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true, output: 'ok' }) + registerHandler('function_execute', handler) + + await expect( + executeTool( + 'function_execute', + { code: 'return 1' }, + { userId: 'user-1', workflowId: '', userPermission: 'write' } + ) + ).resolves.toEqual({ success: true, output: 'ok' }) + expect(handler).toHaveBeenCalledOnce() + }) + + it('projects resolved secrets before logging registered handler failures', async () => { + const secret = 'mounted-secret-value' + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-secret' }, + ]) + registry.recordResolved('API_KEY', secret) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + registerHandler('throwing_tool', async () => { + throw new Error(`Provider reflected ${secret}`) + }) + + await expect( + executeTool('throwing_tool', {}, { userId: 'user-1', resolvedSecretTraceRegistry: registry }) + ).resolves.toEqual({ success: false, error: `Provider reflected ${secret}` }) + + expect(toolExecutorLogger?.error).toHaveBeenCalledWith('Tool execution failed', { + toolId: 'throwing_tool', + error: 'Provider reflected {{API_KEY}}', + abortSignalAborted: false, + }) + expect(JSON.stringify(toolExecutorLogger?.error.mock.calls)).not.toContain(secret) }) it('falls back to app tool executor for dynamic sim tools', async () => { diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 3b9efa8438b..6488b695f25 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -1,8 +1,10 @@ import { createLogger } from '@sim/logger' +import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { executeTool as executeAppTool } from '@/tools' -import { isClientExecuted, isKnownTool, isSimExecuted } from './router' +import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { ToolCallDescriptor, ToolExecutionContext, @@ -44,6 +46,20 @@ export async function executeTool( params: Record, context: ToolExecutionContext ): Promise { + const requiredPermission = getToolEntry(toolId)?.requiredPermission + if ( + requiredPermission && + !permissionSatisfies( + (context.userPermission ?? null) as PermissionType | null, + requiredPermission + ) + ) { + return { + success: false, + error: `Permission denied: ${toolId} requires ${requiredPermission} access. You have '${context.userPermission ?? 'none'}' permission.`, + } + } + const normalizedParams = normalizeToolParams(toolId, params, context) // Client-routed tools (e.g. run_workflow) are normally executed in the browser and never @@ -82,7 +98,7 @@ export async function executeTool( const message = toError(error).message logger.error('Tool execution failed', { toolId, - error: message, + error: projectToolErrorMessageForCopilot(message, context.resolvedSecretTraceRegistry), abortSignalAborted: context.abortSignal?.aborted ?? false, }) return { success: false, error: message } diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index a08fda51758..93db4b4eb23 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -1,5 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export interface ToolExecutionContext { @@ -24,7 +25,9 @@ export interface ToolExecutionContext { abortSignal?: AbortSignal userTimezone?: string userPermission?: string - decryptedEnvVars?: Record + secretMountPolicy?: SecretMountPolicy + /** Undefined uses the execution actor; null explicitly disables raw secret mounting. */ + secretActorUserId?: string | null resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry } diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts index ef66300b447..b99cb55cf98 100644 --- a/apps/sim/lib/copilot/tools/client/completion.ts +++ b/apps/sim/lib/copilot/tools/client/completion.ts @@ -26,10 +26,12 @@ export async function reportClientToolCompletion( toolCallId: string, status: AsyncConfirmationStatus, message?: string, - data?: AsyncCompletionData + data?: AsyncCompletionData, + executionId?: string ): Promise { const basePayload = { toolCallId, + ...(executionId ? { executionId } : {}), status, message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), ...(data !== undefined ? { data } : {}), @@ -61,6 +63,7 @@ export async function reportClientToolCompletion( const retryResponse = await send( JSON.stringify({ toolCallId, + ...(executionId ? { executionId } : {}), status, message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), data: dataWithoutLogs, 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 ac5fff66d70..873497f3407 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 @@ -102,6 +102,7 @@ import { describe('run tool execution cancellation', () => { beforeEach(() => { vi.clearAllMocks() + window.sessionStorage.clear() getCurrentExecutionId.mockReturnValue(null) getWorkflowEntries.mockReturnValue([]) loadExecutionPointer.mockResolvedValue(null) @@ -133,6 +134,7 @@ describe('run tool execution cancellation', () => { it('can report a manual stop using the explicit toolCallId override', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) + getCurrentExecutionId.mockReturnValueOnce('exec-manual') await reportManualRunToolStop('wf-1', 'tool-override') @@ -143,13 +145,14 @@ describe('run tool execution cancellation', () => { body: expect.stringContaining('"toolCallId":"tool-override"'), }) ) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-manual"') }) it('prefers workflow_input, forwards triggerBlockId, and respects useDeployedState', async () => { executeWorkflowWithFullLogging.mockResolvedValueOnce({ success: true, - output: { ok: true }, - logs: [], + output: { token: 'raw-secret-output' }, + logs: [{ output: 'raw-secret-log' }], }) executeRunToolOnClient('tool-2', 'run_workflow', { @@ -172,6 +175,41 @@ describe('run tool execution cancellation', () => { useDraftState: false, }) ) + const executionId = executeWorkflowWithFullLogging.mock.calls[0][0].executionId + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining(`"executionId":"${executionId}"`), + }) + ) + }) + expect(fetch.mock.calls[0][1]?.body).not.toContain('raw-secret') + }) + + it('reports the workflow execution id with terminal error results', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + executeWorkflowWithFullLogging.mockResolvedValueOnce({ + success: false, + output: {}, + error: 'workflow failed', + logs: [], + }) + + executeRunToolOnClient('tool-error', 'run_workflow', { workflowId: 'wf-1' }) + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining('"status":"error"'), + }) + ) + }) + const executionId = executeWorkflowWithFullLogging.mock.calls[0][0].executionId + expect(fetchMock.mock.calls[0][1]?.body).toContain(`"executionId":"${executionId}"`) + expect(fetchMock.mock.calls[0][1]?.body).not.toContain('workflow failed') }) it('treats a tab-local execution pointer as handled in background', async () => { @@ -197,6 +235,33 @@ describe('run tool execution cancellation', () => { body: expect.stringContaining('"status":"background"'), }) ) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-existing"') + }) + + it('strips raw payloads from legacy pending completion recovery', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + loadExecutionPointer.mockResolvedValueOnce({ + workflowId: 'wf-1', + executionId: 'exec-existing', + lastEventId: 7, + }) + window.sessionStorage.setItem( + 'sim:copilot:run-tool-completion:tool-recovered', + JSON.stringify({ + status: 'success', + message: 'legacy raw-secret-error', + data: { output: 'legacy raw-secret-output', logs: ['legacy raw-secret-log'] }, + executionId: 'exec-existing', + }) + ) + + await expect(bindRunToolToExecution('tool-recovered', 'wf-1')).resolves.toBe(true) + + const body = fetchMock.mock.calls[0][1]?.body + expect(body).toContain('"status":"success"') + expect(body).toContain('"executionId":"exec-existing"') + expect(body).not.toContain('raw-secret') }) it('does not recover from shared console rows without a tab-local pointer', async () => { @@ -241,6 +306,7 @@ describe('run tool execution cancellation', () => { }) expect(clearExecutionPointer).not.toHaveBeenCalled() expect(setIsExecuting).toHaveBeenCalledWith('wf-1', false) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-1"') expect(fetchMock).not.toHaveBeenCalledWith( '/api/copilot/confirm', expect.objectContaining({ 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 cd66f64031f..62a7bc15110 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -1,9 +1,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, - type AsyncCompletionData, type AsyncConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' @@ -17,6 +17,7 @@ import { CompletionReportError, reportClientToolCompletion as reportCompletion, } from '@/lib/copilot/tools/client/completion' +import { getWorkflowToolCompletionMessage } from '@/lib/copilot/tools/workflow-tools' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { SSEEventHandlerError, SSEStreamInterruptedError } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' @@ -36,8 +37,7 @@ const PENDING_COMPLETION_STORAGE_PREFIX = 'sim:copilot:run-tool-completion:' interface PendingCompletionReport { status: AsyncConfirmationStatus - message?: string - data?: AsyncCompletionData + executionId?: string } function resolveWorkflowInput(params: Record): unknown { @@ -141,8 +141,11 @@ export async function bindRunToolToExecution( await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + pendingCompletion.status === MothershipStreamV1ToolOutcome.cancelled + ? { reason: 'user_cancelled', cancelledByUser: true } + : undefined, + pendingCompletion.executionId ?? pointer.executionId ) clearPendingCompletionReport(toolCallId) } catch (error) { @@ -160,12 +163,9 @@ export async function bindRunToolToExecution( await reportCompletion( toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.background, - 'Client recovered an existing workflow execution; continuing in background.', - { - workflowId, - executionId: pointer.executionId, - lastEventId: pointer.lastEventId, - } + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background), + undefined, + pointer.executionId ) } catch (error) { logger.warn('[RunTool] Failed to report recovered execution as background', { @@ -186,8 +186,8 @@ export async function bindRunToolToExecution( * Mirrors staging's RunWorkflowClientTool.handleAccept(): * 1. Execute via executeWorkflowWithFullLogging * 2. Update client tool state directly (success/error) - * 3. Report completion to server via /api/copilot/confirm (Redis), - * where the server-side handler picks it up and tells Go + * 3. Report a structural completion notification; the server restores the + * bound execution result from its log before resuming Copilot */ export function executeRunToolOnClient( toolCallId: string, @@ -246,15 +246,19 @@ export async function reportManualRunToolStop( manuallyStoppedToolCallIds.add(toolCallId) } + const executionId = + useExecutionStore.getState().getCurrentExecutionId(workflowId) ?? + (await loadExecutionPointer(workflowId).catch(() => null))?.executionId + await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.cancelled, - 'Workflow execution was stopped manually by the user.', + getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.cancelled), { reason: 'user_cancelled', cancelledByUser: true, - workflowId, - } + }, + executionId ) } @@ -347,7 +351,6 @@ async function doExecuteRunTool( const executionId = generateId() setCurrentExecutionId(targetWorkflowId, executionId) saveExecutionPointer({ workflowId: targetWorkflowId, executionId, lastEventId: 0 }) - const executionStartTime = new Date().toISOString() const releaseVisibleExecutionForBackground = () => { const { setCurrentExecutionId: clearExecId, setActiveBlocks } = useExecutionStore.getState() if (activeRunToolByWorkflowId.get(targetWorkflowId) === toolCallId) { @@ -360,12 +363,15 @@ async function doExecuteRunTool( const onPageHide = () => { if (manuallyStoppedToolCallIds.has(toolCallId)) return + const activeExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId navigator.sendBeacon( COPILOT_CONFIRM_API_PATH, new Blob( [ JSON.stringify({ toolCallId, + executionId: activeExecutionId, status: 'background', message: 'Client disconnected, execution continuing server-side', }), @@ -397,6 +403,7 @@ async function doExecuteRunTool( workflowId: targetWorkflowId, workflowInput, executionId, + copilotToolCallId: toolCallId, overrideTriggerType: 'copilot', triggerBlockId, useDraftState, @@ -406,28 +413,16 @@ async function doExecuteRunTool( preserveExecutionOnTerminal: true, }) + const completedExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + // Determine success (same logic as staging's RunWorkflowClientTool) - let succeeded = true - let errorMessage: string | undefined - try { - if (result && typeof result === 'object' && 'success' in (result as any)) { - succeeded = Boolean((result as any).success) - if (!succeeded) { - errorMessage = (result as any)?.error || (result as any)?.output?.error - } - } else if ( - result && - typeof result === 'object' && - 'execution' in (result as any) && - (result as any).execution - ) { - succeeded = Boolean((result as any).execution.success) - if (!succeeded) { - errorMessage = - (result as any).execution?.error || (result as any).execution?.output?.error - } - } - } catch {} + const succeeded = + isPlainRecord(result) && Object.hasOwn(result, 'success') + ? Boolean(result.success) + : isPlainRecord(result) && isPlainRecord(result.execution) + ? Boolean(result.execution.success) + : true if (manuallyStoppedToolCallIds.has(toolCallId)) { logger.info('[RunTool] Skipping generic completion — already manually stopped', { @@ -438,31 +433,30 @@ async function doExecuteRunTool( logger.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName }) const pendingCompletion = { status: MothershipStreamV1ToolOutcome.success, - message: `Workflow execution completed. Started at: ${executionStartTime}`, - data: buildResultData(result), + executionId: completedExecutionId, } savePendingCompletionReport(toolCallId, pendingCompletion) await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) } else { - const msg = errorMessage || 'Workflow execution failed' - logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg }) + logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName }) const pendingCompletion = { status: MothershipStreamV1ToolOutcome.error, - message: msg, - data: buildResultData(result), + executionId: completedExecutionId, } savePendingCompletionReport(toolCallId, pendingCompletion) await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) } @@ -489,7 +483,9 @@ async function doExecuteRunTool( await reportCompletion( toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.background, - 'Client lost local stream processing; workflow execution may still be continuing server-side.' + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background), + undefined, + err.executionId ?? executionId ) return } @@ -504,7 +500,15 @@ async function doExecuteRunTool( return } logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) - await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, msg) + const failedExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + await reportCompletion( + toolCallId, + MothershipStreamV1ToolOutcome.error, + getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.error), + undefined, + failedExecutionId + ) } } finally { if (typeof window !== 'undefined') { @@ -529,34 +533,3 @@ async function doExecuteRunTool( } } } - -/** - * Extract a structured result payload from the raw execution result - * for the LLM to see the actual workflow output. - */ -function buildResultData(result: unknown): Record | undefined { - if (!result || typeof result !== 'object') return undefined - - const r = result as Record - - if ('success' in r) { - return { - success: r.success, - output: r.output, - logs: r.logs, - error: r.error, - } - } - - if ('execution' in r && r.execution && typeof r.execution === 'object') { - const exec = r.execution as Record - return { - success: exec.success, - output: exec.output, - logs: exec.logs, - error: exec.error, - } - } - - return undefined -} diff --git a/apps/sim/lib/copilot/tools/handlers/context.ts b/apps/sim/lib/copilot/tools/handlers/context.ts index 02ba4d078be..06f1c05716c 100644 --- a/apps/sim/lib/copilot/tools/handlers/context.ts +++ b/apps/sim/lib/copilot/tools/handlers/context.ts @@ -3,8 +3,11 @@ import { type BillingAttributionSnapshot, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + type CopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import type { ExecutionContext } from '@/lib/copilot/request/types' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { getWorkflowById } from '@/lib/workflows/utils' export async function prepareExecutionContext( @@ -13,14 +16,14 @@ export async function prepareExecutionContext( chatId?: string, options?: { workspaceId?: string - decryptedEnvVars?: Record + environmentContext?: CopilotEnvironmentContext billingAttribution?: BillingAttributionSnapshot } ): Promise { const workspaceId = options?.workspaceId ?? (await getWorkflowById(workflowId))?.workspaceId ?? undefined - const [decryptedEnvVars, billingAttribution] = await Promise.all([ - options?.decryptedEnvVars ?? getEffectiveDecryptedEnv(userId, workspaceId), + const [environmentContext, billingAttribution] = await Promise.all([ + options?.environmentContext ?? prepareCopilotEnvironmentContext(userId, workspaceId), options?.billingAttribution ? Promise.resolve(assertBillingAttributionSnapshot(options.billingAttribution)) : workspaceId @@ -39,7 +42,7 @@ export async function prepareExecutionContext( workflowId, workspaceId, chatId, - decryptedEnvVars, + ...environmentContext, billingAttribution, } } 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 0320fb41f35..cef1cf7e4a9 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { encryptionMock, encryptionMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -20,6 +22,7 @@ const { mockFetchServableWorkspaceFileBuffer, mockGetSandboxWorkspaceFilePath, mockListWorkspaceFileFolders, + mockMaterializeCopilotCodeSecrets, } = vi.hoisted(() => ({ mockIsFeatureEnabled: vi.fn(), mockGetTableById: vi.fn(), @@ -36,9 +39,11 @@ const { mockFetchServableWorkspaceFileBuffer: vi.fn(), mockGetSandboxWorkspaceFilePath: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), + mockMaterializeCopilotCodeSecrets: vi.fn(), })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/table/service', () => ({ getTableById: mockGetTableById, listTables: mockListTables, @@ -68,8 +73,14 @@ vi.mock('@/lib/copilot/vfs/path-utils', () => ({ decodeVfsPathSegments: (p: string) => p.split('/'), encodeVfsPathSegments: (s: string[]) => s.join('/'), })) +vi.mock('@/lib/copilot/tools/secret-mount-materializer.server', () => ({ + CopilotCodeSecretAccessError: class CopilotCodeSecretAccessError extends Error {}, + materializeCopilotCodeSecrets: mockMaterializeCopilotCodeSecrets, +})) +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const table = { id: 'tbl_1', @@ -93,25 +104,251 @@ describe('executeFunctionExecute trace-secret provenance', () => { beforeEach(() => { vi.clearAllMocks() mockExecuteTool.mockResolvedValue({ success: true }) + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ envVars: {}, catalogEntries: [] }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) }) - it('forwards the registry only through server execution options', async () => { - const resolvedSecretTraceRegistry = { recordResolved: vi.fn() } - - await executeFunctionExecute({ code: 'return {{API_KEY}}' }, { - userId: 'u1', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } as never) + it('mounts only explicit references and imports active provenance out of band', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') + return { success: true, output: { result: 'secret-value' } } + }) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + const runtimeResult = await executeFunctionExecute( + { + code: 'return {{API_KEY}}', + envVars: { ATTACKER_KEY: 'attacker-value' }, + secretScope: 'all', + mountedSecrets: ['ATTACKER_KEY'], + _context: { resolvedSecretTraceRegistry: 'attacker-value' }, + }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) expect(mockExecuteTool).toHaveBeenCalledWith( 'function_execute', - expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), - { resolvedSecretTraceRegistry } + expect.objectContaining({ + envVars: { API_KEY: 'secret-value' }, + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + _context: expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), + }), + { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) const appParams = mockExecuteTool.mock.calls[0]?.[1] as Record - expect(appParams._context).not.toHaveProperty('resolvedSecretTraceRegistry') expect(JSON.stringify(appParams)).not.toContain('resolvedSecretTraceRegistry') + expect(runtimeResult).toEqual({ success: true, output: { result: 'secret-value' } }) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + }) + + it('does not mount direct environment-map or shell-variable access', async () => { + await executeFunctionExecute( + { code: 'return environmentVariables.API_KEY + "$API_KEY"' }, + { userId: 'u1', workflowId: '', workspaceId: 'ws_1' } + ) + + expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }), + { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } + ) + }) + + it('returns the raw runtime result when provenance import fails', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + const runtimeResult = { success: true, output: { result: 'secret-value' } } + mockExecuteTool.mockResolvedValue(runtimeResult) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([], { + userId: 'u1', + workspaceId: 'ws_1', + }) + vi.spyOn(resolvedSecretTraceRegistry, 'importProvenance').mockRejectedValueOnce( + new Error('provenance import failed') + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).resolves.toBe(runtimeResult) + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + }) + + it('fails parallel projections closed until exact mounted provenance is active', async () => { + let completeMaterialization: ((value: unknown) => void) | undefined + mockMaterializeCopilotCodeSecrets.mockReturnValueOnce( + new Promise((resolve) => { + completeMaterialization = resolve + }) + ) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') + return { success: true, output: { result: 'secret-value' } } + }) + + const execution = executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot( + { success: true, output: { result: 'secret-value' } }, + resolvedSecretTraceRegistry + ) + ).toEqual({ success: true }) + + completeMaterialization?.({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + await execution + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + expect(mockExecuteTool).toHaveBeenCalledOnce() + }) + + it('does not activate a mounted reference when the Function route rejects before resolution', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + mockExecuteTool.mockResolvedValueOnce({ + success: false, + error: 'Too many sandbox output files requested', + }) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).resolves.toEqual({ + success: false, + error: 'Too many sandbox output files requested', + }) + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) + }) + + it('releases pending provenance without activation when mounting is denied', async () => { + mockMaterializeCopilotCodeSecrets.mockRejectedValueOnce(new Error('mount denied')) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).rejects.toThrow('mount denied') + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) + expect(mockExecuteTool).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index e064b40d579..45634e9792d 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,4 +1,11 @@ import { createLogger } from '@sim/logger' +import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { + CopilotCodeSecretAccessError, + type MaterializedCopilotCodeSecrets, + materializeCopilotCodeSecrets, +} from '@/lib/copilot/tools/secret-mount-materializer.server' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -23,8 +30,9 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' -import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types' const logger = createLogger('CopilotFunctionExecute') @@ -451,67 +459,112 @@ export async function resolveInputFiles( return sandboxFiles } +async function importMountedProvenance( + source: ResolvedSecretTraceRegistry, + target: ResolvedSecretTraceRegistry | undefined +): Promise { + if (!target) return + + try { + const imported = await target.importProvenance(source.exportProvenance(), { trusted: true }) + if (!imported) target.markIncomplete() + } catch { + target.markIncomplete() + } +} + export async function executeFunctionExecute( params: Record, context: ToolExecutionContext ): Promise { const enrichedParams = { ...params } - - if (context.decryptedEnvVars && Object.keys(context.decryptedEnvVars).length > 0) { - enrichedParams.envVars = { - ...context.decryptedEnvVars, - ...((enrichedParams.envVars as Record) || {}), + const requestedNames = applySecretMountPolicy( + extractCodeSecretNames(params.code, params.language), + context.secretMountPolicy + ) + const completePendingActivation = + requestedNames.length > 0 + ? context.resolvedSecretTraceRegistry?.beginPendingActivation() + : undefined + let mountedRegistry: ResolvedSecretTraceRegistry | undefined + + try { + const secretActorUserId = + context.secretActorUserId === undefined ? context.userId : context.secretActorUserId + let mounted: MaterializedCopilotCodeSecrets = { envVars: {}, catalogEntries: [] } + if (requestedNames.length > 0) { + if (!secretActorUserId) { + throw new CopilotCodeSecretAccessError('Secret access is unavailable for this Copilot run') + } + if (!context.workspaceId) { + throw new CopilotCodeSecretAccessError( + 'A workspace is required to mount secrets into Copilot code' + ) + } + mounted = await materializeCopilotCodeSecrets({ + actorUserId: secretActorUserId, + workspaceId: context.workspaceId, + requestedNames, + }) } - } + mountedRegistry = new ResolvedSecretTraceRegistry(mounted.catalogEntries, { + userId: secretActorUserId ?? context.userId, + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + }) - if (context.workspaceId) { - const inputs = enrichedParams.inputs as - | { - files?: CanonicalFileInput[] - directories?: CanonicalDirectoryInput[] - tables?: CanonicalTableInput[] + enrichedParams.envVars = mounted.envVars + enrichedParams.secretScope = 'selected' + enrichedParams.mountedSecrets = requestedNames + + if (context.workspaceId) { + const inputs = enrichedParams.inputs as + | { + files?: CanonicalFileInput[] + directories?: CanonicalDirectoryInput[] + tables?: CanonicalTableInput[] + } + | undefined + const inputFiles = [ + ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), + ...(inputs?.files ?? []), + ] + const inputDirectories = inputs?.directories ?? [] + const inputTables = [ + ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), + ...(inputs?.tables ?? []), + ] + + if (inputFiles?.length || inputTables?.length || inputDirectories.length) { + const resolved = await resolveInputFiles( + context.workspaceId, + inputFiles, + inputTables, + inputDirectories + ) + if (resolved.length > 0) { + const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] + enrichedParams._sandboxFiles = [...existing, ...resolved] } - | undefined - const inputFiles = [ - ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), - ...(inputs?.files ?? []), - ] - const inputDirectories = inputs?.directories ?? [] - const inputTables = [ - ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), - ...(inputs?.tables ?? []), - ] - - if (inputFiles?.length || inputTables?.length || inputDirectories.length) { - const resolved = await resolveInputFiles( - context.workspaceId, - inputFiles, - inputTables, - inputDirectories - ) - if (resolved.length > 0) { - const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] - enrichedParams._sandboxFiles = [...existing, ...resolved] } } - } - enrichedParams._context = { - ...(typeof enrichedParams._context === 'object' && enrichedParams._context !== null - ? (enrichedParams._context as object) - : {}), - userId: context.userId, - workflowId: context.workflowId, - workspaceId: context.workspaceId, - chatId: context.chatId, - executionId: context.executionId, - runId: context.runId, - enforceCredentialAccess: true, - } + enrichedParams._context = { + userId: context.userId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + chatId: context.chatId, + executionId: context.executionId, + runId: context.runId, + enforceCredentialAccess: true, + } - return context.resolvedSecretTraceRegistry - ? executeAppTool('function_execute', enrichedParams, { - resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, - }) - : executeAppTool('function_execute', enrichedParams) + return await executeAppTool('function_execute', enrichedParams, { + resolvedSecretTraceRegistry: mountedRegistry, + }) + } finally { + if (mountedRegistry) { + await importMountedProvenance(mountedRegistry, context.resolvedSecretTraceRegistry) + } + completePendingActivation?.() + } } diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 097794dac06..b7d1559d44d 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -138,6 +138,7 @@ vi.mock('../access', () => ({ getDefaultWorkspaceId: vi.fn(), })) +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' import { performUpdateWorkflow } from '@/lib/workflows/orchestration' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' @@ -716,6 +717,61 @@ describe('Copilot workflow execution billing attribution', () => { expect(JSON.stringify(result)).not.toContain('encrypted-secret') }) + it('fails concurrent tool-result projection closed until child provenance is imported', async () => { + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + const context: ExecutionContext = { + ...executionContext, + resolvedSecretTraceRegistry: registry, + } + let resolveExecution!: (value: unknown) => void + let markExecutionStarted!: () => void + const executionStarted = new Promise((resolve) => { + markExecutionStarted = resolve + }) + executeWorkflowMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveExecution = resolve + markExecutionStarted() + }) + ) + + const execution = executeRunWorkflow( + { workflowId: 'workflow-1', useMockPayload: true }, + context + ) + await executionStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) + ).not.toHaveProperty('output') + + resolveExecution({ + success: true, + output: { value: 'secret-value' }, + logs: [], + metadata: { executionId: 'new-execution-1' }, + executionState: { + resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) + ).toMatchObject({ output: { value: '{{API_KEY}}' } }) + }) + it('marks provenance incomplete when child execution returns no trusted state', async () => { const registry = new ResolvedSecretTraceRegistry() const context: ExecutionContext = { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 4a8823cd717..260bf73fbd2 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -95,10 +95,12 @@ async function executeCopilotWorkflowTarget(params: { params.workflow.workspaceId, childExecutionId ) + const trustedInitialResolvedSecretTraceProvenance = + params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) + const completePendingActivation = + params.context.resolvedSecretTraceRegistry?.beginPendingActivation() try { - const trustedInitialResolvedSecretTraceProvenance = - params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) const result = await executeWorkflow( params.workflow, generateRequestId(), @@ -139,6 +141,8 @@ async function executeCopilotWorkflowTarget(params: { await releaseExecutionSlot(childExecutionId) } throw error + } finally { + completePendingActivation?.() } } diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts new file mode 100644 index 00000000000..14313766c5d --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts @@ -0,0 +1,456 @@ +/** + * @vitest-environment node + */ +import { credential, environment, workspaceEnvironment } from '@sim/db/schema' +import { + dbChainMockFns, + encryptionMock, + encryptionMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { or } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +import { + CopilotCodeSecretAccessError, + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, + materializeCopilotCodeSecrets, +} from '@/lib/copilot/tools/secret-mount-materializer.server' + +interface CredentialRow { + type: 'env_personal' | 'env_workspace' + envKey: string + envOwnerUserId: string | null + role: 'admin' | 'member' | null + status: 'active' | 'pending' | 'revoked' | null + updatedAt: Date + encryptedValue: string | null + encryptedValueBytes: number | null +} + +function queueSources(input: { + personal?: Record + personalOverLimit?: string[] + workspace?: Record + workspaceOverLimit?: string[] + credentials?: CredentialRow[] +}): void { + queueTableRows(environment, [ + { variables: input.personal ?? {}, overLimitNames: input.personalOverLimit ?? [] }, + ]) + queueTableRows(workspaceEnvironment, [ + { variables: input.workspace ?? {}, overLimitNames: input.workspaceOverLimit ?? [] }, + ]) + queueTableRows(credential, input.credentials ?? []) +} + +function credentialRow( + overrides: Partial & Pick +): CredentialRow { + return { + envOwnerUserId: null, + role: null, + status: null, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + encryptedValue: null, + encryptedValueBytes: null, + ...overrides, + } +} + +function mockSqlText(value: unknown): string { + if (typeof value !== 'object' || value === null || !('toSQL' in value)) { + throw new Error('Expected a mock SQL fragment') + } + const toSQL = value.toSQL + if (typeof toSQL !== 'function') throw new Error('Expected a mock SQL renderer') + const rendered = toSQL.call(value) as { sql?: unknown } + if (typeof rendered.sql !== 'string') throw new Error('Expected rendered SQL text') + return rendered.sql +} + +describe('materializeCopilotCodeSecrets', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + it('mounts the actor own personal secret', async () => { + queueSources({ personal: { API_KEY: 'personal-cipher' } }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).resolves.toEqual({ + envVars: { API_KEY: 'plain:personal-cipher' }, + catalogEntries: [ + { name: 'API_KEY', plaintext: 'plain:personal-cipher', encryptedValue: 'personal-cipher' }, + ], + }) + }) + + it('mounts an own __proto__ secret as data without mutating record prototypes', async () => { + queueSources({ personal: Object.fromEntries([['__proto__', 'personal-cipher']]) }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['__proto__'], + }) + + expect(Object.hasOwn(result.envVars, '__proto__')).toBe(true) + expect(result.envVars.__proto__).toBe('plain:personal-cipher') + expect(Object.getPrototypeOf(result.envVars)).toBe(Object.prototype) + }) + + it('casts stored JSON values before using JSONB operators', async () => { + queueSources({ personal: { API_KEY: 'personal-cipher' } }) + + await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + for (const [selection] of dbChainMockFns.select.mock.calls.slice(0, 2)) { + const fields = selection as Record + expect(mockSqlText(fields.variables)).toContain("coalesce(?::jsonb, '{}'::jsonb)") + expect(mockSqlText(fields.overLimitNames)).toContain("coalesce(?::jsonb, '{}'::jsonb)") + } + + const personalCredentialPredicate = vi.mocked(or).mock.calls[0]?.[1] + expect(mockSqlText(personalCredentialPredicate)).toContain( + "coalesce(?::jsonb, '{}'::jsonb) ? ?" + ) + }) + + it('lets a workspace admin mount workspace secrets with workspace precedence', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspace: { API_KEY: 'workspace-cipher' }, + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' }) + }) + + it('lets an active per-secret admin mount a workspace secret', async () => { + queueSources({ + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'admin', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' }) + }) + + it.each([ + ['member', 'active'], + ['admin', 'revoked'], + ['admin', 'pending'], + ] as const)('denies a workspace secret for a %s/%s credential grant', async (role, status) => { + queueSources({ + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [credentialRow({ type: 'env_workspace', envKey: 'API_KEY', role, status })], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toBeInstanceOf(CopilotCodeSecretAccessError) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('denies workspace secrets when the actor has zero credential grants', async () => { + queueSources({ workspace: { API_KEY: 'workspace-cipher' }, credentials: [] }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Copilot code cannot access the requested secret: API_KEY') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('lets an authorized personal value win over an unauthorized same-name workspace value', async () => { + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'member', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:personal-cipher' }) + }) + + it('lets an authorized personal value win over an unauthorized over-limit workspace value', async () => { + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspaceOverLimit: ['API_KEY'], + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'member', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:personal-cipher' }) + }) + + it('does not fall back when an authorized workspace value exceeds the encrypted byte limit', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspaceOverLimit: ['API_KEY'], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('does not fall back when the actor own personal value exceeds the encrypted byte limit', async () => { + queueSources({ + personalOverLimit: ['API_KEY'], + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'API_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValue: 'shared-cipher', + encryptedValueBytes: 13, + }), + ], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('mounts another owner personal secret only for an active per-secret admin', async () => { + queueSources({ + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'SHARED_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValue: 'shared-cipher', + encryptedValueBytes: 13, + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['SHARED_KEY'], + }) + + expect(result.envVars).toEqual({ SHARED_KEY: 'plain:shared-cipher' }) + }) + + it('uses the current encrypted value on every call so rotation is observed', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ workspace: { API_KEY: 'rotated-cipher' } }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:rotated-cipher' }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledWith('rotated-cipher') + }) + + it('fails atomically for missing or deleted names before decrypting authorized values', async () => { + queueSources({ personal: { ALLOWED: 'allowed-cipher' } }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['ALLOWED', 'DELETED'], + }) + ).rejects.toThrow('Copilot code cannot access the requested secret: DELETED') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('fails the whole call when decryption fails', async () => { + queueSources({ personal: { API_KEY: 'broken-cipher' } }) + encryptionMockFns.mockDecryptSecret.mockRejectedValue(new Error('decrypt failed')) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('One or more requested secrets could not be decrypted') + }) + + it('fails the whole call when mounted plaintext exceeds the byte budget', async () => { + queueSources({ personal: { API_KEY: 'large-cipher' } }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'x'.repeat(64 * 1024 + 1) }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + }) + + it('fails the whole call when an authorized shared personal ciphertext exceeds the byte limit', async () => { + queueSources({ + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'API_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValueBytes: 512 * 1024 + 1, + }), + ], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects over-limit requests before access checks, database reads, or decryption', async () => { + const requestedNames = Array.from( + { length: MAX_SECRET_MOUNT_NAMES + 1 }, + (_, index) => `SECRET_${index}` + ) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames, + }) + ).rejects.toThrow(`at most ${MAX_SECRET_MOUNT_NAMES} secrets`) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects overlong names before access checks, database reads, or decryption', async () => { + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['S'.repeat(MAX_SECRET_MOUNT_NAME_LENGTH + 1)], + }) + ).rejects.toThrow(`at most ${MAX_SECRET_MOUNT_NAME_LENGTH} characters`) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts new file mode 100644 index 00000000000..47d81467c8f --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts @@ -0,0 +1,312 @@ +import { db } from '@sim/db' +import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' +import { and, desc, eq, inArray, or, sql } from 'drizzle-orm' +import type { AnyPgColumn } from 'drizzle-orm/pg-core' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' +import { decryptSecret } from '@/lib/core/security/encryption' +import { setRecordValue } from '@/lib/core/utils/records' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import type { ResolvedSecretTraceCatalogEntry } from '@/executor/utils/resolved-secret-trace-registry' + +export { MAX_SECRET_MOUNT_NAME_LENGTH, MAX_SECRET_MOUNT_NAMES } + +const MAX_SECRET_MOUNT_ENCRYPTED_BYTES = 512 * 1024 +const MAX_SECRET_MOUNT_PLAINTEXT_BYTES = 64 * 1024 +const MAX_SECRET_MOUNT_TOTAL_PLAINTEXT_BYTES = 256 * 1024 + +interface CredentialAccessRow { + type: 'env_personal' | 'env_workspace' + envKey: string + envOwnerUserId: string | null + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + updatedAt: Date + encryptedValue: string | null + encryptedValueBytes: number | null +} + +interface AuthorizedEncryptedSecret { + name: string + encryptedValue: string +} + +export interface MaterializedCopilotCodeSecrets { + envVars: Record + catalogEntries: ResolvedSecretTraceCatalogEntry[] +} + +export class CopilotCodeSecretAccessError extends Error { + constructor(message: string) { + super(message) + this.name = 'CopilotCodeSecretAccessError' + } +} + +function normalizeRequestedNames(names: readonly string[]): string[] { + const normalized: string[] = [] + const seen = new Set() + for (const name of names) { + if (name.length === 0 || seen.has(name)) continue + if (name.length > MAX_SECRET_MOUNT_NAME_LENGTH) { + throw new CopilotCodeSecretAccessError( + `Copilot secret names may be at most ${MAX_SECRET_MOUNT_NAME_LENGTH} characters` + ) + } + seen.add(name) + normalized.push(name) + } + if (normalized.length > MAX_SECRET_MOUNT_NAMES) { + throw new CopilotCodeSecretAccessError( + `Copilot code may request at most ${MAX_SECRET_MOUNT_NAMES} secrets per call` + ) + } + return normalized +} + +function encryptedVariables(row: { variables: unknown } | undefined): Record { + if (!row?.variables || typeof row.variables !== 'object' || Array.isArray(row.variables)) + return {} + const result: Record = {} + for (const [name, value] of Object.entries(row.variables)) { + if (typeof value === 'string') setRecordValue(result, name, value) + } + return result +} + +function requestedVariables(column: AnyPgColumn, names: readonly string[]) { + const keys = sql.join( + names.map((name) => sql`${name}`), + sql`, ` + ) + return sql>`coalesce( + ( + select jsonb_object_agg(entry.key, entry.value) + from jsonb_each_text(coalesce(${column}::jsonb, '{}'::jsonb)) as entry(key, value) + where entry.key in (${keys}) + and octet_length(entry.value) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + ), + '{}'::jsonb + )`.as('variables') +} + +function requestedOverLimitNames(column: AnyPgColumn, names: readonly string[]) { + const keys = sql.join( + names.map((name) => sql`${name}`), + sql`, ` + ) + return sql`coalesce( + ( + select jsonb_agg(entry.key order by entry.key) + from jsonb_each_text(coalesce(${column}::jsonb, '{}'::jsonb)) as entry(key, value) + where entry.key in (${keys}) + and octet_length(entry.value) > ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + ), + '[]'::jsonb + )`.as('over_limit_names') +} + +function overLimitNames(row: { overLimitNames?: unknown } | undefined): Set { + if (!Array.isArray(row?.overLimitNames)) return new Set() + return new Set(row.overLimitNames.filter((name): name is string => typeof name === 'string')) +} + +function activeAdmin(row: CredentialAccessRow): boolean { + return row.role === 'admin' && row.status === 'active' +} + +function unavailableError(names: readonly string[]): CopilotCodeSecretAccessError { + return new CopilotCodeSecretAccessError( + `Copilot code cannot access the requested secret${names.length === 1 ? '' : 's'}: ${names.join(', ')}` + ) +} + +/** + * Resolves exact Secrets-tab values for arbitrary Copilot code after rechecking current authority. + * No plaintext is produced until every requested name has an authorized source. + */ +export async function materializeCopilotCodeSecrets(params: { + actorUserId: string + workspaceId: string + requestedNames: readonly string[] +}): Promise { + const requestedNames = normalizeRequestedNames(params.requestedNames) + if (requestedNames.length === 0) return { envVars: {}, catalogEntries: [] } + + const access = await checkWorkspaceAccess(params.workspaceId, params.actorUserId) + if (!access.exists || !access.canWrite) { + throw new CopilotCodeSecretAccessError( + 'Write access is required to mount secrets into Copilot code' + ) + } + + const [personalRows, workspaceRows, credentialRows] = await Promise.all([ + db + .select({ + variables: requestedVariables(environment.variables, requestedNames), + overLimitNames: requestedOverLimitNames(environment.variables, requestedNames), + }) + .from(environment) + .where(eq(environment.userId, params.actorUserId)) + .limit(1), + db + .select({ + variables: requestedVariables(workspaceEnvironment.variables, requestedNames), + overLimitNames: requestedOverLimitNames(workspaceEnvironment.variables, requestedNames), + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, params.workspaceId)) + .limit(1), + db + .selectDistinctOn([credential.type, credential.envKey], { + type: credential.type, + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + role: credentialMember.role, + status: credentialMember.status, + updatedAt: credential.updatedAt, + encryptedValue: sql`case + when octet_length(${environment.variables} ->> ${credential.envKey}) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + then ${environment.variables} ->> ${credential.envKey} + else null + end`.as('encrypted_value'), + encryptedValueBytes: sql< + number | null + >`octet_length(${environment.variables} ->> ${credential.envKey})`.as( + 'encrypted_value_bytes' + ), + }) + .from(credential) + .innerJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, params.actorUserId) + ) + ) + .leftJoin(environment, eq(environment.userId, credential.envOwnerUserId)) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + inArray(credential.type, ['env_workspace', 'env_personal']), + inArray(credential.envKey, requestedNames), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active'), + or( + eq(credential.type, 'env_workspace'), + sql`coalesce(${environment.variables}::jsonb, '{}'::jsonb) ? ${credential.envKey}` + ) + ) + ) + .orderBy(credential.type, credential.envKey, desc(credential.updatedAt)) + .limit(MAX_SECRET_MOUNT_NAMES * 2), + ]) + + const ownPersonalEncrypted = encryptedVariables(personalRows[0]) + const workspaceEncrypted = encryptedVariables(workspaceRows[0]) + const ownPersonalOverLimit = overLimitNames(personalRows[0]) + const workspaceOverLimit = overLimitNames(workspaceRows[0]) + const envCredentialRows = credentialRows.filter( + (row): row is CredentialAccessRow => + (row.type === 'env_personal' || row.type === 'env_workspace') && + typeof row.envKey === 'string' + ) + const authorizedSharedPersonalRows = envCredentialRows.filter( + (row) => + row.type === 'env_personal' && + row.envOwnerUserId !== null && + row.envOwnerUserId !== params.actorUserId && + activeAdmin(row) + ) + + const authorizedSources: AuthorizedEncryptedSecret[] = [] + const unavailable: string[] = [] + const overLimit: string[] = [] + for (const name of requestedNames) { + const workspaceValue = workspaceEncrypted[name] + const workspaceExists = workspaceValue !== undefined || workspaceOverLimit.has(name) + const workspaceAuthorized = + workspaceExists && + (access.canAdmin || + envCredentialRows.some( + (row) => row.type === 'env_workspace' && row.envKey === name && activeAdmin(row) + )) + + if (workspaceAuthorized) { + if (workspaceValue === undefined) { + overLimit.push(name) + continue + } + authorizedSources.push({ name, encryptedValue: workspaceValue }) + continue + } + + const ownPersonalValue = ownPersonalEncrypted[name] + if (ownPersonalOverLimit.has(name)) { + overLimit.push(name) + continue + } + if (ownPersonalValue !== undefined) { + authorizedSources.push({ name, encryptedValue: ownPersonalValue }) + continue + } + + const sharedPersonal = authorizedSharedPersonalRows + .filter((row) => row.envKey === name) + .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime()) + .at(0) + if ( + sharedPersonal && + sharedPersonal.encryptedValueBytes !== null && + sharedPersonal.encryptedValueBytes > MAX_SECRET_MOUNT_ENCRYPTED_BYTES + ) { + overLimit.push(name) + continue + } + const sharedPersonalValue = sharedPersonal?.encryptedValue ?? undefined + if (sharedPersonalValue !== undefined) { + authorizedSources.push({ name, encryptedValue: sharedPersonalValue }) + continue + } + + unavailable.push(name) + } + + if (overLimit.length > 0) { + throw new CopilotCodeSecretAccessError('Requested secrets exceed the Copilot mount size limit') + } + if (unavailable.length > 0) throw unavailableError(unavailable) + + let decryptedEntries: Array<{ name: string; plaintext: string; encryptedValue: string }> + try { + decryptedEntries = await Promise.all( + authorizedSources.map(async ({ name, encryptedValue }) => { + const { decrypted } = await decryptSecret(encryptedValue) + return { name, plaintext: decrypted, encryptedValue } + }) + ) + } catch { + throw new CopilotCodeSecretAccessError('One or more requested secrets could not be decrypted') + } + + let totalPlaintextBytes = 0 + for (const entry of decryptedEntries) { + const plaintextBytes = Buffer.byteLength(entry.plaintext, 'utf8') + totalPlaintextBytes += plaintextBytes + if ( + plaintextBytes > MAX_SECRET_MOUNT_PLAINTEXT_BYTES || + totalPlaintextBytes > MAX_SECRET_MOUNT_TOTAL_PLAINTEXT_BYTES + ) { + throw new CopilotCodeSecretAccessError( + 'Requested secrets exceed the Copilot mount size limit' + ) + } + } + + return { + envVars: Object.fromEntries(decryptedEntries.map((entry) => [entry.name, entry.plaintext])), + catalogEntries: decryptedEntries, + } +} diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts new file mode 100644 index 00000000000..f8d84b3fe93 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -0,0 +1,16 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { computeBlockLevelInputs } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' +import { MothershipBlock } from '@/blocks/blocks/mothership' + +describe('get blocks metadata', () => { + it('omits server-only Mothership policy inputs from block metadata definitions', () => { + const definitions = computeBlockLevelInputs(MothershipBlock) + + expect(definitions).not.toHaveProperty('secretScope') + expect(definitions).not.toHaveProperty('mountedSecrets') + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 6dbb214de80..5a7c1f2d3a9 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -180,7 +180,9 @@ export const getBlocksMetadataServerTool: BaseServerTool< // `workflow_executor`; the agent never configures a workflowId/inputMapping. // Present it as self-contained: its visible input fields + curated outputs, // no tools/operations. - const visibleSubBlocks = (blockConfig.subBlocks || []).filter((sb) => !sb.hidden) + const visibleSubBlocks = (blockConfig.subBlocks || []).filter( + (sb) => !sb.hidden && !sb.hideFromCopilot + ) const outputs = blockConfig.outputs ? Object.fromEntries( Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) @@ -273,11 +275,13 @@ export const getBlocksMetadataServerTool: BaseServerTool< }) } - const blockInputs = computeBlockLevelInputs(blockConfig) + const hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) + const blockInputs = computeBlockLevelInputs(blockConfig, hiddenParamKeys) const { commonParameters, operationParameters } = splitParametersByOperation( Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks.filter( - (sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + (sb) => + !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' ) : [], blockInputs @@ -297,7 +301,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< : {} const filteredToolParams: Record = {} for (const [k, v] of Object.entries(toolParams)) { - if (!(k in blockInputs)) filteredToolParams[k] = v + if (!(k in blockInputs) && !hiddenParamKeys.has(k)) filteredToolParams[k] = v } operations[opId] = { toolId: resolvedToolId, @@ -968,10 +972,25 @@ function splitParametersByOperation( return { commonParameters, operationParameters } } -function computeBlockLevelInputs(blockConfig: BlockConfig): Record { +function getCopilotHiddenParamKeys(blockConfig: BlockConfig): Set { + const hiddenParamKeys = new Set() + for (const subBlock of blockConfig.subBlocks ?? []) { + if (!subBlock.hideFromCopilot) continue + if (subBlock.id) hiddenParamKeys.add(subBlock.id) + if (subBlock.canonicalParamId) hiddenParamKeys.add(subBlock.canonicalParamId) + } + return hiddenParamKeys +} + +export function computeBlockLevelInputs( + blockConfig: BlockConfig, + hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) +): Record { const inputs = blockConfig.inputs || {} const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') + ? blockConfig.subBlocks.filter( + (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [] const byParamKey: Record = {} @@ -988,6 +1007,7 @@ function computeBlockLevelInputs(blockConfig: BlockConfig): Record const blockInputs: Record = {} for (const key of Object.keys(inputs)) { + if (hiddenParamKeys.has(key)) continue const sbs = byParamKey[key] || [] const isOperationGated = sbs.some((sb) => { const cond = normalizeCondition(sb.condition) @@ -1006,7 +1026,9 @@ function computeOperationLevelInputs( ): Record> { const inputs = blockConfig.inputs || {} const subBlocks = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') + ? blockConfig.subBlocks.filter( + (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [] const opInputs: Record> = {} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 295ba44db98..8ae05a41465 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -150,6 +150,17 @@ const genericWebhookBlockConfig = { ], } +const mothershipBlockConfig = { + type: 'mothership', + name: 'Sim Chat', + outputs: {}, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { id: 'mountedSecrets', type: 'dropdown', hideFromCopilot: true }, + ], +} + // Block whose tool selector throws — should fall back to scanning access tools (video_falai). const throwSelectorBlockConfig = { type: 'throw_selector_block', @@ -204,6 +215,7 @@ const blockConfigsByType: Record = { throw_gate_block: throwGateBlockConfig, throw_selector_block: throwSelectorBlockConfig, generic_webhook: genericWebhookBlockConfig, + mothership: mothershipBlockConfig, } vi.mock('@/blocks/registry', () => ({ @@ -358,6 +370,17 @@ describe('validateInputsForBlock', () => { expect(result.errors[0]?.error).toContain('read-only') }) + it('rejects server-only Sim Chat secret-mount policy inputs', () => { + const result = validateInputsForBlock( + 'mothership', + { prompt: 'Keep this', secretScope: 'all', mountedSecrets: ['API_KEY'] }, + 'chat-1' + ) + + expect(result.validInputs).toEqual({ prompt: 'Keep this' }) + expect(result.errors.map((error) => error.field)).toEqual(['secretScope', 'mountedSecrets']) + }) + it('accepts known agent model ids', () => { const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4-6' }, 'agent-1') 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 36bc722a0f8..48d44f21dbc 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 @@ -128,6 +128,17 @@ export function validateInputsForBlock( continue } + if (subBlockConfig.hideFromCopilot === true) { + errors.push({ + blockId, + blockType, + field: key, + value, + error: `Field "${key}" on block type "${blockType}" is server-managed and cannot be set by Copilot`, + }) + continue + } + // Note: We do NOT check subBlockConfig.condition here. // Conditions are for UI display logic (show/hide fields in the editor). // For API/Copilot, any valid field in the block schema should be accepted. diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts index fc750614dfb..c7c6c103d92 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -1,3 +1,9 @@ +import { isPlainRecord } from '@sim/utils/object' +import { + ASYNC_TOOL_CONFIRMATION_STATUS, + type AsyncConfirmationStatus, +} from '@/lib/copilot/async-runs/lifecycle' + const WORKFLOW_TOOL_NAMES = [ 'run_workflow', 'run_workflow_until_block', @@ -10,3 +16,64 @@ const WORKFLOW_TOOL_NAME_SET = new Set(WORKFLOW_TOOL_NAMES) export function isWorkflowToolName(name: string): boolean { return WORKFLOW_TOOL_NAME_SET.has(name) } + +/** Resolves the workflow target from immutable tool arguments, then the owning Copilot run. */ +export function resolveWorkflowToolTargetId( + args: unknown, + runWorkflowId?: string | null +): string | undefined { + if (isPlainRecord(args) && typeof args.workflowId === 'string' && args.workflowId.length > 0) { + return args.workflowId + } + return typeof runWorkflowId === 'string' && runWorkflowId.length > 0 ? runWorkflowId : undefined +} + +export function getWorkflowToolCompletionExecutionId(data: unknown): string | undefined { + if (!isPlainRecord(data)) return undefined + return typeof data.executionId === 'string' && data.executionId.length > 0 + ? data.executionId + : undefined +} + +export function getWorkflowToolCompletionMessage(status: AsyncConfirmationStatus): string { + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) { + return 'Workflow execution completed.' + } + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { + return 'Workflow execution was cancelled.' + } + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + return 'Workflow execution is continuing in the background.' + } + return 'Workflow execution failed.' +} + +export function getWorkflowToolConfirmationStatus( + status: 'completed' | 'failed' | 'cancelled' +): AsyncConfirmationStatus { + if (status === 'completed') return ASYNC_TOOL_CONFIRMATION_STATUS.success + if (status === 'cancelled') return ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + return ASYNC_TOOL_CONFIRMATION_STATUS.error +} + +export function createStructuralWorkflowToolCompletionData( + status: AsyncConfirmationStatus, + workflowId?: string, + executionId?: string +): Record { + const data: Record = {} + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) data.success = true + if ( + status === ASYNC_TOOL_CONFIRMATION_STATUS.error || + status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + ) { + data.success = false + } + if (workflowId) data.workflowId = workflowId + if (executionId) data.executionId = executionId + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { + data.reason = 'user_cancelled' + data.cancelledByUser = true + } + return data +} diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index daf3c3c8086..4394b746e0b 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -217,6 +217,44 @@ describe('hosted-key VFS metadata', () => { expect(schema.inputs.apiKey).toBeDefined() expect(schema.toolAuth.search.mode).toBe('hosted_or_byok') }) + + it('omits server-only lifecycle inputs from block schemas', () => { + const block = { + type: 'mothership', + name: 'Sim Chat', + description: 'Talk to Sim', + category: 'blocks', + bgColor: '#000000', + icon: () => null, + subBlocks: [ + { id: 'prompt', title: 'Prompt', type: 'long-input' }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + hideFromCopilot: true, + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + hideFromCopilot: true, + }, + ], + tools: { access: [] }, + inputs: { + prompt: { type: 'string' }, + secretScope: { type: 'string' }, + mountedSecrets: { type: 'json' }, + }, + outputs: {}, + } as unknown as BlockConfig + + const schema = JSON.parse(serializeBlockSchema(block)) + + expect(schema.subBlocks.map((subBlock: { id: string }) => subBlock.id)).toEqual(['prompt']) + expect(schema.inputs).toEqual({ prompt: { type: 'string' } }) + }) }) describe('serializeKBMeta', () => { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0972b7a0d1f..5dd5959a86e 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -573,12 +573,14 @@ export function serializeBlockSchema( const customBlock = isCustomBlockType(block.type) const hosted = options?.hosted ?? isHosted const visibleSubBlocks = block.subBlocks.filter( - (sb) => !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) + (sb) => !sb.hideFromCopilot && !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) ) const visibleIds = new Set(visibleSubBlocks.map((sb) => sb.id)) const hiddenIds = new Set( block.subBlocks - .filter((sb) => isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden)) + .filter( + (sb) => sb.hideFromCopilot || isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden) + ) .map((sb) => sb.id) .filter((id) => !visibleIds.has(id)) ) diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 12b852165f1..9a1ee04aefa 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -39,6 +39,8 @@ export interface AsyncExecutionCorrelation { requestId: string source: AsyncExecutionCorrelationSource workflowId: string + /** Server-validated binding for a browser-routed Copilot workflow tool execution. */ + copilotToolCallId?: string triggerType?: string webhookId?: string scheduleId?: string diff --git a/apps/sim/lib/core/utils/records.test.ts b/apps/sim/lib/core/utils/records.test.ts index 80195d71ccc..9bc22e8f07e 100644 --- a/apps/sim/lib/core/utils/records.test.ts +++ b/apps/sim/lib/core/utils/records.test.ts @@ -29,6 +29,14 @@ describe('record normalization utilities', () => { expect(normalizeStringRecord([])).toEqual({}) }) + it('preserves own __proto__ keys without changing the record prototype', () => { + const normalized = normalizeStringRecord(Object.fromEntries([['__proto__', 'secret-value']])) + + expect(Object.hasOwn(normalized, '__proto__')).toBe(true) + expect(normalized.__proto__).toBe('secret-value') + expect(Object.getPrototypeOf(normalized)).toBe(Object.prototype) + }) + it('normalizes record maps by dropping malformed entries', () => { expect( normalizeRecordMap({ diff --git a/apps/sim/lib/core/utils/records.ts b/apps/sim/lib/core/utils/records.ts index b13554b5c54..aea457c67d1 100644 --- a/apps/sim/lib/core/utils/records.ts +++ b/apps/sim/lib/core/utils/records.ts @@ -3,6 +3,15 @@ import { isPlainRecord } from '@sim/utils/object' export type UnknownRecord = Record export type StringRecord = Record +export function setRecordValue(record: Record, key: string, value: unknown): void { + Object.defineProperty(record, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }) +} + /** * Normalizes optional execution context maps to the record shape expected by * internal API contracts. @@ -25,7 +34,11 @@ export function normalizeStringRecord(value: unknown): StringRecord { if (entryValue === undefined || entryValue === null) { continue } - normalized[key] = typeof entryValue === 'string' ? entryValue : String(entryValue) + setRecordValue( + normalized, + key, + typeof entryValue === 'string' ? entryValue : String(entryValue) + ) } return normalized } @@ -41,7 +54,7 @@ export function normalizeRecordMap(value: unknown): Record = {} for (const [key, entryValue] of Object.entries(value)) { if (isPlainRecord(entryValue)) { - normalized[key] = entryValue + setRecordValue(normalized, key, entryValue) } } return normalized @@ -72,7 +85,7 @@ export function normalizeWorkflowVariables(value: unknown): UnknownRecord { const key = id ?? name if (key) { - normalized[key] = variable + setRecordValue(normalized, key, variable) } } diff --git a/apps/sim/lib/credentials/environment.test.ts b/apps/sim/lib/credentials/environment.test.ts index 64c89d058d8..349a94d5db0 100644 --- a/apps/sim/lib/credentials/environment.test.ts +++ b/apps/sim/lib/credentials/environment.test.ts @@ -15,10 +15,93 @@ vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({ })) import { + getPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' +describe('getPersonalEnvKeyRawAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns own values without querying credential grants', async () => { + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { OWN_KEY: 'u-1' }, + }) + + expect([...result.ownedKeys]).toEqual(['OWN_KEY']) + expect(result.adminKeys.size).toBe(0) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('allows own values and only active admin grants for other personal values', async () => { + queueTableRows(credential, [ + { + envKey: 'SHARED_ADMIN', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + }, + { + envKey: 'SHARED_MEMBER', + envOwnerUserId: 'owner-3', + role: 'member', + status: 'active', + }, + { + envKey: 'REVOKED_ADMIN', + envOwnerUserId: 'owner-4', + role: 'admin', + status: 'revoked', + }, + ]) + + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { + OWN_KEY: 'u-1', + SHARED_ADMIN: 'owner-2', + SHARED_MEMBER: 'owner-3', + REVOKED_ADMIN: 'owner-4', + }, + }) + + expect([...result.ownedKeys]).toEqual(['OWN_KEY']) + expect([...result.adminKeys]).toEqual(['SHARED_ADMIN']) + }) + + it('requires the admin grant to belong to the exact effective secret owner', async () => { + queueTableRows(credential, [ + { + envKey: 'COLLISION', + envOwnerUserId: 'owner-a', + role: 'admin', + status: 'active', + }, + { + envKey: 'COLLISION', + envOwnerUserId: 'owner-b', + role: 'member', + status: 'active', + }, + ]) + + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { COLLISION: 'owner-b' }, + }) + + expect(result.ownedKeys.size).toBe(0) + expect(result.adminKeys.size).toBe(0) + }) +}) + describe('getWorkspaceEnvKeyAdminAccess', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index da4f743cbab..e70be39ebff 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -107,6 +107,67 @@ export interface WorkspaceEnvKeyAdminAccess { knownKeys: Set } +export interface PersonalEnvKeyRawAccess { + /** Keys stored in the caller's own personal Secrets catalog. */ + ownedKeys: Set + /** Keys owned by someone else for which the caller is an active credential admin. */ + adminKeys: Set +} + +/** Resolves which personal secret values a workspace viewer may read as plaintext. */ +export async function getPersonalEnvKeyRawAccess(params: { + workspaceId: string + personalOwners: Record + userId: string +}): Promise { + const keys = Object.keys(params.personalOwners) + if (keys.length === 0) return { ownedKeys: new Set(), adminKeys: new Set() } + + const ownedKeys = new Set( + keys.filter((envKey) => params.personalOwners[envKey] === params.userId) + ) + const sharedKeys = keys.filter((envKey) => !ownedKeys.has(envKey)) + if (sharedKeys.length === 0) return { ownedKeys, adminKeys: new Set() } + + const credentialRows = await db + .select({ + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + role: credentialMember.role, + status: credentialMember.status, + }) + .from(credential) + .leftJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, params.userId) + ) + ) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'env_personal'), + inArray(credential.envKey, sharedKeys) + ) + ) + + const adminKeys = new Set() + for (const row of credentialRows) { + if ( + row.envKey && + row.envOwnerUserId === params.personalOwners[row.envKey] && + row.envOwnerUserId !== params.userId && + row.role === 'admin' && + row.status === 'active' + ) { + adminKeys.add(row.envKey) + } + } + + return { ownedKeys, adminKeys } +} + /** * For a set of workspace env keys, resolves which the caller may administer * (active `credential_member` with role `admin`) and which already have an diff --git a/apps/sim/lib/credentials/secret-mount-options.test.ts b/apps/sim/lib/credentials/secret-mount-options.test.ts new file mode 100644 index 00000000000..ae688715dbc --- /dev/null +++ b/apps/sim/lib/credentials/secret-mount-options.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceCredential } from '@/lib/api/contracts' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' + +function credential( + overrides: Partial & Pick +): WorkspaceCredential { + return { + workspaceId: 'workspace-1', + displayName: overrides.id, + description: null, + providerId: null, + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + } +} + +describe('selectRawMountableSecretNames', () => { + it('keeps only admin environment credentials and returns unique sorted names', () => { + const credentials = [ + credential({ id: 'workspace-z', type: 'env_workspace', envKey: 'ZETA', role: 'admin' }), + credential({ id: 'personal-a', type: 'env_personal', envKey: 'ALPHA', role: 'admin' }), + credential({ id: 'duplicate-a', type: 'env_workspace', envKey: 'ALPHA', role: 'admin' }), + credential({ id: 'member', type: 'env_workspace', envKey: 'MEMBER', role: 'member' }), + credential({ id: 'oauth', type: 'oauth', envKey: 'OAUTH', role: 'admin' }), + credential({ id: 'missing-key', type: 'env_personal', envKey: null, role: 'admin' }), + ] + + expect(selectRawMountableSecretNames(credentials)).toEqual(['ALPHA', 'ZETA']) + }) +}) diff --git a/apps/sim/lib/credentials/secret-mount-options.ts b/apps/sim/lib/credentials/secret-mount-options.ts new file mode 100644 index 00000000000..d159601ac69 --- /dev/null +++ b/apps/sim/lib/credentials/secret-mount-options.ts @@ -0,0 +1,22 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts' + +/** + * Returns the secret names the current credential-list actor may mount as plaintext. + * The credentials API has already derived workspace-admin and per-credential roles; + * this selector deliberately keeps only environment credentials with effective admin access. + */ +export function selectRawMountableSecretNames(credentials: WorkspaceCredential[]): string[] { + const names = new Set() + + for (const credential of credentials) { + if ( + (credential.type === 'env_workspace' || credential.type === 'env_personal') && + credential.role === 'admin' && + credential.envKey + ) { + names.add(credential.envKey) + } + } + + return [...names].sort() +} diff --git a/apps/sim/lib/environment/utils.test.ts b/apps/sim/lib/environment/utils.test.ts index feb5c95bef0..91f0bdf6b3b 100644 --- a/apps/sim/lib/environment/utils.test.ts +++ b/apps/sim/lib/environment/utils.test.ts @@ -1,16 +1,27 @@ /** * @vitest-environment node */ -import { dbChainMockFns, encryptionMock, encryptionMockFns, resetDbChainMock } from '@sim/testing' +import { environment, workspaceEnvironment } from '@sim/db/schema' +import { + dbChainMockFns, + encryptionMock, + encryptionMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateWorkspaceEnvCredentials, + mockCheckWorkspaceAccess, + mockGetAccessibleEnvCredentials, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess, mockRecordAudit, } = vi.hoisted(() => ({ mockCreateWorkspaceEnvCredentials: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockGetAccessibleEnvCredentials: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), mockRecordAudit: vi.fn(), @@ -27,23 +38,87 @@ vi.mock('@sim/audit', () => ({ })) vi.mock('@/lib/credentials/environment', () => ({ createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials, - getAccessibleEnvCredentials: vi.fn(), + getAccessibleEnvCredentials: mockGetAccessibleEnvCredentials, getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, syncPersonalEnvCredentialsForUser: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: vi.fn(), + checkWorkspaceAccess: mockCheckWorkspaceAccess, getUserEntityPermissions: mockGetUserEntityPermissions, })) import { getEffectiveDecryptedEnv, getEffectiveEnvironmentSnapshot, + getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, upsertWorkspaceEnvVars, WorkspaceEnvAccessError, } from '@/lib/environment/utils' +describe('getPersonalAndWorkspaceEnv access filtering', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + mockGetAccessibleEnvCredentials.mockResolvedValue([]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + it('filters every workspace secret when the caller has zero credential grants', async () => { + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + + const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' }) + expect(snapshot.workspaceDecrypted).toEqual({}) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce() + }) + + it('preserves legacy workspace secrets without credential rows for workspace admins', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueTableRows(environment, [{ variables: {} }]) + queueTableRows(workspaceEnvironment, [{ variables: { LEGACY_KEY: 'legacy-cipher' } }]) + + const snapshot = await getPersonalAndWorkspaceEnv('admin-1', 'workspace-1') + + expect(snapshot.workspaceDecrypted).toEqual({ LEGACY_KEY: 'plain:legacy-cipher' }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce() + }) + + it('preserves shared-personal precedence when an accessible owner shares the same name', async () => { + mockGetAccessibleEnvCredentials.mockResolvedValue([ + { + type: 'env_personal', + envKey: 'SHARED_KEY', + envOwnerUserId: 'owner-2', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + queueTableRows(environment, [{ variables: { SHARED_KEY: 'own-cipher' } }]) + queueTableRows(environment, [{ userId: 'owner-2', variables: { SHARED_KEY: 'shared-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: {} }]) + + const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ SHARED_KEY: 'plain:shared-cipher' }) + expect(snapshot.personalOwners).toEqual({ SHARED_KEY: 'owner-2' }) + }) +}) + describe('upsertWorkspaceEnvVars', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 37089698158..65d9b89be0b 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -48,6 +48,7 @@ export interface EnvironmentResolutionSnapshot { workspaceEncrypted: Record personalDecrypted: Record workspaceDecrypted: Record + personalOwners: Record conflicts: string[] decryptionFailures: string[] } @@ -75,6 +76,7 @@ function cloneEnvironmentResolutionSnapshot( workspaceEncrypted: { ...snapshot.workspaceEncrypted }, personalDecrypted: { ...snapshot.personalDecrypted }, workspaceDecrypted: { ...snapshot.workspaceDecrypted }, + personalOwners: { ...snapshot.personalOwners }, conflicts: [...snapshot.conflicts], decryptionFailures: [...snapshot.decryptionFailures], } @@ -165,7 +167,7 @@ export async function getPersonalAndWorkspaceEnv( const ownPersonalEncrypted: Record = (personalRows[0]?.variables as any) || {} const allWorkspaceEncrypted: Record = (workspaceRows[0]?.variables as any) || {} - const hasCredentialFiltering = Boolean(workspaceId) && accessibleEnvCredentials.length > 0 + const hasCredentialFiltering = Boolean(workspaceId) const workspaceCredentialKeys = new Set( accessibleEnvCredentials.filter((row) => row.type === 'env_workspace').map((row) => row.envKey) ) @@ -205,6 +207,9 @@ export async function getPersonalAndWorkspaceEnv( let personalEncrypted: Record = ownPersonalEncrypted let workspaceEncrypted: Record = allWorkspaceEncrypted + const personalOwners: Record = Object.fromEntries( + Object.keys(ownPersonalEncrypted).map((envKey) => [envKey, userId]) + ) if (hasCredentialFiltering) { personalEncrypted = { ...ownPersonalEncrypted } @@ -213,14 +218,17 @@ export async function getPersonalAndWorkspaceEnv( const encryptedValue = ownerVariables?.[envKey] if (encryptedValue) { personalEncrypted[envKey] = encryptedValue + personalOwners[envKey] = ownerUserId } } - workspaceEncrypted = Object.fromEntries( - Object.entries(allWorkspaceEncrypted).filter(([envKey]) => - workspaceCredentialKeys.has(envKey) - ) - ) + workspaceEncrypted = workspaceCanAdmin + ? { ...allWorkspaceEncrypted } + : Object.fromEntries( + Object.entries(allWorkspaceEncrypted).filter(([envKey]) => + workspaceCredentialKeys.has(envKey) + ) + ) } const decryptionFailures: string[] = [] @@ -268,6 +276,7 @@ export async function getPersonalAndWorkspaceEnv( workspaceEncrypted, personalDecrypted, workspaceDecrypted, + personalOwners, conflicts, decryptionFailures, } diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index f79f83d915d..ef73b1c11b6 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -380,6 +380,42 @@ describe('ExecutionLogger', () => { expect(compacted.traceSpans?.[0]?.children?.[0]).not.toHaveProperty('input') }) + test('retains the trusted Copilot binding in metadata-only compaction', () => { + const loggerInstance = new ExecutionLogger() as unknown as { + compactExecutionDataForStorage( + executionData: WorkflowExecutionLog['executionData'], + executionId: string + ): WorkflowExecutionLog['executionData'] + } + const correlation = { + executionId: 'execution-metadata-only', + requestId: 'request-1', + source: 'workflow' as const, + workflowId: 'workflow-1', + copilotToolCallId: 'tool-call-1', + } + + const compacted = loggerInstance.compactExecutionDataForStorage( + { + environment: { + variables: { OVERSIZED: 'x'.repeat(3.5 * 1024 * 1024) }, + workflowId: 'workflow-1', + executionId: 'execution-metadata-only', + userId: 'user-1', + workspaceId: 'workspace-1', + }, + correlation, + hasTraceSpans: false, + traceSpanCount: 0, + }, + 'execution-metadata-only' + ) + + expect(compacted.executionDataTruncated).toBe(true) + expect(compacted.correlation).toEqual(correlation) + expect(compacted).not.toHaveProperty('environment') + }) + test('retains tool-call structure when aggregate trace content exceeds the compaction cap', () => { const loggerInstance = new ExecutionLogger() as unknown as { compactExecutionDataForStorage( diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 897275d78f8..5394919c439 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -491,6 +491,7 @@ export class ExecutionLogger implements IExecutionLoggerService { ...(executionData.billingAttribution ? { billingAttribution: executionData.billingAttribution } : {}), + ...(executionData.correlation ? { correlation: executionData.correlation } : {}), hasTraceSpans: executionData.hasTraceSpans, traceSpanCount: executionData.traceSpanCount, tokens: executionData.tokens, diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index b4e79338534..eb1616e0869 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -92,7 +92,12 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({ models: {}, }), createEnvironmentObject: vi.fn(), - createTriggerObject: vi.fn(), + createTriggerObject: vi.fn((type: string, additionalData?: Record) => ({ + type, + source: type, + timestamp: '2026-01-01T00:00:00.000Z', + ...(additionalData ? { data: additionalData } : {}), + })), loadDeployedWorkflowStateForLogging: vi.fn(), loadWorkflowStateForExecution: loadWorkflowStateForExecutionMock, })) @@ -234,6 +239,40 @@ describe('LoggingSession start snapshots', () => { ) }) + it('persists only the server-validated execution correlation', async () => { + const session = new LoggingSession('workflow-1', 'execution-1', 'copilot', 'req-1') + const trustedCorrelation = { + executionId: 'execution-1', + requestId: 'req-1', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'trusted-tool-call', + } + session.setTrustedExecutionCorrelation(trustedCorrelation) + + await session.start({ + userId: 'user-1', + workspaceId: 'workspace-1', + triggerData: { + correlation: { + executionId: 'submitted-execution', + requestId: 'submitted-request', + source: 'workflow', + copilotToolCallId: 'submitted-tool-call', + }, + }, + }) + + expect(startWorkflowExecutionMock).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: expect.objectContaining({ + data: expect.objectContaining({ correlation: trustedCorrelation }), + }), + }) + ) + }) + it('does not create a log when hydrating a persisted execution for completion', async () => { const session = new LoggingSession('workflow-1', 'execution-existing', 'manual', 'req-existing') diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index 9703d5b57bd..bf96ef4022d 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -185,6 +185,7 @@ export class LoggingSession { private environment?: ExecutionEnvironment private workflowState?: WorkflowState private correlation?: NonNullable['correlation'] + private trustedExecutionCorrelation?: NonNullable['correlation'] private actorUserId: string | null = null private billingAttribution?: BillingAttributionSnapshot private isResume = false @@ -225,6 +226,13 @@ export class LoggingSession { this.resolvedSecretTraceRegistry = registry } + /** Adds server-validated lifecycle correlation without exposing it to executor metadata. */ + setTrustedExecutionCorrelation( + correlation: NonNullable['correlation']> + ): void { + this.trustedExecutionCorrelation = { ...correlation } + } + /** Adds the trusted execution-ref scope needed to rewrite offloaded trace content. */ setTraceLargeValueAccess(context: LargeValueStoreContext): void { this.traceLargeValueAccess = context @@ -618,8 +626,11 @@ export class LoggingSession { } try { - this.trigger = createTriggerObject(this.triggerType, triggerData) - this.correlation = triggerData?.correlation + const effectiveTriggerData = this.trustedExecutionCorrelation + ? { ...triggerData, correlation: this.trustedExecutionCorrelation } + : triggerData + this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) + this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( this.workflowId, this.executionId, @@ -1081,8 +1092,11 @@ export class LoggingSession { deploymentVersionId, workflowState, } = params - this.trigger = createTriggerObject(this.triggerType, triggerData) - this.correlation = triggerData?.correlation + const effectiveTriggerData = this.trustedExecutionCorrelation + ? { ...triggerData, correlation: this.trustedExecutionCorrelation } + : triggerData + this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) + this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( this.workflowId, this.executionId, diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index 0632dc231c7..9164002569d 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -19,18 +19,18 @@ import type { LargeValueStoreContext } from '@/lib/execution/payloads/store' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' import type { ToolCall, TraceSpan } from '@/lib/logs/types' import type { IterationToolCall, ProviderTimingSegment } from '@/executor/types' -import type { - ResolvedSecretTraceMatch, - ResolvedSecretTraceRegistry, -} from '@/executor/utils/resolved-secret-trace-registry' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + projectResolvedSecretContent, + type ResolvedSecretMatcher, +} from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('TraceSecretProjection') const REF_CONCURRENCY = 4 const MAX_CONTENT_NODES = 100_000 const MAX_CONTENT_DEPTH = 100 -const MAX_MATCHER_NODES = 250_000 -const MAX_SECRET_LITERAL_LENGTH = 64 * 1024 -const MAX_MATCH_EVENTS = 1_000_000 const MAX_LARGE_VALUES = 1_024 const MAX_LARGE_VALUE_CHAIN_DEPTH = 32 const MAX_LARGE_MANIFEST_CHUNKS = MAX_LARGE_VALUES @@ -65,25 +65,8 @@ const LARGE_ARRAY_MANIFEST_KEYS = new Set([ ]) const LARGE_ARRAY_MANIFEST_CHUNK_KEYS = new Set(['ref', 'count', 'byteSize']) -interface SecretReplacement { - plaintext: string - replacement: string -} - -interface SecretTrieNode { - children: Map - failure?: SecretTrieNode - outputLink?: SecretTrieNode - replacement?: SecretReplacement -} - -interface SecretMatcher { - root: SecretTrieNode - maxPatternLength: number -} - interface ProjectionContext { - matcher: SecretMatcher + matcher: ResolvedSecretMatcher store: LargeValueStoreContext allowLargeValueWrites: boolean safeLargeValues: WeakSet @@ -113,13 +96,8 @@ interface TraversalState { ancestors: WeakSet } -interface SanitizationTraversalState extends TraversalState { - outputBytes: number - maxBytes: number -} - interface PlaintextInvariantContext { - matcher: SecretMatcher + matcher: ResolvedSecretMatcher safeLargeValues: WeakSet /** Valid refs are trusted only after the full projector has already rewritten them. */ allowVerifiedLargeValues?: boolean @@ -150,95 +128,8 @@ class TraceSecretProjectionError extends Error { } } -function compareStrings(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - -function normalizeReplacements(matches: readonly ResolvedSecretTraceMatch[]): SecretReplacement[] { - const replacementByPlaintext = new Map() - - for (const match of matches) { - if (!match.plaintext) continue - const current = replacementByPlaintext.get(match.plaintext) - if (current === undefined || compareStrings(match.replacement, current) < 0) { - replacementByPlaintext.set(match.plaintext, match.replacement) - } - } - - const provisional = [...replacementByPlaintext.keys()] - .map((plaintext) => { - const requested = replacementByPlaintext.get(plaintext) ?? '' - return { plaintext, replacement: requested } - }) - .sort( - (left, right) => - right.plaintext.length - left.plaintext.length || - compareStrings(left.replacement, right.replacement) || - compareStrings(left.plaintext, right.plaintext) - ) - - const detector = createSecretMatcher( - provisional.map(({ plaintext }) => ({ plaintext, replacement: '' })) - ) - return provisional.map(({ plaintext, replacement }) => ({ - plaintext, - replacement: containsSecret(replacement, detector) ? '' : replacement, - })) -} - -function createSecretMatcher(replacements: readonly SecretReplacement[]): SecretMatcher { - const root: SecretTrieNode = { children: new Map() } - root.failure = root - let nodeCount = 1 - let maxPatternLength = 0 - for (const replacement of replacements) { - if (replacement.plaintext.length > MAX_SECRET_LITERAL_LENGTH) { - throw new TraceSecretProjectionError('Secret literal exceeds the matcher size limit') - } - maxPatternLength = Math.max(maxPatternLength, replacement.plaintext.length) - let node = root - for (let index = 0; index < replacement.plaintext.length; index += 1) { - const character = replacement.plaintext[index] - let child = node.children.get(character) - if (!child) { - child = { children: new Map() } - node.children.set(character, child) - nodeCount += 1 - if (nodeCount > MAX_MATCHER_NODES) { - throw new TraceSecretProjectionError('Secret matcher node limit exceeded') - } - } - node = child - } - node.replacement = replacement - } - - const queue: SecretTrieNode[] = [] - for (const child of root.children.values()) { - child.failure = root - queue.push(child) - } - for (let cursor = 0; cursor < queue.length; cursor += 1) { - const node = queue[cursor] - for (const [character, child] of node.children) { - let fallback = node.failure ?? root - while (fallback !== root && !fallback.children.has(character)) { - fallback = fallback.failure ?? root - } - const transition = fallback.children.get(character) - child.failure = transition && transition !== child ? transition : root - child.outputLink = child.failure.replacement ? child.failure : child.failure.outputLink - queue.push(child) - } - } - - return { root, maxPatternLength } -} - function createProjectionContext( - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, store: LargeValueStoreContext, allowLargeValueWrites: boolean ): ProjectionContext { @@ -260,108 +151,6 @@ function createProjectionContext( } } -function advanceMatcher( - matcher: SecretMatcher, - node: SecretTrieNode, - character: string -): SecretTrieNode { - let current = node - while (current !== matcher.root && !current.children.has(character)) { - current = current.failure ?? matcher.root - } - return current.children.get(character) ?? matcher.root -} - -function containsSecret(value: string, matcher: SecretMatcher): boolean { - let node = matcher.root - for (let index = 0; index < value.length; index += 1) { - node = advanceMatcher(matcher, node, value[index]) - if (node.replacement || node.outputLink) return true - } - return false -} - -function sanitizeString( - value: string, - matcher: SecretMatcher, - maxBytes = MAX_INLINE_MATERIALIZATION_BYTES -): string { - if (maxBytes < 0) { - throw new TraceSecretProjectionError('Sanitized trace string exceeds the size limit') - } - if (Buffer.byteLength(value, 'utf8') > maxBytes) { - throw new TraceSecretProjectionError('Trace string exceeds the size limit') - } - if (matcher.maxPatternLength === 0 || value.length === 0) return value - - let emitCursor = 0 - let literalStart = 0 - let outputBytes = 0 - let matchEvents = 0 - const chunks: string[] = [] - const windowSize = matcher.maxPatternLength - const slotStarts = new Int32Array(windowSize) - const slotEnds = new Int32Array(windowSize) - slotStarts.fill(-1) - const slotReplacements = new Array(windowSize) - - const append = (chunk: string): void => { - if (!chunk) return - outputBytes += Buffer.byteLength(chunk, 'utf8') - if (outputBytes > maxBytes) { - throw new TraceSecretProjectionError('Sanitized trace string exceeds the size limit') - } - const lastIndex = chunks.length - 1 - if (lastIndex >= 0 && chunks[lastIndex].length + chunk.length <= 64 * 1024) { - chunks[lastIndex] += chunk - } else { - chunks.push(chunk) - } - } - - const finalizeThrough = (limit: number): void => { - while (emitCursor <= limit && emitCursor < value.length) { - const slot = emitCursor % windowSize - if (slotStarts[slot] === emitCursor && slotReplacements[slot] !== undefined) { - append(value.slice(literalStart, emitCursor)) - append(slotReplacements[slot] ?? '') - emitCursor = slotEnds[slot] - literalStart = emitCursor - } else { - emitCursor += 1 - } - } - } - - let node = matcher.root - for (let index = 0; index < value.length; index += 1) { - node = advanceMatcher(matcher, node, value[index]) - let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink - while (outputNode?.replacement) { - matchEvents += 1 - if (matchEvents > MAX_MATCH_EVENTS) { - throw new TraceSecretProjectionError('Secret matcher event limit exceeded') - } - const start = index - outputNode.replacement.plaintext.length + 1 - if (start >= emitCursor) { - const slot = start % windowSize - const end = index + 1 - if (slotStarts[slot] !== start || end > slotEnds[slot]) { - slotStarts[slot] = start - slotEnds[slot] = end - slotReplacements[slot] = outputNode.replacement.replacement - } - } - outputNode = outputNode.outputLink - } - finalizeThrough(index - matcher.maxPatternLength + 1) - } - - finalizeThrough(value.length - 1) - append(value.slice(literalStart)) - return chunks.join('') -} - function visitNode(state: TraversalState, depth: number): void { state.nodes += 1 if (state.nodes > MAX_CONTENT_NODES) { @@ -591,75 +380,6 @@ function getLargeValueCandidate(value: unknown): LargeValueCandidate | undefined return value as LargeArrayManifest } -function sanitizeInlineValue( - value: unknown, - matcher: SecretMatcher, - safeLargeValues: WeakSet, - state: SanitizationTraversalState, - depth = 0 -): unknown { - visitNode(state, depth) - if (typeof value === 'string') { - const sanitized = sanitizeString(value, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitized, 'utf8') - return sanitized - } - if (value === null || typeof value === 'number' || typeof value === 'boolean') { - const rendered = String(value) - if (!containsSecret(rendered, matcher)) return value - const sanitized = sanitizeString(rendered, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitized, 'utf8') - return sanitized - } - if (value === undefined) return value - if (typeof value !== 'object') { - throw new TraceSecretProjectionError('Unsupported trace content value') - } - const largeValue = getLargeValueCandidate(value) - if (largeValue) { - if (!safeLargeValues.has(value as object)) { - throw new TraceSecretProjectionError('Trace content contains an unverified large value') - } - return value - } - if (!Array.isArray(value) && !isPlainRecord(value)) { - throw new TraceSecretProjectionError('Unsupported trace content object') - } - - enterObject(value, state) - try { - if (Array.isArray(value)) { - assertArrayFitsTraversal(value, state) - const sanitized = new Array(value.length) - for (const [index, item] of arrayDataEntries(value)) { - sanitized[index] = sanitizeInlineValue(item, matcher, safeLargeValues, state, depth + 1) - } - return sanitized - } - - const prototype = Object.getPrototypeOf(value) - const sanitized = Object.create(prototype) as Record - const sanitizedKeys = new Set() - for (const [key, item] of enumerableDataEntries(value)) { - const sanitizedKey = sanitizeString(key, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') - if (sanitizedKeys.has(sanitizedKey)) { - throw new TraceSecretProjectionError('Secret replacement caused an object-key collision') - } - sanitizedKeys.add(sanitizedKey) - Object.defineProperty(sanitized, sanitizedKey, { - value: sanitizeInlineValue(item, matcher, safeLargeValues, state, depth + 1), - enumerable: true, - configurable: true, - writable: true, - }) - } - return sanitized - } finally { - leaveObject(value, state) - } -} - function collectLargeValues( value: unknown, refs: object[], @@ -840,12 +560,13 @@ async function sanitizeMaterializedValue( withinRefWorker = false ): Promise { const withSafeRefs = await replaceLargeValues(value, context, path, withinRefWorker) - return sanitizeInlineValue(withSafeRefs, context.matcher, context.safeLargeValues, { - nodes: 0, - ancestors: new WeakSet(), - outputBytes: 0, - maxBytes, + const projection = projectResolvedSecretContent(withSafeRefs, context.matcher, maxBytes, { + isOpaqueSafeObject: (candidate) => context.safeLargeValues.has(candidate), }) + if (!projection.safe) { + throw new TraceSecretProjectionError('Trace content could not be sanitized') + } + return projection.value } async function storeSanitizedLargeValue( @@ -1473,13 +1194,13 @@ function assertNoPlaintext( ): void { visitNode(state, depth) if (typeof value === 'string') { - if (containsSecret(value, context.matcher)) { + if (containsResolvedSecret(value, context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace content still contains a secret') } return } if (value === null || typeof value === 'number' || typeof value === 'boolean') { - if (containsSecret(String(value), context.matcher)) { + if (containsResolvedSecret(String(value), context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace primitive still contains a secret') } return @@ -1519,7 +1240,7 @@ function assertNoPlaintext( return } for (const [key, item] of enumerableDataEntries(value)) { - if (containsSecret(key, context.matcher)) { + if (containsResolvedSecret(key, context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace key still contains a secret') } assertNoPlaintext(item, context, state, depth + 1) @@ -1746,7 +1467,7 @@ async function verifyPostTransformLargeValues( async function assertPostTransformTraceSpansAreSafe( traceSpans: TraceSpan[], - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, store: LargeValueStoreContext ): Promise { const projection = createProjectionContext(matcher, store, false) @@ -1788,14 +1509,10 @@ export async function enforceTraceSpanSecretInvariant( try { if (!options.registry?.isComplete()) return structuralOnlyTraceSpans(traceSpans) - const replacements = normalizeReplacements(options.registry.getActiveMatches()) - if (replacements.length === 0) return traceSpans + const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches()) + if (!matcher) return traceSpans - await assertPostTransformTraceSpansAreSafe( - traceSpans, - createSecretMatcher(replacements), - options.store - ) + await assertPostTransformTraceSpansAreSafe(traceSpans, matcher, options.store) return traceSpans } catch { logger.warn('Trace secret invariant failed; retaining structural spans only') @@ -1816,11 +1533,11 @@ export async function projectTraceSpansForSecrets( } try { - const replacements = normalizeReplacements(options.registry.getActiveMatches()) - if (replacements.length === 0) return cloneTraceSpansForProjection(traceSpans) + const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches()) + if (!matcher) return cloneTraceSpansForProjection(traceSpans) const context = createProjectionContext( - createSecretMatcher(replacements), + matcher, options.store, options.allowLargeValueWrites !== false ) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index b1341811878..5655bbe877b 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -3,15 +3,27 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { decryptSecretMock } = vi.hoisted(() => ({ +const { decryptSecretMock, materializeLargeValueRefMock, storeLargeValueMock } = vi.hoisted(() => ({ decryptSecretMock: vi.fn(), + materializeLargeValueRefMock: vi.fn(), + storeLargeValueMock: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: decryptSecretMock, })) -import { projectExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +vi.mock('@/lib/execution/payloads/store', () => ({ + materializeLargeValueRef: materializeLargeValueRefMock, + storeLargeValue: storeLargeValueMock, +})) + +import { + externalizeExecutionData, + materializeExecutionData, + projectExecutionDataForDisplay, + TRACE_STORE_REF_KEY, +} from '@/lib/logs/execution/trace-store' const CONTEXT = { workspaceId: 'workspace-1', @@ -25,6 +37,55 @@ beforeEach(() => { decryptSecretMock.mockResolvedValue({ decrypted: '1234' }) }) +describe('execution data storage', () => { + it('keeps the trusted Copilot binding when an externalized payload is unavailable', async () => { + const correlation = { copilotToolCallId: 'tool-call-1' } + const ref = { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'object', + size: 128, + key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_bbbbbbbbbbbb.json', + executionId: 'execution-1', + preview: { unsafe: 'must-not-remain-inline' }, + } as const + storeLargeValueMock.mockResolvedValue(ref) + materializeLargeValueRefMock.mockRejectedValue(new Error('object unavailable')) + + const slim = await externalizeExecutionData( + { + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + finalOutput: { unsafe: 'must-not-remain-inline' }, + }, + CONTEXT + ) + + expect(slim).toEqual({ + [TRACE_STORE_REF_KEY]: { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'object', + size: 128, + key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_bbbbbbbbbbbb.json', + executionId: 'execution-1', + }, + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + }) + + await expect(materializeExecutionData(slim, CONTEXT)).resolves.toEqual({ + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + }) + }) +}) + describe('projectExecutionDataForDisplay', () => { it('projects persisted output, input, errors, and spans from trusted provenance', async () => { const executionData = { diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 16bf3b6cd75..f429bade600 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -20,14 +20,13 @@ export const TRACE_STORE_REF_KEY = 'traceStoreRef' /** * The only metadata kept inline on the slim row (everything else lives in the - * externalized object). These two describe trace presence/count and uniquely - * survive object expiry — so a reader can still report "trace data expired (N - * spans)" after retention without an object fetch. All other fields + * externalized object). Trace presence/count survives object expiry for log + * diagnostics, while correlation preserves the server-issued binding used to + * authenticate terminal Copilot workflow-tool executions. All other fields * (environment, trigger, tokens, models, truncation flags, and of course the - * heavy payloads) are in the stored object and recovered on materialize, so - * keeping them inline too would just be duplication. + * heavy payloads) are recovered from the stored object. */ -const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount'] as const +const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount', 'correlation'] as const /** * Read-path context. Resolves an externalized payload by storage key, authorized diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts new file mode 100644 index 00000000000..f8231124a92 --- /dev/null +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckWorkspaceAccess, + mockGetUserEntityPermissions, + mockRunHeadlessCopilotLifecycle, + mockSendInboxResponse, +} = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockRunHeadlessCopilotLifecycle: vi.fn(), + mockSendInboxResponse: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/auth/ban', () => ({ + getActivelyBannedUserIds: vi.fn().mockResolvedValue([]), + isEmailBlocked: vi.fn().mockResolvedValue(false), +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + resolveOrCreateChat: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/payload', () => ({ + buildIntegrationToolSchemas: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/chat/persisted-message', () => ({ + buildPersistedAssistantMessage: vi.fn().mockReturnValue({ id: 'assistant-message' }), + buildPersistedUserMessage: vi.fn().mockReturnValue({ id: 'user-message' }), +})) + +vi.mock('@/lib/copilot/chat/workspace-context', () => ({ + generateWorkspaceContext: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: vi.fn() }, +})) + +vi.mock('@/lib/copilot/entitlements', () => ({ + computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ + runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, +})) + +vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ + requestChatTitle: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isDocSandboxEnabled: false, + isHosted: true, +})) + +vi.mock('@/lib/mothership/inbox/agentmail-client', () => ({})) + +vi.mock('@/lib/mothership/inbox/response', () => ({ + sendInboxResponse: mockSendInboxResponse, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('owner-1'), +})) + +import { executeInboxTask } from '@/lib/mothership/inbox/executor' + +const INBOX_TASK = { + id: 'task-1', + workspaceId: 'workspace-1', + status: 'received', + fromEmail: 'sender@example.com', + fromName: 'Sender', + subject: 'Task', + bodyPreview: 'Please do this', + bodyText: 'Please do this', + bodyHtml: null, + hasAttachments: false, + agentmailMessageId: null, + chatId: 'chat-1', +} + +const WORKSPACE = { + id: 'workspace-1', + ownerId: 'owner-1', + inboxProviderId: 'provider-1', + inboxSecretScope: 'selected', + inboxMountedSecrets: ['INBOX_KEY'], +} + +describe('Inbox raw-secret actor', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ permission: 'write' }) + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: true, + content: 'done', + contentBlocks: [], + toolCalls: [], + chatId: 'chat-1', + }) + mockSendInboxResponse.mockResolvedValue('response-1') + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'task-1' }]) + .mockResolvedValueOnce([{ model: 'claude-opus-4-8' }]) + }) + + it('gives a workspace member their own raw-secret authority', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, [{ id: 'member-1' }]) + mockGetUserEntityPermissions.mockResolvedValue('write') + + await executeInboxTask('task-1') + + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: 'member-1', + secretActorUserId: 'member-1', + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['INBOX_KEY'], + }, + }) + ) + }) + + it('keeps owner execution fallback but removes raw-secret authority for an external sender', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, []) + + await executeInboxTask('task-1') + + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: 'owner-1', + secretActorUserId: null, + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['INBOX_KEY'], + }, + }) + ) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 21fcc48c286..4359130a79d 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -18,6 +18,7 @@ import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' @@ -64,6 +65,8 @@ export async function executeInboxTask(taskId: string): Promise { id: workspace.id, ownerId: workspace.ownerId, inboxProviderId: workspace.inboxProviderId, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, }) .from(workspace) .where(eq(workspace.id, inboxTask.workspaceId)) @@ -82,14 +85,15 @@ export async function executeInboxTask(taskId: string): Promise { let responseSent = false try { - const [[claimed], userId] = await Promise.all([ + const [[claimed], actor] = await Promise.all([ db .update(mothershipInboxTask) .set({ status: 'processing', processingStartedAt: new Date() }) .where(and(eq(mothershipInboxTask.id, taskId), eq(mothershipInboxTask.status, 'received'))) .returning({ id: mothershipInboxTask.id }), - resolveUserId(inboxTask.fromEmail, ws), + resolveInboxExecutionActor(inboxTask.fromEmail, ws), ]) + const userId = actor.executionUserId if (!claimed) { logger.info('Task already claimed by another execution, skipping', { taskId }) @@ -252,6 +256,12 @@ export async function executeInboxTask(taskId: string): Promise { autoExecuteTools: true, interactive: false, billingAttribution, + ...(userPermission ? { userPermission } : {}), + secretActorUserId: actor.secretActorUserId, + secretMountPolicy: normalizeSecretMountPolicy({ + secretScope: ws.inboxSecretScope, + mountedSecrets: ws.inboxMountedSecrets, + }), }) const cleanContent = stripThinkingTags(result.content || '') @@ -328,13 +338,19 @@ export async function executeInboxTask(taskId: string): Promise { } /** - * Resolve which user ID to use for execution. - * Match sender email to a workspace member, fallback to workspace owner. + * Resolve the execution and raw-secret actors independently. Workspace members + * execute and mount secrets as themselves. External senders retain the existing + * owner execution fallback but receive no raw-secret actor. */ -async function resolveUserId( +interface InboxExecutionActor { + executionUserId: string + secretActorUserId: string | null +} + +async function resolveInboxExecutionActor( senderEmail: string, ws: { id: string; ownerId: string } -): Promise { +): Promise { const [matchedUser] = await db .select({ id: user.id }) .from(user) @@ -345,11 +361,11 @@ async function resolveUserId( if (matchedUser) { const permission = await getUserEntityPermissions(matchedUser.id, 'workspace', ws.id) if (permission !== null) { - return matchedUser.id + return { executionUserId: matchedUser.id, secretActorUserId: matchedUser.id } } } - return ws.ownerId + return { executionUserId: ws.ownerId, secretActorUserId: null } } /** diff --git a/apps/sim/lib/workflows/executor/execution-state.test.ts b/apps/sim/lib/workflows/executor/execution-state.test.ts index 39aa68c066d..ae72a5e218f 100644 --- a/apps/sim/lib/workflows/executor/execution-state.test.ts +++ b/apps/sim/lib/workflows/executor/execution-state.test.ts @@ -19,6 +19,7 @@ import { getExecutionInputForWorkflow, getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId, + getTrustedWorkflowToolExecution, } from '@/lib/workflows/executor/execution-state' const EXECUTION_STATE = { @@ -75,6 +76,186 @@ describe('execution state lookup', () => { expect(result).toEqual(EXECUTION_STATE) }) + it('loads a terminal workflow result with an exact persisted Copilot binding', async () => { + const provenance = { + version: 1 as const, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + } + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + finalOutput: { token: 'raw-secret' }, + executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: provenance }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: true, + finalOutput: { token: 'raw-secret' }, + blockLogs: [], + provenance, + }) + }) + + it('accepts a bound complete execution with no activated secrets', async () => { + const provenance = { version: 1 as const, complete: true, entries: [] } + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + trigger: { data: { correlation: { copilotToolCallId: 'tool-call-1' } } }, + executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: provenance }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toMatchObject({ provenance }) + }) + + it('returns validated incomplete provenance so the terminal projector can fail closed', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'failed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: false, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toMatchObject({ + status: 'failed', + provenance: { version: 1, complete: false, entries: [] }, + }) + }) + + it('trusts compacted terminal status while withholding unavailable execution content', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + executionStateSummary: { + executedBlockCount: 1, + blockLogCount: 1, + completedLoopCount: 0, + activeExecutionPathLength: 0, + pendingQueueLength: 0, + }, + finalOutput: { token: 'must-not-cross' }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: false, + }) + }) + + it('withholds execution content when persisted provenance is malformed', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + finalOutput: { token: 'must-not-cross' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 2, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: false, + }) + }) + + it('rejects mismatched bindings and nonterminal rows', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'another-tool-call' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'running', + executionData: {}, + }, + ]) + + await expect( + getTrustedWorkflowToolExecution('execution-2', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + }) + it('materializes externalized execution data when reusing workflow input', async () => { const slimExecutionData = { traceStoreRef: { diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index c854890d71e..4f0b8eacfbf 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -4,6 +4,10 @@ import { isRecordLike } from '@sim/utils/object' import { and, desc, eq, or, sql } from 'drizzle-orm' import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store' import type { SerializableExecutionState } from '@/executor/execution/types' +import { + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceProvenanceV1, +} from '@/executor/utils/resolved-secret-trace-registry' const LATEST_EXECUTION_STATE_CANDIDATE_LIMIT = 10 @@ -54,37 +58,43 @@ interface ExecutionStateRow { executionId: string workflowId: string | null workspaceId: string + status?: string executionData: unknown } -async function materializeExecutionDataFromRow( - row: ExecutionStateRow | undefined -): Promise | null> { - if (!row) return null +interface TrustedWorkflowToolExecutionBase { + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'cancelled' +} - return materializeExecutionData(row.executionData as Record | null, { - workspaceId: row.workspaceId, - workflowId: row.workflowId, - executionId: row.executionId, - }) +export interface TrustedWorkflowToolExecutionWithoutContent + extends TrustedWorkflowToolExecutionBase { + contentAvailable: false } -async function extractExecutionStateFromRow( - row: ExecutionStateRow | undefined -): Promise { - const executionData = await materializeExecutionDataFromRow(row) - return extractExecutionState(executionData) +export interface TrustedWorkflowToolExecutionWithContent extends TrustedWorkflowToolExecutionBase { + contentAvailable: true + finalOutput?: unknown + error?: string + blockLogs: SerializableExecutionState['blockLogs'] + provenance: ResolvedSecretTraceProvenanceV1 } -export async function getExecutionStateForWorkflow( +export type TrustedWorkflowToolExecution = + | TrustedWorkflowToolExecutionWithoutContent + | TrustedWorkflowToolExecutionWithContent + +async function getExecutionStateRow( executionId: string, workflowId: string -): Promise { +): Promise { const [row] = await db .select({ executionId: workflowExecutionLogs.executionId, workflowId: workflowExecutionLogs.workflowId, workspaceId: workflowExecutionLogs.workspaceId, + status: workflowExecutionLogs.status, executionData: workflowExecutionLogs.executionData, }) .from(workflowExecutionLogs) @@ -96,9 +106,94 @@ export async function getExecutionStateForWorkflow( ) .limit(1) + return row +} + +async function materializeExecutionDataFromRow( + row: ExecutionStateRow | undefined +): Promise | null> { + if (!row) return null + + return materializeExecutionData(row.executionData as Record | null, { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + }) +} + +async function extractExecutionStateFromRow( + row: ExecutionStateRow | undefined +): Promise { + const executionData = await materializeExecutionDataFromRow(row) + return extractExecutionState(executionData) +} + +export async function getExecutionStateForWorkflow( + executionId: string, + workflowId: string +): Promise { + const row = await getExecutionStateRow(executionId, workflowId) return extractExecutionStateFromRow(row) } +/** Loads a terminal workflow result only when its server-persisted Copilot binding matches. */ +export async function getTrustedWorkflowToolExecution( + executionId: string, + workflowId: string, + copilotToolCallId: string +): Promise { + const row = await getExecutionStateRow(executionId, workflowId) + if ( + !row || + (row.status !== 'completed' && row.status !== 'failed' && row.status !== 'cancelled') + ) { + return null + } + + const executionData = await materializeExecutionDataFromRow(row) + const state = extractExecutionState(executionData) + const provenance = state?.resolvedSecretTraceProvenance + const topLevelCorrelation = executionData?.correlation + const triggerCorrelation = isRecordLike(executionData?.trigger) + ? executionData.trigger.data + : undefined + const correlation = isRecordLike(topLevelCorrelation) + ? topLevelCorrelation + : isRecordLike(triggerCorrelation) && isRecordLike(triggerCorrelation.correlation) + ? triggerCorrelation.correlation + : undefined + + if ( + !executionData || + !isRecordLike(correlation) || + correlation.copilotToolCallId !== copilotToolCallId + ) { + return null + } + + if (!state || !isResolvedSecretTraceProvenanceV1(provenance)) { + return { + executionId, + workflowId, + status: row.status, + contentAvailable: false, + } + } + + return { + executionId, + workflowId, + status: row.status, + contentAvailable: true, + ...(Object.hasOwn(executionData, 'finalOutput') + ? { finalOutput: executionData.finalOutput } + : {}), + ...(typeof executionData.error === 'string' ? { error: executionData.error } : {}), + blockLogs: state.blockLogs, + provenance, + } +} + /** * Returns the workflow input recorded for a past execution so a new run can * reuse it by reference. `found` distinguishes a missing execution from an @@ -108,21 +203,7 @@ export async function getExecutionInputForWorkflow( executionId: string, workflowId: string ): Promise<{ found: boolean; input?: unknown }> { - const [row] = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workflowId, workflowId) - ) - ) - .limit(1) + const row = await getExecutionStateRow(executionId, workflowId) if (!row) { return { found: false } diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 404af18c04f..07f541e553f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -42,13 +42,27 @@ const multiTriggerConfig = { ], } +const mothershipConfig = { + type: 'mothership', + name: 'Sim Chat', + category: 'blocks', + outputs: {}, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { id: 'mountedSecrets', type: 'dropdown', hideFromCopilot: true }, + ], +} + vi.mock('@/blocks/registry', () => ({ getBlock: (type: string) => type === 'generic_webhook' ? genericWebhookConfig : type === 'github_v2' ? multiTriggerConfig - : undefined, + : type === 'mothership' + ? mothershipConfig + : undefined, })) /** @@ -112,6 +126,29 @@ describe('sanitizeForCopilot knowledge tag subblocks', () => { }) }) +describe('sanitizeForCopilot server-only block inputs', () => { + it('omits Sim Chat secret-mount policy while retaining model-visible inputs', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('chat-1', { + type: 'mothership', + name: 'Sim Chat 1', + enabled: true, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'Help me' }, + secretScope: { id: 'secretScope', type: 'dropdown', value: 'selected' }, + mountedSecrets: { + id: 'mountedSecrets', + type: 'dropdown', + value: ['OPENAI_API_KEY'], + }, + }, + }) + ) + + expect(result.blocks['chat-1'].inputs).toEqual({ prompt: 'Help me' }) + }) +}) + /** Builds a one-block workflow for webhook-URL synthesis tests. */ function makeSingleBlockWorkflow(blockId: string, block: Record): WorkflowState { return { diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 2d4f09bba40..812dbb62f6f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -270,11 +270,14 @@ function isToolInput(value: unknown): value is ToolInput { * already handled by `sanitizeWorkflowForSharing`. */ function sanitizeSubBlocks( - subBlocks: BlockState['subBlocks'] + subBlocks: BlockState['subBlocks'], + hiddenIds: ReadonlySet ): Record { const sanitized: Record = {} Object.entries(subBlocks).forEach(([key, subBlock]) => { + if (hiddenIds.has(key)) return + // Skip null/undefined values if (subBlock.value === null || subBlock.value === undefined) { return @@ -569,7 +572,12 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState { inputs = loopInputs } else { // For regular blocks, sanitize subBlocks - inputs = sanitizeSubBlocks(block.subBlocks) + const hiddenIds = new Set( + (getBlock(block.type)?.subBlocks ?? []) + .filter((subBlock) => subBlock.hideFromCopilot) + .map((subBlock) => subBlock.id) + ) + inputs = sanitizeSubBlocks(block.subBlocks, hiddenIds) const webhookUrl = resolveTriggerWebhookUrl(blockId, block) if (webhookUrl) { diff --git a/apps/sim/lib/workflows/schedules/orchestration.test.ts b/apps/sim/lib/workflows/schedules/orchestration.test.ts index b656490043d..771cdd5840c 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.test.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.test.ts @@ -32,12 +32,15 @@ import { performUpdateJob } from '@/lib/workflows/schedules/orchestration' const BASE_JOB = { id: 'job-1', sourceWorkspaceId: 'workspace-1', + sourceUserId: 'user-1', sourceType: 'job', archivedAt: null, timezone: 'UTC', cronExpression: null, jobTitle: 'Nightly task', status: 'disabled', + secretScope: 'all', + mountedSecrets: [], } describe('performUpdateJob', () => { @@ -81,4 +84,50 @@ describe('performUpdateJob', () => { nextRunAt: new Date('2099-01-01T09:00:00Z'), }) }) + + it('denies task content edits from a non-creator without writing', async () => { + queueTableRows(schemaMock.workflowSchedule, [BASE_JOB]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'workspace-writer', + prompt: 'Changed prompt', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'forbidden' }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('allows a non-creator to pause a task', async () => { + queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'active' }]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'workspace-writer', + status: 'paused', + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ status: 'disabled' }) + }) + + it('persists a canonical selected secret policy for the creator', async () => { + queueTableRows(schemaMock.workflowSchedule, [BASE_JOB]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'user-1', + secretScope: 'selected', + mountedSecrets: [' B ', 'A', 'B'], + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ + secretScope: 'selected', + mountedSecrets: ['B', 'A'], + }) + }) }) diff --git a/apps/sim/lib/workflows/schedules/orchestration.ts b/apps/sim/lib/workflows/schedules/orchestration.ts index 47da9c5e71d..28a49eec405 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.ts @@ -6,6 +6,10 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import type { ScheduleContext } from '@/lib/api/contracts/schedules' +import { + normalizeSecretMountPolicy, + type SecretMountScope, +} from '@/lib/copilot/secret-mount-policy' import { captureServerEvent } from '@/lib/posthog/server' import { computeNextRunAt, @@ -15,7 +19,7 @@ import { const logger = createLogger('ScheduleOrchestration') -type ScheduleErrorCode = 'not_found' | 'validation' | 'internal' +type ScheduleErrorCode = 'not_found' | 'forbidden' | 'validation' | 'internal' interface ActorMetadata { actorName?: string | null @@ -39,6 +43,8 @@ export interface PerformCreateJobParams extends ActorMetadata { endsAt?: string | null /** `@`-mentioned resources / `/`-invoked skills captured with the prompt. */ contexts?: ScheduleContext[] | null + secretScope?: SecretMountScope + mountedSecrets?: string[] sourceChatId?: string | null sourceTaskName?: string | null } @@ -68,6 +74,8 @@ export interface PerformUpdateJobParams extends ActorMetadata { maxRuns?: number | null endsAt?: string | null contexts?: ScheduleContext[] | null + secretScope?: SecretMountScope + mountedSecrets?: string[] } export interface PerformExcludeOccurrenceParams extends ActorMetadata { @@ -192,6 +200,7 @@ export async function performCreateJob( try { const id = generateId() const now = new Date() + const secretMountPolicy = normalizeSecretMountPolicy(params) await db.insert(workflowSchedule).values({ id, workflowId: null, @@ -217,6 +226,8 @@ export async function performCreateJob( sourceTaskName: params.sourceTaskName || null, sourceUserId: params.userId, sourceWorkspaceId: params.workspaceId, + secretScope: secretMountPolicy.secretScope, + mountedSecrets: secretMountPolicy.mountedSecrets, }) const [schedule] = await db @@ -284,6 +295,27 @@ export async function performUpdateJob( if (!job) return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' } + const hasCreatorOnlyUpdate = + params.title !== undefined || + params.prompt !== undefined || + params.cronExpression !== undefined || + params.time !== undefined || + params.timezone !== undefined || + params.lifecycle !== undefined || + params.successCondition !== undefined || + params.maxRuns !== undefined || + params.endsAt !== undefined || + params.contexts !== undefined || + params.secretScope !== undefined || + params.mountedSecrets !== undefined + if (hasCreatorOnlyUpdate && job.sourceUserId !== params.userId) { + return { + success: false, + error: 'Only the task creator can edit this task', + errorCode: 'forbidden', + } + } + const updates: Partial = { updatedAt: new Date() } if (params.title !== undefined) updates.jobTitle = params.title.trim() if (params.prompt !== undefined) updates.prompt = params.prompt.trim() @@ -312,6 +344,14 @@ export async function performUpdateJob( if (params.successCondition !== undefined) updates.successCondition = params.successCondition if (params.maxRuns !== undefined) updates.maxRuns = params.maxRuns if (params.contexts !== undefined) updates.contexts = params.contexts + if (params.secretScope !== undefined || params.mountedSecrets !== undefined) { + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: params.secretScope ?? job.secretScope, + mountedSecrets: params.mountedSecrets ?? job.mountedSecrets, + }) + updates.secretScope = secretMountPolicy.secretScope + updates.mountedSecrets = secretMountPolicy.mountedSecrets + } const effectiveStatus = updates.status ?? job.status let endsAt: Date | null = job.endsAt diff --git a/apps/sim/lib/workflows/subblocks/options.ts b/apps/sim/lib/workflows/subblocks/options.ts index 8f5baab9b33..bf97f149a21 100644 --- a/apps/sim/lib/workflows/subblocks/options.ts +++ b/apps/sim/lib/workflows/subblocks/options.ts @@ -1,7 +1,13 @@ +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' import { fetchWorkspaceEnvironment } from '@/lib/environment/api' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment' import { getSandboxListQueryOptions, type SandboxListResponse } from '@/hooks/queries/sandboxes' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -70,6 +76,21 @@ export async function fetchWorkspaceSecretNameOptions(): Promise ({ id: name, label: name })) } +/** Loads only secret names the current actor may mount as plaintext into Copilot code. */ +export async function fetchWorkspaceRawSecretNameOptions(): Promise { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + const credentials = await getQueryClient().fetchQuery({ + queryKey: workspaceCredentialKeys.list(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchWorkspaceCredentialList(workspaceId, signal), + staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, + }) + + return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name })) +} + /** * Labels a sandbox for the picker. The name is what identifies it, so that is all * the label carries by default — the block's own list is already scoped to one diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index f39614c6987..ef27aa74c40 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -329,6 +329,16 @@ export class Serializer { enabled: block.enabled, } + const privateInputIds = new Set() + for (const subBlock of blockConfig.subBlocks) { + if (!subBlock.hideFromCopilot) continue + privateInputIds.add(subBlock.id) + if (subBlock.canonicalParamId) privateInputIds.add(subBlock.canonicalParamId) + } + if (privateInputIds.size > 0) { + serialized.privateInputIds = [...privateInputIds] + } + if (block.data?.canonicalModes) { serialized.canonicalModes = block.data.canonicalModes as Record } diff --git a/apps/sim/serializer/private-inputs.test.ts b/apps/sim/serializer/private-inputs.test.ts new file mode 100644 index 00000000000..d97e0e6d109 --- /dev/null +++ b/apps/sim/serializer/private-inputs.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const { mockGetBlock } = vi.hoisted(() => ({ + mockGetBlock: vi.fn(), +})) + +vi.mock('@/blocks', () => ({ + getBlock: mockGetBlock, +})) + +vi.mock('@/tools/metadata', () => ({ + getToolParams: vi.fn(() => undefined), +})) + +import { Serializer } from '@/serializer' + +describe('Serializer private inputs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue({ + name: 'Private lifecycle block', + description: 'Test block', + category: 'blocks', + bgColor: '#000000', + tools: { + access: ['private_lifecycle'], + config: { tool: () => 'private_lifecycle' }, + }, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { + id: 'mountedSecretsAdvanced', + canonicalParamId: 'mountedSecrets', + type: 'dropdown', + hideFromCopilot: true, + }, + ], + inputs: { + prompt: { type: 'string' }, + secretScope: { type: 'string' }, + mountedSecrets: { type: 'json' }, + }, + outputs: {}, + }) + }) + + it('derives executor-private input ids from block metadata', () => { + const block = { + id: 'block-1', + type: 'private_lifecycle', + name: 'Private lifecycle block', + position: { x: 0, y: 0 }, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'Run the task' }, + secretScope: { id: 'secretScope', type: 'dropdown', value: 'selected' }, + mountedSecretsAdvanced: { + id: 'mountedSecretsAdvanced', + type: 'dropdown', + value: ['API_KEY'], + }, + }, + outputs: {}, + enabled: true, + } as BlockState + + const serialized = new Serializer().serializeWorkflow({ [block.id]: block }, [], {}) + + expect(serialized.blocks[0].privateInputIds).toEqual([ + 'secretScope', + 'mountedSecretsAdvanced', + 'mountedSecrets', + ]) + }) +}) diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index 8d7bc56e4ed..2fb123ecee9 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -40,6 +40,8 @@ export interface SerializedBlock { enabled: boolean /** Canonical mode overrides from block.data (used by agent handler for tool param resolution) */ canonicalModes?: Record + /** Server-only lifecycle input ids omitted from execution-log projections. */ + privateInputIds?: string[] } export interface SerializedLoop { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index d3895b9c367..579dedc54ff 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -26,6 +26,7 @@ import { import { sleep } from '@sim/utils/helpers' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { ANONYMOUS_SECRET_TRACE_REPLACEMENT, ResolvedSecretTraceRegistry, @@ -700,12 +701,97 @@ describe('executeTool Function', () => { expect(new Headers(requestInit?.headers).get('x-sim-request-private-tool-metadata')).toBe( 'resolved-secret-names-v1' ) - expect(result.output).not.toHaveProperty('__resolvedSecretNames') + expect(result.output).toEqual({ + success: true, + output: { result: 'secret-value', stdout: '' }, + }) expect(registry.getActiveMatches()).toEqual([ { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, ]) }) + it('fails concurrent projection closed while custom-tool provenance is pending', async () => { + const secret = 'custom-tool-secret-value' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'API_KEY', + plaintext: secret, + encryptedValue: 'encrypted-value', + }, + ]) + mockGetToolAsync.mockResolvedValueOnce({ + id: 'custom_pending-provenance', + name: 'Pending provenance custom tool', + description: 'Tests late provenance activation', + version: '1.0.0', + params: {}, + request: { + url: '/api/function/execute', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: () => ({ code: 'return {{API_KEY}}', envVars: { API_KEY: secret } }), + }, + transformResponse: async (response: Response) => { + const data = await response.json() + return { success: true, output: data.output } + }, + }) + + let resolveRequest!: (response: Response) => void + let markRequestStarted!: () => void + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + global.fetch = Object.assign( + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + markRequestStarted() + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const execution = executeTool( + 'custom_pending-provenance', + { envVars: { API_KEY: secret } }, + { + executionContext: createToolExecutionContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await requestStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).not.toHaveProperty('output') + + resolveRequest( + new Response( + JSON.stringify({ + success: true, + output: { result: secret }, + __resolvedSecretNames: ['API_KEY'], + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-names-v1', + }, + } + ) + ) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).toMatchObject({ output: { result: '{{API_KEY}}' } }) + }) + it('keeps the Function result unchanged when requested provenance is missing', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( @@ -1778,6 +1864,53 @@ describe('Copilot Env Variable Reference Resolution', () => { expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') }) + it('fails concurrent projection closed while a user-only secret reference is resolving', async () => { + const secret = 'sntrys_real_token' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SENTRY_AUTH_TOKEN', + plaintext: secret, + encryptedValue: 'encrypted-token', + }, + ]) + let resolveEnvironment!: (variables: Record) => void + let markResolutionStarted!: () => void + const resolutionStarted = new Promise((resolve) => { + markResolutionStarted = resolve + }) + mockGetEffectiveDecryptedEnv.mockImplementationOnce( + () => + new Promise>((resolve) => { + resolveEnvironment = resolve + markResolutionStarted() + }) + ) + mockJsonFetch() + + const execution = executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { + executionContext: copilotContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await resolutionStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).not.toHaveProperty('output') + + resolveEnvironment({ SENTRY_AUTH_TOKEN: secret }) + await expect(execution).resolves.toMatchObject({ success: true }) + + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).toMatchObject({ output: { result: '{{SENTRY_AUTH_TOKEN}}' } }) + }) + it('trims whitespace inside the braces like the executor resolver', async () => { const fetchMock = mockJsonFetch() @@ -2251,6 +2384,74 @@ describe('MCP Tool Execution', () => { ]) }) + it('fails concurrent projection closed while MCP provenance is pending', async () => { + const secret = 'mcp-secret-value' + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'test-user', + workspaceId: 'workspace-456', + }) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: secret }) + + let resolveRequest!: (response: Response) => void + let markRequestStarted!: () => void + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + global.fetch = Object.assign( + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + markRequestStarted() + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const execution = executeTool( + 'mcp-123-list_files', + { path: '/test' }, + { + executionContext: createToolExecutionContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await requestStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { value: secret } }, registry) + ).not.toHaveProperty('output') + + resolveRequest( + new Response( + JSON.stringify({ + success: true, + data: { output: { content: [{ type: 'text', text: secret }] } }, + __resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-token' }], + scope: { userId: 'test-user', workspaceId: 'workspace-456' }, + }, + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-provenance-v1', + }, + } + ) + ) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { value: secret } }, registry) + ).toMatchObject({ output: { value: '{{MCP_TOKEN}}' } }) + }) + it('rejects unmarked MCP provenance instead of trusting a response body field', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 36f9a0eeb28..3d20a4faaaf 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -243,28 +243,33 @@ async function resolveCopilotEnvReferences( ) } - const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') - const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) - - for (const { paramId, value } of pending) { - const missingKeys: string[] = [] - const resolved = resolveEnvVarReferences(value, envVars, { - allowEmbedded: false, - missingKeys, - onResolved: (name, resolvedValue) => { - resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) - }, - }) - if (missingKeys.length > 0) { - const scopeHint = scope.workspaceId - ? '' - : ' (no workspace context — only personal variables are available here)' - throw new Error( - `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + - `Check environment/variables.json for available variable names.` - ) + const completePendingActivation = resolvedSecretTraceRegistry?.beginPendingActivation() + try { + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') + const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) + + for (const { paramId, value } of pending) { + const missingKeys: string[] = [] + const resolved = resolveEnvVarReferences(value, envVars, { + allowEmbedded: false, + missingKeys, + onResolved: (name, resolvedValue) => { + resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) + }, + }) + if (missingKeys.length > 0) { + const scopeHint = scope.workspaceId + ? '' + : ' (no workspace context — only personal variables are available here)' + throw new Error( + `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + + `Check environment/variables.json for available variable names.` + ) + } + params[paramId] = resolved as string } - params[paramId] = resolved as string + } finally { + completePendingActivation?.() } } @@ -1246,6 +1251,7 @@ export async function executeTool( // Hoisted so the outer catch can attribute a thrown failure to the chosen key. let hostedKeyForMetrics: { provider: string; tool: string; key: string } | undefined + let completePendingSecretActivation: (() => void) | undefined try { let tool: ToolConfig | undefined @@ -1271,6 +1277,10 @@ export async function executeTool( ? RESOLVED_SECRET_NAMES_METADATA_V1 : undefined + if (resolvedSecretTraceRegistry && (privateToolMetadataType || toolKind === 'mcp')) { + completePendingSecretActivation = resolvedSecretTraceRegistry.beginPendingActivation() + } + // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools` // denylist is enforced alongside the existing mcp/custom/skill gates. if (scope.userId && scope.workspaceId) { @@ -1773,6 +1783,8 @@ export async function executeTool( duration, }, } + } finally { + completePendingSecretActivation?.() } } diff --git a/packages/db/migrations/0280_great_riptide.sql b/packages/db/migrations/0280_great_riptide.sql new file mode 100644 index 00000000000..e895e1ec040 --- /dev/null +++ b/packages/db/migrations/0280_great_riptide.sql @@ -0,0 +1,5 @@ +-- migration-safe: additive columns use non-null defaults, so old and new app versions can read and write these rows throughout the deploy. +ALTER TABLE "workflow_schedule" ADD COLUMN "secret_scope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD COLUMN "mounted_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_secret_scope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_mounted_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL; diff --git a/packages/db/migrations/meta/0280_snapshot.json b/packages/db/migrations/meta/0280_snapshot.json new file mode 100644 index 00000000000..0a4dee2c63f --- /dev/null +++ b/packages/db/migrations/meta/0280_snapshot.json @@ -0,0 +1,18398 @@ +{ + "id": "d1c2701c-3233-4ce4-9bf1-3ee3065c8e9b", + "prevId": "4b619949-ee98-4251-b621-5f37a9fa23a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 30be907c184..64de524fd6e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1954,6 +1954,13 @@ "when": 1785542556609, "tag": "0279_collab_doc_state_and_content_version", "breakpoints": true + }, + { + "idx": 280, + "version": "7", + "when": 1785640502989, + "tag": "0280_great_riptide", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 87805620307..e29c6825f91 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -756,6 +756,8 @@ export const workflowSchedule = pgTable( sourceWorkspaceId: text('source_workspace_id').references(() => workspace.id, { onDelete: 'cascade', }), + secretScope: text('secret_scope').notNull().default('all'), + mountedSecrets: jsonb('mounted_secrets').$type().notNull().default([]), jobHistory: jsonb('job_history').$type>(), /** `@`-mentioned resources / `/`-invoked skills captured with the prompt, resolved into the agent run at fire time. */ contexts: jsonb('contexts').$type>>(), @@ -1595,6 +1597,8 @@ export const workspace = pgTable( inboxEnabled: boolean('inbox_enabled').notNull().default(false), inboxAddress: text('inbox_address'), inboxProviderId: text('inbox_provider_id'), + inboxSecretScope: text('inbox_secret_scope').notNull().default('all'), + inboxMountedSecrets: jsonb('inbox_mounted_secrets').$type().notNull().default([]), archivedAt: timestamp('archived_at'), organizationAssignedAt: timestamp('organization_assigned_at'), forkedFromWorkspaceId: text('forked_from_workspace_id').references( diff --git a/packages/testing/src/mocks/logging-session.mock.ts b/packages/testing/src/mocks/logging-session.mock.ts index 0f951db2484..3cefc0eb2e0 100644 --- a/packages/testing/src/mocks/logging-session.mock.ts +++ b/packages/testing/src/mocks/logging-session.mock.ts @@ -5,7 +5,8 @@ import { vi } from 'vitest' * `@/lib/logs/execution/logging-session`. Every instance method is backed by a * shared `vi.fn()` so tests that construct multiple sessions observe identical * mock state. `mockSafeStart` defaults to `true` because callers branch on the - * boolean result. All other methods resolve to `undefined`. + * boolean result. Projection methods return their input; other methods resolve + * to `undefined`. * * @example * ```ts @@ -24,6 +25,10 @@ export const loggingSessionMockFns = { mockSafeStart: vi.fn().mockResolvedValue(true), mockWaitForCompletion: vi.fn().mockResolvedValue(undefined), mockWaitForPostExecution: vi.fn().mockResolvedValue(undefined), + mockSetTrustedExecutionCorrelation: vi.fn(), + mockProjectBlockLogsForDisplay: vi.fn(async (logs: unknown) => logs), + mockProjectDisplayContent: vi.fn(async (content: unknown) => content), + mockProjectLiveDisplayText: vi.fn(async (_field: string, value: string) => ({ value })), mockSafeComplete: vi.fn().mockResolvedValue(undefined), mockSafeCompleteWithError: vi.fn().mockResolvedValue(undefined), mockSafeCompleteWithCancellation: vi.fn().mockResolvedValue(undefined), @@ -47,6 +52,10 @@ function buildLoggingSessionInstance() { safeStart: loggingSessionMockFns.mockSafeStart, waitForCompletion: loggingSessionMockFns.mockWaitForCompletion, waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution, + setTrustedExecutionCorrelation: loggingSessionMockFns.mockSetTrustedExecutionCorrelation, + projectBlockLogsForDisplay: loggingSessionMockFns.mockProjectBlockLogsForDisplay, + projectDisplayContent: loggingSessionMockFns.mockProjectDisplayContent, + projectLiveDisplayText: loggingSessionMockFns.mockProjectLiveDisplayText, safeComplete: loggingSessionMockFns.mockSafeComplete, safeCompleteWithError: loggingSessionMockFns.mockSafeCompleteWithError, safeCompleteWithCancellation: loggingSessionMockFns.mockSafeCompleteWithCancellation,