diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f3482d665d4..f3b23b10b5d 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -19,6 +19,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here} - COPILOT_API_KEY=${COPILOT_API_KEY} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f038327feb..e91c1026469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,7 +252,7 @@ jobs: echo "ERROR: DEV_TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 exit 1 fi - bunx trigger.dev@4.4.3 deploy --env preview --branch dev-sim + bunx trigger.dev@4.5.7 deploy --env preview --branch dev-sim # Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR. # Runs in parallel with tests — only immutable sha tags are pushed here, and diff --git a/apps/docs/content/docs/en/integrations/knowledge.mdx b/apps/docs/content/docs/en/integrations/knowledge.mdx index d5493b4bb8c..42ebb1b7eb3 100644 --- a/apps/docs/content/docs/en/integrations/knowledge.mdx +++ b/apps/docs/content/docs/en/integrations/knowledge.mdx @@ -43,6 +43,7 @@ Search for similar content in a knowledge base using vector similarity | `query` | string | No | Search query text \(optional when using tag filters\) | | `topK` | number | No | Number of most similar results to return \(1-100\) | | `tagFilters` | array | No | Array of tag filters with tagName and tagValue properties | +| `searchMode` | string | No | Retrieval mode: 'vector' \(default\) uses semantic similarity only, 'hybrid' also runs a full-text leg and fuses both | | `rerankerEnabled` | boolean | No | Whether to apply Cohere reranking to vector search results | | `rerankerModel` | string | No | Cohere rerank model to use \(one of: rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5\) | | `rerankerInputCount` | number | No | Number of vector results sent to the Cohere reranker \(1–100\). Defaults to topK × 4 capped at 100. | diff --git a/apps/docs/content/docs/en/integrations/managed_agent.mdx b/apps/docs/content/docs/en/integrations/managed_agent.mdx index 2a42532eabf..dd652b99989 100644 --- a/apps/docs/content/docs/en/integrations/managed_agent.mdx +++ b/apps/docs/content/docs/en/integrations/managed_agent.mdx @@ -64,4 +64,200 @@ Open a Claude Platform Managed Agent session and return the assistant response a | `inputTokens` | number | Cumulative input tokens for the session. | | `outputTokens` | number | Cumulative output tokens for the session. | +### `managed_agent_create_session` + +Create a Claude Platform Managed Agent session and return its id without waiting for a reply. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `agent` | string | Yes | Managed-agent id inside the linked Claude workspace. | +| `environment` | string | Yes | Environment id inside the linked Claude workspace. | +| `environmentType` | string | No | Environment execution model hint \('cloud' \| 'self_hosted'\). | +| `userMessage` | string | No | Optional first message; seeds initial_events and starts the agent immediately. | +| `vaults` | array | No | Zero or more vault ids for MCP tool auth. | +| `vaultsAck` | boolean | No | Acknowledgement that the author may use the attached vaults. | +| `memoryStoreId` | string | No | Optional Agent Memory Store id. | +| `memoryAccess` | string | No | Memory store access mode: 'read_write' \(default\) or 'read_only'. | +| `memoryInstructions` | string | No | Per-attachment guidance for how the agent should use the memory store. | +| `files` | array | No | File attachments \(cloud envs only\), as \[\{fileId, mountPath?\}\]. | +| `sessionParameters` | object | No | Key/value session metadata forwarded to the session. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | Anthropic session id \(sesn_...\). | +| `started` | boolean | True when a first message was seeded, so the agent is already running. | + +### `managed_agent_send_message` + +Send a user message to an existing Claude Platform Managed Agent session. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userMessage` | string | Yes | The user message to send to the session. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session the message was sent to. | +| `sent` | boolean | True when the event was accepted by the API. | + +### `managed_agent_get_session` + +Read a Managed Agent session: status, stop reason, token usage, metadata, and any tool calls awaiting approval. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was read. | +| `status` | string | Session status — 'idle', 'running', 'rescheduling', or 'terminated'. | +| `stopReason` | string | Why the session last stopped, e.g. 'end_turn' or 'requires_action'. | +| `requiresAction` | boolean | True when the session is waiting on a tool confirmation or custom tool result. If this is true while pendingTools is empty, the session is blocked but the API named no blocking events — surface it rather than treating the session as done. | +| `pendingTools` | json | Blocking tool calls — \[\{id, eventType, kind, name, input\}\]. Route by kind: 'confirmation' ids go to Respond To Tool Confirmation, 'custom_tool_result' ids go to Respond To Custom Tool. | +| `metadata` | json | Session metadata. | +| `title` | string | Session title. | +| `inputTokens` | number | Cumulative input tokens. | +| `outputTokens` | number | Cumulative output tokens. | + +### `managed_agent_list_events` + +Read a Managed Agent session's event history and the agent's reply text. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `eventTypes` | array | No | Optional event-type filter, e.g. \['agent.message'\]. Omit to return every event. | +| `limit` | number | No | Maximum events to return, keeping the most recent \(default 500\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was read. | +| `events` | json | Session events, oldest first. | +| `count` | number | Number of events returned. | +| `assistantText` | string | Concatenated text of every persisted agent.message, in order. | +| `truncated` | boolean | True when the limit was hit and older events were dropped. | + +### `managed_agent_update_session` + +Update a Managed Agent session's title or metadata. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `title` | string | No | New session title. | +| `sessionParameters` | object | No | Replacement metadata map \(replaces all stored metadata, not merged\). Leaving it empty leaves the stored metadata unchanged — use clearMetadata to remove it. | +| `clearMetadata` | boolean | No | Removes all of the session's stored metadata. Overrides any map supplied above. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was updated. | +| `updated` | boolean | True when the update was accepted. | +| `metadata` | json | Metadata after the update. | +| `title` | string | Title after the update. | + +### `managed_agent_interrupt_session` + +Stop a running Managed Agent session; it stays usable afterwards. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was interrupted. | +| `interrupted` | boolean | True when the interrupt was accepted. | + +### `managed_agent_respond_tool_confirmation` + +Allow or deny the tool calls a Managed Agent session is waiting on before it can continue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `toolUseIds` | array | Yes | Blocking tool-use EVENT ids, from Get Session pendingTools\[\].id where kind is 'confirmation' \(not toolu_ ids\). | +| `decision` | string | Yes | 'allow' to let the tools run, or 'deny' to reject them. | +| `denyMessage` | string | No | Reason surfaced to the agent. Only sent when the decision is deny. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was answered. | +| `decision` | string | The decision applied — 'allow' or 'deny'. | +| `confirmedToolUseIds` | json | The tool-use event ids that were answered. | + +### `managed_agent_respond_custom_tool` + +Return the result of a custom tool a Managed Agent session is waiting on so it can continue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customToolUseId` | string | Yes | The custom tool-use EVENT id being answered, from Get Session pendingTools\[\].id where kind is 'custom_tool_result'. | +| `result` | string | Yes | The tool's output, returned to the agent as text. | +| `isError` | boolean | No | Mark the result as a failure so the agent can adjust its approach. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was answered. | +| `answeredToolUseId` | string | The custom tool-use event id that was answered. | + +### `managed_agent_archive_session` + +Archive a Managed Agent session, preserving its history. Not reversible. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was archived. | +| `archived` | boolean | True when the archive was accepted. | + +### `managed_agent_delete_session` + +Permanently delete a Managed Agent session, its events, and its sandbox. Not reversible. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was deleted. | +| `deleted` | boolean | True when the delete was accepted. | + diff --git a/apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx b/apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx index 00ffae3f3ab..be9bb00d462 100644 --- a/apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx +++ b/apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx @@ -37,6 +37,19 @@ In our example, adding `Department equals "Billing"` makes the search consider o Filters run before the vector comparison, so they make a search both more precise and cheaper. See [Tags and filtering](/knowledgebase/tags) for the full operator list by tag type. +## Retrieval Mode + +**Retrieval Mode** is an advanced setting that chooses how matches are found. + +| Mode | What it does | +| --- | --- | +| Vector only | The default. Ranks purely on meaning, as described above. | +| Hybrid | Also runs a keyword search over the same chunks and blends the two rankings. | + +Semantic search is strong on paraphrase and weak on literal strings: an error code, a ticket key like `PROJ-1234`, a SKU, or a rare product name carries little meaning for the model, so the chunk containing it may not rank near the top. Hybrid adds a keyword pass that matches those tokens exactly, then merges the two lists so a chunk found by either signal can surface. + +Turn it on when your documents are full of identifiers, codes, or names people search for verbatim. Leave it off for prose-heavy bases where questions are asked in natural language. Hybrid costs no extra API calls — the keyword pass runs entirely in the database. + ## Rerank Results **Rerank Results** is an optional second pass. Vector search ranks by raw similarity; reranking re-scores the top matches with a dedicated relevance model (Cohere's rerank models) and reorders them, which sharpens the ordering when the best answer isn't the literal closest vector. @@ -95,6 +108,7 @@ When the agent's answer is off, the cause is usually in retrieval, not the agent - **No results, or wrong documents.** A tag filter may be excluding what you want, or the documents may not be indexed yet. A document is only searchable once its processing status is `completed`; while it is `pending`, `processing`, or `failed`, its chunks won't appear. - **Low similarity scores across the board.** The query is too vague, or the information simply isn't in the base. Rewrite the query to match how the documents phrase things. - **Right documents, wrong order.** Turn on Rerank Results, or raise Number of Results so the relevant chunk is included. +- **An exact code, ID, or name isn't found.** Switch Retrieval Mode to Hybrid so a keyword pass runs alongside the semantic one. See [debugging retrieval](/knowledgebase/debugging-retrieval) for the full diagnostic path, and [chunking strategies](/knowledgebase/chunking-strategies) for how chunk boundaries shape what a search can return. diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 7e8f86e958c..416779ba00b 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -64,7 +64,8 @@ import { Callout } from 'fumadocs-ui/components/callout' | Variable | Description | |----------|-------------| | `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` | -| `COPILOT_API_KEY` | API key for copilot features | +| `COPILOT_API_KEY` | API key for Chat. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run | +| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key | | `ADMIN_API_KEY` | Admin API key for GitOps operations | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index dbe3a59c40a..b502d34e459 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -211,7 +211,7 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t ### Create PR [#setup-cloud-pr] -Create PR runs in a sandbox image with the Pi CLI and git baked in. +Create PR runs in a sandbox image with the Pi CLI, Git, Node.js, and Bun baked in. Repository dependencies are not preinstalled; Pi can run `bun install` when a repository needs them. 1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals Create PR, Update PR, and Review Code in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. These modes stay hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 4967b8c8186..b2e8ca4c523 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -6040,7 +6040,7 @@ "post": { "operationId": "searchKnowledgeBase", "summary": "Search Knowledge Base", - "description": "Perform vector similarity search across one or more knowledge bases. Supports semantic search via query text, tag-based filtering, or a combination of both.", + "description": "Search across one or more knowledge bases. Supports semantic search via query text, tag-based filtering, or a combination of both. Set `searchMode` to `hybrid` to additionally run a full-text keyword leg and fuse it with the semantic results.", "tags": ["Knowledge Bases"], "x-codeSamples": [ { @@ -6095,6 +6095,12 @@ "items": { "$ref": "#/components/schemas/TagFilter" } + }, + "searchMode": { + "type": "string", + "enum": ["vector", "hybrid"], + "default": "vector", + "description": "Retrieval strategy. `vector` ranks purely on embedding similarity. `hybrid` also runs a full-text keyword search and fuses the two rankings by reciprocal rank, which retrieves exact tokens — error codes, ticket keys, identifiers, rare product names — that embeddings alone rank poorly. Ignored when only tagFilters are provided." } } }, @@ -6102,7 +6108,8 @@ "workspaceId": "wsp_abc123", "knowledgeBaseIds": ["d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f"], "query": "How do I reset my password?", - "topK": 5 + "topK": 5, + "searchMode": "hybrid" } } } diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 633ffb60827..35e45fc29e6 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -33,9 +33,12 @@ "@sim/workflow-types": "workspace:*", "@socket.io/redis-adapter": "8.3.0", "drizzle-orm": "^0.45.2", + "lib0": "0.2.117", "postgres": "^3.4.5", "redis": "5.10.0", "socket.io": "^4.8.1", + "y-protocols": "1.0.7", + "yjs": "13.6.31", "zod": "4.3.6" }, "devDependencies": { diff --git a/apps/realtime/src/access-revalidation.test.ts b/apps/realtime/src/access-revalidation.test.ts index 0688d5be334..4ea7999c301 100644 --- a/apps/realtime/src/access-revalidation.test.ts +++ b/apps/realtime/src/access-revalidation.test.ts @@ -50,16 +50,14 @@ function makeManager(sockets: FakeSocket[], presence: Partial[] = const manager = { io: { sockets: { sockets: socketMap } }, isReady: () => true, - getWorkflowUsers: vi.fn().mockResolvedValue(presence), - getWorkflowIdForSocket: vi.fn().mockResolvedValue(null), - removeUserFromRoom: vi - .fn() - .mockImplementation(async (_socketId: string, workflowId?: string) => workflowId ?? null), + getRoomUsers: vi.fn().mockResolvedValue(presence), + getRoomForSocket: vi.fn().mockResolvedValue(null), + removeUserFromRoom: vi.fn().mockResolvedValue(true), broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined), } return manager as unknown as IRoomManager & { - getWorkflowUsers: ReturnType - getWorkflowIdForSocket: ReturnType + getRoomUsers: ReturnType + getRoomForSocket: ReturnType removeUserFromRoom: ReturnType broadcastPresenceUpdate: ReturnType } @@ -84,8 +82,11 @@ describe('access-revalidation sweep', () => { expect.objectContaining({ workflowId: 'wf-1' }) ) expect(socket.leave).toHaveBeenCalledWith('wf-1') - expect(manager.removeUserFromRoom).toHaveBeenCalledWith('sock-1', 'wf-1') - expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1') + expect(manager.removeUserFromRoom).toHaveBeenCalledWith( + { type: 'workflow', id: 'wf-1' }, + 'sock-1' + ) + expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith({ type: 'workflow', id: 'wf-1' }) }) it('keeps a socket whose access is still valid', async () => { @@ -141,7 +142,30 @@ describe('access-revalidation sweep', () => { expect(mockResolveRole).toHaveBeenCalledWith('user-1', 'wf-1', 'read') // The security scan must stay Redis-free — presence is never consulted. - expect(manager.getWorkflowUsers).not.toHaveBeenCalled() + expect(manager.getRoomUsers).not.toHaveBeenCalled() + }) + + it('never evicts a socket joined only to a non-workflow room (files/tables/file-doc)', async () => { + // The sweep shares one io with the files/tables/file-doc handlers. Those rooms are + // namespaced (`workspace-files:ws-1`, `table:t-1`), so treating every socket.rooms + // entry as a workflow id would resolve a bogus permission → null → evict the socket + // from its files/table room every pass. Non-workflow rooms must be filtered out. + const filesSocket = makeSocket('sock-1', 'user-1', 'workspace-files:ws-1') + const tableSocket = makeSocket('sock-2', 'user-2', 'table:t-1') + const manager = makeManager([filesSocket, tableSocket]) + // Even if the role resolver would say "no access", these must never be swept. + mockResolveRole.mockResolvedValue(null) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(mockResolveRole).not.toHaveBeenCalled() + expect(filesSocket.leave).not.toHaveBeenCalled() + expect(filesSocket.emit).not.toHaveBeenCalled() + expect(tableSocket.leave).not.toHaveBeenCalled() + expect(tableSocket.emit).not.toHaveBeenCalled() + expect(manager.removeUserFromRoom).not.toHaveBeenCalled() }) it('evicts only the revoked socket, not co-members of the room', async () => { @@ -199,28 +223,28 @@ describe('access-revalidation sweep', () => { sweep.stop() expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2) - expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1') + expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith({ type: 'workflow', id: 'wf-1' }) }) - it('defers cleanup when removal fails with expired socket mappings', async () => { + it('drops eviction cleanup when the socket is no longer mapped to the room (no infinite retry)', async () => { const socket = makeSocket('sock-1', 'user-1', 'wf-1') const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) - // Mapping keys already expired (lookup resolves null) AND the removal fails - // (the Redis manager swallows the transport error into null) — the failed - // removal must still defer instead of reading as success. - manager.removeUserFromRoom.mockResolvedValueOnce(null) + // A healthy lookup shows the socket is no longer mapped to any workflow room (its presence + // is already gone), and removeUserFromRoom reports a no-op `false`. This is "already clean", + // not a deferrable failure — the cleanup must drop it, never re-enqueue a still-connected + // socket forever. (A genuine failure — still mapped + false — is covered by the next test.) + manager.getRoomForSocket.mockResolvedValue(null) + manager.removeUserFromRoom.mockResolvedValue(false) mockResolveRole.mockResolvedValue(null) const sweep = startAccessRevalidationSweep(manager) await sweep.runOnce() - - expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled() - await sweep.runOnce() sweep.stop() - expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2) - expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1') + // Attempted once, then dropped — not re-enqueued across passes, and no broadcast. + expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(1) + expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled() }) it('defers cleanup when the manager swallows a removal failure into null', async () => { @@ -228,8 +252,8 @@ describe('access-revalidation sweep', () => { const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) // Live mapping but the removal reports nothing removed — the Redis manager // swallows transport errors into null, so this is the only failure signal. - manager.getWorkflowIdForSocket.mockResolvedValue('wf-1') - manager.removeUserFromRoom.mockResolvedValueOnce(null) + manager.getRoomForSocket.mockResolvedValue({ type: 'workflow', id: 'wf-1' }) + manager.removeUserFromRoom.mockResolvedValueOnce(false) mockResolveRole.mockResolvedValue(null) const sweep = startAccessRevalidationSweep(manager) @@ -243,7 +267,7 @@ describe('access-revalidation sweep', () => { sweep.stop() expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2) - expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1') + expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith({ type: 'workflow', id: 'wf-1' }) }) it('skips removal when the socket has since moved to a different workflow', async () => { @@ -251,7 +275,7 @@ describe('access-revalidation sweep', () => { const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) // Between the membership snapshot and cleanup, the socket switched to a // workflow it can still access — removal must not touch its new presence. - manager.getWorkflowIdForSocket.mockResolvedValue('wf-2') + manager.getRoomForSocket.mockResolvedValue({ type: 'workflow', id: 'wf-2' }) mockResolveRole.mockResolvedValue(null) const sweep = startAccessRevalidationSweep(manager) @@ -357,7 +381,7 @@ describe('access-revalidation sweep', () => { const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) // A Redis outage where commands hang in the offline queue instead of // failing: the cleanup lane stalls, but scans must keep running. - manager.getWorkflowIdForSocket.mockReturnValue(new Promise(() => {})) + manager.getRoomForSocket.mockReturnValue(new Promise(() => {})) mockResolveRole.mockResolvedValue(null) const sweep = startAccessRevalidationSweep(manager) diff --git a/apps/realtime/src/access-revalidation.ts b/apps/realtime/src/access-revalidation.ts index 18d63ea490d..93edaae9a0b 100644 --- a/apps/realtime/src/access-revalidation.ts +++ b/apps/realtime/src/access-revalidation.ts @@ -1,9 +1,10 @@ import { createLogger } from '@sim/logger' import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events' +import { parseRoomName, ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { sleep } from '@sim/utils/helpers' import type { AuthenticatedSocket } from '@/middleware/auth' import { ROLE_REVALIDATION_TTL_MS, resolveCurrentWorkflowRole } from '@/middleware/permissions' -import type { IRoomManager } from '@/rooms' +import { type IRoomManager, workflowRoom as wf } from '@/rooms' const logger = createLogger('AccessRevalidation') @@ -65,9 +66,14 @@ interface ScanTarget { * Collects this pod's authenticated sockets with the workflow room each has * joined, in stable socket order. * - * The workflow room is derived from the socket's own `rooms` set (pod-local, no - * Redis round-trips): a socket joins exactly one workflow room, so its rooms are - * `{ ownSocketId, workflowId }`. Only local sockets are evaluated — sockets are + * Rooms are derived from the socket's own `rooms` set (pod-local, no Redis + * round-trips). A socket may occupy several rooms of different types at once + * (workflow canvas, workspace-files browser, table, file-doc), all on the same + * io — so each name is decoded with {@link parseRoomName} and only **workflow** + * rooms are swept here. Non-workflow room names are namespaced (`type:id`) and + * resolve to a non-workflow type; sweeping them as workflow ids would resolve a + * bogus permission, come back `null`, and spuriously evict the socket from its + * files/table room every pass. Only local sockets are evaluated — sockets are * sticky to a pod, so every socket is swept by exactly one pod using that pod's * warm role cache (mirroring the per-pod reasoning of the write-path cache). */ @@ -78,7 +84,9 @@ function collectScanTargets(io: IRoomManager['io']): ScanTarget[] { if (!authed.userId) continue for (const room of socket.rooms) { if (room === socket.id) continue - targets.push({ workflowId: room, socket: authed, userId: authed.userId }) + const ref = parseRoomName(room) + if (ref?.type !== ROOM_TYPES.WORKFLOW) continue + targets.push({ workflowId: ref.id, socket: authed, userId: authed.userId }) } } return targets @@ -129,9 +137,20 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR async function cleanupEvictedSocket(socketId: string, workflowId: string): Promise { const key = `${socketId}:${workflowId}` try { + // A fully-disconnected socket already had its presence removed by the + // disconnect handler (removeSocketFromAllRooms), so there is nothing left to + // clean. Dropping here also keeps the boolean removeUserFromRoom below from + // reporting a false "not a member" for an already-gone entry and retrying it + // forever (the pre-generalization manager returned the target on a no-op). + if (!io.sockets.sockets.get(socketId)) { + pendingCleanups.delete(key) + return + } + // Unlike removeUserFromRoom, this read does not swallow transport errors, // so a Redis outage lands in the catch below and defers the cleanup. - const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socketId) + const currentRoom = await roomManager.getRoomForSocket(socketId, ROOM_TYPES.WORKFLOW) + const currentWorkflowId = currentRoom?.id ?? null if (currentWorkflowId !== null && currentWorkflowId !== workflowId) { // The socket has since moved to a different workflow it can still // access; that join's room switch already removed this room's presence @@ -148,16 +167,26 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR return } - const removed = await roomManager.removeUserFromRoom(socketId, workflowId) - if (removed === null) { - // The sweep always passes the target room, and both managers report a - // performed removal by returning it — the Redis manager swallows - // transport errors into null, so null means the removal did not happen - // (even when the socket's mapping keys have already expired). - throw new Error('room-state removal not confirmed') + // A null mapping here is the normal case (the socket's mapping key may have + // expired) and does NOT mean "skip" — the eviction still removes the presence + // entry from the known target room via the explicit ref below. + const removed = await roomManager.removeUserFromRoom(wf(workflowId), socketId) + if (!removed) { + // `false` conflates two outcomes: the entry was already gone (a no-op), or a + // transport error the manager swallowed. Only retry when the socket is still mapped + // to THIS room — then a false result is a genuine, deferrable failure. When a healthy + // getRoomForSocket above returned no workflow mapping (`currentWorkflowId === null`), + // the presence entry is already gone, so the cleanup is complete: dropping it avoids + // re-enqueuing a still-connected socket forever. (A real Redis outage throws at + // getRoomForSocket and is deferred by the outer catch, never reaching here.) + if (currentWorkflowId === workflowId) { + throw new Error('room-state removal not confirmed') + } + pendingCleanups.delete(key) + return } - await roomManager.broadcastPresenceUpdate(workflowId) + await roomManager.broadcastPresenceUpdate(wf(workflowId)) pendingCleanups.delete(key) } catch (error) { pendingCleanups.set(key, { socketId, workflowId }) diff --git a/apps/realtime/src/handlers/avatar.ts b/apps/realtime/src/handlers/avatar.ts new file mode 100644 index 00000000000..349b166e270 --- /dev/null +++ b/apps/realtime/src/handlers/avatar.ts @@ -0,0 +1,29 @@ +import { db, user } from '@sim/db' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { AuthenticatedSocket } from '@/middleware/auth' + +const logger = createLogger('PresenceAvatar') + +/** + * The avatar URL for a presence entry: the socket's authenticated image when + * present, otherwise a single lookup of the user's stored image. Never throws — + * presence must not fail on an avatar lookup, so a DB error resolves to `null`. + */ +export async function resolveAvatarUrl( + socket: AuthenticatedSocket, + userId: string +): Promise { + if (socket.userImage) return socket.userImage + try { + const [record] = await db + .select({ image: user.image }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + return record?.image ?? null + } catch (error) { + logger.warn('Failed to load user avatar for presence', { userId, error }) + return null + } +} diff --git a/apps/realtime/src/handlers/connection.ts b/apps/realtime/src/handlers/connection.ts index 90eddb82464..33d90b5bfb0 100644 --- a/apps/realtime/src/handlers/connection.ts +++ b/apps/realtime/src/handlers/connection.ts @@ -1,4 +1,6 @@ import { createLogger } from '@sim/logger' +import { parseRoomName, ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' +import { cleanupFileDocForSocket } from '@/handlers/file-doc' import { cleanupPendingSubblocksForSocket } from '@/handlers/subblocks' import { cleanupPendingVariablesForSocket } from '@/handlers/variables' import type { AuthenticatedSocket } from '@/middleware/auth' @@ -6,6 +8,14 @@ import type { IRoomManager } from '@/rooms' const logger = createLogger('ConnectionHandlers') +/** + * Room types whose presence lives in the room manager (Redis-backed), so a disconnect must + * remove the socket + broadcast a correction. The workspace-files and file-doc rooms are + * NOT here: workspace-files carries no presence (native Socket.IO membership only), and + * file-doc broadcasts its own server-authenticated roster via `cleanupFileDocForSocket`. + */ +const PRESENCE_BEARING_TYPES = new Set([ROOM_TYPES.WORKFLOW, ROOM_TYPES.TABLE]) + export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { socket.on('error', (error) => { logger.error(`Socket ${socket.id} error:`, error) @@ -15,20 +25,68 @@ export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager logger.error(`Socket ${socket.id} connection error:`, error) }) - socket.on('disconnect', async (reason) => { + // `disconnecting` (not `disconnect`): here `socket.rooms` is still populated and + // authoritative, so presence is cleaned up even if the Redis room-set key was + // evicted or TTL-expired (which would leave the manager's stored rooms empty). + socket.on('disconnecting', async (reason) => { try { + // Snapshot the live Socket.IO room membership SYNCHRONOUSLY, before any + // await: Socket.IO clears `socket.rooms` via leaveAll() as soon as the + // synchronous portion of this `disconnecting` handler returns (i.e. at the + // first await below), so reading it afterwards would see an empty set and + // the eviction fallback would be dead. + const liveRoomNames = [...socket.rooms] + // Clean up pending debounce entries for this socket to prevent memory leaks cleanupPendingSubblocksForSocket(socket.id) cleanupPendingVariablesForSocket(socket.id) + // Clear the socket's collaborative-document awareness (removes its caret for + // everyone else) and drop the room if it was the last editor. `endOfLife` drops the + // socket's join-generation entry — safe only here, on true disconnect (see cleanup). + cleanupFileDocForSocket(socket.id, roomManager.io, true) - const workflowIdHint = [...socket.rooms].find((roomId) => roomId !== socket.id) - const workflowId = await roomManager.removeUserFromRoom(socket.id, workflowIdHint) + // A socket may occupy multiple rooms (one per type). Remove it from every + // room the manager knows about. + const removedRooms = await roomManager.removeSocketFromAllRooms(socket.id) + + // Union with the snapshotted Socket.IO membership (authoritative, and it + // survives a Redis eviction/TTL lapse that would leave the manager's tracked + // rooms empty). Attempt removal for any room the manager didn't already + // remove — best-effort, since a transient Redis error can't be recovered here. + const wasInRooms = new Map() + // Only presence-bearing rooms get a corrective broadcast. Manager-removed rooms are + // presence-bearing by construction today (only workflow/table write the socket→room hash), + // but filter symmetrically with the fallback path below so a future room type that ever + // tracks presence here can't emit a bogus presence-update no client listens to. + for (const room of removedRooms) { + if (PRESENCE_BEARING_TYPES.has(room.type)) wasInRooms.set(roomName(room), room) + } + for (const name of liveRoomNames) { + // `wasInRooms.has(name)` already excludes every room the manager removed (same + // room-name key via the roomName/parseRoomName bijection). Skip room types with no + // manager-tracked presence (workspace-files, file-doc): removing there is a no-op and + // broadcasting a correction would emit a dead presence-update no client listens to. + if (name === socket.id || wasInRooms.has(name)) continue + const ref = parseRoomName(name) + if (!ref || !PRESENCE_BEARING_TYPES.has(ref.type)) continue + wasInRooms.set(name, ref) + await roomManager.removeUserFromRoom(ref, socket.id) + } + + // Broadcast a correction to every room this socket was in, EXCLUDING this + // socket — so it is never shown as a ghost collaborator even if its presence + // entry outlived a failed removal (transient Redis error; the hashes have no + // TTL). Any orphaned entry is additionally reclaimed by the next join's + // stale-presence sweep. + for (const room of wasInRooms.values()) { + await roomManager.broadcastPresenceUpdate(room, socket.id) + } - if (workflowId) { - await roomManager.broadcastPresenceUpdate(workflowId) - logger.info( - `Socket ${socket.id} disconnected from workflow ${workflowId} (reason: ${reason})` - ) + if (wasInRooms.size > 0) { + const rooms = Array.from(wasInRooms.values()) + .map((room) => `${room.type}:${room.id}`) + .join(', ') + logger.info(`Socket ${socket.id} disconnected from [${rooms}] (reason: ${reason})`) } } catch (error) { logger.error(`Error handling disconnect for socket ${socket.id}:`, error) diff --git a/apps/realtime/src/handlers/file-doc-app.ts b/apps/realtime/src/handlers/file-doc-app.ts new file mode 100644 index 00000000000..87e515954be --- /dev/null +++ b/apps/realtime/src/handlers/file-doc-app.ts @@ -0,0 +1,127 @@ +import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { env, getBaseUrl } from '@/env' + +/** + * The relay's client for the app's internal file-doc endpoints. The app owns the markdown↔Yjs + * conversion engine (TipTap + jsdom) and blob/DB access; the relay owns the live document. So for any + * operation that needs conversion, the relay delegates here over the shared `x-api-key` channel. The + * timeouts (and their ordering vs. the app-side bounds) live in the shared `FILE_DOC_TIMEOUTS`. + */ + +function postToApp(path: string, payload: unknown, timeoutMs: number): Promise { + return fetch(`${getBaseUrl()}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(timeoutMs), + }) +} + +/** + * Ask the app to build a server-authoritative seed (markdown → Yjs) for a file's collaborative + * document. Returns the Yjs update to apply, or `null` for a genuinely empty/missing file (an empty + * document is correct). THROWS on a transport failure (non-2xx / network / timeout / malformed body) + * so the caller can tell a real empty from a failure it should be allowed to retry. + */ +export async function fetchFileDocSeed( + workspaceId: string, + fileId: string +): Promise<{ update: Uint8Array; version: number } | null> { + const response = await postToApp( + '/api/internal/file-doc/seed', + { workspaceId, fileId }, + FILE_DOC_TIMEOUTS.seedRequestMs + ) + if (!response.ok) { + throw new Error(`Seed fetch failed for file ${fileId}: ${response.status}`) + } + const body = (await response.json()) as { update?: unknown; version?: unknown } + const update = body?.update + // A well-formed response is `{ update: base64-string | null, version: number | null }`. Anything + // else is a contract violation, not a "genuinely empty file" — throw so the caller retries rather + // than silently treating a malformed body as empty and stranding the room unseeded. + if (update === null) return null + if (typeof update !== 'string' || typeof body?.version !== 'number') { + throw new Error(`Seed fetch for file ${fileId} returned a malformed body`) + } + return { update: new Uint8Array(Buffer.from(update, 'base64')), version: body.version } +} + +/** + * Ask the app to merge new markdown into the live document as a minimal Yjs diff — Stage C, so a + * copilot edit streams into open editors instead of the file changing underneath them. The relay + * ships the document's current state and applies the returned diff (which Yjs reconciles with any + * concurrent user edits). THROWS on a transport failure or malformed body. + */ +export async function fetchFileDocMerge( + fileId: string, + docState: Uint8Array, + markdown: string +): Promise { + const response = await postToApp( + '/api/internal/file-doc/merge', + { fileId, docState: Buffer.from(docState).toString('base64'), markdown }, + FILE_DOC_TIMEOUTS.mergeRequestMs + ) + if (!response.ok) { + throw new Error(`Merge fetch failed for file ${fileId}: ${response.status}`) + } + const body = (await response.json()) as { update?: unknown } + if (typeof body?.update !== 'string') { + throw new Error(`Merge fetch for file ${fileId} returned a malformed body`) + } + return new Uint8Array(Buffer.from(body.update, 'base64')) +} + +/** + * Result of a persist attempt (mirrors the app's `persistFileDoc` contract): + * - `persisted` — written; `version` is the new durable version the relay records as synced. + * - `missing` — the file is gone. + * - `conflict` — the file changed out-of-band since `expectedVersion`; NOT written. The relay leaves the + * durable content authoritative and reads nothing off this result beyond the status. + */ +export type PersistResult = + | { status: 'persisted'; version: number } + | { status: 'missing' } + | { status: 'conflict' } + | { status: 'deferred' } + +/** + * Ask the app to project a live collaborative document back to durable markdown and write it to the + * file (Yjs → markdown, through the exact editor engine) — the server-authoritative durable path that + * replaces the editor's client autosave. `expectedVersion` (the durable version the live doc synced + * from) is the optimistic-concurrency guard: on a mismatch the app returns `conflict` (rather than + * clobbering) so the caller reconciles and retries. THROWS only on a transport/contract failure. + */ +export async function fetchFileDocPersist( + workspaceId: string, + fileId: string, + userId: string, + docState: Uint8Array, + expectedVersion?: number +): Promise { + const response = await postToApp( + '/api/internal/file-doc/persist', + { + workspaceId, + fileId, + userId, + docState: Buffer.from(docState).toString('base64'), + ...(expectedVersion !== undefined ? { expectedVersion } : {}), + }, + FILE_DOC_TIMEOUTS.persistRequestMs + ) + if (!response.ok) { + throw new Error(`Persist failed for file ${fileId}: ${response.status}`) + } + const body = (await response.json()) as PersistResult + if ( + body?.status !== 'persisted' && + body?.status !== 'missing' && + body?.status !== 'conflict' && + body?.status !== 'deferred' + ) { + throw new Error(`Persist for file ${fileId} returned a malformed body`) + } + return body +} diff --git a/apps/realtime/src/handlers/file-doc-store.test.ts b/apps/realtime/src/handlers/file-doc-store.test.ts new file mode 100644 index 00000000000..1662a8a3426 --- /dev/null +++ b/apps/realtime/src/handlers/file-doc-store.test.ts @@ -0,0 +1,490 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +/** + * One shared in-memory Redis backing per test, so several {@link FileDocStore} instances (modelling + * several ECS tasks) all talk to the "same Redis". A minimal fake of just the stream/lock ops the + * store uses. + */ +interface Backing { + streams: Map }[]> + kv: Map + seq: number + /** Number of upcoming xAdd calls to fail with a transient error (to exercise publish retry). */ + failXAdd: number +} + +const state = vi.hoisted(() => ({ backing: null as Backing | null })) + +const seqOf = (id: string) => Number(id.split('-')[0]) + +function makeClient(): any { + const b = () => { + if (!state.backing) throw new Error('backing not initialized') + return state.backing + } + const client: any = { + connect: async () => {}, + quit: async () => {}, + on: () => client, + duplicate: () => makeClient(), + xAdd: async (key: string, _star: string, fields: Record) => { + if (b().failXAdd > 0) { + b().failXAdd-- + throw new Error('transient xAdd failure') + } + const id = `${++b().seq}-0` + const arr = b().streams.get(key) ?? [] + arr.push({ id, message: { ...fields } }) + b().streams.set(key, arr) + return id + }, + xRange: async (key: string) => (b().streams.get(key) ?? []).map((e) => ({ ...e })), + xLen: async (key: string) => (b().streams.get(key) ?? []).length, + xTrim: async (key: string, _strategy: string, minid: string) => { + const arr = b().streams.get(key) ?? [] + b().streams.set( + key, + arr.filter((e) => seqOf(e.id) >= seqOf(minid)) + ) + }, + xRead: async (streams: { key: string; id: string }[]) => { + const res: { name: string; messages: { id: string; message: Record }[] }[] = + [] + for (const { key, id } of streams) { + const after = (b().streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id)) + if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) }) + } + if (res.length) return res + await new Promise((r) => setTimeout(r, 5)) + return null + }, + set: async (key: string, val: string, opts?: { NX?: boolean }) => { + if (opts?.NX && b().kv.has(key)) return null + b().kv.set(key, val) + return 'OK' + }, + del: async (key: string) => { + b().kv.delete(key) + return 1 + }, + eval: async (script: string, opts: { keys: string[]; arguments: string[] }) => { + const [key] = opts.keys + // Atomic seed-if-empty (SEED_IF_EMPTY_SCRIPT): append the entry iff the stream is empty, in one + // synchronous step — mirroring Redis's atomic Lua execution, so two concurrent evals can never both + // append (the second sees a non-empty stream). + if (script.includes('xlen')) { + const [field, value] = opts.arguments + const arr = b().streams.get(key) ?? [] + if (arr.length > 0) return 0 + const id = `${++b().seq}-0` + arr.push({ id, message: { [field]: value } }) + b().streams.set(key, arr) + return 1 + } + // Compare-and-delete Lua (RELEASE_LOCK_SCRIPT): del only if the stored value matches the token. + const [token] = opts.arguments + if (b().kv.get(key) === token) { + b().kv.delete(key) + return 1 + } + return 0 + }, + expire: async () => 1, + } + return client +} + +vi.mock('redis', () => ({ createClient: () => makeClient() })) + +import { FileDocStore, REDIS_AGENT_ORIGIN, REDIS_ORIGIN } from '@/handlers/file-doc-store' + +const REDIS_URL = 'redis://fake' +const NAME = 'workspace-file-doc:file-1' + +function docWithText(text: string): Y.Doc { + const doc = new Y.Doc() + doc.getText('body').insert(0, text) + return doc +} + +/** The delta a doc emits when `text` is inserted — what the relay would `publish`. */ +function updateFor(text: string): Uint8Array { + const doc = docWithText(text) + const update = Y.encodeStateAsUpdate(doc) + doc.destroy() + return update +} + +let stores: FileDocStore[] = [] +async function newStore(): Promise { + const store = new FileDocStore(REDIS_URL) + await store.init() + stores.push(store) + return store +} + +describe('FileDocStore', () => { + beforeEach(() => { + state.backing = { streams: new Map(), kv: new Map(), seq: 0, failXAdd: 0 } + stores = [] + }) + + afterEach(async () => { + await Promise.all(stores.map((s) => s.shutdown())) + }) + + it('elects exactly one seeder across tasks (no split-brain seed)', async () => { + const a = await newStore() + const b = await newStore() + // shouldSeed returns a lock token (truthy) for the winner, null for the loser. + const [aTok, bTok] = await Promise.all([a.shouldSeed(NAME), b.shouldSeed(NAME)]) + expect([aTok, bTok].filter(Boolean)).toHaveLength(1) + }) + + it('does not re-seed once the stream already has content (stale lock)', async () => { + const a = await newStore() + const token = await a.shouldSeed(NAME) + expect(token).toBeTruthy() + // A seeds and releases its lock. + a.publish(NAME, updateFor('hello')) + await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull()) + await a.releaseSeedLock(NAME, token as string) + // A different task must NOT seed again — the lock is free but the stream is non-empty. + const b = await newStore() + expect(await b.shouldSeed(NAME)).toBeNull() + }) + + it('getStreamState reconstructs the shared document from the stream', async () => { + const a = await newStore() + a.publish(NAME, updateFor('shared content')) + let state: Uint8Array | null = null + await vi.waitFor(async () => { + state = await a.getStreamState(NAME) + expect(state).not.toBeNull() + }) + const doc = new Y.Doc() + Y.applyUpdate(doc, state!) + expect(doc.getText('body').toString()).toBe('shared content') + doc.destroy() + }) + + it('attachRoom catches a fresh task up to the current shared state', async () => { + const a = await newStore() + a.publish(NAME, updateFor('already here')) + await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull()) + + // A second task opens the same file: its doc must load the existing content, not start empty. + const b = await newStore() + const doc = new Y.Doc() + await b.attachRoom(NAME, doc) + expect(doc.getText('body').toString()).toBe('already here') + doc.destroy() + }) + + it('converges a peer task via the tailer after attach', async () => { + const a = await newStore() + const b = await newStore() + const bDoc = new Y.Doc() + await b.attachRoom(NAME, bDoc) + + // A publishes an edit; B's multiplexed reader must apply it to B's attached doc. + a.publish(NAME, updateFor('from task A')) + await vi.waitFor(() => expect(bDoc.getText('body').toString()).toBe('from task A'), { + timeout: 2000, + }) + bDoc.destroy() + }) + + it('compaction never trims peer entries the compacting task has not yet integrated', async () => { + const streamKey = `filedoc:stream:${NAME}` + const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64') + + // Two peer edits published by ANOTHER task that this task's tailer has not read yet. + const peerDoc = new Y.Doc() + const peerUpdates: Uint8Array[] = [] + peerDoc.on('update', (u: Uint8Array) => peerUpdates.push(u)) + peerDoc.getText('body').insert(0, 'PEER1') + peerDoc.getText('body').insert(5, 'PEER2') + + // Backing: 400 already-integrated (no-op) entries this task's doc reflects, then the 2 un-integrated + // peer entries. Enough entries to cross COMPACT_THRESHOLD. + const entries = Array.from({ length: 400 }, (_, i) => ({ + id: `${i + 1}-0`, + message: { u: noop }, + })) + entries.push({ id: '401-0', message: { u: Buffer.from(peerUpdates[0]).toString('base64') } }) + entries.push({ id: '402-0', message: { u: Buffer.from(peerUpdates[1]).toString('base64') } }) + state.backing!.streams.set(streamKey, entries) + state.backing!.seq = 402 + + const a = await newStore() + // This task has integrated only up to entry 400 (all no-ops) — its local doc is empty and lags the + // two peer entries. Inject that lagging room directly (a real edit was integrated → realEdited). + ;(a as any).rooms.set(NAME, { + doc: new Y.Doc(), + lastId: '400-0', + publishes: 0, + seededObserved: true, + realEdited: true, + }) + await (a as any).maybeCompact(NAME) + + // A fresh catch-up must still reconstruct the peer content — compaction must not have trimmed 401/402. + const doc = new Y.Doc() + Y.applyUpdate(doc, (await a.getStreamState(NAME))!) + expect(doc.getText('body').toString()).toBe('PEER1PEER2') + doc.destroy() + + // The appended snapshot entry must carry the snapshot marker, so a fresh catch-up task treats it as + // edited content (not a bare seed) and persists on last-disconnect. + const stream = state.backing!.streams.get(streamKey)! + expect(stream[stream.length - 1].message.s).toBe('1') + }) + + it('tags an agent-streamed frame so a peer tailer applies it as REDIS_AGENT_ORIGIN (never persisted)', async () => { + const streamKey = `filedoc:stream:${NAME}` + const a = await newStore() + const b = await newStore() + const bDoc = new Y.Doc() + // Capture the origin the tailer stamps each applied entry with — the persistence gate keys off it. + const origins: unknown[] = [] + bDoc.on('update', (_u: Uint8Array, origin: unknown) => origins.push(origin)) + await b.attachRoom(NAME, bDoc) + + // A normal edit tails as REDIS_ORIGIN (a peer edit that CAN be persisted). + a.publish(NAME, updateFor('user edit')) + await vi.waitFor(() => expect(origins).toContain(REDIS_ORIGIN), { timeout: 2000 }) + + // An agent-streamed frame is published WITH the agent flag: the stream entry carries the marker, and + // the peer tailer applies it as REDIS_AGENT_ORIGIN — excluded from the relay's edited/persist gate. + a.publish(NAME, updateFor('agent frame'), true) + await vi.waitFor(() => expect(origins).toContain(REDIS_AGENT_ORIGIN), { timeout: 2000 }) + const stream = state.backing!.streams.get(streamKey)! + expect(stream.some((e) => e.message.a === '1')).toBe(true) + // The normal edit's entry carries no agent marker. + expect(stream.filter((e) => e.message.a === '1')).toHaveLength(1) + bDoc.destroy() + }) + + it('latches realEdited synchronously so a concurrent compaction can never mislabel a real edit', async () => { + // The data-loss race: a real edit sits in room.doc synchronously, but if realEdited were set only + // AFTER appendUpdate's awaits, a concurrent agent-triggered compaction could snapshot that content and + // stamp it an agent (no-persist) frame — losing the edit. The latch must be set in the same tick. + const a = await newStore() + const doc = new Y.Doc() + await a.attachRoom(NAME, doc) + const room = (a as any).rooms.get(NAME) + expect(room.realEdited).toBe(false) + // Kick off a real (non-agent) append but do NOT await it: realEdited must already be true before the + // xAdd/expire awaits resolve, so any compaction racing on the awaits sees the real edit. + const pending = (a as any).appendUpdate(NAME, updateFor('real user edit')) + expect(room.realEdited).toBe(true) + await pending + doc.destroy() + }) + + it('stamps a compaction snapshot of an agent-ONLY stream as an agent frame (never persisted)', async () => { + const streamKey = `filedoc:stream:${NAME}` + const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64') + // A doc whose content is purely agent preview (no real edit integrated) — realEdited stays false. + const agentDoc = docWithText('agent-only preview body') + const entries = Array.from({ length: 400 }, (_, i) => ({ + id: `${i + 1}-0`, + message: { u: noop }, + })) + state.backing!.streams.set(streamKey, entries) + state.backing!.seq = 400 + + const a = await newStore() + ;(a as any).rooms.set(NAME, { + doc: agentDoc, + lastId: '400-0', + publishes: 0, + seededObserved: true, + realEdited: false, + }) + await (a as any).maybeCompact(NAME) + + // The snapshot must carry the AGENT marker, NOT the snapshot marker, so a peer catch-up applies it as + // REDIS_AGENT_ORIGIN and never marks the doc edited — the no-persist guarantee survives compaction. + const stream = state.backing!.streams.get(streamKey)! + const last = stream[stream.length - 1].message + expect(last.a).toBe('1') + expect(last.s).toBeUndefined() + // Content is still fully reconstructable from the compacted stream. + const doc = new Y.Doc() + Y.applyUpdate(doc, (await a.getStreamState(NAME))!) + expect(doc.getText('body').toString()).toBe('agent-only preview body') + doc.destroy() + agentDoc.destroy() + }) + + it('retries a transient append failure so the edit is not lost from the shared log', async () => { + const a = await newStore() + state.backing!.failXAdd = 2 // first two xAdd attempts throw; the third must succeed + a.publish(NAME, updateFor('resilient')) + await vi.waitFor( + async () => { + const doc = new Y.Doc() + Y.applyUpdate(doc, (await a.getStreamState(NAME))!) + expect(doc.getText('body').toString()).toBe('resilient') + doc.destroy() + }, + { timeout: 2000 } + ) + }) + + it('streamHasContent fences a seed apply against an already-seeded stream', async () => { + const a = await newStore() + expect(await a.streamHasContent(NAME)).toBe(false) + a.publish(NAME, updateFor('seeded')) + await vi.waitFor(async () => expect(await a.streamHasContent(NAME)).toBe(true)) + }) + + it('serializes merges across tasks via the merge lock', async () => { + const a = await newStore() + const b = await newStore() + const aTok = await a.acquireMergeSlot(NAME, 5_000) + expect(aTok).toBeTruthy() + // A holds it → B is refused until A releases. + expect(await b.acquireMergeSlot(NAME, 5_000)).toBeNull() + // A stale-holder release with the WRONG token must NOT free A's lock (compare-and-delete). + await b.releaseMergeSlot(NAME, 'wrong-token') + expect(await b.acquireMergeSlot(NAME, 5_000)).toBeNull() + // A releases with its real token → B can now acquire. + await a.releaseMergeSlot(NAME, aTok as string) + const bTok = await b.acquireMergeSlot(NAME, 5_000) + expect(bTok).toBeTruthy() + await b.releaseMergeSlot(NAME, bTok as string) + }) + + it('is disabled without a REDIS_URL and behaves single-replica', async () => { + const store = new FileDocStore(undefined) + expect(store.enabled).toBe(false) + // Seeds locally (returns a sentinel token), never touches a stream. + expect(await store.shouldSeed(NAME)).toBeTruthy() + expect(await store.getStreamState(NAME)).toBeNull() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) // no-op, no throw + expect(doc.getText('body').toString()).toBe('') + doc.destroy() + }) + + it('seedIfEmpty writes the seed once and reports it, then refuses a non-empty stream', async () => { + const a = await newStore() + expect(await a.seedIfEmpty(NAME, updateFor('first'))).toBe(true) + // A second seed attempt (any task) must be refused — the stream already holds content. + const b = await newStore() + expect(await b.seedIfEmpty(NAME, updateFor('second'))).toBe(false) + const doc = new Y.Doc() + Y.applyUpdate(doc, (await a.getStreamState(NAME))!) + expect(doc.getText('body').toString()).toBe('first') + doc.destroy() + }) + + it('atomic seed prevents split-brain even when the seed lock expired mid-seed', async () => { + // The exact split-brain precondition from the concurrency audit: the seed lock is only an efficiency + // optimization, so if it lapses (TTL) while the stream is still empty, TWO tasks can both hold a + // token and both try to seed with DIFFERENT docs (distinct Yjs client ids). The atomic seedIfEmpty + // must still let only one land — otherwise the union duplicates content. + const a = await newStore() + const b = await newStore() + const tokenA = await a.shouldSeed(NAME) + expect(tokenA).toBeTruthy() + // Simulate A's lock expiring mid-seed so B also wins the freed lock over a still-empty stream. + state.backing!.kv.delete(`filedoc:seedlock:${NAME}`) + const tokenB = await b.shouldSeed(NAME) + expect(tokenB).toBeTruthy() + // Both tasks now race to seed with distinct client ids. + const [seededA, seededB] = await Promise.all([ + a.seedIfEmpty(NAME, updateFor('SEED-A')), + b.seedIfEmpty(NAME, updateFor('SEED-B')), + ]) + expect([seededA, seededB].filter(Boolean)).toHaveLength(1) + // Exactly one seed is in the stream — the reconstructed text is a single seed, never a duplicated + // union of both (e.g. 'SEED-ASEED-B'). + const doc = new Y.Doc() + Y.applyUpdate(doc, (await a.getStreamState(NAME))!) + expect(['SEED-A', 'SEED-B']).toContain(doc.getText('body').toString()) + doc.destroy() + }) + + it('a peer edit published during attachRoom catch-up is not lost', async () => { + // Author two INCREMENTAL edits from one doc so they converge to 'basepeer' (not an independent union). + const author = new Y.Doc() + const updates: Uint8Array[] = [] + author.on('update', (u: Uint8Array) => updates.push(u)) + author.getText('body').insert(0, 'base') + author.getText('body').insert(4, 'peer') + + const a = await newStore() + a.publish(NAME, updates[0]) // 'base' + await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull()) + + // Task B attaches; while its synchronous catch-up runs, task A publishes the second edit. The tailer + // resumes from the id catch-up stopped at, so the edit converges rather than falling into a gap. + const b = await newStore() + const bDoc = new Y.Doc() + const attach = b.attachRoom(NAME, bDoc) + a.publish(NAME, updates[1]) // 'peer' appended + await attach + await vi.waitFor(() => expect(bDoc.getText('body').toString()).toBe('basepeer'), { + timeout: 2000, + }) + bDoc.destroy() + author.destroy() + }) + + it('concurrent compaction on two tasks preserves the full document', async () => { + const streamKey = `filedoc:stream:${NAME}` + const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64') + + // Two peer edits neither compacting task has integrated (id > each task's lastId). + const peerDoc = new Y.Doc() + const peerUpdates: Uint8Array[] = [] + peerDoc.on('update', (u: Uint8Array) => peerUpdates.push(u)) + peerDoc.getText('body').insert(0, 'PEER1') + peerDoc.getText('body').insert(5, 'PEER2') + + const entries = Array.from({ length: 400 }, (_, i) => ({ + id: `${i + 1}-0`, + message: { u: noop }, + })) + entries.push({ id: '401-0', message: { u: Buffer.from(peerUpdates[0]).toString('base64') } }) + entries.push({ id: '402-0', message: { u: Buffer.from(peerUpdates[1]).toString('base64') } }) + state.backing!.streams.set(streamKey, entries) + state.backing!.seq = 402 + + // Two tasks whose local docs lag at DIFFERENT points (400 and 401): both cross the threshold and + // compact concurrently. Each must only trim what its own snapshot subsumes, so the union of both + // snapshots plus the un-integrated peer entries still reconstructs the whole doc. + const a = await newStore() + const b = await newStore() + const docA = new Y.Doc() + Y.applyUpdate(docA, peerUpdates[0]) // A integrated up to 401 + ;(a as any).rooms.set(NAME, { + doc: docA, + lastId: '401-0', + publishes: 0, + seededObserved: true, + realEdited: true, + }) + ;(b as any).rooms.set(NAME, { + doc: new Y.Doc(), + lastId: '400-0', + publishes: 0, + seededObserved: true, + realEdited: true, + }) + await Promise.all([(a as any).maybeCompact(NAME), (b as any).maybeCompact(NAME)]) + + const doc = new Y.Doc() + Y.applyUpdate(doc, (await a.getStreamState(NAME))!) + expect(doc.getText('body').toString()).toBe('PEER1PEER2') + doc.destroy() + }) +}) diff --git a/apps/realtime/src/handlers/file-doc-store.ts b/apps/realtime/src/handlers/file-doc-store.ts new file mode 100644 index 00000000000..f7b8fc5180a --- /dev/null +++ b/apps/realtime/src/handlers/file-doc-store.ts @@ -0,0 +1,725 @@ +/** + * Shared, multi-replica Yjs backend for the collaborative file-document relay, over Redis Streams. + * + * The relay keeps an in-memory {@link Y.Doc} per open file (for the sync handshake, awareness, and + * copilot merges), but on a horizontally-scaled deployment (multiple ECS tasks, autoscaling) that + * per-process doc is NOT authoritative on its own: two tasks each seeding the same file from markdown + * would mint independent Yjs client ids and union into duplicated content (split-brain), and a task + * only ever sees the edits of ITS OWN clients. This module makes every task converge on ONE CRDT per + * file by treating a Redis Stream as the shared, ordered, replayable log of Yjs updates — the union of + * a stream's entries IS the document. It is the "shared Yjs backend (y-redis / Hocuspocus)" the relay's + * single-replica model always deferred, built natively for our Socket.IO transport on the Redis the + * Socket.IO adapter already runs. + * + * How it fits the relay's message flow (see `file-doc.ts`): + * - Doc-sync messages no longer ride the Socket.IO Redis ADAPTER cross-pod. Instead each applied + * update is {@link publish}ed to the stream; every task's multiplexed reader + * applies it to its local doc (origin {@link REDIS_ORIGIN}) and fans it out to ITS OWN clients. So a + * client receives each update exactly once, from its own task's local broadcast — no adapter + * amplification, and every task's doc stays converged. (Awareness/presence stay on the adapter: they + * are ephemeral and need no convergence or replay.) + * - {@link attachRoom} does a synchronous catch-up read from the head of the stream when a task first + * opens a file, so a late-joining task (the normal case under autoscaling) loads the current shared + * state before its first client syncs. Catch-up + tail are seamless: the tailer resumes from the + * exact id catch-up stopped at. + * - The one-time seed is written via the atomic {@link seedIfEmpty} (append-iff-empty in one Redis + * step), so exactly one task ever writes the seed cluster-wide (the fix for split-brain) — even if two + * tasks race. {@link shouldSeed} is a Redis lock + empty-stream check layered on top ONLY as an + * efficiency gate (so tasks don't all run the seed fetch); correctness does not depend on it. + * + * When `REDIS_URL` is unset (single-pod dev) the store is DISABLED and every method degrades to the + * relay's original single-replica behavior: seed locally, no stream, no tailer. + * + * @module + */ +import { createLogger } from '@sim/logger' +import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { backoffWithJitter } from '@sim/utils/retry' +import { createClient, type RedisClientType } from 'redis' +import * as Y from 'yjs' + +const logger = createLogger('FileDocStore') + +/** + * Compare-and-delete: release a lock ONLY if this task still holds it (its token still the value), so a + * lock that expired and was re-acquired by another task is never stolen by the original holder's release. + */ +const RELEASE_LOCK_SCRIPT = + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" + +/** + * Atomic seed: append the seed entry ONLY if the stream is still empty, in one Redis-side step. This is + * the real split-brain guard — two tasks racing (even both past an expired seed lock) can never both + * write a seed (each would mint a distinct Yjs client id → duplicated content), because the emptiness + * check and the append happen atomically with no check-then-append window. The seed lock is only an + * efficiency optimization (avoid two seed fetches); correctness does not depend on it staying held. + * Returns 1 if THIS call wrote the seed, 0 if the stream already had content. + */ +const SEED_IF_EMPTY_SCRIPT = + "if redis.call('xlen', KEYS[1]) == 0 then redis.call('xadd', KEYS[1], '*', ARGV[1], ARGV[2]); return 1 else return 0 end" + +/** + * Monotonic set of the synced-version token: overwrite ONLY when the new value is greater than the + * stored one (or none is stored). The token is written fire-and-forget from multiple sites (seed stamp, + * merge, persist) and across tasks, so an out-of-order write must never REGRESS it to a version older + * than the live doc already incorporates — a regressed token causes spurious If-Match conflicts and, on a + * last-leave flush with no live room to reconcile into, a lost persist. Refreshes the TTL on both paths + * so a write skipped as older still keeps the (higher) value alive. Versions are monotonic epoch-ms, + * comfortably within a Lua double, so the numeric compare is exact. + */ +const SET_VERSION_IF_NEWER_SCRIPT = + "local c = redis.call('get', KEYS[1]); if c == false or tonumber(c) < tonumber(ARGV[1]) then redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2]) else redis.call('expire', KEYS[1], ARGV[2]) end; return 1" + +/** + * The transaction origin the store stamps on updates it applies from the stream. The relay's + * `doc.on('update')` handler uses it to distinguish an update that ARRIVED from a peer (fan out to + * local clients, but do NOT re-publish — it is already in the stream) from a local edit (fan out AND + * publish). It must be a non-string sentinel so it is never mistaken for a socket id. + */ +export const REDIS_ORIGIN = Symbol('file-doc-redis') + +/** + * Origin for a COMPACTED SNAPSHOT applied from the stream. A snapshot folds the seed + all prior edits + * into one entry, so a fresh task catching up from it would otherwise never see a separate post-seed + * edit frame and would treat the doc as unedited. The relay's edit-tracker uses this origin to mark the + * doc edited (a snapshot only exists after the stream crossed the compaction threshold, i.e. real edits + * happened). Behaves like {@link REDIS_ORIGIN} otherwise (already in the stream — never re-published). + */ +export const REDIS_SNAPSHOT_ORIGIN = Symbol('file-doc-redis-snapshot') + +/** + * Origin for an AGENT-STREAMED frame applied from the stream (a copilot output token relayed via + * {@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}). A peer task tails these to stay live mid-stream, but + * they are transient preview content the copilot's durable `edit_content` write reconciles — so the + * relay's edit-tracker must NOT mark the doc edited on them (a startup-race duplicate between two stream + * leaders would otherwise become eligible for a peer task's persist). Behaves like {@link REDIS_ORIGIN} + * otherwise (already in the stream — never re-published). + */ +export const REDIS_AGENT_ORIGIN = Symbol('file-doc-redis-agent') + +const STREAM_PREFIX = 'filedoc:stream:' +/** Cluster-wide "durable version the live doc is synced to" (the persist If-Match token). */ +const SYNC_VERSION_PREFIX = 'filedoc:syncver:' +const SEED_LOCK_PREFIX = 'filedoc:seedlock:' +const COMPACT_LOCK_PREFIX = 'filedoc:compactlock:' +const PERSIST_LOCK_PREFIX = 'filedoc:persistlock:' +const MERGE_LOCK_PREFIX = 'filedoc:mergelock:' +/** Cluster-wide "a client is actively streaming an agent edit into this live doc" flag — set (refreshed) + * on every agent frame so a durable {@link applyMarkdownToLiveFileDoc} merge defers to that client + * (which is applying the same content) instead of double-writing it. Short-TTL'd so it self-clears the + * moment streaming stops, after which the final durable merge lands as a near-noop. */ +const AGENT_STREAM_PREFIX = 'filedoc:agentstream:' + +/** The field each stream entry carries — a base64 Yjs update. */ +const UPDATE_FIELD = 'u' +/** Marks a stream entry as a compaction SNAPSHOT (folds seed + edits), so the tailer applies it with + * {@link REDIS_SNAPSHOT_ORIGIN}. Present only on snapshot entries. */ +const SNAPSHOT_FIELD = 's' +/** Marks a stream entry as an AGENT-STREAMED preview frame, so the tailer applies it with + * {@link REDIS_AGENT_ORIGIN} (never marks the doc edited). Present only on agent-frame entries. */ +const AGENT_FIELD = 'a' + +/** Sentinel token a DISABLED store returns from a lock acquire, so single-replica callers proceed + * without special-casing; {@link FileDocStore.releaseLock} treats it as a no-op. Not a real UUID, so it + * can never collide with a {@link generateId} token. */ +const DISABLED_LOCK_TOKEN = '__disabled__' + +/** How long a blocking multiplexed read waits before re-snapshotting the live room set. Also bounds + * how long a room attached mid-block waits for its first cross-task update (updates are not lost — the + * next read resumes from its last id — only briefly delayed). */ +const READ_BLOCK_MS = 1_000 +/** Idle poll cadence when NO room is open on this task, so a freshly-attached room is picked up fast + * without busy-spinning an empty task. */ +const IDLE_POLL_MS = 250 +/** Max entries drained per stream per read. */ +const READ_COUNT = 200 +/** Compact a stream once it exceeds this many entries (snapshot + trim). */ +const COMPACT_THRESHOLD = 400 +/** Check whether compaction is due only every Nth local publish, to avoid an XLEN per keystroke. */ +const COMPACT_CHECK_EVERY = 64 +/** Compaction critical section (snapshot + xAdd + xTrim) is fast; a generous TTL covers a slow Redis + * round-trip without risking expiry mid-compact. Released via compare-and-delete regardless. */ +const COMPACT_LOCK_TTL_MS = 10_000 +/** Retry a failed stream append this many times before giving up, so a transient Redis blip doesn't + * silently drop an edit from the shared log (which no peer would then ever see). */ +const PUBLISH_MAX_RETRIES = 3 +/** The seed lock spans the app seed fetch (hard-bounded at `seedRequestMs = 8s`) + the atomic seed + * append. It is only an EFFICIENCY optimization — it stops two tasks both running the seed fetch — and is + * sized to comfortably exceed the fetch bound while staying near the client readiness deadline (12s) so a + * dead seeder's lock frees when clients would recover anyway. Double-seed is prevented even if the lock + * expires mid-seed, because the seed is written via the atomic {@link SEED_IF_EMPTY_SCRIPT} + * (append-iff-empty), NOT the lock — correctness never depends on the lock staying held. */ +const SEED_LOCK_TTL_MS = FILE_DOC_TIMEOUTS.seedRequestMs + 4_000 +/** How long a stream survives with no heartbeat — long enough that an occupied-but-idle doc never + * loses its shared state (the heartbeat refreshes it while any task holds the room). */ +const STREAM_TTL_SEC = 600 +/** Refresh every occupied stream's TTL on this cadence, so a live doc's stream never expires. */ +const HEARTBEAT_MS = 60_000 + +const streamKey = (name: string) => `${STREAM_PREFIX}${name}` + +/** + * Decode one stream entry's base64 Yjs update and apply it to `doc`. A malformed entry is logged and + * SKIPPED — never thrown — so one bad frame can neither wedge the tailer nor abort a headless + * stream-fold. Shared by the tailer/catch-up (applies with {@link REDIS_ORIGIN}) and the merge-base + * reconstruction (no origin — a throwaway doc), so the two can never diverge on how an entry is read. + */ +function applyEntryToDoc( + doc: Y.Doc, + id: string, + message: Record, + origin?: unknown +): void { + const encoded = message[UPDATE_FIELD] + if (!encoded) return + try { + Y.applyUpdate(doc, new Uint8Array(Buffer.from(encoded, 'base64')), origin) + } catch (error) { + logger.warn('FileDocStore dropping malformed stream entry', { + id, + error: getErrorMessage(error), + }) + } +} + +/** Whether a doc carries the seed flag (mirrors the relay's `isDocSeeded`), so the store can tell the + * one-time seed transition from a real post-seed edit without re-implementing the check divergently. */ +function isDocSeeded(doc: Y.Doc): boolean { + return doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag) === true +} + +/** One locally-open room the store tracks: its doc and the last stream id applied to it. */ +interface StoreRoom { + doc: Y.Doc + /** The id of the last stream entry applied to `doc`; the tailer resumes strictly after it. */ + lastId: string + /** Local publish count, to pace compaction checks. */ + publishes: number + /** Set once the doc has been observed seeded, so the seed transition itself is never mistaken for an + * edit (mirrors the relay's `seededObserved`). */ + seededObserved: boolean + /** Whether the doc has integrated any REAL (non-agent, non-seed) edit. Compaction stamps its snapshot + * as an AGENT snapshot ({@link REDIS_AGENT_ORIGIN}, never persisted) until this is true, so a long + * agent-only stream that crosses the compaction threshold can't fold its preview content into a + * snapshot that marks peers edited. */ + realEdited: boolean +} + +/** + * The Redis-Streams shared Yjs backend. A single instance per process. `enabled` is false when there + * is no `REDIS_URL`, in which case every method is a no-op and the relay runs single-replica. + */ +export class FileDocStore { + readonly enabled: boolean + /** Command connection: XADD / locks / XLEN / XTRIM / EXPIRE. */ + private write: RedisClientType | null = null + /** Dedicated connection for blocking XREAD (a blocking command monopolizes its connection). */ + private read: RedisClientType | null = null + private readonly rooms = new Map() + private running = false + private heartbeat: ReturnType | null = null + + constructor(private readonly redisUrl: string | undefined) { + this.enabled = Boolean(redisUrl) + } + + /** Connect the two Redis clients and start the multiplexed reader + TTL heartbeat. Idempotent. */ + async init(): Promise { + if (!this.enabled || this.running || !this.redisUrl) return + const options = { + url: this.redisUrl, + socket: { + reconnectStrategy: (retries: number) => { + if (retries > 10) return new Error('FileDocStore Redis reconnection failed') + return Math.min(retries * 100, 3000) + }, + }, + } + this.write = createClient(options) + this.read = this.write.duplicate() + this.write.on('error', (err) => logger.error('FileDocStore write client error:', err)) + this.read.on('error', (err) => logger.error('FileDocStore read client error:', err)) + await Promise.all([this.write.connect(), this.read.connect()]) + this.running = true + void this.runReader() + this.heartbeat = setInterval(() => void this.refreshTtls(), HEARTBEAT_MS) + logger.info('FileDocStore ready — shared Yjs backend over Redis Streams enabled') + } + + /** Stop the reader/heartbeat and close both clients. */ + async shutdown(): Promise { + this.running = false + if (this.heartbeat) clearInterval(this.heartbeat) + this.heartbeat = null + await Promise.all([this.write?.quit().catch(() => {}), this.read?.quit().catch(() => {})]) + this.write = null + this.read = null + } + + /** + * Register a locally-opened room and load the shared state into its doc: read the whole stream from + * the head, apply every entry (origin {@link REDIS_ORIGIN}), and remember the last id so the tailer + * resumes exactly after it. A brand-new file has an empty stream and loads nothing (it is seeded + * shortly after, via {@link shouldSeed}). No-op when disabled. + */ + async attachRoom(name: string, doc: Y.Doc): Promise { + if (!this.enabled || !this.write) return + // Register BEFORE the async read so a concurrent publish/tailer for this room can't be missed — + // the tailer resumes from `lastId`, which the catch-up advances. + const room: StoreRoom = { + doc, + lastId: '0', + publishes: 0, + seededObserved: false, + realEdited: false, + } + this.rooms.set(name, room) + try { + const entries = await this.write.xRange(streamKey(name), '-', '+') + for (const entry of entries) { + // The room can be detached + its doc destroyed while catch-up is in flight (a fast open→close); + // stop touching it the moment that happens. + if (this.rooms.get(name) !== room) return + this.applyEntry(room, entry.id, entry.message) + } + await this.write.expire(streamKey(name), STREAM_TTL_SEC) + } catch (error) { + logger.warn(`FileDocStore catch-up failed for ${name}`, { error: getErrorMessage(error) }) + } + } + + /** Deregister a room the relay is destroying, so the tailer stops touching its (about-to-be-destroyed) doc. */ + detachRoom(name: string): void { + this.rooms.delete(name) + } + + /** + * Append a locally-applied update to the shared stream so every task converges, AWAITING the write + * and retrying a transient failure ({@link PUBLISH_MAX_RETRIES}) so a Redis blip can't silently drop + * an edit from the shared log. Only the `xAdd` is retried; the TTL refresh + compaction check are + * post-write best-effort and never re-trigger the append. Throws if the append ultimately fails. + */ + private async appendUpdate(name: string, update: Uint8Array, agent = false): Promise { + if (!this.write) return + // Latch realEdited SYNCHRONOUSLY — before the first await — for a real (non-agent) publish. The edit + // already sits in room.doc (applied in doc.on('update') before publish was called), so if this set + // were deferred past the xAdd/expire awaits a CONCURRENT agent-frame-triggered maybeCompact could read + // realEdited=false, snapshot the doc (which already holds this real edit), and stamp it an agent + // (no-persist) snapshot — a lost edit. Setting it in the same synchronous tick as the doc mutation + // makes "room.doc holds a real edit ⇒ realEdited" hold before any compaction (always async) can run. + // Monotonic latch, so an eager set is safe; the seed never flows through here (it uses seedIfEmpty). + if (!agent) { + const editedRoom = this.rooms.get(name) + if (editedRoom) editedRoom.realEdited = true + } + const encoded = Buffer.from(update).toString('base64') + const fields: Record = { [UPDATE_FIELD]: encoded } + if (agent) fields[AGENT_FIELD] = '1' + for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) { + try { + await this.write.xAdd(streamKey(name), '*', fields) + break + } catch (error) { + if (attempt === PUBLISH_MAX_RETRIES) { + logger.error(`FileDocStore append failed for ${name}`, { error: getErrorMessage(error) }) + throw error + } + // Snappy backoff — a stream append is a fast op; a transient blip clears in tens of ms. + // `backoffWithJitter` is 1-indexed, so pass the 1-based attempt number. + await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 50, maxMs: 500 })) + } + } + await this.write.expire(streamKey(name), STREAM_TTL_SEC).catch(() => {}) + const room = this.rooms.get(name) + if (room && ++room.publishes % COMPACT_CHECK_EVERY === 0) void this.maybeCompact(name) + } + + /** + * Fire-and-forget append for the hot keystroke path (`doc.on('update')`): converges peers without + * blocking the relay. Retries internally; never throws. No-op when disabled. Pass `agent: true` for + * a copilot preview frame so peer tasks tail it as {@link REDIS_AGENT_ORIGIN} and never persist it. + */ + publish(name: string, update: Uint8Array, agent = false): void { + if (!this.enabled || !this.write) return + void this.appendUpdate(name, update, agent).catch(() => {}) // already logged inside appendUpdate + } + + /** + * Awaitable append for callers that must know the update is durably in the stream before proceeding + * — the copilot merge, so the cross-task merge lock is not released before the diff is committed + * (else the next task would diff a stale base). Throws on ultimate failure. No-op when disabled. + */ + async publishAndWait(name: string, update: Uint8Array): Promise { + if (!this.enabled || !this.write) return + await this.appendUpdate(name, update) + } + + /** + * Atomically seed the stream iff it is still empty (see {@link SEED_IF_EMPTY_SCRIPT}). This is what + * actually prevents split-brain double-seeding: the emptiness check and the append happen in one + * Redis-side step, so — unlike a separate {@link streamHasContent} fence + {@link publishAndWait} — + * there is no check-then-append window, and two tasks racing (even both past an expired seed lock) can + * never both write a seed. Returns true if THIS call wrote the seed (apply it locally), false if the + * stream was already seeded (the tailer will deliver the peer's seed — do NOT apply a second one). + * Retries a transient Redis error like {@link appendUpdate}; throws if it ultimately fails. Disabled → + * true (single-replica: seed locally, no stream). + */ + async seedIfEmpty(name: string, update: Uint8Array): Promise { + if (!this.enabled || !this.write) return true + const encoded = Buffer.from(update).toString('base64') + for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) { + try { + const wrote = await this.write.eval(SEED_IF_EMPTY_SCRIPT, { + keys: [streamKey(name)], + arguments: [UPDATE_FIELD, encoded], + }) + await this.write.expire(streamKey(name), STREAM_TTL_SEC).catch(() => {}) + return wrote === 1 + } catch (error) { + if (attempt === PUBLISH_MAX_RETRIES) { + logger.error(`FileDocStore seed failed for ${name}`, { error: getErrorMessage(error) }) + throw error + } + await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 50, maxMs: 500 })) + } + } + return false + } + + /** + * Whether the file's stream already holds content — an EFFICIENCY recheck in {@link shouldSeed} that + * skips the seed fetch when a prior holder already seeded (the split-brain guard itself is the atomic + * {@link SEED_IF_EMPTY_SCRIPT}, not this check). Treats `true` as "already seeded", so it fails CLOSED: + * a Redis `xLen` error returns `true` (cannot confirm empty → skip the redundant fetch; the atomic seed + * would no-op anyway). `false` only when genuinely empty, or when disabled (single-replica). + */ + async streamHasContent(name: string): Promise { + if (!this.enabled || !this.write) return false + try { + return (await this.write.xLen(streamKey(name))) > 0 + } catch (error) { + logger.warn(`FileDocStore streamHasContent failed for ${name}`, { + error: getErrorMessage(error), + }) + return true + } + } + + /** + * Acquire a distributed lock with a unique ownership TOKEN (`SET key NX PX`). Returns the + * token to release with, or `null` if not won. Fails CLOSED (null) on a Redis error — a lock we can't + * prove we hold must not be treated as held. The special sentinel {@link DISABLED_LOCK_TOKEN} lets a + * disabled store return a truthy token so callers proceed single-replica without special-casing. + */ + private async acquireLock(key: string, ttlMs: number): Promise { + if (!this.enabled || !this.write) return DISABLED_LOCK_TOKEN + const token = generateId() + try { + return (await this.write.set(key, token, { NX: true, PX: ttlMs })) === 'OK' ? token : null + } catch (error) { + logger.warn(`FileDocStore lock ${key} failed`, { error: getErrorMessage(error) }) + return null + } + } + + /** Release a lock via compare-and-delete, so it is only dropped if we still hold our token. */ + private async releaseLock(key: string, token: string): Promise { + if (!this.write || token === DISABLED_LOCK_TOKEN) return + await this.write.eval(RELEASE_LOCK_SCRIPT, { keys: [key], arguments: [token] }).catch(() => {}) + } + + /** + * Decide whether THIS task should run the (expensive) seed fetch + write for a file. Returns a lock + * TOKEN when this task wins the seed lock and the stream still looks empty; `null` otherwise. This is an + * EFFICIENCY gate — it stops every task that opens the file at once from each fetching the seed. It does + * NOT by itself guarantee a single seed: exactly-once is enforced by the atomic {@link seedIfEmpty} the + * token-holder then calls (the split-brain guard), so a lock that expires mid-seed cannot cause a + * double-seed. Release the token with {@link releaseSeedLock}. Disabled → always a token (single-replica: + * seed locally). + */ + async shouldSeed(name: string): Promise { + const token = await this.acquireLock(`${SEED_LOCK_PREFIX}${name}`, SEED_LOCK_TTL_MS) + if (!token || token === DISABLED_LOCK_TOKEN) return token + // The lock could be free yet the stream already seeded (a prior holder seeded then its lock + // expired). Re-check so we skip the redundant seed fetch — the atomic seedIfEmpty would no-op anyway, + // but this avoids the wasted app round-trip. + if (await this.streamHasContent(name)) { + await this.releaseSeedLock(name, token) + return null + } + return token + } + + /** + * Build the file's current shared state from the stream, headless (no registered room), for a merge + * that must reach the live doc regardless of which task holds it. Returns the encoded Yjs state, or + * `null` when the stream is empty — i.e. no doc is (or was recently) live, so there is nothing to + * merge into and the caller should fall back to a direct file write. Disabled → always null. + */ + async getStreamState(name: string): Promise { + if (!this.enabled || !this.write) return null + const entries = await this.write.xRange(streamKey(name), '-', '+') + if (entries.length === 0) return null + const doc = new Y.Doc() + try { + for (const entry of entries) applyEntryToDoc(doc, entry.id, entry.message) + return Y.encodeStateAsUpdate(doc) + } finally { + doc.destroy() + } + } + + /** Release the seed lock (compare-and-delete) once the seed has been published or a seed attempt failed. */ + async releaseSeedLock(name: string, token: string): Promise { + await this.releaseLock(`${SEED_LOCK_PREFIX}${name}`, token) + } + + /** + * A best-effort TTL dedup WINDOW (NOT a lock): claim the right to run a debounced persist for the next + * `ttlMs`, so concurrent tasks editing the same file don't each write a redundant blob version. It is + * never released — it simply expires after `ttlMs`, gating the debounced persist to ~once per window + * cluster-wide. Fails OPEN (returns true on a Redis error): a redundant persist is a harmless + * idempotent write, so it must never block a real one. The final last-collaborator flush does NOT gate + * on this — it must always write. + */ + async tryClaimPersistWindow(name: string, ttlMs: number): Promise { + if (!this.enabled || !this.write) return true + try { + const won = await this.write.set(`${PERSIST_LOCK_PREFIX}${name}`, '1', { + NX: true, + PX: ttlMs, + }) + return won === 'OK' + } catch { + return true + } + } + + /** + * Try to claim the cross-task right to merge new content into this file. The relay already serializes + * merges per task; this extends that across tasks so two copilot edits to the same file landing on + * different tasks don't each diff the SAME shared base and publish conflicting full-document rewrites. + * The loser waits and retries so it diffs against the winner's RESULT (correct sequential merge). + * Returns a lock TOKEN (proceed) when disabled or once won; `null` otherwise (fails CLOSED on error, so + * a merge never races when exclusivity can't be proven). Release with {@link releaseMergeSlot}. + */ + async acquireMergeSlot(name: string, ttlMs: number): Promise { + return this.acquireLock(`${MERGE_LOCK_PREFIX}${name}`, ttlMs) + } + + /** + * The durable file version (its `updatedAt`, epoch ms) the shared live doc is synced to — the + * cluster-wide {@link https://www.rfc-editor.org/rfc/rfc7232 `If-Match`} token for persistence. Held + * in Redis (not per-task room state) so whichever task runs a debounced/last-leave persist reads the + * SAME version, even though the write that advanced it (a seed or a merged edit) may have run on + * another task. Returns `null` when unset/expired (persist then falls back to the local room's value). + */ + async getSyncedVersion(name: string): Promise { + if (!this.enabled || !this.write) return null + try { + const value = await this.write.get(`${SYNC_VERSION_PREFIX}${name}`) + const parsed = value === null ? Number.NaN : Number(value) + return Number.isFinite(parsed) ? parsed : null + } catch (error) { + logger.warn(`FileDocStore getSyncedVersion failed for ${name}`, { + error: getErrorMessage(error), + }) + return null + } + } + + /** Record the durable version the shared live doc is now synced to. MONOTONIC — writes only when the + * new value exceeds the stored one ({@link SET_VERSION_IF_NEWER_SCRIPT}), so an out-of-order + * fire-and-forget write can't regress the token. Best-effort; TTL-bounded like the stream so an idle + * file's key can't outlive its room. No-op when disabled (single-pod fallback). */ + async setSyncedVersion(name: string, version: number): Promise { + if (!this.enabled || !this.write) return + // Retry a transient failure (bounded) rather than swallow it: this token is the ONLY way a + // peer-seeded task learns the durable version, so a dropped write would leave that peer's persists + // deferring forever with the session's edits stranded in the TTL'd stream. The monotonic script makes + // a retry that races a newer value a no-op, never a regression. + for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) { + try { + await this.write.eval(SET_VERSION_IF_NEWER_SCRIPT, { + keys: [`${SYNC_VERSION_PREFIX}${name}`], + arguments: [String(version), String(STREAM_TTL_SEC)], + }) + return + } catch (error) { + if (attempt === PUBLISH_MAX_RETRIES) { + logger.warn(`FileDocStore setSyncedVersion failed for ${name}`, { + error: getErrorMessage(error), + }) + return + } + await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 50, maxMs: 500 })) + } + } + } + + /** Mark (or refresh) that a client is actively streaming an agent edit into this live doc — a plain + * `SET key "1" PX ttl`, so it self-clears when streaming stops. Best-effort; no-op when disabled. */ + async markAgentStreaming(name: string, ttlMs: number): Promise { + if (!this.enabled || !this.write) return + try { + await this.write.set(`${AGENT_STREAM_PREFIX}${name}`, '1', { PX: ttlMs }) + } catch (error) { + logger.warn(`FileDocStore markAgentStreaming failed for ${name}`, { + error: getErrorMessage(error), + }) + } + } + + /** Whether a client is currently streaming an agent edit into this live doc (see + * {@link markAgentStreaming}). Best-effort; treats an error/disabled store as "not streaming" so a + * merge never blocks on this check. */ + async isAgentStreaming(name: string): Promise { + if (!this.enabled || !this.write) return false + try { + return (await this.write.exists(`${AGENT_STREAM_PREFIX}${name}`)) === 1 + } catch (error) { + logger.warn(`FileDocStore isAgentStreaming failed for ${name}`, { + error: getErrorMessage(error), + }) + return false + } + } + + async releaseMergeSlot(name: string, token: string): Promise { + await this.releaseLock(`${MERGE_LOCK_PREFIX}${name}`, token) + } + + private applyEntry(room: StoreRoom, id: string, message: Record): void { + room.lastId = id + // A compaction snapshot folds seed + edits into one frame; stamp it so the relay's edit-tracker + // treats a fresh catch-up from it as edited (a snapshot only exists once real edits accumulated). An + // agent-streamed preview frame is stamped separately so the tracker NEVER marks it edited. + const origin = message[SNAPSHOT_FIELD] + ? REDIS_SNAPSHOT_ORIGIN + : message[AGENT_FIELD] + ? REDIS_AGENT_ORIGIN + : REDIS_ORIGIN + const seededBefore = room.seededObserved + applyEntryToDoc(room.doc, id, message, origin) + if (isDocSeeded(room.doc)) room.seededObserved = true + // Track a real edit integrated from the stream so compaction knows whether its snapshot represents + // real content or agent-only preview: a real snapshot (folds real edits), or a markerless edit + // applied AFTER the doc was already seeded (the seed transition itself never counts). Agent frames + // and agent snapshots (REDIS_AGENT_ORIGIN) never count. + if (origin === REDIS_SNAPSHOT_ORIGIN || (origin === REDIS_ORIGIN && seededBefore)) { + room.realEdited = true + } + } + + /** + * The single multiplexed tail loop: block-read every locally-open room's stream from its last id and + * apply new entries. One blocking connection for the whole process regardless of open-file count. + */ + private async runReader(): Promise { + while (this.running && this.read) { + const snapshot = new Map(this.rooms) + if (snapshot.size === 0) { + await sleep(IDLE_POLL_MS) + continue + } + try { + const res = await this.read.xRead( + [...snapshot].map(([name, room]) => ({ key: streamKey(name), id: room.lastId })), + { BLOCK: READ_BLOCK_MS, COUNT: READ_COUNT } + ) + if (!res) continue + for (const stream of res) { + const name = stream.name.slice(STREAM_PREFIX.length) + const room = this.rooms.get(name) + // Skip if detached mid-read, OR replaced by a close→reopen (a DIFFERENT StoreRoom): applying + // entries read against the OLD room's lastId to the new one could regress its lastId (harmless + // but wasteful re-delivery). The new room caught itself up via xRange already. + if (!room || room !== snapshot.get(name)) continue + for (const entry of stream.messages) this.applyEntry(room, entry.id, entry.message) + } + } catch (error) { + if (!this.running) break + logger.warn('FileDocStore reader error; retrying', { error: getErrorMessage(error) }) + await sleep(500) + } + } + } + + /** + * Snapshot-then-trim compaction: append a full-state snapshot and drop the older deltas it subsumes, + * so the stream stays bounded while a fresh task can still catch up from the head. Lock-guarded so + * only one task compacts a given stream at a time (concurrent snapshot+trim would race). Trims only up + * to what the snapshot provably contains — never un-integrated peer entries (see below). + */ + private async maybeCompact(name: string): Promise { + if (!this.write) return + const room = this.rooms.get(name) + if (!room) return + try { + if ((await this.write.xLen(streamKey(name))) < COMPACT_THRESHOLD) return + const key = `${COMPACT_LOCK_PREFIX}${name}` + const token = await this.acquireLock(key, COMPACT_LOCK_TTL_MS) + if (!token) return + try { + // Capture the snapshot AND the id it covers in one synchronous step (no await between): the + // snapshot is `room.doc`, which holds exactly what this task's tailer has integrated — every + // entry up to `room.lastId`. Entries a peer task published AFTER that (id > lastId) are NOT in + // the snapshot and this task's blocking reader may not have seen them yet, so we must NOT trim + // them — only entries the snapshot provably subsumes (id <= lastId). Trimming to the freshly + // appended snapshot id instead would silently drop those un-integrated peer entries. + const upTo = room.lastId + const snapshot = Buffer.from(Y.encodeStateAsUpdate(room.doc)).toString('base64') + // Stamp the snapshot by what it folds: a real edit → SNAPSHOT_FIELD (a fresh catch-up treats it + // as edited content, not a bare seed). An agent-ONLY stream (no real edit yet) → AGENT_FIELD, so a + // peer catching up applies it as REDIS_AGENT_ORIGIN and never marks the doc edited — preserving + // the no-persist guarantee even when a long copilot stream alone crosses the compaction threshold. + const marker = room.realEdited ? SNAPSHOT_FIELD : AGENT_FIELD + await this.write.xAdd(streamKey(name), '*', { + [UPDATE_FIELD]: snapshot, + [marker]: '1', + }) + // MINID keeps entries with id >= upTo: the snapshot, any un-integrated peer entries, and + // `upTo` itself (redundant with the snapshot, harmless); it drops only the folded older deltas. + await this.write.xTrim(streamKey(name), 'MINID', upTo) + } finally { + await this.releaseLock(key, token) + } + } catch (error) { + logger.warn(`FileDocStore compaction failed for ${name}`, { error: getErrorMessage(error) }) + } + } + + private async refreshTtls(): Promise { + if (!this.write) return + for (const name of this.rooms.keys()) { + await this.write.expire(streamKey(name), STREAM_TTL_SEC).catch(() => {}) + // Keep the synced-version key alive as long as its stream, so an open-but-idle doc's persist + // If-Match token can't expire out from under it (which would force a needless reconcile). + await this.write.expire(`${SYNC_VERSION_PREFIX}${name}`, STREAM_TTL_SEC).catch(() => {}) + } + } +} + +let store: FileDocStore | null = null + +/** + * Initialize the process-wide store from the realtime server bootstrap (alongside the socket adapter). + * Authoritative: if a disabled placeholder was lazily created by an early {@link getFileDocStore} call, + * this REPLACES it with the real, connected store — so the bootstrap can never silently no-op. A second + * call once already initialized is a no-op. + */ +export async function initFileDocStore(redisUrl: string | undefined): Promise { + if (store?.enabled) return store + store = new FileDocStore(redisUrl) + await store.init() + return store +} + +/** The process-wide store. Returns a disabled instance if init was never called (e.g. in unit tests). */ +export function getFileDocStore(): FileDocStore { + if (!store) store = new FileDocStore(undefined) + return store +} diff --git a/apps/realtime/src/handlers/file-doc.multireplica.test.ts b/apps/realtime/src/handlers/file-doc.multireplica.test.ts new file mode 100644 index 00000000000..cfffe81e857 --- /dev/null +++ b/apps/realtime/src/handlers/file-doc.multireplica.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment node + * + * Multi-replica (store-enabled) coverage for the copilot live-merge stale-check. The main + * `file-doc.test.ts` runs with the store DISABLED (single-replica fallback); this file mocks an ENABLED + * store so the cross-process branch of `mergeMarkdownIntoRoom` — staleness against the SHARED synced + * version under the merge lock, and `recordVersion` writing `setSyncedVersion` — is exercised directly. + * The enabled merge path reads its base from the shared store (not an in-memory room), so no JOIN/seed + * is needed: calling `applyMarkdownToLiveFileDoc` against the fake store drives the branch on its own. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +const { mockFetchFileDocMerge } = vi.hoisted(() => ({ + mockFetchFileDocMerge: vi.fn(), +})) + +/** + * A minimal ENABLED store: in-memory monotonic synced version (mirrors SET_VERSION_IF_NEWER_SCRIPT), a + * non-null stream state so the merge has a base, and no-op locks/publish. Only the surface the + * store-enabled merge path touches is implemented. + */ +const fakeStore = { + enabled: true, + versions: new Map(), + acquireMergeSlot: vi.fn(async () => 'token'), + releaseMergeSlot: vi.fn(async () => {}), + getStreamState: vi.fn(async () => new Uint8Array([1])), + publishAndWait: vi.fn(async () => {}), + getSyncedVersion: vi.fn(async (name: string) => fakeStore.versions.get(name) ?? null), + setSyncedVersion: vi.fn(async (name: string, version: number) => { + fakeStore.versions.set(name, Math.max(fakeStore.versions.get(name) ?? 0, version)) + }), + markAgentStreaming: vi.fn(async () => {}), + isAgentStreaming: vi.fn(async () => false), +} + +vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: vi.fn() })) + +vi.mock('@/handlers/file-doc-app', () => ({ + fetchFileDocSeed: vi.fn(), + fetchFileDocMerge: mockFetchFileDocMerge, + fetchFileDocPersist: vi.fn(), +})) + +vi.mock('@/handlers/file-doc-store', () => ({ + getFileDocStore: () => fakeStore, + REDIS_ORIGIN: Symbol('redis'), + REDIS_SNAPSHOT_ORIGIN: Symbol('redis-snapshot'), +})) + +import { applyMarkdownToLiveFileDoc } from '@/handlers/file-doc' + +const ROOM_NAME = 'workspace-file-doc:file-1' + +describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + fakeStore.versions.clear() + fakeStore.acquireMergeSlot.mockResolvedValue('token') + fakeStore.getStreamState.mockResolvedValue(new Uint8Array([1])) + mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc())) + }) + + it('drops a stale durable write against the SHARED synced version', async () => { + // A durable write (e.g. a concurrent human save on another process) records the shared synced version. + expect(await applyMarkdownToLiveFileDoc('file-1', '# durable', { version: 100 })).toBe( + 'applied' + ) + expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100) + mockFetchFileDocMerge.mockClear() + + // A durable write with an OLDER version than the SHARED synced version is stale — rejected under the + // lock before any diff is built, so it can't regress the doc across replicas. + expect(await applyMarkdownToLiveFileDoc('file-1', '# older durable', { version: 50 })).toBe( + 'stale' + ) + expect(mockFetchFileDocMerge).not.toHaveBeenCalled() + + // A newer durable write applies and advances the shared synced version. + expect(await applyMarkdownToLiveFileDoc('file-1', '# durable again', { version: 150 })).toBe( + 'applied' + ) + expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 150) + // setSyncedVersion fired only for the two applied durable writes, never for the stale one. + expect(fakeStore.setSyncedVersion).toHaveBeenCalledTimes(2) + }) + + it('defers the content merge cluster-wide when a client is streaming (records version, no diff)', async () => { + // The cluster-wide counterpart of the single-replica deferral: while the shared `isAgentStreaming` + // flag is set (a client on ANY replica is streaming this agent edit), the durable merge must record + // the version but skip the content diff — the streaming client owns the bytes, so a whole-document + // merge here would double-write them. + fakeStore.isAgentStreaming.mockResolvedValue(true) + + expect( + await applyMarkdownToLiveFileDoc('file-1', '# streamed by a client', { version: 100 }) + ).toBe('applied') + expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // content deferred to the client + expect(fakeStore.publishAndWait).not.toHaveBeenCalled() + expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100) // version still recorded + + // Once streaming stops the flag clears and the (now near-noop) durable merge resumes normally. + fakeStore.isAgentStreaming.mockResolvedValue(false) + expect(await applyMarkdownToLiveFileDoc('file-1', '# final durable', { version: 150 })).toBe( + 'applied' + ) + expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts new file mode 100644 index 00000000000..d56952bc619 --- /dev/null +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -0,0 +1,1063 @@ +/** + * @vitest-environment node + */ +import { + FILE_DOC_EVENTS, + FILE_DOC_MESSAGE_TYPE, + FILE_DOC_SEED, +} from '@sim/realtime-protocol/file-doc' +import * as decoding from 'lib0/decoding' +import * as encoding from 'lib0/encoding' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import * as Y from 'yjs' +import type { IRoomManager } from '@/rooms' + +const { mockAuthorizeRoom, mockFetchFileDocSeed, mockFetchFileDocMerge, mockFetchFileDocPersist } = + vi.hoisted(() => ({ + mockAuthorizeRoom: vi.fn(), + mockFetchFileDocSeed: vi.fn(), + mockFetchFileDocMerge: vi.fn(), + mockFetchFileDocPersist: vi.fn(), + })) + +vi.mock('@sim/platform-authz/rooms', () => ({ + authorizeRoom: mockAuthorizeRoom, +})) + +vi.mock('@/handlers/file-doc-app', () => ({ + fetchFileDocSeed: mockFetchFileDocSeed, + fetchFileDocMerge: mockFetchFileDocMerge, + fetchFileDocPersist: mockFetchFileDocPersist, +})) + +import { + applyMarkdownToLiveFileDoc, + cleanupFileDocForSocket, + flushAllFileDocRooms, + setupWorkspaceFileDocHandlers, +} from '@/handlers/file-doc' + +type Handler = (payload?: unknown) => Promise | void + +const ROOM_NAME = 'workspace-file-doc:file-1' + +interface SentMessage { + target: string + except?: string + event: string + payload: unknown +} + +/** An `io` mock that records every server-originated emit with its target/except. */ +function createIo() { + const sent: SentMessage[] = [] + /** Records `io.in(socketId).socketsLeave(room)` — a socket forced out of a room from outside. */ + const left: { socketId: string; room: string }[] = [] + const to = vi.fn((target: string) => ({ + except: (exclude: string) => ({ + emit: (event: string, payload: unknown) => + sent.push({ target, except: exclude, event, payload }), + }), + emit: (event: string, payload: unknown) => sent.push({ target, event, payload }), + })) + const inFn = vi.fn((socketId: string) => ({ + socketsLeave: (room: string) => { + left.push({ socketId, room }) + }, + })) + // Doc-sync frames fan out via `io.local.to(...)` (cross-task delivery rides the Redis stream, not the + // adapter). With the store disabled in tests, `local` is the whole room — mirror `to` so those emits + // are recorded identically. Awareness/presence still use `io.to(...)`. + return { io: { to, in: inFn, local: { to } } as unknown as IRoomManager['io'], sent, left } +} + +/** Every socket id a test created, so `afterEach` can drop their rooms without a + * hardcoded list drifting out of sync with the tests. */ +const createdSocketIds = new Set() + +function createSocket(id: string, overrides?: Record) { + createdSocketIds.add(id) + const handlers: Record = {} + const socket = { + id, + userId: 'user-1', + userName: 'Test User', + // Set so the server's roster resolves the avatar from the socket (never the DB). + userImage: 'avatar.png', + disconnected: false, + on: vi.fn((event: string, handler: Handler) => { + handlers[event] = handler + }), + emit: vi.fn(), + join: vi.fn(), + leave: vi.fn(), + ...overrides, + } + return { handlers, socket } +} + +function createRoomManager( + io: IRoomManager['io'], + overrides?: Partial +): IRoomManager { + return { + isReady: vi.fn().mockReturnValue(true), + io, + ...overrides, + } as unknown as IRoomManager +} + +function setup(id: string, io: IRoomManager['io'], socketOverrides?: Record) { + const { socket, handlers } = createSocket(id, socketOverrides) + setupWorkspaceFileDocHandlers( + socket as unknown as Parameters[0], + createRoomManager(io) + ) + return { socket, handlers } +} + +const FILE_DOC_FIELD = 'default' + +/** Let a fire-and-forget `void ensureServerSeed(...)` chain settle (mock resolves synchronously). */ +async function flushMicrotasks(): Promise { + // Enough to drain the fire-and-forget seed chain (shouldSeed → fetch → fence → publish → apply). + for (let i = 0; i < 8; i++) await Promise.resolve() +} + +/** + * An encoded Yjs update shaped like the server seed builder's output: some content in the shared + * `default` type plus the {@link FILE_DOC_SEED} flag, so applying it marks the doc seeded. + */ +function seedResult(content: string): { update: Uint8Array; version: number } { + const doc = new Y.Doc() + doc.getText(FILE_DOC_FIELD).insert(0, content) + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + return { update: Y.encodeStateAsUpdate(doc), version: 1 } +} + +/** Apply a server sync reply frame (`[SYNC tag][sync message]`) into a fresh client doc. */ +function applySyncReply(frameBytes: Uint8Array, doc: Y.Doc): void { + const decoder = decoding.createDecoder(frameBytes) + decoding.readVarUint(decoder) // skip the message-type tag + syncProtocol.readSyncMessage(decoder, encoding.createEncoder(), doc, null) +} + +/** Frame a Yjs message with its type tag, exactly as the client provider would. */ +function frame(type: number, write: (encoder: encoding.Encoder) => void): Uint8Array { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, type) + write(encoder) + return encoding.toUint8Array(encoder) +} + +/** Build a real awareness frame carrying a single client's state. */ +function awarenessFrame(clientId: number, name: string): { frame: Uint8Array; clientId: number } { + const doc = new Y.Doc() + // Force a specific clientID so the test can bind/spoof deliberately. + doc.clientID = clientId + const awareness = new awarenessProtocol.Awareness(doc) + awareness.setLocalStateField('user', { name }) + const update = awarenessProtocol.encodeAwarenessUpdate(awareness, [clientId]) + return { + frame: frame(FILE_DOC_MESSAGE_TYPE.AWARENESS, (e) => encoding.writeVarUint8Array(e, update)), + clientId, + } +} + +function joinSuccessFileId(socket: { emit: ReturnType }) { + const calls = socket.emit.mock.calls.filter( + (call: unknown[]) => call[0] === FILE_DOC_EVENTS.JOIN_SUCCESS + ) + const last = calls[calls.length - 1] + return (last?.[1] as { fileId: string } | undefined)?.fileId +} + +describe('setupWorkspaceFileDocHandlers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthorizeRoom.mockResolvedValue({ + allowed: true, + status: 200, + workspaceId: 'ws-1', + workspacePermission: 'write', + }) + // Default: the server seed builder returns no content (empty file). Tests that + // exercise seeding override this per-case with an encoded Yjs update. + mockFetchFileDocSeed.mockResolvedValue(null) + // Default: the merge builder returns a valid no-op (empty-doc) update. Tests exercising copilot + // merges override it. + mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc())) + // Default: persist succeeds. Tests asserting conflict/reconcile override this per-case. + mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 1 }) + }) + + afterEach(() => { + // The room store is module-global; drop every room the test's sockets opened. + const { io } = createIo() + // Simulate a full disconnect between tests (`endOfLife`) so the module-global join-generation + // map is cleared and never bleeds a counter into the next test. + for (const id of createdSocketIds) cleanupFileDocForSocket(id, io, true) + createdSocketIds.clear() + }) + + it('rejects join when the socket is not authenticated', async () => { + const { io } = createIo() + const { socket, handlers } = setup('socket-1', io, { userId: undefined, userName: undefined }) + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'AUTHENTICATION_REQUIRED', retryable: false }) + ) + }) + + it('rejects join with a retryable error when realtime is unavailable', async () => { + const { io } = createIo() + const { socket, handlers } = createSocket('socket-1') + setupWorkspaceFileDocHandlers( + socket as unknown as Parameters[0], + createRoomManager(io, { isReady: vi.fn().mockReturnValue(false) }) + ) + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'ROOM_MANAGER_UNAVAILABLE', retryable: true }) + ) + }) + + it('rejects a payload missing the file id or client id before authorizing', async () => { + const { io } = createIo() + const { socket, handlers } = setup('socket-1', io) + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: '', clientId: 1 }) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'INVALID_PAYLOAD', retryable: false }) + ) + expect(mockAuthorizeRoom).not.toHaveBeenCalled() + }) + + it('requires write permission and reports 404 as NOT_FOUND', async () => { + mockAuthorizeRoom.mockResolvedValue({ allowed: false, status: 404, workspacePermission: null }) + const { io } = createIo() + const { socket, handlers } = setup('socket-1', io) + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + expect(mockAuthorizeRoom).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'write', + room: { type: 'workspace-file-doc', id: 'file-1' }, + }) + ) + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'NOT_FOUND', retryable: false }) + ) + }) + + it('does NOT persist a seeded-but-unedited doc on last disconnect (no clobber of a concurrent write)', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() // let the seed apply + + // Last collaborator leaves without ever editing — projecting this seed back over the file could + // clobber a concurrent copilot write, so the final flush must NOT persist. + cleanupFileDocForSocket('socket-1', io, true) + await flushMicrotasks() + expect(mockFetchFileDocPersist).not.toHaveBeenCalled() + }) + + it('persists on last disconnect once a genuine user edit has landed', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + // A real user edit (socket-origin sync update) marks the doc dirty. + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'user typed this') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + + cleanupFileDocForSocket('socket-1', io, true) + await flushMicrotasks() + expect(mockFetchFileDocPersist).toHaveBeenCalled() + }) + + it('applies + fans out an agent-streamed frame (SYNC_NO_PERSIST) but never persists it', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io, sent } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + const before = sent.length + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'agent streamed this') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + + // It fans out to the WHOLE room — no socket excluded — so peers AND a same-socket sibling provider see + // the stream live (the emitting provider no-ops on its own echo). + const fanout = sent + .slice(before) + .filter((m) => m.event === FILE_DOC_EVENTS.MESSAGE && m.except === undefined) + expect(fanout.length).toBeGreaterThan(0) + + // ...but it must NOT mark the doc dirty: a last-disconnect flush never persists agent content (the + // copilot's final edit_content write is the authoritative durable persist). + cleanupFileDocForSocket('socket-1', io, true) + await flushMicrotasks() + expect(mockFetchFileDocPersist).not.toHaveBeenCalled() + }) + + it('stops on a persist conflict without clobbering (single attempt, durable left authoritative)', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + // A persist reports an out-of-band change (If-Match conflict). The relay must NOT re-persist against + // the current stream (the external write commits durable before its chokepoint merge lands, so a + // re-persist could clobber it) — it stops after a single attempt and leaves the durable file + // authoritative; a later flush projects the converged stream once the merge lands. + mockFetchFileDocPersist.mockResolvedValue({ + status: 'conflict', + }) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'user typed this') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + + // The conflict is handled gracefully: the persist is attempted exactly once (never silently skipped, + // never retried against a possibly-behind stream) and the durable file is left authoritative. + cleanupFileDocForSocket('socket-1', io, true) + await flushMicrotasks() + expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1) + }) + + it('flushAllFileDocRooms persists open EDITED rooms (graceful shutdown), skips unedited', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + // Seed-only room: a graceful-shutdown flush must NOT persist it. + mockFetchFileDocPersist.mockClear() + await flushAllFileDocRooms() + expect(mockFetchFileDocPersist).not.toHaveBeenCalled() + + // After a real user edit, the same flush persists (edits would otherwise be lost on deploy). + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'typed') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + mockFetchFileDocPersist.mockClear() + await flushAllFileDocRooms() + expect(mockFetchFileDocPersist).toHaveBeenCalled() + }) + + it('joins the room, sends sync step 1, and seeds the document from the server', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { socket, handlers } = setup('socket-1', io) + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + expect(socket.join).toHaveBeenCalledWith(ROOM_NAME) + expect(joinSuccessFileId(socket)).toBe('file-1') + + // A binary sync-step-1 message (type tag 0) is sent to kick off the handshake. + const syncMessage = socket.emit.mock.calls.find( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + expect((syncMessage?.[1] as Uint8Array)[0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) + + // The server seeds authoritatively from the file's stored markdown, keyed by (workspaceId, fileId). + await flushMicrotasks() + expect(mockFetchFileDocSeed).toHaveBeenCalledWith('ws-1', 'file-1') + + // The seeded state is served to a client that syncs: request step 2 and decode it. + socket.emit.mockClear() + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc())) + ) + const reply = socket.emit.mock.calls.find( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + const clientDoc = new Y.Doc() + applySyncReply(reply?.[1] as Uint8Array, clientDoc) + expect(clientDoc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag)).toBe(true) + expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# From server') + }) + + it('seeds the document only once from the server across concurrent joiners of the same file', async () => { + // Keep the first seed fetch IN FLIGHT so the doc is still unseeded when the second socket joins: + // that forces the dedup onto `serverSeedStarted` (the in-flight guard) rather than `isDocSeeded`. + let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {} + mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve))) + const { io } = createIo() + const a = setup('socket-a', io) + const b = setup('socket-b', io) + + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + // Second join happened with the fetch still pending; only after this does the seed land. + expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(1) + resolveSeed(seedResult('# From server')) + await flushMicrotasks() + expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(1) + }) + + it('marks an empty/absent-file doc seeded so clients still reach readiness', async () => { + // A genuinely absent file yields a null seed (a read error would throw, not return null). The + // relay must still flip `initialContentLoaded` so the client's `synced && seeded` gate opens. + mockFetchFileDocSeed.mockResolvedValue(null) + const { io } = createIo() + const { socket, handlers } = setup('socket-1', io) + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + socket.emit.mockClear() + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc())) + ) + const reply = socket.emit.mock.calls.find( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + const clientDoc = new Y.Doc() + applySyncReply(reply?.[1] as Uint8Array, clientDoc) + expect(clientDoc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag)).toBe(true) + expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('') + }) + + it('makes one seed attempt and releases the guard on failure so a later join retries', async () => { + mockFetchFileDocSeed + .mockRejectedValueOnce(new Error('transport blip')) + .mockResolvedValueOnce(seedResult('# Recovered')) + const { io } = createIo() + const { socket, handlers } = setup('socket-1', io) + + // First join: a single attempt that fails — no in-room retry loop. + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(1) + + // The guard was released, so a subsequent join re-attempts and this time the seed lands. + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + await flushMicrotasks() + expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(2) + + socket.emit.mockClear() + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc())) + ) + const reply = socket.emit.mock.calls.find( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + const clientDoc = new Y.Doc() + applySyncReply(reply?.[1] as Uint8Array, clientDoc) + expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# Recovered') + }) + + it('does not seed a room that was dropped while the seed fetch was in flight', async () => { + let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {} + mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve))) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + // The only owner leaves → the room (and its doc) is destroyed while the fetch is still pending. + cleanupFileDocForSocket('socket-1', io, true) + // Resolving now must not touch the destroyed doc or throw (liveness re-check after the await). + resolveSeed(seedResult('# Too late')) + await expect(flushMicrotasks()).resolves.toBeUndefined() + }) + + it('still seeds when content was synced into the doc before the seed returned', async () => { + // Defensive: the guard is `isDocSeeded`, NOT doc-emptiness. In practice a fresh client never + // writes ahead of the seed (@tiptap/y-tiptap suppresses the empty-paragraph placeholder and real + // edits are readiness-gated), but even if some update landed content in the doc before the seed + // fetch resolved, the seed must still apply and set the flag — or the client's + // `synced && initialContentLoaded` gate would never open. + let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {} + mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve))) + const { io } = createIo() + const { socket, handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + // The client syncs a placeholder update — content in the doc, but no seed flag. + const placeholder = new Y.Doc() + placeholder.getText(FILE_DOC_FIELD).insert(0, 'x') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(placeholder)) + ) + ) + resolveSeed(seedResult('# Seeded')) + await flushMicrotasks() + + socket.emit.mockClear() + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc())) + ) + const reply = socket.emit.mock.calls.find( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + const clientDoc = new Y.Doc() + applySyncReply(reply?.[1] as Uint8Array, clientDoc) + expect(clientDoc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag)).toBe(true) + expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toContain('# Seeded') + }) + + it('merges a copilot edit into a seeded live room and relays it to editors', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original')) + const { io, sent } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() // seed lands → room is seeded/live + + // The app returns a diff (here, an update introducing text) for the relay to apply. + const diff = new Y.Doc() + diff.getText(FILE_DOC_FIELD).insert(0, 'copilot content') + mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(diff)) + sent.length = 0 + + const result = await applyMarkdownToLiveFileDoc('file-1', '# Rewritten by copilot') + + expect(result).toBe('applied') + expect(mockFetchFileDocMerge).toHaveBeenCalledWith( + 'file-1', + expect.any(Uint8Array), + '# Rewritten by copilot' + ) + // Applying the diff fires doc.on('update') → the merge is broadcast to the whole room. + expect(sent.some((m) => m.event === FILE_DOC_EVENTS.MESSAGE && m.target === ROOM_NAME)).toBe( + true + ) + }) + + it('defers the content merge to an actively-streaming client (records version, no double-write)', async () => { + // Two-writer duplication guard: while a client is streaming an agent edit into the shared doc + // (agent frames flowing), a durable apply-edit merge must NOT also publish the same content — the + // client's private shadow never observes this merge and would re-insert it. The merge still records + // the durable version; once streaming stops it resumes and lands as a near-noop. + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + // A client streams an agent frame → the room is now "actively streaming". + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'agent streaming this live') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + + mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc())) + // The durable merge lands mid-stream: it must defer (record version) and skip the content diff. + const result = await applyMarkdownToLiveFileDoc('file-1', '# Rewritten by copilot', { + version: 100, + }) + expect(result).toBe('applied') + expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // content deferred to the client + + // The recorded version is honored: a later stale merge is still rejected on it. + expect(await applyMarkdownToLiveFileDoc('file-1', '# older', { version: 50 })).toBe('stale') + }) + + it('reports no-live-room (and does not call the app) when the file has no seeded room', async () => { + const result = await applyMarkdownToLiveFileDoc('file-1', '# anything') + expect(result).toBe('no-live-room') + expect(mockFetchFileDocMerge).not.toHaveBeenCalled() + }) + + it('rejects a stale versioned merge (not newer than the synced version) without regressing the doc', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original')) // seed version 1 + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc())) + + // A newer durable version lands and is recorded as the synced version. + expect(await applyMarkdownToLiveFileDoc('file-1', '# newer', { version: 100 })).toBe('applied') + mockFetchFileDocMerge.mockClear() + + // An older durable version arriving out of order (e.g. a concurrent write on another process) is + // stale: skipped before any diff is computed, so the live doc never regresses to older content and + // no diff is published that a later persist could write back. + expect(await applyMarkdownToLiveFileDoc('file-1', '# older, stale', { version: 50 })).toBe( + 'stale' + ) + // The same version is idempotent — also skipped. + expect(await applyMarkdownToLiveFileDoc('file-1', '# same version', { version: 100 })).toBe( + 'stale' + ) + expect(mockFetchFileDocMerge).not.toHaveBeenCalled() + }) + + it('serializes concurrent merges for the same file (second waits for the first)', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + // First merge is left in flight; the second must not start its own fetch until the first finishes. + const noOpUpdate = Y.encodeStateAsUpdate(new Y.Doc()) + let resolveFirst: (v: Uint8Array) => void = () => {} + mockFetchFileDocMerge + .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) + .mockResolvedValueOnce(noOpUpdate) + + const first = applyMarkdownToLiveFileDoc('file-1', '# One') + const second = applyMarkdownToLiveFileDoc('file-1', '# Two') + await flushMicrotasks() + expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(1) // second is queued behind the first + + resolveFirst(noOpUpdate) + await first + await second + // Only after the first resolved did the second run — and it snapshotted the post-first state. + expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(2) + }) + + it('relays a document update to the rest of the room, excluding the sender', async () => { + const { io, sent } = createIo() + const a = setup('socket-a', io) + const b = setup('socket-b', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + sent.length = 0 + + const clientDoc = new Y.Doc() + clientDoc.getText('default').insert(0, 'hello') + const update = Y.encodeStateAsUpdate(clientDoc) + a.handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeUpdate(e, update)) + ) + + const relayed = sent.find((m) => m.event === FILE_DOC_EVENTS.MESSAGE) + expect(relayed?.target).toBe(ROOM_NAME) + expect(relayed?.except).toBe('socket-a') + expect((relayed?.payload as Uint8Array)[0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) + }) + + it('relays an owned awareness update to the room, excluding the sender', async () => { + const { io, sent } = createIo() + const { frame: awFrame, clientId } = awarenessFrame(4242, 'Ada') + const a = setup('socket-a', io) + const b = setup('socket-b', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId }) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + sent.length = 0 + + a.handlers[FILE_DOC_EVENTS.MESSAGE](awFrame) + + const relayed = sent.find( + (m) => + m.event === FILE_DOC_EVENTS.MESSAGE && + (m.payload as Uint8Array)[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS + ) + expect(relayed?.except).toBe('socket-a') + }) + + it('drops an awareness frame that spoofs another client id', async () => { + const { io, sent } = createIo() + // socket-a binds client id 100 at join, but sends awareness for client 999. + const { frame: spoof } = awarenessFrame(999, 'Mallory') + const a = setup('socket-a', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 100 }) + sent.length = 0 + + a.handlers[FILE_DOC_EVENTS.MESSAGE](spoof) + + const relayed = sent.find( + (m) => + m.event === FILE_DOC_EVENTS.MESSAGE && + (m.payload as Uint8Array)[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS + ) + expect(relayed).toBeUndefined() + }) + + it("rejects a DIFFERENT user binding a peer's client id (spoof)", async () => { + const { io } = createIo() + const a = setup('socket-a', io) + const b = setup('socket-b', io, { userId: 'attacker' }) + + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 7 }) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 7 }) + + expect(b.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'CLIENT_ID_IN_USE' }) + ) + expect(b.socket.join).not.toHaveBeenCalled() + }) + + it('reclaims a client id for the SAME user reconnecting (reused Yjs client id)', async () => { + const { io } = createIo() + // The same user's dropped socket still owns client id 7 (its disconnect + // cleanup has not run yet) when it reconnects on a new socket reusing id 7. + const a = setup('socket-a', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 7 }) + const b = setup('socket-b', io) // same default userId 'user-1' + + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 7 }) + + expect(joinSuccessFileId(b.socket)).toBe('file-1') + expect(b.socket.join).toHaveBeenCalledWith(ROOM_NAME) + }) + + it('a socket owns MULTIPLE client ids (co-mounted providers) and relays awareness for each', async () => { + // The shared workspace socket hosts one provider per collaborative view, so the chat file preview + // and the standalone Files editor for the same file each JOIN with their own Yjs client id over ONE + // socket. Ownership is per client id: BOTH announcements must relay. (The old one-owner-per-socket + // model let the later JOIN overwrite the earlier, dropping its awareness — which broke the + // single-writer agent-stream election, letting a peer also self-elect and duplicate streamed text.) + const { io, sent } = createIo() + const a = setup('socket-a', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 500 }) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 501 }) + expect(joinSuccessFileId(a.socket)).toBe('file-1') + + const relayedFor = (clientId: number) => { + sent.length = 0 + a.handlers[FILE_DOC_EVENTS.MESSAGE](awarenessFrame(clientId, `c${clientId}`).frame) + return sent.find( + (m) => + m.event === FILE_DOC_EVENTS.MESSAGE && + (m.payload as Uint8Array)[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS + ) + } + // The FIRST provider's client id (500) is still owned after the second joins — its awareness relays. + expect(relayedFor(500)).toBeDefined() + // The second provider's client id (501) relays too. + expect(relayedFor(501)).toBeDefined() + // A client id this socket does NOT own is still dropped (ownership is not blanket-allowed). + expect(relayedFor(999)).toBeUndefined() + }) + + it('preserves the existing caret when a rebind to a foreign client id is rejected', async () => { + const { io, sent } = createIo() + const { frame: awFrame } = awarenessFrame(10, 'A') + const a = setup('socket-a', io) + const b = setup('socket-b', io, { userId: 'user-b' }) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 10 }) + a.handlers[FILE_DOC_EVENTS.MESSAGE](awFrame) // a publishes its caret for client 10 + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 20 }) + sent.length = 0 + + // socket-a (owns 10) tries to rebind to 20, owned by a different user → reject. + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 20 }) + + expect(a.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'CLIENT_ID_IN_USE' }) + ) + // The rejected rebind must NOT have removed a's existing caret (no awareness + // removal broadcast fires). + const removal = sent.find( + (m) => + m.event === FILE_DOC_EVENTS.MESSAGE && + (m.payload as Uint8Array)[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS + ) + expect(removal).toBeUndefined() + }) + + it('drops a malformed frame without throwing', async () => { + const { io } = createIo() + const a = setup('socket-a', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + expect(() => + a.handlers[FILE_DOC_EVENTS.MESSAGE](new Uint8Array([255, 254, 253, 200])) + ).not.toThrow() + }) + + it('drops the document when the last editor leaves, re-seeding a fresh joiner from the server', async () => { + const { io } = createIo() + const a = setup('socket-a', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + cleanupFileDocForSocket('socket-a', io) + + // The room was dropped with its last owner: a fresh joiner starts a new document, so the server + // is asked to seed it again (a stale in-memory doc is never reused across an empty gap). + mockFetchFileDocSeed.mockClear() + const b = setup('socket-b', io) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + await flushMicrotasks() + + expect(b.socket.join).toHaveBeenCalledWith(ROOM_NAME) + expect(mockFetchFileDocSeed).toHaveBeenCalledWith('ws-1', 'file-1') + }) + + it('aborts a join superseded by a newer join during authorization (no cross-binding)', async () => { + const { io } = createIo() + let resolveFirst: (v: unknown) => void = () => {} + mockAuthorizeRoom + .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) + .mockResolvedValueOnce({ allowed: true, status: 200, workspacePermission: 'write' }) + const s = setup('socket-a', io) + + const pending = s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 1 }) + resolveFirst({ allowed: true, status: 200, workspacePermission: 'write' }) + await pending + + // The socket is bound only to the newer file, never cross-bound to file-1. + expect(s.socket.join).toHaveBeenCalledWith('workspace-file-doc:file-2') + expect(s.socket.join).not.toHaveBeenCalledWith('workspace-file-doc:file-1') + }) + + it('does not register a socket that disconnected during authorization', async () => { + const { io, sent } = createIo() + let resolveAuth: (v: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValueOnce(new Promise((resolve) => (resolveAuth = resolve))) + const s = setup('socket-a', io) + + const pending = s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + s.socket.disconnected = true + cleanupFileDocForSocket('socket-a', io, true) // disconnect cleanup — no-op, nothing registered yet + resolveAuth({ allowed: true, status: 200, workspacePermission: 'write' }) + await pending + + expect(s.socket.join).not.toHaveBeenCalled() + // No room leaked: a fresh joiner starts a new document and joins cleanly. + const b = setup('socket-b', io) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + expect(b.socket.join).toHaveBeenCalledWith(ROOM_NAME) + expect(joinSuccessFileId(b.socket)).toBe('file-1') + }) + + it('does not abort an in-flight join when a leave for a different file arrives', async () => { + const { io } = createIo() + let resolveAuth: (v: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValueOnce(new Promise((resolve) => (resolveAuth = resolve))) + const s = setup('socket-a', io) + + const pending = s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 1 }) + // A stale leave for a DIFFERENT file must not invalidate the in-flight join. + s.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) + resolveAuth({ allowed: true, status: 200, workspacePermission: 'write' }) + await pending + + expect(joinSuccessFileId(s.socket)).toBe('file-2') + expect(s.socket.join).toHaveBeenCalledWith('workspace-file-doc:file-2') + }) + + it('does not reset the join generation on a leave, so an in-flight join still binds', async () => { + const { io } = createIo() + const s = setup('socket-a', io) + + // file-1 join completes; the socket is registered in file-1. + await s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + // file-2 join goes in-flight (authorize deferred). + let resolveAuth: (v: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValueOnce(new Promise((resolve) => (resolveAuth = resolve))) + const pending = s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 1 }) + + // A deferred leave for the prior file-1 lands while file-2's join awaits authorization. Its + // cleanup must NOT reset the monotonic join generation, or file-2's guard would see an emptied + // map (`undefined !== generation`) and abort the join the client actually wants. + s.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) + + resolveAuth({ allowed: true, status: 200, workspacePermission: 'write' }) + await pending + + expect(joinSuccessFileId(s.socket)).toBe('file-2') + expect(s.socket.join).toHaveBeenCalledWith('workspace-file-doc:file-2') + }) + + it('cancels an in-flight join when the client leaves that same file (no ghost owner)', async () => { + const { io, sent } = createIo() + let resolveAuth: (v: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValueOnce(new Promise((resolve) => (resolveAuth = resolve))) + const s = setup('socket-a', io) + + // Join file-1 is awaiting authorization when the client leaves file-1 (fast open→close). + const pending = s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + s.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) + resolveAuth({ allowed: true, status: 200, workspacePermission: 'write' }) + await pending + + // The stale join must not register: no success, no room join, and no presence broadcast that + // would leave a ghost collaborator until disconnect. + expect(s.socket.join).not.toHaveBeenCalled() + expect(joinSuccessFileId(s.socket)).toBeUndefined() + expect(sent.some((m) => m.event === FILE_DOC_EVENTS.PRESENCE)).toBe(false) + }) + + it('scopes LEAVE to the named file (a leave for a different file is a no-op)', async () => { + const { io } = createIo() + const a = setup('socket-a', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + a.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'other' }) + expect(a.socket.leave).not.toHaveBeenCalledWith(ROOM_NAME) + + a.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) + expect(a.socket.leave).toHaveBeenCalledWith(ROOM_NAME) + }) + + it('replies with a sync step 2 to the sender on a sync step 1 frame', async () => { + const { io } = createIo() + const a = setup('socket-a', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + // Give the server doc some content so a step-1 request yields a non-empty step 2. + const seeded = new Y.Doc() + seeded.getText('default').insert(0, 'hi') + a.handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(seeded)) + ) + ) + a.socket.emit.mockClear() + + a.handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc())) + ) + + const reply = a.socket.emit.mock.calls.find( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + expect((reply?.[1] as Uint8Array)[0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) + }) + + it('leaves the previous document when a socket switches files', async () => { + const { io, sent } = createIo() + const s = setup('socket-a', io) + await s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 1 }) + + expect(s.socket.leave).toHaveBeenCalledWith('workspace-file-doc:file-1') + expect(s.socket.join).toHaveBeenCalledWith('workspace-file-doc:file-2') + + // file-1's room was dropped (socket-a was its only owner): a fresh joiner of file-1 starts a new + // document, so the server is asked to seed it again. + await flushMicrotasks() + mockFetchFileDocSeed.mockClear() + const b = setup('socket-b', io) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + await flushMicrotasks() + expect(b.socket.join).toHaveBeenCalledWith('workspace-file-doc:file-1') + expect(mockFetchFileDocSeed).toHaveBeenCalledWith('ws-1', 'file-1') + }) + + it('fully evicts a reclaimed prior socket so it can no longer write to the doc', async () => { + const { io, sent, left } = createIo() + const a = setup('socket-a', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 7 }) + const b = setup('socket-b', io) // same default user-1 + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 7 }) // reclaims client id 7 + + // The stale prior socket is forced out of the Socket.IO room... + expect(left).toContainEqual({ socketId: 'socket-a', room: ROOM_NAME }) + + // ...and its room mapping is cleared, so a later document (SYNC) frame from it is dropped + // (handleMessage's SYNC path gates on socketToRoomName): nothing is applied or relayed. + sent.length = 0 + const doc = new Y.Doc() + doc.getText('t').insert(0, 'x') + const updateFrame = frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(doc)) + ) + a.handlers[FILE_DOC_EVENTS.MESSAGE](updateFrame) + expect(sent.some((m) => m.event === FILE_DOC_EVENTS.MESSAGE)).toBe(false) + }) + + it('does not drop the current document when a switch is rejected for a foreign client id', async () => { + const { io } = createIo() + const a = setup('socket-a', io) // user-1 + const other = setup('socket-c', io, { userId: 'user-b' }) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 10 }) // a owns 10 in file-1 + await other.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 99 }) // user-b owns 99 in file-2 + a.socket.leave.mockClear() + a.socket.join.mockClear() + + // a tries to switch to file-2 but requests client id 99, owned by a DIFFERENT user → reject. + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 99 }) + + expect(a.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'CLIENT_ID_IN_USE' }) + ) + // The rejected switch must leave file-1 intact — a is not torn out of its current document. + expect(a.socket.leave).not.toHaveBeenCalledWith('workspace-file-doc:file-1') + expect(a.socket.join).not.toHaveBeenCalledWith('workspace-file-doc:file-2') + }) + + it('broadcasts a server-authenticated presence roster on join, one entry per session', async () => { + const { io, sent } = createIo() + const a = setup('socket-a', io, { userId: 'user-a', userName: 'Ada', userImage: 'ada.png' }) + const b = setup('socket-b', io, { userId: 'user-b', userName: 'Bob', userImage: 'bob.png' }) + + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + + const roster = sent.filter((m) => m.event === FILE_DOC_EVENTS.PRESENCE).at(-1)?.payload as { + fileId: string + users: Array<{ socketId: string; userId: string; userName: string; avatarUrl: string | null }> + } + expect(roster.fileId).toBe('file-1') + // Identity is each socket's authenticated session — not any client-supplied value. + expect([...roster.users].sort((x, y) => x.userId.localeCompare(y.userId))).toEqual([ + { socketId: 'socket-a', userId: 'user-a', userName: 'Ada', avatarUrl: 'ada.png' }, + { socketId: 'socket-b', userId: 'user-b', userName: 'Bob', avatarUrl: 'bob.png' }, + ]) + }) + + it('keeps a per-session entry for two sockets of the SAME user (no server-side user dedup)', async () => { + const { io, sent } = createIo() + // Two tabs of one account: the client self-excludes its own socket, so the roster must carry + // BOTH sessions or a client could never see the other tab as present. + const a = setup('socket-a', io, { userId: 'user-a', userName: 'Ada', userImage: 'ada.png' }) + const b = setup('socket-b', io, { userId: 'user-a', userName: 'Ada', userImage: 'ada.png' }) + + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + + const roster = sent.filter((m) => m.event === FILE_DOC_EVENTS.PRESENCE).at(-1)?.payload as { + users: Array<{ socketId: string; userId: string }> + } + expect([...roster.users].map((u) => u.socketId).sort()).toEqual(['socket-a', 'socket-b']) + }) +}) diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts new file mode 100644 index 00000000000..a3d761bb40f --- /dev/null +++ b/apps/realtime/src/handlers/file-doc.ts @@ -0,0 +1,1177 @@ +/** + * Collaborative document editing (live carets + text selection) for a single + * file's rich-text editor. This is the standard Yjs "websocket server" relay — + * an authoritative in-memory {@link Y.Doc} + {@link awarenessProtocol.Awareness} + * per file — carried over the shared, already-authenticated Socket.IO connection + * and the room abstraction, rather than a separate ws server. Clients speak the + * `y-protocols` sync + awareness protocols; the server applies and relays them. + * + * Multi-replica safe. The in-memory {@link Y.Doc} is NOT authoritative on its own — every task + * converges on one CRDT per file through the shared Redis-Streams backend in {@link file-doc-store}: + * each applied update is published to the file's stream and every task's tailer applies it to its own + * doc and fans it out to its own clients, so two tasks can never split-brain. Consequently doc-sync + * messages are broadcast LOCALLY ({@link io.local}) and cross-task delivery rides the stream — NOT the + * Socket.IO adapter (that would double-deliver). Awareness/presence stay on the adapter: they are + * ephemeral and need neither convergence nor replay. When `REDIS_URL` is unset the store is disabled + * and this falls back to the original single-replica behavior (local doc, local seed, local fan-out). + * + * Durability: the live doc is projected back to the file's markdown server-side — debounced while it is + * edited and flushed when the last collaborator leaves — via the app's `/persist` endpoint (the app + * owns the conversion engine). This replaces the editor's client autosave, closing the copilot + * clobber-window, and the Redis stream is the crash buffer between flushes. The markdown file remains + * the long-term source of truth; the Redis stream is ephemeral (TTL'd, heartbeat-refreshed while live). + * + * @module + */ +import { createLogger } from '@sim/logger' +import { + FILE_DOC_EVENTS, + FILE_DOC_MESSAGE_TYPE, + FILE_DOC_SEED, + FILE_DOC_TIMEOUTS, + type FileDocPresenceUser, + type JoinFileDocPayload, + type LeaveFileDocPayload, + toFileDocBytes, +} from '@sim/realtime-protocol/file-doc' +import { ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import * as decoding from 'lib0/decoding' +import * as encoding from 'lib0/encoding' +import type { Server } from 'socket.io' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import * as Y from 'yjs' +import { resolveAvatarUrl } from '@/handlers/avatar' +import { fetchFileDocMerge, fetchFileDocPersist, fetchFileDocSeed } from '@/handlers/file-doc-app' +import { + getFileDocStore, + REDIS_AGENT_ORIGIN, + REDIS_ORIGIN, + REDIS_SNAPSHOT_ORIGIN, +} from '@/handlers/file-doc-store' +import { resolveRoomJoinAuth } from '@/handlers/room-join-auth' +import type { AuthenticatedSocket } from '@/middleware/auth' +import type { IRoomManager } from '@/rooms' + +const logger = createLogger('FileDocHandlers') + +/** + * The transaction origin the server stamps on a SEED apply, so `doc.on('update')` can broadcast + share + * it (peers still need the seed) but skip the debounced persist — the seed IS the file's current + * content, so writing it straight back would only churn a redundant blob version. + */ +const SEED_ORIGIN = Symbol('file-doc-seed') + +/** Debounce window for the server-side project-to-markdown persist while a doc is actively edited. */ +const PERSIST_DEBOUNCE_MS = 5_000 +/** Max-wait cap on the persist debounce: a CONTINUOUS edit burst keeps resetting the 5s debounce and + * would otherwise never persist until an idle pause, so force a flush at least this often — bounding how + * many edits are unpersisted (in the stream only) if the task dies mid-burst. */ +const PERSIST_MAX_WAIT_MS = 20_000 +/** On a FINAL (last-leave/shutdown) flush the version read is the last chance to resolve the If-Match + * before the room is torn down; a transient Redis blip yielding no version would otherwise defer and + * strand the session's edits in the TTL'd stream. The version is cluster-wide and heartbeat-refreshed, so + * a brief bounded retry recovers a blip without stalling teardown (a genuinely-unset version never + * appears, so a long wait buys nothing). */ +const FINAL_VERSION_RETRIES = 2 +const FINAL_VERSION_RETRY_MS = 100 + +/** Cross-task merge lock. The TTL must exceed the whole critical section it guards — stream fold + + * `fetchFileDocMerge` (bounded at `mergeRequestMs`) + the awaited publish — so the lock never expires + * mid-merge and lets a second task race the same base; hence `mergeRequestMs` plus generous headroom. + * The waiter retries at {@link MERGE_LOCK_RETRY_MS} for LONGER than the TTL, so a live holder always + * releases (or a dead holder's lock expires) within the window. Release is compare-and-delete on an + * ownership token, so even a pathological over-TTL hold never deletes another task's re-acquired lock. */ +const MERGE_LOCK_TTL_MS = FILE_DOC_TIMEOUTS.mergeRequestMs + 5_000 +const MERGE_LOCK_RETRY_MS = 200 +const MERGE_LOCK_RETRIES = Math.ceil( + (MERGE_LOCK_TTL_MS + FILE_DOC_TIMEOUTS.mergeRequestMs) / MERGE_LOCK_RETRY_MS +) + +/** + * How long after the last agent frame a client is still treated as "actively streaming" (so a durable + * merge defers to it). Comfortably longer than the gap between agent frames (per-rAF to the client's + * reparse throttle) so brief throttle pauses don't flip it off mid-stream, yet short enough that the + * final durable merge — which lands as a near-noop once the client has streamed everything — isn't + * deferred for long after the stream ends. + */ +const AGENT_STREAM_FLAG_TTL_MS = 10_000 + +/** One presence ownership within a room: a (socket, clientID) pair. */ +interface FileDocOwner { + /** + * An awareness clientID this socket declared at join. The socket may only publish/remove awareness + * for a clientID it owns, so an authenticated peer cannot forge or clear another collaborator's + * presence. A single socket can own SEVERAL clientIDs at once — the shared workspace socket hosts one + * provider per mounted collaborative view, so e.g. the chat file preview and the standalone Files + * editor for the same file each bind their own Yjs clientID over the one socket. The election that + * picks a single agent-stream writer depends on every such provider's awareness propagating, so + * ownership is tracked per clientID, not one-per-socket (which would drop the later joiner's frames). + */ + clientId: number + /** The owning user — used to tell a reconnect (same user reusing its Yjs client + * id) from a spoof (a different user binding a peer's id). */ + userId: string + /** Server-authenticated display identity for the presence roster (from the socket's + * session, never the client-set awareness — so a peer cannot spoof it). */ + userName: string + avatarUrl: string | null +} + +interface FileDocRoom { + /** The `workspace_files.id` this room edits. */ + fileId: string + doc: Y.Doc + awareness: awarenessProtocol.Awareness + /** socketId → (clientId → its presence ownership). A socket owns one entry per collaborative provider + * it mounted for this file (see {@link FileDocOwner}); an empty inner map is never kept. */ + owners: Map> + /** True once the server-side seed fetch has started, so concurrent joins don't each fetch. + * Reset on a fetch FAILURE so a later join can retry (a genuinely empty file stays empty). */ + serverSeedStarted: boolean + /** The workspace this file belongs to, captured at join — needed to persist back to markdown. */ + workspaceId: string | null + /** The last collaborator to edit here, for persist attribution (blob metadata) only. */ + lastEditorUserId: string | null + /** + * True once a genuine edit (a user edit — local OR a peer's, relayed via the tailer) has been applied + * here AFTER seeding. Persistence is gated on it so a doc that was only seeded is NEVER projected back + * over the file: the seed is captured from possibly-stale markdown and must not clobber a concurrent + * external write, whereas real edits are not otherwise durable and must be persisted by whichever task + * is last to leave — even one that only tailed the edits. + */ + edited: boolean + /** Whether this room has observed its doc become seeded — so a post-seed update counts as an edit but + * the seed transition itself does not. See the `doc.on('update')` edit-tracking below. */ + seededObserved: boolean + /** The pending debounced persist timer, if any. */ + persistTimer: ReturnType | null + /** Absolute time (ms) by which a debounced persist must fire even under continuous editing (the + * max-wait cap); null when no persist is pending. */ + persistDeadline: number | null + /** + * The durable file version (its `updatedAt`, epoch ms) this live doc is known to be synced to — set + * on seed, advanced when a durable write is merged in (apply-edit) and on each successful persist. It + * is the `If-Match` token {@link flushPersist} sends so a persist can never clobber an out-of-band + * edit the live doc hasn't incorporated. `null` before the first seed (persist writes unconditionally). + */ + syncedVersion: number | null + /** + * Epoch ms until which a client is treated as actively streaming an agent edit into this doc — + * refreshed on every agent frame ({@link AGENT_SYNC_ORIGIN}/{@link REDIS_AGENT_ORIGIN}). While it is in + * the future, a durable {@link applyMarkdownToLiveFileDoc} merge DEFERS its content diff to that client + * (which applies the same content into the shared doc), preventing the two-writer duplication; the merge + * still records the durable version. The single-replica counterpart of the cluster-wide + * {@link FileDocStore.isAgentStreaming} flag. `0` when no agent stream is active. + */ + agentStreamingUntil: number +} + +/** Live documents keyed by Socket.IO room name. Module-global: one Y.Doc per file. */ +const fileDocRooms = new Map() +/** socketId → its current file-doc room name (a socket edits at most one doc). */ +const socketToRoomName = new Map() +/** + * socketId → a monotonic join generation. A JOIN bumps it on arrival and, after + * the async authorization, proceeds only if the generation is still its own — so + * a newer JOIN (a fast document switch) or a disconnect (which drops the entry in + * cleanup) that occurred during authorization aborts the now-stale JOIN. Without + * this, an out-of-order authorize completion could bind the socket to the wrong + * document, or a disconnect-during-authorize could register a dead socket and + * leak its room. + */ +const joinGeneration = new Map() + +interface AwarenessChange { + added: number[] + updated: number[] + removed: number[] +} + +const fileDocRoom = (fileId: string): RoomRef => ({ + type: ROOM_TYPES.WORKSPACE_FILE_DOC, + id: fileId, +}) + +/** + * A `y-protocols` transaction/awareness origin is the emitting socket id (a + * string) when it came from a client, and something else (`null` / `'local'` / + * `'timeout'`) for server-internal changes. Returns the socket id to exclude + * from a relay, or `null` to broadcast to the whole room. + */ +function originSocketId(origin: unknown): string | null { + return typeof origin === 'string' ? origin : null +} + +/** + * The transaction origin stamped on an agent-streamed frame (a {@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST} + * apply). A non-string sentinel, so `originSocketId` returns `null` for it and the update never triggers + * `edited`/`schedulePersist` (the copilot's final `edit_content` write is the durable persist). Unlike a + * client edit, an agent frame is broadcast to the WHOLE room (its originating socket is NOT excluded), so a + * second {@link FileDocProvider} on the same socket — e.g. the chat preview alongside the Files editor — + * also receives the mid-stream ops. The emitting provider no-ops on its own echo (the ops are already + * applied locally), so broadcasting back to the sender is harmless. + */ +const AGENT_SYNC_ORIGIN = Symbol('file-doc-agent-sync') + +/** + * Broadcast an AWARENESS frame to the room ACROSS tasks via the Socket.IO Redis adapter. Awareness + * (cursors/selection) is ephemeral and needs no convergence or replay, so the adapter's cross-task + * fan-out is exactly right for it. + */ +function broadcast(io: Server, name: string, payload: Uint8Array, exceptSocketId: string | null) { + const channel = exceptSocketId ? io.to(name).except(exceptSocketId) : io.to(name) + channel.emit(FILE_DOC_EVENTS.MESSAGE, payload) +} + +/** + * Broadcast a DOC-SYNC frame to this task's LOCAL clients only. Cross-task delivery rides the shared + * Redis stream (each task's tailer applies the update and runs its OWN local fan-out), so using the + * adapter here would double-deliver and amplify. With no adapter (single-pod dev) `io.local` is the + * whole room, so behavior is unchanged. + */ +function broadcastLocal( + io: Server, + name: string, + payload: Uint8Array, + exceptSocketId: string | null +) { + const channel = exceptSocketId ? io.local.to(name).except(exceptSocketId) : io.local.to(name) + channel.emit(FILE_DOC_EVENTS.MESSAGE, payload) +} + +/** + * Schedule a debounced server-side persist of the live doc back to durable markdown. Coalesces rapid + * edits; a no-op until the room knows its workspace (set at join). A {@link PERSIST_MAX_WAIT_MS} + * max-wait caps the debounce so a continuous burst still persists periodically. The final flush on + * last-disconnect is separate ({@link flushPersist} with `final`). + */ +function schedulePersist(name: string, room: FileDocRoom): void { + if (!room.workspaceId || !room.lastEditorUserId) return + const now = Date.now() + if (room.persistDeadline === null) room.persistDeadline = now + PERSIST_MAX_WAIT_MS + if (room.persistTimer) clearTimeout(room.persistTimer) + const delay = Math.max(0, Math.min(PERSIST_DEBOUNCE_MS, room.persistDeadline - now)) + room.persistTimer = setTimeout(() => { + room.persistTimer = null + room.persistDeadline = null + void flushPersist(name, room, false) + }, delay) +} + +/** + * Project the live doc to markdown and write it durably via the app. `final` (last collaborator + * leaving) always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW + * (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks + * editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure + * is retried on the next debounce; the stream holds the state meanwhile). + * + * Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge — or + * a peer's edit — published by another task may not be integrated into `room.doc` yet (and the stream + * holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't + * clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a + * fallback before any await, so a `void flushPersist(name, room, true)` fired immediately before the + * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative. + */ +async function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise { + // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}). + if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return + const store = getFileDocStore() + const workspaceId = room.workspaceId + const userId = room.lastEditorUserId + // Synchronous fallback capture — before any await, since the caller may destroy `room.doc` the moment + // this yields. Only meaningful once seeded; used only when the authoritative stream state is absent. + const localState = isDocSeeded(room.doc) ? Y.encodeStateAsUpdate(room.doc) : null + + // Capture the AUTHORITATIVE doc state: the shared stream when enabled (a copilot merge or a peer's + // edit published by another task may not be integrated into THIS task's `room.doc` yet), else the + // local snapshot. Re-read each attempt so a post-reconcile retry projects the converged state. + const captureState = async (): Promise => { + if (!store.enabled) { + // Single-pod: re-read the live doc so a post-reconcile retry projects the CONVERGED state, not the + // snapshot captured before the reconcile mutated it (which — with the version already advanced — + // would let the If-Match pass and clobber the reconciled edit). Fall back to that snapshot once the + // room is torn down (last-leave), where the doc is gone and there is nothing left to reconcile. + return fileDocRooms.get(name) === room && isDocSeeded(room.doc) + ? Y.encodeStateAsUpdate(room.doc) + : localState + } + try { + return (await store.getStreamState(name)) ?? localState + } catch (streamError) { + // A transient Redis read must NOT drop the write when we already hold a valid local snapshot — + // else the last-disconnect flush loses the session's edits as the room is torn down. But once a + // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a + // failed read then correctly THROWS and aborts rather than clobbering with the stale snapshot. + if (!localState) throw streamError + logger.warn(`Stream state unavailable for file ${room.fileId}; persisting local snapshot`, { + error: getErrorMessage(streamError), + }) + return localState + } + } + // The If-Match token: the freshest synced version known — max of the cluster value (Redis) and the + // room's, so a lagging fire-and-forget `setSyncedVersion` can't let a stale read shadow a newer local + // value (versions are monotonic epoch-ms). Cached back into the room so a later transient Redis failure + // — or a peer-seeded/tail-only task that never set it locally — still resolves it. `undefined` if unknown. + const currentVersion = async (): Promise => { + const shared = store.enabled ? await store.getSyncedVersion(name) : null + const best = Math.max(shared ?? 0, room.syncedVersion ?? 0) + if (best > 0) room.syncedVersion = best + return best > 0 ? best : undefined + } + + try { + if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))) + return + + // The If-Match token: the durable content version the live doc is synced to. + let ifMatch = await currentVersion() + // FINAL flush = last chance before teardown: if the version read momentarily fails (Redis blip) for a + // peer-seeded/tail-only task that never cached it, retry briefly rather than defer and strand the + // edits in the TTL'd stream (the version is cluster-wide + heartbeat-refreshed). Bounded — a genuinely + // unset version never appears, and the flush must not stall teardown. + for ( + let i = 0; + ifMatch === undefined && final && store.enabled && i < FINAL_VERSION_RETRIES; + i++ + ) { + await sleep(FINAL_VERSION_RETRY_MS) + ifMatch = await currentVersion() + } + + // Persist under optimistic concurrency (RFC 7232 If-Match): the write commits only if the file is + // still at the version the live doc synced from, so a projection can never silently clobber an + // out-of-band edit. A single attempt — on conflict we STOP rather than retry (see below). + const docState = await captureState() + if (!docState) return // nothing seeded/authoritative to persist yet + const result = await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch) + if (result.status === 'missing') return // the file was deleted; nothing to write + if (result.status === 'deferred') { + // No version token available (momentarily — a Redis blip on a peer-seeded task). Leave the edits in + // the stream; a later persist writes them once the version is re-established. + logger.warn(`Persist deferred for file ${room.fileId} (no synced version available yet)`) + return + } + if (result.status === 'persisted') { + room.syncedVersion = Math.max(room.syncedVersion ?? 0, result.version) + void store.setSyncedVersion(name, result.version) + return + } + // status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT + // re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge + // (`mergeEditIntoLiveFileDoc`) reaches the stream, so a re-persist landing in that window would CAS-pass + // with a stream that still lacks the external content and clobber the committed write. Instead leave the + // durable content authoritative — the chokepoint merges the change into the stream and, ONLY once it is + // actually there, advances the synced version (via the merge's own `recordVersion`); a later flush + // (a subsequent debounced persist, or the final flush) then projects the converged stream with a token + // that matches. The session's edits stay in the stream meanwhile. Deliberately do NOT advance the synced + // version here: before the stream reflects the durable content, that would let the next flush clobber it. + logger.warn( + `Persist conflict for file ${room.fileId}; durable content advanced out-of-band, left authoritative` + ) + } catch (error) { + logger.warn(`Persist failed for file ${room.fileId}`, { error: getErrorMessage(error) }) + } +} + +/** + * Broadcast the room's collaborator roster to everyone in it, for the avatar stack. One entry + * PER SESSION (socket) — the client excludes its own socket and dedupes the remainder per user + * for display, so a second tab of the same account still registers as present (mirroring the + * canvas presence model). Deduping here instead would drop the current user's other sessions + * asymmetrically (only one socket survives), so each client could never reliably self-exclude. + * Identity comes from each owner's server-authenticated session — never the client-set awareness + * — so a peer cannot spoof or suppress an entry. + */ +function broadcastFileDocPresence(io: Server, name: string, room: FileDocRoom) { + const users: FileDocPresenceUser[] = [] + // One entry PER SOCKET (session), not per clientID: a socket's several providers are the same + // authenticated user, so any of its owners carries the identity; the client dedupes per user for the + // avatar stack (see the roster comment above). An empty inner map is never stored, so `owner` exists. + for (const [socketId, clientMap] of room.owners) { + const owner = clientMap.values().next().value + if (!owner) continue + users.push({ + socketId, + userId: owner.userId, + userName: owner.userName, + avatarUrl: owner.avatarUrl, + }) + } + io.to(name).emit(FILE_DOC_EVENTS.PRESENCE, { fileId: room.fileId, users }) +} + +/** Whether the client has recorded that it seeded the document's initial content. */ +function isDocSeeded(doc: Y.Doc): boolean { + return doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag) === true +} + +/** + * Decode the client IDs an awareness update carries, without applying it, to + * check a frame only touches its sender's own presence. Mirrors the wire format + * of `awarenessProtocol.encodeAwarenessUpdate`: a count, then per client a + * varUint id, a varUint clock, and a varString state. + */ +function awarenessUpdateClientIds(update: Uint8Array): number[] { + const decoder = decoding.createDecoder(update) + const count = decoding.readVarUint(decoder) + const ids: number[] = [] + for (let i = 0; i < count; i++) { + ids.push(decoding.readVarUint(decoder)) + decoding.readVarUint(decoder) // clock + decoding.readVarUint8Array(decoder) // state bytes — advanced past, only ids matter + } + return ids +} + +/** + * Drop a room's document + awareness once it has no owners on THIS task, so an idle file holds no + * memory. Before dropping, flush the converged doc back to durable markdown (the last collaborator on + * this task leaving) and detach from the shared stream. A later joiner re-creates it — catching up + * from the stream if the doc is still live on another task, or re-seeding from markdown otherwise. + */ +function destroyRoomIfIdle(name: string) { + const room = fileDocRooms.get(name) + if (!room || room.owners.size > 0) return + room.persistDeadline = null + if (room.persistTimer) { + clearTimeout(room.persistTimer) + room.persistTimer = null + } + // Final durable flush BEFORE teardown — `flushPersist` encodes the doc synchronously (before the + // destroy below) and awaits the write in the background. Best-effort; never throws. + void flushPersist(name, room, true) + getFileDocStore().detachRoom(name) + room.awareness.destroy() + room.doc.destroy() + fileDocRooms.delete(name) +} + +/** + * Flush every open, edited room's converged doc to durable markdown, AWAITING the writes. Called on + * graceful shutdown (rolling deploy / scale-in) so edits since the last debounce aren't left only in the + * ephemeral stream — the per-socket disconnect flush is fire-and-forget and would race `process.exit`. + * Best-effort and bounded by each persist's own timeout; never throws. Rooms are NOT torn down here (the + * process is exiting); only their durable state is secured. + */ +export async function flushAllFileDocRooms(): Promise { + const flushes: Promise[] = [] + for (const [name, room] of fileDocRooms) { + if (room.edited) flushes.push(flushPersist(name, room, true)) + } + await Promise.all(flushes) +} + +/** + * Seed a room's document server-side, once, on the first join: ask the app to build the seed (the + * file's current markdown → Yjs, through the exact editor engine) and apply it, which relays the + * content to every connected client via `doc.on('update')`. No client is elected to import content. + * + * `isDocSeeded` is the sufficient guard: content only ever reaches the doc alongside the seed flag + * (this seed, or a client's offline fallback), so an unseeded doc is genuinely empty and safe to seed. + * A genuinely empty/missing file returns `null` (a read error throws instead), so still set the flag — + * an empty doc must reach readiness, not wait forever. After the fetch, re-check the room is still + * live and unseeded (an owner may have left, or a client seeded it, while the fetch was in flight). + * + * Recovery on failure is deliberately simple — no in-room retry loop: a single attempt bounded by a + * timeout shorter than the client's readiness deadline, then release the guard. A transient failure + * is re-attempted by the next join/reconnect; a persistent one lets the connected client's readiness + * deadline lapse into its read-only fallback. (An in-room backoff retry can outlast that client + * deadline, so it would keep trying a doc the client has already given up on — worse, not better.) + */ +async function ensureServerSeed( + name: string, + room: FileDocRoom, + workspaceId: string +): Promise { + if (room.serverSeedStarted || isDocSeeded(room.doc)) return + room.serverSeedStarted = true + const store = getFileDocStore() + // Exactly one task across the cluster builds the seed; the others receive it via the stream (the fix + // for split-brain seeding). Returns a lock token here (single-pod: a sentinel token). + const token = await store.shouldSeed(name) + if (!token) { + // A peer is seeding (or already did). Release our guard so a later join can retry if the seed never + // arrives (e.g. the seeder died); the stream / this doc being seeded makes a retry safe. + room.serverSeedStarted = false + return + } + // We hold the seed lock — release it on EVERY exit from here (one `finally`, impossible to leak). + try { + const seed = await fetchFileDocSeed(workspaceId, room.fileId) + if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return + // Build the seed (file content + seed flag, or just the flag for an empty/missing file) and write it + // to the shared stream ATOMICALLY, iff the stream is still empty. This — NOT the seed lock — is the + // split-brain guard: two tasks racing (even both past an expired lock) can never both seed, because + // the emptiness check and the append are one Redis-side step. Publish-before-apply: the doc is marked + // seeded (via the local apply) only once the seed is durably in the stream, so a failed write leaves + // the doc unseeded and the stream empty for a clean retry. SEED_ORIGIN keeps `doc.on('update')` from + // re-publishing it. + const seedUpdate = seed?.update ?? emptySeedUpdate() + const didSeed = await store.seedIfEmpty(name, seedUpdate) + // Record the durable version the moment THIS task's seed is in the stream — BEFORE the liveness/ + // seeded guard below. Recording it only now that our seed WON (not from the fetch, before knowing who + // won) keeps it in step with the stream's actual content: a newer own-fetch version could otherwise + // shadow a peer's winning seed and let a later persist clobber an out-of-band edit. But it must not + // sit AFTER the guard: the tailer can integrate our just-appended seed during the await above, so + // `isDocSeeded` may already be true here — an early return would then leave the stream holding seed + // content with NO cluster If-Match token, and later persists would defer and strand session edits. + // Cluster-wide (Redis) so any task's persist reads it; the live room is the single-pod fallback / the + // read-through-cache seed. (No version for an empty/missing file — nothing durable to guard.) + if (didSeed && seed) { + const live = fileDocRooms.get(name) + if (live) live.syncedVersion = Math.max(live.syncedVersion ?? 0, seed.version) + void store.setSyncedVersion(name, seed.version) + } + if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return + if (didSeed) { + Y.applyUpdate(room.doc, seedUpdate, SEED_ORIGIN) + } else { + // A peer seeded first: its seed arrives via the tailer, so we must NOT apply our own — a second, + // different-client-id seed IS the split-brain. Clear the guard so a later join can retry if that + // peer seed somehow never lands (e.g. a fail-closed `xLen` error made `shouldSeed` skip a genuinely + // empty stream); a real peer-seed makes the retry a no-op. + room.serverSeedStarted = false + } + } catch (error) { + logger.warn(`Server seed failed for file ${room.fileId} (workspace ${workspaceId})`, error) + room.serverSeedStarted = false + } finally { + await store.releaseSeedLock(name, token) + } +} + +/** The seed update for an empty/missing file: just the `initialContentLoaded` flag, so an empty doc + * still reaches readiness (and its emptiness is durably shared like any seed). */ +function emptySeedUpdate(): Uint8Array { + const doc = new Y.Doc() + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + try { + return Y.encodeStateAsUpdate(doc) + } finally { + doc.destroy() + } +} + +/** Serializes live merges per file so overlapping calls never race the same doc (see below). */ +const fileDocMergeChains = new Map>() + +/** + * How a merge is positioned on the file's version line — mirrors the sim-side `LiveFileDocMergeOrder` + * wire field. A durable `version` is checked (applied only if newer than the doc's current version) AND + * recorded as the synced version. + */ +interface MergeOrder { + version?: number +} + +/** + * Apply new markdown into a file's LIVE collaborative document (Stage C — copilot writing into an open + * doc). Ships the document's current state to the app to build a minimal Yjs diff, applies it — which + * fires `doc.on('update')` and relays the merge to every connected editor, reconciled with any + * concurrent user edits — and reports whether it landed. + * + * Merges for the same file are SERIALIZED — within a task by the {@link fileDocMergeChains} promise + * chain, and ACROSS tasks by a Redis merge lock (below) — so each diff is computed against the previous + * merge's result, never the same stale base concurrently. + * + * Multi-task: the diff is computed against, and published to, the file's SHARED stream state — so the + * merge reaches the live doc no matter which task holds it (the apply-edit HTTP call can land on any + * task). Every task's tailer then applies it and fans it out to its own clients. Because the merge + * always lands in the stream while the stream exists, the stream can never go stale relative to a + * copilot direct file write. + * + * Returns `'no-live-room'` when there is no shared state to merge against (no doc is or was recently + * live): the caller (copilot) writes the file directly and the next open seeds from that markdown. + * Returns `'merge-unavailable'` when the cross-task merge lock could not be acquired (transient + * contention, not an absent stream) — the caller likewise falls back to a direct write, but a persist + * reconcile treats it distinctly (retry later) rather than as "nothing to reconcile into". + */ +export function applyMarkdownToLiveFileDoc( + fileId: string, + markdown: string, + order: MergeOrder = {} +): Promise<'applied' | 'no-live-room' | 'merge-unavailable' | 'stale'> { + const name = roomName(fileDocRoom(fileId)) + const prior = fileDocMergeChains.get(name) ?? Promise.resolve() + // `.catch` so a failed prior merge doesn't reject this one — each merge is independent. + const run = prior.catch(() => {}).then(() => mergeMarkdownIntoRoom(name, fileId, markdown, order)) + fileDocMergeChains.set( + name, + run.finally(() => { + if (fileDocMergeChains.get(name) === run) fileDocMergeChains.delete(name) + }) + ) + return run +} + +async function mergeMarkdownIntoRoom( + name: string, + fileId: string, + markdown: string, + { version }: MergeOrder +): Promise<'applied' | 'no-live-room' | 'merge-unavailable' | 'stale'> { + const store = getFileDocStore() + + // The durable version this merge carries is now incorporated in the live doc — record it (cluster-wide + // in Redis for multi-task, plus this task's room) so the persist If-Match guard treats this write as + // synced rather than an out-of-band conflict. AWAITED so the version is durable before the merge lock + // releases, so the next lock holder's staleness check (below) reads a consistent value. + const recordVersion = async () => { + if (version === undefined) return + const room = fileDocRooms.get(name) + // Never regress the token: merges/seeds/persists all write it, so a lower value arriving out of + // order must not shadow a higher one the doc already incorporates (the Redis side is guarded + // identically by SET_VERSION_IF_NEWER_SCRIPT). + if (room) room.syncedVersion = Math.max(room.syncedVersion ?? 0, version) + await store.setSyncedVersion(name, version) + } + + // Order this merge on the file's version line, where `current` is the durable version the doc already + // incorporates. `version` is a DB-monotonic `contentUpdatedAt` value (no wall-clock), so ordering is + // immune to clock skew: a durable `version` is stale if it is NOT strictly newer than `current` — a + // newer durable write already landed (possibly on another process, out of dispatch order); applying its + // older markdown would regress the doc while the monotonic token stays high. A merge with no `version` + // is never stale (legacy, unordered). + const isStale = (current: number): boolean => { + if (version !== undefined) return version <= current + return false + } + + if (store.enabled) { + // Serialize merges to this file ACROSS tasks — the per-file chain above only covers this process. + // Two copilot edits to the same file landing on different tasks must not diff the SAME shared base + // and publish conflicting full-document rewrites. Retry LONGER than the lock TTL, so a live holder + // always releases (or its lock expires) first and we acquire — never merging against a shared base + // while a peer holds the lock. If somehow still unavailable, skip the live merge (copilot's durable + // file write stands) rather than race. + let token = await store.acquireMergeSlot(name, MERGE_LOCK_TTL_MS) + for (let i = 0; !token && i < MERGE_LOCK_RETRIES; i++) { + await sleep(MERGE_LOCK_RETRY_MS) + token = await store.acquireMergeSlot(name, MERGE_LOCK_TTL_MS) + } + if (!token) { + logger.warn(`Merge lock unavailable for file ${fileId}; skipping live merge`) + return 'merge-unavailable' + } + try { + // Staleness is checked under the lock against the cluster-wide synced version, so a durable merge + // that lost the race to a newer one (on any process) is dropped rather than regressing the doc. + const shared = await store.getSyncedVersion(name) + const current = Math.max(shared ?? 0, fileDocRooms.get(name)?.syncedVersion ?? 0) + if (isStale(current)) return 'stale' + // Defer to an actively-streaming client: it is applying this SAME agent edit into the shared doc + // frame-by-frame, so also publishing a whole-document merge here would double-write the content (the + // client's private shadow never observes this merge, so it re-inserts what we added → duplication). + // Still record the durable version so the persist If-Match stays correct; the client owns the bytes, + // and once streaming stops the flag clears and the final durable merge lands as a near-noop. + if (await store.isAgentStreaming(name)) { + await recordVersion() + return 'applied' + } + // Compute the diff against the committed SHARED state and PUBLISH it — every task with the doc + // live (including this one, via its own tailer) applies it and fans it out to its clients, so the + // merge reaches the live doc no matter which task the apply-edit call landed on. An empty stream + // means no doc is (or was recently) live → nothing to merge into. AWAIT the publish so the diff is + // durably in the stream before we release the lock (else the next task would diff a stale base). + const base = await store.getStreamState(name) + if (!base) return 'no-live-room' + const diff = await fetchFileDocMerge(fileId, base, markdown) + await store.publishAndWait(name, diff) + await recordVersion() + return 'applied' + } finally { + await store.releaseMergeSlot(name, token) + } + } + + // Single-replica fallback: apply straight to the local authoritative doc. + const room = fileDocRooms.get(name) + if (!room || room.owners.size === 0 || !isDocSeeded(room.doc)) return 'no-live-room' + if (isStale(room.syncedVersion ?? 0)) return 'stale' + // Defer to an actively-streaming client (see the multi-replica branch above) — it applies this agent + // edit itself, so merging it here too would double-write. Record the version; skip the content merge. + if (room.agentStreamingUntil > Date.now()) { + await recordVersion() + return 'applied' + } + const update = await fetchFileDocMerge(fileId, Y.encodeStateAsUpdate(room.doc), markdown) + // The room may have been dropped while the diff was being built; never touch a destroyed doc. + if (fileDocRooms.get(name) !== room) return 'no-live-room' + // No transaction origin → `doc.on('update')` relays to the WHOLE room (every editor sees copilot). + Y.applyUpdate(room.doc, update) + await recordVersion() + return 'applied' +} + +/** + * Get (or lazily create) the authoritative document for a room, wiring the two + * relay handlers exactly once: document updates and awareness changes are + * broadcast to the room, excluding the origin socket (it already applied them). + */ +function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { + const name = roomName(ref) + const existing = fileDocRooms.get(name) + if (existing) return existing + + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + // The server holds no cursor of its own; it only relays clients' awareness. + awareness.setLocalState(null) + + const room: FileDocRoom = { + fileId: ref.id, + doc, + awareness, + owners: new Map(), + serverSeedStarted: false, + workspaceId: null, + lastEditorUserId: null, + edited: false, + seededObserved: false, + persistTimer: null, + persistDeadline: null, + syncedVersion: null, + agentStreamingUntil: 0, + } + // Register synchronously BEFORE the async catch-up so a concurrent join sees this room, not a second. + fileDocRooms.set(name, room) + + doc.on('update', (update: Uint8Array, origin: unknown) => { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeUpdate(encoder, update) + // Fan out to THIS task's clients only (excluding the origin socket if local — a user edit OR an + // agent-streamed frame). Cross-task delivery rides the shared stream — every task's tailer applies + + // runs its own local fan-out. + // A client edit excludes its own sender socket (echo suppression). An agent frame broadcasts to the + // WHOLE room — no socket excluded — so a same-socket sibling provider (chat preview + Files editor) + // stays live mid-stream; the emitting provider no-ops on its own echo. + broadcastLocal( + io, + name, + encoding.toUint8Array(encoder), + origin === AGENT_SYNC_ORIGIN ? null : originSocketId(origin) + ) + // Share every locally-originated update to the stream so peers converge. Skip updates that already + // came FROM the stream (REDIS_ORIGIN / REDIS_SNAPSHOT_ORIGIN / REDIS_AGENT_ORIGIN) and SEED_ORIGIN — + // the seed is published EXPLICITLY and AWAITED under the seed lock (so it lands before the lock + // releases), which a fire-and-forget publish here couldn't guarantee. An agent-streamed frame + // (AGENT_SYNC_ORIGIN) is published WITH the agent marker so peer tasks tail it as REDIS_AGENT_ORIGIN + // and never mark the doc edited on it (see the edit-tracker below). + if ( + origin !== REDIS_ORIGIN && + origin !== REDIS_SNAPSHOT_ORIGIN && + origin !== REDIS_AGENT_ORIGIN && + origin !== SEED_ORIGIN + ) + getFileDocStore().publish(name, update, origin === AGENT_SYNC_ORIGIN) + // A locally-originated agent frame (this task's stream leader) means a client is applying this agent + // edit itself. Refresh the "actively streaming" markers so a durable `applyMarkdownToLiveFileDoc` + // merge defers to that client instead of double-writing the same content (the two-writer + // duplication). Both are TTL'd, so they self-clear once frames stop and the final durable merge then + // lands as a near-noop. Only AGENT_SYNC_ORIGIN is handled: the in-memory marker is read solely by the + // single-replica merge branch (where every agent frame is AGENT_SYNC_ORIGIN), and the cluster flag is + // read cluster-wide, so a peer task tailing REDIS_AGENT_ORIGIN never needs to set either. + if (origin === AGENT_SYNC_ORIGIN) { + room.agentStreamingUntil = Date.now() + AGENT_STREAM_FLAG_TTL_MS + void getFileDocStore().markAgentStreaming(name, AGENT_STREAM_FLAG_TTL_MS) + } + // Edit tracking for persistence. Mark the doc dirty on any update applied AFTER it was seeded — a + // local user edit (socket origin) OR a peer's edit relayed via the tailer (REDIS_ORIGIN) — so + // whichever task is last to leave persists real edits, even one that only tailed them. A compaction + // snapshot on catch-up (REDIS_SNAPSHOT_ORIGIN) also counts: it folds real edits into one frame, so a + // fresh task catching up purely from it must not treat the doc as unedited. The seed transition + // itself is never counted, so a seeded-but-unedited doc is never projected back over the file. An + // agent-streamed frame ({@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}) is never counted anywhere: on + // the ORIGINATING task it applies under {@link AGENT_SYNC_ORIGIN}, and across replicas it is published + // WITH the agent marker so PEER tasks tail it as REDIS_AGENT_ORIGIN — neither is in the edited set + // below. So a transient startup-race duplicate between two stream leaders is never eligible for + // persistence; the copilot's durable `edit_content` write remains the sole authority over file bytes. + const seededBefore = room.seededObserved + if (isDocSeeded(room.doc)) room.seededObserved = true + if ( + originSocketId(origin) || + origin === REDIS_SNAPSHOT_ORIGIN || + (seededBefore && origin === REDIS_ORIGIN) + ) + room.edited = true + // Debounce a persist for LOCAL user edits only (peers debounce their own). + if (originSocketId(origin)) schedulePersist(name, room) + }) + + awareness.on('update', ({ added, updated, removed }: AwarenessChange, origin: unknown) => { + const changed = added.concat(updated, removed) + if (changed.length === 0) return + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(awareness, changed) + ) + broadcast(io, name, encoding.toUint8Array(encoder), originSocketId(origin)) + }) + + // Load the shared state into the doc and start tailing the stream (fire-and-forget: content streams + // in via `doc.on('update')` as it lands, mirroring the fire-and-forget seed below). Disabled → no-op. + void getFileDocStore().attachRoom(name, doc) + + return room +} + +function emitJoinError( + socket: AuthenticatedSocket, + fileId: unknown, + error: string, + code: string, + retryable: boolean +) { + socket.emit(FILE_DOC_EVENTS.JOIN_ERROR, { + fileId: typeof fileId === 'string' ? fileId : '', + error, + code, + retryable, + }) +} + +function handleMessage(socket: AuthenticatedSocket, data: unknown) { + const name = socketToRoomName.get(socket.id) + if (!name) return + const room = fileDocRooms.get(name) + if (!room) return + + const bytes = toFileDocBytes(data) + if (!bytes) return + + // A malformed frame from any client must never escape as a process-level + // exception; drop it and keep the relay running. + try { + const decoder = decoding.createDecoder(bytes) + const messageType = decoding.readVarUint(decoder) + + switch (messageType) { + case FILE_DOC_MESSAGE_TYPE.SYNC: { + // Attribute a server-side persist of the resulting edit to the actual editor (blob metadata). A + // socket's providers are all the same user, so any owner's userId identifies the editor. + const editor = room.owners.get(socket.id)?.values().next().value?.userId + if (editor) room.lastEditorUserId = editor + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + // `socket.id` is the transaction origin, so the doc's `update` handler + // excludes this sender when relaying the applied update to the room. + syncProtocol.readSyncMessage(decoder, encoder, room.doc, socket.id) + // A reply longer than the 1-byte type tag is a sync step 2 (or step 1) + // destined for the sender only; applied updates fan out via `doc.on`. + if (encoding.length(encoder) > 1) { + socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + break + } + case FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST: { + // An agent-streamed frame: apply + fan out to the room (so a collaborator sees the stream live) but + // do NOT treat it as a durable user edit. Unlike SYNC, we do NOT set `lastEditorUserId`, and the + // apply uses {@link AGENT_SYNC_ORIGIN} (a non-string sentinel) so `originSocketId` is `null` in + // `doc.on('update')` — skipping `edited`/`schedulePersist`, and broadcasting to the WHOLE room + // (including the sender socket, so a same-socket sibling provider stays live). The copilot's final + // `edit_content` write remains the authoritative durable persist. + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.readSyncMessage(decoder, encoder, room.doc, AGENT_SYNC_ORIGIN) + if (encoding.length(encoder) > 1) { + socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + break + } + case FILE_DOC_MESSAGE_TYPE.AWARENESS: { + const update = decoding.readVarUint8Array(decoder) + // Enforce presence ownership: a socket may only publish/remove awareness for a clientID it bound + // at join, so a peer cannot spoof or clear another collaborator's caret. A socket can own SEVERAL + // clientIDs (one per mounted provider), so the frame is accepted only if EVERY id it carries is + // owned by this socket. + const owned = room.owners.get(socket.id) + if (owned === undefined || awarenessUpdateClientIds(update).some((id) => !owned.has(id))) { + logger.warn('Dropping awareness frame for an unowned client id', { socketId: socket.id }) + return + } + awarenessProtocol.applyAwarenessUpdate(room.awareness, update, socket.id) + break + } + default: + logger.warn('Unknown file-doc message type', { messageType }) + } + } catch (error) { + logger.warn('Dropping malformed file-doc frame', { socketId: socket.id, error }) + } +} + +/** + * Remove a socket from its file-doc room: clear its awareness state (so its caret + * disappears for everyone else) and drop the room's document when the last + * collaborator leaves. + * Exported for the disconnect handler; safe to call for a socket in no room. + */ +export function cleanupFileDocForSocket(socketId: string, io: Server, endOfLife = false): void { + // The join-generation counter is monotonic for the socket's WHOLE life and must survive a room + // switch/leave: resetting it here would let the next join reuse a low number that a still + // in-flight earlier join also holds, so that stale join passes the generation guard and rebinds + // the socket to the wrong document. Drop it ONLY when the socket is truly gone (disconnect), + // which is also the only place the map would otherwise leak. An in-flight join is already + // aborted on disconnect by the `socket.disconnected` check, and on a switch by a newer join + // bumping the generation — neither needs this delete. + if (endOfLife) joinGeneration.delete(socketId) + + const name = socketToRoomName.get(socketId) + if (!name) return + socketToRoomName.delete(socketId) + + const room = fileDocRooms.get(name) + if (!room) return + + // The socket may own several clientIDs (one per provider it mounted for this file); drop them ALL. + // The client only emits LEAVE / disconnects once its LAST provider for the file tears down, so a + // per-socket cleanup here is correct — an earlier single-provider unmount already cleared its own + // caret via its awareness removal. + const clientMap = room.owners.get(socketId) + room.owners.delete(socketId) + if (clientMap !== undefined && clientMap.size > 0) { + // Fires the awareness `update` handler with a non-socket origin → the removals + // are broadcast to every remaining client, so the departed carets vanish. + awarenessProtocol.removeAwarenessStates(room.awareness, [...clientMap.keys()], null) + // Refresh the roster for whoever remains (server-authenticated identity). + broadcastFileDocPresence(io, name, room) + } + + destroyRoomIfIdle(name) +} + +/** + * Registers the collaborative file-document handlers on a socket. Room id is the + * file id; joining requires workspace `write` (editing a document). Mirrors the + * workspace-files join shape (auth → readiness → validate → authorize → join), + * then runs the Yjs sync/awareness handshake. + * + * The avatar roster is derived from this room's own `owners` map and broadcast as + * `FILE_DOC_EVENTS.PRESENCE` — NOT the Redis-backed room-manager presence the workflow / + * table rooms use — because the file-doc room already owns an authoritative in-memory Y.Doc + * pinned to a single replica, so the session identity is right here with no extra store. + */ +export function setupWorkspaceFileDocHandlers( + socket: AuthenticatedSocket, + roomManager: IRoomManager +) { + const io = roomManager.io + // The file this socket currently intends to edit (set when a join starts). A leave targeting it + // — or an unscoped leave — advances the join generation to cancel an in-flight join, so a join + // awaiting authorization can't complete after the client left and register a ghost owner. A + // leave for a DIFFERENT file must NOT cancel it (a document switch), mirroring workspace-files. + let currentFileId: string | null = null + + socket.on(FILE_DOC_EVENTS.JOIN, async ({ fileId, clientId }: JoinFileDocPayload) => { + // Hoisted so the catch can tell whether this join was superseded (a switch to another file) + // before surfacing a retryable error for the abandoned one. + let generation: number | undefined + try { + const userId = socket.userId + const userName = socket.userName + + if (!userId || !userName) { + emitJoinError(socket, fileId, 'Authentication required', 'AUTHENTICATION_REQUIRED', false) + return + } + if (!roomManager.isReady()) { + emitJoinError(socket, fileId, 'Realtime unavailable', 'ROOM_MANAGER_UNAVAILABLE', true) + return + } + if ( + typeof fileId !== 'string' || + fileId.length === 0 || + // A Yjs clientID is a uint32; reject NaN/Infinity/negative/non-integer so a malformed id + // can't become a bogus ownership key. + !Number.isInteger(clientId) || + clientId < 0 + ) { + emitJoinError(socket, fileId, 'Invalid join payload', 'INVALID_PAYLOAD', false) + return + } + + // Claim this JOIN's generation before the async authorize below, and record the file the + // socket now intends to edit so a leave for it can cancel this join if it's still in-flight. + generation = (joinGeneration.get(socket.id) ?? 0) + 1 + joinGeneration.set(socket.id, generation) + currentFileId = fileId + + const room = fileDocRoom(fileId) + const name = roomName(room) + + const authorized = await resolveRoomJoinAuth({ + userId, + room, + action: 'write', + logger, + logLabel: `file-doc room for ${userId}`, + messages: { + verifyFailed: 'Failed to verify workspace access', + notFound: 'File not found', + accessDenied: 'Access denied to file', + }, + emitError: ({ error, code, retryable }) => + emitJoinError(socket, fileId, error, code, retryable), + }) + if (!authorized) return + + // Server-authenticated identity for the presence roster (never trusts the client-set + // awareness). Resolved here so the generation guard below also covers this await. + const avatarUrl = await resolveAvatarUrl(socket, userId) + + // Abort a JOIN superseded during authorization/identity resolution: the socket + // disconnected, or a newer JOIN (a document switch) bumped the generation. Registering + // here would leak a dead socket's room or bind the socket to the wrong document. + if (socket.disconnected || joinGeneration.get(socket.id) !== generation) return + + const entry = getOrCreateRoom(io, room) + + // A client id must be owned by at most one user, or a peer could bind an active + // collaborator's id and pass the per-frame ownership check to spoof/clear its caret. + // Distinguish a reconnect from a spoof by the owning user: the same user reclaiming its + // own client id (a dropped socket reconnecting reuses the Yjs client id, and its prior + // socket may not be cleaned up yet) takes over the stale binding; a DIFFERENT user is + // rejected. This runs BEFORE any teardown of the socket's current binding below, so a + // rejected rebind — even during a document switch — leaves the socket's existing document + // and caret untouched. + for (const [otherSid, clientMap] of entry.owners) { + if (otherSid === socket.id) continue + const owner = clientMap.get(clientId) + if (owner === undefined) continue + if (owner.userId !== userId) { + emitJoinError(socket, fileId, 'Client id already in use', 'CLIENT_ID_IN_USE', false) + return + } + // Same user reclaiming its client id on a stale prior socket: evict just THAT clientID's binding + // + caret from the old socket. If that leaves the old socket with no providers, also drop its + // room mapping + Socket.IO membership so it can no longer send document (sync) frames + // (handleMessage's SYNC path gates on socketToRoomName, not owners); an old socket that still + // hosts OTHER providers keeps them. Done inline rather than via cleanupFileDocForSocket, which + // could destroyRoomIfIdle the room we're joining. + clientMap.delete(clientId) + awarenessProtocol.removeAwarenessStates(entry.awareness, [clientId], null) + if (clientMap.size === 0) { + entry.owners.delete(otherSid) + socketToRoomName.delete(otherSid) + io.in(otherSid).socketsLeave(name) + } + } + + // Only now that the rebind is guaranteed to succeed, leave a previously-joined document if + // switching (a socket edits at most one). A duplicate join of the SAME room falls through + // and simply re-runs the sync handshake, idempotently. + const currentName = socketToRoomName.get(socket.id) + if (currentName && currentName !== name) { + socket.leave(currentName) + cleanupFileDocForSocket(socket.id, io) + } + + // ADD this provider's clientID to the socket's ownership set (do NOT overwrite a sibling provider + // on the same socket — that lone-owner overwrite is exactly what dropped the chat preview's + // awareness when the Files editor co-mounted). A re-JOIN of the same clientID is idempotent. A + // single provider that later unmounts clears its own caret via its awareness removal; the whole + // set is dropped on the socket's LEAVE/disconnect (client emits LEAVE only after its LAST provider + // for the file tears down). + let clientMap = entry.owners.get(socket.id) + if (clientMap === undefined) { + clientMap = new Map() + entry.owners.set(socket.id, clientMap) + } + clientMap.set(clientId, { clientId, userId, userName, avatarUrl }) + socketToRoomName.set(socket.id, name) + socket.join(name) + + // Capture what the server-side persist needs: the workspace to write back to, and the current + // user for attribution (refreshed to the actual editor on each edit in `handleMessage`). + if (authorized.workspaceId) entry.workspaceId = authorized.workspaceId + entry.lastEditorUserId = userId + + socket.emit(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId }) + // Server-authenticated roster → everyone in the room, including this joiner. + broadcastFileDocPresence(io, name, entry) + + // Begin the sync handshake: send the server's state (sync step 1). The + // client replies with its updates and requests the server's in return. + const syncEncoder = encoding.createEncoder() + encoding.writeVarUint(syncEncoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep1(syncEncoder, entry.doc) + socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(syncEncoder)) + + // Send existing awareness so the new client immediately sees others' carets. + const states = entry.awareness.getStates() + if (states.size > 0) { + const awarenessEncoder = encoding.createEncoder() + encoding.writeVarUint(awarenessEncoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array( + awarenessEncoder, + awarenessProtocol.encodeAwarenessUpdate(entry.awareness, Array.from(states.keys())) + ) + socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(awarenessEncoder)) + } + + // Seed the document server-side (once). Fire-and-forget: the join completes immediately and + // the seed relays to this socket via `doc.on('update')` the moment it lands. + if (authorized.workspaceId) void ensureServerSeed(name, entry, authorized.workspaceId) + + logger.info(`User ${userId} joined file-doc room ${fileId}`) + } catch (error) { + logger.error('Error joining file-doc room:', error) + try { + const name = roomName(fileDocRoom(fileId)) + socket.leave(name) + // Roll back ONLY this join's target room. cleanupFileDocForSocket keys off socketToRoomName, + // which — if the join failed before rebinding to the target (e.g. a switch that threw during + // client-id reclaim) — still points at the socket's PRIOR, valid document. Running it then + // would tear down a document the socket is validly in. So only run it when the binding + // already points at the target; otherwise the socket never registered as an owner of this + // room and the only leftover is a freshly-created empty room, dropped below. + if (socketToRoomName.get(socket.id) === name) cleanupFileDocForSocket(socket.id, io) + destroyRoomIfIdle(name) + } catch {} + // Suppress the client-facing error when this join was already superseded (a switch to another + // file, or a disconnect): the rollback above still ran, but a retryable error naming the + // abandoned file could make a client re-join it and cancel the newer one (matches the sibling + // handlers). + if ( + socket.disconnected || + (generation !== undefined && joinGeneration.get(socket.id) !== generation) + ) + return + emitJoinError(socket, fileId, 'Failed to join file document', 'JOIN_FAILED', true) + } + }) + + socket.on(FILE_DOC_EVENTS.MESSAGE, (data: unknown) => handleMessage(socket, data)) + + socket.on(FILE_DOC_EVENTS.LEAVE, (payload?: LeaveFileDocPayload) => { + try { + // Cancel an in-flight join whose file the client is now leaving (or an unscoped leave): a + // join still awaiting authorization would otherwise complete after the client left, register + // as an owner, and broadcast a ghost collaborator until disconnect. Guard on the current + // file intent so a stale/deferred leave for a DIFFERENT file can't abort the join the client + // has since switched to (bumping the generation blindly caused that regression in #5941). + if (!payload?.fileId || payload.fileId === currentFileId) { + joinGeneration.set(socket.id, (joinGeneration.get(socket.id) ?? 0) + 1) + currentFileId = null + } + // Tear down membership only for a REGISTERED room; a leave that raced ahead of an in-flight + // join (nothing registered yet) has already cancelled it above. + const name = socketToRoomName.get(socket.id) + if (!name) return + // Scope the leave to the named file when provided: a deferred leave from a + // prior document must not evict the socket from one it has since opened. + if (payload?.fileId && roomName(fileDocRoom(payload.fileId)) !== name) return + socket.leave(name) + cleanupFileDocForSocket(socket.id, io) + } catch (error) { + logger.error('Error leaving file-doc room:', error) + } + }) +} diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index 6ded2e54741..8dd71093673 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -1,9 +1,13 @@ +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { setupConnectionHandlers } from '@/handlers/connection' +import { setupWorkspaceFileDocHandlers } from '@/handlers/file-doc' import { setupOperationsHandlers } from '@/handlers/operations' import { setupPresenceHandlers } from '@/handlers/presence' import { setupSubblocksHandlers } from '@/handlers/subblocks' +import { setupTablesHandlers } from '@/handlers/tables' import { setupVariablesHandlers } from '@/handlers/variables' import { setupWorkflowHandlers } from '@/handlers/workflow' +import { setupWorkspaceInvalidationRoom } from '@/handlers/workspace-invalidation-room' import type { AuthenticatedSocket } from '@/middleware/auth' import type { IRoomManager } from '@/rooms' @@ -13,5 +17,10 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupSubblocksHandlers(socket, roomManager) setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) + // Presence-free, workspace-scoped live-list rooms (share one implementation). + setupWorkspaceInvalidationRoom(socket, roomManager, ROOM_TYPES.WORKSPACE_FILES) + setupWorkspaceInvalidationRoom(socket, roomManager, ROOM_TYPES.WORKSPACE_TABLES) + setupWorkspaceFileDocHandlers(socket, roomManager) + setupTablesHandlers(socket, roomManager) setupConnectionHandlers(socket, roomManager) } diff --git a/apps/realtime/src/handlers/operations.ts b/apps/realtime/src/handlers/operations.ts index eef51847718..d3d603887db 100644 --- a/apps/realtime/src/handlers/operations.ts +++ b/apps/realtime/src/handlers/operations.ts @@ -9,6 +9,7 @@ import { type VariableOperation, WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -16,7 +17,7 @@ import { ZodError } from 'zod' import { persistWorkflowOperation } from '@/database/operations' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkWorkflowOperationPermission } from '@/middleware/permissions' -import type { IRoomManager, UserSession } from '@/rooms' +import { type IRoomManager, type UserSession, workflowRoom as wf } from '@/rooms' const logger = createLogger('OperationsHandlers') @@ -44,7 +45,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager let session: UserSession | null = null try { - workflowId = await roomManager.getWorkflowIdForSocket(socket.id) + workflowId = (await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW))?.id ?? null session = await roomManager.getUserSession(socket.id) } catch (error) { logger.error('Error loading session for workflow operation:', error) @@ -65,7 +66,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager let hasRoom = false try { - hasRoom = await roomManager.hasWorkflowRoom(workflowId) + hasRoom = await roomManager.hasRoom(wf(workflowId)) } catch (error) { logger.error('Error checking workflow room:', error) emitOperationError( @@ -98,14 +99,16 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager const operationTimestamp = isPositionUpdate ? timestamp : Date.now() // Get user presence for permission checking - const users = await roomManager.getWorkflowUsers(workflowId) + const users = await roomManager.getRoomUsers(wf(workflowId)) const userPresence = users.find((u) => u.socketId === socket.id) // Skip permission checks for non-committed position updates (broadcasts only, no persistence) if (isPositionUpdate && !commitPositionUpdate) { // Update last activity if (userPresence) { - await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() }) + await roomManager.updateUserActivity(wf(workflowId), socket.id, { + lastActivity: Date.now(), + }) } } else { // Check permissions from cached role for all other operations @@ -123,7 +126,9 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager return } - await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() }) + await roomManager.updateUserActivity(wf(workflowId), socket.id, { + lastActivity: Date.now(), + }) // Re-validate the workspace role against the DB (cached per pod for a short // window) so revoked or downgraded collaborators lose write access live. @@ -198,7 +203,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager timestamp: operationTimestamp, userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) if (operationId) { socket.emit('operation-confirmed', { @@ -244,7 +249,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager timestamp: operationTimestamp, userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) if (operationId) { socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) @@ -277,7 +282,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) const broadcastData = { operation, @@ -317,7 +322,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) const broadcastData = { operation, @@ -354,7 +359,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) socket.to(workflowId).emit('workflow-operation', { operation, @@ -386,7 +391,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) socket.to(workflowId).emit('workflow-operation', { operation, @@ -415,7 +420,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) socket.to(workflowId).emit('workflow-operation', { operation, @@ -447,7 +452,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) socket.to(workflowId).emit('workflow-operation', { operation, @@ -479,7 +484,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) socket.to(workflowId).emit('workflow-operation', { operation, @@ -511,7 +516,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) socket.to(workflowId).emit('workflow-operation', { operation, @@ -540,7 +545,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) socket.to(workflowId).emit('workflow-operation', { operation, @@ -569,7 +574,7 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager userId: session.userId, }) - await roomManager.updateRoomLastModified(workflowId) + await roomManager.updateRoomLastModified(wf(workflowId)) const broadcastData = { operation, diff --git a/apps/realtime/src/handlers/presence.ts b/apps/realtime/src/handlers/presence.ts index 13aadc22f34..78b53176e2f 100644 --- a/apps/realtime/src/handlers/presence.ts +++ b/apps/realtime/src/handlers/presence.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import type { AuthenticatedSocket } from '@/middleware/auth' import type { IRoomManager } from '@/rooms' @@ -7,16 +8,16 @@ const logger = createLogger('PresenceHandlers') export function setupPresenceHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { socket.on('cursor-update', async ({ cursor }) => { try { - const workflowId = await roomManager.getWorkflowIdForSocket(socket.id) + const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW) const session = await roomManager.getUserSession(socket.id) - if (!workflowId || !session) return + if (!room || !session) return // Update cursor in room state - await roomManager.updateUserActivity(workflowId, socket.id, { cursor }) + await roomManager.updateUserActivity(room, socket.id, { cursor }) - // Broadcast to other users in the room - socket.to(workflowId).emit('cursor-update', { + // Broadcast to other users in the room (workflow room name is the bare id) + socket.to(room.id).emit('cursor-update', { socketId: socket.id, userId: session.userId, userName: session.userName, @@ -30,16 +31,16 @@ export function setupPresenceHandlers(socket: AuthenticatedSocket, roomManager: socket.on('selection-update', async ({ selection }) => { try { - const workflowId = await roomManager.getWorkflowIdForSocket(socket.id) + const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW) const session = await roomManager.getUserSession(socket.id) - if (!workflowId || !session) return + if (!room || !session) return // Update selection in room state - await roomManager.updateUserActivity(workflowId, socket.id, { selection }) + await roomManager.updateUserActivity(room, socket.id, { selection }) - // Broadcast to other users in the room - socket.to(workflowId).emit('selection-update', { + // Broadcast to other users in the room (workflow room name is the bare id) + socket.to(room.id).emit('selection-update', { socketId: socket.id, userId: session.userId, userName: session.userName, diff --git a/apps/realtime/src/handlers/room-join-auth.ts b/apps/realtime/src/handlers/room-join-auth.ts new file mode 100644 index 00000000000..a7c2a9aa24c --- /dev/null +++ b/apps/realtime/src/handlers/room-join-auth.ts @@ -0,0 +1,55 @@ +import type { createLogger } from '@sim/logger' +import { authorizeRoom } from '@sim/platform-authz/rooms' +import type { RoomRef } from '@sim/realtime-protocol/rooms' + +type Authorized = Awaited> + +interface ResolveRoomJoinAuthParams { + userId: string + room: RoomRef + action: 'read' | 'write' + logger: ReturnType + /** Included in the warn log on an authorize throw, e.g. `table room for ${userId}`. */ + logLabel: string + messages: { verifyFailed: string; notFound: string; accessDenied: string } + /** Emits the handler's own JOIN_ERROR shape (event name + id key differ per handler). */ + emitError: (args: { error: string; code: string; retryable: boolean }) => void +} + +/** + * Runs the shared authorize→allowed slice of a room join: authorizes the room and checks + * the result, emitting the handler-specific JOIN_ERROR on failure. Returns the authorized + * result on success, or `null` when it has already emitted an error and the caller must return. + * + * Deliberately excludes the auth/readiness/id-validation preamble and the join-generation + * capture/recheck — those differ per handler, and for file-doc the generation capture sits + * mid-preamble. This helper is always invoked strictly between a handler's generation capture + * and its post-authorize recheck; it contains exactly the one `await authorizeRoom` that the + * recheck was designed to cover and returns before any state mutation, so it never straddles + * that seam. Pass each handler's own `logger` so the log namespace/request-id context is kept. + */ +export async function resolveRoomJoinAuth( + params: ResolveRoomJoinAuthParams +): Promise { + const { userId, room, action, logger, logLabel, messages, emitError } = params + + let authorized: Authorized + try { + authorized = await authorizeRoom({ userId, room, action }) + } catch (error) { + logger.warn(`Error authorizing ${logLabel}:`, error) + emitError({ error: messages.verifyFailed, code: 'VERIFY_ACCESS_FAILED', retryable: true }) + return null + } + + if (!authorized.allowed) { + emitError({ + error: authorized.status === 404 ? messages.notFound : messages.accessDenied, + code: authorized.status === 404 ? 'NOT_FOUND' : 'ACCESS_DENIED', + retryable: false, + }) + return null + } + + return authorized +} diff --git a/apps/realtime/src/handlers/subblocks.ts b/apps/realtime/src/handlers/subblocks.ts index b2f94b6fb98..93e7411d148 100644 --- a/apps/realtime/src/handlers/subblocks.ts +++ b/apps/realtime/src/handlers/subblocks.ts @@ -3,12 +3,13 @@ import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { getErrorMessage } from '@sim/utils/errors' import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkWorkflowOperationPermission } from '@/middleware/permissions' -import type { IRoomManager } from '@/rooms' +import { type IRoomManager, workflowRoom as wf } from '@/rooms' const logger = createLogger('SubblocksHandlers') @@ -69,7 +70,8 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: } try { - const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id) + const sessionWorkflowId = + (await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW))?.id ?? null const session = await roomManager.getUserSession(socket.id) if (!sessionWorkflowId || !session) { @@ -106,7 +108,7 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: return } - const hasRoom = await roomManager.hasWorkflowRoom(workflowId) + const hasRoom = await roomManager.hasRoom(wf(workflowId)) if (!hasRoom) { logger.debug(`Ignoring subblock update: workflow room not found`, { socketId: socket.id, @@ -117,7 +119,7 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: return } - const users = await roomManager.getWorkflowUsers(workflowId) + const users = await roomManager.getRoomUsers(wf(workflowId)) const userPresence = users.find((user) => user.socketId === socket.id) if (!userPresence) { socket.emit('operation-forbidden', { @@ -182,7 +184,7 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: } // Update user activity - await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() }) + await roomManager.updateUserActivity(wf(workflowId), socket.id, { lastActivity: Date.now() }) // Server-side debounce/coalesce by workflowId+blockId+subblockId const debouncedKey = `${workflowId}:${blockId}:${subblockId}` diff --git a/apps/realtime/src/handlers/tables.test.ts b/apps/realtime/src/handlers/tables.test.ts new file mode 100644 index 00000000000..518c5ee376a --- /dev/null +++ b/apps/realtime/src/handlers/tables.test.ts @@ -0,0 +1,351 @@ +/** + * @vitest-environment node + */ +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { TABLE_PRESENCE_EVENTS } from '@sim/realtime-protocol/table-presence' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { IRoomManager } from '@/rooms' + +const { mockAuthorizeRoom } = vi.hoisted(() => ({ + mockAuthorizeRoom: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: vi.fn() }, + user: { image: 'image' }, +})) + +vi.mock('@sim/platform-authz/rooms', () => ({ + authorizeRoom: mockAuthorizeRoom, +})) + +import { setupTablesHandlers } from '@/handlers/tables' + +const TABLE_ROOM = { type: ROOM_TYPES.TABLE, id: 'table-1' } + +function createSocket(overrides?: Record) { + const handlers: Record Promise | void> = {} + const toEmit = vi.fn() + const socket = { + id: 'socket-1', + userId: 'user-1', + userName: 'Test User', + userImage: 'avatar.png', + on: vi.fn((event: string, handler: (payload: unknown) => Promise | void) => { + handlers[event] = handler + }), + emit: vi.fn(), + join: vi.fn(), + leave: vi.fn(), + to: vi.fn().mockReturnValue({ emit: toEmit }), + ...overrides, + } + return { handlers, socket, toEmit } +} + +function createRoomManager(overrides?: Partial): IRoomManager { + return { + isReady: vi.fn().mockReturnValue(true), + getRoomForSocket: vi.fn().mockResolvedValue(null), + getRoomsForSocket: vi.fn().mockResolvedValue([]), + removeUserFromRoom: vi.fn().mockResolvedValue(false), + removeSocketFromAllRooms: vi.fn().mockResolvedValue([]), + broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined), + getRoomUsers: vi.fn().mockResolvedValue([]), + hasRoom: vi.fn().mockResolvedValue(false), + deleteRoom: vi.fn().mockResolvedValue(undefined), + addUserToRoom: vi.fn().mockResolvedValue(undefined), + getUserSession: vi.fn().mockResolvedValue(null), + updateUserActivity: vi.fn().mockResolvedValue(undefined), + updateRoomLastModified: vi.fn().mockResolvedValue(undefined), + emitToRoom: vi.fn(), + getUniqueUserCount: vi.fn().mockResolvedValue(1), + getTotalActiveConnections: vi.fn().mockResolvedValue(0), + shutdown: vi.fn().mockResolvedValue(undefined), + initialize: vi.fn().mockResolvedValue(undefined), + io: { + in: vi.fn().mockReturnValue({ socketsLeave: vi.fn().mockResolvedValue(undefined) }), + }, + ...overrides, + } as unknown as IRoomManager +} + +type SetupArg = Parameters[0] + +describe('setupTablesHandlers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthorizeRoom.mockResolvedValue({ + allowed: true, + status: 200, + workspaceId: 'ws-1', + workspacePermission: 'admin', + }) + }) + + it('rejects join when the socket is not authenticated', async () => { + const { socket, handlers } = createSocket({ userId: undefined, userName: undefined }) + setupTablesHandlers(socket as unknown as SetupArg, createRoomManager()) + + await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-1' }) + + expect(socket.emit).toHaveBeenCalledWith(TABLE_PRESENCE_EVENTS.JOIN_ERROR, { + tableId: 'table-1', + error: 'Authentication required', + code: 'AUTHENTICATION_REQUIRED', + retryable: false, + }) + }) + + it('rejects join with a retryable error when realtime is unavailable', async () => { + const { socket, handlers } = createSocket() + setupTablesHandlers( + socket as unknown as SetupArg, + createRoomManager({ isReady: vi.fn().mockReturnValue(false) }) + ) + + await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + TABLE_PRESENCE_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'ROOM_MANAGER_UNAVAILABLE', retryable: true }) + ) + }) + + it('rejects join when table access is denied', async () => { + mockAuthorizeRoom.mockResolvedValue({ + allowed: false, + status: 403, + workspaceId: 'ws-1', + workspacePermission: null, + }) + const { socket, handlers } = createSocket() + setupTablesHandlers(socket as unknown as SetupArg, createRoomManager()) + + await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + TABLE_PRESENCE_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false }) + ) + }) + + it('joins the table room and broadcasts presence on success', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-1', tabSessionId: 'tab-1' }) + + expect(socket.join).toHaveBeenCalledWith('table:table-1') + expect(roomManager.addUserToRoom).toHaveBeenCalledWith( + TABLE_ROOM, + 'socket-1', + expect.objectContaining({ userId: 'user-1', role: 'admin' }) + ) + expect(socket.emit).toHaveBeenCalledWith( + TABLE_PRESENCE_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ tableId: 'table-1', socketId: 'socket-1' }) + ) + expect(roomManager.broadcastPresenceUpdate).toHaveBeenCalledWith(TABLE_ROOM) + }) + + it('persists and relays a cell selection to the namespaced room (id + cell only)', async () => { + const { socket, handlers, toEmit } = createSocket() + const roomManager = createRoomManager({ + getRoomForSocket: vi.fn().mockResolvedValue(TABLE_ROOM), + }) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + const cell = { + anchor: { rowId: 'row-1', columnId: 'col-a' }, + focus: { rowId: 'row-1', columnId: 'col-a' }, + editing: true, + } + await handlers[TABLE_PRESENCE_EVENTS.CELL_SELECTION]({ cell }) + + expect(roomManager.updateUserActivity).toHaveBeenCalledWith(TABLE_ROOM, 'socket-1', { cell }) + // Namespaced room → broadcast targets roomName(room), not the bare id. + expect(socket.to).toHaveBeenCalledWith('table:table-1') + // The delta carries only the socket id + cell — identity comes from the roster. + expect(toEmit).toHaveBeenCalledWith(TABLE_PRESENCE_EVENTS.CELL_SELECTION, { + socketId: 'socket-1', + cell, + }) + }) + + it('drops a malformed cell selection without storing or relaying it', async () => { + const { socket, handlers, toEmit } = createSocket() + const roomManager = createRoomManager({ + getRoomForSocket: vi.fn().mockResolvedValue(TABLE_ROOM), + }) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + await handlers[TABLE_PRESENCE_EVENTS.CELL_SELECTION]({ cell: { anchor: 'x"]' } }) + + expect(roomManager.updateUserActivity).not.toHaveBeenCalled() + expect(toEmit).not.toHaveBeenCalled() + }) + + it('strips unknown/oversized fields from an otherwise-valid selection before storing or relaying', async () => { + const { socket, handlers, toEmit } = createSocket() + const roomManager = createRoomManager({ + getRoomForSocket: vi.fn().mockResolvedValue(TABLE_ROOM), + }) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + await handlers[TABLE_PRESENCE_EVENTS.CELL_SELECTION]({ + cell: { + anchor: { rowId: 'row-1', columnId: 'col-a', junk: 'x'.repeat(10_000) }, + focus: { rowId: 'row-1', columnId: 'col-a' }, + editing: true, + bloat: 'x'.repeat(100_000), + }, + }) + + // Only the whitelisted fields survive — a hostile peer can't amplify an oversized object. + const expected = { + anchor: { rowId: 'row-1', columnId: 'col-a' }, + focus: { rowId: 'row-1', columnId: 'col-a' }, + editing: true, + } + expect(roomManager.updateUserActivity).toHaveBeenCalledWith(TABLE_ROOM, 'socket-1', { + cell: expected, + }) + expect(toEmit).toHaveBeenCalledWith(TABLE_PRESENCE_EVENTS.CELL_SELECTION, { + socketId: 'socket-1', + cell: expected, + }) + }) + + it('skips a superseded queued join on a fast table switch', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + mockAuthorizeRoom.mockResolvedValue({ + allowed: true, + status: 200, + workspaceId: 'ws-1', + workspacePermission: 'admin', + }) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + // Two joins enqueued back-to-back (A then B). B bumps the generation synchronously, so A's + // queued run no-ops at its start check — only B commits. Because JOINs are serialized on one + // op chain, A's and B's Redis writes can never interleave (no map-clobber, no stranding). + handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-A' }) + await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-B' }) + + expect(socket.join).toHaveBeenCalledWith('table:table-B') + expect(socket.join).not.toHaveBeenCalledWith('table:table-A') + expect(roomManager.addUserToRoom).toHaveBeenCalledWith( + { type: ROOM_TYPES.TABLE, id: 'table-B' }, + 'socket-1', + expect.anything() + ) + expect(roomManager.addUserToRoom).not.toHaveBeenCalledWith( + { type: ROOM_TYPES.TABLE, id: 'table-A' }, + expect.anything(), + expect.anything() + ) + }) + + it('aborts an in-flight join when a leave for that table arrives during authorize', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + let releaseAuth: (value: unknown) => void = () => {} + const pending = new Promise((resolve) => { + releaseAuth = resolve + }) + mockAuthorizeRoom.mockReturnValueOnce(pending) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + const joinPromise = handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-1' }) + // Client navigates away while the join is still awaiting authorization. + await handlers[TABLE_PRESENCE_EVENTS.LEAVE]({ tableId: 'table-1' }) + releaseAuth({ allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'admin' }) + await joinPromise + + // The cancelled join must touch no room state — the socket is not stranded. + expect(socket.join).not.toHaveBeenCalled() + expect(roomManager.addUserToRoom).not.toHaveBeenCalled() + expect(roomManager.broadcastPresenceUpdate).not.toHaveBeenCalledWith(TABLE_ROOM) + }) + + it('aborts an in-flight join when an unscoped leave arrives during authorize', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + let releaseAuth: (value: unknown) => void = () => {} + const pending = new Promise((resolve) => { + releaseAuth = resolve + }) + mockAuthorizeRoom.mockReturnValueOnce(pending) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + const joinPromise = handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-1' }) + // A leave with no table id (view teardown) must also cancel the in-flight join. + await handlers[TABLE_PRESENCE_EVENTS.LEAVE](undefined) + releaseAuth({ allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'admin' }) + await joinPromise + + expect(socket.join).not.toHaveBeenCalled() + expect(roomManager.addUserToRoom).not.toHaveBeenCalled() + }) + + it('does not abort an in-flight join when a leave targets a different table', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + let releaseAuth: (value: unknown) => void = () => {} + const pending = new Promise((resolve) => { + releaseAuth = resolve + }) + mockAuthorizeRoom.mockReturnValueOnce(pending) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + const joinPromise = handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-B' }) + // A stale/deferred leave for a table the client already left must NOT cancel the B join. + await handlers[TABLE_PRESENCE_EVENTS.LEAVE]({ tableId: 'table-A' }) + releaseAuth({ allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'admin' }) + await joinPromise + + expect(socket.join).toHaveBeenCalledWith('table:table-B') + expect(roomManager.addUserToRoom).toHaveBeenCalledWith( + { type: ROOM_TYPES.TABLE, id: 'table-B' }, + 'socket-1', + expect.anything() + ) + }) + + it('leaves the table room on leave', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager({ + getRoomForSocket: vi.fn().mockResolvedValue(TABLE_ROOM), + }) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + await handlers[TABLE_PRESENCE_EVENTS.LEAVE]({ tableId: 'table-1' }) + + expect(socket.leave).toHaveBeenCalledWith('table:table-1') + expect(roomManager.removeUserFromRoom).toHaveBeenCalledWith(TABLE_ROOM, 'socket-1') + expect(roomManager.broadcastPresenceUpdate).toHaveBeenCalledWith(TABLE_ROOM, 'socket-1') + }) + + it('rolls back the Socket.IO membership when a join fails mid-commit', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager({ + // socket.join lands first, then the presence write throws — the socket is now in the + // Socket.IO room with no matching socket→room map entry, unreclaimable by any later op. + addUserToRoom: vi.fn().mockRejectedValue(new Error('redis down')), + }) + setupTablesHandlers(socket as unknown as SetupArg, roomManager) + + await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-1' }) + + // The catch must always roll back the partial membership, not skip it. + expect(socket.leave).toHaveBeenCalledWith('table:table-1') + expect(roomManager.removeUserFromRoom).toHaveBeenCalledWith(TABLE_ROOM, 'socket-1') + expect(socket.emit).toHaveBeenCalledWith( + TABLE_PRESENCE_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'JOIN_FAILED' }) + ) + }) +}) diff --git a/apps/realtime/src/handlers/tables.ts b/apps/realtime/src/handlers/tables.ts new file mode 100644 index 00000000000..2474063c9a0 --- /dev/null +++ b/apps/realtime/src/handlers/tables.ts @@ -0,0 +1,314 @@ +import { createLogger } from '@sim/logger' +import { ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' +import { + type JoinTablePayload, + TABLE_PRESENCE_EVENTS, + type TableCellRef, + type TableCellSelection, +} from '@sim/realtime-protocol/table-presence' +import { resolveAvatarUrl } from '@/handlers/avatar' +import { resolveRoomJoinAuth } from '@/handlers/room-join-auth' +import type { AuthenticatedSocket } from '@/middleware/auth' +import type { IRoomManager, UserPresence } from '@/rooms' +import { filterVisiblePresence, sweepStalePresence } from '@/rooms/presence-visibility' + +const logger = createLogger('TablePresenceHandlers') + +/** Longest accepted row/column id — real ids are UUIDs/short ids; this bounds a hostile payload. */ +const MAX_CELL_ID_LENGTH = 200 + +/** The table presence room ref for a table id. */ +const tableRoom = (tableId: string): RoomRef => ({ type: ROOM_TYPES.TABLE, id: tableId }) + +function isCellRef(value: unknown): value is TableCellRef { + if (typeof value !== 'object' || value === null) return false + const ref = value as { rowId?: unknown; columnId?: unknown } + return ( + typeof ref.rowId === 'string' && + ref.rowId.length <= MAX_CELL_ID_LENGTH && + typeof ref.columnId === 'string' && + ref.columnId.length <= MAX_CELL_ID_LENGTH + ) +} + +/** + * Validate + whitelist an untrusted peer's selection before it is stored and + * rebroadcast (it ultimately flows into a DOM query on every viewer). Returns the + * normalized selection — `null` for a legitimately cleared selection — or `undefined` + * for anything malformed, so the caller drops it. Only the known fields survive, so a + * hostile client can't amplify an oversized object through the room. + */ +function normalizeCellSelection(cell: unknown): TableCellSelection | undefined { + if (cell === null) return null + if (typeof cell !== 'object') return undefined + const candidate = cell as { anchor?: unknown; focus?: unknown; editing?: unknown } + if (!isCellRef(candidate.anchor) || !isCellRef(candidate.focus)) return undefined + return { + anchor: { rowId: candidate.anchor.rowId, columnId: candidate.anchor.columnId }, + focus: { rowId: candidate.focus.rowId, columnId: candidate.focus.columnId }, + ...(candidate.editing === true ? { editing: true } : {}), + } +} + +/** + * Live cell-selection presence for the table grid. Mirrors the workspace-files + * join flow but is table-scoped (room id = tableId) with a bidirectional + * cell-selection channel — the grid analog of the workflow cursor/selection + * relay. Table *data* still flows through the one-way durable event stream + * (`lib/table/events.ts`); this socket carries only ephemeral presence. + * + * Table rooms are namespaced (`table:${id}`), so every broadcast targets + * `roomName(room)`, never the bare `room.id` (which the workflow handler can use + * only because a workflow room's name equals its id). + */ +export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { + // Monotonic per-socket generation: each JOIN/LEAVE bumps it synchronously on arrival, and a + // queued or in-flight op that finds a newer generation aborts — a fast table switch A→B thus + // cancels A the instant B arrives. + let joinGeneration = 0 + // The table the socket currently intends to be in (set when a join is enqueued). A leave + // targeting it — or an unscoped leave — bumps the generation to cancel that join; a leave for a + // DIFFERENT table must NOT (a table switch), mirroring workspace-files. + let currentTableId: string | null = null + // Serialize this socket's room mutations (JOIN + LEAVE) so their multi-step async Redis commits + // can never interleave: two concurrent joins would otherwise race on the single-valued + // socket→room map (a late addUserToRoom clobbering a newer join's entry). This restores the + // atomic-commit property the synchronous sibling handlers (file-doc, workspace-files) get for + // free. CELL_SELECTION is NOT chained — it only touches presence activity, never the map. + let opChain: Promise = Promise.resolve() + + socket.on(TABLE_PRESENCE_EVENTS.JOIN, ({ tableId, tabSessionId }: JoinTablePayload) => { + // Validate the id BEFORE claiming a generation, so a malformed join can't advance + // joinGeneration and cancel a legitimate in-flight join for another table. + if (typeof tableId !== 'string' || tableId.length === 0) { + socket.emit(TABLE_PRESENCE_EVENTS.JOIN_ERROR, { + tableId: typeof tableId === 'string' ? tableId : '', + error: 'Invalid table id', + code: 'INVALID_PAYLOAD', + retryable: false, + }) + return + } + const joinAttempt = (joinGeneration += 1) + currentTableId = tableId + opChain = opChain + .then(() => runJoin(tableId, tabSessionId, joinAttempt)) + .catch((error) => logger.error('Error joining table room:', error)) + // Returned so callers awaiting this op (e.g. tests) can await its completion; Socket.IO + // ignores a handler's return value. + return opChain + }) + + async function runJoin(tableId: string, tabSessionId: string | undefined, joinAttempt: number) { + // True once this JOIN has been superseded — a newer JOIN/LEAVE bumped joinGeneration, or the + // socket disconnected. Because ops are serialized, no other op mutates room state while this + // one runs, so only two checks are needed: skip a superseded queued op (here), and one final + // check right before the membership commit. + const superseded = () => joinGeneration !== joinAttempt || socket.disconnected + if (superseded()) return + try { + const userId = socket.userId + const userName = socket.userName + + if (!userId || !userName) { + socket.emit(TABLE_PRESENCE_EVENTS.JOIN_ERROR, { + tableId, + error: 'Authentication required', + code: 'AUTHENTICATION_REQUIRED', + retryable: false, + }) + return + } + + if (!roomManager.isReady()) { + socket.emit(TABLE_PRESENCE_EVENTS.JOIN_ERROR, { + tableId, + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + return + } + + const room = tableRoom(tableId) + + const authorized = await resolveRoomJoinAuth({ + userId, + room, + action: 'read', + logger, + logLabel: `table room for ${userId}`, + messages: { + verifyFailed: 'Failed to verify table access', + notFound: 'Table not found', + accessDenied: 'Access denied to table', + }, + emitError: ({ error, code, retryable }) => + socket.emit(TABLE_PRESENCE_EVENTS.JOIN_ERROR, { tableId, error, code, retryable }), + }) + if (!authorized) return + + // Server-authenticated avatar for the presence roster. + const avatarUrl = await resolveAvatarUrl(socket, userId) + + // Leave a previously-joined table room if switching tables. No generation guard is needed + // around this: serialization guarantees no concurrent op committed to a different room + // during the lookup, so `currentRoom` is the socket's genuine prior room, safe to leave. + const currentRoom = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.TABLE) + if (currentRoom && currentRoom.id !== tableId) { + socket.leave(roomName(currentRoom)) + await roomManager.removeUserFromRoom(currentRoom, socket.id) + await roomManager.broadcastPresenceUpdate(currentRoom) + } + + // Reclaim presence orphaned by an ungraceful disconnect (no `disconnecting` + // event fires on a pod crash; the room hashes have no TTL). Returns the roster it + // read so the same-tab dedup below reuses it instead of issuing a second read. + const roster = await sweepStalePresence(roomManager, room) + + // Clean up the same user's stale socket from the same tab (a reconnect that raced + // the old socket's disconnect), so presence shows one entry. Reuses the sweep's + // roster snapshot; re-removing an already-swept entry is a harmless no-op. + if (tabSessionId) { + for (const existing of roster) { + if ( + existing.socketId !== socket.id && + existing.userId === userId && + existing.tabSessionId === tabSessionId + ) { + await roomManager.removeUserFromRoom(room, existing.socketId) + await roomManager.io.in(existing.socketId).socketsLeave(roomName(room)) + } + } + } + + // Final re-check before the membership commit: a LEAVE or a newer JOIN enqueued during the + // awaits above bumped the generation, or the socket disconnected. Abort before registering. + if (superseded()) return + + socket.join(roomName(room)) + + const presence: UserPresence = { + userId, + room, + userName, + socketId: socket.id, + tabSessionId, + joinedAt: Date.now(), + lastActivity: Date.now(), + role: authorized.workspacePermission ?? 'read', + avatarUrl, + } + + // If the socket disconnects during this commit (disconnect cleanup runs off the op chain), + // this write can land after it, leaving a stale presence entry. Benign and self-correcting: + // filterVisiblePresence hides it and sweepStalePresence reclaims it (same as the siblings). + await roomManager.addUserToRoom(room, socket.id, presence) + + // Filter the join ack to live members so a new joiner never briefly sees a + // ghost from an entry the sweep hasn't reclaimed yet. + const presenceUsers = await filterVisiblePresence( + roomManager.io, + room, + await roomManager.getRoomUsers(room) + ) + socket.emit(TABLE_PRESENCE_EVENTS.JOIN_SUCCESS, { + tableId, + socketId: socket.id, + presenceUsers, + }) + + // Post-success, purely decorative: notify peers. The user is already joined and acked, so a + // Redis blip here must not surface as a join failure — swallow it (the next healthy broadcast + // reconciles peers). Kept OUT of the rollback catch below, which is only for pre-success failures. + try { + await roomManager.broadcastPresenceUpdate(room) + } catch (error) { + logger.warn(`Post-join presence broadcast failed for table room ${tableId}`, error) + } + + logger.info(`User ${userId} (${userName}) joined table room ${tableId}`) + } catch (error) { + logger.error('Error joining table room:', error) + // Roll back a partial join: cleanup keys off the socket→room map, so a `socket.join` that + // landed without a matching `addUserToRoom` (a throw in between) would otherwise leave the + // socket stranded in the Socket.IO room, unreclaimable by any later op. A failure between the + // commit and the success ack rolls back too and surfaces a retryable error, so the client + // retries rather than hanging. Safe to run even when superseded — serialization means the + // newer op hasn't committed yet, so this touches only this join's own (this-table) state. + try { + const room = tableRoom(tableId) + socket.leave(roomName(room)) + await roomManager.removeUserFromRoom(room, socket.id) + } catch { + // Best-effort rollback — the original join failure is the one surfaced below, so a + // secondary cleanup error must not mask it or throw out of the error handler. + } + // Suppress the client-facing error when this join was already superseded: the client has moved + // to a newer table, and a retryable error naming the abandoned one could make it re-join and + // supersede the newer join. The rollback above still runs. + if (superseded()) return + socket.emit(TABLE_PRESENCE_EVENTS.JOIN_ERROR, { + tableId, + error: 'Failed to join table', + code: 'JOIN_FAILED', + retryable: true, + }) + } + } + + socket.on(TABLE_PRESENCE_EVENTS.LEAVE, (payload?: { tableId?: string }) => { + // Cancel an in-flight/queued join whose table the client is now leaving (or an unscoped + // leave). Scope to the current table intent so a stale/deferred leave for a DIFFERENT table + // can't cancel the join the client has since switched to. Bumped synchronously here — before + // the teardown is enqueued — so it cancels a running join at its next generation check. + if (!payload?.tableId || payload.tableId === currentTableId) { + joinGeneration += 1 + currentTableId = null + } + opChain = opChain + .then(() => runLeave(payload)) + .catch((error) => logger.error('Error leaving table room:', error)) + return opChain + }) + + async function runLeave(payload?: { tableId?: string }) { + try { + if (!roomManager.isReady()) return + const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.TABLE) + if (!room) return + // Scope the leave to a specific table when the client provides one: a deferred leave from a + // prior view must not evict the socket from a room it has since switched into. + if (payload?.tableId && payload.tableId !== room.id) return + socket.leave(roomName(room)) + await roomManager.removeUserFromRoom(room, socket.id) + await roomManager.broadcastPresenceUpdate(room, socket.id) + } catch (error) { + logger.error('Error leaving table room:', error) + } + } + + socket.on(TABLE_PRESENCE_EVENTS.CELL_SELECTION, async ({ cell }: { cell: unknown }) => { + try { + // Drop a malformed/oversized selection from an untrusted peer before it is stored + // or rebroadcast (`undefined` = invalid; `null` = a legitimately cleared selection). + const selection = normalizeCellSelection(cell) + if (selection === undefined) return + + const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.TABLE) + if (!room) return + + // Persist so a later joiner sees this viewer's current selection in the join ack. + await roomManager.updateUserActivity(room, socket.id, { cell: selection }) + + // Relay to peers (namespaced room → roomName, not room.id). Peers already know this + // socket's identity from the presence roster, so the delta carries only id + cell. + socket.to(roomName(room)).emit(TABLE_PRESENCE_EVENTS.CELL_SELECTION, { + socketId: socket.id, + cell: selection, + }) + } catch (error) { + logger.error(`Error handling table cell selection for socket ${socket.id}:`, error) + } + }) +} diff --git a/apps/realtime/src/handlers/variables.ts b/apps/realtime/src/handlers/variables.ts index 7a4303e70b3..0d6eaac2e3a 100644 --- a/apps/realtime/src/handlers/variables.ts +++ b/apps/realtime/src/handlers/variables.ts @@ -4,11 +4,12 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkWorkflowOperationPermission } from '@/middleware/permissions' -import type { IRoomManager } from '@/rooms' +import { type IRoomManager, workflowRoom as wf } from '@/rooms' const logger = createLogger('VariablesHandlers') @@ -61,7 +62,8 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: } try { - const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id) + const sessionWorkflowId = + (await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW))?.id ?? null const session = await roomManager.getUserSession(socket.id) if (!sessionWorkflowId || !session) { @@ -98,7 +100,7 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: return } - const hasRoom = await roomManager.hasWorkflowRoom(workflowId) + const hasRoom = await roomManager.hasRoom(wf(workflowId)) if (!hasRoom) { logger.debug(`Ignoring variable update: workflow room not found`, { socketId: socket.id, @@ -109,7 +111,7 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: return } - const users = await roomManager.getWorkflowUsers(workflowId) + const users = await roomManager.getRoomUsers(wf(workflowId)) const userPresence = users.find((user) => user.socketId === socket.id) if (!userPresence) { socket.emit('operation-forbidden', { @@ -174,7 +176,7 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: } // Update user activity - await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() }) + await roomManager.updateUserActivity(wf(workflowId), socket.id, { lastActivity: Date.now() }) const debouncedKey = `${workflowId}:${variableId}:${field}` const existing = pendingVariableUpdates.get(debouncedKey) diff --git a/apps/realtime/src/handlers/workflow.test.ts b/apps/realtime/src/handlers/workflow.test.ts index ad649f88f53..552db8125c5 100644 --- a/apps/realtime/src/handlers/workflow.test.ts +++ b/apps/realtime/src/handlers/workflow.test.ts @@ -4,12 +4,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { IRoomManager } from '@/rooms' -const { mockGetWorkflowState, mockVerifyWorkflowAccess, mockResolveCurrentWorkflowRole } = - vi.hoisted(() => ({ - mockGetWorkflowState: vi.fn(), - mockVerifyWorkflowAccess: vi.fn(), - mockResolveCurrentWorkflowRole: vi.fn(), - })) +const { + mockGetWorkflowState, + mockVerifyWorkflowAccess, + mockResolveCurrentWorkflowRole, + mockResolveAvatarUrl, +} = vi.hoisted(() => ({ + mockGetWorkflowState: vi.fn(), + mockVerifyWorkflowAccess: vi.fn(), + mockResolveCurrentWorkflowRole: vi.fn(), + mockResolveAvatarUrl: vi.fn(), +})) + +vi.mock('@/handlers/avatar', () => ({ + resolveAvatarUrl: mockResolveAvatarUrl, +})) vi.mock('@sim/db', () => ({ db: { select: vi.fn() }, @@ -33,13 +42,14 @@ interface JoinWorkflowPayload { } function createSocket(overrides?: Partial>) { - const handlers: Record Promise | void> = {} + // leave-workflow takes no payload; join-workflow takes one — so the stored handler's arg is optional. + const handlers: Record Promise | void> = {} const socket = { id: 'socket-1', userId: 'user-1', userName: 'Test User', userImage: 'avatar.png', - on: vi.fn((event: string, handler: (payload: JoinWorkflowPayload) => Promise | void) => { + on: vi.fn((event: string, handler: (payload?: JoinWorkflowPayload) => Promise | void) => { handlers[event] = handler }), emit: vi.fn(), @@ -57,21 +67,21 @@ function createSocket(overrides?: Partial>) { function createRoomManager(overrides?: Partial): IRoomManager { return { isReady: vi.fn().mockReturnValue(true), - getWorkflowIdForSocket: vi.fn().mockResolvedValue(null), - removeUserFromRoom: vi.fn().mockResolvedValue(null), + getRoomForSocket: vi.fn().mockResolvedValue(null), + getRoomsForSocket: vi.fn().mockResolvedValue([]), + removeUserFromRoom: vi.fn().mockResolvedValue(false), + removeSocketFromAllRooms: vi.fn().mockResolvedValue([]), broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined), - getWorkflowUsers: vi.fn().mockResolvedValue([]), - hasWorkflowRoom: vi.fn().mockResolvedValue(false), + getRoomUsers: vi.fn().mockResolvedValue([]), + hasRoom: vi.fn().mockResolvedValue(false), + deleteRoom: vi.fn().mockResolvedValue(undefined), addUserToRoom: vi.fn().mockResolvedValue(undefined), getUserSession: vi.fn().mockResolvedValue(null), updateUserActivity: vi.fn().mockResolvedValue(undefined), updateRoomLastModified: vi.fn().mockResolvedValue(undefined), - emitToWorkflow: vi.fn(), + emitToRoom: vi.fn(), getUniqueUserCount: vi.fn().mockResolvedValue(1), getTotalActiveConnections: vi.fn().mockResolvedValue(0), - handleWorkflowDeletion: vi.fn().mockResolvedValue(undefined), - handleWorkflowRevert: vi.fn().mockResolvedValue(undefined), - handleWorkflowUpdate: vi.fn().mockResolvedValue(undefined), shutdown: vi.fn().mockResolvedValue(undefined), initialize: vi.fn().mockResolvedValue(undefined), io: { @@ -90,6 +100,36 @@ describe('setupWorkflowHandlers', () => { mockGetWorkflowState.mockResolvedValue({ id: 'workflow-1', state: {} }) mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true, role: 'admin' }) mockResolveCurrentWorkflowRole.mockResolvedValue('admin') + mockResolveAvatarUrl.mockResolvedValue('avatar.png') + }) + + it('resolves the avatar before joining so no await sits between socket.join and addUserToRoom', async () => { + const order: string[] = [] + mockResolveAvatarUrl.mockImplementation(async () => { + order.push('avatar') + return 'avatar.png' + }) + const { socket, handlers } = createSocket({ + join: vi.fn(() => { + order.push('join') + }), + }) + const roomManager = createRoomManager({ + addUserToRoom: vi.fn(async () => { + order.push('add') + }), + }) + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' }) + + // The avatar await must complete before socket.join; reintroducing it between + // join and addUserToRoom reopens the revoke-race ghost-presence window. + expect(order).toEqual(['avatar', 'join', 'add']) }) it('includes workflowId when authentication is missing', async () => { @@ -193,7 +233,7 @@ describe('setupWorkflowHandlers', () => { expect(mockResolveCurrentWorkflowRole).toHaveBeenCalledWith('user-1', 'workflow-1', 'write') expect(socket.join).toHaveBeenCalledWith('workflow-1') expect(roomManager.addUserToRoom).toHaveBeenCalledWith( - 'workflow-1', + { type: 'workflow', id: 'workflow-1' }, 'socket-1', expect.objectContaining({ role: 'read' }) ) @@ -223,8 +263,8 @@ describe('setupWorkflowHandlers', () => { it('includes workflowId when an unexpected join failure occurs', async () => { const { socket, handlers } = createSocket() const roomManager = createRoomManager({ - getWorkflowIdForSocket: vi.fn().mockRejectedValue(new Error('boom')), - removeUserFromRoom: vi.fn().mockResolvedValue(null), + getRoomForSocket: vi.fn().mockRejectedValue(new Error('boom')), + removeUserFromRoom: vi.fn().mockResolvedValue(false), }) setupWorkflowHandlers( @@ -241,4 +281,174 @@ describe('setupWorkflowHandlers', () => { retryable: true, }) }) + + it('cancels a superseded queued join on a fast workflow switch', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + // Enqueue A without awaiting, then B: B bumps the generation synchronously, so A is superseded + // before its queued op runs and must never commit. + handlers['join-workflow']({ workflowId: 'workflow-a', tabSessionId: 'tab-1' }) + await handlers['join-workflow']({ workflowId: 'workflow-b', tabSessionId: 'tab-1' }) + + expect(socket.join).toHaveBeenCalledWith('workflow-b') + expect(socket.join).not.toHaveBeenCalledWith('workflow-a') + expect(roomManager.addUserToRoom).toHaveBeenCalledWith( + { type: 'workflow', id: 'workflow-b' }, + 'socket-1', + expect.anything() + ) + expect(roomManager.addUserToRoom).not.toHaveBeenCalledWith( + { type: 'workflow', id: 'workflow-a' }, + 'socket-1', + expect.anything() + ) + }) + + it('does not let a malformed join cancel a valid in-flight join', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + const validJoin = handlers['join-workflow']({ workflowId: 'workflow-a', tabSessionId: 'tab-1' }) + // A malformed join arrives mid-flight — it must be rejected WITHOUT advancing the generation, + // so it can't supersede the valid join already in flight. + handlers['join-workflow']({ workflowId: '', tabSessionId: 'tab-1' }) + await validJoin + + expect(socket.emit).toHaveBeenCalledWith( + 'join-workflow-error', + expect.objectContaining({ code: 'INVALID_PAYLOAD' }) + ) + // The valid join still committed — not superseded by the malformed one. + expect(socket.join).toHaveBeenCalledWith('workflow-a') + expect(roomManager.addUserToRoom).toHaveBeenCalledWith( + { type: 'workflow', id: 'workflow-a' }, + 'socket-1', + expect.anything() + ) + }) + + it('cancels an in-flight join when a leave is enqueued before it commits', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' }) + await handlers['leave-workflow']() + + expect(socket.join).not.toHaveBeenCalled() + expect(roomManager.addUserToRoom).not.toHaveBeenCalled() + }) + + it('rolls back the workflow membership when addUserToRoom fails mid-commit', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager({ + addUserToRoom: vi.fn().mockRejectedValue(new Error('redis down')), + }) + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' }) + + expect(socket.leave).toHaveBeenCalledWith('workflow-1') + expect(roomManager.removeUserFromRoom).toHaveBeenCalledWith( + { type: 'workflow', id: 'workflow-1' }, + 'socket-1' + ) + expect(socket.emit).toHaveBeenCalledWith( + 'join-workflow-error', + expect.objectContaining({ code: 'JOIN_WORKFLOW_FAILED' }) + ) + }) + + it('does not roll back a committed join when a post-success step fails', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager({ + // Trailing broadcast (post-addUserToRoom, post-success-ack) fails on a Redis blip. + broadcastPresenceUpdate: vi.fn().mockRejectedValue(new Error('redis blip')), + }) + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' }) + + // The user is genuinely joined and was acked; the trailing failure must NOT tear them out. + expect(socket.emit).toHaveBeenCalledWith( + 'join-workflow-success', + expect.objectContaining({ workflowId: 'workflow-1' }) + ) + expect(socket.leave).not.toHaveBeenCalled() + expect(roomManager.removeUserFromRoom).not.toHaveBeenCalled() + expect(socket.emit).not.toHaveBeenCalledWith('join-workflow-error', expect.anything()) + }) + + it('rolls back and surfaces a retryable error when a pre-success step fails after commit', async () => { + // getWorkflowState runs after addUserToRoom but before the success ack — its failure must roll + // back and emit a retryable error so the client retries, never hanging committed-but-unacked. + mockGetWorkflowState.mockRejectedValue(new Error('db blip')) + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' }) + + expect(socket.emit).not.toHaveBeenCalledWith('join-workflow-success', expect.anything()) + expect(roomManager.removeUserFromRoom).toHaveBeenCalledWith( + { type: 'workflow', id: 'workflow-1' }, + 'socket-1' + ) + expect(socket.emit).toHaveBeenCalledWith( + 'join-workflow-error', + expect.objectContaining({ code: 'JOIN_WORKFLOW_FAILED', retryable: true }) + ) + }) + + it('leaves the workflow room even when the session key has expired', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager({ + getRoomForSocket: vi.fn().mockResolvedValue({ type: 'workflow', id: 'workflow-1' }), + getUserSession: vi.fn().mockResolvedValue(null), + }) + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + await handlers['leave-workflow']() + + expect(socket.leave).toHaveBeenCalledWith('workflow-1') + expect(roomManager.removeUserFromRoom).toHaveBeenCalledWith( + { type: 'workflow', id: 'workflow-1' }, + 'socket-1' + ) + expect(roomManager.broadcastPresenceUpdate).toHaveBeenCalledWith({ + type: 'workflow', + id: 'workflow-1', + }) + }) }) diff --git a/apps/realtime/src/handlers/workflow.ts b/apps/realtime/src/handlers/workflow.ts index 2c00895b201..523c3747779 100644 --- a/apps/realtime/src/handlers/workflow.ts +++ b/apps/realtime/src/handlers/workflow.ts @@ -1,15 +1,58 @@ -import { db, user } from '@sim/db' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { getWorkflowState } from '@/database/operations' +import { resolveAvatarUrl } from '@/handlers/avatar' import type { AuthenticatedSocket } from '@/middleware/auth' import { resolveCurrentWorkflowRole, verifyWorkflowAccess } from '@/middleware/permissions' -import type { IRoomManager, UserPresence } from '@/rooms' +import { type IRoomManager, type UserPresence, workflowRoom as wf } from '@/rooms' +import { filterVisiblePresence } from '@/rooms/presence-visibility' const logger = createLogger('WorkflowHandlers') export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { - socket.on('join-workflow', async ({ workflowId, tabSessionId }) => { + // Monotonic per-socket generation: each JOIN/LEAVE bumps it synchronously on arrival, and a + // queued or in-flight op that finds a newer generation aborts — a fast workflow switch A→B thus + // cancels A the instant B arrives. + let joinGeneration = 0 + // Serialize this socket's room mutations (JOIN + LEAVE) so their multi-step async Redis commits + // can never interleave: two concurrent joins would otherwise race on the single-valued + // socket→room map (a late addUserToRoom clobbering a newer join's entry, leaving the socket a + // ghost in the old room and receiving its operation broadcasts). This matches the sibling + // handlers (tables, file-doc, workspace-files). + let opChain: Promise = Promise.resolve() + + socket.on('join-workflow', ({ workflowId, tabSessionId }) => { + // Validate the id BEFORE claiming a generation, so a malformed join can't advance joinGeneration + // and cancel a legitimate in-flight switch (matches tables/workspace-files). + if (typeof workflowId !== 'string' || workflowId.length === 0) { + socket.emit('join-workflow-error', { + workflowId: typeof workflowId === 'string' ? workflowId : '', + error: 'Invalid workflow id', + code: 'INVALID_PAYLOAD', + retryable: false, + }) + return + } + const joinAttempt = (joinGeneration += 1) + opChain = opChain + .then(() => runJoin(workflowId, tabSessionId, joinAttempt)) + .catch((error) => logger.error('Error joining workflow:', error)) + // Returned so callers awaiting this op (e.g. tests) can await its completion; Socket.IO + // ignores a handler's return value. + return opChain + }) + + async function runJoin( + workflowId: string, + tabSessionId: string | undefined, + joinAttempt: number + ) { + // True once this JOIN has been superseded — a newer JOIN/LEAVE bumped joinGeneration, or the + // socket disconnected. Because ops are serialized, no other op mutates room state while this + // one runs, so only two checks are needed: skip a superseded queued op (here), and one final + // check right before the membership commit. + const superseded = () => joinGeneration !== joinAttempt || socket.disconnected + if (superseded()) return try { const userId = socket.userId const userName = socket.userName @@ -64,18 +107,19 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: return } - // Leave current room if in one - const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id) - if (currentWorkflowId) { - socket.leave(currentWorkflowId) - await roomManager.removeUserFromRoom(socket.id, currentWorkflowId) - await roomManager.broadcastPresenceUpdate(currentWorkflowId) + // Leave a previously-joined workflow room if switching workflows. Guard on a DIFFERENT id so a + // re-join of the SAME workflow doesn't leave→re-add and flicker presence for peers. + const currentRoom = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW) + if (currentRoom && currentRoom.id !== workflowId) { + socket.leave(currentRoom.id) + await roomManager.removeUserFromRoom(currentRoom, socket.id) + await roomManager.broadcastPresenceUpdate(currentRoom) } // Keep this above Redis socket key TTL (1h) so a normal idle user is not evicted too aggressively. const STALE_THRESHOLD_MS = 75 * 60 * 1000 const now = Date.now() - const existingUsers = await roomManager.getWorkflowUsers(workflowId) + const existingUsers = await roomManager.getRoomUsers(wf(workflowId)) let liveSocketIds = new Set() let canCheckLiveness = false @@ -106,7 +150,7 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: logger.info( `Cleaning up socket ${existingUser.socketId} for user ${existingUser.userId} (same tab)` ) - await roomManager.removeUserFromRoom(existingUser.socketId, workflowId) + await roomManager.removeUserFromRoom(wf(workflowId), existingUser.socketId) await roomManager.io.in(existingUser.socketId).socketsLeave(workflowId) continue } @@ -124,21 +168,30 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: logger.info( `Cleaning up socket ${existingUser.socketId} for user ${existingUser.userId} (stale activity)` ) - await roomManager.removeUserFromRoom(existingUser.socketId, workflowId) + await roomManager.removeUserFromRoom(wf(workflowId), existingUser.socketId) await roomManager.io.in(existingUser.socketId).socketsLeave(workflowId) } catch (error) { logger.warn(`Best-effort cleanup failed for socket ${existingUser.socketId}`, error) } } + // Resolve the avatar before the critical section below. It is the only + // await that used to sit between socket.join and addUserToRoom, and a sweep + // eviction in that gap would socketsLeave the socket while its presence + // mapping did not yet exist — cleanupEvictedSocket would find nothing to + // remove, then this join would write presence for a socket already out of + // the room (a ghost collaborator until the stale sweep). Hoisting it keeps + // the whole re-auth -> socket.join -> addUserToRoom section await-free. + const avatarUrl = await resolveAvatarUrl(socket, userId) + // Re-authorize immediately before joining: the access-revalidation sweep // may have evicted this socket while the awaits above were in flight, and // its eviction is recorded in the shared role cache before it runs — so a // revoked user resolves to null here. The resolver is single-flighted per // (user, workflow), so this read cannot race the sweep's and overwrite a // recorded revocation with a stale role; and no awaits sit between this - // check and socket.join, so a sweep eviction cannot interleave after it - // and be reversed by this join. + // check and addUserToRoom (avatar resolution is hoisted above), so a sweep + // eviction cannot interleave inside the join and be reversed by it. const currentRole = await resolveCurrentWorkflowRole(userId, workflowId, userRole) if (currentRole === null) { logger.warn( @@ -154,29 +207,19 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: } userRole = currentRole + // Final re-check before the membership commit: a LEAVE or a newer JOIN enqueued during the + // awaits above bumped the generation, or the socket disconnected. Abort before registering. + // (This guards against a superseding op; the avatar hoist above guards against the off-chain + // access-revalidation sweep, which does not bump the generation.) + if (superseded()) return + // Join the new room socket.join(workflowId) - // Get avatar URL - let avatarUrl = socket.userImage || null - if (!avatarUrl) { - try { - const [userRecord] = await db - .select({ image: user.image }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) - - avatarUrl = userRecord?.image ?? null - } catch (error) { - logger.warn('Failed to load user avatar for presence', { userId, error }) - } - } - // Create presence entry const userPresence: UserPresence = { userId, - workflowId, + room: wf(workflowId), userName, socketId: socket.id, tabSessionId, @@ -186,11 +229,16 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: avatarUrl, } - // Add user to room - await roomManager.addUserToRoom(workflowId, socket.id, userPresence) + // Add user to room — the membership commit. + await roomManager.addUserToRoom(wf(workflowId), socket.id, userPresence) - // Get current presence list for the join acknowledgment - const presenceUsers = await roomManager.getWorkflowUsers(workflowId) + // Get current presence list for the join acknowledgment, filtered to live members so a new + // joiner never sees a ghost from an entry the stale sweep hasn't reclaimed yet. + const presenceUsers = await filterVisiblePresence( + roomManager.io, + wf(workflowId), + await roomManager.getRoomUsers(wf(workflowId)) + ) // Get workflow state const workflowState = await getWorkflowState(workflowId) @@ -205,18 +253,34 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: // Send workflow state socket.emit('workflow-state', workflowState) - // Broadcast presence update to all users in the room - await roomManager.broadcastPresenceUpdate(workflowId) - - const uniqueUserCount = await roomManager.getUniqueUserCount(workflowId) - logger.info( - `User ${userId} (${userName}) joined workflow ${workflowId}. Room now has ${uniqueUserCount} unique users.` - ) + // Post-success, purely decorative: notify peers and log the count. The user is already joined + // and acked, so a Redis blip here must not surface as a join failure — swallow it (the next + // healthy presence broadcast reconciles peers). It must stay OUT of the rollback catch below, + // which is only for pre-success failures. + try { + await roomManager.broadcastPresenceUpdate(wf(workflowId)) + const uniqueUserCount = await roomManager.getUniqueUserCount(wf(workflowId)) + logger.info( + `User ${userId} (${userName}) joined workflow ${workflowId}. Room now has ${uniqueUserCount} unique users.` + ) + } catch (error) { + logger.warn(`Post-join presence broadcast failed for workflow ${workflowId}`, error) + } } catch (error) { logger.error('Error joining workflow:', error) - // Undo socket.join and room manager entry if any operation failed + // Roll back a partial join: cleanup keys off the socket→room map, so a `socket.join` that + // landed without a matching `addUserToRoom` (a throw in between) would otherwise strand the + // socket in the Socket.IO room, unreclaimable by any later op. A failure between the commit + // and the success ack rolls back too and surfaces a retryable error — so the client retries + // rather than hanging (never left committed-but-unacked). Safe even when superseded — + // serialization means the newer op hasn't committed yet, so this touches only this join's own + // room state, never the newer op's. socket.leave(workflowId) - await roomManager.removeUserFromRoom(socket.id, workflowId) + await roomManager.removeUserFromRoom(wf(workflowId), socket.id) + // Suppress the client-facing error when this join was already superseded: the client has moved + // to a newer workflow, and a retryable error naming the abandoned one could make it re-join and + // supersede the newer join (an A/B flicker). The rollback above still runs. + if (superseded()) return const isReady = roomManager.isReady() socket.emit('join-workflow-error', { workflowId, @@ -225,26 +289,33 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: retryable: true, }) } + } + + socket.on('leave-workflow', () => { + // A leave always cancels any in-flight/queued join for this socket (the client emits it with no + // payload — there is no partial-switch case as there is for tables). Bumped synchronously here, + // before the teardown is enqueued, so it cancels a running join at its next generation check. + joinGeneration += 1 + opChain = opChain + .then(() => runLeave()) + .catch((error) => logger.error('Error leaving workflow:', error)) + return opChain }) - socket.on('leave-workflow', async () => { + async function runLeave() { try { - if (!roomManager.isReady()) { - return - } - - const workflowId = await roomManager.getWorkflowIdForSocket(socket.id) - const session = await roomManager.getUserSession(socket.id) - - if (workflowId && session) { - socket.leave(workflowId) - await roomManager.removeUserFromRoom(socket.id, workflowId) - await roomManager.broadcastPresenceUpdate(workflowId) - - logger.info(`User ${session.userId} (${session.userName}) left workflow ${workflowId}`) - } + if (!roomManager.isReady()) return + const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW) + // The room ref alone is sufficient to leave; no session lookup is gated in front of it, so an + // idle user whose 1h session key expired (while the 24h room mapping is still live) can still + // leave cleanly instead of being stranded as a ghost until disconnect. + if (!room) return + socket.leave(room.id) + await roomManager.removeUserFromRoom(room, socket.id) + await roomManager.broadcastPresenceUpdate(room) + logger.info(`User ${socket.userId} (${socket.userName}) left workflow ${room.id}`) } catch (error) { logger.error('Error leaving workflow:', error) } - }) + } } diff --git a/apps/realtime/src/handlers/workspace-invalidation-room.test.ts b/apps/realtime/src/handlers/workspace-invalidation-room.test.ts new file mode 100644 index 00000000000..643fca696f0 --- /dev/null +++ b/apps/realtime/src/handlers/workspace-invalidation-room.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { IRoomManager } from '@/rooms' + +const { mockAuthorizeRoom } = vi.hoisted(() => ({ + mockAuthorizeRoom: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: vi.fn() }, + user: { image: 'image' }, +})) + +vi.mock('@sim/platform-authz/rooms', () => ({ + authorizeRoom: mockAuthorizeRoom, +})) + +import { setupWorkspaceInvalidationRoom } from '@/handlers/workspace-invalidation-room' + +type Payload = { workspaceId?: string } + +function createSocket(overrides?: Record) { + const handlers: Record Promise | void> = {} + // Live Set so the handler's native `socket.rooms` membership tracking works in tests. + const rooms = new Set() + const socket = { + id: 'socket-1', + userId: 'user-1', + userName: 'Test User', + userImage: 'avatar.png', + rooms, + on: vi.fn((event: string, handler: (payload?: Payload) => Promise | void) => { + handlers[event] = handler + }), + emit: vi.fn(), + join: vi.fn((room: string) => rooms.add(room)), + leave: vi.fn((room: string) => rooms.delete(room)), + to: vi.fn().mockReturnValue({ emit: vi.fn() }), + ...overrides, + } + return { handlers, socket, rooms } +} + +function createRoomManager(overrides?: Partial): IRoomManager { + return { + isReady: vi.fn().mockReturnValue(true), + getRoomForSocket: vi.fn().mockResolvedValue(null), + getRoomsForSocket: vi.fn().mockResolvedValue([]), + removeUserFromRoom: vi.fn().mockResolvedValue(false), + removeSocketFromAllRooms: vi.fn().mockResolvedValue([]), + broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined), + getRoomUsers: vi.fn().mockResolvedValue([]), + hasRoom: vi.fn().mockResolvedValue(false), + deleteRoom: vi.fn().mockResolvedValue(undefined), + addUserToRoom: vi.fn().mockResolvedValue(undefined), + getUserSession: vi.fn().mockResolvedValue(null), + updateUserActivity: vi.fn().mockResolvedValue(undefined), + updateRoomLastModified: vi.fn().mockResolvedValue(undefined), + emitToRoom: vi.fn(), + getUniqueUserCount: vi.fn().mockResolvedValue(1), + getTotalActiveConnections: vi.fn().mockResolvedValue(0), + shutdown: vi.fn().mockResolvedValue(undefined), + initialize: vi.fn().mockResolvedValue(undefined), + io: { + in: vi.fn().mockReturnValue({ socketsLeave: vi.fn().mockResolvedValue(undefined) }), + }, + ...overrides, + } as unknown as IRoomManager +} + +// The two presence-free live-list rooms share one implementation; run the whole suite against both +// so files and tables can never drift. Event names and room names derive from the room type. +describe.each([ROOM_TYPES.WORKSPACE_FILES, ROOM_TYPES.WORKSPACE_TABLES] as const)( + 'setupWorkspaceInvalidationRoom(%s)', + (roomType) => { + const joinEvent = `join-${roomType}` + const successEvent = `${joinEvent}-success` + const errorEvent = `${joinEvent}-error` + const leaveEvent = `leave-${roomType}` + const roomOf = (workspaceId: string) => `${roomType}:${workspaceId}` + + const setup = (socket: ReturnType['socket'], roomManager: IRoomManager) => + setupWorkspaceInvalidationRoom( + socket as unknown as Parameters[0], + roomManager, + roomType + ) + + beforeEach(() => { + vi.clearAllMocks() + mockAuthorizeRoom.mockResolvedValue({ + allowed: true, + status: 200, + workspaceId: 'ws-1', + workspacePermission: 'admin', + }) + }) + + it('rejects join when the socket is not authenticated', async () => { + const { socket, handlers } = createSocket({ userId: undefined, userName: undefined }) + setup(socket, createRoomManager()) + + await handlers[joinEvent]({ workspaceId: 'ws-1' }) + + expect(socket.emit).toHaveBeenCalledWith(errorEvent, { + workspaceId: 'ws-1', + error: 'Authentication required', + code: 'AUTHENTICATION_REQUIRED', + retryable: false, + }) + }) + + it('rejects join with a retryable error when realtime is unavailable', async () => { + const { socket, handlers } = createSocket() + setup(socket, createRoomManager({ isReady: vi.fn().mockReturnValue(false) })) + + await handlers[joinEvent]({ workspaceId: 'ws-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + errorEvent, + expect.objectContaining({ code: 'ROOM_MANAGER_UNAVAILABLE', retryable: true }) + ) + }) + + it('rejects join when workspace access is denied', async () => { + mockAuthorizeRoom.mockResolvedValue({ + allowed: false, + status: 403, + workspaceId: 'ws-1', + workspacePermission: null, + }) + const { socket, handlers } = createSocket() + setup(socket, createRoomManager()) + + await handlers[joinEvent]({ workspaceId: 'ws-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + errorEvent, + expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false }) + ) + }) + + it('joins the room on success without any presence bookkeeping', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + setup(socket, roomManager) + + await handlers[joinEvent]({ workspaceId: 'ws-1' }) + + expect(socket.join).toHaveBeenCalledWith(roomOf('ws-1')) + expect(socket.emit).toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-1' }) + // The room is live-list-only: no room-manager presence is tracked or broadcast. + expect(roomManager.addUserToRoom).not.toHaveBeenCalled() + expect(roomManager.broadcastPresenceUpdate).not.toHaveBeenCalled() + }) + + it('leaves a previously-joined room when switching workspaces', async () => { + const { socket, handlers, rooms } = createSocket() + rooms.add(roomOf('ws-old')) + setup(socket, createRoomManager()) + + await handlers[joinEvent]({ workspaceId: 'ws-1' }) + + expect(socket.leave).toHaveBeenCalledWith(roomOf('ws-old')) + expect(socket.join).toHaveBeenCalledWith(roomOf('ws-1')) + }) + + it('leaves the scoped room on leave', () => { + const { socket, handlers, rooms } = createSocket() + rooms.add(roomOf('ws-1')) + setup(socket, createRoomManager()) + + handlers[leaveEvent]({ workspaceId: 'ws-1' }) + + expect(socket.leave).toHaveBeenCalledWith(roomOf('ws-1')) + }) + + it('cancels an in-flight join when the user leaves that workspace mid-authorize', async () => { + const { socket, handlers } = createSocket() + let resolveAuth: (value: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValue( + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + setup(socket, createRoomManager()) + + // Join ws-1 is awaiting authorization when the view unmounts and leaves ws-1. + const joinPromise = handlers[joinEvent]({ workspaceId: 'ws-1' }) + handlers[leaveEvent]({ workspaceId: 'ws-1' }) + resolveAuth({ allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'admin' }) + await joinPromise + + // The stale join must NOT join the room the client has since left (no stranded membership). + expect(socket.join).not.toHaveBeenCalled() + expect(socket.emit).not.toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-1' }) + }) + + it('does not cancel an in-flight join when a deferred leave targets a different workspace', async () => { + const { socket, handlers } = createSocket() + let resolveAuth: (value: unknown) => void = () => {} + mockAuthorizeRoom.mockReturnValue( + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + setup(socket, createRoomManager()) + + // The client has switched to ws-2 (join in-flight) when a stale leave for the prior ws-1 lands. + const joinPromise = handlers[joinEvent]({ workspaceId: 'ws-2' }) + handlers[leaveEvent]({ workspaceId: 'ws-1' }) + resolveAuth({ allowed: true, status: 200, workspaceId: 'ws-2', workspacePermission: 'admin' }) + await joinPromise + + // The deferred leave for ws-1 must not abort the join the client actually wants (ws-2). + expect(socket.join).toHaveBeenCalledWith(roomOf('ws-2')) + expect(socket.emit).toHaveBeenCalledWith(successEvent, { workspaceId: 'ws-2' }) + }) + } +) diff --git a/apps/realtime/src/handlers/workspace-invalidation-room.ts b/apps/realtime/src/handlers/workspace-invalidation-room.ts new file mode 100644 index 00000000000..aacb141e9ae --- /dev/null +++ b/apps/realtime/src/handlers/workspace-invalidation-room.ts @@ -0,0 +1,155 @@ +import { createLogger } from '@sim/logger' +import { type RoomRef, type RoomType, roomName } from '@sim/realtime-protocol/rooms' +import { resolveRoomJoinAuth } from '@/handlers/room-join-auth' +import type { AuthenticatedSocket } from '@/middleware/auth' +import type { IRoomManager } from '@/rooms' + +const logger = createLogger('WorkspaceInvalidationRoom') + +interface JoinPayload { + workspaceId: string +} + +/** + * Wires a workspace-scoped, presence-free "invalidation room" onto a socket: the client joins a + * room named after its workspace, and a `${roomType}-changed` event — fanned out by the server-side + * mutation path over HTTP — reaches every viewer so they refetch. This is the shared core behind the + * workspace-files and workspace-tables browsers; they differ only in `roomType` (which also derives + * the event names, since each room type's wire token IS its event stem: `join-${roomType}`, + * `leave-${roomType}`, `join-${roomType}-success/-error`, and the `${roomType}-changed` broadcast). + * + * These rooms carry NO presence — "who's in a resource" comes from the per-resource room (file-doc / + * table), and mutations go over HTTP. Membership is tracked natively by Socket.IO (`socket.rooms`), + * so a workspace switch just leaves the prior room — no room-manager presence bookkeeping to sync. + */ +export function setupWorkspaceInvalidationRoom( + socket: AuthenticatedSocket, + roomManager: IRoomManager, + roomType: RoomType +) { + const joinEvent = `join-${roomType}` + const leaveEvent = `leave-${roomType}` + const successEvent = `${joinEvent}-success` + const errorEvent = `${joinEvent}-error` + const roomPrefix = `${roomType}:` + const room = (workspaceId: string): RoomRef => ({ type: roomType, id: workspaceId }) + + // Monotonic per-socket join counter: each join captures its number and, after the async + // authorize, aborts if a newer intent has superseded it — a fast workspace switch A→B can + // otherwise let A's late completion leave B and strand the socket in A, missing B's + // `${roomType}-changed` invalidations. + let joinGeneration = 0 + // The workspace the socket currently intends to be in (set when a join starts). A leave that + // targets this workspace — or an unscoped "leave all" — advances joinGeneration so an in-flight + // join is cancelled instead of completing after the view has closed. A stale/deferred leave for + // a DIFFERENT workspace must NOT advance it, or it would abort the join the client has since + // switched to (the bug that bit the file-doc room in #5941). + let currentWorkspace: string | null = null + + socket.on(joinEvent, async ({ workspaceId }: JoinPayload) => { + // Validate synchronously BEFORE claiming a generation, so a rejected/malformed join can't + // advance joinGeneration and cancel a legitimate in-flight join for another workspace. + if (!socket.userId || !socket.userName) { + socket.emit(errorEvent, { + workspaceId, + error: 'Authentication required', + code: 'AUTHENTICATION_REQUIRED', + retryable: false, + }) + return + } + + if (!roomManager.isReady()) { + socket.emit(errorEvent, { + workspaceId, + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + return + } + + // Validate the client-supplied id before it reaches the DB query (join payloads are + // otherwise raw client input) and before advancing the generation. + if (typeof workspaceId !== 'string' || workspaceId.length === 0) { + socket.emit(errorEvent, { + workspaceId: typeof workspaceId === 'string' ? workspaceId : '', + error: 'Invalid workspace id', + code: 'INVALID_PAYLOAD', + retryable: false, + }) + return + } + + const joinAttempt = (joinGeneration += 1) + currentWorkspace = workspaceId + try { + const ref = room(workspaceId) + + const authorized = await resolveRoomJoinAuth({ + userId: socket.userId, + room: ref, + action: 'read', + logger, + logLabel: `${roomType} room for ${socket.userId}`, + messages: { + verifyFailed: 'Failed to verify workspace access', + notFound: 'Workspace not found', + accessDenied: 'Access denied to workspace', + }, + emitError: ({ error, code, retryable }) => + socket.emit(errorEvent, { workspaceId, error, code, retryable }), + }) + if (!authorized) return + + // A newer join started on this socket during authorize (or it dropped): abort so a + // stale join can't leave the room the client has since switched to. + if (joinGeneration !== joinAttempt || socket.disconnected) return + + // Leave any previously-joined room of this type (workspace switch), read straight from the + // socket's native room membership so there's no presence store to keep in sync. + const target = roomName(ref) + for (const joined of socket.rooms) { + if (joined !== target && joined.startsWith(roomPrefix)) socket.leave(joined) + } + + socket.join(target) + socket.emit(successEvent, { workspaceId }) + } catch (error) { + logger.error(`Error joining ${roomType} room:`, error) + try { + socket.leave(roomName(room(workspaceId))) + } catch {} + // Suppress the client-facing error when this join was already superseded: the client has + // switched to a newer workspace, and a retryable error naming the abandoned one could make it + // re-join and cancel the newer join. The leave above still runs. + if (joinGeneration !== joinAttempt || socket.disconnected) return + socket.emit(errorEvent, { + workspaceId, + error: `Failed to join ${roomType}`, + code: 'JOIN_FAILED', + retryable: true, + }) + } + }) + + socket.on(leaveEvent, (payload?: { workspaceId?: string }) => { + // Cancel an in-flight join whose target the client is now leaving: a join awaiting + // authorization when the view unmounts would otherwise complete afterwards and strand the + // socket in a room it has left. Only when the leave targets the current join intent (or is + // unscoped) — a deferred leave for a different workspace must not abort the join the client + // has since switched to. + if (!payload?.workspaceId || payload.workspaceId === currentWorkspace) { + joinGeneration += 1 + currentWorkspace = null + } + // Scope the leave to a specific workspace when the client provides one: a deferred leave + // from a prior page must not evict a room the socket has since switched into. + const target = payload?.workspaceId ? roomName(room(payload.workspaceId)) : null + for (const joined of socket.rooms) { + if (!joined.startsWith(roomPrefix)) continue + if (target && joined !== target) continue + socket.leave(joined) + } + }) +} diff --git a/apps/realtime/src/index.test.ts b/apps/realtime/src/index.test.ts index 92ddc101c69..eb7ee030937 100644 --- a/apps/realtime/src/index.test.ts +++ b/apps/realtime/src/index.test.ts @@ -4,11 +4,12 @@ * @vitest-environment node */ import { createServer, request as httpRequest } from 'http' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { createMockLogger } from '@sim/testing' import { randomInt } from '@sim/utils/random' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { createSocketIOServer } from '@/config/socket' -import { MemoryRoomManager } from '@/rooms' +import { MemoryRoomManager, workflowRoom } from '@/rooms' import { createHttpHandler } from '@/routes/http' vi.mock('@/auth', () => ({ @@ -230,9 +231,10 @@ describe('Socket Server Index Integration', () => { const workflowId = 'test-workflow-123' const socketId = 'test-socket-123' + const room = workflowRoom(workflowId) const presence = { userId: 'user-123', - workflowId, + room, userName: 'Test User', socketId, joinedAt: Date.now(), @@ -240,10 +242,10 @@ describe('Socket Server Index Integration', () => { role: 'admin', } - await roomManager.addUserToRoom(workflowId, socketId, presence) + await roomManager.addUserToRoom(room, socketId, presence) - expect(await roomManager.hasWorkflowRoom(workflowId)).toBe(true) - const users = await roomManager.getWorkflowUsers(workflowId) + expect(await roomManager.hasRoom(room)).toBe(true) + const users = await roomManager.getRoomUsers(room) expect(users).toHaveLength(1) expect(users[0].socketId).toBe(socketId) }) @@ -252,9 +254,10 @@ describe('Socket Server Index Integration', () => { const socketId = 'test-socket-123' const workflowId = 'test-workflow-456' + const room = workflowRoom(workflowId) const presence = { userId: 'user-123', - workflowId, + room, userName: 'Test User', socketId, joinedAt: Date.now(), @@ -262,9 +265,9 @@ describe('Socket Server Index Integration', () => { role: 'admin', } - await roomManager.addUserToRoom(workflowId, socketId, presence) + await roomManager.addUserToRoom(room, socketId, presence) - expect(await roomManager.getWorkflowIdForSocket(socketId)).toBe(workflowId) + expect(await roomManager.getRoomForSocket(socketId, ROOM_TYPES.WORKFLOW)).toEqual(room) const session = await roomManager.getUserSession(socketId) expect(session).toBeDefined() expect(session?.userId).toBe('user-123') @@ -274,9 +277,10 @@ describe('Socket Server Index Integration', () => { const workflowId = 'test-workflow-789' const socketId = 'test-socket-789' + const room = workflowRoom(workflowId) const presence = { userId: 'user-789', - workflowId, + room, userName: 'Test User', socketId, joinedAt: Date.now(), @@ -284,16 +288,16 @@ describe('Socket Server Index Integration', () => { role: 'admin', } - await roomManager.addUserToRoom(workflowId, socketId, presence) + await roomManager.addUserToRoom(room, socketId, presence) - expect(await roomManager.hasWorkflowRoom(workflowId)).toBe(true) + expect(await roomManager.hasRoom(room)).toBe(true) // Remove user - await roomManager.removeUserFromRoom(socketId) + await roomManager.removeUserFromRoom(room, socketId) // Room should be cleaned up since it's now empty - expect(await roomManager.hasWorkflowRoom(workflowId)).toBe(false) - expect(await roomManager.getWorkflowIdForSocket(socketId)).toBeNull() + expect(await roomManager.hasRoom(room)).toBe(false) + expect(await roomManager.getRoomForSocket(socketId, ROOM_TYPES.WORKFLOW)).toBeNull() }) }) @@ -324,7 +328,7 @@ describe('Socket Server Index Integration', () => { expect(typeof roomManager.addUserToRoom).toBe('function') expect(typeof roomManager.removeUserFromRoom).toBe('function') - expect(typeof roomManager.handleWorkflowDeletion).toBe('function') + expect(typeof roomManager.removeSocketFromAllRooms).toBe('function') expect(typeof roomManager.broadcastPresenceUpdate).toBe('function') }) }) diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 3f7f7eb22d0..80663141334 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -6,6 +6,8 @@ import { createSocketIOServer, shutdownSocketIOAdapter } from '@/config/socket' import { assertSchemaCompatibility } from '@/database/preflight' import { env } from '@/env' import { setupAllHandlers } from '@/handlers' +import { flushAllFileDocRooms } from '@/handlers/file-doc' +import { getFileDocStore, initFileDocStore } from '@/handlers/file-doc-store' import { type AuthenticatedSocket, authenticateSocket } from '@/middleware/auth' import { type IRoomManager, MemoryRoomManager, RedisRoomManager } from '@/rooms' import { createHttpHandler } from '@/routes/http' @@ -55,6 +57,10 @@ async function main() { // Initialize room manager (Redis or in-memory based on config) const roomManager = await createRoomManager(io) + // Initialize the shared Yjs backend for collaborative file docs (Redis Streams). Enabled only when + // REDIS_URL is set; otherwise the relay runs its original single-replica in-memory doc path. + await initFileDocStore(env.REDIS_URL) + // Set up authentication middleware io.use(authenticateSocket) @@ -106,11 +112,26 @@ async function main() { logger.info(`Health check available at: http://localhost:${PORT}/health`) }) + let shuttingDown = false const shutdown = async () => { + // SIGINT and SIGTERM both bind this; a double signal (or SIGTERM then SIGINT during the drain) + // must not run the whole teardown twice — that means a second forced-exit timer and a second + // Redis quit (which throws "The client is closed"). + if (shuttingDown) return + shuttingDown = true logger.info('Shutting down Socket.IO server...') accessRevalidation.stop() + // Flush open collaborative docs to durable markdown BEFORE tearing down Redis/the store — the + // per-socket disconnect flush is fire-and-forget and would race process exit. + try { + await flushAllFileDocRooms() + logger.info('Flushed open collaborative documents') + } catch (error) { + logger.error('Error flushing collaborative documents on shutdown:', error) + } + try { await roomManager.shutdown() logger.info('RoomManager shutdown complete') @@ -124,6 +145,21 @@ async function main() { logger.error('Error during Socket.IO adapter shutdown:', error) } + try { + await getFileDocStore().shutdown() + } catch (error) { + logger.error('Error during FileDocStore shutdown:', error) + } + + // Close local client connections so `httpServer.close()` can complete its callback and exit + // gracefully — otherwise open websockets keep it hanging until the forced-exit timer below. + // Local-only: a rolling deploy must not disconnect clients pinned to other pods. + try { + io.local.disconnectSockets(true) + } catch (error) { + logger.error('Error disconnecting sockets on shutdown:', error) + } + httpServer.close(() => { logger.info('Socket.IO server closed') process.exit(0) diff --git a/apps/realtime/src/rooms/index.ts b/apps/realtime/src/rooms/index.ts index 8067fc1b215..29157aa4581 100644 --- a/apps/realtime/src/rooms/index.ts +++ b/apps/realtime/src/rooms/index.ts @@ -1,3 +1,4 @@ export { MemoryRoomManager } from '@/rooms/memory-manager' export { RedisRoomManager } from '@/rooms/redis-manager' -export type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types' +export type { IRoomManager, RoomState, UserPresence, UserSession } from '@/rooms/types' +export { WorkflowRoomService, workflowRoom } from '@/rooms/workflow-room-service' diff --git a/apps/realtime/src/rooms/memory-manager.test.ts b/apps/realtime/src/rooms/memory-manager.test.ts new file mode 100644 index 00000000000..af98414f540 --- /dev/null +++ b/apps/realtime/src/rooms/memory-manager.test.ts @@ -0,0 +1,209 @@ +/** + * Multi-room semantics for the room manager. Exercises the invariants the + * single-room → multi-room migration must preserve: a socket in two rooms, + * refcounted session cleanup, presence isolation, and full-disconnect cleanup. + * + * @vitest-environment node + */ +import { ROOM_TYPES, type RoomRef } from '@sim/realtime-protocol/rooms' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRoomManager } from '@/rooms/memory-manager' +import { sweepStalePresence } from '@/rooms/presence-visibility' +import type { UserPresence } from '@/rooms/types' + +function fakeIo(liveSocketIds: string[] = []) { + const emit = vi.fn() + return { + emit, + io: { + to: vi.fn().mockReturnValue({ emit }), + in: vi.fn().mockReturnValue({ + fetchSockets: vi.fn().mockResolvedValue(liveSocketIds.map((id) => ({ id }))), + }), + } as never, + } +} + +function presence(room: RoomRef, socketId: string, userId: string): UserPresence { + return { + userId, + room, + userName: `user-${userId}`, + socketId, + joinedAt: Date.now(), + lastActivity: Date.now(), + role: 'admin', + } +} + +const WORKFLOW: RoomRef = { type: ROOM_TYPES.WORKFLOW, id: 'wf-1' } +const FILES: RoomRef = { type: ROOM_TYPES.WORKSPACE_FILES, id: 'ws-1' } + +describe('MemoryRoomManager multi-room', () => { + let manager: MemoryRoomManager + + beforeEach(async () => { + manager = new MemoryRoomManager(fakeIo().io) + await manager.initialize() + }) + + it('tracks a single socket in two rooms of different types', async () => { + await manager.addUserToRoom(WORKFLOW, 'socket-1', presence(WORKFLOW, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + + const rooms = await manager.getRoomsForSocket('socket-1') + expect(rooms).toHaveLength(2) + expect(rooms).toContainEqual(WORKFLOW) + expect(rooms).toContainEqual(FILES) + + expect(await manager.getRoomForSocket('socket-1', ROOM_TYPES.WORKFLOW)).toEqual(WORKFLOW) + expect(await manager.getRoomForSocket('socket-1', ROOM_TYPES.WORKSPACE_FILES)).toEqual(FILES) + }) + + it('keeps the shared session alive when leaving one of two rooms (refcount)', async () => { + await manager.addUserToRoom(WORKFLOW, 'socket-1', presence(WORKFLOW, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + + const removed = await manager.removeUserFromRoom(WORKFLOW, 'socket-1') + expect(removed).toBe(true) + + // The files room and the shared session must survive. + expect(await manager.hasRoom(WORKFLOW)).toBe(false) + expect(await manager.hasRoom(FILES)).toBe(true) + expect(await manager.getUserSession('socket-1')).not.toBeNull() + expect(await manager.getRoomForSocket('socket-1', ROOM_TYPES.WORKFLOW)).toBeNull() + expect(await manager.getRoomForSocket('socket-1', ROOM_TYPES.WORKSPACE_FILES)).toEqual(FILES) + }) + + it('drops the shared session only when the last room is left', async () => { + await manager.addUserToRoom(WORKFLOW, 'socket-1', presence(WORKFLOW, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + + await manager.removeUserFromRoom(WORKFLOW, 'socket-1') + expect(await manager.getUserSession('socket-1')).not.toBeNull() + + await manager.removeUserFromRoom(FILES, 'socket-1') + expect(await manager.getUserSession('socket-1')).toBeNull() + expect(await manager.getRoomsForSocket('socket-1')).toHaveLength(0) + }) + + it('isolates presence between rooms of different types', async () => { + await manager.addUserToRoom(WORKFLOW, 'socket-1', presence(WORKFLOW, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-2', presence(FILES, 'socket-2', 'user-2')) + + expect(await manager.getRoomUsers(WORKFLOW)).toHaveLength(1) + expect(await manager.getRoomUsers(FILES)).toHaveLength(2) + }) + + it('removes a socket from every room on disconnect and reports them', async () => { + await manager.addUserToRoom(WORKFLOW, 'socket-1', presence(WORKFLOW, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-2', presence(FILES, 'socket-2', 'user-2')) + + const removed = await manager.removeSocketFromAllRooms('socket-1') + expect(removed).toHaveLength(2) + expect(removed).toContainEqual(WORKFLOW) + expect(removed).toContainEqual(FILES) + + expect(await manager.hasRoom(WORKFLOW)).toBe(false) + // The files room still has socket-2. + expect(await manager.getRoomUsers(FILES)).toHaveLength(1) + expect(await manager.getUserSession('socket-1')).toBeNull() + }) + + it('does not clobber another type when two sockets share a room', async () => { + await manager.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-2', presence(FILES, 'socket-2', 'user-2')) + + await manager.removeUserFromRoom(FILES, 'socket-1') + expect(await manager.hasRoom(FILES)).toBe(true) + expect(await manager.getUserSession('socket-2')).not.toBeNull() + }) + + it('sweepStalePresence reclaims not-live stale entries but keeps live and fresh ones', async () => { + const { io } = fakeIo(['socket-live']) + const m = new MemoryRoomManager(io) + await m.initialize() + + const staleMs = 76 * 60 * 1000 + await m.addUserToRoom(FILES, 'socket-live', presence(FILES, 'socket-live', 'u1')) + await m.addUserToRoom(FILES, 'socket-dead', { + ...presence(FILES, 'socket-dead', 'u2'), + joinedAt: Date.now() - staleMs, + lastActivity: Date.now() - staleMs, + }) + await m.addUserToRoom(FILES, 'socket-recent', presence(FILES, 'socket-recent', 'u3')) + + await sweepStalePresence(m, FILES) + + const remaining = (await m.getRoomUsers(FILES)).map((u) => u.socketId).sort() + // socket-dead: not live + stale → removed. socket-live: live → kept. + // socket-recent: not live but fresh (transient) → kept. + expect(remaining).toEqual(['socket-live', 'socket-recent']) + }) + + it('deleteRoom unconditionally drops all room state', async () => { + await manager.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + await manager.addUserToRoom(FILES, 'socket-2', presence(FILES, 'socket-2', 'user-2')) + expect(await manager.hasRoom(FILES)).toBe(true) + + await manager.deleteRoom(FILES) + + expect(await manager.hasRoom(FILES)).toBe(false) + expect(await manager.getRoomUsers(FILES)).toHaveLength(0) + }) + + it('ignores removal of a room the socket is not in (id-guarded)', async () => { + await manager.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + + // Removing a workflow room the socket never joined must be a no-op — it must + // not wipe the files mapping or the shared session. + const removed = await manager.removeUserFromRoom(WORKFLOW, 'socket-1') + expect(removed).toBe(false) + expect(await manager.hasRoom(FILES)).toBe(true) + expect(await manager.getUserSession('socket-1')).not.toBeNull() + expect(await manager.getRoomForSocket('socket-1', ROOM_TYPES.WORKSPACE_FILES)).toEqual(FILES) + }) + + it('broadcasts presence on the room-type-specific event name', async () => { + const { emit, io } = fakeIo() + const m = new MemoryRoomManager(io) + await m.initialize() + await m.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + + await m.broadcastPresenceUpdate(FILES) + expect(emit).toHaveBeenCalledWith('workspace-files:presence-update', expect.any(Array)) + + await m.broadcastPresenceUpdate(WORKFLOW) + expect(emit).toHaveBeenCalledWith('presence-update', expect.any(Array)) + }) + + it('omits an excluded socket from the presence broadcast (disconnect ghost guard)', async () => { + const { emit, io } = fakeIo() + const m = new MemoryRoomManager(io) + await m.initialize() + await m.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + await m.addUserToRoom(FILES, 'socket-2', presence(FILES, 'socket-2', 'user-2')) + + // Broadcast as if socket-1 is disconnecting: even though its presence entry is + // still present, it must not appear in the emitted list. + await m.broadcastPresenceUpdate(FILES, 'socket-1') + const emitted = emit.mock.calls.at(-1)?.[1] as Array<{ socketId: string }> + expect(emitted.map((u) => u.socketId)).toEqual(['socket-2']) + }) + + it('never emits a presence entry whose socket is no longer live (ghost guard)', async () => { + // Only socket-2 is a live Socket.IO member; socket-1 is an orphaned entry that + // outlived a failed removal. + const { emit, io } = fakeIo(['socket-2']) + const m = new MemoryRoomManager(io) + await m.initialize() + await m.addUserToRoom(FILES, 'socket-1', presence(FILES, 'socket-1', 'user-1')) + await m.addUserToRoom(FILES, 'socket-2', presence(FILES, 'socket-2', 'user-2')) + + await m.broadcastPresenceUpdate(FILES) + const emitted = emit.mock.calls.at(-1)?.[1] as Array<{ socketId: string }> + expect(emitted.map((u) => u.socketId)).toEqual(['socket-2']) + }) +}) diff --git a/apps/realtime/src/rooms/memory-manager.ts b/apps/realtime/src/rooms/memory-manager.ts index a0aeb9d1fea..f7ebed862ac 100644 --- a/apps/realtime/src/rooms/memory-manager.ts +++ b/apps/realtime/src/rooms/memory-manager.ts @@ -1,16 +1,30 @@ import { createLogger } from '@sim/logger' +import { + presenceEventName, + type RoomRef, + type RoomType, + roomName, +} from '@sim/realtime-protocol/rooms' import type { Server } from 'socket.io' -import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types' +import { filterVisiblePresence } from '@/rooms/presence-visibility' +import type { IRoomManager, RoomState, UserPresence, UserSession } from '@/rooms/types' const logger = createLogger('MemoryRoomManager') +/** Stable string key for a room in the local maps (distinct from the Socket.IO room name). */ +function roomKey(room: RoomRef): string { + return `${room.type}:${room.id}` +} + /** - * In-memory room manager for single-pod deployments - * Used as fallback when REDIS_URL is not configured + * In-memory room manager for single-pod deployments. Used when REDIS_URL is not + * configured. Domain-neutral: keyed by {@link RoomRef}, supports a socket in + * multiple rooms (one per {@link RoomType}). */ export class MemoryRoomManager implements IRoomManager { - private workflowRooms = new Map() - private socketToWorkflow = new Map() + private rooms = new Map() + /** socketId -> (roomType -> roomId) */ + private socketRooms = new Map>() private userSessions = new Map() private _io: Server @@ -31,235 +45,156 @@ export class MemoryRoomManager implements IRoomManager { } async shutdown(): Promise { - this.workflowRooms.clear() - this.socketToWorkflow.clear() + this.rooms.clear() + this.socketRooms.clear() this.userSessions.clear() logger.info('MemoryRoomManager shutdown complete') } - async addUserToRoom(workflowId: string, socketId: string, presence: UserPresence): Promise { - // Create room if it doesn't exist - if (!this.workflowRooms.has(workflowId)) { - this.workflowRooms.set(workflowId, { - workflowId, - users: new Map(), - lastModified: Date.now(), - activeConnections: 0, - }) + async addUserToRoom(room: RoomRef, socketId: string, presence: UserPresence): Promise { + const key = roomKey(room) + let state = this.rooms.get(key) + if (!state) { + state = { room, users: new Map(), lastModified: Date.now(), activeConnections: 0 } + this.rooms.set(key, state) } - const room = this.workflowRooms.get(workflowId)! - room.users.set(socketId, presence) - room.activeConnections++ - room.lastModified = Date.now() + state.users.set(socketId, presence) + state.activeConnections++ + state.lastModified = Date.now() - // Map socket to workflow - this.socketToWorkflow.set(socketId, workflowId) + let socketRoomMap = this.socketRooms.get(socketId) + if (!socketRoomMap) { + socketRoomMap = new Map() + this.socketRooms.set(socketId, socketRoomMap) + } + socketRoomMap.set(room.type, room.id) - // Store session this.userSessions.set(socketId, { userId: presence.userId, userName: presence.userName, avatarUrl: presence.avatarUrl, }) - logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`) + logger.debug(`Added user ${presence.userId} to room ${key} (socket: ${socketId})`) } - async removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise { - const currentWorkflowId = this.socketToWorkflow.get(socketId) ?? null - const workflowId = workflowIdHint ?? currentWorkflowId + async removeUserFromRoom(room: RoomRef, socketId: string): Promise { + const key = roomKey(room) + const state = this.rooms.get(key) + let existed = false - if (!workflowId) { - return null - } - - const room = this.workflowRooms.get(workflowId) - if (room) { - if (room.users.delete(socketId)) { - room.activeConnections = Math.max(0, room.activeConnections - 1) + if (state?.users.has(socketId)) { + existed = true + state.users.delete(socketId) + state.activeConnections = Math.max(0, state.activeConnections - 1) + if (state.users.size === 0) { + this.rooms.delete(key) + logger.info(`Cleaned up empty room: ${key}`) } + } - // Clean up empty rooms - if (room.activeConnections === 0) { - this.workflowRooms.delete(workflowId) - logger.info(`Cleaned up empty workflow room: ${workflowId}`) + const socketRoomMap = this.socketRooms.get(socketId) + if (socketRoomMap && socketRoomMap.get(room.type) === room.id) { + socketRoomMap.delete(room.type) + // Drop the shared session only when the socket has left its last room. + if (socketRoomMap.size === 0) { + this.socketRooms.delete(socketId) + this.userSessions.delete(socketId) } } - // Only clear the socket's own mappings when it is not mapped to a different - // room — removing a stale room's entry must not destroy the mapping of a - // room the socket has since moved to. - if (currentWorkflowId === null || currentWorkflowId === workflowId) { - this.socketToWorkflow.delete(socketId) + return existed + } + + async removeSocketFromAllRooms(socketId: string): Promise { + const socketRoomMap = this.socketRooms.get(socketId) + if (!socketRoomMap || socketRoomMap.size === 0) { this.userSessions.delete(socketId) + return [] + } + + const rooms: RoomRef[] = Array.from(socketRoomMap.entries()).map(([type, id]) => ({ type, id })) + for (const room of rooms) { + await this.removeUserFromRoom(room, socketId) } + // Belt-and-suspenders: ensure session is gone even if the map drifted. + this.socketRooms.delete(socketId) + this.userSessions.delete(socketId) + return rooms + } - logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`) - return workflowId + async getRoomsForSocket(socketId: string): Promise { + const socketRoomMap = this.socketRooms.get(socketId) + if (!socketRoomMap) return [] + return Array.from(socketRoomMap.entries()).map(([type, id]) => ({ type, id })) } - async getWorkflowIdForSocket(socketId: string): Promise { - return this.socketToWorkflow.get(socketId) ?? null + async getRoomForSocket(socketId: string, type: RoomType): Promise { + const id = this.socketRooms.get(socketId)?.get(type) + return id ? { type, id } : null } async getUserSession(socketId: string): Promise { return this.userSessions.get(socketId) ?? null } - async getWorkflowUsers(workflowId: string): Promise { - const room = this.workflowRooms.get(workflowId) - if (!room) return [] - return Array.from(room.users.values()) + async getRoomUsers(room: RoomRef): Promise { + const state = this.rooms.get(roomKey(room)) + if (!state) return [] + return Array.from(state.users.values()) } - async hasWorkflowRoom(workflowId: string): Promise { - return this.workflowRooms.has(workflowId) + async hasRoom(room: RoomRef): Promise { + return this.rooms.has(roomKey(room)) + } + + async deleteRoom(room: RoomRef): Promise { + this.rooms.delete(roomKey(room)) } async updateUserActivity( - workflowId: string, + room: RoomRef, socketId: string, - updates: Partial> + updates: Partial> ): Promise { - const room = this.workflowRooms.get(workflowId) - if (!room) return - - const presence = room.users.get(socketId) - if (presence) { - if (updates.cursor !== undefined) presence.cursor = updates.cursor - if (updates.selection !== undefined) presence.selection = updates.selection - presence.lastActivity = updates.lastActivity ?? Date.now() - } - } + const presence = this.rooms.get(roomKey(room))?.users.get(socketId) + if (!presence) return - async updateRoomLastModified(workflowId: string): Promise { - const room = this.workflowRooms.get(workflowId) - if (room) { - room.lastModified = Date.now() - } + if (updates.cursor !== undefined) presence.cursor = updates.cursor + if (updates.selection !== undefined) presence.selection = updates.selection + if (updates.cell !== undefined) presence.cell = updates.cell + presence.lastActivity = updates.lastActivity ?? Date.now() } - async broadcastPresenceUpdate(workflowId: string): Promise { - const users = await this.getWorkflowUsers(workflowId) - this._io.to(workflowId).emit('presence-update', users) + async updateRoomLastModified(room: RoomRef): Promise { + const state = this.rooms.get(roomKey(room)) + if (state) state.lastModified = Date.now() } - emitToWorkflow(workflowId: string, event: string, payload: T): void { - this._io.to(workflowId).emit(event, payload) + async broadcastPresenceUpdate(room: RoomRef, excludeSocketId?: string): Promise { + const users = await this.getRoomUsers(room) + const visible = await filterVisiblePresence(this._io, room, users, excludeSocketId) + this._io.to(roomName(room)).emit(presenceEventName(room.type), visible) } - async getUniqueUserCount(workflowId: string): Promise { - const room = this.workflowRooms.get(workflowId) - if (!room) return 0 + emitToRoom(room: RoomRef, event: string, payload: T): void { + this._io.to(roomName(room)).emit(event, payload) + } + async getUniqueUserCount(room: RoomRef): Promise { + const state = this.rooms.get(roomKey(room)) + if (!state) return 0 const uniqueUsers = new Set() - room.users.forEach((presence) => { - uniqueUsers.add(presence.userId) - }) - + state.users.forEach((presence) => uniqueUsers.add(presence.userId)) return uniqueUsers.size } async getTotalActiveConnections(): Promise { let total = 0 - for (const room of this.workflowRooms.values()) { - total += room.activeConnections + for (const state of this.rooms.values()) { + total += state.activeConnections } return total } - - async handleWorkflowDeletion(workflowId: string): Promise { - logger.info(`Handling workflow deletion notification for ${workflowId}`) - - const room = this.workflowRooms.get(workflowId) - if (!room) { - logger.debug(`No active room found for deleted workflow ${workflowId}`) - return - } - - this._io.to(workflowId).emit('workflow-deleted', { - workflowId, - message: 'This workflow has been deleted', - timestamp: Date.now(), - }) - - const socketsToDisconnect: string[] = [] - room.users.forEach((_presence, socketId) => { - socketsToDisconnect.push(socketId) - }) - - for (const socketId of socketsToDisconnect) { - const socket = this._io.sockets.sockets.get(socketId) - if (socket) { - socket.leave(workflowId) - logger.debug(`Disconnected socket ${socketId} from deleted workflow ${workflowId}`) - } - await this.removeUserFromRoom(socketId) - } - - this.workflowRooms.delete(workflowId) - logger.info( - `Cleaned up workflow room ${workflowId} after deletion (${socketsToDisconnect.length} users disconnected)` - ) - } - - async handleWorkflowRevert(workflowId: string, timestamp: number): Promise { - logger.info(`Handling workflow revert notification for ${workflowId}`) - - const room = this.workflowRooms.get(workflowId) - if (!room) { - logger.debug(`No active room found for reverted workflow ${workflowId}`) - return - } - - this._io.to(workflowId).emit('workflow-reverted', { - workflowId, - message: 'Workflow has been reverted to deployed state', - timestamp, - }) - - room.lastModified = timestamp - - logger.info(`Notified ${room.users.size} users about workflow revert: ${workflowId}`) - } - - async handleWorkflowUpdate(workflowId: string): Promise { - logger.info(`Handling workflow update notification for ${workflowId}`) - - const room = this.workflowRooms.get(workflowId) - if (!room) { - logger.debug(`No active room found for updated workflow ${workflowId}`) - return - } - - const timestamp = Date.now() - - this._io.to(workflowId).emit('workflow-updated', { - workflowId, - message: 'Workflow has been updated externally', - timestamp, - }) - - room.lastModified = timestamp - - logger.info(`Notified ${room.users.size} users about workflow update: ${workflowId}`) - } - - async handleWorkflowDeployed(workflowId: string): Promise { - logger.info(`Handling workflow deployed notification for ${workflowId}`) - - const room = this.workflowRooms.get(workflowId) - if (!room) { - logger.debug(`No active room found for deployed workflow ${workflowId}`) - return - } - - this._io.to(workflowId).emit('workflow-deployed', { - workflowId, - timestamp: Date.now(), - }) - - logger.info(`Notified ${room.users.size} users about workflow deployment change: ${workflowId}`) - } } diff --git a/apps/realtime/src/rooms/presence-visibility.ts b/apps/realtime/src/rooms/presence-visibility.ts new file mode 100644 index 00000000000..56c22a0e861 --- /dev/null +++ b/apps/realtime/src/rooms/presence-visibility.ts @@ -0,0 +1,84 @@ +import { type RoomRef, roomName } from '@sim/realtime-protocol/rooms' +import type { Server } from 'socket.io' +import type { IRoomManager, UserPresence } from '@/rooms/types' + +/** + * How stale a not-live presence entry must be before a join-time sweep reclaims + * it. The `liveIds` gate (not this threshold) is what protects an active + * collaborator; the threshold only bounds how long a genuinely-orphaned entry + * (e.g. a crashed pod that never fired `disconnecting`) lingers. Matches the + * workflow join sweep. + */ +const STALE_PRESENCE_THRESHOLD_MS = 75 * 60 * 1000 + +/** + * Filters a room's stored presence down to what should actually be broadcast: + * drops `excludeSocketId` (e.g. a socket mid-disconnect that is still connected), + * then reconciles against the live Socket.IO membership so an entry orphaned by a + * failed removal (the room hashes have no TTL) is never emitted as a ghost. + * + * Fail-safe: if the liveness lookup throws, or returns an empty set while we still + * hold presence entries (a cross-pod `fetchSockets` timeout, not a truly empty + * room), we emit the unfiltered list rather than hide everyone — a transient + * ghost self-corrects on the next broadcast, but hiding live collaborators would + * be a worse, visible failure. + */ +export async function filterVisiblePresence( + io: Server, + room: RoomRef, + users: T[], + excludeSocketId?: string +): Promise { + const candidates = excludeSocketId + ? users.filter((user) => user.socketId !== excludeSocketId) + : users + + try { + const liveSockets = await io.in(roomName(room)).fetchSockets() + if (liveSockets.length === 0) { + return candidates + } + const liveIds = new Set(liveSockets.map((socket) => socket.id)) + return candidates.filter((user) => liveIds.has(user.socketId)) + } catch { + return candidates + } +} + +/** + * Reclaims orphaned presence entries in a room: any stored socket that is no + * longer a live Socket.IO member AND has been idle past + * {@link STALE_PRESENCE_THRESHOLD_MS} is removed. This is how a room-users hash + * (which has no TTL) is bounded against ungraceful disconnects — a pod crash + * fires no `disconnecting` event, so its entries would otherwise persist forever. + * Run on join, like the workflow room does. No-op when the liveness lookup fails + * (so a transient adapter blip can't evict live collaborators). + */ +export async function sweepStalePresence( + manager: IRoomManager, + room: RoomRef +): Promise { + // Read the roster first so it is returned to the caller (for same-tab dedup) even when the + // liveness probe below fails — a fetchSockets outage must skip only the stale-removal, never + // the caller's dedup. + const users = await manager.getRoomUsers(room) + let liveIds: Set + try { + const liveSockets = await manager.io.in(roomName(room)).fetchSockets() + liveIds = new Set(liveSockets.map((socket) => socket.id)) + } catch { + return users + } + + const now = Date.now() + for (const user of users) { + if (liveIds.has(user.socketId)) continue + const lastSeen = user.lastActivity || user.joinedAt || 0 + if (now - lastSeen > STALE_PRESENCE_THRESHOLD_MS) { + await manager.removeUserFromRoom(room, user.socketId) + } + } + // Return the pre-removal roster so a caller can reuse it (e.g. same-tab dedup) instead + // of re-reading; re-removing an already-swept entry downstream is a harmless no-op. + return users +} diff --git a/apps/realtime/src/rooms/redis-manager.ts b/apps/realtime/src/rooms/redis-manager.ts index 4ed34d2dfa5..7282f4a6778 100644 --- a/apps/realtime/src/rooms/redis-manager.ts +++ b/apps/realtime/src/rooms/redis-manager.ts @@ -1,140 +1,154 @@ import { createLogger } from '@sim/logger' +import { + presenceEventName, + type RoomRef, + type RoomType, + roomName, +} from '@sim/realtime-protocol/rooms' import { createClient, type RedisClientType } from 'redis' import type { Server } from 'socket.io' +import { filterVisiblePresence } from '@/rooms/presence-visibility' import type { IRoomManager, UserPresence, UserSession } from '@/rooms/types' const logger = createLogger('RedisRoomManager') +/** + * Redis key scheme (all room-scoped keys are prefixed by room type): + * {type}:{id}:users HASH socketId -> UserPresence JSON (room membership) + * {type}:{id}:meta HASH room metadata (lastModified) + * socket:{sid}:rooms HASH roomType -> roomId (the socket's rooms, one per type) + * socket:{sid}:session HASH userId/userName/avatarUrl (shared across the socket's rooms) + * + * Workflow rooms keep their historical `workflow:{id}:users`/`:meta` keys (the + * type prefix IS `workflow`), so no presence-state migration is needed for them. + */ const KEYS = { - workflowUsers: (wfId: string) => `workflow:${wfId}:users`, - workflowMeta: (wfId: string) => `workflow:${wfId}:meta`, - socketWorkflow: (socketId: string) => `socket:${socketId}:workflow`, + roomUsers: (room: RoomRef) => `${room.type}:${room.id}:users`, + roomMeta: (room: RoomRef) => `${room.type}:${room.id}:meta`, + socketRooms: (socketId: string) => `socket:${socketId}:rooms`, socketSession: (socketId: string) => `socket:${socketId}:session`, - socketPresenceWorkflow: (socketId: string) => `socket:${socketId}:presence-workflow`, } as const -const SOCKET_KEY_TTL = 3600 -const SOCKET_PRESENCE_WORKFLOW_KEY_TTL = 24 * 60 * 60 +/** TTL for the socket's room-set. Long enough that an idle-but-connected socket is not evicted. */ +const SOCKET_ROOMS_TTL = 24 * 60 * 60 +/** + * The shared session key MUST share the room-set TTL. `getRoomForSocket` reads the room-set and the + * workflow handlers gate edits/presence on `room && session`, so a session that expired while the + * room-set is still alive would wedge an active-but-idle collaborator into "session expired" until a + * full reload — the activity update only `EXPIRE`s the session, which cannot resurrect an + * already-gone key (only `addUserToRoom` re-`HSET`s it). Both are refreshed together on every + * activity, so they expire together. + */ +const SESSION_TTL = SOCKET_ROOMS_TTL /** - * Lua script for atomic user removal from room. - * The hint, when provided, is the target room to remove membership from; the - * socket's current mapping is only the fallback. Socket-level keys are cleared - * only when the socket is not mapped to a different room, so removing a stale - * room cannot destroy the mapping of a room the socket has since moved to. - * Returns the target workflowId, or null when no target could be resolved. - * Handles room cleanup atomically to prevent race conditions. + * Atomic single-room removal. Removes a socket from one room's presence, drops + * the room from the socket's room-set, and — critically — deletes the SHARED + * session key only when the socket has left its LAST room (otherwise a leave from + * one room would break the socket's other rooms). Cleans up empty room state. + * + * KEYS: [socketRooms, socketSession, roomUsers, roomMeta] + * ARGV: [roomType, socketId, roomId] + * Returns 1 if the socket was a member of the room, else 0. */ -const REMOVE_USER_SCRIPT = ` -local socketWorkflowKey = KEYS[1] +const REMOVE_ROOM_SCRIPT = ` +local socketRoomsKey = KEYS[1] local socketSessionKey = KEYS[2] -local socketPresenceWorkflowKey = KEYS[3] -local workflowUsersPrefix = ARGV[1] -local workflowMetaPrefix = ARGV[2] -local socketId = ARGV[3] -local workflowIdHint = ARGV[4] - -local currentWorkflowId = redis.call('GET', socketWorkflowKey) -if not currentWorkflowId then - currentWorkflowId = redis.call('GET', socketPresenceWorkflowKey) +local roomUsersKey = KEYS[3] +local roomMetaKey = KEYS[4] +local roomType = ARGV[1] +local socketId = ARGV[2] +local roomId = ARGV[3] + +local removed = redis.call('HDEL', roomUsersKey, socketId) + +-- Only drop the socket's mapping for this type if it points at THIS room, so +-- removing a room the socket isn't in can't wipe a different room's mapping or +-- spuriously trigger the last-room session cleanup (mirrors the memory manager). +if redis.call('HGET', socketRoomsKey, roomType) == roomId then + redis.call('HDEL', socketRoomsKey, roomType) + if redis.call('HLEN', socketRoomsKey) == 0 then + redis.call('DEL', socketRoomsKey, socketSessionKey) + end end -local workflowId = currentWorkflowId -if workflowIdHint ~= '' then - workflowId = workflowIdHint +if redis.call('HLEN', roomUsersKey) == 0 then + redis.call('DEL', roomUsersKey, roomMetaKey) end -if not workflowId then - return nil -end - -local workflowUsersKey = workflowUsersPrefix .. workflowId .. ':users' -local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta' - -redis.call('HDEL', workflowUsersKey, socketId) - -if (not currentWorkflowId) or currentWorkflowId == workflowId then - redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey) -end - -local remaining = redis.call('HLEN', workflowUsersKey) -if remaining == 0 then - redis.call('DEL', workflowUsersKey, workflowMetaKey) -end - -return workflowId +return removed ` /** - * Lua script for atomic user activity update. - * Performs read-modify-write atomically to prevent lost updates. - * Also refreshes TTL on socket keys to prevent expiry during long sessions. + * Atomic presence-activity update (read-modify-write) that also refreshes the + * socket key TTLs to keep a long-lived session alive. + * + * KEYS: [roomUsers, socketRooms, socketSession] + * ARGV: [socketId, cursorJson, selectionJson, lastActivity, roomsTtl, sessionTtl, cellJson] + * Returns 1 if the socket had presence in the room, else 0. */ const UPDATE_ACTIVITY_SCRIPT = ` -local workflowUsersKey = KEYS[1] -local socketWorkflowKey = KEYS[2] +local roomUsersKey = KEYS[1] +local socketRoomsKey = KEYS[2] local socketSessionKey = KEYS[3] -local socketPresenceWorkflowKey = KEYS[4] local socketId = ARGV[1] local cursorJson = ARGV[2] local selectionJson = ARGV[3] local lastActivity = ARGV[4] -local ttl = tonumber(ARGV[5]) -local presenceWorkflowTtl = tonumber(ARGV[6]) +local roomsTtl = tonumber(ARGV[5]) +local sessionTtl = tonumber(ARGV[6]) +local cellJson = ARGV[7] -local existingJson = redis.call('HGET', workflowUsersKey, socketId) +local existingJson = redis.call('HGET', roomUsersKey, socketId) if not existingJson then return 0 end local existing = cjson.decode(existingJson) - if cursorJson ~= '' then existing.cursor = cjson.decode(cursorJson) end if selectionJson ~= '' then existing.selection = cjson.decode(selectionJson) end +if cellJson ~= '' then + existing.cell = cjson.decode(cellJson) +end existing.lastActivity = tonumber(lastActivity) -redis.call('HSET', workflowUsersKey, socketId, cjson.encode(existing)) -redis.call('EXPIRE', socketWorkflowKey, ttl) -redis.call('EXPIRE', socketSessionKey, ttl) -redis.call('EXPIRE', socketPresenceWorkflowKey, presenceWorkflowTtl) +redis.call('HSET', roomUsersKey, socketId, cjson.encode(existing)) +redis.call('EXPIRE', socketRoomsKey, roomsTtl) +redis.call('EXPIRE', socketSessionKey, sessionTtl) return 1 ` /** - * Redis-backed room manager for multi-pod deployments. - * Uses Lua scripts for atomic operations to prevent race conditions. + * Redis-backed room manager for multi-pod deployments. Domain-neutral: keyed by + * {@link RoomRef}, supports a socket in multiple rooms (one per {@link RoomType}). + * Uses Lua scripts for atomic multi-key operations. */ export class RedisRoomManager implements IRoomManager { private redis: RedisClientType private _io: Server private isConnected = false - private removeUserScriptSha: string | null = null + private removeRoomScriptSha: string | null = null private updateActivityScriptSha: string | null = null constructor(io: Server, redisUrl: string) { this._io = io - this.redis = createClient({ - url: redisUrl, - }) + this.redis = createClient({ url: redisUrl }) this.redis.on('error', (err) => { logger.error('Redis client error:', err) }) - this.redis.on('reconnecting', () => { logger.warn('Redis client reconnecting...') this.isConnected = false }) - this.redis.on('ready', () => { logger.info('Redis client ready') this.isConnected = true }) - this.redis.on('end', () => { logger.warn('Redis client connection closed') this.isConnected = false @@ -146,7 +160,14 @@ export class RedisRoomManager implements IRoomManager { } isReady(): boolean { - return this.isConnected + // Gate on the loaded script SHAs, not just the connection: the `ready` event flips + // `isConnected` true as soon as the socket connects — before `initialize()` loads the Lua + // scripts — so a bare `isConnected` check would report ready while `removeUserFromRoom` / + // `updateUserActivity` would silently no-op on a null SHA. Reporting not-ready here makes the + // POST endpoints return a retryable 503 during that startup window instead. + return ( + this.isConnected && this.removeRoomScriptSha !== null && this.updateActivityScriptSha !== null + ) } async initialize(): Promise { @@ -154,14 +175,18 @@ export class RedisRoomManager implements IRoomManager { try { await this.redis.connect() - this.isConnected = true - // Pre-load Lua scripts for better performance - this.removeUserScriptSha = await this.redis.scriptLoad(REMOVE_USER_SCRIPT) + this.removeRoomScriptSha = await this.redis.scriptLoad(REMOVE_ROOM_SCRIPT) this.updateActivityScriptSha = await this.redis.scriptLoad(UPDATE_ACTIVITY_SCRIPT) + // Mark ready only after the scripts load — isReady() gates removeUserFromRoom/updateUserActivity, + // which silently no-op without a script SHA. Setting the flag before scriptLoad would make + // isReady() lie if scriptLoad threw. + this.isConnected = true + logger.info('RedisRoomManager connected to Redis and scripts loaded') } catch (error) { + this.isConnected = false logger.error('Failed to connect to Redis:', error) throw error } @@ -169,7 +194,6 @@ export class RedisRoomManager implements IRoomManager { async shutdown(): Promise { if (!this.isConnected) return - try { await this.redis.quit() this.isConnected = false @@ -179,93 +203,102 @@ export class RedisRoomManager implements IRoomManager { } } - async addUserToRoom(workflowId: string, socketId: string, presence: UserPresence): Promise { + async addUserToRoom(room: RoomRef, socketId: string, presence: UserPresence): Promise { try { const pipeline = this.redis.multi() - pipeline.hSet(KEYS.workflowUsers(workflowId), socketId, JSON.stringify(presence)) - pipeline.hSet(KEYS.workflowMeta(workflowId), 'lastModified', Date.now().toString()) - pipeline.set(KEYS.socketWorkflow(socketId), workflowId) - pipeline.expire(KEYS.socketWorkflow(socketId), SOCKET_KEY_TTL) - pipeline.set(KEYS.socketPresenceWorkflow(socketId), workflowId) - pipeline.expire(KEYS.socketPresenceWorkflow(socketId), SOCKET_PRESENCE_WORKFLOW_KEY_TTL) + pipeline.hSet(KEYS.roomUsers(room), socketId, JSON.stringify(presence)) + pipeline.hSet(KEYS.roomMeta(room), 'lastModified', Date.now().toString()) + pipeline.hSet(KEYS.socketRooms(socketId), room.type, room.id) + pipeline.expire(KEYS.socketRooms(socketId), SOCKET_ROOMS_TTL) pipeline.hSet(KEYS.socketSession(socketId), { userId: presence.userId, userName: presence.userName, avatarUrl: presence.avatarUrl || '', }) - pipeline.expire(KEYS.socketSession(socketId), SOCKET_KEY_TTL) + pipeline.expire(KEYS.socketSession(socketId), SESSION_TTL) const results = await pipeline.exec() - // Check if any command failed const failed = results.some((result) => result instanceof Error) if (failed) { - logger.error(`Pipeline partially failed when adding user to room`, { workflowId, socketId }) + logger.error('Pipeline partially failed when adding user to room', { + room, + socketId, + }) throw new Error('Failed to store user session data in Redis') } - logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`) + logger.debug(`Added user ${presence.userId} to room ${room.type}:${room.id} (${socketId})`) } catch (error) { - logger.error(`Failed to add user to room: ${socketId} -> ${workflowId}`, error) + logger.error(`Failed to add user to room: ${socketId} -> ${room.type}:${room.id}`, error) throw error } } - async removeUserFromRoom( - socketId: string, - workflowIdHint?: string, - retried = false - ): Promise { - if (!this.removeUserScriptSha) { + async removeUserFromRoom(room: RoomRef, socketId: string, retried = false): Promise { + if (!this.removeRoomScriptSha) { logger.error('removeUserFromRoom called before initialize()') - return null + return false } try { - const workflowId = await this.redis.evalSha(this.removeUserScriptSha, { + const removed = await this.redis.evalSha(this.removeRoomScriptSha, { keys: [ - KEYS.socketWorkflow(socketId), + KEYS.socketRooms(socketId), KEYS.socketSession(socketId), - KEYS.socketPresenceWorkflow(socketId), + KEYS.roomUsers(room), + KEYS.roomMeta(room), ], - arguments: ['workflow:', 'workflow:', socketId, workflowIdHint ?? ''], + arguments: [room.type, socketId, room.id], }) - - if (typeof workflowId === 'string' && workflowId.length > 0) { - logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`) - return workflowId - } - - return null + return typeof removed === 'number' ? removed > 0 : Number(removed) > 0 } catch (error) { if ((error as Error).message?.includes('NOSCRIPT') && !retried) { logger.warn('Lua script not found, reloading...') - this.removeUserScriptSha = await this.redis.scriptLoad(REMOVE_USER_SCRIPT) - return this.removeUserFromRoom(socketId, workflowIdHint, true) + this.removeRoomScriptSha = await this.redis.scriptLoad(REMOVE_ROOM_SCRIPT) + return this.removeUserFromRoom(room, socketId, true) } - logger.error(`Failed to remove user from room: ${socketId}`, error) - return null + logger.error(`Failed to remove socket ${socketId} from room ${room.type}:${room.id}`, error) + return false } } - async getWorkflowIdForSocket(socketId: string): Promise { - const workflowId = await this.redis.get(KEYS.socketWorkflow(socketId)) - if (workflowId) { - return workflowId + async removeSocketFromAllRooms(socketId: string): Promise { + const rooms = await this.getRoomsForSocket(socketId) + if (rooms.length === 0) { + // Nothing tracked (already cleaned up or TTL-expired); ensure session is gone. + await this.redis.del(KEYS.socketSession(socketId)).catch(() => {}) + return [] } - return this.redis.get(KEYS.socketPresenceWorkflow(socketId)) + const removed: RoomRef[] = [] + for (const room of rooms) { + const wasMember = await this.removeUserFromRoom(room, socketId) + if (wasMember) removed.push(room) + } + return removed } - async getUserSession(socketId: string): Promise { + async getRoomsForSocket(socketId: string): Promise { try { - const session = await this.redis.hGetAll(KEYS.socketSession(socketId)) + const entries = await this.redis.hGetAll(KEYS.socketRooms(socketId)) + return Object.entries(entries).map(([type, id]) => ({ type: type as RoomType, id })) + } catch (error) { + logger.error(`Failed to get rooms for socket ${socketId}:`, error) + return [] + } + } - if (!session.userId) { - return null - } + async getRoomForSocket(socketId: string, type: RoomType): Promise { + const id = await this.redis.hGet(KEYS.socketRooms(socketId), type) + return id ? { type, id } : null + } + async getUserSession(socketId: string): Promise { + try { + const session = await this.redis.hGetAll(KEYS.socketSession(socketId)) + if (!session.userId) return null return { userId: session.userId, userName: session.userName, @@ -277,34 +310,54 @@ export class RedisRoomManager implements IRoomManager { } } - async getWorkflowUsers(workflowId: string): Promise { + /** + * Reads and parses the room roster. Throws on a transport error (so a caller can + * distinguish "genuinely empty" from "read failed"); a single corrupted entry is + * skipped, not fatal. + */ + private async readRoomUsers(room: RoomRef): Promise { + const users = await this.redis.hGetAll(KEYS.roomUsers(room)) + return Object.entries(users) + .map(([socketId, json]) => { + try { + return JSON.parse(json) as UserPresence + } catch { + logger.warn(`Corrupted user data for socket ${socketId}, skipping`) + return null + } + }) + .filter((u): u is UserPresence => u !== null) + } + + async getRoomUsers(room: RoomRef): Promise { try { - const users = await this.redis.hGetAll(KEYS.workflowUsers(workflowId)) - return Object.entries(users) - .map(([socketId, json]) => { - try { - return JSON.parse(json) as UserPresence - } catch { - logger.warn(`Corrupted user data for socket ${socketId}, skipping`) - return null - } - }) - .filter((u): u is UserPresence => u !== null) + return await this.readRoomUsers(room) } catch (error) { - logger.error(`Failed to get workflow users for ${workflowId}:`, error) + logger.error(`Failed to get room users for ${room.type}:${room.id}:`, error) return [] } } - async hasWorkflowRoom(workflowId: string): Promise { - const exists = await this.redis.exists(KEYS.workflowUsers(workflowId)) + async hasRoom(room: RoomRef): Promise { + const exists = await this.redis.exists(KEYS.roomUsers(room)) return exists > 0 } + async deleteRoom(room: RoomRef): Promise { + // Log AND rethrow (like addUserToRoom): a failed wipe must not be reported as a + // clean deletion by the caller — the request surfaces it (and can be retried). + try { + await this.redis.del([KEYS.roomUsers(room), KEYS.roomMeta(room)]) + } catch (error) { + logger.error(`Failed to delete room ${room.type}:${room.id}:`, error) + throw error + } + } + async updateUserActivity( - workflowId: string, + room: RoomRef, socketId: string, - updates: Partial>, + updates: Partial>, retried = false ): Promise { if (!this.updateActivityScriptSha) { @@ -314,155 +367,64 @@ export class RedisRoomManager implements IRoomManager { try { await this.redis.evalSha(this.updateActivityScriptSha, { - keys: [ - KEYS.workflowUsers(workflowId), - KEYS.socketWorkflow(socketId), - KEYS.socketSession(socketId), - KEYS.socketPresenceWorkflow(socketId), - ], + keys: [KEYS.roomUsers(room), KEYS.socketRooms(socketId), KEYS.socketSession(socketId)], arguments: [ socketId, updates.cursor !== undefined ? JSON.stringify(updates.cursor) : '', updates.selection !== undefined ? JSON.stringify(updates.selection) : '', (updates.lastActivity ?? Date.now()).toString(), - SOCKET_KEY_TTL.toString(), - SOCKET_PRESENCE_WORKFLOW_KEY_TTL.toString(), + SOCKET_ROOMS_TTL.toString(), + SESSION_TTL.toString(), + // Trailing arg (ARGV[7]) so existing indices stay stable. `null` (cleared + // selection) serializes to 'null'; `undefined` (no cell change) to '' (skip). + updates.cell !== undefined ? JSON.stringify(updates.cell) : '', ], }) } catch (error) { if ((error as Error).message?.includes('NOSCRIPT') && !retried) { logger.warn('Lua script not found, reloading...') this.updateActivityScriptSha = await this.redis.scriptLoad(UPDATE_ACTIVITY_SCRIPT) - return this.updateUserActivity(workflowId, socketId, updates, true) + return this.updateUserActivity(room, socketId, updates, true) } logger.error(`Failed to update user activity: ${socketId}`, error) } } - async updateRoomLastModified(workflowId: string): Promise { - await this.redis.hSet(KEYS.workflowMeta(workflowId), 'lastModified', Date.now().toString()) - } - - async broadcastPresenceUpdate(workflowId: string): Promise { - const users = await this.getWorkflowUsers(workflowId) - // io.to() with Redis adapter broadcasts to all pods - this._io.to(workflowId).emit('presence-update', users) - } - - emitToWorkflow(workflowId: string, event: string, payload: T): void { - this._io.to(workflowId).emit(event, payload) - } - - async getUniqueUserCount(workflowId: string): Promise { - const users = await this.getWorkflowUsers(workflowId) - const uniqueUserIds = new Set(users.map((u) => u.userId)) - return uniqueUserIds.size + async updateRoomLastModified(room: RoomRef): Promise { + await this.redis.hSet(KEYS.roomMeta(room), 'lastModified', Date.now().toString()) } - async getTotalActiveConnections(): Promise { - // This is more complex with Redis - we'd need to scan all workflow:*:users keys - // For now, just count sockets in this server instance - // The true count would require aggregating across all pods - return this._io.sockets.sockets.size - } - - async handleWorkflowDeletion(workflowId: string): Promise { - logger.info(`Handling workflow deletion notification for ${workflowId}`) - + async broadcastPresenceUpdate(room: RoomRef, excludeSocketId?: string): Promise { + let users: UserPresence[] try { - const users = await this.getWorkflowUsers(workflowId) - if (users.length === 0) { - logger.debug(`No active users found for deleted workflow ${workflowId}`) - return - } - - // Notify all clients across all pods via Redis adapter - this._io.to(workflowId).emit('workflow-deleted', { - workflowId, - message: 'This workflow has been deleted', - timestamp: Date.now(), - }) - - // Use Socket.IO's cross-pod socketsLeave() to remove all sockets from the room - // This works across all pods when using the Redis adapter - await this._io.in(workflowId).socketsLeave(workflowId) - logger.debug(`All sockets left workflow room ${workflowId} via socketsLeave()`) - - // Remove all users from Redis state - for (const user of users) { - await this.removeUserFromRoom(user.socketId, workflowId) - } - - // Clean up room data - await this.redis.del([KEYS.workflowUsers(workflowId), KEYS.workflowMeta(workflowId)]) - - logger.info( - `Cleaned up workflow room ${workflowId} after deletion (${users.length} users disconnected)` - ) + // Read via the throwing variant, NOT getRoomUsers: a transport error there returns `[]`, which + // would broadcast an empty roster and clear every remaining collaborator's presence until the + // next healthy update. Skip instead — the next successful join/activity broadcast (or the + // stale sweep) reconciles peers. + users = await this.readRoomUsers(room) } catch (error) { - logger.error(`Failed to handle workflow deletion for ${workflowId}:`, error) - } - } - - async handleWorkflowRevert(workflowId: string, timestamp: number): Promise { - logger.info(`Handling workflow revert notification for ${workflowId}`) - - const hasRoom = await this.hasWorkflowRoom(workflowId) - if (!hasRoom) { - logger.debug(`No active room found for reverted workflow ${workflowId}`) + logger.error( + `Skipping presence broadcast for ${room.type}:${room.id} (roster read failed):`, + error + ) return } - - this._io.to(workflowId).emit('workflow-reverted', { - workflowId, - message: 'Workflow has been reverted to deployed state', - timestamp, - }) - - await this.updateRoomLastModified(workflowId) - - const userCount = await this.getUniqueUserCount(workflowId) - logger.info(`Notified ${userCount} users about workflow revert: ${workflowId}`) + const visible = await filterVisiblePresence(this._io, room, users, excludeSocketId) + // io.to() with the Redis adapter broadcasts to all pods. + this._io.to(roomName(room)).emit(presenceEventName(room.type), visible) } - async handleWorkflowUpdate(workflowId: string): Promise { - logger.info(`Handling workflow update notification for ${workflowId}`) - - const hasRoom = await this.hasWorkflowRoom(workflowId) - if (!hasRoom) { - logger.debug(`No active room found for updated workflow ${workflowId}`) - return - } - - const timestamp = Date.now() - - this._io.to(workflowId).emit('workflow-updated', { - workflowId, - message: 'Workflow has been updated externally', - timestamp, - }) - - await this.updateRoomLastModified(workflowId) - - const userCount = await this.getUniqueUserCount(workflowId) - logger.info(`Notified ${userCount} users about workflow update: ${workflowId}`) + emitToRoom(room: RoomRef, event: string, payload: T): void { + this._io.to(roomName(room)).emit(event, payload) } - async handleWorkflowDeployed(workflowId: string): Promise { - logger.info(`Handling workflow deployed notification for ${workflowId}`) - - const hasRoom = await this.hasWorkflowRoom(workflowId) - if (!hasRoom) { - logger.debug(`No active room found for deployed workflow ${workflowId}`) - return - } - - this._io.to(workflowId).emit('workflow-deployed', { - workflowId, - timestamp: Date.now(), - }) + async getUniqueUserCount(room: RoomRef): Promise { + const users = await this.getRoomUsers(room) + return new Set(users.map((u) => u.userId)).size + } - const userCount = await this.getUniqueUserCount(workflowId) - logger.info(`Notified ${userCount} users about workflow deployment change: ${workflowId}`) + async getTotalActiveConnections(): Promise { + // Local instance only; the true cross-pod count would require aggregation. + return this._io.sockets.sockets.size } } diff --git a/apps/realtime/src/rooms/types.ts b/apps/realtime/src/rooms/types.ts index 0447516805e..f1a67d66abb 100644 --- a/apps/realtime/src/rooms/types.ts +++ b/apps/realtime/src/rooms/types.ts @@ -1,11 +1,16 @@ +import type { RoomRef, RoomType } from '@sim/realtime-protocol/rooms' +import type { TableCellSelection } from '@sim/realtime-protocol/table-presence' import type { Server } from 'socket.io' /** - * User presence data stored in room state + * User presence data stored in room state. + * + * `room` is the generic room address (see `@sim/realtime-protocol/rooms`). A + * socket may hold presence in more than one room, but only one room per type. */ export interface UserPresence { userId: string - workflowId: string + room: RoomRef userName: string socketId: string tabSessionId?: string @@ -14,11 +19,19 @@ export interface UserPresence { role: string cursor?: { x: number; y: number } selection?: { type: 'block' | 'edge' | 'none'; id?: string } + /** The viewer's current table cell selection, for table presence rooms. */ + cell?: TableCellSelection avatarUrl?: string | null + /** + * The subfolder the user is viewing, recorded at join for room types that track + * a per-viewer location (e.g. the workspace file browser). `null` is the root. + */ + folderId?: string | null } /** - * User session data (minimal info for quick lookups) + * User session data (minimal info for quick lookups). Shared across all rooms a + * socket is in — keyed by socket, not by room. */ export interface UserSession { userId: string @@ -27,125 +40,101 @@ export interface UserSession { } /** - * Workflow room state + * Room presence state. */ -export interface WorkflowRoom { - workflowId: string +export interface RoomState { + room: RoomRef users: Map lastModified: number activeConnections: number } /** - * Common interface for room managers (in-memory and Redis) - * All methods that access state are async to support Redis operations + * Common interface for room managers (in-memory and Redis). + * + * The manager is domain-neutral: it tracks room membership and presence keyed by + * {@link RoomRef}, and knows nothing about workflows, files, or any specific + * domain. Domain lifecycle concerns (e.g. workflow deletion/deploy broadcasts) + * live in domain services that compose a manager — see `WorkflowRoomService`. + * + * A socket may occupy multiple rooms, at most one per {@link RoomType}. The + * shared session key is dropped only when a socket leaves its last room. + * + * All state-accessing methods are async to support the Redis implementation. */ export interface IRoomManager { readonly io: Server - /** - * Initialize the room manager (connect to Redis, etc.) - */ + /** Initialize the manager (connect to Redis, load scripts, etc.). */ initialize(): Promise - /** - * Whether the room manager is ready to serve requests - */ + /** Whether the manager is ready to serve requests. */ isReady(): boolean - /** - * Clean shutdown - */ + /** Clean shutdown. */ shutdown(): Promise - /** - * Add a user to a workflow room - */ - addUserToRoom(workflowId: string, socketId: string, presence: UserPresence): Promise + /** Add a socket's presence to a room. */ + addUserToRoom(room: RoomRef, socketId: string, presence: UserPresence): Promise /** - * Remove a user's membership of a workflow room. - * When workflowIdHint is provided it is the target room; the socket's current - * mapping is only the fallback (and covers missing/expired mapping keys). - * Socket-level mappings are cleared only when the socket is not mapped to a - * different room, so removing a stale room cannot destroy the mapping of a - * room the socket has since moved to. - * Returns the target workflowId, or null when no target could be resolved - * (or, for the Redis manager, when the removal failed). + * Remove a socket from a single room. Returns `true` if it was a member. The + * shared session is dropped only if this was the socket's last room. */ - removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise + removeUserFromRoom(room: RoomRef, socketId: string): Promise /** - * Get the workflow ID for a socket + * Remove a socket from every room it occupies (disconnect). Returns the rooms + * it was in, so the caller can rebroadcast presence per room. */ - getWorkflowIdForSocket(socketId: string): Promise + removeSocketFromAllRooms(socketId: string): Promise - /** - * Get user session data for a socket - */ + /** Every room the socket currently occupies. */ + getRoomsForSocket(socketId: string): Promise + + /** The socket's room of a given type (at most one per type), or `null`. */ + getRoomForSocket(socketId: string, type: RoomType): Promise + + /** Session data for a socket (shared across its rooms). */ getUserSession(socketId: string): Promise - /** - * Get all users in a workflow room - */ - getWorkflowUsers(workflowId: string): Promise + /** All users present in a room. */ + getRoomUsers(room: RoomRef): Promise - /** - * Check if a workflow room exists - */ - hasWorkflowRoom(workflowId: string): Promise + /** Whether a room currently has any presence. */ + hasRoom(room: RoomRef): Promise /** - * Update user activity (cursor, selection, lastActivity) + * Unconditionally drop all state for a room (presence + metadata). Used when a + * room's underlying resource is destroyed (e.g. a deleted workflow) to guarantee + * no state lingers even if per-socket removals failed or a socket joined mid-teardown. */ + deleteRoom(room: RoomRef): Promise + + /** Update a socket's activity (cursor, selection, cell, lastActivity) within a room. */ updateUserActivity( - workflowId: string, + room: RoomRef, socketId: string, - updates: Partial> + updates: Partial> ): Promise - /** - * Update room's lastModified timestamp - */ - updateRoomLastModified(workflowId: string): Promise + /** Bump a room's lastModified timestamp. */ + updateRoomLastModified(room: RoomRef): Promise /** - * Broadcast presence update to all clients in a workflow room + * Broadcast the room's presence list to all clients in the room. Pass + * `excludeSocketId` (e.g. a disconnecting socket) to omit that socket from the + * broadcast even if its presence entry outlived a failed removal — so it is + * never shown as a ghost collaborator. */ - broadcastPresenceUpdate(workflowId: string): Promise + broadcastPresenceUpdate(room: RoomRef, excludeSocketId?: string): Promise - /** - * Emit an event to all clients in a workflow room - */ - emitToWorkflow(workflowId: string, event: string, payload: T): void + /** Emit an event to all clients in a room. */ + emitToRoom(room: RoomRef, event: string, payload: T): void - /** - * Get the number of unique users in a workflow room - */ - getUniqueUserCount(workflowId: string): Promise + /** Number of unique users in a room. */ + getUniqueUserCount(room: RoomRef): Promise - /** - * Get total active connections across all rooms - */ + /** Total active connections tracked by this instance. */ getTotalActiveConnections(): Promise - - /** - * Handle workflow deletion - notify users and clean up room - */ - handleWorkflowDeletion(workflowId: string): Promise - - /** - * Handle workflow revert - notify users - */ - handleWorkflowRevert(workflowId: string, timestamp: number): Promise - - /** - * Handle workflow update - notify users - */ - handleWorkflowUpdate(workflowId: string): Promise - - /** - * Handle workflow deployment change - notify users to refresh deployment state - */ - handleWorkflowDeployed(workflowId: string): Promise } diff --git a/apps/realtime/src/rooms/workflow-room-service.ts b/apps/realtime/src/rooms/workflow-room-service.ts new file mode 100644 index 00000000000..165224b08c4 --- /dev/null +++ b/apps/realtime/src/rooms/workflow-room-service.ts @@ -0,0 +1,126 @@ +import { createLogger } from '@sim/logger' +import { ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' +import type { IRoomManager } from '@/rooms/types' + +const logger = createLogger('WorkflowRoomService') + +/** The workflow room ref for a workflow id. Its Socket.IO room name is the bare id. */ +export function workflowRoom(workflowId: string): RoomRef { + return { type: ROOM_TYPES.WORKFLOW, id: workflowId } +} + +/** + * Workflow-domain lifecycle broadcasts, composed over a domain-neutral + * {@link IRoomManager}. Keeps workflow-specific concerns (deletion, revert, + * update, deploy notifications) out of the generic manager, mirroring how the + * workflow socket handlers own workflow semantics. + */ +export class WorkflowRoomService { + constructor(private readonly manager: IRoomManager) {} + + async handleWorkflowDeletion(workflowId: string): Promise { + logger.info(`Handling workflow deletion notification for ${workflowId}`) + const room = workflowRoom(workflowId) + + const name = roomName(room) + + // Always notify — reach every socket still in the Socket.IO room so the client + // clears the deleted workflow, even if that socket's Redis presence was evicted + // (in which case it would be missing from getRoomUsers). Emitting to an empty + // room is a harmless no-op. + this.manager.emitToRoom(room, 'workflow-deleted', { + workflowId, + message: 'This workflow has been deleted', + timestamp: Date.now(), + }) + + // Clean per-socket state for every socket that is either a live Socket.IO member + // OR still has presence — so an evicted/late-joined socket's room mapping and + // session are dropped too, not just the presence-tracked ones. + const socketIds = new Set() + try { + const liveSockets = await this.manager.io.in(name).fetchSockets() + for (const s of liveSockets) socketIds.add(s.id) + } catch (error) { + logger.warn(`Could not enumerate sockets for deleted workflow ${workflowId}`, error) + } + for (const user of await this.manager.getRoomUsers(room)) socketIds.add(user.socketId) + + // Remove every socket from the Socket.IO room (cross-pod via the Redis adapter). + await this.manager.io.in(name).socketsLeave(name) + + // Independent per-socket removals — run concurrently. + await Promise.all( + Array.from(socketIds, (socketId) => this.manager.removeUserFromRoom(room, socketId)) + ) + + // Final unconditional wipe — the workflow is gone, so no room state may linger + // even if a per-socket removal failed (matches the pre-refactor managers, which + // ended deletion with an unconditional room drop). + await this.manager.deleteRoom(room) + + logger.info( + `Cleaned up workflow room ${workflowId} after deletion (${socketIds.size} sockets removed)` + ) + } + + async handleWorkflowRevert(workflowId: string, timestamp: number): Promise { + logger.info(`Handling workflow revert notification for ${workflowId}`) + const room = workflowRoom(workflowId) + + if (!(await this.manager.hasRoom(room))) { + logger.debug(`No active room found for reverted workflow ${workflowId}`) + return + } + + this.manager.emitToRoom(room, 'workflow-reverted', { + workflowId, + message: 'Workflow has been reverted to deployed state', + timestamp, + }) + + await this.manager.updateRoomLastModified(room) + + const userCount = await this.manager.getUniqueUserCount(room) + logger.info(`Notified ${userCount} users about workflow revert: ${workflowId}`) + } + + async handleWorkflowUpdate(workflowId: string): Promise { + logger.info(`Handling workflow update notification for ${workflowId}`) + const room = workflowRoom(workflowId) + + if (!(await this.manager.hasRoom(room))) { + logger.debug(`No active room found for updated workflow ${workflowId}`) + return + } + + this.manager.emitToRoom(room, 'workflow-updated', { + workflowId, + message: 'Workflow has been updated externally', + timestamp: Date.now(), + }) + + await this.manager.updateRoomLastModified(room) + + const userCount = await this.manager.getUniqueUserCount(room) + logger.info(`Notified ${userCount} users about workflow update: ${workflowId}`) + } + + async handleWorkflowDeployed(workflowId: string): Promise { + logger.info(`Handling workflow deployed notification for ${workflowId}`) + const room = workflowRoom(workflowId) + + if (!(await this.manager.hasRoom(room))) { + logger.debug(`No active room found for deployed workflow ${workflowId}`) + return + } + + this.manager.emitToRoom(room, 'workflow-deployed', { + workflowId, + timestamp: Date.now(), + }) + + const userCount = await this.manager.getUniqueUserCount(room) + logger.info(`Notified ${userCount} users about workflow deployment change: ${workflowId}`) + } +} diff --git a/apps/realtime/src/routes/http.test.ts b/apps/realtime/src/routes/http.test.ts new file mode 100644 index 00000000000..725341deac9 --- /dev/null +++ b/apps/realtime/src/routes/http.test.ts @@ -0,0 +1,61 @@ +import type { IncomingMessage, ServerResponse } from 'http' +import { describe, expect, it, vi } from 'vitest' +import type { IRoomManager } from '@/rooms' +import { createHttpHandler } from '@/routes/http' + +function createMocks(req: Partial) { + const setHeader = vi.fn() + const writeHead = vi.fn() + const end = vi.fn() + const logger = { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() } + const roomManager = { + getTotalActiveConnections: vi.fn().mockResolvedValue(0), + isReady: vi.fn().mockReturnValue(true), + } as unknown as IRoomManager + + return { + handler: createHttpHandler(roomManager, logger), + req: { headers: {}, ...req } as IncomingMessage, + res: { setHeader, writeHead, end } as unknown as ServerResponse, + setHeader, + writeHead, + end, + } +} + +describe('createHttpHandler', () => { + /** + * `/health` is the only route on this server that returns 200 with a body, so + * it is the only genuinely indexable surface on the `sockets.*` hostnames. + * Node merges `setHeader` values into `writeHead`, and no branch here sets + * `X-Robots-Tag`, so the handler-level call reaches every response. + */ + it.each([ + ['health check', { method: 'GET', url: '/health' }], + ['unmatched route', { method: 'GET', url: '/' }], + ['unauthenticated internal API call', { method: 'POST', url: '/api/workflow-deleted' }], + ])('marks the %s noindex', async (_label, req) => { + const { handler, req: request, res, setHeader } = createMocks(req) + + await handler(request, res) + + expect(setHeader).toHaveBeenCalledWith('X-Robots-Tag', 'noindex, nofollow') + }) + + it('still serves the unmatched-route 404 unchanged', async () => { + const { handler, req, res, writeHead, end } = createMocks({ method: 'GET', url: '/' }) + + await handler(req, res) + + expect(writeHead).toHaveBeenCalledWith(404, { 'Content-Type': 'application/json' }) + expect(end).toHaveBeenCalledWith(JSON.stringify({ error: 'Not found' })) + }) + + it('still serves the health check as 200', async () => { + const { handler, req, res, writeHead } = createMocks({ method: 'GET', url: '/health' }) + + await handler(req, res) + + expect(writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' }) + }) +}) diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index 0f8ed73cc52..aed7d1a58a9 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -1,7 +1,9 @@ import type { IncomingMessage, ServerResponse } from 'http' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { safeCompare } from '@sim/security/compare' import { env } from '@/env' -import type { IRoomManager } from '@/rooms' +import { applyMarkdownToLiveFileDoc } from '@/handlers/file-doc' +import { type IRoomManager, WorkflowRoomService } from '@/rooms' interface Logger { info: (message: string, ...args: unknown[]) => void @@ -41,6 +43,10 @@ function readRequestBody(req: IncomingMessage): Promise { }) } +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + function sendSuccess(res: ServerResponse): void { res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: true })) @@ -58,7 +64,11 @@ function sendError(res: ServerResponse, message: string, status = 500): void { * @returns HTTP request handler function */ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { + const workflowRoomService = new WorkflowRoomService(roomManager) + return async (req: IncomingMessage, res: ServerResponse) => { + res.setHeader('X-Robots-Tag', 'noindex, nofollow') + // Health check doesn't require auth if (req.method === 'GET' && req.url === '/health') { try { @@ -99,7 +109,8 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { try { const body = await readRequestBody(req) const { workflowId } = JSON.parse(body) - await roomManager.handleWorkflowDeletion(workflowId) + if (!isNonEmptyString(workflowId)) return sendError(res, 'Invalid workflowId', 400) + await workflowRoomService.handleWorkflowDeletion(workflowId) sendSuccess(res) } catch (error) { logger.error('Error handling workflow deletion notification:', error) @@ -113,7 +124,8 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { try { const body = await readRequestBody(req) const { workflowId } = JSON.parse(body) - await roomManager.handleWorkflowUpdate(workflowId) + if (!isNonEmptyString(workflowId)) return sendError(res, 'Invalid workflowId', 400) + await workflowRoomService.handleWorkflowUpdate(workflowId) sendSuccess(res) } catch (error) { logger.error('Error handling workflow update notification:', error) @@ -127,7 +139,8 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { try { const body = await readRequestBody(req) const { workflowId } = JSON.parse(body) - await roomManager.handleWorkflowDeployed(workflowId) + if (!isNonEmptyString(workflowId)) return sendError(res, 'Invalid workflowId', 400) + await workflowRoomService.handleWorkflowDeployed(workflowId) sendSuccess(res) } catch (error) { logger.error('Error handling workflow deployed notification:', error) @@ -141,7 +154,8 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { try { const body = await readRequestBody(req) const { workflowId, timestamp } = JSON.parse(body) - await roomManager.handleWorkflowRevert(workflowId, timestamp) + if (!isNonEmptyString(workflowId)) return sendError(res, 'Invalid workflowId', 400) + await workflowRoomService.handleWorkflowRevert(workflowId, timestamp) sendSuccess(res) } catch (error) { logger.error('Error handling workflow revert notification:', error) @@ -150,6 +164,73 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return } + // Fan out a file-tree change to everyone viewing a workspace's files, so their + // browser refetches. File mutations happen over the HTTP API (not the socket); + // this is the lossy liveness signal — a missed one only means stale-until-refetch. + if (req.method === 'POST' && req.url === '/api/workspace-files-changed') { + try { + const body = await readRequestBody(req) + const { workspaceId } = JSON.parse(body) + if (!isNonEmptyString(workspaceId)) return sendError(res, 'Invalid workspaceId', 400) + roomManager.emitToRoom( + { type: ROOM_TYPES.WORKSPACE_FILES, id: workspaceId }, + 'workspace-files-changed', + { workspaceId, timestamp: Date.now() } + ) + sendSuccess(res) + } catch (error) { + logger.error('Error handling workspace files changed notification:', error) + sendError(res, 'Failed to process files change notification') + } + return + } + + // Fan out a table-list change to everyone viewing a workspace's tables, so their browser + // refetches. The list-level counterpart to workspace-files-changed; same lossy-signal contract. + if (req.method === 'POST' && req.url === '/api/workspace-tables-changed') { + try { + const body = await readRequestBody(req) + const { workspaceId } = JSON.parse(body) + if (!isNonEmptyString(workspaceId)) return sendError(res, 'Invalid workspaceId', 400) + roomManager.emitToRoom( + { type: ROOM_TYPES.WORKSPACE_TABLES, id: workspaceId }, + 'workspace-tables-changed', + { workspaceId, timestamp: Date.now() } + ) + sendSuccess(res) + } catch (error) { + logger.error('Error handling workspace tables changed notification:', error) + sendError(res, 'Failed to process tables change notification') + } + return + } + + // Merge a durable file write into a file's LIVE collaborative document so open editors reconcile to + // it (Stage C) — this is the stream-end/durable reconcile, not token-by-token streaming (that is now + // applied client-side by the open editor). Returns `{ applied }`: when false, no seeded live room + // exists and the caller writes the file directly instead. Live user edits are preserved — the app + // builds a minimal CRDT diff. + if (req.method === 'POST' && req.url === '/api/file-doc/apply-edit') { + try { + const body = await readRequestBody(req) + const { fileId, markdown, version } = JSON.parse(body) + if (!isNonEmptyString(fileId) || typeof markdown !== 'string') { + return sendError(res, 'Invalid fileId or markdown', 400) + } + // `version` (the durable updatedAt this markdown was written with) records that the live doc now + // incorporates that durable version, so the persist If-Match guard won't flag it as a conflict. + const result = await applyMarkdownToLiveFileDoc(fileId, markdown, { + version: typeof version === 'number' ? version : undefined, + }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ applied: result === 'applied' })) + } catch (error) { + logger.error('Error applying copilot edit to live file-doc:', error) + sendError(res, 'Failed to apply edit to live document') + } + return + } + res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Not found' })) } diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 3c2dcf8f229..db177410995 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -21,6 +21,10 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. +# Chat (Optional) +# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run +# NEXT_PUBLIC_CHAT_DISABLED=true # Hides the Chat module: the workspace lands on your first workflow, and the chats list, scheduled tasks, and editor Chat panel are absent. Chat is shown when unset; `bun run setup` sets this for you if you skip the chat key + # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes diff --git a/apps/sim/app/(landing)/integrations/(shell)/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/page.tsx index c2a5dfd1356..75760b22f41 100644 --- a/apps/sim/app/(landing)/integrations/(shell)/page.tsx +++ b/apps/sim/app/(landing)/integrations/(shell)/page.tsx @@ -6,9 +6,9 @@ import { type FAQItem, INTEGRATIONS, type Integration, - POPULAR_WORKFLOWS, toIntegrationSummary, } from '@/lib/integrations' +import { POPULAR_WORKFLOWS } from '@/lib/integrations/popular-workflows' import { withFilteredNoindex } from '@/lib/landing/seo' import { JsonLd } from '@/app/(landing)/components/json-ld' import { LandingFAQ } from '@/app/(landing)/components/landing-faq' diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index d1a5cdd0b46..c016a87c49c 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -17,7 +17,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants' import { parseInstagramLongLivedToken, parseInstagramProfile, @@ -25,6 +24,7 @@ import { } from '@/lib/oauth/instagram' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' +import { INSTAGRAM_GRAPH_BASE } from '@/tools/instagram/constants' const logger = createLogger('InstagramCallback') diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index c99e3ff9f6e..c4791882bb4 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -11,6 +11,8 @@ import { renderPasswordResetEmail, renderPaymentFailedEmail, renderPlanWelcomeEmail, + renderScheduleDisabledEmail, + renderUsageLimitReachedEmail, renderUsageThresholdEmail, renderWelcomeEmail, renderWorkspaceInvitationEmail, @@ -93,6 +95,43 @@ const emailTemplates = { billingPortalUrl: 'https://sim.ai/settings/billing', failureReason: 'Card declined', }), + 'usage-limit-reached': () => + renderUsageLimitReachedEmail({ + userName: 'John', + planName: 'Pro', + scope: 'user', + currentUsage: 20, + limit: 20, + ctaLink: 'https://sim.ai/settings/billing', + }), + 'usage-limit-reached-org': () => + renderUsageLimitReachedEmail({ + userName: 'John', + planName: 'Team', + scope: 'organization', + currentUsage: 500, + limit: 500, + ctaLink: 'https://sim.ai/organization/org_123/settings/billing', + }), + + // Operational notification emails + 'schedule-disabled': () => + renderScheduleDisabledEmail({ + recipientName: 'John', + kind: 'workflow', + resourceName: 'Daily digest', + reason: 'consecutive_failures', + failedCount: 100, + manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456', + }), + 'schedule-disabled-auth': () => + renderScheduleDisabledEmail({ + recipientName: 'John', + kind: 'job', + resourceName: 'Weekly report', + reason: 'authentication_error', + manageLink: 'https://sim.ai/workspace/ws_123/scheduled-tasks', + }), } as const type EmailTemplate = keyof typeof emailTemplates @@ -122,7 +161,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'plan-welcome-team', 'credit-purchase', 'payment-failed', + 'usage-limit-reached', + 'usage-limit-reached-org', ], + Notifications: ['schedule-disabled', 'schedule-disabled-auth'], } const categoryHtml = Object.entries(categories) diff --git a/apps/sim/app/api/files/serve-inline-image.ts b/apps/sim/app/api/files/serve-inline-image.ts index 88c3383d961..162685e8682 100644 --- a/apps/sim/app/api/files/serve-inline-image.ts +++ b/apps/sim/app/api/files/serve-inline-image.ts @@ -8,8 +8,10 @@ import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils' const logger = createLogger('InlineImageServe') /** - * A shared/edited/deleted file must never serve stale bytes from its fixed inline URL, so every inline - * image revalidates on each request. + * An embedded image is authenticated content served from a fixed inline URL, and the file behind it can + * be DELETED or its access REVOKED at any time — so it always revalidates, letting each request re-run the + * server-side deletion/authorization check rather than serving a stale (possibly no-longer-authorized) + * image from cache. Private so no shared cache/CDN ever stores it. */ const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate' diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index aad77fe0393..4b29a568e78 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -60,6 +60,9 @@ function getWorkspaceIdForCompile(key: string): string | undefined { const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable' const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate' +/** For the genuinely-public, pre-auth asset routes (avatars, OG images, workspace logos) — these are + * intentionally shared-cacheable. Passed EXPLICITLY so the default response cache stays `private`. */ +const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000' /** * Cache-Control for a served file. A versioned request (`?v=`) addresses @@ -314,6 +317,7 @@ async function handleCloudProxyPublic( buffer: fileBuffer, contentType, filename, + cacheControl: PUBLIC_ASSET_CACHE_CONTROL, }) } catch (error) { logger.error('Error serving public cloud file:', error) @@ -338,6 +342,7 @@ async function handleLocalFilePublic(filename: string): Promise { buffer: fileBuffer, contentType, filename, + cacheControl: PUBLIC_ASSET_CACHE_CONTROL, }) } catch (error) { logger.error('Error reading public local file:', error) diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index ff02212a808..3e4e2463cc3 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -173,6 +173,26 @@ describe('extractFilename', () => { expect(response.headers.get('Content-Security-Policy')).toBeNull() }) + it('defaults to a PRIVATE cache so access-verified content is never shared-cached', () => { + const response = createFileResponse({ + buffer: Buffer.from('fake-image-data'), + contentType: 'image/png', + filename: 'safe-image.png', + }) + // No explicit cacheControl → must NOT be `public` (a shared cache/CDN could re-serve authed bytes). + expect(response.headers.get('Cache-Control')).toBe('private, no-cache') + }) + + it('honors an explicit cacheControl (e.g. public assets opt in)', () => { + const response = createFileResponse({ + buffer: Buffer.from('fake-image-data'), + contentType: 'image/png', + filename: 'avatar.png', + cacheControl: 'public, max-age=31536000', + }) + expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000') + }) + it('should serve PDFs inline safely', () => { const response = createFileResponse({ buffer: Buffer.from('fake-pdf-data'), diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index 0eedc8211cc..a5f97e4b431 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -211,7 +211,10 @@ export function createFileResponse(file: FileResponse): NextResponse { const headers: Record = { 'Content-Type': contentType, 'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(file.filename)}`, - 'Cache-Control': file.cacheControl || 'public, max-age=31536000', + // Default to PRIVATE: this response is served only after access verification, so it must never be + // stored by a shared cache/CDN and re-served cross-user. Genuinely public assets (avatars, OG images, + // workspace logos) pass an explicit `cacheControl` (see PUBLIC_ASSET_CACHE_CONTROL in the serve route). + 'Cache-Control': file.cacheControl || 'private, no-cache', 'X-Content-Type-Options': 'nosniff', } diff --git a/apps/sim/app/api/internal/file-doc/merge/route.ts b/apps/sim/app/api/internal/file-doc/merge/route.ts new file mode 100644 index 00000000000..40f7e5204c0 --- /dev/null +++ b/apps/sim/app/api/internal/file-doc/merge/route.ts @@ -0,0 +1,37 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { mergeFileDocContract } from '@/lib/api/contracts/file-doc' +import { parseRequest } from '@/lib/api/server' +import { buildFileDocMergeUpdate } from '@/lib/collab-doc/merge' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('FileDocMergeAPI') + +/** + * POST /api/internal/file-doc/merge — merge new markdown into a live collaborative document as a + * minimal Yjs diff (Stage C — copilot writing into an open doc). The realtime relay ships the current + * doc state; the app returns the diff to apply + relay. Internal only: gated on the shared + * `x-api-key: INTERNAL_API_SECRET` secret, matching the seed endpoint and the realtime relay. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = checkInternalApiKey(request) + if (!auth.success) return createUnauthorizedResponse() + + const parsed = await parseRequest(mergeFileDocContract, request, {}) + if (!parsed.success) return parsed.response + const { fileId, docState, markdown } = parsed.data.body + + try { + const update = buildFileDocMergeUpdate(Buffer.from(docState, 'base64'), markdown) + return NextResponse.json({ update: Buffer.from(update).toString('base64') }) + } catch (error) { + logger.error('Failed to merge markdown into file-doc', { fileId, error }) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to merge document') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/internal/file-doc/persist/route.ts b/apps/sim/app/api/internal/file-doc/persist/route.ts new file mode 100644 index 00000000000..a85923deea7 --- /dev/null +++ b/apps/sim/app/api/internal/file-doc/persist/route.ts @@ -0,0 +1,44 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { persistFileDocContract } from '@/lib/api/contracts/file-doc' +import { parseRequest } from '@/lib/api/server' +import { persistFileDoc } from '@/lib/collab-doc/persist' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('FileDocPersistAPI') + +/** + * POST /api/internal/file-doc/persist — project a live collaborative document back to durable markdown + * (Yjs → markdown, through the exact editor engine) and write it to the file. Internal only: gated on + * the shared `x-api-key: INTERNAL_API_SECRET` secret, matching the header the realtime relay sends. + * The relay owns the live doc but not the conversion engine or blob/DB access, so it ships the current + * doc state here — the server-authoritative durable path that replaces the editor's client autosave. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = checkInternalApiKey(request) + if (!auth.success) return createUnauthorizedResponse() + + const parsed = await parseRequest(persistFileDocContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId, fileId, userId, docState, expectedVersion } = parsed.data.body + + try { + const result = await persistFileDoc( + workspaceId, + fileId, + userId, + new Uint8Array(Buffer.from(docState, 'base64')), + expectedVersion + ) + return NextResponse.json(result) + } catch (error) { + logger.error('Failed to persist file-doc', { workspaceId, fileId, error }) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to persist document') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/internal/file-doc/seed/route.test.ts b/apps/sim/app/api/internal/file-doc/seed/route.test.ts new file mode 100644 index 00000000000..f64504f9ded --- /dev/null +++ b/apps/sim/app/api/internal/file-doc/seed/route.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckInternalApiKey, mockBuildFileDocSeed } = vi.hoisted(() => ({ + mockCheckInternalApiKey: vi.fn(), + mockBuildFileDocSeed: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/http', () => ({ + checkInternalApiKey: mockCheckInternalApiKey, + createUnauthorizedResponse: () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), +})) + +vi.mock('@/lib/collab-doc/seed', () => ({ + buildFileDocSeed: mockBuildFileDocSeed, +})) + +import { POST } from './route' + +function seedRequest(body: unknown) { + return createMockRequest('POST', body, { 'x-api-key': 'internal' }) +} + +describe('POST /api/internal/file-doc/seed', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckInternalApiKey.mockReturnValue({ success: true }) + }) + + // Regression guard for the auth-helper choice: the realtime relay authenticates with + // `x-api-key: INTERNAL_API_SECRET`, so this route MUST gate on `checkInternalApiKey`. Wiring the + // Bearer-JWT-only `checkInternalAuth` (which forbids `x-api-key`) 401s every real seed fetch. + it('401s when the internal api key is rejected, without building a seed', async () => { + mockCheckInternalApiKey.mockReturnValue({ success: false }) + const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' })) + expect(res.status).toBe(401) + expect(mockBuildFileDocSeed).not.toHaveBeenCalled() + }) + + it('returns the seed as base64 for an authorized request', async () => { + mockBuildFileDocSeed.mockResolvedValue({ update: new Uint8Array([1, 2, 3, 4]) }) + const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' })) + expect(res.status).toBe(200) + expect((await res.json()).update).toBe(Buffer.from([1, 2, 3, 4]).toString('base64')) + expect(mockBuildFileDocSeed).toHaveBeenCalledWith('ws-1', 'file-1') + }) + + it('returns update:null for a genuinely absent file', async () => { + mockBuildFileDocSeed.mockResolvedValue(null) + const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'missing' })) + expect(res.status).toBe(200) + expect((await res.json()).update).toBeNull() + }) + + it('400s on a body missing required fields (contract validation, after auth)', async () => { + const res = await POST(seedRequest({ workspaceId: 'ws-1' })) + expect(res.status).toBe(400) + expect(mockBuildFileDocSeed).not.toHaveBeenCalled() + }) + + it('500s when the seed build throws (a read error the relay should retry)', async () => { + mockBuildFileDocSeed.mockRejectedValue(new Error('db down')) + const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' })) + expect(res.status).toBe(500) + }) +}) diff --git a/apps/sim/app/api/internal/file-doc/seed/route.ts b/apps/sim/app/api/internal/file-doc/seed/route.ts new file mode 100644 index 00000000000..aaf051da094 --- /dev/null +++ b/apps/sim/app/api/internal/file-doc/seed/route.ts @@ -0,0 +1,40 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { buildFileDocSeedContract } from '@/lib/api/contracts/file-doc' +import { parseRequest } from '@/lib/api/server' +import { buildFileDocSeed } from '@/lib/collab-doc/seed' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('FileDocSeedAPI') + +/** + * POST /api/internal/file-doc/seed — build a server-authoritative collaborative-document seed + * (markdown → Yjs) for the realtime relay to apply on room creation. Internal only: gated on the + * shared `x-api-key: INTERNAL_API_SECRET` secret, matching the header the realtime relay sends + * (`apps/realtime/src/handlers/file-doc-app.ts`) and the realtime server's own inbound validator. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = checkInternalApiKey(request) + if (!auth.success) return createUnauthorizedResponse() + + const parsed = await parseRequest(buildFileDocSeedContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId, fileId } = parsed.data.body + + try { + const seed = await buildFileDocSeed(workspaceId, fileId) + return NextResponse.json({ + update: seed ? Buffer.from(seed.update).toString('base64') : null, + version: seed ? seed.version : null, + }) + } catch (error) { + logger.error('Failed to build file-doc seed', { workspaceId, fileId, error }) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to build seed') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 88e379dafe7..d12fd48e0aa 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -21,18 +21,12 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites const { mockGetDocumentTagDefinitions, - mockHandleTagOnlySearch, - mockHandleVectorOnlySearch, - mockHandleTagAndVectorSearch, - mockGetQueryStrategy, + mockExecuteKnowledgeSearch, mockGenerateSearchEmbedding, mockGetDocumentMetadataByIds, } = vi.hoisted(() => ({ mockGetDocumentTagDefinitions: vi.fn(), - mockHandleTagOnlySearch: vi.fn(), - mockHandleVectorOnlySearch: vi.fn(), - mockHandleTagAndVectorSearch: vi.fn(), - mockGetQueryStrategy: vi.fn(), + mockExecuteKnowledgeSearch: vi.fn(), mockGenerateSearchEmbedding: vi.fn(), mockGetDocumentMetadataByIds: vi.fn(), })) @@ -69,10 +63,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ })) vi.mock('./utils', () => ({ - handleTagOnlySearch: mockHandleTagOnlySearch, - handleVectorOnlySearch: mockHandleVectorOnlySearch, - handleTagAndVectorSearch: mockHandleTagAndVectorSearch, - getQueryStrategy: mockGetQueryStrategy, + executeKnowledgeSearch: mockExecuteKnowledgeSearch, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, APIError: class APIError extends Error { @@ -118,15 +109,7 @@ describe('Knowledge Search API Route', () => { resetDbChainMock() setEnv({ OPENAI_API_KEY: 'test-api-key' }) - mockHandleTagOnlySearch.mockClear() - mockHandleVectorOnlySearch.mockClear() - mockHandleTagAndVectorSearch.mockClear() - mockGetQueryStrategy.mockClear().mockReturnValue({ - useParallel: false, - distanceThreshold: 1.0, - parallelLimit: 15, - singleQueryOptimized: true, - }) + mockExecuteKnowledgeSearch.mockClear() mockGenerateSearchEmbedding .mockClear() .mockResolvedValue({ embedding: [0.1, 0.2, 0.3, 0.4, 0.5], isBYOK: false }) @@ -192,7 +175,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValue([]) - mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -212,14 +195,50 @@ describe('Knowledge Search API Route', () => { expect(data.data.results[0].similarity).toBe(0.8) // 1 - 0.2 expect(data.data.query).toBe(validSearchData.query) expect(data.data.knowledgeBaseIds).toEqual(['kb-123']) - expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({ + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123'], topK: 10, + searchMode: 'vector', + query: validSearchData.query, queryVector: JSON.stringify(mockEmbedding), - distanceThreshold: expect.any(Number), + structuredFilters: undefined, }) }) + it('should forward the hybrid searchMode opt-in to the retrieval layer', async () => { + mockGetUserId.mockResolvedValue('user-123') + + mockCheckKnowledgeBaseAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { + id: 'kb-123', + userId: 'user-123', + name: 'Test KB', + deletedAt: null, + }, + }) + + dbChainMockFns.limit.mockResolvedValue([]) + + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: [{ embedding: mockEmbedding }], + }), + }) + + const req = createMockRequest('POST', { ...validSearchData, searchMode: 'hybrid' }) + const response = await POST(req) + + expect(response.status).toBe(200) + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith( + expect.objectContaining({ searchMode: 'hybrid' }) + ) + }) + it('should perform search successfully with multiple knowledge bases', async () => { const multiKbData = { ...validSearchData, @@ -239,7 +258,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValue([]) - mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -256,11 +275,13 @@ describe('Knowledge Search API Route', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) expect(data.data.knowledgeBaseIds).toEqual(['kb-123', 'kb-456']) - expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({ + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123', 'kb-456'], topK: 10, + searchMode: 'vector', + query: multiKbData.query, queryVector: JSON.stringify(mockEmbedding), - distanceThreshold: expect.any(Number), + structuredFilters: undefined, }) }) @@ -284,7 +305,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValue([]) - mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -348,7 +369,7 @@ describe('Knowledge Search API Route', () => { embeddingModel: 'text-embedding-3-small', }, }) - mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) const attribution = encodeURIComponent( JSON.stringify({ actorUserId: 'user-123', @@ -532,7 +553,7 @@ describe('Knowledge Search API Route', () => { mockGetUserId.mockResolvedValue('user-123') dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) - mockHandleVectorOnlySearch.mockRejectedValueOnce(new Error('Database error')) + mockExecuteKnowledgeSearch.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('POST', validSearchData) const response = await POST(req) @@ -750,7 +771,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) - mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults) const req = createMockRequest('POST', tagOnlyData) const response = await POST(req) @@ -763,9 +784,10 @@ describe('Knowledge Search API Route', () => { expect(data.data.query).toBe('') // Empty query expect(data.data.cost).toBeUndefined() // No cost for tag-only search expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() // No embedding API call - expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({ + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123'], topK: 10, + searchMode: 'vector', structuredFilters: [ { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined }, ], @@ -796,7 +818,7 @@ describe('Knowledge Search API Route', () => { dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) - mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -816,14 +838,15 @@ describe('Knowledge Search API Route', () => { expect(data.data.query).toBe('test search') expect(data.data.cost).toBeDefined() // Cost included for vector search expect(mockGenerateSearchEmbedding).toHaveBeenCalled() // Embedding API called - expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({ + expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123'], topK: 10, + searchMode: 'vector', + query: 'test search', + queryVector: JSON.stringify(mockEmbedding), structuredFilters: [ { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined }, ], - queryVector: JSON.stringify(mockEmbedding), - distanceThreshold: 1, // Single KB uses threshold of 1.0 }) }) @@ -987,7 +1010,7 @@ describe('Knowledge Search API Route', () => { mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) + mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults) dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) @@ -1016,7 +1039,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockHandleVectorOnlySearch.mockResolvedValue([ + mockExecuteKnowledgeSearch.mockResolvedValue([ { id: 'chunk-1', content: 'Content from active document', @@ -1034,13 +1057,6 @@ describe('Knowledge Search API Route', () => { }, ]) - mockGetQueryStrategy.mockReturnValue({ - useParallel: false, - distanceThreshold: 1.0, - parallelLimit: 15, - singleQueryOptimized: true, - }) - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false }) mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-active': { @@ -1092,7 +1108,7 @@ describe('Knowledge Search API Route', () => { { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, ]) - mockHandleTagOnlySearch.mockResolvedValue([ + mockExecuteKnowledgeSearch.mockResolvedValue([ { id: 'chunk-2', content: 'Content from active document with tag', @@ -1110,13 +1126,6 @@ describe('Knowledge Search API Route', () => { }, ]) - mockGetQueryStrategy.mockReturnValue({ - useParallel: false, - distanceThreshold: 1.0, - parallelLimit: 15, - singleQueryOptimized: true, - }) - mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-active-tagged': { filename: 'Active Tagged Document.pdf', sourceUrl: null }, }) @@ -1164,7 +1173,7 @@ describe('Knowledge Search API Route', () => { { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, ]) - mockHandleTagAndVectorSearch.mockResolvedValue([ + mockExecuteKnowledgeSearch.mockResolvedValue([ { id: 'chunk-3', content: 'Relevant content from active document', @@ -1182,13 +1191,6 @@ describe('Knowledge Search API Route', () => { }, ]) - mockGetQueryStrategy.mockReturnValue({ - useParallel: false, - distanceThreshold: 1.0, - parallelLimit: 15, - singleQueryOptimized: true, - }) - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false }) mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-active-combined': { filename: 'Active Combined Search.pdf', sourceUrl: null }, diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index aa337524f6a..12a88854571 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -27,12 +27,9 @@ import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/ import type { StructuredFilter } from '@/lib/knowledge/types' import { estimateTokenCount } from '@/lib/tokenization/estimators' import { + executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, - getQueryStrategy, - handleTagAndVectorSearch, - handleTagOnlySearch, - handleVectorOnlySearch, type SearchResult, } from '@/app/api/knowledge/search/utils' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' @@ -318,32 +315,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : validatedData.topK if (!hasQuery && hasFilters) { - results = await handleTagOnlySearch({ + results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK: validatedData.topK, + searchMode: validatedData.searchMode, structuredFilters, }) - } else if (hasQuery && hasFilters) { - logger.debug(`[${requestId}] Executing tag + vector search with filters:`, structuredFilters) - const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK) - const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) - - results = await handleTagAndVectorSearch({ - knowledgeBaseIds: accessibleKbIds, - topK: candidateTopK, - structuredFilters, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) - } else if (hasQuery && !hasFilters) { - const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK) + } else if (hasQuery) { + logger.debug( + `[${requestId}] Executing ${validatedData.searchMode} search`, + hasFilters ? { structuredFilters } : undefined + ) const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) - results = await handleVectorOnlySearch({ + results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK: candidateTopK, + searchMode: validatedData.searchMode, + query: validatedData.query, queryVector, - distanceThreshold: strategy.distanceThreshold, + structuredFilters: hasFilters ? structuredFilters : undefined, }) } else { return NextResponse.json( diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 4a9c5e1f0e5..461289c09d2 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -4,7 +4,14 @@ * * @vitest-environment node */ -import { mockNextFetchResponse, setupGlobalFetchMock } from '@sim/testing/mocks' +import { + dbChainMockFns, + mockNextFetchResponse, + queueTableRows, + resetDbChainMock, + schemaMock, + setupGlobalFetchMock, +} from '@sim/testing/mocks' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { env } from '@/lib/core/config/env' import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' @@ -39,12 +46,47 @@ afterEach(() => { }) import { + executeKeywordSearch, + executeKnowledgeSearch, + fuseByReciprocalRank, generateSearchEmbedding, + getQueryStrategy, handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch, + RRF_K, + type SearchResult, } from '@/app/api/knowledge/search/utils' +/** Minimal SearchResult builder — only the fields fusion and ordering read. */ +function makeResult(id: string, distance = 0.1): SearchResult { + return { + id, + content: `content-${id}`, + documentId: `doc-${id}`, + chunkIndex: 0, + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, + distance, + knowledgeBaseId: 'kb-123', + } +} + describe('Knowledge Search Utils', () => { beforeEach(() => { vi.clearAllMocks() @@ -183,6 +225,320 @@ describe('Knowledge Search Utils', () => { }) }) + describe('fuseByReciprocalRank', () => { + it('ranks a row found by both legs above rows found by only one', () => { + const shared = makeResult('shared') + const vectorOnly = makeResult('vector-only') + const keywordOnly = makeResult('keyword-only') + + const fused = fuseByReciprocalRank( + [ + [vectorOnly, shared], + [keywordOnly, shared], + ], + 10 + ) + + expect(fused[0].id).toBe('shared') + // `shared` is credited to both legs, so the following tie is even and + // resolves to the earliest list. + expect(fused.map((r) => r.id)).toEqual(['shared', 'vector-only', 'keyword-only']) + }) + + it('dedupes by chunk id, keeping the first occurrence', () => { + const fromVector = makeResult('chunk-1', 0.2) + const fromKeyword = { ...makeResult('chunk-1', 0.9), content: 'stale copy' } + + const fused = fuseByReciprocalRank([[fromVector], [fromKeyword]], 10) + + expect(fused).toHaveLength(1) + expect(fused[0].content).toBe('content-chunk-1') + expect(fused[0].distance).toBe(0.2) + }) + + it('preserves leg ordering when only one leg returns rows', () => { + const rows = [makeResult('a'), makeResult('b'), makeResult('c')] + + expect(fuseByReciprocalRank([rows, []], 10).map((r) => r.id)).toEqual(['a', 'b', 'c']) + expect(fuseByReciprocalRank([[], rows], 10).map((r) => r.id)).toEqual(['a', 'b', 'c']) + }) + + it('scores by reciprocal rank so a deep double hit beats a shallow single hit', () => { + const deepShared = makeResult('deep-shared') + const topSingle = makeResult('top-single') + + /** + * `deep-shared` sits at rank 2 in both legs: 2 / (RRF_K + 2). + * `top-single` sits at rank 1 in one leg only: 1 / (RRF_K + 1). + * With RRF_K = 60 the double hit wins. + */ + expect(2 / (RRF_K + 2)).toBeGreaterThan(1 / (RRF_K + 1)) + + const fused = fuseByReciprocalRank( + [ + [topSingle, deepShared], + [makeResult('other'), deepShared], + ], + 10 + ) + + expect(fused[0].id).toBe('deep-shared') + }) + + it('does not let the first leg starve the second at small topK', () => { + const lexicalOnly = makeResult('lexical-only') + const vectorOnly = makeResult('vector-only') + + /** + * Rank 1 in each leg scores identically. Ordering by score alone would + * always emit the first list's row, so a `topK: 1` hybrid search would + * return exactly what vector-only search already returned. + */ + expect(fuseByReciprocalRank([[lexicalOnly], [vectorOnly]], 1).map((r) => r.id)).toEqual([ + 'lexical-only', + ]) + expect(fuseByReciprocalRank([[lexicalOnly], [vectorOnly]], 2).map((r) => r.id)).toEqual([ + 'lexical-only', + 'vector-only', + ]) + }) + + it('interleaves tied ranks so neither leg monopolizes the head', () => { + const legA = [makeResult('a1'), makeResult('a2'), makeResult('a3')] + const legB = [makeResult('b1'), makeResult('b2'), makeResult('b3')] + + expect(fuseByReciprocalRank([legA, legB], 6).map((r) => r.id)).toEqual([ + 'a1', + 'b1', + 'a2', + 'b2', + 'a3', + 'b3', + ]) + }) + + it('still floats a row found by both legs above every single-leg row', () => { + const shared = makeResult('shared') + const legA = [makeResult('a1'), shared] + const legB = [makeResult('b1'), shared] + + // shared is rank 2 in both legs (2/62) and outscores either rank-1 row (1/61). + expect(fuseByReciprocalRank([legA, legB], 3).map((r) => r.id)).toEqual(['shared', 'a1', 'b1']) + }) + + it('does not let a shared top hit evict the lexical-only row at topK 2', () => { + const shared = makeResult('shared') + const lexicalOnly = makeResult('lexical-only') + const vectorOnly = makeResult('vector-only') + + /** + * `shared` is rank 1 in both legs. Crediting it to only one leg would + * leave the round-robin owing the other leg the remaining slot, evicting + * the row that only the shared hit's leg could produce. + */ + const fused = fuseByReciprocalRank( + [ + [shared, lexicalOnly], + [shared, vectorOnly], + ], + 2 + ) + + expect(fused.map((r) => r.id)).toEqual(['shared', 'lexical-only']) + }) + + it('trims the fused list to topK', () => { + const rows = Array.from({ length: 8 }, (_, i) => makeResult(`chunk-${i}`)) + + expect(fuseByReciprocalRank([rows, []], 3)).toHaveLength(3) + }) + + it('returns an empty list when every leg is empty', () => { + expect(fuseByReciprocalRank([[], []], 10)).toEqual([]) + }) + }) + + describe('executeKeywordSearch', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('returns nothing for a whitespace-only query without touching the database', async () => { + const results = await executeKeywordSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + query: ' ', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results).toEqual([]) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('issues one query per knowledge base once the parallel threshold is crossed', async () => { + const knowledgeBaseIds = ['kb-1', 'kb-2', 'kb-3', 'kb-4', 'kb-5'] + expect(getQueryStrategy(knowledgeBaseIds.length, 10).useParallel).toBe(true) + + await executeKeywordSearch({ + knowledgeBaseIds, + topK: 10, + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + /** + * A single global LIMIT would let the lexically strongest base consume + * every slot, so an exact-token hit in a smaller base never reaches + * fusion. The vector leg already fans out here; both legs must match. + */ + expect(dbChainMockFns.select).toHaveBeenCalledTimes(knowledgeBaseIds.length) + }) + + it('ranks without selecting the embedding column, then hydrates the survivors', async () => { + queueTableRows(schemaMock.embedding, [{ id: 'kw-1', keywordRank: 0.9 }]) + queueTableRows(schemaMock.embedding, [makeResult('kw-1')]) + + const results = await executeKeywordSearch({ + knowledgeBaseIds: ['kb-1'], + topK: 10, + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results.map((r) => r.id)).toEqual(['kw-1']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + + /** + * Projecting the distance in the ranking pass makes Postgres detoast the + * 1536-dimension vector for every full-text match before the LIMIT, so + * cost tracks how common the term is rather than topK. The ranking pass + * must select ids and relevance only. + */ + const rankingSelect = dbChainMockFns.select.mock.calls[0][0] + expect(Object.keys(rankingSelect)).toEqual(['id', 'keywordRank']) + expect(Object.keys(dbChainMockFns.select.mock.calls[1][0])).toContain('distance') + }) + + it('uses a single query when the parallel threshold is not crossed', async () => { + const knowledgeBaseIds = ['kb-1', 'kb-2'] + expect(getQueryStrategy(knowledgeBaseIds.length, 10).useParallel).toBe(false) + + await executeKeywordSearch({ + knowledgeBaseIds, + topK: 10, + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + }) + + describe('executeKnowledgeSearch', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('throws when neither a query nor tag filters are provided', async () => { + await expect( + executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + }) + ).rejects.toThrow('A search query or tag filters are required') + }) + + it('throws when a query is provided without a query vector', async () => { + await expect( + executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + query: 'PROJ-1234', + }) + ).rejects.toThrow('Query vector is required') + }) + + it('runs a single retrieval leg in vector mode', async () => { + queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) + + const results = await executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'vector', + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results.map((r) => r.id)).toEqual(['vector-hit']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + + it('runs both legs and fuses them in hybrid mode', async () => { + // Vector leg, then the keyword leg's ranking pass, then its hydration pass. + queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) + queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }]) + queueTableRows(schemaMock.embedding, [makeResult('keyword-hit')]) + + const results = await executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results.map((r) => r.id).sort()).toEqual(['keyword-hit', 'vector-hit']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) + }) + + it('falls back to vector results when the keyword leg fails', async () => { + queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) + + /** + * Both legs share one `orderBy` spy, so target the keyword leg by its + * ranking expression. Calling the untouched spy first captures the + * sentinel that tells the mock to build its normal chain, which the + * vector leg still needs. + */ + const chainDefault = dbChainMockFns.orderBy() + dbChainMockFns.orderBy.mockImplementation((fragment: unknown) => { + const text = (fragment as { strings?: string[] })?.strings?.join('') ?? '' + if (text.includes('ts_rank_cd')) { + throw new Error('tsquery blew up') + } + return chainDefault + }) + + const results = await executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + query: 'PROJ-1234', + queryVector: JSON.stringify([0.1, 0.2, 0.3]), + }) + + expect(results.map((r) => r.id)).toEqual(['vector-hit']) + }) + + it('skips both query legs when only tag filters are provided', async () => { + queueTableRows(schemaMock.embedding, [makeResult('tag-hit')]) + + const results = await executeKnowledgeSearch({ + knowledgeBaseIds: ['kb-123'], + topK: 10, + searchMode: 'hybrid', + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' } as never, + ], + }) + + expect(results.map((r) => r.id)).toEqual(['tag-hit']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + }) + }) + describe('generateSearchEmbedding', () => { it('should use Azure OpenAI when KB-specific config is provided', async () => { const { env } = await import('@/lib/core/config/env') diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index afaff875b5b..1a3b62fbf87 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -1,8 +1,12 @@ import { db } from '@sim/db' import { document, embedding } from '@sim/db/schema' -import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' import type { StructuredFilter } from '@/lib/knowledge/types' +const logger = createLogger('KnowledgeSearch') + export interface DocumentMetadata { filename: string sourceUrl: string | null @@ -306,6 +310,36 @@ function getStructuredTagFilters(filters: StructuredFilter[], embeddingTable: an return conditions } +/** + * Text-search configuration used to build the query. Must match the config the + * generated `embedding.content_tsv` column was built with + * (`to_tsvector('english', content)`) — a mismatch silently stops Postgres from + * using the `emb_content_fts_idx` GIN index and degrades to a sequential scan. + */ +const FTS_CONFIG = 'english' + +/** + * Reciprocal-rank-fusion damping constant. 60 is the value from the original RRF + * paper and matches the docs Ask-AI retriever (`apps/docs/app/api/chat/route.ts`). + */ +export const RRF_K = 60 + +/** + * Row visibility predicates shared by every search leg: a chunk is only + * retrievable when both it and its document are enabled, the document finished + * processing, and it has not been excluded, archived, or soft-deleted. + */ +function getVisibilityConditions() { + return [ + eq(embedding.enabled, true), + eq(document.enabled, true), + eq(document.processingStatus, 'completed'), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + ] +} + export function getQueryStrategy(kbCount: number, topK: number) { const useParallel = kbCount > 4 || (kbCount > 2 && topK > 50) const distanceThreshold = kbCount > 3 ? 0.8 : 1.0 @@ -512,6 +546,185 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise { + const { knowledgeBaseIds, topK, query, queryVector, structuredFilters } = params + + if (!query.trim()) { + return [] + } + + const tsQuery = sql`websearch_to_tsquery(${FTS_CONFIG}, ${query})` + const rankExpr = sql`ts_rank_cd(${embedding.contentTsv}, ${tsQuery})` + const tagFilterConditions = structuredFilters?.length + ? getStructuredTagFilters(structuredFilters, embedding) + : [] + + const rankConditions = (kbScope: SQL | undefined) => + and( + kbScope, + ...getVisibilityConditions(), + sql`${embedding.contentTsv} @@ ${tsQuery}`, + ...tagFilterConditions + ) + + /** Ranking pass: ids and relevance only, so no vector is read. */ + const rankRows = (kbScope: SQL | undefined, limit: number) => + db + .select({ id: embedding.id, keywordRank: rankExpr.as('keyword_rank') }) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where(rankConditions(kbScope)) + .orderBy(sql`${rankExpr} DESC`) + .limit(limit) + + const strategy = getQueryStrategy(knowledgeBaseIds.length, topK) + + let ranked: { id: string; keywordRank: number }[] + if (strategy.useParallel) { + const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5 + const perBase = await Promise.all( + knowledgeBaseIds.map((kbId) => rankRows(eq(embedding.knowledgeBaseId, kbId), parallelLimit)) + ) + ranked = perBase.flat().sort((a, b) => b.keywordRank - a.keywordRank) + } else { + ranked = await rankRows(inArray(embedding.knowledgeBaseId, knowledgeBaseIds), topK) + } + + const topIds = ranked.slice(0, topK).map((row) => row.id) + if (topIds.length === 0) { + return [] + } + + /** Hydration pass: full rows plus the cosine distance, bounded to the survivors. */ + const hydrated = await db + .select( + getSearchResultFields( + sql`${embedding.embedding} <=> ${queryVector}::vector`.as('distance') + ) + ) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where(and(inArray(embedding.id, topIds), ...getVisibilityConditions())) + + const rowById = new Map(hydrated.map((row) => [row.id, row])) + return topIds.map((id) => rowById.get(id)).filter((row): row is SearchResult => row !== undefined) +} + +/** + * Fuse independently-ranked result lists by reciprocal rank: + * `score(row) = Σ 1 / (RRF_K + rank)` across the lists it appears in. + * + * Rank fusion is used rather than score normalization because cosine distance + * and `ts_rank_cd` are on incomparable scales with no corpus-independent + * mapping between them. Rows are deduped by chunk id, first occurrence wins. + * + * Equal scores are common and must not be broken by list order: rank *n* in one + * leg always ties rank *n* in every other leg, so sorting alone would let the + * first list monopolize the head of the output and starve the others entirely + * at small `topK`. Selection therefore drains each tie group round-robin, + * preferring the candidate whose least-served leg has been served least. + * + * A row is credited to *every* leg that returned it, not to one chosen leg: it + * satisfied all of them, and charging a shared hit to a single leg would leave + * the round-robin owing the other one a slot it has already been served — + * which at small `topK` evicts a row only the shared hit's leg could produce. + * A total tie goes to the earliest list, so callers put the leg whose hits the + * other leg cannot produce first. + */ +export function fuseByReciprocalRank(rankedLists: SearchResult[][], topK: number): SearchResult[] { + const scores = new Map() + const rowById = new Map() + const legsOfRow = new Map() + + rankedLists.forEach((list, leg) => { + list.forEach((row, index) => { + scores.set(row.id, (scores.get(row.id) ?? 0) + 1 / (RRF_K + index + 1)) + if (!rowById.has(row.id)) { + rowById.set(row.id, row) + } + const legs = legsOfRow.get(row.id) + if (legs) { + if (!legs.includes(leg)) legs.push(leg) + } else { + legsOfRow.set(row.id, [leg]) + } + }) + }) + + // Stable sort keeps rowById insertion order (earliest leg first) inside each tie group. + const ordered = [...rowById.values()].sort( + (a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0) + ) + + const contributed = rankedLists.map(() => 0) + /** How starved a candidate's most-neglected leg is; lower wins the tie. */ + const starvation = (id: string) => + Math.min(...(legsOfRow.get(id) ?? [0]).map((leg) => contributed[leg])) + + const fused: SearchResult[] = [] + let groupStart = 0 + + while (groupStart < ordered.length && fused.length < topK) { + const groupScore = scores.get(ordered[groupStart].id) ?? 0 + let groupEnd = groupStart + while (groupEnd < ordered.length && (scores.get(ordered[groupEnd].id) ?? 0) === groupScore) { + groupEnd++ + } + + const group = ordered.slice(groupStart, groupEnd) + while (group.length > 0 && fused.length < topK) { + let pick = 0 + for (let i = 1; i < group.length; i++) { + if (starvation(group[i].id) < starvation(group[pick].id)) { + pick = i + } + } + const [row] = group.splice(pick, 1) + fused.push(row) + for (const leg of legsOfRow.get(row.id) ?? []) { + contributed[leg]++ + } + } + + groupStart = groupEnd + } + + return fused +} + export async function handleTagAndVectorSearch(params: SearchParams): Promise { const { knowledgeBaseIds, topK, structuredFilters, queryVector, distanceThreshold } = params @@ -537,3 +750,88 @@ export async function handleTagAndVectorSearch(params: SearchParams): Promise { + const { knowledgeBaseIds, topK, searchMode, query, queryVector, structuredFilters } = params + + const hasQuery = Boolean(query?.trim()) + const hasFilters = Boolean(structuredFilters && structuredFilters.length > 0) + + if (!hasQuery) { + if (!hasFilters) { + throw new Error('A search query or tag filters are required') + } + return await handleTagOnlySearch({ knowledgeBaseIds, topK, structuredFilters }) + } + + if (!queryVector) { + throw new Error('Query vector is required when searching with a query') + } + + const { distanceThreshold } = getQueryStrategy(knowledgeBaseIds.length, topK) + + const vectorSearch = hasFilters + ? handleTagAndVectorSearch({ + knowledgeBaseIds, + topK, + structuredFilters, + queryVector, + distanceThreshold, + }) + : handleVectorOnlySearch({ knowledgeBaseIds, topK, queryVector, distanceThreshold }) + + if (searchMode === 'vector') { + return await vectorSearch + } + + /** + * The lexical leg is best-effort: a failure there falls back to vector-only + * results rather than failing the whole search. + */ + const keywordSearch = executeKeywordSearch({ + knowledgeBaseIds, + topK, + query: query!, + queryVector, + structuredFilters, + }).catch((error) => { + logger.warn('Keyword search leg failed; falling back to vector-only results', { + error: getErrorMessage(error, 'Unknown error'), + }) + return [] as SearchResult[] + }) + + const [vectorResults, keywordResults] = await Promise.all([vectorSearch, keywordSearch]) + + /** + * Lexical leg first: on a total tie it wins, which is the behavior this mode + * exists for — an exact-token chunk the vector leg ranked below its distance + * threshold is precisely what a caller opted into hybrid to recover, and at + * `topK: 1` something has to win. + */ + return fuseByReciprocalRank([keywordResults, vectorResults], topK) +} diff --git a/apps/sim/app/api/mothership/events/route.ts b/apps/sim/app/api/mothership/events/route.ts index 5509358254d..c942c825665 100644 --- a/apps/sim/app/api/mothership/events/route.ts +++ b/apps/sim/app/api/mothership/events/route.ts @@ -11,6 +11,7 @@ import type { NextRequest } from 'next/server' import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-chats' import { validationErrorResponse } from '@/lib/api/server' import { chatPubSub } from '@/lib/copilot/chat-status' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceSSE } from '@/lib/events/sse-endpoint' @@ -37,6 +38,10 @@ const mothershipEventsHandler = createWorkspaceSSE({ }) export const GET = withRouteHandler((request: NextRequest) => { + // Closes streams held by tabs that were open when Chat was turned off; the + // client hook already declines to open new ones. + if (!isChatEnabled) return new Response(null, { status: 404 }) + const validation = mothershipEventsQuerySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams.entries()) ) diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 4de1d5aaebf..45053f9d53d 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -9,6 +9,8 @@ import { requestUtilsMockFns, resetDbChainMock, resetEnvFlagsMock, + resetEnvMock, + setEnv, setEnvFlags, } from '@sim/testing' import { type NextRequest, NextResponse } from 'next/server' @@ -30,6 +32,8 @@ const { mockShouldExecuteInline, mockResolveSystemBillingAttribution, mockAssertBillingAttributionSnapshot, + mockApplyScheduleFailureUpdate, + mockNotifyScheduleAutoDisabled, } = vi.hoisted(() => ({ mockVerifyCronAuth: vi.fn().mockReturnValue(null), mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined), @@ -44,6 +48,8 @@ const { mockShouldExecuteInline: vi.fn().mockReturnValue(false), mockResolveSystemBillingAttribution: vi.fn(), mockAssertBillingAttributionSnapshot: vi.fn(), + mockApplyScheduleFailureUpdate: vi.fn().mockResolvedValue({ updated: true, disabled: false }), + mockNotifyScheduleAutoDisabled: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/lib/auth/internal', () => ({ @@ -59,15 +65,11 @@ vi.mock('@/background/schedule-execution', () => ({ executeScheduleJob: mockExecuteScheduleJob, executeJobInline: mockExecuteJobInline, releaseScheduleLock: mockReleaseScheduleLock, - buildScheduleFailureUpdate: (now: Date, nextRunAt: Date | null) => ({ - updatedAt: now, - lastQueuedAt: null, - nextRunAt, - failedCount: { type: 'sql' }, - lastFailedAt: now, - status: { type: 'sql' }, - infraRetryCount: 0, - }), + applyScheduleFailureUpdate: mockApplyScheduleFailureUpdate, +})) + +vi.mock('@/lib/workflows/schedules/disable-notifications', () => ({ + notifyScheduleAutoDisabled: mockNotifyScheduleAutoDisabled, })) vi.mock('@/lib/core/async-jobs', () => ({ @@ -275,7 +277,10 @@ function createMockRequest(): NextRequest { } as NextRequest } -afterAll(resetEnvFlagsMock) +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) describe('Scheduled Workflow Execution API Route', () => { beforeEach(() => { @@ -290,6 +295,9 @@ describe('Scheduled Workflow Execution API Route', () => { dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id') setEnvFlags({ isTriggerDevEnabled: false, isHosted: false, isProd: false, isDev: true }) + // Prompt-job claims are skipped without the mothership credential; pin it so + // these cases do not depend on whether the runner happens to have a .env. + setEnv({ COPILOT_API_KEY: 'test-api-key' }) mockShouldExecuteInline.mockReturnValue(false) mockEnqueue.mockReset() mockEnqueue.mockResolvedValue('job-id-1') @@ -730,11 +738,11 @@ describe('Scheduled Workflow Execution API Route', () => { error: expect.stringContaining('exhausted retry attempts'), }) ) - expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect(mockApplyScheduleFailureUpdate).toHaveBeenCalledWith( expect.objectContaining({ - lastQueuedAt: null, - lastFailedAt: expect.any(Date), - nextRunAt: expect.any(Date), + scheduleId: 'schedule-1', + expectedLastQueuedAt: claimedAt, + executor: expect.anything(), }) ) }) @@ -779,12 +787,11 @@ describe('Scheduled Workflow Execution API Route', () => { await runScheduleTick('test-request-id') expect(mockEnqueue).not.toHaveBeenCalled() - expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect(mockApplyScheduleFailureUpdate).toHaveBeenCalledWith( expect.objectContaining({ - lastQueuedAt: null, - lastFailedAt: expect.any(Date), + scheduleId: schedule.id, + expectedLastQueuedAt: claimedAt, nextRunAt: expect.any(Date), - infraRetryCount: 0, }) ) expect(dbChainMockFns.set).not.toHaveBeenCalledWith( @@ -794,6 +801,44 @@ describe('Scheduled Workflow Execution API Route', () => { ) }) + it('emails the schedule owners when a non-retryable setup failure disables the schedule', async () => { + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: claimedAt, + } + mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant')) + mockApplyScheduleFailureUpdate.mockResolvedValueOnce({ updated: true, disabled: true }) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + + expect(mockNotifyScheduleAutoDisabled).toHaveBeenCalledWith( + expect.objectContaining({ scheduleId: schedule.id, reason: 'consecutive_failures' }) + ) + }) + + it('does not email when the failure update leaves the schedule active', async () => { + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: claimedAt, + } + mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant')) + mockApplyScheduleFailureUpdate.mockResolvedValueOnce({ updated: true, disabled: false }) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + + expect(mockNotifyScheduleAutoDisabled).not.toHaveBeenCalled() + }) + it('uses one backend mode decision for slot accounting and schedule processing', async () => { mockShouldExecuteInline.mockReturnValue(true) dbChainMockFns.limit diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 13f87274810..de14972d700 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -17,11 +17,13 @@ import { } from '@/lib/billing/core/billing-attribution' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { JOB_STATUS, type Job } from '@/lib/core/async-jobs/types' +import { env } from '@/lib/core/config/env' import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' import { SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, SCHEDULE_EXECUTION_QUEUE_NAME, @@ -31,7 +33,7 @@ import { } from '@/lib/workflows/schedules/execution-limits' import { calculateScheduleInfraRetryDelayMs } from '@/lib/workflows/schedules/retry' import { - buildScheduleFailureUpdate, + applyScheduleFailureUpdate, executeJobInline, executeScheduleJob, releaseScheduleLock, @@ -43,6 +45,12 @@ export const maxDuration = 3600 const logger = createLogger('ScheduledExecuteAPI') const WORKFLOW_CHUNK_SIZE = 100 +/** + * Recovery sweeps a batch of up to `STALE_SCHEDULE_RECOVERY_BATCH_SIZE` schedules, + * each fanning out to every workspace admin. Cap the mail so one tick can't turn + * into hundreds of inline sends; the remainder is logged. + */ +const STALE_SCHEDULE_RECOVERY_NOTIFY_LIMIT = 25 const JOB_CHUNK_SIZE = 100 const MAX_TICK_DURATION_MS = 3 * 60 * 1000 const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout() @@ -394,20 +402,22 @@ async function markClaimedScheduleFailed( context: string ): Promise { const now = new Date() - await db - .update(workflowSchedule) - .set(buildScheduleFailureUpdate(now, getScheduleNextRunAt(schedule, now))) - .where( - and( - eq(workflowSchedule.id, schedule.id), - isNull(workflowSchedule.archivedAt), - eq(workflowSchedule.lastQueuedAt, expectedLastQueuedAt) - ) - ) - .catch((error) => { - logger.error(`[${requestId}] ${context}`, error) - throw error + const { disabled } = await applyScheduleFailureUpdate({ + scheduleId: schedule.id, + now, + nextRunAt: getScheduleNextRunAt(schedule, now), + expectedLastQueuedAt, + requestId, + context, + }) + + if (disabled) { + await notifyScheduleAutoDisabled({ + scheduleId: schedule.id, + reason: 'consecutive_failures', + requestId, }) + } } async function deferClaimedScheduleAfterQueueFailure( @@ -491,6 +501,13 @@ async function handleClaimedScheduleSetupFailure( async function recoverStaleDatabaseScheduleJobs(now: Date): Promise { const staleStartedBefore = getStaleScheduleExecutionCutoff(now) + /** + * Collected inside the transaction, flushed after it commits. Emailing inside + * would both notify about writes a rollback discards and issue pooled-client + * reads while the transaction still holds row locks under the advisory lock. + */ + const disabledScheduleIds: string[] = [] + await db.transaction(async (tx) => { const [lock] = await tx.execute<{ acquired: boolean }>( sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${SCHEDULE_EXECUTION_QUEUE_NAME}, 0)) AS acquired` @@ -539,16 +556,17 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise { const claimedAt = getSchedulePayloadClaimedAt(payload) if (!payload || !claimedAt) continue - await tx - .update(workflowSchedule) - .set(buildScheduleFailureUpdate(now, getScheduleNextRunAt(payload, now))) - .where( - and( - eq(workflowSchedule.id, payload.scheduleId), - isNull(workflowSchedule.archivedAt), - eq(workflowSchedule.lastQueuedAt, claimedAt) - ) - ) + const { disabled } = await applyScheduleFailureUpdate({ + scheduleId: payload.scheduleId, + now, + nextRunAt: getScheduleNextRunAt(payload, now), + expectedLastQueuedAt: claimedAt, + requestId: 'stale-schedule-recovery', + context: `Error updating schedule ${payload.scheduleId} after stale lease recovery`, + executor: tx, + }) + + if (disabled) disabledScheduleIds.push(payload.scheduleId) } if (retryableRows.length > 0) { @@ -568,6 +586,22 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise { ) } }) + + const notifiable = disabledScheduleIds.slice(0, STALE_SCHEDULE_RECOVERY_NOTIFY_LIMIT) + if (disabledScheduleIds.length > notifiable.length) { + logger.warn('Capped schedule auto-disable notifications for stale recovery batch', { + disabled: disabledScheduleIds.length, + notified: notifiable.length, + }) + } + + for (const scheduleId of notifiable) { + await notifyScheduleAutoDisabled({ + scheduleId, + reason: 'consecutive_failures', + requestId: 'stale-schedule-recovery', + }) + } } function isStaleDatabaseScheduleJob(job: { status: string; startedAt?: Date }): boolean { @@ -1212,7 +1246,18 @@ export async function runScheduleTick(requestId: string): Promise * - * SSE stream of cell-state transitions. Replay-on-reconnect via `from`; - * absent `from` tails from the latest event id (fresh mount — the client has - * just fetched current state, so replaying history would rewind it). - * Pruning (buffer cap exceeded or TTL expired) sends a `pruned` event and - * closes; the client responds with a full row-query refetch and reconnects - * tailing from latest. */ + * SSE stream of cell-state transitions over the shared durable event log. Auth + * and access are checked here; the replay/tail/poll/heartbeat/prune mechanics + * come from `createEventStreamResponse`. */ export const GET = withRouteHandler(async (req: NextRequest, context: RouteContext) => { const requestId = generateRequestId() const parsed = await parseRequest(tableEventStreamContract, req, context) @@ -51,123 +35,13 @@ export const GET = withRouteHandler(async (req: NextRequest, context: RouteConte const access = await checkAccess(tableId, auth.userId, 'read') if (!access.ok) return accessError(access, requestId, tableId) - logger.info(`[${requestId}] Table event stream opened`, { tableId, fromEventId }) - - const encoder = new TextEncoder() - let closed = false - - const stream = new ReadableStream({ - async start(controller) { - let lastEventId = fromEventId ?? 0 - const deadline = Date.now() + MAX_STREAM_DURATION_MS - let nextHeartbeatAt = Date.now() + HEARTBEAT_INTERVAL_MS - - const enqueue = (text: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(text)) - } catch { - closed = true - } - } - - const sendEvents = (events: TableEventEntry[]) => { - for (const entry of events) { - if (closed) return - enqueue(`data: ${JSON.stringify(entry)}\n\n`) - lastEventId = entry.eventId - } - } - - const sendPrunedAndClose = (earliestEventId: number | undefined) => { - enqueue( - `event: pruned\ndata: ${JSON.stringify({ earliestEventId: earliestEventId ?? null })}\n\n` - ) - if (!closed) { - closed = true - try { - controller.close() - } catch {} - } - } - - const sendHeartbeat = () => { - // SSE comment line — keeps proxies (ALB default 60s idle) from closing - // the connection during quiet periods. - enqueue(`: ping ${Date.now()}\n\n`) - } - - try { - // No replay cursor → tail from the latest event id. Resolved inside - // the try so a Redis failure errors the stream (client reconnects - // with backoff) rather than silently replaying the whole buffer. - if (fromEventId === undefined) { - lastEventId = await getLatestTableEventId(tableId) - } - // Initial replay from buffer. - const initial = await readTableEventsSince(tableId, lastEventId) - if (initial.status === 'pruned') { - sendPrunedAndClose(initial.earliestEventId) - return - } - if (initial.status === 'unavailable') { - throw new Error(`Table event buffer unavailable: ${initial.error}`) - } - sendEvents(initial.events) - - // Stream loop — poll the buffer and forward new events. Workflow - // execution stream uses the same shape; pub/sub wakeups are an - // optimization we can add later if 500ms polling becomes a problem. - while (!closed && Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS) - if (closed) return - - const result = await readTableEventsSince(tableId, lastEventId) - if (result.status === 'pruned') { - sendPrunedAndClose(result.earliestEventId) - return - } - if (result.status === 'unavailable') { - throw new Error(`Table event buffer unavailable: ${result.error}`) - } - if (result.events.length > 0) { - sendEvents(result.events) - } - - if (Date.now() >= nextHeartbeatAt) { - sendHeartbeat() - nextHeartbeatAt = Date.now() + HEARTBEAT_INTERVAL_MS - } - } - - // Reached the defensive duration ceiling — close cleanly so the client - // reconnects with the latest lastEventId. - if (!closed) { - enqueue(`event: rotate\ndata: {}\n\n`) - closed = true - try { - controller.close() - } catch {} - } - } catch (error) { - logger.error(`[${requestId}] Table event stream error`, { - tableId, - error: toError(error).message, - }) - if (!closed) { - try { - controller.error(error) - } catch {} - } - } - }, - cancel() { - closed = true - logger.info(`[${requestId}] Client disconnected from table event stream`, { tableId }) - }, - }) - - return new NextResponse(stream, { - headers: { ...SSE_HEADERS, 'X-Table-Id': tableId }, + return createEventStreamResponse({ + requestId, + label: 'table', + streamId: tableId, + fromEventId, + getLatestEventId: getLatestTableEventId, + readEventsSince: readTableEventsSince, + extraHeaders: { 'X-Table-Id': tableId }, }) }) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 8b5de09bee0..c6f2e7c46b4 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -10,6 +10,7 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { signalTableSchemaChanged } from '@/lib/table/events' import { addWorkflowGroup, deleteWorkflowGroup, @@ -106,6 +107,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro }, requestId ) + signalTableSchemaChanged(tableId) return NextResponse.json({ success: true, data: { @@ -168,6 +170,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R }, requestId ) + signalTableSchemaChanged(tableId) return NextResponse.json({ success: true, data: { @@ -201,6 +204,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: { tableId, groupId: validated.groupId }, requestId ) + signalTableSchemaChanged(tableId) return NextResponse.json({ success: true, data: { diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 7a9802442cc..8ee9ed8f170 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -37,6 +37,7 @@ import { wouldExceedRowLimit, } from '@/lib/table' import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' +import { signalTableSchemaChanged } from '@/lib/table/events' import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' import { getUserSettings } from '@/lib/users/queries' import { @@ -323,6 +324,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro mappedColumns: validation.mappedHeaders.length, skippedHeaders: validation.skippedHeaders.length, }) + signalTableSchemaChanged(tableId) return NextResponse.json({ success: true, @@ -385,6 +387,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro createdColumns: additions.length, mappedColumns: validation.mappedHeaders.length, }) + signalTableSchemaChanged(tableId) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts index f85df0af4bd..6883b56038c 100644 --- a/apps/sim/app/api/table/[tableId]/metadata/route.ts +++ b/apps/sim/app/api/table/[tableId]/metadata/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableMetadata } from '@/lib/table' import { updateTableMetadata } from '@/lib/table' +import { signalTableMetadataChanged, signalTableSchemaChanged } from '@/lib/table/events' import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableMetadataAPI') @@ -43,11 +44,20 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const updated = await updateTableMetadata( + const { metadata: updated, schemaChanged } = await updateTableMetadata( tableId, validated.metadata, table.metadata as TableMetadata | null ) + // Signal collaborators to re-apply the new column layout (width/pin/order) live; the + // grid reconciles against its in-progress resize/drag so a peer's change never clobbers + // the local gesture. A reorder that scrubs a workflow-group's dependencies also mutated + // the schema — escalate to the schema signal so peers refresh run-state/enrichment too. + if (schemaChanged) { + signalTableSchemaChanged(tableId) + } else { + signalTableMetadataChanged(tableId) + } return NextResponse.json({ success: true, data: { metadata: updated } }) } catch (error) { diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 03eaa4c7243..225e7556613 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -19,6 +19,7 @@ import { updateTableLocks, } from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' +import { signalTableSchemaChanged } from '@/lib/table/events' import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { @@ -185,6 +186,8 @@ export const PATCH = withRouteHandler( if (validated.name !== undefined) { await renameTable(tableId, validated.name, requestId, authResult.userId) + // Live-collab: tell open viewers the definition changed so they refetch. + signalTableSchemaChanged(tableId) } if (validated.folderId !== undefined) { diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 82ec048ca84..6cb994bff85 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -15,6 +15,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' import { deleteRow, updateRow } from '@/lib/table' +import { signalTableRowsChanged } from '@/lib/table/events' import { rowWireTranslators } from '@/app/api/table/row-wire' import { accessError, @@ -147,6 +148,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR // Only `null` when a `cancellationGuard` is supplied and the SQL guard // rejects the write — this route doesn't pass one, so reaching null is a bug. if (!updatedRow) throw new Error('updateRow returned null without a cancellationGuard') + // An edit that also triggers a dispatch already emits dispatch/cell events; the + // debounced rows refetch on the peer coalesces the two. + signalTableRowsChanged(tableId) // Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new'). // Firing a second mode: 'incomplete' dispatch here would race with the // `mode: 'new'` one AND bulk-clear sibling-group outputs (the incomplete @@ -213,6 +217,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row } await deleteRow(table, rowId, requestId) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 748c7138b50..cd6350699bd 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -26,6 +26,7 @@ import { validateRowSize, } from '@/lib/table' import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' import { isTablePredicate, predicateToFilter } from '@/lib/table/query-builder/converters' import { validatePredicateShape, @@ -122,6 +123,7 @@ async function handleBatchInsert( table, requestId ) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, @@ -207,6 +209,7 @@ export const POST = withRouteHandler( table, requestId ) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, @@ -433,6 +436,7 @@ export const PUT = withRouteHandler( { status: 200 } ) } + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, @@ -499,6 +503,7 @@ export const DELETE = withRouteHandler( { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, requestId ) + if (result.deletedCount > 0) signalTableRowsChanged(tableId) return NextResponse.json({ success: true, @@ -528,6 +533,7 @@ export const DELETE = withRouteHandler( }, requestId ) + if (result.affectedCount > 0) signalTableRowsChanged(tableId) return NextResponse.json({ success: true, @@ -601,6 +607,7 @@ export const PATCH = withRouteHandler( table, requestId ) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts index bc97623ef9a..d6656c11ab4 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' import { upsertRow } from '@/lib/table' +import { signalTableRowsChanged } from '@/lib/table/events' import { rowWireTranslators } from '@/app/api/table/row-wire' import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' @@ -54,6 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser table, requestId ) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts index a1f73b9d6af..95920fd4475 100644 --- a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -5,7 +5,7 @@ import { instagramPublishCarouselContract } from '@/lib/api/contracts/tools/inst import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveInstagramCarouselMedia } from '@/lib/integrations/instagram/resolve-media' +import { resolveInstagramCarouselMedia } from '@/app/api/tools/instagram/resolve-media' import { createMediaContainer, publishMediaContainer, diff --git a/apps/sim/app/api/tools/instagram/publish-image/route.ts b/apps/sim/app/api/tools/instagram/publish-image/route.ts index 8b97b5c9228..2d6af6d1a55 100644 --- a/apps/sim/app/api/tools/instagram/publish-image/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-image/route.ts @@ -5,7 +5,7 @@ import { instagramPublishImageContract } from '@/lib/api/contracts/tools/instagr import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media' import { createMediaContainer, publishMediaContainer, diff --git a/apps/sim/app/api/tools/instagram/publish-reel/route.ts b/apps/sim/app/api/tools/instagram/publish-reel/route.ts index b68a23da438..a33b6bfa98e 100644 --- a/apps/sim/app/api/tools/instagram/publish-reel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-reel/route.ts @@ -5,7 +5,7 @@ import { instagramPublishReelContract } from '@/lib/api/contracts/tools/instagra import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media' import { createMediaContainer, publishMediaContainer, diff --git a/apps/sim/app/api/tools/instagram/publish-story/route.ts b/apps/sim/app/api/tools/instagram/publish-story/route.ts index bee2b6ed62e..2d1717e12a7 100644 --- a/apps/sim/app/api/tools/instagram/publish-story/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-story/route.ts @@ -5,7 +5,7 @@ import { instagramPublishStoryContract } from '@/lib/api/contracts/tools/instagr import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media' import { createMediaContainer, publishMediaContainer, diff --git a/apps/sim/app/api/tools/instagram/publish-video/route.ts b/apps/sim/app/api/tools/instagram/publish-video/route.ts index d2eb3fe5b3e..ab805234ce6 100644 --- a/apps/sim/app/api/tools/instagram/publish-video/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-video/route.ts @@ -5,7 +5,7 @@ import { instagramPublishVideoContract } from '@/lib/api/contracts/tools/instagr import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media' import { createMediaContainer, publishMediaContainer, diff --git a/apps/sim/app/api/tools/instagram/resolve-media.test.ts b/apps/sim/app/api/tools/instagram/resolve-media.test.ts new file mode 100644 index 00000000000..a72c56fa28f --- /dev/null +++ b/apps/sim/app/api/tools/instagram/resolve-media.test.ts @@ -0,0 +1,258 @@ +/** + * @vitest-environment node + */ +import type { Logger } from '@sim/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHasCloudStorage, mockResolveFileInputToUrl } = vi.hoisted(() => ({ + mockHasCloudStorage: vi.fn(), + mockResolveFileInputToUrl: vi.fn(), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + hasCloudStorage: mockHasCloudStorage, +})) + +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + resolveFileInputToUrl: mockResolveFileInputToUrl, +})) + +import { + INSTAGRAM_MEDIA_URL_TTL_SECONDS, + resolveInstagramCarouselMedia, + resolveInstagramMedia, +} from '@/app/api/tools/instagram/resolve-media' + +const logger = {} as Logger +const context = { + userId: 'user-1', + requestId: 'request-1', + logger, +} + +function uploadedFile(overrides: Record = {}) { + return { + id: 'file-1', + key: 'execution/workflow-1/execution-1/photo.jpg', + name: 'photo.jpg', + size: 1024, + type: 'image/jpeg', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockHasCloudStorage.mockReturnValue(true) + mockResolveFileInputToUrl.mockImplementation( + async ({ file, filePath }: { file?: { name?: string }; filePath?: string }) => ({ + fileUrl: filePath || `https://signed.example.com/${file?.name || 'media'}`, + }) + ) +}) + +describe('resolveInstagramMedia', () => { + it('accepts a public HTTPS media URL', async () => { + const result = await resolveInstagramMedia({ + ...context, + input: 'https://cdn.example.com/photo.jpg', + role: 'image', + }) + + expect(result).toEqual({ + media: expect.objectContaining({ + url: 'https://cdn.example.com/photo.jpg', + kind: 'image', + mimeType: 'image/jpeg', + }), + }) + }) + + it('rejects a public HTTP media URL before resolving it', async () => { + const result = await resolveInstagramMedia({ + ...context, + input: 'http://cdn.example.com/photo.jpg', + role: 'image', + }) + + expect(result.error).toEqual({ + status: 400, + message: 'Instagram media URLs must use HTTPS so Meta can download them', + }) + expect(mockResolveFileInputToUrl).not.toHaveBeenCalled() + }) + + it('resolves an uploaded file with the Instagram publishing URL lifetime', async () => { + const file = uploadedFile() + const result = await resolveInstagramMedia({ ...context, input: file, role: 'image' }) + + expect(result.media).toEqual({ + url: 'https://signed.example.com/photo.jpg', + kind: 'image', + mimeType: 'image/jpeg', + size: 1024, + name: 'photo.jpg', + }) + expect(mockResolveFileInputToUrl).toHaveBeenCalledWith({ + file, + filePath: undefined, + ...context, + presignExpirySeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS, + }) + }) + + it('requires cloud storage for uploaded files and internal URLs', async () => { + mockHasCloudStorage.mockReturnValue(false) + + for (const input of [uploadedFile(), '/api/files/serve/execution/photo.jpg']) { + const result = await resolveInstagramMedia({ ...context, input, role: 'image' }) + expect(result.error).toEqual({ + status: 400, + message: expect.stringContaining('Cloud storage is required'), + }) + } + expect(mockResolveFileInputToUrl).not.toHaveBeenCalled() + }) + + it('validates JPEG MIME type and size without loading file bytes', async () => { + const invalidType = await resolveInstagramMedia({ + ...context, + input: uploadedFile({ name: 'photo.png', type: 'image/png' }), + role: 'image', + label: 'Image', + }) + const oversized = await resolveInstagramMedia({ + ...context, + input: uploadedFile({ size: 8 * 1024 * 1024 + 1 }), + role: 'image', + label: 'Image', + }) + + expect(invalidType.error?.message).toBe('Image must be a JPEG image (got image/png)') + expect(oversized.error?.message).toContain("Instagram's 8MB JPEG limit") + }) + + it.each([ + { role: 'video' as const, maxBytes: 300 * 1024 * 1024, label: 'Video' }, + { role: 'story' as const, maxBytes: 100 * 1024 * 1024, label: 'Story' }, + ])('enforces the $role video size limit', async ({ role, maxBytes, label }) => { + const result = await resolveInstagramMedia({ + ...context, + input: uploadedFile({ + key: 'execution/workflow-1/execution-1/video.mp4', + name: 'video.mp4', + size: maxBytes + 1, + type: 'video/mp4', + }), + role, + label, + }) + + expect(result.error?.message).toContain(`video limit for ${role}`) + }) + + it('rejects unsupported video formats', async () => { + const result = await resolveInstagramMedia({ + ...context, + input: uploadedFile({ name: 'video.webm', type: 'video/webm' }), + role: 'video', + label: 'Video', + }) + + expect(result.error?.message).toBe('Video must be an MP4 or MOV video (got video/webm)') + }) +}) + +describe('resolveInstagramCarouselMedia', () => { + it('parses JSON input and infers image and video item types sequentially', async () => { + let activeResolutions = 0 + let maxActiveResolutions = 0 + mockResolveFileInputToUrl.mockImplementation(async ({ filePath }: { filePath?: string }) => { + activeResolutions += 1 + maxActiveResolutions = Math.max(maxActiveResolutions, activeResolutions) + await Promise.resolve() + activeResolutions -= 1 + return { fileUrl: filePath } + }) + + const result = await resolveInstagramCarouselMedia( + JSON.stringify([ + 'https://cdn.example.com/carousel-1.jpg', + 'https://cdn.example.com/carousel-2.mp4', + ]), + context.userId, + context.requestId, + logger + ) + + expect(result.items?.map(({ url, kind }) => ({ url, kind }))).toEqual([ + { url: 'https://cdn.example.com/carousel-1.jpg', kind: 'image' }, + { url: 'https://cdn.example.com/carousel-2.mp4', kind: 'video' }, + ]) + expect(maxActiveResolutions).toBe(1) + }) + + it('supports legacy comma-separated URLs with an explicit video prefix', async () => { + const result = await resolveInstagramCarouselMedia( + 'https://cdn.example.com/carousel-1.jpg, video:https://cdn.example.com/carousel-2.mp4', + context.userId, + context.requestId, + logger + ) + + expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video']) + }) + + it('infers media types for uploaded file arrays', async () => { + const result = await resolveInstagramCarouselMedia( + [ + uploadedFile(), + uploadedFile({ + id: 'file-2', + key: 'execution/workflow-1/execution-1/video.mov', + name: 'video.mov', + type: 'video/quicktime', + }), + ], + context.userId, + context.requestId, + logger + ) + + expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video']) + }) + + it.each([ + { count: 1, label: 'too few' }, + { count: 11, label: 'too many' }, + ])('rejects $label carousel items before resolving them', async ({ count }) => { + const input = Array.from( + { length: count }, + (_, index) => `https://cdn.example.com/carousel-${index + 1}.jpg` + ) + + const result = await resolveInstagramCarouselMedia( + input, + context.userId, + context.requestId, + logger + ) + + expect(result.error).toEqual({ + status: 400, + message: 'Carousels require between 2 and 10 items', + }) + expect(mockResolveFileInputToUrl).not.toHaveBeenCalled() + }) + + it('rejects malformed carousel JSON', async () => { + const result = await resolveInstagramCarouselMedia( + '[not-json', + context.userId, + context.requestId, + logger + ) + + expect(result.error).toEqual({ status: 400, message: 'Carousel media JSON is invalid' }) + }) +}) diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/app/api/tools/instagram/resolve-media.ts similarity index 100% rename from apps/sim/lib/integrations/instagram/resolve-media.ts rename to apps/sim/app/api/tools/instagram/resolve-media.ts diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index 978fbfdf751..8f75f565454 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -12,10 +12,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockHandleVectorOnlySearch, - mockHandleTagOnlySearch, - mockHandleTagAndVectorSearch, - mockGetQueryStrategy, + mockExecuteKnowledgeSearch, mockGenerateSearchEmbedding, mockGetDocumentMetadataByIds, mockAuthenticateRequest, @@ -24,10 +21,7 @@ const { mockResolveSystemBillingAttribution, mockRecordSearchEmbeddingUsage, } = vi.hoisted(() => ({ - mockHandleVectorOnlySearch: vi.fn(), - mockHandleTagOnlySearch: vi.fn(), - mockHandleTagAndVectorSearch: vi.fn(), - mockGetQueryStrategy: vi.fn(), + mockExecuteKnowledgeSearch: vi.fn(), mockGenerateSearchEmbedding: vi.fn(), mockGetDocumentMetadataByIds: vi.fn(), mockAuthenticateRequest: vi.fn(), @@ -51,10 +45,7 @@ const SYSTEM_BILLING_ATTRIBUTION = { } vi.mock('@/app/api/knowledge/search/utils', () => ({ - handleVectorOnlySearch: mockHandleVectorOnlySearch, - handleTagOnlySearch: mockHandleTagOnlySearch, - handleTagAndVectorSearch: mockHandleTagAndVectorSearch, - getQueryStrategy: mockGetQueryStrategy, + executeKnowledgeSearch: mockExecuteKnowledgeSearch, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, })) @@ -115,12 +106,11 @@ describe('v1 knowledge search route — per-KB embedding model', () => { rateLimit: {}, }) mockValidateWorkspaceAccess.mockResolvedValue(null) - mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 0.5 }) mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false, }) - mockHandleVectorOnlySearch.mockResolvedValue([]) + mockExecuteKnowledgeSearch.mockResolvedValue([]) mockGetDocumentMetadataByIds.mockResolvedValue({}) mockResolveBillingAttribution.mockImplementation( ({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) => @@ -218,7 +208,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => { hasAccess: true, knowledgeBase: baseKb('kb-confluence', 'text-embedding-3-small'), }) - mockHandleVectorOnlySearch.mockResolvedValue([ + mockExecuteKnowledgeSearch.mockResolvedValue([ { documentId: 'doc-confluence', knowledgeBaseId: 'kb-confluence', @@ -250,7 +240,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => { }) it('allows tag-only search across mixed embedding models', async () => { - mockHandleTagOnlySearch.mockResolvedValue([]) + mockExecuteKnowledgeSearch.mockResolvedValue([]) mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: baseKb('kb-mixed', 'text-embedding-3-small'), diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 3ac848b303d..490c8e88b7b 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -13,12 +13,9 @@ import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' import { + executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, - getQueryStrategy, - handleTagAndVectorSearch, - handleTagOnlySearch, - handleVectorOnlySearch, type SearchResult, } from '@/app/api/knowledge/search/utils' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' @@ -49,7 +46,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, topK, query, tagFilters } = parsed.data.body + const { workspaceId, topK, query, tagFilters, searchMode } = parsed.data.body const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) if (accessError) return accessError @@ -190,29 +187,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let queryEmbeddingIsBYOK: boolean | null = null if (!hasQuery && hasFilters) { - results = await handleTagOnlySearch({ + results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK, + searchMode, structuredFilters, }) - } else if (hasQuery && hasFilters) { - const strategy = getQueryStrategy(accessibleKbIds.length, topK) - const queryEmbeddingResult = await generateSearchEmbedding( - query!, - queryEmbeddingModel, - workspaceId - ) - queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - const queryVector = JSON.stringify(queryEmbeddingResult.embedding) - results = await handleTagAndVectorSearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - structuredFilters, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) } else if (hasQuery) { - const strategy = getQueryStrategy(accessibleKbIds.length, topK) const queryEmbeddingResult = await generateSearchEmbedding( query!, queryEmbeddingModel, @@ -220,11 +201,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK const queryVector = JSON.stringify(queryEmbeddingResult.embedding) - results = await handleVectorOnlySearch({ + results = await executeKnowledgeSearch({ knowledgeBaseIds: accessibleKbIds, topK, + searchMode, + query, queryVector, - distanceThreshold: strategy.distanceThreshold, + structuredFilters: hasFilters ? structuredFilters : undefined, }) } else { return NextResponse.json( diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index d1a507fa195..b5471842a6c 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -21,6 +21,7 @@ import { import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' import { columnTypeById } from '@/lib/table/column-types' import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { signalTableSchemaChanged } from '@/lib/table/events' import { accessError, checkAccess, @@ -76,6 +77,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum } const updatedTable = await addTableColumn(tableId, validated.column, requestId) + signalTableSchemaChanged(tableId) recordAudit({ workspaceId: validated.workspaceId, @@ -329,6 +331,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu if (!updatedTable) { return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) } + signalTableSchemaChanged(tableId) recordAudit({ workspaceId: validated.workspaceId, @@ -416,6 +419,7 @@ export const DELETE = withRouteHandler( { tableId, columnName: validated.columnName }, requestId ) + signalTableSchemaChanged(tableId) recordAudit({ workspaceId: validated.workspaceId, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 5fee4f3d03b..fe2a5a022ab 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -16,6 +16,7 @@ import type { RowData, TableSchema } from '@/lib/table' import { deleteRow, updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' +import { signalTableRowsChanged } from '@/lib/table/events' import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, @@ -159,6 +160,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR if (!updatedRow) { return NextResponse.json({ error: 'Row not found' }, { status: 404 }) } + signalTableRowsChanged(tableId) // Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new'). // Firing a second mode: 'incomplete' dispatch here would race with it AND // bulk-clear sibling-group outputs. @@ -241,6 +243,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row // Route through the service (not a raw `db.delete`) so the delete lock is // enforced — the raw path would return 200 on a locked table. await deleteRow(result.table, rowId, requestId) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index bef97b4d8f0..2a7f6244f88 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -29,6 +29,7 @@ import { sortNamesToIds, } from '@/lib/table/column-keys' import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' import { queryRows } from '@/lib/table/rows/service' import { resolveFilterSelectValues } from '@/lib/table/select-values' import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' @@ -89,6 +90,7 @@ async function handleBatchInsert( table, requestId ) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, @@ -282,6 +284,7 @@ export const POST = withRouteHandler( table, requestId ) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, @@ -367,6 +370,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR }, requestId ) + if (result.affectedCount > 0) signalTableRowsChanged(tableId) if (result.affectedCount === 0) { return NextResponse.json({ @@ -439,6 +443,7 @@ export const DELETE = withRouteHandler( { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, requestId ) + if (result.deletedCount > 0) signalTableRowsChanged(tableId) return NextResponse.json({ success: true, @@ -467,6 +472,7 @@ export const DELETE = withRouteHandler( }, requestId ) + if (result.affectedCount > 0) signalTableRowsChanged(tableId) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index bf4a00df91b..f89bad3096f 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -9,6 +9,7 @@ import type { RowData, TableSchema } from '@/lib/table' import { upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' +import { signalTableRowsChanged } from '@/lib/table/events' import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, @@ -75,6 +76,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser table, requestId ) + signalTableRowsChanged(tableId) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index c57ac919e38..11726424281 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -38,15 +38,20 @@ describe('GET /api/workspaces/[id]/files/inline', () => { mockDownloadFile.mockResolvedValue(PNG) }) - it('serves a workspace-scoped image by fileId', async () => { + it('serves a workspace-scoped image by fileId, always revalidating', async () => { const res = await GET(req('fileId=wf_abc'), params) expect(res.status).toBe(200) expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' }) + // Authenticated content: always revalidate so a deletion/revocation is enforced on the next request. + expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') }) - it('serves a workspace-scoped image by key', async () => { + it('serves a workspace-scoped image by key, always revalidating', async () => { const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params) expect(res.status).toBe(200) + // Same policy as fileId: authenticated content never cached past a revalidation, so a deleted or + // access-revoked image drops out immediately rather than lingering in a private browser cache. + expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') }) it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => { diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts index fd29a6c9dfb..4ed0b90c285 100644 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/register/route.ts @@ -7,6 +7,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { FileConflictError, parseWorkspaceFileKey, @@ -63,6 +64,8 @@ export const POST = withRouteHandler( if (created) { logger.info(`Registered direct upload ${name} -> ${key}`) + await notifyWorkspaceFilesChanged(workspaceId) + captureServerEvent( userId, 'file_uploaded', diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index d36de9f8083..2bc0d86370a 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -19,11 +19,17 @@ export const dynamic = 'force-dynamic' /** * Browser half of the CLI key handoff. * - * Signed-out visitors bounce through login carrying a *re-serialized* + * Signed-out visitors bounce through signup carrying a *re-serialized* * `callbackUrl` — only the params the handoff understands survive, so the round * trip cannot be used to smuggle anything else back into this page. The request * is validated before that bounce: a bogus callback is rejected here rather * than after making the user sign in for nothing. + * + * Signup rather than login because this page is reached from a terminal: the + * setup wizard sends people here while standing up a self-host, and someone + * configuring Sim for the first time has no account yet. Both auth pages + * cross-link carrying the same `callbackUrl`, so a returning user is one click + * from login with their destination intact. */ export default async function CliAuthPage({ searchParams, @@ -43,7 +49,7 @@ export default async function CliAuthPage({ challenge: resolution.request.challenge, pairing: resolution.request.pairing, }) - redirect(`/login?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) + redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) } return ( diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 3a42fca1bc8..334bd36b029 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -6,7 +6,12 @@ import { BrandedLayout } from '@/components/branded-layout' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' import '@/app/_styles/globals.css' -import { isHosted, isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/env-flags' +import { + isChatEnabled, + isHosted, + isReactGrabEnabled, + isReactScanEnabled, +} from '@/lib/core/config/env-flags' import { DesktopUpdateGate } from '@/app/_shell/desktop-update-gate' import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler' import { QueryProvider } from '@/app/_shell/providers/query-provider' @@ -150,6 +155,12 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } var activeTab = panelState && panelState.activeTab; + // A session that used the Chat tab before it was turned off still + // has 'copilot' persisted; without this the CSS hides every tab + // body and the panel paints empty. + if (activeTab === 'copilot' && !${isChatEnabled}) { + activeTab = 'toolbar'; + } if (activeTab) { document.documentElement.setAttribute('data-panel-active-tab', activeTab); } diff --git a/apps/sim/app/upgrade/page.tsx b/apps/sim/app/upgrade/page.tsx new file mode 100644 index 00000000000..aa9a9263ee0 --- /dev/null +++ b/apps/sim/app/upgrade/page.tsx @@ -0,0 +1,37 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { isUpgradeReason, UPGRADE_REASON_PARAM } from '@/lib/billing/upgrade-reasons' + +/** + * Public upgrade entry, for callers that cannot know a workspace id — a + * self-hosted deployment linking its users to the hosted plans, or an email + * that predates a workspace switch. + * + * Workspace resolution is not repeated here: `/workspace` already owns it, + * including local recency, last-active fallback, stale-session recovery, and + * the no-workspace creation policy. + */ +export default async function UpgradePage({ + searchParams, +}: { + searchParams: Promise> +}) { + const [session, params] = await Promise.all([getSession(), searchParams]) + + const rawReason = params[UPGRADE_REASON_PARAM] + const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason + const reason = isUpgradeReason(reasonValue) ? reasonValue : undefined + + const target = reason + ? `/workspace?redirect=upgrade&${UPGRADE_REASON_PARAM}=${reason}` + : '/workspace?redirect=upgrade' + + // `/workspace` recovers a signed-out visitor by hard-navigating to `/login` + // with no callback, which would drop the upgrade intent — carry it here + // instead, where the destination is still known. + if (!session?.user) { + redirect(`/login?callbackUrl=${encodeURIComponent(target)}`) + } + + redirect(target) +} diff --git a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/layout.tsx index 6dc5b2e20bd..e1b93d67538 100644 --- a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/layout.tsx @@ -1,3 +1,8 @@ +import { notFound } from 'next/navigation' +import { isChatEnabled } from '@/lib/core/config/env-flags' + export default function ChatLayout({ children }: { children: React.ReactNode }) { + if (!isChatEnabled) notFound() + return
{children}
} diff --git a/apps/sim/app/workspace/[workspaceId]/components/presence/presence-avatars.tsx b/apps/sim/app/workspace/[workspaceId]/components/presence/presence-avatars.tsx new file mode 100644 index 00000000000..a76ec009448 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/presence/presence-avatars.tsx @@ -0,0 +1,116 @@ +'use client' + +import { Avatar, AvatarFallback, AvatarImage, cn, Tooltip } from '@sim/emcn' +import { getUserColor } from '@/lib/workspaces/colors' + +/** Minimal presence shape the avatar stack renders — shared by workflow and files. */ +export interface PresenceAvatarUser { + /** Unique id per presence entry, used as the render key: a socket id where presence is + * per-connection (workflow), absent where entries are deduped per user (file docs). */ + socketId?: string + userId: string + userName?: string + avatarUrl?: string | null +} + +interface UserAvatarProps { + user: PresenceAvatarUser + index: number +} + +/** + * A single collaborator avatar: their image, falling back to a colored circle + * with their initial. Wrapped in a name tooltip when the name is known. + */ +function UserAvatar({ user, index }: UserAvatarProps) { + const color = getUserColor(user.userId) + const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?' + + const avatarElement = ( + + {user.avatarUrl && ( + + )} + + {initials} + + + ) + + if (user.userName) { + return ( + + {avatarElement} + + {user.userName} + + + ) + } + + return avatarElement +} + +interface PresenceAvatarsProps { + /** Collaborators to show — already filtered to exclude the current socket. */ + users: PresenceAvatarUser[] + /** Max avatars before collapsing the remainder into a "+N" chip. */ + maxVisible?: number + /** Layout-only classes for the outer stack (e.g. surrounding margin); chrome is owned here. */ + className?: string +} + +const DEFAULT_MAX_VISIBLE = 5 + +/** + * Overlapping stack of collaborator avatars for presence. Presentational only — + * the caller owns fetching/filtering presence (workflow sidebar item, files + * header, etc.), so the stack looks identical everywhere it appears. + */ +export function PresenceAvatars({ + users, + maxVisible = DEFAULT_MAX_VISIBLE, + className, +}: PresenceAvatarsProps) { + // Reverse so the rightmost avatar stays stable as new ones reveal on the left. + // slice() already returns a fresh array, so the in-place reverse is safe. + const visibleUsers = users.slice(0, maxVisible).reverse() + const overflowCount = Math.max(0, users.length - maxVisible) + + if (visibleUsers.length === 0) { + return null + } + + return ( +
+ {overflowCount > 0 && ( + + + + + +{overflowCount} + + + + + {overflowCount} more user{overflowCount > 1 ? 's' : ''} + + + )} + {visibleUsers.map((user, index) => ( + + ))} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index a28d735a1f8..df0960385c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -33,7 +33,6 @@ import { ArrowUpLeft } from 'lucide-react' import { createPortal } from 'react-dom' import { TITLE_BAR_LANE_PT } from '@/components/page-header-bar' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' -import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' export interface DropdownOption { label: string @@ -195,10 +194,9 @@ export const ResourceHeader = memo(function ResourceHeader({ {TitleIcon && } {titleLabel && ( - + + {titleLabel} + )} )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx index 12a3d72a794..2ba7a2e3131 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx @@ -2,6 +2,7 @@ import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react' import { cn } from '@sim/emcn' +import './document-table.css' interface EditConfig { onCellChange: (row: number, col: number, value: string) => void @@ -20,6 +21,11 @@ export interface DataTableHandle { type EditingCell = { row: number; col: number } | null +/** + * Tabular renderer for CSV and XLSX previews. Chrome (borders, padding, typography, header fill) + * comes entirely from `document-table.css`, the definition shared with markdown tables in the rich + * markdown editor — the only classes here are the optional edit affordances. + */ const DataTableBase = forwardRef(function DataTable( { headers, rows, editConfig }, ref @@ -94,16 +100,15 @@ const DataTableBase = forwardRef(function DataT editingCell?.row === row && editingCell?.col === col return ( -
- - +
+
+ {headers.map((header, i) => ( {rows.map((row, ri) => ( - + {headers.map((_, ci) => (
editConfig && startEdit(-1, i, String(header ?? ''))} > @@ -114,7 +119,7 @@ const DataTableBase = forwardRef(function DataT onChange={(e) => setEditValue(e.target.value)} onBlur={commitEdit} onKeyDown={handleKeyDown} - className='w-full min-w-[60px] bg-transparent font-semibold text-[12px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' + className='w-full min-w-[60px] bg-transparent outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' /> ) : ( String(header ?? '') @@ -125,13 +130,12 @@ const DataTableBase = forwardRef(function DataT
editConfig && startEdit(ri, ci, String(row[ci] ?? ''))} > @@ -142,7 +146,7 @@ const DataTableBase = forwardRef(function DataT onChange={(e) => setEditValue(e.target.value)} onBlur={commitEdit} onKeyDown={handleKeyDown} - className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' + className='w-full min-w-[60px] bg-transparent outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' /> ) : ( String(row[ci] ?? '') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css new file mode 100644 index 00000000000..6a73506634b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.css @@ -0,0 +1,46 @@ +/* + * Canonical table chrome for the file viewer. Both surfaces that render a table for a file — the + * rich markdown editor (`.rich-markdown-prose table`) and the tabular previews CSV/XLSX render + * through `DataTable` (`.document-table`) — share this one definition so a table looks the same + * whichever file it came from. Editor-only concerns (prose block margin, fixed layout for column + * resizing, cell paragraph reset) stay in rich-markdown-editor.css. + */ + +/* `overflow-wrap` matches what `.rich-markdown-prose` sets on its own root: cells hold arbitrary + file data, so an unbreakable token (a URL, a hash) must break rather than overflow its cell. */ +.document-table { + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.rich-markdown-prose table, +.document-table table { + width: 100%; + border-collapse: collapse; + overflow: hidden; +} + +.rich-markdown-prose th, +.rich-markdown-prose td, +.document-table th, +.document-table td { + position: relative; + border: 1px solid var(--divider); + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; + font-size: 14px; + line-height: 1.5rem; +} + +.rich-markdown-prose th, +.document-table th { + background: var(--surface-4); + font-weight: 600; +} + +/* Cell editors are invisible until focused: they take the cell's own typography and color. */ +.document-table input { + font: inherit; + color: inherit; +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts new file mode 100644 index 00000000000..753d4f5c343 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/document-table.test.ts @@ -0,0 +1,155 @@ +/** + * @vitest-environment jsdom + * + * A table must look the same whichever file it came from: a markdown file rendered by the rich + * markdown editor (`.rich-markdown-prose table`) and a CSV/XLSX preview rendered by `DataTable` + * (`.document-table`) sit in the same file viewer, in the same session, one click apart. They used + * to drift — the previews carried their own chrome (rounded outer frame, `--surface-2` header, + * 13px body / 12px header, `--text-secondary` cells) while markdown tables used full cell borders + * on `--divider`, a `--surface-4` header, and 14px text. + * + * These load the real, shipped CSS (not a copy). Two complementary assertions, because jsdom's CSS + * engine resolves only part of what matters here: it applies the cascade for longhand declarations + * (so `getComputedStyle` parity is a real check on padding/typography/fill), but it does not expand + * the `border` shorthand at all. Borders are therefore checked structurally — one rule, whose + * selector list covers both roots — which is also the property that actually prevents drift. + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' + +const SHARED_CSS_PATH = path.join(__dirname, 'document-table.css') +const EDITOR_CSS_PATH = path.join(__dirname, 'rich-markdown-editor', 'rich-markdown-editor.css') + +/** Chrome both surfaces must agree on, as longhands jsdom resolves. */ +const SHARED_CELL_PROPS = [ + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'font-size', + 'line-height', + 'text-align', + 'vertical-align', +] as const + +const sheets = new Map() + +beforeAll(() => { + for (const file of [SHARED_CSS_PATH, EDITOR_CSS_PATH]) { + const style = document.createElement('style') + style.textContent = readFileSync(file, 'utf-8') + document.head.appendChild(style) + if (!style.sheet) throw new Error(`stylesheet did not parse: ${file}`) + sheets.set(file, style.sheet) + } +}) + +let containers: HTMLDivElement[] = [] + +afterEach(() => { + for (const c of containers) c.remove() + containers = [] +}) + +/** Mounts a one-cell table inside `rootClass` and returns the root plus its `th` and `td`. */ +function mountTable(rootClass: string): { root: HTMLElement; th: HTMLElement; td: HTMLElement } { + const container = document.createElement('div') + container.className = rootClass + container.innerHTML = + '
h
c
' + document.body.appendChild(container) + containers.push(container) + const th = container.querySelector('th') + const td = container.querySelector('td') + if (!th || !td) throw new Error('table cells not found') + return { root: container, th, td } +} + +function declarations(el: Element, props: readonly string[]): Record { + const computed = getComputedStyle(el) + return Object.fromEntries(props.map((p) => [p, computed.getPropertyValue(p)])) +} + +/** Style rules of one loaded stylesheet, in source order. */ +function styleRules(cssPath: string): CSSStyleRule[] { + const sheet = sheets.get(cssPath) + if (!sheet) throw new Error(`stylesheet not loaded: ${cssPath}`) + return Array.from(sheet.cssRules).filter((r): r is CSSStyleRule => r instanceof CSSStyleRule) +} + +/** Selector list of the single shared rule declaring `property: value`, as trimmed selectors. */ +function selectorsDeclaring(cssPath: string, property: string, value: string): string[] { + const matching = styleRules(cssPath).filter((r) => + r.style.getPropertyValue(property).includes(value) + ) + expect(matching).toHaveLength(1) + return matching[0].selectorText.split(',').map((s) => s.trim()) +} + +describe('document-table chrome is shared with markdown tables', () => { + it('cells resolve to identical padding and typography', () => { + const prose = mountTable('rich-markdown-prose') + const preview = mountTable('document-table') + + expect(declarations(preview.td, SHARED_CELL_PROPS)).toEqual( + declarations(prose.td, SHARED_CELL_PROPS) + ) + expect(declarations(preview.th, SHARED_CELL_PROPS)).toEqual( + declarations(prose.th, SHARED_CELL_PROPS) + ) + }) + + /** + * Cells hold arbitrary file data, so an unbreakable token (a URL, a hash) must break rather than + * overflow — the previews lost `whitespace-nowrap` and would otherwise have no wrapping rule at + * all. `overflow-wrap` is inherited from each surface's root (jsdom does not propagate inherited + * properties to descendants, so the roots are what can be asserted). + */ + it('both roots declare the same wrapping for unbreakable cell values', () => { + const prose = mountTable('rich-markdown-prose') + const preview = mountTable('document-table') + + const wrap = getComputedStyle(prose.root).getPropertyValue('overflow-wrap') + expect(wrap).toBe('anywhere') + expect(getComputedStyle(preview.root).getPropertyValue('overflow-wrap')).toBe(wrap) + }) + + it('the resolved values are the markdown editor values, not jsdom defaults', () => { + const { th, td } = mountTable('document-table') + + expect(getComputedStyle(td).getPropertyValue('padding-left')).toBe('0.75rem') + expect(getComputedStyle(td).getPropertyValue('font-size')).toBe('14px') + expect(getComputedStyle(th).getPropertyValue('font-weight')).toBe('600') + }) + + it('one rule draws the cell border for both roots', () => { + expect(selectorsDeclaring(SHARED_CSS_PATH, 'border', 'var(--divider)')).toEqual( + expect.arrayContaining([ + '.rich-markdown-prose th', + '.rich-markdown-prose td', + '.document-table th', + '.document-table td', + ]) + ) + }) + + it('one rule fills the header for both roots', () => { + expect(selectorsDeclaring(SHARED_CSS_PATH, 'background', 'var(--surface-4)')).toEqual( + expect.arrayContaining(['.rich-markdown-prose th', '.document-table th']) + ) + }) + + it('the editor stylesheet no longer re-declares cell chrome of its own', () => { + const redeclared = styleRules(EDITOR_CSS_PATH).filter( + (r) => + /\.rich-markdown-prose (th|td)\b/.test(r.selectorText) && + (r.style.getPropertyValue('border') || + r.style.getPropertyValue('padding') || + r.style.getPropertyValue('font-size') || + r.style.getPropertyValue('background')) + ) + + expect(redeclared.map((r) => r.selectorText)).toEqual([]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 9984e16b0df..006012191ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -108,8 +108,20 @@ interface FileViewerProps { streamingContent?: string isAgentEditing?: boolean streamIsIncremental?: boolean + streamOperation?: string disableStreamingAutoScroll?: boolean previewContextKey?: string + /** + * Opt this surface into live collaborative editing (markdown files only). Set by the + * Files page; the agent/Chat surface leaves it off so collaboration and agent-streaming + * never target one editor. See {@link RichMarkdownEditorProps.collaborative}. + */ + collaborative?: boolean + /** + * Called (debounced) with the markdown document's leading-heading text while the file is still + * untitled, so the caller can name the file after it. Only wired for the editable markdown editor. + */ + onDeriveTitleFromHeading?: (headingText: string) => void } export function FileViewer(props: FileViewerProps) { @@ -139,8 +151,11 @@ function FileViewerContent({ streamingContent, isAgentEditing, streamIsIncremental, + streamOperation, disableStreamingAutoScroll = false, previewContextKey, + collaborative, + onDeriveTitleFromHeading, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -183,8 +198,11 @@ function FileViewerContent({ streamingContent={streamingContent} isAgentEditing={isAgentEditing} streamIsIncremental={streamIsIncremental} + streamOperation={streamOperation} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + collaborative={collaborative} + onDeriveTitleFromHeading={onDeriveTitleFromHeading} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block-schema.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block-schema.ts new file mode 100644 index 00000000000..5feaeaf85dc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block-schema.ts @@ -0,0 +1,34 @@ +import type { JSONContent } from '@tiptap/core' +import { CodeBlock } from '@tiptap/extension-code-block' + +/** + * React-free schema half of the code-block node. Lives apart from {@link ./code-block} (its React + * node view) so the shared editor schema — `createMarkdownContentExtensions` in `./extensions` — can + * be imported by server code (the collab-doc seed converter) without pulling a client component + * (`useEffect`) into a Server Component module. The client editor injects the node-view variant + * ({@link CodeBlockWithLanguage}) via `nodeViews`. + */ + +function codeBlockText(node: JSONContent): string { + return (node.content ?? []).map((child) => child.text ?? '').join('') +} + +/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */ +function fenceFor(text: string): string { + const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length)) + return '`'.repeat(Math.max(3, longestRun + 1)) +} + +/** + * Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code + * block that itself contains a ``` line round-trips instead of shattering. Shared by the test + * (plain) and live ({@link CodeBlockWithLanguage}) paths. + */ +export const MarkdownCodeBlock = CodeBlock.extend({ + renderMarkdown: (node: JSONContent) => { + const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '' + const text = codeBlockText(node) + const fence = fenceFor(text) + return `${fence}${language}\n${text}\n${fence}` + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index 7db9b1481b9..2464ccaec44 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -8,12 +8,11 @@ import { DropdownMenuTrigger, useCopyToClipboard, } from '@sim/emcn' -import type { JSONContent } from '@tiptap/core' -import { CodeBlock } from '@tiptap/extension-code-block' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react' import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram' +import { MarkdownCodeBlock } from './code-block-schema' import { detectLanguage } from './detect-language' import { useEditorEditable } from './use-editor-editable' @@ -228,30 +227,6 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView ) } -function codeBlockText(node: JSONContent): string { - return (node.content ?? []).map((child) => child.text ?? '').join('') -} - -/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */ -function fenceFor(text: string): string { - const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length)) - return '`'.repeat(Math.max(3, longestRun + 1)) -} - -/** - * Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code - * block that itself contains a ``` line round-trips instead of shattering. Shared by the test - * (plain) and live ({@link CodeBlockWithLanguage}) paths. - */ -export const MarkdownCodeBlock = CodeBlock.extend({ - renderMarkdown: (node: JSONContent) => { - const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '' - const text = codeBlockText(node) - const fence = fenceFor(text) - return `${fence}${language}\n${text}\n${fence}` - }, -}) - /** * Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The * `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/agent-stream-leader.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/agent-stream-leader.test.ts new file mode 100644 index 00000000000..59ae7f50649 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/agent-stream-leader.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { + announceAgentApplying, + clearAgentApplying, + isAgentStreamLeader, +} from './agent-stream-leader' + +/** An Awareness with explicit peer states injected (self, if present, carries no `agentApplying`). */ +function awarenessWith(entries: Array<[number, Record]>): Awareness { + const aw = new Awareness(new Y.Doc()) + const states = aw.getStates() as Map> + for (const [clientId, state] of entries) states.set(clientId, state) + return aw +} + +describe('agent-stream leader election', () => { + it('a sole announcer is the leader', () => { + expect(isAgentStreamLeader(awarenessWith([[5, { agentApplying: true }]]), 5)).toBe(true) + }) + + it('the lowest clientID among announcers leads; higher announcers do not', () => { + const aw = awarenessWith([ + [7, { agentApplying: true }], + [3, { agentApplying: true }], + [9, { user: { name: 'someone else, not applying' } }], + ]) + expect(isAgentStreamLeader(aw, 3)).toBe(true) + expect(isAgentStreamLeader(aw, 7)).toBe(false) + }) + + it('a client that is not announcing is never the leader', () => { + expect(isAgentStreamLeader(awarenessWith([[3, { agentApplying: true }]]), 8)).toBe(false) + }) + + it('with no announcers, nobody leads', () => { + expect(isAgentStreamLeader(awarenessWith([[3, { user: {} }]]), 3)).toBe(false) + }) + + it('announce makes self the leader; clear relinquishes it', () => { + const doc = new Y.Doc() + const aw = new Awareness(doc) + announceAgentApplying(aw) + expect(aw.getLocalState()?.agentApplying).toBe(true) + expect(isAgentStreamLeader(aw, doc.clientID)).toBe(true) + + clearAgentApplying(aw) + expect(aw.getLocalState()?.agentApplying ?? null).toBeNull() + expect(isAgentStreamLeader(aw, doc.clientID)).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/agent-stream-leader.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/agent-stream-leader.ts new file mode 100644 index 00000000000..8cc79f790ac --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/agent-stream-leader.ts @@ -0,0 +1,37 @@ +import type { Awareness } from 'y-protocols/awareness' + +/** + * Awareness field a collaborative client sets on its OWN state while it is applying an agent stream into + * the shared doc. Read by every peer to run the single-writer election below. Coexists with the caret + * `user` field (`setLocalStateField` writes one field without clobbering others). + */ +const AGENT_APPLYING_FIELD = 'agentApplying' + +/** Announce that this client is applying an agent stream (candidate in the leader election). */ +export function announceAgentApplying(awareness: Awareness): void { + awareness.setLocalStateField(AGENT_APPLYING_FIELD, true) +} + +/** Stop announcing (this client is no longer applying an agent stream). */ +export function clearAgentApplying(awareness: Awareness): void { + awareness.setLocalStateField(AGENT_APPLYING_FIELD, null) +} + +/** + * Single-writer election: exactly one collaborative client applies a given agent stream into the shared + * doc, so N tabs/windows watching the same live copilot stream don't each insert it under a different + * Yjs clientID and duplicate the content. The leader is the MINIMUM clientID among all clients currently + * announcing (via {@link announceAgentApplying}) that they are applying — a deterministic tie-break that + * needs no coordinator. A brief startup race (before an announcement propagates to peers) is bounded to a + * frame or two — self-corrected the moment awareness converges, and reconciled anyway by the durable + * server write. In the common single-client case the caller is the only announcer, so it always leads. + */ +export function isAgentStreamLeader(awareness: Awareness, selfClientId: number): boolean { + let leader = Number.POSITIVE_INFINITY + awareness.getStates().forEach((state, clientId) => { + if ((state as Record | undefined)?.[AGENT_APPLYING_FIELD] === true) { + leader = Math.min(leader, clientId) + } + }) + return leader === selfClientId +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts new file mode 100644 index 00000000000..d0019198c8b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { initProseMirrorDoc, updateYFragment, ySyncPluginKey } from '@tiptap/y-tiptap' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { createMarkdownEditorExtensions } from '../editor-extensions' +import { + AGENT_STREAM_ORIGIN, + applyAgentStreamFrame, + beginAgentStream, + endAgentStream, +} from './apply-streamed-markdown' + +beforeAll(() => { + // jsdom does not implement elementFromPoint; the Placeholder extension's viewport tracking calls it + // on view mount. Returning null makes ProseMirror's posAtCoords fall back gracefully. + if (!document.elementFromPoint) { + document.elementFromPoint = () => null + } +}) + +/** A headless collaborative editor bound to a fresh Y.Doc — the same extension wiring the component uses. */ +function makeCollabEditor() { + const doc = new Y.Doc() + const awareness = new Awareness(doc) + const editor = new Editor({ + extensions: createMarkdownEditorExtensions({ + placeholder: '', + collaboration: { + doc, + awareness, + user: { name: 'Tester', color: '#ffffff', clientId: doc.clientID }, + }, + }), + content: '', + }) + return { editor, doc, awareness } +} + +const teardown: Array<() => void> = [] +afterEach(() => { + for (const fn of teardown.splice(0)) fn() +}) + +function track(t: { editor: Editor; doc: Y.Doc; awareness: Awareness }) { + teardown.push(() => { + t.editor.destroy() + t.awareness.destroy() + t.doc.destroy() + }) + return t +} + +describe('agent-stream applier', () => { + it('relies on y-tiptap internals that still exist (upgrade guardrail)', () => { + // beginAgentStream/applyAgentStreamFrame reach into y-tiptap internals (not public TipTap API): + // `ySyncPluginKey`, `updateYFragment`, `initProseMirrorDoc`. A y-tiptap bump that renames or drops + // any of them can pass typecheck yet break at runtime — assert their runtime shape here so an upgrade + // fails loudly at test time instead of in production. Pinned to an exact y-tiptap version in + // package.json; bump that pin and this guard together. + expect(typeof updateYFragment).toBe('function') + expect(typeof initProseMirrorDoc).toBe('function') + expect(ySyncPluginKey).toBeDefined() + expect(typeof ySyncPluginKey.getState).toBe('function') + }) + + it('streams agent content into the live collaborative doc and broadcasts it as Yjs ops', () => { + const { editor, doc } = track(makeCollabEditor()) + + const session = beginAgentStream(editor) + expect(session).not.toBeNull() + expect(applyAgentStreamFrame(editor, session!, '# Title\n\nHello world.')).toBe(true) + expect(editor.getText()).toContain('Hello world') + + // The write lands as ops on the shared doc, so any peer receives it (this is what makes a + // collaborator on /files see the stream without ever holding `streamingContent`). + const peer = new Y.Doc() + Y.applyUpdate(peer, Y.encodeStateAsUpdate(doc)) + expect(peer.getXmlFragment('default').toString()).toContain('Hello world') + peer.destroy() + endAgentStream(session!) + }) + + it('beginAgentStream returns null when the editor has no ySync binding', () => { + // A plain editor with no collaboration has no ySync binding, so a stream cannot start against it. + const editor = new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '' }), + content: '', + }) + teardown.push(() => editor.destroy()) + expect(beginAgentStream(editor)).toBeNull() + }) + + it('keeps agent-streamed ops out of the undo stack while user edits stay undoable', () => { + const { editor } = track(makeCollabEditor()) + + const session = beginAgentStream(editor)! + applyAgentStreamFrame(editor, session, '# Streamed\n\nAgent wrote this.') + endAgentStream(session) + // The streamed op relayed under a non-`ySyncPluginKey` origin, which the Collaboration UndoManager + // does not track — so there is nothing to undo, and an undo must not revert the agent's content. + expect(editor.can().undo()).toBe(false) + editor.commands.undo() + expect(editor.getText()).toContain('Agent wrote this') + + // A genuine user edit IS captured (origin ySyncPluginKey) — proving the test isn't vacuous: + // undo works, and it reverts only the user edit, leaving the agent content intact. + editor.commands.focus('end') + editor.commands.insertContent(' USER-TYPED') + expect(editor.getText()).toContain('USER-TYPED') + expect(editor.can().undo()).toBe(true) + editor.commands.undo() + expect(editor.getText()).not.toContain('USER-TYPED') + expect(editor.getText()).toContain('Agent wrote this') + }) + + it('a shadow reused after the live doc advanced duplicates content; a fresh one does not', () => { + // The invariant behind the component's leadership-regain teardown: a shadow tracks only ITS OWN + // reconciles, so once the live doc advances under another writer, REUSING that stale shadow re-emits + // ops for content already present (duplication). Seeding a FRESH shadow from the current doc fixes it. + const stale = track(makeCollabEditor()) + const staleSession = beginAgentStream(stale.editor)! // seeded from the empty base + applyAgentStreamFrame(stale.editor, staleSession, 'Alpha paragraph.') + // Another writer advances the live doc while this shadow is NOT looking (a handoff to an interim leader). + stale.editor.commands.focus('end') + stale.editor.commands.insertContent('\n\nBeta paragraph.') + // Reusing the stale shadow (only knows "Alpha") to reconcile toward the full body re-inserts "Beta". + applyAgentStreamFrame( + stale.editor, + staleSession, + 'Alpha paragraph.\n\nBeta paragraph.\n\nGamma paragraph.' + ) + endAgentStream(staleSession) + const staleText = stale.editor.getText() + expect(staleText.match(/Beta paragraph/g)?.length).toBe(2) // duplicated — what the teardown prevents + + // Fresh shadow re-seeded from the CURRENT doc (what a regaining leader does after teardown) emits only + // the genuine delta, so no content duplicates. + const fresh = track(makeCollabEditor()) + const first = beginAgentStream(fresh.editor)! + applyAgentStreamFrame(fresh.editor, first, 'Alpha paragraph.') + fresh.editor.commands.focus('end') + fresh.editor.commands.insertContent('\n\nBeta paragraph.') + endAgentStream(first) + const regained = beginAgentStream(fresh.editor)! // re-seeded from the advanced doc + applyAgentStreamFrame( + fresh.editor, + regained, + 'Alpha paragraph.\n\nBeta paragraph.\n\nGamma paragraph.' + ) + endAgentStream(regained) + const freshText = fresh.editor.getText() + expect(freshText.match(/Beta paragraph/g)?.length).toBe(1) + expect(freshText).toContain('Gamma paragraph') + }) + + it('preserves a concurrent peer edit to a region the agent snapshot does not include', () => { + // This is the core "AI as a CRDT peer" guarantee: the agent relays only its OWN delta (computed + // against a private shadow), never a whole-document reconcile that would revert a peer's edit. + const { editor, doc } = track(makeCollabEditor()) + + const session = beginAgentStream(editor)! + applyAgentStreamFrame(editor, session, 'Alpha paragraph.\n\nBeta paragraph.') + + // A peer edits the FIRST paragraph directly on the shared doc — the agent's later snapshot still + // carries the ORIGINAL first paragraph (it was built from the base, before this edit). + const peer = new Y.Doc() + Y.applyUpdate(peer, Y.encodeStateAsUpdate(doc)) + const peerFrag = peer.getXmlFragment('default') + peer.transact(() => { + const firstPara = peerFrag.get(0) as Y.XmlElement + const textNode = firstPara.get(0) as Y.XmlText + textNode.insert(textNode.toString().length, ' EDITED') + }) + Y.applyUpdate(doc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(doc))) + peer.destroy() + + // The agent appends a third paragraph. Its snapshot's first paragraph is the stale original, but the + // shadow-relayed delta only inserts the new paragraph — so the peer's " EDITED" must survive. + applyAgentStreamFrame( + editor, + session, + 'Alpha paragraph.\n\nBeta paragraph.\n\nGamma paragraph.' + ) + endAgentStream(session) + + const live = doc.getXmlFragment('default').toString() + expect(live).toContain('EDITED') + expect(live).toContain('Gamma paragraph') + }) + + it('reuses cached binding metadata across frames, still emitting minimal per-frame deltas', () => { + // The binding `meta` is built ONCE (first frame) and reused — `updateYFragment` maintains it in place, + // so we skip an O(doc) `initProseMirrorDoc` rebuild per frame. This guards that caching preserves the + // minimal-delta + no-duplication behavior across frames (a stale/rebuilt mapping would re-emit existing + // paragraphs → duplication). + const { editor, doc } = track(makeCollabEditor()) + const session = beginAgentStream(editor)! + + applyAgentStreamFrame(editor, session, 'One.') + expect(session.meta).not.toBeNull() // built and cached on the first frame + + const deltas: Uint8Array[] = [] + const onUpdate = (u: Uint8Array, origin: unknown) => { + if (origin === AGENT_STREAM_ORIGIN) deltas.push(u) + } + doc.on('update', onUpdate) + applyAgentStreamFrame(editor, session, 'One.\n\nTwo.') + applyAgentStreamFrame(editor, session, 'One.\n\nTwo.\n\nThree.') + doc.off('update', onUpdate) + + // Two later frames → two incremental deltas; the cached mapping kept diffs minimal and correct. + expect(deltas.length).toBe(2) + const text = editor.getText() + expect(text).toContain('One') + expect(text).toContain('Two') + expect(text).toContain('Three') + expect(text.match(/One/g)?.length).toBe(1) + expect(text.match(/Two/g)?.length).toBe(1) + endAgentStream(session) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.ts new file mode 100644 index 00000000000..88f54d6c0b7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.ts @@ -0,0 +1,89 @@ +import type { Editor } from '@tiptap/core' +import { Node as PMNode } from '@tiptap/pm/model' +import { initProseMirrorDoc, updateYFragment, ySyncPluginKey } from '@tiptap/y-tiptap' +import * as Y from 'yjs' +import { parseMarkdownToDoc } from '../markdown-parse' + +/** The Yjs fragment name TipTap's Collaboration extension binds to (its default `field`). */ +const COLLAB_DOC_FIELD = 'default' + +/** + * Transaction origin for agent-streamed writes into a live collaborative doc. It is deliberately NOT + * the `ySyncPluginKey` origin that local user edits use, so the Collaboration UndoManager — which + * tracks only `ySyncPluginKey` — excludes streamed ops from the user's undo stack. + */ +export const AGENT_STREAM_ORIGIN = Symbol('agent-stream') + +/** + * A private Yjs replica the agent stream reconciles against, so a stream writes into the live doc as a + * TRUE peer: only the agent's own delta reaches the shared doc, never a whole-document reconcile that + * would revert a collaborator's concurrent edit. Seeded from the live doc at stream start; it receives + * ONLY agent reconciles (never peer updates), so `shadow → nextTarget` yields exactly the agent's change. + */ +export interface AgentStreamSession { + shadow: Y.Doc + fragment: Y.XmlFragment + /** + * The fragment↔PM binding metadata (`mapping`/`isOMark`) `updateYFragment` diffs against. Built ONCE + * from the seeded fragment on the first frame, then maintained IN PLACE by `updateYFragment` on every + * subsequent frame — the same persistent structure y-tiptap's own `ProsemirrorBinding` keeps for a + * doc's whole life. Caching it avoids an O(doc) `initProseMirrorDoc` tree rebuild per streamed frame. + * Safe ONLY because the shadow receives the agent's OWN reconciles and nothing else (never peer + * updates), so the fragment never changes outside `updateYFragment`; it is torn down with the session + * on leadership loss, so it can't outlive its fragment. A future change that ever applies an external + * update to `shadow` MUST reset this to `null`. + */ + meta: ReturnType['meta'] | null +} + +/** + * Begin an agent stream by snapshotting the live doc into a private shadow replica. Returns `null` when + * the editor has no live ySync binding (e.g. a non-collaborative editor). + */ +export function beginAgentStream(editor: Editor): AgentStreamSession | null { + const binding = ySyncPluginKey.getState(editor.state)?.binding + if (!binding) return null + const shadow = new Y.Doc() + Y.applyUpdate(shadow, Y.encodeStateAsUpdate(binding.doc)) + return { shadow, fragment: shadow.getXmlFragment(COLLAB_DOC_FIELD), meta: null } +} + +/** + * Apply one streamed markdown body. Reconciles the shadow toward `body` with `updateYFragment` (the same + * minimal-diff primitive TipTap runs per keystroke), captures ONLY the resulting agent delta, and relays + * it into the live doc under {@link AGENT_STREAM_ORIGIN}. Because the shadow never sees peer updates, the + * delta touches only what the agent changed — so concurrent peer edits elsewhere in the live doc survive, + * the change renders locally (via the binding's observer, the remote-edit path), broadcasts to every + * peer, and stays out of the user's undo stack. Returns `false` when the editor has no live ySync binding. + */ +export function applyAgentStreamFrame( + editor: Editor, + session: AgentStreamSession, + body: string +): boolean { + const binding = ySyncPluginKey.getState(editor.state)?.binding + if (!binding) return false + const target = PMNode.fromJSON(editor.schema, parseMarkdownToDoc(body)) + let delta: Uint8Array | null = null + const capture = (update: Uint8Array, origin: unknown) => { + if (origin === AGENT_STREAM_ORIGIN) delta = update + } + session.shadow.on('update', capture) + try { + session.shadow.transact(() => { + // Build the binding metadata once, then reuse it; updateYFragment maintains it in place. See + // AgentStreamSession.meta for why per-frame reuse is safe (and why a rebuild would be wasteful). + session.meta ??= initProseMirrorDoc(session.fragment, editor.schema).meta + updateYFragment(session.shadow, session.fragment, target, session.meta) + }, AGENT_STREAM_ORIGIN) + } finally { + session.shadow.off('update', capture) + } + if (delta) Y.applyUpdate(binding.doc, delta, AGENT_STREAM_ORIGIN) + return true +} + +/** End an agent stream and free its shadow replica. */ +export function endAgentStream(session: AgentStreamSession): void { + session.shadow.destroy() +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.test.ts new file mode 100644 index 00000000000..523335ad8e5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { activateCaretLabel, CARET_LABEL_HOLD_MS, renderCaret } from './caret-presence' + +const ACTIVE = 'collaboration-carets__caret--active' +const FLIP = 'collaboration-carets__caret--flip' + +describe('caret-presence', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('builds a tagged caret with a name label, shown on appearance', () => { + const caret = renderCaret({ name: 'Ada', color: '#f783ac', clientId: 4242 }) + expect(caret.classList.contains('collaboration-carets__caret')).toBe(true) + expect(caret.dataset.caretClientId).toBe('4242') + expect(caret.style.getPropertyValue('--caret-color')).toBeTruthy() + const label = caret.querySelector('.collaboration-carets__label') + expect(label?.textContent).toBe('Ada') + expect(caret.classList.contains(ACTIVE)).toBe(true) + }) + + it('falls back to a default name for a bare user state', () => { + const caret = renderCaret({ clientId: 1 }) + expect(caret.querySelector('.collaboration-carets__label')?.textContent).toBe('Collaborator') + }) + + it('hides the label after the inactivity hold, and re-activation restarts it', () => { + const caret = renderCaret({ name: 'Ada', color: '#f783ac', clientId: 4242 }) + vi.advanceTimersByTime(CARET_LABEL_HOLD_MS - 1) + expect(caret.classList.contains(ACTIVE)).toBe(true) + vi.advanceTimersByTime(1) + expect(caret.classList.contains(ACTIVE)).toBe(false) + + activateCaretLabel(caret) + expect(caret.classList.contains(ACTIVE)).toBe(true) + vi.advanceTimersByTime(CARET_LABEL_HOLD_MS) + expect(caret.classList.contains(ACTIVE)).toBe(false) + }) + + it('flips the label left only when it would overflow the editor right edge', () => { + const caret = renderCaret({ name: 'Ada', color: '#f783ac', clientId: 4242 }) + const label = caret.querySelector('.collaboration-carets__label') + if (!label) throw new Error('label missing') + // double-cast-allowed: jsdom has no layout; stub the label's right edge for the measure + label.getBoundingClientRect = () => ({ right: 500 }) as unknown as DOMRect + + activateCaretLabel(caret, 600) // editor edge past the label → no flip + expect(caret.classList.contains(FLIP)).toBe(false) + + activateCaretLabel(caret, 400) // editor edge before the label's right → flip + expect(caret.classList.contains(FLIP)).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.ts new file mode 100644 index 00000000000..c522b60e088 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.ts @@ -0,0 +1,163 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import type { Awareness } from 'y-protocols/awareness' + +/** + * Remote-collaborator caret presence for the file editor: the name label's + * show-then-fade behavior and the edge-aware flip, plus the ProseMirror plugin + * that drives them off awareness activity. + * + * Matches Google Docs: the name flag shows only while the peer has typed in the last + * few seconds or on local hover, then hides after inactivity, leaving just the caret. + */ + +/** + * How long a peer's name label stays visible after their last activity (cursor + * move / edit) before it fades, leaving just the colored caret bar. + */ +export const CARET_LABEL_HOLD_MS = 2000 + +/** Fallback caret color when a peer's awareness carries no `color`. */ +export const DEFAULT_CARET_COLOR = '#000000' + +/** + * The active-state class {@link activateCaretLabel} toggles on the caret node to reveal the + * name label; CSS transitions it back to hidden when removed. (yCursorPlugin reuses the DOM + * node and never re-runs `render`, which is why this file drives the class itself.) + */ +const CARET_ACTIVE_CLASS = 'collaboration-carets__caret--active' + +/** The class that flips the label to the caret's left near the editor's right edge. */ +const CARET_FLIP_CLASS = 'collaboration-carets__caret--flip' + +/** Per-caret fade timers, keyed by the (reused) caret DOM node. */ +const caretFadeTimers = new WeakMap>() + +/** + * Show a peer's name label, (re)start its fade timer, and flip it to the caret's + * left when it would run off the editor's right edge. + * + * yCursorPlugin renders each caret as a keyed widget decoration, so ProseMirror + * REUSES the same DOM node as the caret moves (verified: the render function is + * not re-invoked on a position change) — a CSS animation therefore cannot restart + * on activity. Instead the activity signal is the awareness `change` event, which + * (unlike `update`) fires only on a real state change, not the 15s heartbeat, so + * an idle peer's label correctly stays hidden. + */ +export function activateCaretLabel(caret: HTMLElement, editorRight?: number) { + caret.classList.add(CARET_ACTIVE_CLASS) + const existing = caretFadeTimers.get(caret) + if (existing) clearTimeout(existing) + caretFadeTimers.set( + caret, + setTimeout(() => { + caret.classList.remove(CARET_ACTIVE_CLASS) + caretFadeTimers.delete(caret) + }, CARET_LABEL_HOLD_MS) + ) + + if (editorRight === undefined) return + const label = caret.querySelector('.collaboration-carets__label') + if (!label) return + // Measure the default (rightward) position, then flip left only if it overflows. + caret.classList.remove(CARET_FLIP_CLASS) + if (label.getBoundingClientRect().right > editorRight) { + caret.classList.add(CARET_FLIP_CLASS) + } +} + +/** + * Builds a remote peer's caret DOM: a colored bar plus a name label tagged with + * the peer's Yjs client id (so {@link createCaretActivityExtension} can find and + * re-activate the reused node on later awareness changes). Shown immediately on + * (re)appearance; the fade timer hides it after inactivity. Passed to + * CollaborationCaret as its `render` option, which only supplies `user` — so the + * client id rides along in the awareness `user` payload (each client stamps its own + * `doc.clientID`; see `use-file-doc-collaboration.ts`). + */ +export function renderCaret(user: Record): HTMLElement { + const color = typeof user.color === 'string' ? user.color : DEFAULT_CARET_COLOR + const name = typeof user.name === 'string' && user.name ? user.name : 'Collaborator' + const clientId = typeof user.clientId === 'number' ? user.clientId : undefined + const caret = document.createElement('span') + caret.className = 'collaboration-carets__caret' + // One inline var drives the caret bar, the dormant cap, and the name tag (all in CSS). + caret.style.setProperty('--caret-color', color) + if (clientId !== undefined) caret.dataset.caretClientId = String(clientId) + // The visible caret bar is a SEPARATE, absolutely-positioned child — never an inline border on the + // caret span. The caret is a ProseMirror inline widget inserted between characters; an in-flow bar + // (border + width) reflows the surrounding text by ~1px each time a peer's caret appears or moves. + // An out-of-flow bar has zero layout footprint, so peer carets never nudge the document. + const bar = document.createElement('span') + bar.className = 'collaboration-carets__bar' + caret.appendChild(bar) + const label = document.createElement('div') + label.className = 'collaboration-carets__label' + label.textContent = name + caret.appendChild(label) + activateCaretLabel(caret) + return caret +} + +/** + * Drives the caret name-label show-then-fade off awareness activity. Because the + * caret DOM node is reused across moves (see {@link activateCaretLabel}), the + * `render` function alone can't reveal the label when a peer moves — this listens + * for awareness `change` events and re-activates the matching caret node. Deferred + * to the next frame so the node exists and is laid out (for the edge-flip measure). + */ +export function createCaretActivityExtension(awareness: Awareness): Extension { + return Extension.create({ + name: 'collaborationCaretActivity', + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('collaborationCaretActivity'), + view: (editorView) => { + // Coalesce bursts of awareness changes into a single rAF per frame, however many + // peers moved: accumulate the changed client ids, then re-activate each matching + // (reused) caret node once. The shared editorRight is read once up front; each moving + // caret then does one getBoundingClientRect for its edge-flip measure (bounded by the + // small number of concurrently-moving peers, not the awareness event rate). + let raf = 0 + const pending = new Set() + const flush = () => { + raf = 0 + const editorRight = editorView.dom.getBoundingClientRect().right + for (const id of pending) { + const caret = editorView.dom.querySelector( + `.collaboration-carets__caret[data-caret-client-id="${id}"]` + ) + if (caret) activateCaretLabel(caret, editorRight) + } + pending.clear() + } + const onChange = ({ added, updated }: { added: number[]; updated: number[] }) => { + for (const id of added) pending.add(id) + for (const id of updated) pending.add(id) + if (pending.size > 0 && !raf) raf = requestAnimationFrame(flush) + } + awareness.on('change', onChange) + return { + destroy: () => { + awareness.off('change', onChange) + if (raf) cancelAnimationFrame(raf) + // Clear any pending fade timers for this editor's carets so they don't fire on + // detached nodes after unmount (harmless no-op, but a genuine leaked timer). + for (const caret of editorView.dom.querySelectorAll( + '.collaboration-carets__caret' + )) { + const timer = caretFadeTimers.get(caret) + if (timer) { + clearTimeout(timer) + caretFadeTimers.delete(caret) + } + } + }, + } + }, + }), + ] + }, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars.tsx new file mode 100644 index 00000000000..1d093b21fa2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars.tsx @@ -0,0 +1,14 @@ +'use client' + +import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars' +import { useFileDocOthers } from './file-doc-room-context' + +/** + * Avatar stack of the collaborators currently in the open file — the `useOthers` avatar + * stack, reading the room roster from {@link useFileDocOthers}. Renders nothing until + * someone else joins. Must sit inside a `FileDocRoomProvider`. + */ +export function FileDocAvatars() { + const others = useFileDocOthers() + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts new file mode 100644 index 00000000000..689c2ee6c77 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -0,0 +1,431 @@ +/** + * @vitest-environment node + */ +import { + FILE_DOC_EVENTS, + FILE_DOC_MESSAGE_TYPE, + FILE_DOC_SEED, +} from '@sim/realtime-protocol/file-doc' +import * as encoding from 'lib0/encoding' +import type { Socket } from 'socket.io-client' +import { describe, expect, it, vi } from 'vitest' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import * as Y from 'yjs' +import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown' +import { FileDocProvider } from './file-doc-provider' + +/** A minimal fake Socket.IO client whose server→client events can be fired in tests. */ +function createSocket(connected = true) { + const listeners = new Map void>>() + const emit = vi.fn() + const socket = { + connected, + emit, + on(event: string, cb: (...args: unknown[]) => void) { + let set = listeners.get(event) + if (!set) { + set = new Set() + listeners.set(event, set) + } + set.add(cb) + }, + off(event: string, cb: (...args: unknown[]) => void) { + listeners.get(event)?.delete(cb) + }, + } + const fire = (event: string, ...args: unknown[]) => { + for (const cb of listeners.get(event) ?? []) cb(...args) + } + return { socket: socket as unknown as Socket, emit, fire } +} + +function createProvider(connected = true) { + const { socket, emit, fire } = createSocket(connected) + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + const provider = new FileDocProvider(socket, 'file-1', doc, awareness) + return { provider, doc, awareness, emit, fire } +} + +/** Messages emitted to the server, decoded to their `{ type, bytes }`. */ +function emittedMessages(emit: ReturnType) { + return emit.mock.calls + .filter( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + .map(([, payload]) => payload as Uint8Array) +} + +describe('FileDocProvider', () => { + it('joins immediately with its client id when the socket is already connected', () => { + const { doc, emit } = createProvider(true) + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, { + fileId: 'file-1', + clientId: doc.clientID, + }) + }) + + it('waits for connect before joining when the socket is offline', () => { + const { emit, fire } = createProvider(false) + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + fire('connect') + expect(emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN, + expect.objectContaining({ fileId: 'file-1' }) + ) + }) + + it('exchanges sync only after JOIN_SUCCESS', () => { + const { emit, fire } = createProvider(true) + emit.mockClear() + + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + + // A sync step 1 (type tag 0) is sent to exchange state with the server. + const messages = emittedMessages(emit) + expect(messages.length).toBeGreaterThan(0) + expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) + }) + + it('ignores a join ack for a different file', () => { + const { emit, fire } = createProvider(true) + emit.mockClear() + + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'other-file' }) + + // No sync/awareness exchange starts for a file this provider does not own. + expect(emittedMessages(emit)).toHaveLength(0) + }) + + it('applies a server sync step 2 and becomes synced', () => { + const { provider, doc, fire } = createProvider(true) + const synced = vi.fn() + provider.on('synced', synced) + + const serverDoc = new Y.Doc() + serverDoc.getText('default').insert(0, 'hello world') + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, serverDoc) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + + expect(doc.getText('default').toString()).toBe('hello world') + expect(provider.synced).toBe(true) + expect(synced).toHaveBeenCalledWith(true) + }) + + it('sends local document edits to the server as sync updates', () => { + const { doc, emit } = createProvider(true) + emit.mockClear() + + doc.getText('default').insert(0, 'x') + + const messages = emittedMessages(emit) + expect(messages.length).toBe(1) + expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) + }) + + it('tags agent-streamed edits as SYNC_NO_PERSIST so the relay skips the durable persist', () => { + const { doc, emit } = createProvider(true) + emit.mockClear() + + // An agent-streamed frame is applied under AGENT_STREAM_ORIGIN; it must still reach the server (peers + // see it live) but as SYNC_NO_PERSIST, so the relay fans it out without treating it as a user edit. + doc.transact(() => doc.getText('default').insert(0, 'agent'), AGENT_STREAM_ORIGIN) + + const messages = emittedMessages(emit) + expect(messages.length).toBe(1) + expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST) + }) + + it('does not echo updates it applied from the server', () => { + const { provider, emit, fire } = createProvider(true) + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + emit.mockClear() + + const serverDoc = new Y.Doc() + serverDoc.getText('default').insert(0, 'remote') + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(serverDoc)) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + + // The applied remote update must not be re-emitted back to the server. + expect(emittedMessages(emit)).toHaveLength(0) + expect(provider.doc.getText('default').toString()).toBe('remote') + }) + + it('sends local awareness (cursor/selection) changes', () => { + const { awareness, emit } = createProvider(true) + emit.mockClear() + + awareness.setLocalStateField('user', { name: 'Ada', color: '#f783ac' }) + + const messages = emittedMessages(emit) + expect(messages.some((m) => m[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS)).toBe(true) + }) + + it('reseeds a cleared awareness so a reused instance can publish again', () => { + const { socket, emit } = createSocket(true) + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + // Simulate a prior provider teardown having cleared the local state — after + // this, y-protocols' setLocalStateField is a permanent no-op, so the caret + // extension could never publish the local user/cursor on a reused instance. + awarenessProtocol.removeAwarenessStates(awareness, [doc.clientID], 'prior-destroy') + expect(awareness.getLocalState()).toBeNull() + + // Constructing a provider on the reused, cleared awareness must restore it. + new FileDocProvider(socket, 'file-1', doc, awareness) + expect(awareness.getLocalState()).not.toBeNull() + + emit.mockClear() + // The caret extension setting the user field must now actually publish. + awareness.setLocalStateField('user', { name: 'Ada', color: '#f783ac' }) + expect(emittedMessages(emit).some((m) => m[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS)).toBe(true) + }) + + it('does not forward awareness it applied from the server', () => { + const { emit, fire } = createProvider(true) + emit.mockClear() + + const remoteDoc = new Y.Doc() + remoteDoc.clientID = 8888 + const remoteAwareness = new awarenessProtocol.Awareness(remoteDoc) + remoteAwareness.setLocalStateField('user', { name: 'Remote' }) + const update = awarenessProtocol.encodeAwarenessUpdate(remoteAwareness, [8888]) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array(encoder, update) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + + // The remote peer's awareness (client 8888, not ours) must not be re-published. + expect(emittedMessages(emit).some((m) => m[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS)).toBe(false) + }) + + it('stops attempting to join and latches joinError after a non-retryable error', () => { + const { provider, emit, fire } = createProvider(true) + emit.mockClear() + + const error = { + fileId: 'file-1', + error: 'Access denied', + code: 'ACCESS_DENIED', + retryable: false, + } + fire(FILE_DOC_EVENTS.JOIN_ERROR, error) + fire('connect') + + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + // Latched so a consumer subscribing after the event can still detect the failure. + expect(provider.joinError).toEqual(error) + }) + + it('still rejoins on reconnect after a retryable error', () => { + const { emit, fire } = createProvider(true) + fire(FILE_DOC_EVENTS.JOIN_ERROR, { + fileId: 'file-1', + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + emit.mockClear() + + fire('connect') + + expect(emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN, + expect.objectContaining({ fileId: 'file-1' }) + ) + }) + + it('resets synced and rejoins on a reconnect', () => { + const { provider, emit, fire } = createProvider(true) + // Become synced. + const serverDoc = new Y.Doc() + serverDoc.getText('default').insert(0, 'hi') + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, serverDoc) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + expect(provider.synced).toBe(true) + emit.mockClear() + + // A reconnect must drop synced and re-issue JOIN so the doc re-syncs. + fire('connect') + + expect(provider.synced).toBe(false) + expect(emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN, + expect.objectContaining({ fileId: 'file-1' }) + ) + }) + + it('leaves the room and detaches on destroy', () => { + const { provider, doc, emit } = createProvider(true) + emit.mockClear() + + provider.destroy() + + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-1' }) + // After destroy, local edits are no longer forwarded. + emit.mockClear() + doc.getText('default').insert(0, 'y') + expect(emittedMessages(emit)).toHaveLength(0) + }) + + it('leaves the room only when the LAST provider for a file on a shared socket is destroyed', () => { + // Two surfaces in one tab (Files editor + embedded chat panel) share one socket and both open the + // same file. Tearing the first down must NOT strand the second — the server drops the socket from + // the room on any LEAVE, so LEAVE may fire only when the last provider goes away. + const { socket, emit } = createSocket(true) + const docA = new Y.Doc() + const docB = new Y.Doc() + const first = new FileDocProvider( + socket, + 'shared-file', + docA, + new awarenessProtocol.Awareness(docA) + ) + const second = new FileDocProvider( + socket, + 'shared-file', + docB, + new awarenessProtocol.Awareness(docB) + ) + emit.mockClear() + + first.destroy() + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, expect.anything()) + + second.destroy() + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'shared-file' }) + }) + + it('scopes the shared-membership refcount per file (a sibling file leaves independently)', () => { + const { socket, emit } = createSocket(true) + const docA = new Y.Doc() + const docB = new Y.Doc() + const fileA = new FileDocProvider(socket, 'file-a', docA, new awarenessProtocol.Awareness(docA)) + const fileB = new FileDocProvider(socket, 'file-b', docB, new awarenessProtocol.Awareness(docB)) + emit.mockClear() + + fileA.destroy() + // A different file's sole provider still leaves immediately. + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-a' }) + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-b' }) + + fileB.destroy() + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-b' }) + }) + + it('gives up with a non-retryable join-error when the first sync never arrives (offline)', () => { + vi.useFakeTimers() + try { + const { provider, emit, fire } = createProvider(false) // socket never connects + const onError = vi.fn() + provider.on('join-error', onError) + + vi.advanceTimersByTime(12_000) + + // Surfaces the same non-retryable rejection the fatal path uses, so the editor falls back to + // showing the file read-only. + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'READINESS_TIMEOUT', retryable: false }) + ) + expect(provider.joinError).toEqual( + expect.objectContaining({ code: 'READINESS_TIMEOUT', retryable: false }) + ) + // Latched fatal: a later connect must not re-join (which could sync server state in and + // duplicate the locally-seeded content). + emit.mockClear() + fire('connect') + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + } finally { + vi.useRealTimers() + } + }) + + it('does not fire the fallback once the doc is synced AND seeded', () => { + vi.useFakeTimers() + try { + const { provider, fire } = createProvider(true) + const onError = vi.fn() + provider.on('join-error', onError) + + // The initial sync brings BOTH content and the server seed flag before the deadline. + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + const remote = new Y.Doc() + remote.getText('default').insert(0, 'hi') + remote.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, remote) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + expect(provider.synced).toBe(true) + + vi.advanceTimersByTime(12_000) + expect(onError).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('fires the fallback when the doc synced but the server seed never landed', () => { + vi.useFakeTimers() + try { + const { provider, fire } = createProvider(true) + const onError = vi.fn() + provider.on('join-error', onError) + + // The socket syncs an empty doc, but the server-side seed never arrives (its build persistently + // failed) — `synced` is true yet `initialContentLoaded` is never set. + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + const remote = new Y.Doc() + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, remote) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + expect(provider.synced).toBe(true) + + vi.advanceTimersByTime(12_000) + + // The readiness deadline still fires → the editor falls back to the stored content read-only, + // and `synced` is dropped so the `synced && seeded` gate stays closed (read-only, not editable). + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'READINESS_TIMEOUT', retryable: false }) + ) + expect(provider.synced).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('ignores a late SyncStep2 that arrives after the readiness deadline (no merge, stays gated)', () => { + vi.useFakeTimers() + try { + const { provider, doc, fire } = createProvider(true) + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + + // Deadline lapses with no first sync → fatal fallback (editor falls back to a read-only seed). + vi.advanceTimersByTime(12_000) + expect(provider.joinError).toEqual(expect.objectContaining({ code: 'READINESS_TIMEOUT' })) + + // A delayed SyncStep2 finally arrives. Applying it would merge server content into the + // already-seeded doc (duplication) and flip synced→true (un-gating autosave), so it MUST be + // dropped once fatal. + const remote = new Y.Doc() + remote.getText('default').insert(0, 'server content') + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, remote) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + + expect(provider.synced).toBe(false) + expect(doc.getText('default').toString()).toBe('') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts new file mode 100644 index 00000000000..9b55ac7b907 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -0,0 +1,372 @@ +import { + FILE_DOC_EVENTS, + FILE_DOC_MESSAGE_TYPE, + FILE_DOC_SEED, + FILE_DOC_TIMEOUTS, + type JoinFileDocError, + type JoinFileDocSuccess, + toFileDocBytes, +} from '@sim/realtime-protocol/file-doc' +import * as decoding from 'lib0/decoding' +import * as encoding from 'lib0/encoding' +import { ObservableV2 } from 'lib0/observable' +import type { Socket } from 'socket.io-client' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import type * as Y from 'yjs' +import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown' + +/** + * Events emitted by {@link FileDocProvider}. + * - `synced`: the first full document sync with the server completed. + * - `join-error`: the server rejected the join (e.g. lost write access). + */ +interface FileDocProviderEvents { + synced: (synced: boolean) => void + 'join-error': (error: JoinFileDocError) => void +} + +/** + * How long to wait to reach a USABLE editor — connected, synced, AND seeded (`initialContentLoaded` + * set by the server seed) — before giving up. It guards two failure modes with one timer: + * - the realtime server is unreachable, so the first sync never arrives; and + * - the socket syncs an empty doc but the server-side seed never lands (its build persistently fails + * / exhausts its retries), which `synced` alone would wrongly treat as "connected, all good". + * + * On the deadline the provider latches fatal and surfaces a non-retryable `join-error` — the exact + * path a fatal rejection uses — so the editor falls back to showing the file's stored content + * read-only instead of a permanently blank pane. Generous enough to clear a slow connect + seed + * round-trip; a healthy cold open reaches readiness well within it. Shared with (and must exceed) the + * relay's seed-fetch timeout — see `FILE_DOC_TIMEOUTS` and its ordering test. + */ +const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs + +/** + * Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the + * embedded chat resource panel) share ONE Socket.IO connection, so both a first and a second provider + * for the same file JOIN the same room over that socket. The server's `leave(name)` drops the socket + * from the room outright — no membership refcount — so the FIRST provider's `destroy()` would strand + * the second (still-mounted) one: no more content or presence updates. Keyed by the {@link Socket} + * OBJECT (stable across reconnects, unlike `socket.id`), so the count survives a reconnect. + * + * The single-provider case is unchanged: the count goes `0 → 1 → 0` and `LEAVE` fires exactly as + * before. `LEAVE` is emitted only when the LAST provider for a file on a socket tears down. + */ +const roomJoinCounts = new WeakMap>() + +/** Record another live provider for `fileId` on `socket` (called at construction). */ +function retainRoomMembership(socket: Socket, fileId: string): void { + let counts = roomJoinCounts.get(socket) + if (!counts) { + counts = new Map() + roomJoinCounts.set(socket, counts) + } + counts.set(fileId, (counts.get(fileId) ?? 0) + 1) +} + +/** + * Drop one live provider for `fileId` on `socket` (called at teardown). Returns `true` when this was + * the last one — i.e. the caller should emit `LEAVE` so the socket leaves the room. + */ +function releaseRoomMembership(socket: Socket, fileId: string): boolean { + const counts = roomJoinCounts.get(socket) + const next = (counts?.get(fileId) ?? 1) - 1 + if (next > 0) { + counts?.set(fileId, next) + return false + } + counts?.delete(fileId) + return true +} + +/** + * The client half of the collaborative file-document protocol: a Yjs provider + * that carries document sync + awareness over the shared, already-authenticated + * Socket.IO connection (the server relay lives in + * `apps/realtime/src/handlers/file-doc.ts`). It is the Socket.IO analogue of + * `y-websocket`'s `WebsocketProvider` — the same `y-protocols` message framing — + * so TipTap's `Collaboration` (bound to {@link doc}) and `CollaborationCaret` + * (bound to this provider's {@link awareness}) work unmodified. + * + * The document and awareness are owned by the caller (the hook) and are NOT + * destroyed here, so the provider can be torn down and rebuilt (e.g. on a socket + * reconnect) without discarding local edits. + */ +export class FileDocProvider extends ObservableV2 { + synced = false + /** + * The latched non-retryable join rejection, or `null`. The `join-error` event is + * transient and can fire before a consumer subscribes, + * so consumers read this on subscription to detect a fatal failure they missed. + */ + joinError: JoinFileDocError | null = null + + private disposed = false + /** Set on a non-retryable join rejection (e.g. lost write access) so the + * provider stops attempting to (re)join until the owner tears it down. */ + private fatal = false + /** Deadline for reaching readiness (synced + seeded); fires the fallback if it is never reached. */ + private readinessTimer: ReturnType | null = null + + constructor( + private readonly socket: Socket, + private readonly fileId: string, + readonly doc: Y.Doc, + readonly awareness: awarenessProtocol.Awareness + ) { + super() + + // Restore an empty local awareness state if it has been cleared. A fresh + // Awareness starts with `{}`, but a *reused* one whose local state was removed + // (a prior provider's `destroy()` clears it, and so does `Awareness.destroy()`) + // returns `null` here — and y-protocols' `setLocalStateField` is a no-op while + // the local state is `null`. The editor binds CollaborationCaret to this exact + // awareness for its whole life, so without this reseed a remount (e.g. React + // StrictMode's mount→unmount→mount, which re-runs the provider effect on the + // same instance) would leave the caret extension unable to ever publish the + // local user/cursor — remote peers would see no caret or selection, even though + // document sync (which does not depend on local awareness) keeps working. + if (awareness.getLocalState() === null) awareness.setLocalState({}) + + socket.on(FILE_DOC_EVENTS.MESSAGE, this.handleMessage) + socket.on(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess) + socket.on(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) + socket.on('connect', this.handleConnect) + doc.on('update', this.handleDocUpdate) + awareness.on('update', this.handleAwarenessUpdate) + // Watch the seed flag so reaching "seeded" (server seed applied) can clear the readiness deadline. + doc.getMap(FILE_DOC_SEED.configMap).observe(this.handleConfigChange) + + // Count this provider against the shared socket's membership of the file's room, so the room is + // left only when the last provider for this file tears down (see {@link releaseRoomMembership}). + retainRoomMembership(socket, fileId) + + if (socket.connected) this.join() + + // Arm the fallback: if we don't reach readiness (synced + seeded) before the deadline, give up. + this.readinessTimer = setTimeout(this.handleReadinessDeadline, READINESS_DEADLINE_MS) + } + + /** Whether the server seed has recorded the initial content on the doc. */ + private isSeeded(): boolean { + return this.doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag) === true + } + + /** Clear the readiness deadline once the editor is usable (synced AND seeded). */ + private handleConfigChange = () => { + if (this.synced && this.isSeeded()) this.clearReadinessTimer() + } + + /** + * Readiness was never reached within {@link READINESS_DEADLINE_MS} — either the realtime server is + * unreachable (never synced) or it synced but the server-side seed never landed (synced yet + * unseeded). Reset `synced` (so the editor gates read-only), latch fatal (so a late reconnect or + * seed can't sync server state in and merge-duplicate the content the editor is about to render + * locally), and surface a synthetic non-retryable join-error — the exact path a fatal rejection + * uses — so the owner falls back to the read-only view of the file's stored content instead of a + * blank pane. No-op if we already reached readiness, already failed fatally, or were torn down. + */ + private handleReadinessDeadline = () => { + this.readinessTimer = null + if ((this.synced && this.isSeeded()) || this.fatal || this.disposed) return + const error: JoinFileDocError = { + fileId: this.fileId, + error: 'Realtime document was not ready in time', + code: 'READINESS_TIMEOUT', + retryable: false, + } + this.fatal = true + this.joinError = error + // Drop `synced` so the editor's `synced && seeded` gate stays closed → the fallback renders the + // stored content read-only rather than becoming editable on a doc the server never seeded. + this.setSynced(false) + this.emit('join-error', [error]) + } + + private clearReadinessTimer() { + if (this.readinessTimer !== null) { + clearTimeout(this.readinessTimer) + this.readinessTimer = null + } + } + + /** Join the room, binding our client id so the server only accepts awareness we own. */ + private join = () => { + if (this.fatal) return + this.socket.emit(FILE_DOC_EVENTS.JOIN, { fileId: this.fileId, clientId: this.doc.clientID }) + } + + /** + * Re-join after a (re)connect. The server re-registers the room before acking, + * so the sync/awareness exchange is deferred to {@link handleJoinSuccess}. + */ + private handleConnect = () => { + if (this.fatal) return + this.setSynced(false) + this.join() + } + + /** + * Handle the join ack. The server registers the room before acking, so an earlier + * send could be dropped — the initial sync + local awareness exchange begins here. + */ + private handleJoinSuccess = (data: JoinFileDocSuccess) => { + if (data.fileId !== this.fileId) return + this.sendSyncStep1() + this.sendLocalAwareness() + } + + /** + * Handle a join rejection. A non-retryable rejection (access denied, invalid) + * won't succeed on retry, so latch {@link fatal} to stop (re)joining and let the + * owner fall back to the non-collaborative view. + */ + private handleJoinError = (data: JoinFileDocError) => { + if (data.fileId !== this.fileId) return + if (data.retryable === false) { + this.fatal = true + this.joinError = data + this.clearReadinessTimer() + } + this.emit('join-error', [data]) + } + + private handleMessage = (data: unknown) => { + // Once we've given up (a non-retryable rejection, or the connect deadline lapsed and the editor + // fell back to a read-only local seed), ignore ALL inbound frames. A late SyncStep2 arriving + // after the deadline would otherwise merge the server's state into the already-seeded doc — + // duplicating content — and flip `synced` true, which un-gates autosave and would persist the + // duplicate back to the real file. `fatal` guarding (re)join alone is not enough; it must also + // stop applying sync here. + if (this.fatal) return + const bytes = toFileDocBytes(data) + if (!bytes) return + + const decoder = decoding.createDecoder(bytes) + const messageType = decoding.readVarUint(decoder) + + switch (messageType) { + case FILE_DOC_MESSAGE_TYPE.SYNC: { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + // `this` is the transaction origin, so our own `doc.on('update')` skips + // re-sending updates we just applied from the server. + const syncType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this) + if (encoding.length(encoder) > 1) { + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + if (syncType === syncProtocol.messageYjsSyncStep2 && !this.synced) this.setSynced(true) + break + } + case FILE_DOC_MESSAGE_TYPE.AWARENESS: { + awarenessProtocol.applyAwarenessUpdate( + this.awareness, + decoding.readVarUint8Array(decoder), + this + ) + break + } + } + } + + private handleDocUpdate = (update: Uint8Array, origin: unknown) => { + // Once fatal (a non-retryable rejection, or the readiness deadline lapsed), the editor may render + // the stored content into the doc locally as its read-only fallback. Never relay those local + // writes — the server never seeded this doc, so echoing them would push unseeded content to peers + // (and each fallen-back client would do so, union-duplicating). A fatal client is fully local. + if (this.fatal) return + // Updates we applied from the server carry `this` as origin — don't echo them. + if (origin === this) return + // Agent-streamed frames must reach peers (so a collaborator sees the stream live) but must NOT be + // treated by the server as a durable user edit — the copilot's final `edit_content` write is the + // authoritative persist. Tag them so the relay applies + fans out but skips persist bookkeeping. + const messageType = + origin === AGENT_STREAM_ORIGIN + ? FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST + : FILE_DOC_MESSAGE_TYPE.SYNC + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageType) + syncProtocol.writeUpdate(encoder, update) + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + + private handleAwarenessUpdate = ( + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown + ) => { + // Only ever publish OUR OWN awareness. Remote changes (origin === this) were + // applied from the server; and a local `Awareness` also emits 30s `timeout` + // removals for remote peers — forwarding either would be a frame for a client + // id we don't own, which the server (correctly) rejects. Filter to our own id + // so honest traffic never trips the ownership guard. + if (origin === this) return + const localId = this.doc.clientID + const changed = [...added, ...updated, ...removed].filter((id) => id === localId) + if (changed.length === 0) return + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, changed) + ) + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + + private sendSyncStep1() { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep1(encoder, this.doc) + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + + private sendLocalAwareness() { + if (this.awareness.getLocalState() === null) return + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, [this.doc.clientID]) + ) + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + + private setSynced(synced: boolean) { + if (this.synced === synced) return + this.synced = synced + // Readiness needs synced AND seeded; only clear the deadline when both hold (the seed may have + // arrived first, or may still be pending — `handleConfigChange` clears it if seeded arrives later). + if (synced && this.isSeeded()) this.clearReadinessTimer() + this.emit('synced', [synced]) + } + + /** + * Tear down the provider: leave the room, clear our awareness (so peers drop our + * caret immediately rather than after the server's 30s timeout), and detach all + * listeners. The document and awareness objects are the caller's and are left intact. + */ + destroy() { + if (this.disposed) { + super.destroy() + return + } + this.disposed = true + this.clearReadinessTimer() + + awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'provider-destroy') + + // Only actually leave the room when this was the last provider for the file on the shared socket — + // otherwise a sibling surface (e.g. the Files editor vs. the embedded chat panel) would be stranded. + if (releaseRoomMembership(this.socket, this.fileId)) { + this.socket.emit(FILE_DOC_EVENTS.LEAVE, { fileId: this.fileId }) + } + this.socket.off(FILE_DOC_EVENTS.MESSAGE, this.handleMessage) + this.socket.off(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess) + this.socket.off(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) + this.socket.off('connect', this.handleConnect) + this.doc.off('update', this.handleDocUpdate) + this.doc.getMap(FILE_DOC_SEED.configMap).unobserve(this.handleConfigChange) + this.awareness.off('update', this.handleAwarenessUpdate) + + super.destroy() + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context.tsx new file mode 100644 index 00000000000..2afc31ae7f1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context.tsx @@ -0,0 +1,43 @@ +'use client' + +import { createContext, type ReactNode, useContext, useState } from 'react' +import type { PresenceAvatarUser } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars' + +const EMPTY_OTHERS: PresenceAvatarUser[] = [] +const noop = () => {} + +// Split into two contexts on purpose: the roster (`others`) changes on every join/leave, +// but the setter is stable. The editor (which owns the awareness) only ever *reports* the +// roster, so it subscribes to the setter context — which never changes identity — and never +// re-renders when the roster does; only the header avatar stack subscribes to `others`. +const FileDocOthersContext = createContext(EMPTY_OTHERS) +const FileDocSetOthersContext = createContext<(users: PresenceAvatarUser[]) => void>(noop) + +/** + * Scopes "who's in this file" presence to the open document — the `RoomProvider` + + * `useOthers` pattern (Liveblocks / y-presence) adapted to our component tree. The editor + * owns the Yjs awareness but sits *below* the file-detail header that renders the avatar + * stack, so it publishes the SERVER-AUTHENTICATED roster into this context + * ({@link useReportFileDocOthers}) and the header reads it ({@link useFileDocOthers}). + * Presence is ephemeral and room-scoped, so it lives in this provider, not a global store. + */ +export function FileDocRoomProvider({ children }: { children: ReactNode }) { + const [others, setOthers] = useState(EMPTY_OTHERS) + return ( + + {children} + + ) +} + +/** The roster of collaborators currently in the open file, for an avatar stack. Empty + * outside a {@link FileDocRoomProvider}. */ +export function useFileDocOthers(): PresenceAvatarUser[] { + return useContext(FileDocOthersContext) +} + +/** Publishes the server roster into the room context (editor side). Returns a stable no-op + * outside a {@link FileDocRoomProvider}. */ +export function useReportFileDocOthers(): (users: PresenceAvatarUser[]) => void { + return useContext(FileDocSetOthersContext) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.test.ts new file mode 100644 index 00000000000..39bb68590fc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { type CollabReadinessInputs, nextCollabReadiness } from './readiness' + +/** Drive a sequence of observations through the latch, returning the readiness at each step. */ +function run(steps: CollabReadinessInputs[]): boolean[] { + let syncedOnce = false + return steps.map((input) => { + const next = nextCollabReadiness(syncedOnce, input) + syncedOnce = next.syncedOnce + return next.ready + }) +} + +describe('nextCollabReadiness', () => { + it('is not ready before syncing or seeding', () => { + const { syncedOnce, ready } = nextCollabReadiness(false, { + synced: false, + seeded: false, + offlineSeed: false, + }) + expect(syncedOnce).toBe(false) + expect(ready).toBe(false) + }) + + it('is not ready when synced but not yet seeded', () => { + const { syncedOnce, ready } = nextCollabReadiness(false, { + synced: true, + seeded: false, + offlineSeed: false, + }) + expect(syncedOnce).toBe(true) // latched + expect(ready).toBe(false) // waits for the seed + }) + + it('opens on the new-file flap sequence: synced true, then seed lands while synced flapped false', () => { + // The exact bug: `synced` and `seeded` are never true in the same observation. The latch must still + // open once BOTH have been seen across observations. + const readiness = run([ + { synced: false, seeded: false, offlineSeed: false }, // joining + { synced: true, seeded: false, offlineSeed: false }, // initial (empty) sync + { synced: false, seeded: false, offlineSeed: false }, // synced flaps false on re-sync + { synced: false, seeded: true, offlineSeed: false }, // server seed lands (synced still false) + ]) + expect(readiness).toEqual([false, false, false, true]) + }) + + it('opens even if the seed lands before we ever observed synced (server seed proves a sync)', () => { + // If the flap beat our first observation, the seed flag alone (not the offline fallback) proves a + // completed sync happened. + const { syncedOnce, ready } = nextCollabReadiness(false, { + synced: false, + seeded: true, + offlineSeed: false, + }) + expect(syncedOnce).toBe(true) + expect(ready).toBe(true) + }) + + it('stays read-only for an offline (local) seed that never reached the server', () => { + const readiness = run([ + { synced: false, seeded: false, offlineSeed: false }, + { synced: false, seeded: true, offlineSeed: true }, // offline fallback seeded locally + ]) + expect(readiness).toEqual([false, false]) + }) + + it('never reverts once ready, even if synced later flaps false', () => { + const readiness = run([ + { synced: true, seeded: true, offlineSeed: false }, // ready + { synced: false, seeded: true, offlineSeed: false }, // synced flaps — must stay ready + ]) + expect(readiness).toEqual([true, true]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.ts new file mode 100644 index 00000000000..343b301b394 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/readiness.ts @@ -0,0 +1,37 @@ +/** + * Collaborative-readiness latch for the file editor. + * + * A file becomes "ready" (editable + agent streaming enabled) once its shared doc has both SYNCED and + * SEEDED. The subtlety this latch solves: a brand-new file's provider reports `synced: true` on the + * initial (empty) sync, then the SERVER pushes the seed and receiving that update flips `synced` back to + * `false` — so `synced` and `seeded` are never `true` in the same observation. An un-latched + * `synced && seeded` gate would therefore never open, and agent streaming would be dropped for the whole + * run (the file only fills in on reload). + * + * The latch: `syncedOnce` is sticky — set the first time a completed sync is observed, and it never + * reverts. A completed sync is proven by EITHER a live `synced`, OR the seed flag being present without + * the offline fallback having set it (`offlineSeed`) — because a SERVER seed can only arrive after a + * sync, whereas the offline fallback seeds locally without ever reaching the server and must stay + * read-only. Once `syncedOnce` is set, a later `synced` flap can no longer re-gate the doc. + */ + +export interface CollabReadinessInputs { + /** The provider's current `synced` flag (may flap false after the seed update). */ + synced: boolean + /** Whether the shared doc carries the seed flag. */ + seeded: boolean + /** Whether the seed flag was set by the offline fallback (no server sync) rather than the server. */ + offlineSeed: boolean +} + +/** + * Pure transition for the readiness latch. `syncedOnce` is the sticky prior state — pass the returned + * `syncedOnce` back in on the next call. `ready` is whether the doc is synced-and-seeded. + */ +export function nextCollabReadiness( + syncedOnce: boolean, + input: CollabReadinessInputs +): { syncedOnce: boolean; ready: boolean } { + const next = syncedOnce || input.synced || (input.seeded && !input.offlineSeed) + return { syncedOnce: next, ready: next && input.seeded } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts new file mode 100644 index 00000000000..5afd9470a21 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts @@ -0,0 +1,172 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { FILE_DOC_EVENTS, type FileDocPresence } from '@sim/realtime-protocol/file-doc' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { getUserColor } from '@/lib/workspaces/colors' +import { useSocket } from '@/app/workspace/providers/socket-provider' +import { FileDocProvider } from './file-doc-provider' +import { useReportFileDocOthers } from './file-doc-room-context' + +/** The live collaboration binding the editor wires into TipTap's Collaboration + * (the {@link Y.Doc}) and CollaborationCaret (the awareness). */ +export interface FileDocCollaboration { + /** Bound to TipTap's Collaboration extension (created synchronously at mount). */ + doc: Y.Doc + /** Bound to CollaborationCaret (via `{ awareness }`); relayed by the provider. */ + awareness: Awareness + /** + * The realtime provider, or `null` until the socket is available. `doc` and + * `awareness` exist before it connects, so the editor can bind immediately; the + * provider is consumed for its readiness signal (`synced`) and fatal `join-error`. + */ + provider: FileDocProvider | null + /** + * The local caret identity published to awareness: `name`/`color` for CollaborationCaret, + * and `clientId` so the caret activity extension can tag each caret node (see + * caret-presence.ts). The avatar roster does NOT come from here — it's server-authenticated + * (see the PRESENCE subscription below) so a peer can't spoof identity via awareness. + */ + user: { name: string; color: string; clientId: number | undefined } +} + +interface UseFileDocCollaborationParams { + fileId: string + userId: string + userName: string + /** + * Whether to establish collaboration. Decided once at editor mount — only for a + * live, editable, non-streaming workspace document. When `false` the hook + * returns `null` and the editor stays fully local. + */ + enabled: boolean +} + +/** + * Owns the per-file Yjs document, awareness, and {@link FileDocProvider} for + * collaborative editing. The document + awareness are created once (this hook + * lives inside an editor that is keyed by file id, so one instance == one file) + * and are the stable objects TipTap binds to; the provider connects them to the + * realtime relay over the shared socket. Returns `null` while disabled. + */ +export function useFileDocCollaboration({ + fileId, + userId, + userName, + enabled, +}: UseFileDocCollaborationParams): FileDocCollaboration | null { + const { socket } = useSocket() + + // The Y.Doc + Awareness are the editor's authoritative binding — created once + // and stable for the hook's life (see sim-react-performance: lazy-init ref). + // Only allocated when collaboration is enabled, so read-only / streaming / + // round-trip-unsafe views never build a Yjs document they won't use. + const docRef = useRef(null) + const awarenessRef = useRef(null) + // Created ONCE and kept stable for the whole editor lifetime. The editor freezes these into + // its extension set at mount (`useEditor` fixes extensions at creation), so the instances the + // provider binds MUST stay byte-identical to what the editor holds — never destroyed-and- + // recreated mid-life. If a StrictMode dev remount destroyed them, the still-mounted editor + // would keep the dead doc while the provider synced a fresh one, and a joining peer would see a + // blank document. Teardown is therefore deferred to a REAL unmount only (see below). + if (enabled && docRef.current === null) { + docRef.current = new Y.Doc() + awarenessRef.current = new Awareness(docRef.current) + } + + // Destroy the doc + awareness on a REAL unmount only. `Awareness` runs a setInterval to expire + // stale peers, so it MUST be destroyed or that timer leaks for every file ever opened. But a + // StrictMode dev remount fires this cleanup and then re-runs the setup synchronously after — so + // we SCHEDULE the teardown and the remount cancels it, keeping the frozen instances alive. A + // genuine unmount has no remount, so the scheduled teardown runs on the next tick. + const pendingTeardownRef = useRef | null>(null) + useEffect(() => { + if (pendingTeardownRef.current !== null) { + clearTimeout(pendingTeardownRef.current) + pendingTeardownRef.current = null + } + return () => { + pendingTeardownRef.current = setTimeout(() => { + awarenessRef.current?.destroy() + docRef.current?.destroy() + }, 0) + } + }, []) + + const [provider, setProvider] = useState(null) + + useEffect(() => { + if (!enabled || !socket) return + // Non-null: both refs are set during render before any effect runs, and are never destroyed + // (see above), so this always binds the same doc/awareness the editor froze at mount. + const doc = docRef.current as Y.Doc + const awareness = awarenessRef.current as Awareness + const fileProvider = new FileDocProvider(socket, fileId, doc, awareness) + setProvider(fileProvider) + return () => { + fileProvider.destroy() + setProvider(null) + } + }, [enabled, socket, fileId]) + + const reportOthers = useReportFileDocOthers() + const reportOthersRef = useRef(reportOthers) + reportOthersRef.current = reportOthers + + // "Who's in this file" roster (the useOthers side of the pattern). The server broadcasts a + // roster of SERVER-AUTHENTICATED identities (see FILE_DOC_EVENTS.PRESENCE) — trusted, + // unlike the client-set awareness `user` field a peer could spoof. Publish it (minus self) + // to the room context for the file-detail avatar stack; cleared on unmount so a file switch + // never shows the previous file's occupants. + useEffect(() => { + if (!enabled || !socket) return + const handlePresence = (data: FileDocPresence) => { + if (data.fileId !== fileId) return + // Exclude only our OWN socket (this session), NOT every session that shares our userId — + // so a second tab of the same account still counts as present, matching the canvas avatars + // (avatars.tsx filters by socketId). Then dedupe per user for the display stack, so + // multiple tabs of one person collapse to a single avatar. + const byUser = new Map< + string, + { userId: string; userName: string; avatarUrl: string | null } + >() + for (const peer of data.users) { + if (peer.socketId === socket.id || byUser.has(peer.userId)) continue + byUser.set(peer.userId, { + userId: peer.userId, + userName: peer.userName, + avatarUrl: peer.avatarUrl, + }) + } + reportOthersRef.current([...byUser.values()]) + } + socket.on(FILE_DOC_EVENTS.PRESENCE, handlePresence) + return () => { + socket.off(FILE_DOC_EVENTS.PRESENCE, handlePresence) + reportOthersRef.current([]) + } + }, [enabled, socket, fileId]) + + // The client id rides in the awareness `user` payload so the caret `render` (which only + // receives `user`) can tag each caret node for the activity-driven name label (see + // caret-presence.ts). `doc.clientID` is stable for the doc's life, so reading it from the + // ref needs no memo dep. + const user = useMemo( + () => ({ name: userName, color: getUserColor(userId), clientId: docRef.current?.clientID }), + [userName, userId] + ) + + return useMemo( + () => + enabled + ? { + doc: docRef.current as Y.Doc, + awareness: awarenessRef.current as Awareness, + provider, + user, + } + : null, + [enabled, provider, user] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 601d702c07c..0e75964c427 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -1,8 +1,18 @@ import type { Extensions } from '@tiptap/core' +import Collaboration from '@tiptap/extension-collaboration' +import CollaborationCaret from '@tiptap/extension-collaboration-caret' import Placeholder from '@tiptap/extension-placeholder' +import type { Awareness } from 'y-protocols/awareness' +import type * as Y from 'yjs' +import { withAlpha } from '@/lib/workspaces/colors' import { BlockMover } from './block-mover' import { CodeBlockWithLanguage } from './code-block' import { CodeBlockHighlight } from './code-highlight' +import { + createCaretActivityExtension, + DEFAULT_CARET_COLOR, + renderCaret, +} from './collaboration/caret-presence' import { LinkEmbed } from './embed/link-embed' import { createMarkdownContentExtensions } from './extensions' import { ResizableImage } from './image' @@ -13,10 +23,20 @@ import { MentionChip } from './mention/mention-chip' import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet' import { SlashCommand } from './slash-command/slash-command' +/** Live collaboration binding for the editor. When present, the editor's history + * is Yjs-backed and remote carets/selection render via CollaborationCaret. */ +export interface EditorCollaboration { + doc: Y.Doc + awareness: Awareness + user: { name: string; color: string } +} + interface MarkdownEditorExtensionOptions { placeholder: string /** Renders supported media links as live players beneath a standalone link. Off by default. */ embeds?: boolean + /** When set, wires TipTap Collaboration + CollaborationCaret onto the shared document. */ + collaboration?: EditorCollaboration } /** @@ -32,15 +52,41 @@ interface MarkdownEditorExtensionOptions { export function createMarkdownEditorExtensions({ placeholder, embeds = false, + collaboration, }: MarkdownEditorExtensionOptions): Extensions { return [ - ...createMarkdownContentExtensions({ - codeBlock: CodeBlockWithLanguage, - image: ResizableImage, - mention: MentionChip, - rawHtmlBlock: RawHtmlBlockWithView, - footnoteDef: FootnoteDefWithView, - }), + ...createMarkdownContentExtensions( + { + codeBlock: CodeBlockWithLanguage, + image: ResizableImage, + mention: MentionChip, + rawHtmlBlock: RawHtmlBlockWithView, + footnoteDef: FootnoteDefWithView, + }, + { disableHistory: Boolean(collaboration) } + ), + ...(collaboration + ? [ + Collaboration.configure({ document: collaboration.doc }), + // CollaborationCaret reads only `provider.awareness` (created synchronously, + // relayed by the socket provider once connected). `render` tags each caret + // with the peer's client id and shows its name label; the selection tint is + // a translucent fill of the peer's identity color. + CollaborationCaret.configure({ + provider: { awareness: collaboration.awareness }, + user: collaboration.user, + render: renderCaret, + selectionRender: (user) => { + const hex = typeof user.color === 'string' ? user.color : DEFAULT_CARET_COLOR + return { + class: 'collaboration-carets__selection', + style: `background-color: ${withAlpha(hex, 0.2)};`, + } + }, + }), + createCaretActivityExtension(collaboration.awareness), + ] + : []), CodeBlockHighlight, SlashCommand, Mention, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 52e4731f27f..8c7d42342f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -11,13 +11,18 @@ import { } from '@tiptap/extension-table' import { Markdown } from '@tiptap/markdown' import StarterKit from '@tiptap/starter-kit' -import { MarkdownCodeBlock } from './code-block' +import { MarkdownCodeBlock } from './code-block-schema' import { Highlight } from './highlight' -import { MarkdownImage } from './image' +import { MarkdownImage } from './image-schema' import { MarkdownLinkInputRule } from './link-input-rule' import { MarkdownMention } from './mention/mention-node' import { SIM_LINK_SCHEME } from './mention/sim-link' -import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet' +import { + FootnoteDef, + FootnoteRef, + RawHtmlBlock, + RawInlineHtml, +} from './raw-markdown-snippet-schema' /** * The `@`-mention link scheme, registered on the Link mark — without it the schema strips the @@ -117,7 +122,10 @@ export interface ContentNodeViews { * registry. The live editor passes the node-view nodes via {@link createMarkdownEditorExtensions}; the * schema and markdown output are identical either way. */ -export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}): Extensions { +export function createMarkdownContentExtensions( + nodeViews: ContentNodeViews = {}, + options: { disableHistory?: boolean } = {} +): Extensions { const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock).configure({ HTMLAttributes: { class: 'code-editor-theme' }, }) @@ -128,6 +136,9 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {} codeBlock: false, code: false, paragraph: false, + // Collaboration provides its own (Yjs-backed) undo/redo — disabling the + // built-in history avoids the two fighting over the shared document. + ...(options.disableHistory ? { undoRedo: false as const } : {}), }), BlockSafeParagraph, InlineCode, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema.ts new file mode 100644 index 00000000000..dccc926d6b4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema.ts @@ -0,0 +1,161 @@ +import type { JSONContent } from '@tiptap/core' +import { Image } from '@tiptap/extension-image' + +/** + * React-free schema half of the image node. Lives apart from {@link ./image} (its React resize node + * view) so the shared editor schema — `createMarkdownContentExtensions` in `./extensions` — can be + * imported by server code (the collab-doc seed converter) without pulling a client component + * (`useEffect`) into a Server Component module. The client editor injects the node-view variant + * ({@link ResizableImage}) via `nodeViews`. + */ + +/** + * A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical + * form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an + * image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize + * the whole construct ourselves and hang the link target on the image node's `href` attribute, so it + * round-trips losslessly (and the file stays editable rather than opening read-only). + */ +const LINKED_IMAGE_RE = + /^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/ + +/** Escape a value for safe interpolation into a double-quoted HTML attribute. */ +function escapeAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +/** + * Serialize an image to markdown when it has no explicit size, and to an HTML `` tag when + * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to + * preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is + * wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`. + * + * A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer + * only recognizes `[![alt](src)](href)`, so emitting `[](href)` would silently drop the link on + * reparse (and the round-trip-safety probe wouldn't catch it). We keep the link and fall back to the + * unsized `[![alt](src)](href)` form — the link matters more than the exact dimensions for a badge. + */ +function imageMarkdown(node: JSONContent): string { + const attrs = node.attrs ?? {} + const src = typeof attrs.src === 'string' ? attrs.src : '' + const alt = typeof attrs.alt === 'string' ? attrs.alt : '' + const title = typeof attrs.title === 'string' ? attrs.title : '' + const href = typeof attrs.href === 'string' ? attrs.href : '' + const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : '' + const width = attrs.width + const height = attrs.height + let image: string + if ((width || height) && !href) { + const parts = [`src="${escapeAttr(src)}"`] + if (alt) parts.push(`alt="${escapeAttr(alt)}"`) + if (title) parts.push(`title="${escapeAttr(title)}"`) + if (width) parts.push(`width="${escapeAttr(String(width))}"`) + if (height) parts.push(`height="${escapeAttr(String(height))}"`) + image = `` + } else { + // Escape so an alt with `]`/`[` or a title with `"` can't break out of the `![…](… "…")` syntax + // and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark). + const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : '' + const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src + image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})` + } + if (!href) return image + // Escape `"`/`\` so an href title can't break out of the `[…](href "title")` syntax (mirrors the + // image title escaping above). + const hrefTitlePart = hrefTitle ? ` "${hrefTitle.replace(/["\\]/g, '\\$&')}"` : '' + return `[${image}](${href}${hrefTitlePart})` +} + +interface MarkdownImageToken { + /** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */ + src?: string + alt?: string + title?: string | null + /** Built-in image token holds the source URL here; our linked token holds the link target. */ + href?: string + hrefTitle?: string | null + /** Built-in image token holds the alt text here. */ + text?: string +} + +/** Map both the built-in image token and our linked-image token onto the image node's attributes. */ +function parseImageToken(token: MarkdownImageToken): JSONContent { + const isLinked = typeof token.src === 'string' + return { + type: 'image', + attrs: isLinked + ? { + src: token.src, + alt: token.alt ?? '', + title: token.title ?? null, + href: token.href ?? null, + hrefTitle: token.hrefTitle ?? null, + } + : { + src: token.href ?? '', + alt: token.text ?? '', + title: token.title ?? null, + href: null, + hrefTitle: null, + }, + } +} + +const widthAttr = { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('width'), + renderHTML: (attributes: Record) => + attributes.width ? { width: String(attributes.width) } : {}, +} + +const heightAttr = { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('height'), + renderHTML: (attributes: Record) => + attributes.height ? { height: String(attributes.height) } : {}, +} + +/** Link target of a linked image — markdown-only state, never emitted as an HTML `` attribute. */ +const hrefAttr = { default: null, rendered: false } +const hrefTitleAttr = { default: null, rendered: false } + +/** + * Image node that carries optional `width`/`height` (serialized as an HTML `` tag) and an + * optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless + * round-trip path (no node view) and the live {@link ResizableImage}. + */ +export const MarkdownImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: widthAttr, + height: heightAttr, + href: hrefAttr, + hrefTitle: hrefTitleAttr, + } + }, + markdownTokenizer: { + name: 'image', + level: 'inline', + start: (src: string) => src.indexOf('[!['), + tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => { + const match = LINKED_IMAGE_RE.exec(src) + if (!match) return undefined + return { + type: 'image', + raw: match[0], + alt: match[1] ?? '', + src: match[2], + title: match[3] ?? null, + href: match[4], + hrefTitle: match[5] ?? null, + } + }, + }, + parseMarkdown: parseImageToken, + renderMarkdown: imageMarkdown, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index fa198e8658e..fc9ce30fc1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -1,167 +1,15 @@ import { useEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' -import type { JSONContent } from '@tiptap/core' -import { Image } from '@tiptap/extension-image' import { NodeSelection, Plugin } from '@tiptap/pm/state' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { useFileContentSource } from '@/hooks/use-file-content-source' +import { MarkdownImage } from './image-schema' import { normalizeLinkHref } from './markdown-fidelity' import { useEditorEditable } from './use-editor-editable' const MIN_WIDTH = 64 -/** - * A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical - * form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an - * image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize - * the whole construct ourselves and hang the link target on the image node's `href` attribute, so it - * round-trips losslessly (and the file stays editable rather than opening read-only). - */ -const LINKED_IMAGE_RE = - /^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/ - -/** Escape a value for safe interpolation into a double-quoted HTML attribute. */ -function escapeAttr(value: string): string { - return value - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>') -} - -/** - * Serialize an image to markdown when it has no explicit size, and to an HTML `` tag when - * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to - * preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is - * wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`. - * - * A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer - * only recognizes `[![alt](src)](href)`, so emitting `[](href)` would silently drop the link on - * reparse (and the round-trip-safety probe wouldn't catch it). We keep the link and fall back to the - * unsized `[![alt](src)](href)` form — the link matters more than the exact dimensions for a badge. - */ -function imageMarkdown(node: JSONContent): string { - const attrs = node.attrs ?? {} - const src = typeof attrs.src === 'string' ? attrs.src : '' - const alt = typeof attrs.alt === 'string' ? attrs.alt : '' - const title = typeof attrs.title === 'string' ? attrs.title : '' - const href = typeof attrs.href === 'string' ? attrs.href : '' - const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : '' - const width = attrs.width - const height = attrs.height - let image: string - if ((width || height) && !href) { - const parts = [`src="${escapeAttr(src)}"`] - if (alt) parts.push(`alt="${escapeAttr(alt)}"`) - if (title) parts.push(`title="${escapeAttr(title)}"`) - if (width) parts.push(`width="${escapeAttr(String(width))}"`) - if (height) parts.push(`height="${escapeAttr(String(height))}"`) - image = `` - } else { - // Escape so an alt with `]`/`[` or a title with `"` can't break out of the `![…](… "…")` syntax - // and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark). - const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : '' - const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src - image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})` - } - if (!href) return image - // Escape `"`/`\` so an href title can't break out of the `[…](href "title")` syntax (mirrors the - // image title escaping above). - const hrefTitlePart = hrefTitle ? ` "${hrefTitle.replace(/["\\]/g, '\\$&')}"` : '' - return `[${image}](${href}${hrefTitlePart})` -} - -interface MarkdownImageToken { - /** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */ - src?: string - alt?: string - title?: string | null - /** Built-in image token holds the source URL here; our linked token holds the link target. */ - href?: string - hrefTitle?: string | null - /** Built-in image token holds the alt text here. */ - text?: string -} - -/** Map both the built-in image token and our linked-image token onto the image node's attributes. */ -function parseImageToken(token: MarkdownImageToken): JSONContent { - const isLinked = typeof token.src === 'string' - return { - type: 'image', - attrs: isLinked - ? { - src: token.src, - alt: token.alt ?? '', - title: token.title ?? null, - href: token.href ?? null, - hrefTitle: token.hrefTitle ?? null, - } - : { - src: token.href ?? '', - alt: token.text ?? '', - title: token.title ?? null, - href: null, - hrefTitle: null, - }, - } -} - -const widthAttr = { - default: null, - parseHTML: (element: HTMLElement) => element.getAttribute('width'), - renderHTML: (attributes: Record) => - attributes.width ? { width: String(attributes.width) } : {}, -} - -const heightAttr = { - default: null, - parseHTML: (element: HTMLElement) => element.getAttribute('height'), - renderHTML: (attributes: Record) => - attributes.height ? { height: String(attributes.height) } : {}, -} - -/** Link target of a linked image — markdown-only state, never emitted as an HTML `` attribute. */ -const hrefAttr = { default: null, rendered: false } -const hrefTitleAttr = { default: null, rendered: false } - -/** - * Image node that carries optional `width`/`height` (serialized as an HTML `` tag) and an - * optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless - * round-trip path (no node view) and the live {@link ResizableImage}. - */ -export const MarkdownImage = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - width: widthAttr, - height: heightAttr, - href: hrefAttr, - hrefTitle: hrefTitleAttr, - } - }, - markdownTokenizer: { - name: 'image', - level: 'inline', - start: (src: string) => src.indexOf('[!['), - tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => { - const match = LINKED_IMAGE_RE.exec(src) - if (!match) return undefined - return { - type: 'image', - raw: match[0], - alt: match[1] ?? '', - src: match[2], - title: match[3] ?? null, - href: match[4], - hrefTitle: match[5] ?? null, - } - }, - }, - parseMarkdown: parseImageToken, - renderMarkdown: imageMarkdown, -}) - /** * Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging * commits the new pixel width to the `width` attribute, which serializes to ``. diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index 586f2bc2ff2..9c2ba8f3b70 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -11,6 +11,7 @@ import { GapCursor } from '@tiptap/pm/gapcursor' import { AllSelection, NodeSelection } from '@tiptap/pm/state' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMarkdownEditorExtensions } from './editor-extensions' +import { postProcessSerializedMarkdown } from './markdown-fidelity' import { MENTION_PLUGIN_KEY } from './mention' import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' @@ -192,13 +193,11 @@ describe('empty wrapped-block Backspace', () => { it.each([ ['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'], ['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'], - ['bullet last', '- one\n- two', 'two', '- one'], ['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'], ['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'], ['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'], - ['nested item', '- one\n - two\n- three', 'two', '- one\n- three'], ])( - 'removes the emptied %s cleanly — one container, no stray paragraph, round-trips', + 'removes an emptied non-trailing %s cleanly — one container, no stray paragraph, round-trips', (_label, markdown, word, expected) => { const editor = editorWith('') editor.commands.setContent(markdown, { contentType: 'markdown' }) @@ -213,10 +212,10 @@ describe('empty wrapped-block Backspace', () => { } ) - it('leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start', () => { - // Regression: `Selection.near` after the delete silently NodeSelected the following image, so a - // second Backspace while "clearing the bullet" deleted the image (and typing would have replaced - // it). The selection left behind must never make the next keystroke destructive. + it('clears a lone empty bullet before an image to a paragraph and never destroys the image', () => { + // Regression: an earlier delete-and-jump path silently NodeSelected the following image, so a + // second Backspace while "clearing the bullet" deleted it. The lone empty bullet now lifts into an + // empty paragraph in place, the image is untouched, and no repeated Backspace destroys it. const editor = editorWith({ type: 'doc', content: [ @@ -227,11 +226,11 @@ describe('empty wrapped-block Backspace', () => { editor.commands.setTextSelection(3) pressBackspace(editor) - expect(blockShape(editor)).toEqual(['image', 'paragraph']) + expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph']) expect(editor.state.selection).not.toBeInstanceOf(NodeSelection) pressBackspace(editor) - expect(blockShape(editor)).toEqual(['image', 'paragraph']) + expect(blockShape(editor)).toContain('image') editor.destroy() }) @@ -252,11 +251,10 @@ describe('empty wrapped-block Backspace', () => { editor.destroy() }) - it('never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)', () => { - // `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly, - // prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an - // image directly before the emptied bullet and no textblock behind it, the backward search - // returns null and the gap-cursor branch takes over — the image is never silently selected. + it('clears a lone empty bullet after an image to a paragraph without selecting the image', () => { + // With an image directly before the emptied bullet, clearing the bullet must not silently select + // (and so endanger) the image. The bullet lifts into an empty paragraph in place; the image is + // untouched and no repeated Backspace destroys it. const editor = editorWith({ type: 'doc', content: [ @@ -268,11 +266,11 @@ describe('empty wrapped-block Backspace', () => { expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') pressBackspace(editor) - expect(blockShape(editor)).toEqual(['image', 'paragraph']) + expect(blockShape(editor)).toEqual(['image', 'paragraph', 'paragraph']) expect(editor.state.selection).not.toBeInstanceOf(NodeSelection) pressBackspace(editor) - expect(blockShape(editor)).toEqual(['image', 'paragraph']) + expect(blockShape(editor)).toContain('image') editor.destroy() }) @@ -291,7 +289,7 @@ describe('empty wrapped-block Backspace', () => { editor.destroy() }) - it('still prefers the previous textblock caret when one exists (image after the bullet untouched)', () => { + it('clears a lone empty bullet between a paragraph and an image to a paragraph, leaving the image', () => { const editor = editorWith({ type: 'doc', content: [ @@ -303,10 +301,204 @@ describe('empty wrapped-block Backspace', () => { editor.commands.setTextSelection(10) pressBackspace(editor) - expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph']) + expect(blockShape(editor)).toEqual(['paragraph', 'paragraph', 'image', 'paragraph']) expect(editor.state.selection.empty).toBe(true) expect(editor.state.selection).not.toBeInstanceOf(NodeSelection) - expect(editor.state.selection.$from.parent.textContent).toBe('hello') + // The cleared bullet is now an empty paragraph, and 'hello' is untouched above it. + expect(editor.state.doc.firstChild?.textContent).toBe('hello') + editor.destroy() + }) +}) + +describe('list Backspace (clear / outdent)', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + /** Puts the caret at the very start of the item text `word`. */ + function caretAtStartOf(editor: Editor, word: string): void { + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === word) editor.commands.setTextSelection(pos) + }) + } + + it('lifts a top-level bullet WITH TEXT into a paragraph, keeping the text (round-trips)', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n- two', { contentType: 'markdown' }) + editor.commands.focus() + caretAtStartOf(editor, 'two') + pressBackspace(editor) + + // A bulletList (just 'one') followed by the lifted 'two' paragraph (+ TipTap's trailing filler). + expect(blockShape(editor).slice(0, 2)).toEqual(['bulletList', 'paragraph']) + const { md, reparsed } = markdownRoundTrip(editor) + expect(md.trim()).toBe('- one\n\ntwo') + expect(reparsed).toBe(md) + editor.destroy() + }) + + it('clears an empty TRAILING bullet to a paragraph in place — no delete, caret stays on the line', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n- two', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'two') + pressBackspace(editor) + + // The bullet becomes a paragraph; the list keeps only 'one'. The caret sits in the new empty + // paragraph rather than jumping back into the 'one' bullet. + const list = editor.getJSON().content?.find((node) => node.type === 'bulletList') + expect(list?.content).toHaveLength(1) + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + expect(editor.state.selection.$from.parent.textContent).toBe('') + expect(editor.getMarkdown().trim()).toBe('- one') + editor.destroy() + }) + + it('clears a lone empty bullet to an empty paragraph (whole doc)', () => { + const editor = editorWith('') + editor.commands.setContent('- one', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'one') + pressBackspace(editor) + + expect(editor.getJSON().content?.some((n) => n.type === 'bulletList')).toBe(false) + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + editor.destroy() + }) + + it('outdents a nested bullet WITH TEXT one level instead of merging it (round-trips)', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n - two', { contentType: 'markdown' }) + editor.commands.focus() + caretAtStartOf(editor, 'two') + pressBackspace(editor) + + const { md, reparsed } = markdownRoundTrip(editor) + expect(md.trim()).toBe('- one\n- two') + expect(reparsed).toBe(md) + editor.destroy() + }) + + it('outdents an empty nested bullet one level (round-trips)', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n - two\n- three', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'two') + pressBackspace(editor) + + const { md, reparsed } = markdownRoundTrip(editor) + expect(md.trim()).toBe('- one\n- \n- three') + expect(reparsed).toBe(md) + editor.destroy() + }) + + it('clears a checklist item the same way (task item → paragraph)', () => { + const editor = editorWith('') + editor.commands.setContent('- [ ] one\n- [ ] two', { contentType: 'markdown' }) + editor.commands.focus() + caretAtStartOf(editor, 'two') + pressBackspace(editor) + + expect(blockShape(editor).slice(0, 2)).toEqual(['taskList', 'paragraph']) + expect(editor.getMarkdown().trim()).toBe('- [ ] one\n\ntwo') + editor.destroy() + }) + + it('does not delete a non-trailing item whose block holds only a non-text atom', () => { + // Emptiness is the caret block's content.size, not its text: a bullet holding only an inline atom + // (image/mention — here a hardBreak stand-in) is NOT block-empty, so Backspace clears it to a + // paragraph (content preserved) instead of removeEmptyWrappedBlock deleting the whole row. + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'hardBreak' }] }], + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] }], + }, + ], + }, + ], + }) + const atomPos = firstPosOf(editor, 'hardBreak') + editor.commands.setTextSelection(atomPos) + pressBackspace(editor) + + // The atom survives (not deleted) and 'two' is untouched. + expect(firstPosOf(editor, 'hardBreak')).toBeGreaterThanOrEqual(0) + expect(editor.state.doc.textContent).toContain('two') + editor.destroy() + }) + + it('removes only the empty first block of a multi-block item, not the whole item', () => { + // An empty first block whose item has sibling blocks must not lift the whole item out of the list; + // only that empty block is removed, the rest of the item (and the list) stays intact. + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { type: 'paragraph' }, + { type: 'paragraph', content: [{ type: 'text', text: 'more' }] }, + ], + }, + ], + }, + ], + }) + // Caret at the start of the empty first paragraph (position 3: doc>bulletList>listItem>paragraph). + editor.commands.setTextSelection(3) + pressBackspace(editor) + + // Still a list (item was NOT lifted out to a top-level paragraph), and 'more' survives. + expect(blockShape(editor)[0]).toBe('bulletList') + expect(editor.state.doc.textContent).toBe('more') + const list = editor.getJSON().content?.find((n) => n.type === 'bulletList') + expect(list?.content).toHaveLength(1) + editor.destroy() + }) +}) + +describe('empty nested bullet does not corrupt its parent (Enter → Tab)', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + it('serializes a stranded empty sub-bullet away instead of turning the parent into a heading', () => { + // Repro: type a bullet, Enter for a new bullet, Tab to indent it into an empty sub-bullet, then + // leave it. The serialized `- one\n - ` would re-parse as `- ## one` (Setext underline). The + // serialize step must strip the empty sub-bullet so the parent stays a bullet and round-trips. + const editor = editorWith('') + editor.commands.setContent('- one', { contentType: 'markdown' }) + editor.commands.focus() + let end = -1 + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === 'one') end = pos + 3 + }) + editor.commands.setTextSelection(end) + pressKey(editor, 'Enter') + pressKey(editor, 'Tab') + + const saved = postProcessSerializedMarkdown(editor.getMarkdown()) + expect(saved).toBe('- one\n') + + // Reloading the saved markdown keeps a bullet — never a heading. + editor.commands.setContent(saved, { contentType: 'markdown' }) + expect(blockShape(editor)).not.toContain('heading') + expect(blockShape(editor)).toContain('bulletList') editor.destroy() }) }) @@ -341,6 +533,52 @@ describe('empty list-item Enter', () => { expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true) editor.destroy() }) + + it('outdents an empty NESTED item one level instead of removing it', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n - two', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'two') + pressKey(editor, 'Enter') + + // The emptied nested item outdents to a second top-level bullet rather than being deleted. + const list = editor.getJSON().content?.find((node) => node.type === 'bulletList') + expect(list?.content).toHaveLength(2) + expect(list?.content?.every((item) => item.type === 'listItem')).toBe(true) + editor.destroy() + }) + + it('removes only the empty first block of a multi-block item, matching Backspace (keeps the list)', () => { + // Symmetry with the Backspace multi-block case: an empty first block whose item has sibling blocks + // is removed in place — the continuation and the list stay intact — rather than exiting the list. + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { type: 'paragraph' }, + { type: 'paragraph', content: [{ type: 'text', text: 'more' }] }, + ], + }, + ], + }, + ], + }) + // Caret at the start of the empty first paragraph (doc>bulletList>listItem>paragraph). + editor.commands.setTextSelection(3) + pressKey(editor, 'Enter') + + expect(blockShape(editor)[0]).toBe('bulletList') + expect(editor.state.doc.textContent).toBe('more') + const list = editor.getJSON().content?.find((n) => n.type === 'bulletList') + expect(list?.content).toHaveLength(1) + editor.destroy() + }) }) describe('verbatim block boundary (isolating)', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index bdbac57bfc6..524e35eee21 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -20,6 +20,50 @@ const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote']) /** Item node types a list is built from, used to detect an empty item's position within its list. */ const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem']) +/** The enclosing list/task item at a caret position, with the facts the boundary keys branch on. */ +interface ListItemContext { + /** `'listItem'` or `'taskItem'` — the type name `liftListItem` must be called with. */ + itemType: string + /** The item's list is itself inside another list item, i.e. the item is indented. */ + isNested: boolean + /** + * The caret's own block (the row the boundary key acts on) has no content. Uses `content.size`, so an + * inline image or mention atom counts as content — a bullet holding only an image is NOT block-empty. + */ + blockEmpty: boolean + /** The item has more than one child block (continuation paragraph, nested list, block image, …). */ + hasSiblingBlocks: boolean + /** The item is the last child of its immediate list. */ + isTrailing: boolean + /** The caret sits in the item's first child block (the row a boundary key should act on). */ + isFirstBlock: boolean +} + +/** + * Resolves the nearest enclosing list/task item at `$from` and the facts the Backspace/Enter handlers + * branch on (nesting, emptiness, trailing position, whether the caret is in the item's first block), or + * null when the caret is not inside a list item. Walking up from `$from` finds the item regardless of + * how deeply the caret's block is nested inside it. + */ +function getListItemContext($from: ResolvedPos): ListItemContext | null { + for (let depth = $from.depth; depth >= 1; depth--) { + const item = $from.node(depth) + if (!LIST_ITEM_TYPES.has(item.type.name)) continue + const listDepth = depth - 1 + const isNested = listDepth >= 1 && LIST_ITEM_TYPES.has($from.node(listDepth - 1).type.name) + const list = $from.node(listDepth) + return { + itemType: item.type.name, + isNested, + blockEmpty: $from.parent.content.size === 0, + hasSiblingBlocks: item.childCount > 1, + isTrailing: $from.index(listDepth) === list.childCount - 1, + isFirstBlock: $from.index(depth) === 0, + } + } + return null +} + const RICH_LEAF_SELECTION_FOCUS_KEY = new PluginKey('richLeafSelectionFocus') /** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */ @@ -136,21 +180,26 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b * Editor-specific keyboard behavior layered on top of StarterKit's defaults: * * - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or - * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an - * *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via - * {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of - * the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap - * that also re-parses to a different markdown document), while the default `joinBackward` alternately - * no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the - * previous item. At the start of a block whose previous sibling is a divider or image, where - * ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing - * the blank line between/below dividers without touching the divider itself), while a *non-empty* - * block selects the leaf — so a first Backspace highlights what a second deletes, the same - * highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection. - * - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link - * removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded - * empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the - * default, which exits the list — the standard "press Enter on a blank bullet to leave the list". + * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a + * *list or task item* it outdents or clears in place via {@link getListItemContext}: a nested item outdents one + * level, a top-level item with text lifts out of the list into a paragraph (keeping the text), and a + * top-level *empty trailing* (or sole) item lifts into an empty paragraph in place — so the blank + * bullet made by pressing Enter can be cleared back to normal text on the same line instead of being + * deleted with the caret jumping to the previous block. The one case lift can't take is a top-level + * *empty, non-trailing* item: lifting it strands an empty paragraph between the two list halves, which + * re-parses to a different markdown document (an empty line between list items is a loose list, not a + * break); that item is removed via {@link removeEmptyWrappedBlock} instead, keeping the list whole. An + * empty block inside a *blockquote* is likewise removed via {@link removeEmptyWrappedBlock}. At the + * start of a block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` + * can't cross the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below + * dividers without touching the divider itself), while a *non-empty* block selects the leaf — so a + * first Backspace highlights what a second deletes, the same highlight-before-delete affordance as + * clicking it and parity with the arrow-key leaf selection. + * - **Enter** on an empty *nested* list/task item outdents it one level, on an empty + * *non-trailing top-level* item removes it ({@link removeEmptyWrappedBlock}) rather than splitting the + * list around a stranded empty paragraph (which does not round-trip), and on an empty *trailing* item + * falls through to the default, which exits the list — the standard "press Enter on a blank bullet to + * leave the list". * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the * block is already fully selected) falls through to the default whole-document select-all, the * same scoped behavior as a code editor. @@ -183,6 +232,28 @@ export const RichMarkdownKeymap = Extension.create({ if ($from.parent.type.name === 'heading') { return editor.commands.setParagraph() } + const listCtx = getListItemContext($from) + if (listCtx?.isFirstBlock) { + const { itemType, isNested, blockEmpty, hasSiblingBlocks, isTrailing } = listCtx + // Backspace at the start of a bullet outdents or clears it in place rather than + // deleting the row and jumping the caret to the previous block. + // - Nested item → outdent one level (empty or not). + // - Top-level item whose first line has content (text OR an inline image/mention) → lift out of + // the list into a paragraph, keeping that content. + // - Top-level item whose empty first block has *sibling* blocks (a continuation paragraph, a + // block image, a nested list) → remove only that empty first block via {@link + // removeEmptyWrappedBlock}, leaving the rest of the item intact (never lift the whole item). + // - Top-level empty single-block item that is trailing (or the sole item) → lift into an empty + // paragraph in place, so a fresh bullet made with Enter can be cleared to normal text in place. + // A top-level *empty, non-trailing* single-block item is the one case lift can't take: it strands + // an empty paragraph between the two list halves, which re-parses to a different markdown document + // (an empty line between list items is a loose list, not a break). That case removes the row via + // {@link removeEmptyWrappedBlock} instead, which keeps the list whole and round-trips. + if (isNested || !blockEmpty) return editor.commands.liftListItem(itemType) + if (hasSiblingBlocks) return removeEmptyWrappedBlock(editor, $from) + if (isTrailing) return editor.commands.liftListItem(itemType) + return removeEmptyWrappedBlock(editor, $from) + } if ($from.parent.content.size === 0 && isInsideWrapper($from)) { return removeEmptyWrappedBlock(editor, $from) } @@ -207,11 +278,17 @@ export const RichMarkdownKeymap = Extension.create({ if (!selection.empty || selection.$from.parentOffset !== 0) return false const { $from } = selection if ($from.parent.content.size !== 0) return false - const itemDepth = $from.depth - 1 - if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false - const listDepth = itemDepth - 1 - const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1 - if (isTrailingItem) return false + const listCtx = getListItemContext($from) + if (!listCtx?.isFirstBlock) return false + // Enter on an empty item, mirroring the Backspace cases above: a nested item outdents one level; + // an empty first block that has *sibling* blocks (continuation paragraph, block image, nested + // list) removes only that empty block in place, keeping the rest of the item — never exiting the + // list or splitting it; a trailing single-block item falls through to the default (exits the + // list); and a non-trailing single-block item is removed rather than splitting the list around a + // stranded empty paragraph (which does not round-trip). + if (listCtx.isNested) return editor.commands.liftListItem(listCtx.itemType) + if (listCtx.hasSiblingBlocks) return removeEmptyWrappedBlock(editor, $from) + if (listCtx.isTrailing) return false return removeEmptyWrappedBlock(editor, $from) }, 'Mod-a': ({ editor }) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts new file mode 100644 index 00000000000..721b91dc305 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { postProcessSerializedMarkdown } from './markdown-fidelity' + +describe('postProcessSerializedMarkdown — empty list-item stripping', () => { + it('drops a nested empty bullet that would re-parse as a Setext heading', () => { + // `- one\n - ` re-parses as `- ## one` (the ` - ` acts as a Setext underline). Stripping the + // empty bullet on serialize keeps the parent a bullet and makes the round-trip stable. + expect(postProcessSerializedMarkdown('- one\n - \n\n')).toBe('- one\n') + }) + + it('drops a nested empty ordered item', () => { + expect(postProcessSerializedMarkdown('1. one\n 2. \n')).toBe('1. one\n') + }) + + it('preserves a top-level empty bullet (placeholder / imported blank — round-trips faithfully)', () => { + // A top-level empty item is not Setext-hazardous and round-trips as an empty item, so it must be + // kept: it may be a placeholder row the user is about to fill, or an intentionally-blank imported item. + expect(postProcessSerializedMarkdown('- one\n- \n')).toBe('- one\n- \n') + expect(postProcessSerializedMarkdown('- one\n- \n- three\n')).toBe('- one\n- \n- three\n') + expect(postProcessSerializedMarkdown('1. one\n2. \n')).toBe('1. one\n2. \n') + }) + + it('keeps bullets that have content', () => { + expect(postProcessSerializedMarkdown('- a\n- b\n')).toBe('- a\n- b\n') + expect(postProcessSerializedMarkdown('- a\n - b\n')).toBe('- a\n - b\n') + }) + + it('keeps a nested empty parent whose next line is an indented child (no orphaning)', () => { + expect(postProcessSerializedMarkdown('- top\n - \n - child\n')).toBe( + '- top\n - \n - child\n' + ) + }) + + it('keeps a nested empty item that follows a same-indent sibling (real placeholder, no Setext hazard)', () => { + // ` - ` after ` - two` (same indent) is a real empty item the parser keeps — it does NOT underline + // a shallower parent's text, so it must not be stripped. (Only ` - ` directly under `- one` does.) + expect(postProcessSerializedMarkdown('- one\n - two\n - \n - three\n')).toBe( + '- one\n - two\n - \n - three\n' + ) + // The hazard case — empty item directly under the shallower parent — is still stripped. + expect(postProcessSerializedMarkdown('- one\n - \n - three\n')).toBe('- one\n - three\n') + }) + + it('keeps a thematic break and empty checklist items (not Setext-hazardous)', () => { + expect(postProcessSerializedMarkdown('text\n\n---\n\nmore\n')).toBe('text\n\n---\n\nmore\n') + expect(postProcessSerializedMarkdown('- [ ] a\n- [ ] \n')).toBe('- [ ] a\n- [ ] \n') + }) + + it('leaves marker-only lines inside a fenced code block untouched', () => { + const code = '```\n- \n-\n1. \n```\n' + expect(postProcessSerializedMarkdown(code)).toBe(code) + }) + + it('leaves marker-only lines inside a tilde (~~~) fence untouched', () => { + const code = '~~~\n- \n1. \n~~~\n' + expect(postProcessSerializedMarkdown(code)).toBe(code) + }) + + it('does not strip inside an unterminated fence (fence stays open to EOF)', () => { + // A fence with no closing delimiter must keep every interior line, including marker-only ones. + const code = '```\n- \n-\n' + expect(postProcessSerializedMarkdown(code)).toBe(code) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts index f416482d491..94511d5ebad 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts @@ -106,16 +106,78 @@ export function normalizeLinkHref(href: string): string { return `https://${trimmed}` } +/** A line that is a bullet/ordered list marker with no content (`-`, ` - `, `1. `). Task items (`- [ ]`) don't match. */ +const EMPTY_LIST_ITEM_LINE = /^([ \t]*)(?:[-*+]|\d+[.)])[ \t]*$/ +/** A fenced code-block delimiter (``` or ~~~), used to leave code interiors untouched. */ +const FENCE_DELIMITER = /^[ \t]*(`{3,}|~{3,})/ +/** Leading indentation of a line, used to detect whether an empty list item has indented children. */ +const LEADING_INDENT = /^[ \t]*/ + +/** + * Removes only the *nested* empty list-item marker lines that re-parse as a Setext heading underline: + * a nested empty bullet (` - `) sitting DIRECTLY under a shallower parent line silently turns that + * parent's text into an `## heading` and drops the bullet on the next load (a data-corrupting + * round-trip). The strip is therefore scoped by three conditions, all required: + * - *indented* (`indent > 0`): a top-level empty bullet (`- ` / `1. `) round-trips faithfully as an + * empty item, never a heading, so a placeholder/blank imported row is preserved. + * - the immediately-preceding line is *shallower* (the parent whose text the underline would consume): + * an empty item after a *same-indent sibling* (` - two` then ` - `) does NOT corrupt — the parser + * keeps it as a real empty item — so it is preserved. A blank line above also breaks the hazard. + * - no more-indented children on the next non-blank line, so its children are never orphaned. + * + * Operates only on the editor's own serialized output, which uses fenced (never 4-space-indented) code + * blocks and `\n` newlines — so tracking fences is sufficient and a bare `-` inside an indented code + * block or a `-\r` line is not a case that can occur here. + */ +function stripEmptyListItemLines(markdown: string): string { + const lines = markdown.split('\n') + const kept: string[] = [] + let fence: string | null = null + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const delimiter = line.match(FENCE_DELIMITER)?.[1] + if (fence) { + kept.push(line) + if (delimiter && delimiter[0] === fence[0] && delimiter.length >= fence.length) fence = null + continue + } + if (delimiter) { + fence = delimiter + kept.push(line) + continue + } + const empty = line.match(EMPTY_LIST_ITEM_LINE) + if (empty) { + const indent = empty[1].length + let next = i + 1 + while (next < lines.length && lines[next].trim() === '') next++ + const hasChildren = + next < lines.length && (lines[next].match(LEADING_INDENT)?.[0].length ?? 0) > indent + // The Setext-underline hazard exists only when the empty item follows a SHALLOWER parent line + // (whose text the underline would consume). An empty item after a same/deeper-indent sibling + // (` - two` then ` - `) is a real empty item the parser keeps — a nested placeholder between + // siblings must not be lost. Uses the preceding non-blank line's indent; a lone empty item with + // nothing above it (`prevIndent = -1`) has no parent text to corrupt but stays stripped as before. + let prevIdx = i - 1 + while (prevIdx >= 0 && lines[prevIdx].trim() === '') prevIdx-- + const prevIndent = prevIdx >= 0 ? (lines[prevIdx].match(LEADING_INDENT)?.[0].length ?? 0) : -1 + if (indent > 0 && !hasChildren && prevIndent < indent) continue + } + kept.push(line) + } + return kept.join('\n') +} + /** - * Cleans up serializer output: restores callout markers the serializer backslash-escapes - * (`> \[!NOTE\]` → `> [!NOTE]`) and collapses trailing blank lines to a single newline. The - * table serializer's spurious surrounding blank lines are trimmed at the source (PipeSafeTable), - * so no global leading-newline strip is needed here — avoiding clobbering content that legitimately - * begins with whitespace. + * Cleans up serializer output: drops empty list-item marker lines that would otherwise corrupt on + * round-trip ({@link stripEmptyListItemLines}), restores callout markers the serializer + * backslash-escapes (`> \[!NOTE\]` → `> [!NOTE]`), and collapses trailing blank lines to a single + * newline. The table serializer's spurious surrounding blank lines are trimmed at the source + * (PipeSafeTable), so no global leading-newline strip is needed here — avoiding clobbering content + * that legitimately begins with whitespace. */ export function postProcessSerializedMarkdown(markdown: string): string { - return collapseAutolinkedUrls(markdown.replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]')).replace( - /\n+$/, - '\n' - ) + return collapseAutolinkedUrls( + stripEmptyListItemLines(markdown).replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]') + ).replace(/\n+$/, '\n') } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index 48cb5718ab8..d59ef6afe6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -59,6 +59,12 @@ const CASES: Array<[string, string]> = [ '1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second', ], ['heading-separated sections', '# A\n\nalpha\n\n## B\n\nbeta\n\n## C\n\ngamma'], + // Blank-line spacing: `@tiptap/markdown` reconstructs empty paragraphs from runs of blank lines, so + // the chunker must reinsert them or a saved blank line vanishes on reload. See the dedicated + // "empty paragraphs" suite below for the exact whole-document-parser parity. + ['one empty paragraph between paragraphs', 'first\n\n\n\nsecond'], + ['two empty paragraphs between paragraphs', 'first\n\n\n\n\n\nsecond'], + ['empty paragraphs between headings and text', '# A\n\n\n\nalpha\n\n\n\n## B'], ] describe('parseMarkdownToDoc (chunked)', () => { @@ -87,6 +93,66 @@ describe('parseMarkdownToDoc (chunked)', () => { expect(splitMarkdownBlocks('\n\n \n')).toEqual([]) }) + // The chunker used to drop empty paragraphs (visual blank lines between blocks) that the whole-document + // parser preserves, so a saved blank line silently vanished on the next load. These assert the chunked + // parse reconstructs the SAME empty-paragraph structure the whole-document parser does — at document + // edges and between blocks, for one or many blank lines, and around lists. + describe('empty paragraphs (blank-line spacing) match the whole-document parser', () => { + /** Block-type shape of a doc, `∅` for an empty paragraph, normalized through the editor. */ + function shapeOf(md: string, parse: 'chunked' | 'whole'): string { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + if (parse === 'whole') editor.commands.setContent(md, { contentType: 'markdown' }) + else editor.commands.setContent(parseMarkdownToDoc(md), { contentType: 'json' }) + const shape = (editor.getJSON().content ?? []) + .map((n) => (n.type === 'paragraph' && !n.content?.length ? '∅' : n.type)) + .join(',') + editor.destroy() + editor = null + return shape + } + + it.each([ + ['one empty between paragraphs', 'a\n\n\n\nb'], + ['two empties between paragraphs', 'a\n\n\n\n\n\nb'], + ['three empties between paragraphs', 'a\n\n\n\n\n\n\n\nb'], + ['even blank-line gap (rounds down)', 'a\n\n\n\n\nb'], + ['leading empties', '\n\n\n\na'], + ['leading + between', '\n\n\na\n\n\n\nb'], + ['empties between a heading and text', '# H\n\n\n\ntext'], + ['empties after a tight list', '- a\n- b\n\n\n\ntext'], + ['empties before a tight list', 'text\n\n\n\n- a\n- b'], + // Line-ending variants: the whole-vs-chunked routing must normalize first, or a `\r`-only body + // skips the empty-paragraph guard and is chunked (dropping the empties this fix restores). + ['CRLF between empties', 'a\r\n\r\n\r\n\r\nb'], + ['CR-only (classic Mac) between empties', 'a\r\r\r\rb'], + ])('chunked matches whole-doc: %s', (_label, md) => { + expect(shapeOf(md, 'chunked')).toBe(shapeOf(md, 'whole')) + }) + }) + + // Regression: a file ending in a blank line (a trailing empty paragraph) must stay EDITABLE. Such an + // empty paragraph can't be serialized stably (postProcess collapses trailing newlines), so the parser + // strips it — keeping the doc round-trip-safe/idempotent instead of flipping the file read-only. + describe('trailing blank lines stay editable (regression)', () => { + it.each([ + ['plain paragraph', 'abc\n\n'], + ['heading + text', '# Title\n\nSome text\n\n'], + ['three trailing newlines', 'hello\n\n\n'], + ['two paragraphs', 'para one\n\npara two\n\n'], + ['interior empties + trailing', 'a\n\n\n\nb\n\n'], + ])('a file ending in a blank line is round-trip-safe: %s', (_label, md) => { + expect(isRoundTripSafe(md)).toBe(true) + }) + + it('strips the trailing empty paragraph but keeps interior ones', () => { + const trailing = parseMarkdownToDoc('abc\n\n').content ?? [] + expect(trailing.at(-1)?.type).toBe('paragraph') + expect(trailing.at(-1)?.content?.length ?? 0).toBeGreaterThan(0) + const interior = parseMarkdownToDoc('a\n\n\n\nb').content ?? [] + expect(interior.some((n) => n.type === 'paragraph' && !n.content?.length)).toBe(true) + }) + }) + it('parses reference-style links whole (non-chunkable) without dropping the definition', () => { const body = 'See [the docs][ref] for details.\n\n[ref]: https://example.com/docs' expect(serializeMarkdownBody(body)).toBe(oneShot(body)) @@ -195,13 +261,18 @@ function buildFuzzDoc(seed: number): string { describe('chunked parse — property test over randomized documents', () => { it('chunked === one-shot for every document, and idempotent for every editable one', () => { const failures: Array<{ seed: number; kind: string }> = [] + // Compare modulo trailing whitespace: `parseMarkdownToDoc` strips trailing empty paragraphs (they + // can't be serialized stably — postProcess collapses trailing newlines — so keeping them would flip + // the file read-only), whereas the raw one-shot parse keeps them. That trailing-only divergence is + // intended and invisible after save; interior/leading fidelity is still compared exactly. + const trimEnd = (md: string) => md.replace(/\n+$/, '') for (let seed = 1; seed <= 400; seed++) { const body = buildFuzzDoc(seed) const chunked = serializeMarkdownBody(body) // Fidelity is the load-bearing invariant — chunked must never diverge from the whole-document // parse, for ANY input; idempotency only needs to hold where the doc is editable (raw HTML is // non-idempotent in the underlying editor regardless of chunking, which is why it opens read-only). - if (chunked !== oneShot(body)) failures.push({ seed, kind: 'fidelity' }) + if (trimEnd(chunked) !== trimEnd(oneShot(body))) failures.push({ seed, kind: 'fidelity' }) else if (isRoundTripSafe(body) && serializeMarkdownBody(chunked) !== chunked) { failures.push({ seed, kind: 'idempotency' }) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index 5417c0ee047..d8255eef7e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -47,6 +47,20 @@ const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/ const LIST_MARKER = /^[ ]{0,3}(?:[-*+]|\d+[.)])\s/ const BLOCKQUOTE = /^[ ]{0,3}>/ +/** + * Blank-line spacing that `@tiptap/markdown` reconstructs as *interior* or *leading* empty paragraphs — + * a run of two or more blank lines somewhere, or blank line(s) at the document's leading edge. `[^\S\n]` + * matches horizontal whitespace, so a "blank" line may carry spaces/tabs. This is only ever tested + * against the `\r`-normalized body ({@link parseMarkdownToDoc}), so no CRLF handling is needed here. + * + * A *single* trailing blank line is deliberately not matched — purely to avoid routing an otherwise-plain + * file to the slower whole-document parser. Correctness does not depend on it: {@link parseMarkdownToDoc} + * strips trailing empty paragraphs on *both* parse paths ({@link stripTrailingEmptyParagraphs}), so + * serialize→parse stays idempotent regardless of which parser ran. (A trailing run of two or more blanks + * still matches the interior alternative — harmless, since the strip cleans it either way.) + */ +const EMPTY_PARAGRAPH_SPACING = /\n[^\S\n]*\n[^\S\n]*\n|^[^\S\n]*\n[^\S\n]*\n/ + /** * Split a markdown body into top-level blocks that can each be parsed independently and reassembled * without changing meaning. Blank lines separate candidate groups (fenced code blocks stay atomic), @@ -120,20 +134,57 @@ export function splitMarkdownBlocks(body: string): string[] { * vs ~1270ms at 61KB — and byte-identical, because each block is parsed with the same tokenizers. * Documents whose constructs span blocks ({@link NON_CHUNKABLE}) parse whole, and any failure falls * back to a single whole-document parse, so correctness never depends on the splitter. + * + * Blank-line spacing ({@link EMPTY_PARAGRAPH_SPACING}) also parses whole: the chunker parses each block + * stripped of the blank lines between them, so it drops the empty paragraphs `@tiptap/markdown` builds + * from runs of blank lines — a saved visual blank line would silently vanish on reload. Whether a gap + * yields an empty paragraph is a global, block-type-dependent decision (kept between two paragraphs, + * dropped after a heading), so it can't be reconstructed block-locally; these documents parse whole for + * exact fidelity. Ordinary single-blank-line separation still takes the fast chunked path. */ export function parseMarkdownToDoc(body: string): JSONContent { const manager = markdownManager() - if (NON_CHUNKABLE.test(body)) return manager.parse(body) - try { - const content: JSONContent[] = [] - for (const block of splitMarkdownBlocks(body)) { - // `MarkdownManager.parse` always returns a doc node with a `content` array; spread its blocks. - content.push(...(manager.parse(block).content ?? [])) + // Normalize line endings up front so the routing guards see the same `\n` the chunker and parser + // do — the guards' `\n`-anchored tests would otherwise miss a classic `\r`-only body (its blank + // lines are `\r`), routing it to the chunker that then drops its empty paragraphs. + const normalized = body.replace(/\r\n?/g, '\n') + let doc: JSONContent + if (NON_CHUNKABLE.test(normalized) || EMPTY_PARAGRAPH_SPACING.test(normalized)) { + doc = manager.parse(normalized) + } else { + try { + const content: JSONContent[] = [] + for (const block of splitMarkdownBlocks(normalized)) { + // `MarkdownManager.parse` always returns a doc node with a `content` array; spread its blocks. + content.push(...(manager.parse(block).content ?? [])) + } + doc = { type: 'doc', content } + } catch { + doc = manager.parse(normalized) } - return { type: 'doc', content } - } catch { - return manager.parse(body) } + return stripTrailingEmptyParagraphs(doc) +} + +/** An empty paragraph node — the shape a blank line reconstructs to (no content, or `content: []`). */ +function isEmptyParagraph(node: JSONContent): boolean { + return node.type === 'paragraph' && !node.content?.length +} + +/** + * Drop trailing empty paragraphs from a parsed doc. {@link postProcessSerializedMarkdown} collapses + * trailing blank lines to a single newline, so a trailing empty paragraph can never round-trip — the + * whole-document parser reconstructs one from a file ending in a blank line, but keeping it makes + * serialize→parse non-idempotent, which flips the file read-only via the round-trip-safety probe. + * Leading/interior empty paragraphs are untouched (postProcess never strips those). TipTap re-adds its + * own trailing filler paragraph on `setContent`, so the editor still has a place to type. + */ +function stripTrailingEmptyParagraphs(doc: JSONContent): JSONContent { + const content = doc.content + if (!content || content.length === 0) return doc + let end = content.length + while (end > 0 && isEmptyParagraph(content[end - 1])) end-- + return end === content.length ? doc : { ...doc, content: content.slice(0, end) } } /** @@ -142,8 +193,19 @@ export function parseMarkdownToDoc(body: string): JSONContent { * normalization the live editor applies, keeping the output identical to `editor.getMarkdown()`. */ export function serializeMarkdownBody(body: string): string { + return serializeDocToMarkdown(parseMarkdownToDoc(body)) +} + +/** + * Serialize a ProseMirror document (as TipTap {@link JSONContent}) to the editor's canonical + * markdown. Loaded via `setContent` so it passes through the same schema normalization the live + * editor applies — output identical to `editor.getMarkdown()`. The server-side collab-doc converter + * uses this to project a Yjs doc back to markdown through the exact client engine (parity by + * construction), so it must stay the single serialize path (do not inline `getMarkdown` elsewhere). + */ +export function serializeDocToMarkdown(doc: JSONContent): string { const editor = parserEditor() - editor.commands.setContent(parseMarkdownToDoc(body), { contentType: 'json' }) + editor.commands.setContent(doc, { contentType: 'json' }) return editor.getMarkdown() } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx index 4f261851c4e..cecd097fd81 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx @@ -3,7 +3,7 @@ import { cn } from '@sim/emcn' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { useParams, useRouter } from 'next/navigation' -import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' +import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style' import { mentionIcon } from './mention-icon' import { MarkdownMention, type MentionAttrs } from './mention-node' import { simLinkPath } from './sim-link' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-schema.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-schema.ts new file mode 100644 index 00000000000..abe01ce42e2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-schema.ts @@ -0,0 +1,481 @@ +/** + * React-free schema half of the raw-HTML/footnote nodes. Lives apart from {@link ./raw-markdown-snippet} + * (their React node views) so the shared editor schema — `createMarkdownContentExtensions` in + * `./extensions` — can be imported by server code (the collab-doc seed converter) without pulling a + * client component (`useEffect`) into a Server Component module. The client editor injects the + * node-view variants ({@link RawHtmlBlockWithView}, {@link FootnoteDefWithView}) via `nodeViews`. + */ +import type { JSONContent, MarkdownToken } from '@tiptap/core' +import { mergeAttributes, Node } from '@tiptap/core' + +/** + * Constructs the schema has no node/mark for: raw HTML blocks (`
`, `
`, …), HTML + * comments, and footnotes. Before this file, all four made the *entire* document open read-only + * (see {@link isRoundTripSafe in ./round-trip-safety}) because the stock pipeline silently drops + * or mangles them. Each node below instead holds the exact source text as its content and + * re-emits it byte-for-byte on serialize — the same "hold raw source, re-render specially" shape + * `MarkdownCodeBlock` uses for Mermaid (see `./code-block.tsx`), just without the diagram render. + * + * Inline tags already covered by a real mark/node — `em`/`i`, `strong`/`b`, `s`/`del`/`strike`, + * `code`, `a`, `br`, `img` — are deliberately excluded from {@link RawInlineHtml} so they keep + * parsing into their proper mark (e.g. `x` → italic) instead of freezing as raw source. + */ +const HANDLED_INLINE_TAGS = new Set([ + 'br', + 'img', + 'em', + 'i', + 'strong', + 'b', + 's', + 'del', + 'strike', + 'code', + 'a', +]) + +const VOID_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]) + +function verbatimText(node: JSONContent): string { + return (node.content ?? []).map((child) => child.text ?? '').join('') +} + +const RAW_HTML_COMMENT_RE = /^/ + +/** + * One HTML attribute: `name` or `name="value"`/`name='value'`/`name=bare`. The quoted-value + * alternatives are what matter — `[^"]*`/`[^']*` consume a literal `>` inside the quotes as part of + * the value, so an attribute like `data-example="a > b"` is treated as one unit instead of ending + * the tag match at the internal `>`. + */ +const ATTRS_RE_SOURCE = String.raw`(?:\s+[^\s"'=<>\`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>\`]+))?)*` + +/** Matches one opening HTML tag, attributes included (see {@link ATTRS_RE_SOURCE}). Group 1 is the + * tag name, group 2 is the self-closing `/` if present — shared by inline and block tokenizing. */ +const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'i') + +/** A fenced block's opening/closing marker may sit inside a blockquote (each line prefixed with + * up to 3 spaces then one or more `>` markers, each optionally followed by a space) and/or be + * independently indented up to 3 spaces with no blockquote at all (CommonMark's own fence-indent + * tolerance — matches `FENCE_OPEN`/`FENCE_CLOSE` in `./markdown-parse.ts`) — matched on both the + * open and close fence line so `> \`\`\`` and ` \`\`\`` both mask correctly. */ +const FENCE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*[ ]{0,3}' + +/** Same as {@link RAW_HTML_COMMENT_RE} but not anchored to the start of the string — used by + * {@link maskCodeRegions} to find a comment anywhere in the scanned text, not just at position 0. */ +const HTML_COMMENT_ANYWHERE_RE = //g + +/** + * Mask fenced code blocks, inline code spans, and HTML comments with same-length filler (newlines + * kept, everything else replaced with a space) so a tag-like mention *inside one of these* — + * `` `
` ``, a fenced example showing HTML syntax, or a comment documenting the tag + * (``) — is never mistaken for a real balancing tag while scanning. Mirrors + * the fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches (extended to also + * tolerate an indented and/or blockquoted fence marker via {@link FENCE_PREFIX_SOURCE}, since a raw + * HTML block can itself be indented or quoted), but preserves length/position (masks in place) + * instead of deleting, so match indices still map onto the original, unmodified `src` the caller + * slices from. + */ +function maskCodeRegions(src: string): string { + const fenceRe = new RegExp( + `^${FENCE_PREFIX_SOURCE}([\`~]{3,})[^\\n]*\\n[\\s\\S]*?^${FENCE_PREFIX_SOURCE}\\1[\`~]*[ \\t]*$`, + 'gm' + ) + return src + .replace(fenceRe, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/`+[^`\n]*`+/g, (m) => ' '.repeat(m.length)) + .replace(HTML_COMMENT_ANYWHERE_RE, (m) => m.replace(/[^\n]/g, ' ')) +} + +/** + * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, + * tracking nesting depth from `fromIndex` onward so `outer inner` consumes + * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A + * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. + * Shared by the inline tokenizer (single line) and the block tokenizer (spans blank lines). + * + * Scans a {@link maskCodeRegions}-masked copy of `src` so a tag name mentioned inside code doesn't + * count as real markup — this narrows, but can't eliminate, the inherent ambiguity of regex-based + * (non-DOM) tag matching: a *bare, unescaped* mention of the same tag name in plain prose (not in + * code) is indistinguishable from a real closing tag here, exactly as it would be to a real HTML + * parser given the same ambiguous input (there is no valid way to "escape" a literal `` inside + * real HTML content other than an entity or code region). Verified this can't lose data even in that + * case — the result still reaches a stable fixpoint on save, just restructured — matching this file's + * "reject on doubt, but never require doubt-free input" gate (`isRoundTripSafe`). + */ +function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { + const masked = maskCodeRegions(src) + const tagRe = new RegExp(`<(/?)${tag}\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'gi') + tagRe.lastIndex = fromIndex + let depth = 1 + for (let match = tagRe.exec(masked); match; match = tagRe.exec(masked)) { + const isClose = match[1] === '/' + const isSelfClosing = Boolean(match[2]) + if (isSelfClosing) continue + if (isClose) { + depth -= 1 + if (depth === 0) return match.index + match[0].length + } else { + depth += 1 + } + } + return -1 +} + +/** + * Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment + * or a def line as part of that token's own `raw` (the same behavior `PipeSafeTable` in + * `./extensions.ts` works around for tables) — storing it verbatim would double it up with the + * block joiner's own separator, growing by two newlines on every save. Block-level callers trim it; + * inline callers never carry one (inline tokens can't span a blank line), so trimming is a no-op there. + */ +function verbatimParse(raw: string): JSONContent[] { + const trimmed = raw.replace(/\n+$/, '') + return trimmed ? [{ type: 'text', text: trimmed }] : [] +} + +interface VerbatimNodeOptions { + name: string + /** Whether this node sits among block content (own line) or inline content (mid-paragraph). */ + inline: boolean + badgeLabel: string +} + +/** + * Shared shape for a node that holds a markdown construct's exact source text and re-emits it + * unchanged — parsing and rendering never inspect or transform the text, so there is nothing for + * these constructs to lose. `markdownTokenName`/`parseMarkdown`/`renderMarkdown` are read directly + * off the returned config by `@tiptap/markdown`'s `MarkdownManager` (see + * `node_modules/@tiptap/markdown/src/MarkdownManager.ts`), independent of the node's `name`. + */ +function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) { + return { + name, + inline, + group: inline ? 'inline' : 'block', + content: 'text*', + marks: '', + code: true, + defining: !inline, + // Block verbatim nodes hold exact source text; `isolating` stops a boundary Backspace/Delete from + // joining across their edge, which would otherwise merge their raw markdown into an adjacent + // paragraph as HTML-escaped prose and destroy the node (silent data loss on save). + isolating: !inline, + selectable: true, + atom: false, + parseHTML() { + return [ + { + tag: `${inline ? 'span' : 'div'}[data-raw-markdown="${name}"]`, + preserveWhitespace: 'full' as const, + }, + ] + }, + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return [ + inline ? 'span' : 'div', + mergeAttributes(HTMLAttributes, { + 'data-raw-markdown': name, + 'data-raw-markdown-label': badgeLabel, + class: inline ? 'raw-markdown-inline' : 'raw-markdown-block', + }), + 0, + ] as const + }, + renderMarkdown(node: JSONContent) { + return verbatimText(node) + }, + } +} + +/** + * Tag names CommonMark/GFM treat as "block-starting" HTML (marked's own type-6 list — see + * `_tag` in `node_modules/marked/src/rules.ts`, verified against the CommonMark spec): a block + * opening with one of these ends at its *matching closing tag*, not at the first blank line. Tags + * NOT in this list (`em`, `a`, `span`, `code`, `kbd`, …) can legitimately start an ordinary + * paragraph (`hi there`), so they're deliberately left to marked's own stricter, single-line + * block-HTML detection below — claiming them here would risk swallowing a paragraph that merely + * starts with inline HTML. + */ +const BLOCK_HTML_TAG_NAMES = new Set([ + 'address', + 'article', + 'aside', + 'base', + 'basefont', + 'blockquote', + 'body', + 'caption', + 'center', + 'col', + 'colgroup', + 'dd', + 'details', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'frame', + 'frameset', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hr', + 'html', + 'iframe', + 'legend', + 'li', + 'link', + 'main', + 'menu', + 'menuitem', + 'meta', + 'nav', + 'noframes', + 'ol', + 'optgroup', + 'option', + 'p', + 'param', + 'search', + 'section', + 'summary', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'title', + 'tr', + 'track', + 'ul', +]) + +/** + * Marked's built-in block-HTML rule ends a `
`/`
`/… block at the *first blank line* + * (CommonMark's HTML-block-type-6 rule) — correct for normal rendering, but wrong for verbatim + * preservation: any real-world `
` with a paragraph inside would fragment into a raw chip, + * an ordinary (rendered) paragraph, and a second raw chip, stranding genuine content in between. + * This tokenizer instead scans to the tag's *matching* close via {@link findBalancedCloseEnd}, blank + * lines included, for tags in {@link BLOCK_HTML_TAG_NAMES}; anything else returns `undefined` and + * falls through to the existing `markdownTokenName: 'html'` handling below (marked's own block + * tokenizer, unchanged). Comments are matched the same way as the inline case — marked's own comment + * rule already spans blank lines correctly, but routing through one path keeps the two tokenizers + * symmetric and independently testable. CommonMark allows up to 3 leading spaces before a block-HTML + * opening line, so the leading indent is split off, matched against separately, and stitched back + * onto `raw` — everything after that first line (including the tag's own body) can be indented + * however the author wrote it, since the balanced scan doesn't care about column position there. + */ +function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined { + const indent = /^ {0,3}/.exec(src)?.[0] ?? '' + const rest = src.slice(indent.length) + + const comment = RAW_HTML_COMMENT_RE.exec(rest) + if (comment) { + const raw = indent + comment[0] + return { type: 'html', raw, text: raw, block: true } + } + + const open = OPEN_TAG_RE.exec(rest) + if (!open) return undefined + const tag = open[1].toLowerCase() + if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined + // A handful of BLOCK_HTML_TAG_NAMES entries (link, meta, base, col, …) are void elements with no + // closing tag at all — treat them as complete right after the open tag (like an explicit `/>`), + // same as `tokenizeRawInlineHtml` already does via VOID_TAGS. Without this, scanning for a + // ``/`` that will never legitimately appear risks grabbing unrelated later content + // (or a stray same-name mention) as if it belonged to this block. + if (open[2] || VOID_TAGS.has(tag)) { + const raw = indent + open[0] + return { type: 'html', raw, text: raw, block: true } + } + + const end = findBalancedCloseEnd(rest, tag, open[0].length) + if (end < 0) return undefined + const raw = indent + rest.slice(0, end) + return { type: 'html', raw, text: raw, block: true } +} + +const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i + +export const RawHtmlBlock = Node.create({ + ...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }), + markdownTokenName: 'html', + markdownTokenizer: { + name: 'rawHtmlBlockTag', + level: 'block' as const, + // Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown` + // auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which + // corrupts the in-progress lexer's shared state (verified directly — every other construct on the + // page silently loses its content once a tokenizer without an explicit `start` is registered). + // The other custom tokenizers below all reference this comment rather than repeating it. + // + // The tokenizer above emits `type: 'html'` explicitly, so its tokens flow into the same + // `markdownTokenName: 'html'` parse registration as marked's own block-HTML tokens below — the + // distinct `name` here only avoids colliding with marked's own built-in `html` extension. + start: () => -1, + tokenize: tokenizeRawHtmlBlockTag, + }, + parseMarkdown(token: MarkdownToken) { + if (!token.block) return [] + const raw = token.raw ?? token.text ?? '' + if (!raw.trim()) return [] + // A lone ``/`
` tag block — leave it to the stock path (Image node / hard break), + // matching the same exclusion `round-trip-safety.ts` used to carve out for these two tags. + if (SKIP_BLOCK_HTML_TAGS.test(raw.trim())) return [] + return { type: 'rawHtmlBlock', content: verbatimParse(raw) } + }, +}) + +const FOOTNOTE_DEF_HEAD_RE = /^ {0,3}\[\^[^\]]+\]:/ +const FOOTNOTE_CONTINUATION_RE = /^ {4,}\S/ + +/** + * Consume a footnote definition's opening line plus any continuation lines GFM allows — indented by + * ≥4 spaces, optionally with blank lines between them (a multi-paragraph definition). Stops at the + * first line that is neither indented nor blank, and never consumes a blank line that isn't followed + * by further continuation (that blank line belongs to whatever block comes next). + */ +function tokenizeFootnoteDef(src: string): MarkdownToken | undefined { + const lines = src.split('\n') + if (!FOOTNOTE_DEF_HEAD_RE.test(lines[0])) return undefined + let lineCount = 1 + while (lineCount < lines.length) { + const line = lines[lineCount] + if (FOOTNOTE_CONTINUATION_RE.test(line)) { + lineCount += 1 + continue + } + if (line === '' && FOOTNOTE_CONTINUATION_RE.test(lines[lineCount + 1] ?? '')) { + lineCount += 2 + continue + } + break + } + const raw = lines.slice(0, lineCount).join('\n') + return { type: 'footnoteDef', raw, text: raw } +} + +/** Footnote definition (`[^id]: the note`, with optional ≥4-space-indented continuation lines) — + * marked has no footnote syntax at all, so without this tokenizer the definition is swallowed as a + * plain paragraph and the reference/definition link is lost. */ +export const FootnoteDef = Node.create({ + ...verbatimNodeConfig({ name: 'footnoteDef', inline: false, badgeLabel: 'Footnote' }), + markdownTokenName: 'footnoteDef', + markdownTokenizer: { + name: 'footnoteDef', + level: 'block' as const, + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. The cost + // here is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no + // blank line between them) is picked up on the next block boundary instead of interrupting early. + start: () => -1, + tokenize: tokenizeFootnoteDef, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'footnoteDef', content: verbatimParse(raw) } + }, +}) + +const FOOTNOTE_REF_RE = /^\[\^[^\]]+\]/ + +/** Footnote reference (`text[^id]`) — verbatim passthrough, same rationale as {@link FootnoteDef}. */ +export const FootnoteRef = Node.create({ + ...verbatimNodeConfig({ name: 'footnoteRef', inline: true, badgeLabel: 'Footnote ref' }), + markdownTokenName: 'footnoteRef', + markdownTokenizer: { + name: 'footnoteRef', + level: 'inline' as const, + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. + start: () => -1, + tokenize(src: string) { + const match = FOOTNOTE_REF_RE.exec(src) + if (!match) return undefined + return { type: 'footnoteRef', raw: match[0], text: match[0] } + }, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'footnoteRef', content: verbatimParse(raw) } + }, +}) + +/** + * Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single + * void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema + * already has a real mark/node for ({@link HANDLED_INLINE_TAGS}) so it keeps parsing normally, and + * for an unterminated open tag (rare/malformed input — falls back to the stock, lossy behavior + * rather than risk mis-consuming the rest of the document). + */ +function tokenizeRawInlineHtml(src: string): MarkdownToken | undefined { + const comment = RAW_HTML_COMMENT_RE.exec(src) + if (comment) return { type: 'rawInlineHtml', raw: comment[0], text: comment[0] } + + const open = OPEN_TAG_RE.exec(src) + if (!open) return undefined + const tag = open[1].toLowerCase() + if (HANDLED_INLINE_TAGS.has(tag)) return undefined + if (open[2] || VOID_TAGS.has(tag)) { + return { type: 'rawInlineHtml', raw: open[0], text: open[0] } + } + + const end = findBalancedCloseEnd(src, tag, open[0].length) + if (end < 0) return undefined + const raw = src.slice(0, end) + return { type: 'rawInlineHtml', raw, text: raw } +} + +/** Inline raw HTML — ``, ``, ``, ``, `` (no Underline mark is registered), + * and any other tag this schema has no mark/node for, plus an inline-position HTML comment. Marked + * classifies inline HTML as its own `'html'` token type, and `@tiptap/markdown`'s inline parser + * hardcodes handling for that type *before* checking its extension registry (unlike block tokens) — + * so claiming it here needs a custom tokenizer, registered under a different token name + * (`rawInlineHtml`) so it's never confused with the stock `'html'` inline path. marked.js runs + * custom extension tokenizers before its own built-ins at both block and inline level (see + * `blockTokens`/`inlineTokens` in `node_modules/marked/lib/marked.esm.js`), so this reliably wins + * the race against marked's default inline HTML/tag tokenizer. */ +export const RawInlineHtml = Node.create({ + ...verbatimNodeConfig({ name: 'rawInlineHtml', inline: true, badgeLabel: 'Raw HTML' }), + markdownTokenName: 'rawInlineHtml', + markdownTokenizer: { + name: 'rawInlineHtml', + level: 'inline' as const, + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. + start: () => -1, + tokenize: tokenizeRawInlineHtml, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'rawInlineHtml', content: verbatimParse(raw) } + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index 1aad444de29..ea4058895e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -1,479 +1,6 @@ -import type { JSONContent, MarkdownToken } from '@tiptap/core' -import { mergeAttributes, Node } from '@tiptap/core' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' - -/** - * Constructs the schema has no node/mark for: raw HTML blocks (`
`, `
`, …), HTML - * comments, and footnotes. Before this file, all four made the *entire* document open read-only - * (see {@link isRoundTripSafe in ./round-trip-safety}) because the stock pipeline silently drops - * or mangles them. Each node below instead holds the exact source text as its content and - * re-emits it byte-for-byte on serialize — the same "hold raw source, re-render specially" shape - * `MarkdownCodeBlock` uses for Mermaid (see `./code-block.tsx`), just without the diagram render. - * - * Inline tags already covered by a real mark/node — `em`/`i`, `strong`/`b`, `s`/`del`/`strike`, - * `code`, `a`, `br`, `img` — are deliberately excluded from {@link RawInlineHtml} so they keep - * parsing into their proper mark (e.g. `x` → italic) instead of freezing as raw source. - */ -const HANDLED_INLINE_TAGS = new Set([ - 'br', - 'img', - 'em', - 'i', - 'strong', - 'b', - 's', - 'del', - 'strike', - 'code', - 'a', -]) - -const VOID_TAGS = new Set([ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', -]) - -function verbatimText(node: JSONContent): string { - return (node.content ?? []).map((child) => child.text ?? '').join('') -} - -const RAW_HTML_COMMENT_RE = /^/ - -/** - * One HTML attribute: `name` or `name="value"`/`name='value'`/`name=bare`. The quoted-value - * alternatives are what matter — `[^"]*`/`[^']*` consume a literal `>` inside the quotes as part of - * the value, so an attribute like `data-example="a > b"` is treated as one unit instead of ending - * the tag match at the internal `>`. - */ -const ATTRS_RE_SOURCE = String.raw`(?:\s+[^\s"'=<>\`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>\`]+))?)*` - -/** Matches one opening HTML tag, attributes included (see {@link ATTRS_RE_SOURCE}). Group 1 is the - * tag name, group 2 is the self-closing `/` if present — shared by inline and block tokenizing. */ -const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'i') - -/** A fenced block's opening/closing marker may sit inside a blockquote (each line prefixed with - * up to 3 spaces then one or more `>` markers, each optionally followed by a space) and/or be - * independently indented up to 3 spaces with no blockquote at all (CommonMark's own fence-indent - * tolerance — matches `FENCE_OPEN`/`FENCE_CLOSE` in `./markdown-parse.ts`) — matched on both the - * open and close fence line so `> \`\`\`` and ` \`\`\`` both mask correctly. */ -const FENCE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*[ ]{0,3}' - -/** Same as {@link RAW_HTML_COMMENT_RE} but not anchored to the start of the string — used by - * {@link maskCodeRegions} to find a comment anywhere in the scanned text, not just at position 0. */ -const HTML_COMMENT_ANYWHERE_RE = //g - -/** - * Mask fenced code blocks, inline code spans, and HTML comments with same-length filler (newlines - * kept, everything else replaced with a space) so a tag-like mention *inside one of these* — - * `` `
` ``, a fenced example showing HTML syntax, or a comment documenting the tag - * (``) — is never mistaken for a real balancing tag while scanning. Mirrors - * the fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches (extended to also - * tolerate an indented and/or blockquoted fence marker via {@link FENCE_PREFIX_SOURCE}, since a raw - * HTML block can itself be indented or quoted), but preserves length/position (masks in place) - * instead of deleting, so match indices still map onto the original, unmodified `src` the caller - * slices from. - */ -function maskCodeRegions(src: string): string { - const fenceRe = new RegExp( - `^${FENCE_PREFIX_SOURCE}([\`~]{3,})[^\\n]*\\n[\\s\\S]*?^${FENCE_PREFIX_SOURCE}\\1[\`~]*[ \\t]*$`, - 'gm' - ) - return src - .replace(fenceRe, (m) => m.replace(/[^\n]/g, ' ')) - .replace(/`+[^`\n]*`+/g, (m) => ' '.repeat(m.length)) - .replace(HTML_COMMENT_ANYWHERE_RE, (m) => m.replace(/[^\n]/g, ' ')) -} - -/** - * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, - * tracking nesting depth from `fromIndex` onward so `outer inner` consumes - * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A - * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. - * Shared by the inline tokenizer (single line) and the block tokenizer (spans blank lines). - * - * Scans a {@link maskCodeRegions}-masked copy of `src` so a tag name mentioned inside code doesn't - * count as real markup — this narrows, but can't eliminate, the inherent ambiguity of regex-based - * (non-DOM) tag matching: a *bare, unescaped* mention of the same tag name in plain prose (not in - * code) is indistinguishable from a real closing tag here, exactly as it would be to a real HTML - * parser given the same ambiguous input (there is no valid way to "escape" a literal `` inside - * real HTML content other than an entity or code region). Verified this can't lose data even in that - * case — the result still reaches a stable fixpoint on save, just restructured — matching this file's - * "reject on doubt, but never require doubt-free input" gate (`isRoundTripSafe`). - */ -function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { - const masked = maskCodeRegions(src) - const tagRe = new RegExp(`<(/?)${tag}\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'gi') - tagRe.lastIndex = fromIndex - let depth = 1 - for (let match = tagRe.exec(masked); match; match = tagRe.exec(masked)) { - const isClose = match[1] === '/' - const isSelfClosing = Boolean(match[2]) - if (isSelfClosing) continue - if (isClose) { - depth -= 1 - if (depth === 0) return match.index + match[0].length - } else { - depth += 1 - } - } - return -1 -} - -/** - * Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment - * or a def line as part of that token's own `raw` (the same behavior `PipeSafeTable` in - * `./extensions.ts` works around for tables) — storing it verbatim would double it up with the - * block joiner's own separator, growing by two newlines on every save. Block-level callers trim it; - * inline callers never carry one (inline tokens can't span a blank line), so trimming is a no-op there. - */ -function verbatimParse(raw: string): JSONContent[] { - const trimmed = raw.replace(/\n+$/, '') - return trimmed ? [{ type: 'text', text: trimmed }] : [] -} - -interface VerbatimNodeOptions { - name: string - /** Whether this node sits among block content (own line) or inline content (mid-paragraph). */ - inline: boolean - badgeLabel: string -} - -/** - * Shared shape for a node that holds a markdown construct's exact source text and re-emits it - * unchanged — parsing and rendering never inspect or transform the text, so there is nothing for - * these constructs to lose. `markdownTokenName`/`parseMarkdown`/`renderMarkdown` are read directly - * off the returned config by `@tiptap/markdown`'s `MarkdownManager` (see - * `node_modules/@tiptap/markdown/src/MarkdownManager.ts`), independent of the node's `name`. - */ -function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) { - return { - name, - inline, - group: inline ? 'inline' : 'block', - content: 'text*', - marks: '', - code: true, - defining: !inline, - // Block verbatim nodes hold exact source text; `isolating` stops a boundary Backspace/Delete from - // joining across their edge, which would otherwise merge their raw markdown into an adjacent - // paragraph as HTML-escaped prose and destroy the node (silent data loss on save). - isolating: !inline, - selectable: true, - atom: false, - parseHTML() { - return [ - { - tag: `${inline ? 'span' : 'div'}[data-raw-markdown="${name}"]`, - preserveWhitespace: 'full' as const, - }, - ] - }, - renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { - return [ - inline ? 'span' : 'div', - mergeAttributes(HTMLAttributes, { - 'data-raw-markdown': name, - 'data-raw-markdown-label': badgeLabel, - class: inline ? 'raw-markdown-inline' : 'raw-markdown-block', - }), - 0, - ] as const - }, - renderMarkdown(node: JSONContent) { - return verbatimText(node) - }, - } -} - -/** - * Tag names CommonMark/GFM treat as "block-starting" HTML (marked's own type-6 list — see - * `_tag` in `node_modules/marked/src/rules.ts`, verified against the CommonMark spec): a block - * opening with one of these ends at its *matching closing tag*, not at the first blank line. Tags - * NOT in this list (`em`, `a`, `span`, `code`, `kbd`, …) can legitimately start an ordinary - * paragraph (`hi there`), so they're deliberately left to marked's own stricter, single-line - * block-HTML detection below — claiming them here would risk swallowing a paragraph that merely - * starts with inline HTML. - */ -const BLOCK_HTML_TAG_NAMES = new Set([ - 'address', - 'article', - 'aside', - 'base', - 'basefont', - 'blockquote', - 'body', - 'caption', - 'center', - 'col', - 'colgroup', - 'dd', - 'details', - 'dialog', - 'dir', - 'div', - 'dl', - 'dt', - 'fieldset', - 'figcaption', - 'figure', - 'footer', - 'form', - 'frame', - 'frameset', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'head', - 'header', - 'hr', - 'html', - 'iframe', - 'legend', - 'li', - 'link', - 'main', - 'menu', - 'menuitem', - 'meta', - 'nav', - 'noframes', - 'ol', - 'optgroup', - 'option', - 'p', - 'param', - 'search', - 'section', - 'summary', - 'table', - 'tbody', - 'td', - 'tfoot', - 'th', - 'thead', - 'title', - 'tr', - 'track', - 'ul', -]) - -/** - * Marked's built-in block-HTML rule ends a `
`/`
`/… block at the *first blank line* - * (CommonMark's HTML-block-type-6 rule) — correct for normal rendering, but wrong for verbatim - * preservation: any real-world `
` with a paragraph inside would fragment into a raw chip, - * an ordinary (rendered) paragraph, and a second raw chip, stranding genuine content in between. - * This tokenizer instead scans to the tag's *matching* close via {@link findBalancedCloseEnd}, blank - * lines included, for tags in {@link BLOCK_HTML_TAG_NAMES}; anything else returns `undefined` and - * falls through to the existing `markdownTokenName: 'html'` handling below (marked's own block - * tokenizer, unchanged). Comments are matched the same way as the inline case — marked's own comment - * rule already spans blank lines correctly, but routing through one path keeps the two tokenizers - * symmetric and independently testable. CommonMark allows up to 3 leading spaces before a block-HTML - * opening line, so the leading indent is split off, matched against separately, and stitched back - * onto `raw` — everything after that first line (including the tag's own body) can be indented - * however the author wrote it, since the balanced scan doesn't care about column position there. - */ -function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined { - const indent = /^ {0,3}/.exec(src)?.[0] ?? '' - const rest = src.slice(indent.length) - - const comment = RAW_HTML_COMMENT_RE.exec(rest) - if (comment) { - const raw = indent + comment[0] - return { type: 'html', raw, text: raw, block: true } - } - - const open = OPEN_TAG_RE.exec(rest) - if (!open) return undefined - const tag = open[1].toLowerCase() - if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined - // A handful of BLOCK_HTML_TAG_NAMES entries (link, meta, base, col, …) are void elements with no - // closing tag at all — treat them as complete right after the open tag (like an explicit `/>`), - // same as `tokenizeRawInlineHtml` already does via VOID_TAGS. Without this, scanning for a - // ``/`` that will never legitimately appear risks grabbing unrelated later content - // (or a stray same-name mention) as if it belonged to this block. - if (open[2] || VOID_TAGS.has(tag)) { - const raw = indent + open[0] - return { type: 'html', raw, text: raw, block: true } - } - - const end = findBalancedCloseEnd(rest, tag, open[0].length) - if (end < 0) return undefined - const raw = indent + rest.slice(0, end) - return { type: 'html', raw, text: raw, block: true } -} - -const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i - -export const RawHtmlBlock = Node.create({ - ...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }), - markdownTokenName: 'html', - markdownTokenizer: { - name: 'rawHtmlBlockTag', - level: 'block' as const, - // Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown` - // auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which - // corrupts the in-progress lexer's shared state (verified directly — every other construct on the - // page silently loses its content once a tokenizer without an explicit `start` is registered). - // The other custom tokenizers below all reference this comment rather than repeating it. - // - // The tokenizer above emits `type: 'html'` explicitly, so its tokens flow into the same - // `markdownTokenName: 'html'` parse registration as marked's own block-HTML tokens below — the - // distinct `name` here only avoids colliding with marked's own built-in `html` extension. - start: () => -1, - tokenize: tokenizeRawHtmlBlockTag, - }, - parseMarkdown(token: MarkdownToken) { - if (!token.block) return [] - const raw = token.raw ?? token.text ?? '' - if (!raw.trim()) return [] - // A lone ``/`
` tag block — leave it to the stock path (Image node / hard break), - // matching the same exclusion `round-trip-safety.ts` used to carve out for these two tags. - if (SKIP_BLOCK_HTML_TAGS.test(raw.trim())) return [] - return { type: 'rawHtmlBlock', content: verbatimParse(raw) } - }, -}) - -const FOOTNOTE_DEF_HEAD_RE = /^ {0,3}\[\^[^\]]+\]:/ -const FOOTNOTE_CONTINUATION_RE = /^ {4,}\S/ - -/** - * Consume a footnote definition's opening line plus any continuation lines GFM allows — indented by - * ≥4 spaces, optionally with blank lines between them (a multi-paragraph definition). Stops at the - * first line that is neither indented nor blank, and never consumes a blank line that isn't followed - * by further continuation (that blank line belongs to whatever block comes next). - */ -function tokenizeFootnoteDef(src: string): MarkdownToken | undefined { - const lines = src.split('\n') - if (!FOOTNOTE_DEF_HEAD_RE.test(lines[0])) return undefined - let lineCount = 1 - while (lineCount < lines.length) { - const line = lines[lineCount] - if (FOOTNOTE_CONTINUATION_RE.test(line)) { - lineCount += 1 - continue - } - if (line === '' && FOOTNOTE_CONTINUATION_RE.test(lines[lineCount + 1] ?? '')) { - lineCount += 2 - continue - } - break - } - const raw = lines.slice(0, lineCount).join('\n') - return { type: 'footnoteDef', raw, text: raw } -} - -/** Footnote definition (`[^id]: the note`, with optional ≥4-space-indented continuation lines) — - * marked has no footnote syntax at all, so without this tokenizer the definition is swallowed as a - * plain paragraph and the reference/definition link is lost. */ -export const FootnoteDef = Node.create({ - ...verbatimNodeConfig({ name: 'footnoteDef', inline: false, badgeLabel: 'Footnote' }), - markdownTokenName: 'footnoteDef', - markdownTokenizer: { - name: 'footnoteDef', - level: 'block' as const, - // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. The cost - // here is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no - // blank line between them) is picked up on the next block boundary instead of interrupting early. - start: () => -1, - tokenize: tokenizeFootnoteDef, - }, - parseMarkdown(token: MarkdownToken) { - const raw = token.raw ?? token.text ?? '' - if (!raw) return [] - return { type: 'footnoteDef', content: verbatimParse(raw) } - }, -}) - -const FOOTNOTE_REF_RE = /^\[\^[^\]]+\]/ - -/** Footnote reference (`text[^id]`) — verbatim passthrough, same rationale as {@link FootnoteDef}. */ -export const FootnoteRef = Node.create({ - ...verbatimNodeConfig({ name: 'footnoteRef', inline: true, badgeLabel: 'Footnote ref' }), - markdownTokenName: 'footnoteRef', - markdownTokenizer: { - name: 'footnoteRef', - level: 'inline' as const, - // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. - start: () => -1, - tokenize(src: string) { - const match = FOOTNOTE_REF_RE.exec(src) - if (!match) return undefined - return { type: 'footnoteRef', raw: match[0], text: match[0] } - }, - }, - parseMarkdown(token: MarkdownToken) { - const raw = token.raw ?? token.text ?? '' - if (!raw) return [] - return { type: 'footnoteRef', content: verbatimParse(raw) } - }, -}) - -/** - * Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single - * void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema - * already has a real mark/node for ({@link HANDLED_INLINE_TAGS}) so it keeps parsing normally, and - * for an unterminated open tag (rare/malformed input — falls back to the stock, lossy behavior - * rather than risk mis-consuming the rest of the document). - */ -function tokenizeRawInlineHtml(src: string): MarkdownToken | undefined { - const comment = RAW_HTML_COMMENT_RE.exec(src) - if (comment) return { type: 'rawInlineHtml', raw: comment[0], text: comment[0] } - - const open = OPEN_TAG_RE.exec(src) - if (!open) return undefined - const tag = open[1].toLowerCase() - if (HANDLED_INLINE_TAGS.has(tag)) return undefined - if (open[2] || VOID_TAGS.has(tag)) { - return { type: 'rawInlineHtml', raw: open[0], text: open[0] } - } - - const end = findBalancedCloseEnd(src, tag, open[0].length) - if (end < 0) return undefined - const raw = src.slice(0, end) - return { type: 'rawInlineHtml', raw, text: raw } -} - -/** Inline raw HTML — ``, ``, ``, ``, `` (no Underline mark is registered), - * and any other tag this schema has no mark/node for, plus an inline-position HTML comment. Marked - * classifies inline HTML as its own `'html'` token type, and `@tiptap/markdown`'s inline parser - * hardcodes handling for that type *before* checking its extension registry (unlike block tokens) — - * so claiming it here needs a custom tokenizer, registered under a different token name - * (`rawInlineHtml`) so it's never confused with the stock `'html'` inline path. marked.js runs - * custom extension tokenizers before its own built-ins at both block and inline level (see - * `blockTokens`/`inlineTokens` in `node_modules/marked/lib/marked.esm.js`), so this reliably wins - * the race against marked's default inline HTML/tag tokenizer. */ -export const RawInlineHtml = Node.create({ - ...verbatimNodeConfig({ name: 'rawInlineHtml', inline: true, badgeLabel: 'Raw HTML' }), - markdownTokenName: 'rawInlineHtml', - markdownTokenizer: { - name: 'rawInlineHtml', - level: 'inline' as const, - // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. - start: () => -1, - tokenize: tokenizeRawInlineHtml, - }, - parseMarkdown(token: MarkdownToken) { - const raw = token.raw ?? token.text ?? '' - if (!raw) return [] - return { type: 'rawInlineHtml', content: verbatimParse(raw) } - }, -}) +import { FootnoteDef, RawHtmlBlock } from './raw-markdown-snippet-schema' const BLOCK_CONTROL_CLASS = 'pointer-events-none absolute top-1.5 right-2 select-none rounded-md bg-[var(--surface-4)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] uppercase tracking-wide opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index cfb5e9a2ec3..4258b9901b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -355,28 +355,15 @@ border-radius: 4px; } +/* Borders, padding, typography, and header fill come from document-table.css — the chrome shared + with the CSV/XLSX previews. Only the editor-specific bits live here: `table-layout: fixed` is + required by prosemirror-tables' column-resizing plugin, the block margin is prose rhythm, and + `overflow: visible` lets a collaborator's caret name label escape the table box instead of being + clipped (fixed layout means columns can't exceed the table width, so nothing else needs clipping). */ .rich-markdown-prose table { - width: 100%; - border-collapse: collapse; table-layout: fixed; margin: 1rem 0; - overflow: hidden; -} - -.rich-markdown-prose th, -.rich-markdown-prose td { - position: relative; - border: 1px solid var(--divider); - padding: 0.5rem 0.75rem; - text-align: left; - vertical-align: top; - font-size: 14px; - line-height: 1.5rem; -} - -.rich-markdown-prose th { - background: var(--surface-4); - font-weight: 600; + overflow: visible; } .rich-markdown-prose th > p, @@ -454,3 +441,122 @@ .rich-markdown-field-prose p.is-editor-empty:first-child::before { color: var(--text-muted); } + +/* + * Collaborative carets (TipTap CollaborationCaret). The caret bar and the name + * label's background are colored inline from each collaborator's identity color + * (the same `getUserColor` mechanism the canvas cursors use); the selection is a + * translucent tint of that color (set via selectionRender). The name label shows + * while the peer is active (JS toggles `--active` on each awareness change) or on + * hover, then fades after inactivity — matching Google Docs. `z-index` lifts the + * caret (and its label) above table cell backgrounds so a caret inside a table + * cell is not hidden behind adjacent cells. + */ +.rich-markdown-prose .collaboration-carets__caret { + /* Zero inline footprint: a positioned anchor with NO width/border/margin, so inserting or moving this + * inline widget never reflows the surrounding text. The visible bar, dormant cap (::before), name label, + * and hover slop (::after) are all positioned relative to this anchor and are out of the inline flow. */ + position: relative; + word-break: normal; + z-index: 20; +} + +/* The visible caret bar — absolutely positioned so it draws over the text without occupying inline + * width. `left: -1px` centers the 2px bar on the cursor position; top/bottom span the line box height. */ +.rich-markdown-prose .collaboration-carets__bar { + position: absolute; + top: -0.1em; + bottom: -0.1em; + left: -1px; + width: 2px; + background-color: var(--caret-color); + pointer-events: none; +} + +/* Dormant affordance: a small cap at the top of the caret in the collaborator's color, + * shaped like a collapsed presence name tag (same `rounded-xs` + notch corner as the + * tables/canvas tags) so the whole presence system reads as one language. It signals + * "someone's here — hover for who", and fades out when the full name tag takes over + * (peer active or on hover). */ +.rich-markdown-prose .collaboration-carets__caret::before { + content: ""; + position: absolute; + /* Seat the cap's square bottom-left corner on the pole's top-left, flush like a flag on its pole. + * `left: -1px` matches the bar's `left: -1px` so the cap's left edge lines up with the bar's left edge; + * `top` overlaps the bar's top a hair so the notch reads as continuous, no gap. */ + top: -2px; + left: -1px; + width: 8px; + height: 5px; + border-radius: 2px 2px 2px 0; + background-color: var(--caret-color); + transition: opacity 0.2s ease; +} + +.rich-markdown-prose .collaboration-carets__caret--active::before, +.rich-markdown-prose .collaboration-carets__caret:hover::before { + opacity: 0; +} + +/* Transparent hover hit-slop. The visible caret bar is only ~2px wide and the dormant cap is + * 8×5px, so "hover for who" (revealing the name label on a dormant caret) would be near + * impossible to trigger. This invisible strip widens the hover target across the caret's full + * height and over the cap — nothing visible changes, only the pointer area. `pointer-events: + * auto` is required so it catches the hover; kept narrow so it barely intrudes on selecting + * text next to a remote caret. */ +.rich-markdown-prose .collaboration-carets__caret::after { + content: ""; + position: absolute; + top: -4px; + bottom: 0; + left: -4px; + right: -4px; + pointer-events: auto; +} + +/* Matches the canvas cursor name tag (cursors.tsx): identity-color background with + * `--surface-1` text at `text-xs`/`font-medium`, so both presence surfaces read as + * one system. `--surface-1` is the base surface token (readable on every assigned + * identity color in both themes), not a hardcoded value. Hidden by default; the + * show/fade is driven by the `--active` class (see caret-presence.ts) and hover. */ +.rich-markdown-prose .collaboration-carets__label { + position: absolute; + top: -1.4em; + left: -1px; + max-width: 10rem; + overflow: hidden; + padding: 0.1rem 0.35rem; + border-radius: 2px 2px 2px 0; + /* 11px = the `text-xs` the canvas/tables presence tags use, for pixel parity. */ + font-size: 11px; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; + text-overflow: ellipsis; + background-color: var(--caret-color); + color: var(--surface-1); + user-select: none; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.rich-markdown-prose .collaboration-carets__caret--active .collaboration-carets__label, +.rich-markdown-prose .collaboration-carets__caret:hover .collaboration-carets__label { + opacity: 1; +} + +/* Near the editor's right edge the label is flipped to the caret's left so it never + * runs off (JS toggles `--flip` after measuring); mirror the tag's notch corner. */ +.rich-markdown-prose .collaboration-carets__caret--flip .collaboration-carets__label { + left: auto; + right: -1px; + border-radius: 2px 2px 0 2px; +} + +/* Remote text selection: a rounded translucent tint of the collaborator's identity + * color (the alpha fill is set inline by selectionRender). */ +.rich-markdown-prose .collaboration-carets__selection { + border-radius: 2px; + pointer-events: none; +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 9107dbd795d..5cd2f43dd3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -1,21 +1,38 @@ 'use client' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' -import type { JSONContent } from '@tiptap/core' +import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' +import { type Extensions, generateHTML, type JSONContent } from '@tiptap/core' +import { isChangeOrigin } from '@tiptap/extension-collaboration' import { Fragment, Slice } from '@tiptap/pm/model' import { NodeSelection } from '@tiptap/pm/state' import { dropPoint } from '@tiptap/pm/transform' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' +import { useSession } from '@/lib/auth/auth-client' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' +import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' import type { SaveStatus } from '@/hooks/use-autosave' import { useFileContentSource } from '@/hooks/use-file-content-source' import { PreviewLoadingFrame } from '../preview-shared' import { useEditableFileContent } from '../use-editable-file-content' +import { + announceAgentApplying, + clearAgentApplying, + isAgentStreamLeader, +} from './collaboration/agent-stream-leader' +import { + type AgentStreamSession, + applyAgentStreamFrame, + beginAgentStream, + endAgentStream, +} from './collaboration/apply-streamed-markdown' +import { nextCollabReadiness } from './collaboration/readiness' +import { useFileDocCollaboration } from './collaboration/use-file-doc-collaboration' import { createMarkdownEditorExtensions } from './editor-extensions' import { findHeadingPos } from './heading-anchors' import { @@ -38,11 +55,15 @@ import { LinkHoverCard } from './menus/link-hover-card' import { TableBubbleMenu } from './menus/table-menu' import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' +import { firstHeadingTitle } from './title-heading' import '@sim/emcn/components/code/code.css' +import '../document-table.css' import './rich-markdown-editor.css' +const PLACEHOLDER = "Write something, or press '/' for commands…" + const EXTENSIONS = createMarkdownEditorExtensions({ - placeholder: "Write something, or press '/' for commands…", + placeholder: PLACEHOLDER, embeds: true, }) @@ -50,6 +71,9 @@ const EXTENSIONS = createMarkdownEditorExtensions({ const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000 const STREAM_REPARSE_THROTTLE_MS = 120 +/** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ +const DERIVE_TITLE_DEBOUNCE_MS = 600 + interface RichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string @@ -67,10 +91,30 @@ interface RichMarkdownEditorProps { * applied live; a rebuild is only revealed while it extends what's shown (see the streaming tick). */ streamIsIncremental?: boolean + /** + * The agent edit operation driving the stream, when known (`create`/`append`/`update`/`patch`). In the + * collaborative path it decides only whether to stream mid-flight: an `update` (from-scratch rewrite) is + * HELD until settle so the open doc doesn't collapse to a partial result, while `append`/`patch`/`create` + * apply each frame. + */ + streamOperation?: string disableStreamingAutoScroll?: boolean previewContextKey?: string /** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */ disableTagging?: boolean + /** + * Opt this surface into live collaborative editing (Files page + the embedded chat file preview). + * Collaboration can coexist with agent streaming: while streaming, the growing content is applied to + * the shared Y.Doc as minimal CRDT diffs (see {@link applyAgentStreamFrame}) rather than a + * full-document `setContent`, so the stream stays smooth and every peer sees it live. + */ + collaborative?: boolean + /** + * Called (debounced) with the document's leading-heading text while the file is still untitled, so the + * caller can name the file after it. Omitted on read-only/non-editable surfaces. See + * {@link isUntitledName}. + */ + onDeriveTitleFromHeading?: (headingText: string) => void } /** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */ @@ -86,10 +130,30 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ streamingContent, isAgentEditing, streamIsIncremental, + streamOperation, disableStreamingAutoScroll = false, previewContextKey, disableTagging, + collaborative = false, + onDeriveTitleFromHeading, }: RichMarkdownEditorProps) { + const { data: session, isPending: isSessionPending } = useSession() + const userId = session?.user?.id ?? '' + const userName = session?.user?.name?.trim() || 'Collaborator' + + /** + * Client-autosave gate. For a NON-collaborative file this is `true` (the client owns durability and + * autosaves the markdown). For a collaborative file it stays `false`: the realtime relay persists the + * shared document to markdown server-side, so the client must never also autosave — a stale keystroke + * saving over a server/copilot edit is exactly the clobber the server path closes. The child reports + * the right value up via `onCollabReadyChange`. + * + * Initialize from the `collaborative` prop (NOT unconditionally `true`): a collaborative file must + * start with autosave OFF, or a save could fire in the window before the child mounts and reports — + * re-clobbering exactly what this closes. The child turns it on for the non-collaborative fallback. + */ + const [collabReady, setCollabReady] = useState(!collaborative) + const { content, setDraftContent, @@ -108,9 +172,14 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ saveRef, discardRef, normalizeBaseline: normalizeMarkdownContent, + canAutosave: collabReady, }) - if (isContentLoading) return + // Wait for the session too: the child decides collaboration ONCE at mount from + // `userId`, so mounting before the session resolves would latch collaboration off + // for a cold-loaded file (both users would then solo-save, last-write-wins). + if (isContentLoading || isSessionPending) + return if (hasContentError) { return ( @@ -128,12 +197,18 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ content={content} isStreaming={isStreamInteractionLocked} canEdit={canEdit} + userId={userId} + userName={userName} autoFocus={autoFocus} streamIsIncremental={streamIsIncremental} + streamOperation={streamOperation} disableStreamingAutoScroll={disableStreamingAutoScroll} disableTagging={disableTagging} + collaborative={collaborative} onChange={setDraftContent} onSaveShortcut={saveImmediately} + onCollabReadyChange={setCollabReady} + onDeriveTitleFromHeading={onDeriveTitleFromHeading} /> ) }) @@ -146,13 +221,24 @@ interface LoadedRichMarkdownEditorProps { /** True while agent output is streaming in: the editor renders it read-only and syncs each chunk. */ isStreaming: boolean canEdit: boolean + /** Current user id + display name, for the collaborative caret identity. */ + userId: string + userName: string autoFocus?: boolean /** See {@link RichMarkdownEditorProps.streamIsIncremental}. */ streamIsIncremental?: boolean + /** See {@link RichMarkdownEditorProps.streamOperation}. */ + streamOperation?: string disableStreamingAutoScroll?: boolean disableTagging?: boolean + /** See {@link RichMarkdownEditorProps.collaborative}. */ + collaborative?: boolean onChange: (markdown: string) => void onSaveShortcut: () => Promise + /** Reports whether the collaborative document is synced+seeded (autosave gate). */ + onCollabReadyChange: (ready: boolean) => void + /** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */ + onDeriveTitleFromHeading?: (headingText: string) => void } interface SettledContent { @@ -172,12 +258,18 @@ export function LoadedRichMarkdownEditor({ content, isStreaming, canEdit, + userId, + userName, autoFocus, streamIsIncremental, + streamOperation, disableStreamingAutoScroll, disableTagging, + collaborative = false, onChange, onSaveShortcut, + onCollabReadyChange, + onDeriveTitleFromHeading, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const streamingAtMountRef = useRef(isStreaming) @@ -187,11 +279,62 @@ export function LoadedRichMarkdownEditor({ if (!streamingAtMountRef.current && settledRef.current === null) { settledRef.current = lockSettled(content) } - const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false) + /** + * Collaboration is decided once at mount from synchronously-available inputs + * (`settledRef` is set just above) via `useState`-init, and never changes — TipTap + * fixes the extension set at editor creation, so it cannot turn on later. Enabled on a + * `collaborative` surface (the Files page or the embedded chat file preview) for an editable, + * round-trip-safe workspace document with a known user, as long as it is not ALREADY streaming at + * mount (`!streamingAtMountRef.current`). An agent stream that begins AFTER mount is applied as CRDT + * diffs into the live doc, so collaboration and streaming coexist (see the streaming effect below). + */ + const [collaborationEnabled] = useState( + () => + collaborative && + canEdit && + !streamingAtMountRef.current && + (settledRef.current?.verdict ?? false) && + Boolean(userId) && + (file.storageContext ?? 'workspace') === 'workspace' + ) + /** + * Whether the collaborative document is safe to edit + persist: synced and seeded. + * Starts `false` for a collaborative document — so the editor is read-only and + * autosave gated until the shared content has arrived (a user must not type into an + * empty, unsynced doc, which the seed would then discard) — and `true` for a local one. + */ + const [collabReady, setCollabReady] = useState(!collaborationEnabled) + const isEditable = + canEdit && !isStreaming && (settledRef.current?.verdict ?? false) && collabReady + + const collaboration = useFileDocCollaboration({ + fileId: file.id, + userId, + userName, + enabled: collaborationEnabled, + }) - /** Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse. */ + /** + * Initial editor content. When collaborating, the Y.Doc is the source of truth — + * start empty and let the server-seeded Yjs sync fill it (below); otherwise seed from the + * parsed markdown (chunked parse is linear vs the editor's ~O(n²) whole-body parse). + */ const [initialContent] = useState(() => - streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body) + streamingAtMountRef.current || collaborationEnabled + ? '' + : parseMarkdownToDoc(splitFrontmatter(content).body) + ) + /** + * A read-only placeholder rendered from the already-fetched markdown while a collaborative doc waits + * for its server seed, so the pane shows content instantly instead of blocking blank on the socket + * round-trip (the seed IS the same markdown, so the swap on {@link collabReady} is seamless). Static + * HTML — it holds no editor, doc, or awareness, so it structurally cannot write to the Y.Doc, which + * is the invariant that keeps seeding out of the client (a client seed duplicates the doc). + */ + const [placeholderHtml] = useState(() => + collaborationEnabled + ? generateHTML(parseMarkdownToDoc(splitFrontmatter(content).body), EXTENSIONS) + : null ) /** * The body currently shown in the editor: seeded from a settled mount, updated on local edits (via @@ -202,16 +345,55 @@ export function LoadedRichMarkdownEditor({ const lastSyncedBodyRef = useRef( streamingAtMountRef.current ? null : splitFrontmatter(content).body ) + /** + * The body the AGENT last applied into the collaborative doc — a dedup guard for the collab streaming + * tick, so an unchanged frame skips a redundant shadow reconcile/reparse. Written ONLY by the streaming + * tick (never by `onUpdate`), and reset to `null` on settle for the next stream. It is NOT a string-prefix + * baseline: the mid-stream hold is decided by operation (`update` waits for settle), not by comparing the + * raw preview against the editor's canonical markdown. + */ + const lastStreamedBodyRef = useRef(null) const onChangeRef = useRef(onChange) onChangeRef.current = onChange const onSaveShortcutRef = useRef(onSaveShortcut) onSaveShortcutRef.current = onSaveShortcut + + /** + * The frontmatter to re-attach to the body on save. For a collaborative doc it lives in the CRDT + * (config map, seeded/updated server-side), so a server edit that changes it is reflected rather + * than reverted by this editor's stale open-time copy; falls back to the locked `settledRef` copy + * before the seed lands and for non-collaborative documents. + */ + const resolveSaveFrontmatter = useCallback((): string => { + const fromDoc = collaboration?.doc + .getMap(FILE_DOC_SEED.configMap) + .get(FILE_DOC_SEED.frontmatterKey) + if (typeof fromDoc === 'string') return fromDoc + return settledRef.current?.frontmatter ?? '' + }, [collaboration]) + + /** + * While the file is still unnamed, name it after its leading heading: `onDeriveTitleFromHeading` is + * called (debounced) so the caller can rename the file, and `fileNameRef` lets the onUpdate handler + * read the current name without re-subscribing. See {@link isUntitledName}. + */ + const onDeriveTitleFromHeadingRef = useRef(onDeriveTitleFromHeading) + onDeriveTitleFromHeadingRef.current = onDeriveTitleFromHeading + const fileNameRef = useRef(file.name) + fileNameRef.current = file.name + const deriveTitleTimerRef = useRef | null>(null) /** * Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change * between sessions within one turn, e.g. an append followed by a rewrite). */ const streamIsIncrementalRef = useRef(streamIsIncremental) streamIsIncrementalRef.current = streamIsIncremental + const streamOperationRef = useRef(streamOperation) + streamOperationRef.current = streamOperation + /** The live agent-stream shadow replica, held for the current stream and freed on settle/unmount. */ + const agentStreamSessionRef = useRef(null) + /** True once this client has announced candidacy in the agent-stream election for the current stream. */ + const agentAnnouncedRef = useRef(false) const router = useRouter() const routerRef = useRef(router) routerRef.current = router @@ -289,8 +471,27 @@ export function LoadedRichMarkdownEditor({ } } + /** + * Extensions: the shared module set for the local path, or a per-instance set + * carrying this document's Collaboration + CollaborationCaret. Built once (collab + * is decided at mount), since `useEditor` fixes the extension set at creation. + */ + const [extensions] = useState(() => + collaboration + ? createMarkdownEditorExtensions({ + placeholder: PLACEHOLDER, + embeds: true, + collaboration: { + doc: collaboration.doc, + awareness: collaboration.awareness, + user: collaboration.user, + }, + }) + : EXTENSIONS + ) + const editor = useEditor({ - extensions: EXTENSIONS, + extensions, editable: isEditable, enablePasteRules: false, autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false, @@ -441,14 +642,188 @@ export function LoadedRichMarkdownEditor({ return false }, }, - onUpdate: ({ editor }) => { + onUpdate: ({ editor, transaction }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) lastSyncedBodyRef.current = md - onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md)) + onChangeRef.current(applyFrontmatter(resolveSaveFrontmatter(), md)) + // While the file is still untitled, name it after its leading heading once typing settles — but + // only for the LOCAL user's own edits. `isChangeOrigin` is true for a remote Yjs change (a peer + // typing); bail BEFORE touching the timer so a remote edit never cancels or reschedules the local + // user's pending rename (and every client doesn't schedule the same rename from a peer's + // not-yet-synced heading). It is false for local edits and non-collaborative surfaces. + if (isChangeOrigin(transaction)) return + // Local edit: restart the debounce. Clearing first cancels a stale rename if the heading was + // removed/changed before it fired; the timer re-derives the title from the live doc rather than a + // value captured now, so it can never name the file after a heading the user has since changed. + // `editor.isEditable` is the autosave gate (canEdit + settled + collab-ready), so a view-only + // viewer or the not-yet-editable mount seed never schedules a rename. + if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current) + if ( + !editor.isEditable || + !isUntitledName(fileNameRef.current) || + firstHeadingTitle(editor.state.doc) === null + ) + return + deriveTitleTimerRef.current = setTimeout(() => { + const liveEditor = editorInstanceRef.current + if (!liveEditor || !liveEditor.isEditable || !isUntitledName(fileNameRef.current)) return + const title = firstHeadingTitle(liveEditor.state.doc) + if (title) onDeriveTitleFromHeadingRef.current?.(title) + }, DERIVE_TITLE_DEBOUNCE_MS) }, }) editorInstanceRef.current = editor + useEffect( + () => () => { + if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current) + }, + [] + ) + + /** + * The loaded markdown to seed the shared doc from, held by pointer so the parse + * runs once at seed time rather than every render. + */ + const seedContentRef = useRef(content) + seedContentRef.current = content + + /** + * The collaborative document lifecycle. In one effect because the three concerns + * are one state machine keyed off the same provider events: + * - **observe** readiness: the server seeds the doc authoritatively (content + + * `initialContentLoaded` flag in ONE Yjs update), so the client only watches for + * synced AND seeded — it never imports content itself on the happy path; + * - **gate** the parent's autosave until the doc is synced AND seeded, so an + * empty/still-syncing doc can never overwrite the real file's markdown mirror; + * - **fall back** on a fatal join: seed the loaded content so it is SHOWN, but + * leave the editor read-only + gated. Every non-retryable failure (auth, access + * denied, not found, client-id conflict) either can't save or is moot, so the + * safe fallback is a read-only view of the content rather than editable-but- + * unsavable — which would silently drop the user's edits. + * + * `ready` (synced+seeded) gates BOTH the editor's editability (a user must never + * type into an empty/unsynced doc) and the parent's autosave. Non-collaborative + * documents are never gated. The server seeds the doc authoritatively (content + the + * seed flag arrive together), so the client only observes readiness. `provider.joinError` + * is latched, so a fatal rejection that fired before this subscription is not missed. + */ + useEffect(() => { + const setReady = (ready: boolean) => { + // Child-local: gates editability (a user must never type into an unsynced/unseeded doc). + setCollabReady(ready) + // Parent: gates CLIENT autosave. In a collaborative session the relay persists the doc to + // markdown server-side (debounced + on last-disconnect), so the client must NOT also autosave — + // a stale keystroke saving over a server/copilot edit is the clobber the server path closes. + // Only the non-collaborative (solo) path client-autosaves. + onCollabReadyChange(collaboration ? false : ready) + } + if (!collaboration) { + setReady(true) + return + } + const { provider, doc } = collaboration + if (!editor) { + setReady(false) + return + } + const config = doc.getMap(FILE_DOC_SEED.configMap) + + // Readiness LATCHES so a post-seed `synced` flap can't re-gate a new file's agent stream — see + // {@link nextCollabReadiness} for the full rationale. `offlineSeed` marks a local (read-only) seed. + let syncedOnce = false + let offlineSeed = false + + const seedFromLoaded = () => { + if (config.get(FILE_DOC_SEED.flag) === true) return + offlineSeed = true + doc.transact(() => { + editor.commands.setContent( + parseMarkdownToDoc(splitFrontmatter(seedContentRef.current).body), + { contentType: 'json', emitUpdate: false } + ) + config.set(FILE_DOC_SEED.flag, true) + }) + } + + if (!provider) { + setReady(false) + return + } + + const report = () => { + const synced = provider.synced + const seeded = config.get(FILE_DOC_SEED.flag) === true + const next = nextCollabReadiness(syncedOnce, { synced, seeded, offlineSeed }) + syncedOnce = next.syncedOnce + setReady(next.ready) + } + const onJoinError = (error: JoinFileDocError) => { + if (error.retryable === false) seedFromLoaded() + } + + // A server edit that changes ONLY the frontmatter (e.g. copilot) updates the config map but not + // the body fragment, so TipTap's `onUpdate` never fires and the autosave draft would keep the + // stale open-time frontmatter — an explicit save could then revert the live change. Re-attach the + // new frontmatter to the current body and push a fresh draft whenever it changes on its own. + let lastFrontmatter = config.get(FILE_DOC_SEED.frontmatterKey) + const syncFrontmatter = () => { + const current = config.get(FILE_DOC_SEED.frontmatterKey) + if (current === lastFrontmatter) return + lastFrontmatter = current + // Null body ref ⇒ no body has synced yet (e.g. this fired before the seed's own `onUpdate`); + // that path re-attaches the frontmatter itself, so there is nothing to do here. + if (lastSyncedBodyRef.current !== null) { + onChangeRef.current( + applyFrontmatter(typeof current === 'string' ? current : '', lastSyncedBodyRef.current) + ) + } + } + + provider.on('synced', report) + provider.on('join-error', onJoinError) + config.observe(report) + config.observe(syncFrontmatter) + report() + if (provider.joinError) onJoinError(provider.joinError) + + return () => { + provider.off('synced', report) + provider.off('join-error', onJoinError) + config.unobserve(report) + config.unobserve(syncFrontmatter) + // Report NOT ready on teardown — the safe direction. If this effect ever re-runs while mounted + // (a future dep change), briefly gating autosave off is harmless; reporting `true` here could + // ungate it while the doc is unready. + onCollabReadyChange(false) + } + }, [collaboration, editor, onCollabReadyChange, setCollabReady]) + + /** + * Owns editability for the collaborative lifecycle: `useEditor`'s `editable` is only the initial + * value, and the streaming/settle effect only moves content in collab mode (never toggles + * editability) — so re-apply here whenever collaboration readiness (synced + seeded) or an agent + * stream flips `isEditable`. + */ + useEffect(() => { + if (!editor || !collaborationEnabled) return + if (editor.isEditable === isEditable) return + // Defer out of the render/commit phase. `isEditable` flips from collab readiness (synced + seeded), + // which is driven by a Yjs `config.observe` firing synchronously inside `Y.applyUpdate` — so this + // effect can run while React is mid-render. `setEditable` re-applies the view state and emits an + // `update` that the React binding commits with `flushSync`, which throws ("cannot flush while + // rendering") in that window. A microtask runs right after the current commit, before paint; re-check + // liveness/value since either can change before it fires. + let cancelled = false + queueMicrotask(() => { + if (cancelled || editor.isDestroyed) return + if (editor.isEditable !== isEditable) editor.setEditable(isEditable) + }) + return () => { + cancelled = true + } + }, [editor, collaborationEnabled, isEditable]) + /** * Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is * shared across instances). Reads only refs, so the handler stays stable across the editor's life. @@ -471,6 +846,8 @@ export function LoadedRichMarkdownEditor({ const pendingStreamBodyRef = useRef(null) const streamRafRef = useRef(null) const lastStreamParseAtRef = useRef(0) + const settleRunSeqRef = useRef(0) + const pendingCollapseRef = useRef(false) useEffect(() => { if (!editor) return const syncEditorBody = (body: string) => { @@ -481,9 +858,169 @@ export function LoadedRichMarkdownEditor({ emitUpdate: false, }) } + // Editor view mutations flush synchronously through the @tiptap/react binding (setContent mounts + // the React node views via flushSync — tiptap#3764), so calling them directly in this effect body + // throws "flushSync ... cannot flush while rendering" when the effect runs mid-render. Defer to a + // microtask (after commit, before paint). The streaming rAF tick below already runs off-render. + // + // Tag each run: a deferred mutation applies only if it is still the latest run (this effect has + // several early-return exits, so a run-token beats a per-exit cleanup flag) and the editor is + // alive. This drops a superseded run's microtask when React ran the next pass — a newer stream or + // settle — before the microtask flushed, so it can't overwrite the newer state. + const runSeq = ++settleRunSeqRef.current + const runOffRender = (mutate: () => void) => { + queueMicrotask(() => { + if (runSeq !== settleRunSeqRef.current || editor.isDestroyed) return + mutate() + }) + } + // Collaborative surface: stream by applying a minimal CRDT diff into the live Y.Doc each frame + // (never `setContent`, which would replace the shared doc and wipe peers). Each diff renders + // locally and broadcasts to every peer, so the stream is smooth here and on other clients (e.g. + // the standalone Files page) alike. This branch moves content only — editability for the collab + // lifecycle is owned by the reactive effect above. + if (collaborationEnabled) { + if (isStreaming) { + wasStreamingRef.current = true + // Apply streamed diffs only after the shared doc has SEEDED (synced + seed flag, i.e. + // `collabReady`). Applying onto an unseeded (empty) doc would let the later seed CRDT-merge into + // it — transient garble. In the common case the doc is long seeded before an agent edit begins; + // in the rare stream-before-seed race we wait, and since `collabReady` is an effect dep this + // re-runs and applies once it lands (the read-only placeholder shows the base content meanwhile — + // see `showPlaceholder`). + if (!collabReady) return + // Announce candidacy in the single-writer election (see the tick) so only one tab/window applies + // this stream. The shadow is opened lazily in the tick, only when THIS client actually leads — so a + // non-leader builds none, and a client that takes leadership mid-stream (a handoff) seeds its shadow + // from the CURRENT doc, already carrying the prior leader's ops, never a stale base. + if (!agentAnnouncedRef.current) { + agentAnnouncedRef.current = true + if (collaboration) announceAgentApplying(collaboration.awareness) + } + const body = splitFrontmatter(content).body + if (body === lastStreamedBodyRef.current) return + pendingStreamBodyRef.current = body + if (streamRafRef.current !== null) return + const tick = () => { + const pending = pendingStreamBodyRef.current + if (pending === null || pending === lastStreamedBodyRef.current) { + streamRafRef.current = null + return + } + // Hold a from-scratch rewrite (`update`) until settle so the open doc doesn't collapse to a + // partial rewrite mid-stream (matching `main`). `append`/`patch`/`create` apply each frame — the + // shadow reconcile is peer-safe, and base-less `append` fragments no longer reach the client (the + // server fail-closes them), so there is nothing here to string-prefix or wipe-guard against. + if (streamOperationRef.current === 'update') { + streamRafRef.current = null + return + } + // Single-writer election: only the leader (min clientID among clients announcing they apply this + // stream) writes it into the shared doc, so multiple tabs/windows watching the same live copilot + // stream don't each insert it and duplicate content. A non-leader renders the leader's ops via + // Yjs; re-checked each frame, so a co-leader stops the moment awareness propagates. (The pick-up + // direction — a successor beginning to write after the leader tab closes — waits for the next + // content frame to run a tick; a stream that already delivered its last frame is covered by + // settle and the durable write, so at worst a brief end-of-stream display lag, never a loss.) + // Bounded residual (accepted): if two tabs start the SAME stream within the awareness-propagation + // window they briefly both lead and duplicate a frame or two — a rare, transient, never-persisted + // glitch (SYNC_NO_PERSIST keeps it out of storage; the durable edit_content write reconciles the + // final doc). Resumes are sequential (the second tab sees the first's announcement), so the common + // multi-tab case elects cleanly. + if ( + collaboration && + !isAgentStreamLeader(collaboration.awareness, collaboration.doc.clientID) + ) { + // Not (or no longer) the leader: discard any shadow this client holds. A shadow only tracks + // ITS OWN reconciles, so one kept across a leadership loss goes stale as the interim leader + // advances the shared doc; reusing it on a later regain would re-emit ops for content already + // present (duplication). Dropping it here means a regain rebuilds a FRESH shadow from the + // current doc via the `??=` below — upholding "a non-leader holds none." + if (agentStreamSessionRef.current) { + endAgentStream(agentStreamSessionRef.current) + agentStreamSessionRef.current = null + } + streamRafRef.current = null + return + } + if ( + pending.length > STREAM_REPARSE_THROTTLE_THRESHOLD && + performance.now() - lastStreamParseAtRef.current < STREAM_REPARSE_THROTTLE_MS + ) { + streamRafRef.current = requestAnimationFrame(tick) + return + } + const el = containerRef.current + const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false + // Open the shadow lazily HERE — only when THIS client actually leads — seeded from the CURRENT + // doc. A non-leader holds none (torn down above), so whether this client is a first-time leader + // or one REGAINING leadership, `??=` finds a null ref and rebuilds fresh from the current doc, + // already carrying the interim leader's ops (never a stale base). Defensive: a ready collab + // editor always has a ySync binding. + agentStreamSessionRef.current ??= beginAgentStream(editor) + const session = agentStreamSessionRef.current + if (!session || !applyAgentStreamFrame(editor, session, pending)) { + streamRafRef.current = null + return + } + streamRafRef.current = null + lastStreamedBodyRef.current = pending + lastStreamParseAtRef.current = performance.now() + if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight + } + streamRafRef.current = requestAnimationFrame(tick) + return + } + if (streamRafRef.current !== null) { + cancelAnimationFrame(streamRafRef.current) + streamRafRef.current = null + } + // Settle: apply the FINAL body so the Y.Doc exactly equals the streamed result — but ONLY the + // elected writer applies it (the same min-clientID election the streaming tick uses). Without this, + // N tabs watching one run each open a fresh shadow and reconcile current→final; a non-leader's local + // settle microtask runs BEFORE the leader's final propagates (a server round-trip), so both insert + // the same tail and Yjs keeps both (it does not dedupe identical text from two clients) → a + // duplicated tail. The election is reliable here (unlike the bounded startup window): the stream ran + // for seconds, so awareness is long converged. Each tab reads leadership BEFORE clearing its own + // announcement — a remote clear is a network round-trip, always slower than these local microtasks, + // so every tab sees the same announcer set and agrees on one leader. The leader reuses its + // up-to-date shadow (catching a throttled last frame) or, if it never applied mid-stream (a held + // `update`, or a pre-seed stream), opens a FRESH shadow from the current doc; a non-leader applies + // nothing (the leader's final broadcasts to it) and frees any shadow it still held. The durable + // `edit_content` write then lands as a noop diff for everyone. + if (wasStreamingRef.current && collabReady) { + wasStreamingRef.current = false + agentAnnouncedRef.current = false + const isSettleWriter = + !collaboration || isAgentStreamLeader(collaboration.awareness, collaboration.doc.clientID) + if (collaboration) clearAgentApplying(collaboration.awareness) + lastStreamedBodyRef.current = null + const heldSession = agentStreamSessionRef.current + agentStreamSessionRef.current = null + if (isSettleWriter) { + const finalBody = splitFrontmatter(content).body + const session = heldSession ?? beginAgentStream(editor) + if (session) { + runOffRender(() => applyAgentStreamFrame(editor, session, finalBody)) + // Free the shadow with an UNGUARDED microtask (not `runOffRender`): a rapid follow-up stream + // can supersede the run token and drop the apply above, but the shadow must always be + // destroyed. Queued after the apply, so it frees the shadow only once that has had its chance. + queueMicrotask(() => endAgentStream(session)) + } + } else if (heldSession) { + // Non-leader: it never writes the final (the leader does + broadcasts it); free any shadow it held. + queueMicrotask(() => endAgentStream(heldSession)) + } + } + return + } if (isStreaming) { wasStreamingRef.current = true - if (editor.isEditable) editor.setEditable(false) + if (editor.isEditable) { + runOffRender(() => { + if (editor.isEditable) editor.setEditable(false) + }) + } const body = splitFrontmatter(content).body if (body === lastSyncedBodyRef.current) return pendingStreamBodyRef.current = body @@ -532,31 +1069,67 @@ export function LoadedRichMarkdownEditor({ if (isInitialSettle || wasStreamingRef.current) { wasStreamingRef.current = false settledRef.current = lockSettled(content) - syncEditorBody(splitFrontmatter(content).body) - // `setContent` maps any pre-existing selection onto the new doc rather than clearing it — a - // select-all survives as "select everything," permanently painting every divider/image with the - // `rich-leaf-in-selection` decoration (keymap.ts) until the user clicks elsewhere. This must run - // on every settle regardless of whether `setContent` ran just above: the last streaming tick - // already syncs `lastSyncedBodyRef` to the final body before settle, so `body` usually already - // equals it here — collapsing only inside that `if` would skip the common streamed-content case - // entirely. `setTextSelection` (not `.focus()`) so this never steals DOM focus from whatever the - // user is doing outside the editor. - editor.commands.setTextSelection(editor.state.doc.content.size) - editor.setEditable(canEdit && settledRef.current.verdict) - if (isInitialSettle && autoFocus) editor.commands.focus('end') + const settledVerdict = settledRef.current.verdict + const shouldFocus = isInitialSettle && autoFocus + // A settle owes a selection collapse. Track it as a ref, not just inline in this microtask: if a + // newer run bumps the token before this microtask fires, this settle's task is dropped — but the + // debt survives, and the run that supersedes it (settle OR the steady-sync path below) clears it. + pendingCollapseRef.current = true + // One ordered microtask: set body → collapse selection → re-apply editability. The collapse is + // load-bearing and runs on every settle even when the body is unchanged — setContent maps a + // pre-existing selection onto the new doc, so a prior select-all survives as "select everything", + // permanently painting every divider/image with the rich-leaf-in-selection decoration (keymap.ts) + // until the user clicks away. setTextSelection (not .focus()) never steals DOM focus. + runOffRender(() => { + syncEditorBody(splitFrontmatter(content).body) + pendingCollapseRef.current = false + editor.commands.setTextSelection(editor.state.doc.content.size) + editor.setEditable(canEdit && settledVerdict && collabReady) + if (shouldFocus) editor.commands.focus('end') + }) return } - syncEditorBody(splitFrontmatter(content).body) - if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict) - }, [editor, content, isStreaming, canEdit, autoFocus, disableStreamingAutoScroll]) + const settled = settledRef.current + runOffRender(() => { + syncEditorBody(splitFrontmatter(content).body) + // Honor a collapse a superseded settle owed but never applied (its microtask was dropped when this + // run bumped the token), so a post-stream select-all can't keep the leaf-in-selection decoration. + if (pendingCollapseRef.current) { + pendingCollapseRef.current = false + editor.commands.setTextSelection(editor.state.doc.content.size) + } + if (settled) editor.setEditable(canEdit && settled.verdict && collabReady) + }) + }, [ + editor, + content, + isStreaming, + canEdit, + autoFocus, + disableStreamingAutoScroll, + collaborationEnabled, + collabReady, + ]) useEffect( () => () => { if (streamRafRef.current !== null) cancelAnimationFrame(streamRafRef.current) + if (agentStreamSessionRef.current) { + endAgentStream(agentStreamSessionRef.current) + agentStreamSessionRef.current = null + } + lastStreamedBodyRef.current = null + agentAnnouncedRef.current = false }, [] ) + // Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet + // seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held + // until `collabReady` (see the streaming effect), so before then the editor is empty; the placeholder + // shows the base content until the seed swaps it in, avoiding both a blank frame and a garbled merge. + const showPlaceholder = collaborationEnabled && !collabReady + return (
0) void insertImagesRef.current(images, at) }} /> + {showPlaceholder && placeholderHtml && ( + // Instant read-only content while the collaborative doc seeds; the editor stays mounted-but- + // hidden below so it renders the seeded doc before the swap. Same layout box → no reflow. +
+ )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index e0723b4d5ca..a499d54b9fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -17,6 +17,7 @@ import { LinkHoverCard } from './menus/link-hover-card' import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' import '@sim/emcn/components/code/code.css' +import '../document-table.css' import './rich-markdown-editor.css' interface RichMarkdownFieldProps { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts new file mode 100644 index 00000000000..f6c46bd027f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from './editor-extensions' +import { firstHeadingTitle } from './title-heading' + +function editorWith(markdown: string): Editor { + const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) + if (markdown) editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor +} + +describe('firstHeadingTitle', () => { + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) + }) + + it('returns the leading H1 text', () => { + const editor = editorWith('# Q3 Planning\n\nbody') + expect(firstHeadingTitle(editor.state.doc)).toBe('Q3 Planning') + editor.destroy() + }) + + it('returns the text of any leading heading level', () => { + const editor = editorWith('## Sub title') + expect(firstHeadingTitle(editor.state.doc)).toBe('Sub title') + editor.destroy() + }) + + it('returns null when the first block is a paragraph, not a heading', () => { + const editor = editorWith('just text\n\n# later heading') + expect(firstHeadingTitle(editor.state.doc)).toBeNull() + editor.destroy() + }) + + it('returns null for an empty leading heading', () => { + const editor = editorWith('') + editor.commands.setContent({ type: 'doc', content: [{ type: 'heading', attrs: { level: 1 } }] }) + expect(firstHeadingTitle(editor.state.doc)).toBeNull() + editor.destroy() + }) + + it('returns null for a whitespace-only leading heading (trim boundary)', () => { + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: ' ' }] }], + }) + expect(firstHeadingTitle(editor.state.doc)).toBeNull() + editor.destroy() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts new file mode 100644 index 00000000000..3877519ca97 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts @@ -0,0 +1,9 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** The text of the document's leading heading (any level), or null when the first block isn't a heading. */ +export function firstHeadingTitle(doc: ProseMirrorNode): string | null { + const first = doc.firstChild + if (!first || first.type.name !== 'heading') return null + const text = first.textContent.trim() + return text.length > 0 ? text : null +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts index 984d2b2e1de..f8dac76bbdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts @@ -61,6 +61,13 @@ interface UseEditableFileContentOptions { * the at-rest baseline, never while an agent stream is in flight. Stable reference required. */ normalizeBaseline?: (raw: string) => string + /** + * Extra gate on autosave (and draft persistence). When `false`, saving is + * suppressed even when otherwise eligible — the collaborative editor uses it to + * hold saves until the shared document is synced AND seeded, so an empty or + * partially-synced doc can never overwrite the real file. Defaults to `true`. + */ + canAutosave?: boolean } interface EditableFileContent { @@ -136,6 +143,7 @@ export function useEditableFileContent({ saveRef, discardRef, normalizeBaseline, + canAutosave = true, }: UseEditableFileContentOptions): EditableFileContent { const onDirtyChangeRef = useRef(onDirtyChange) const onSaveStatusChangeRef = useRef(onSaveStatusChange) @@ -234,7 +242,7 @@ export function useEditableFileContent({ [workspaceId, file.id, markSavedContent] ) - const autosaveEnabled = canEdit && isInitialized && !isStreamInteractionLocked + const autosaveEnabled = canEdit && isInitialized && !isStreamInteractionLocked && canAutosave const { saveStatus, saveImmediately, isDirty, discard } = useAutosave({ content, @@ -249,9 +257,16 @@ export function useEditableFileContent({ ), }) + // When the client can't autosave it isn't the durability owner: the collaborative editor holds + // `canAutosave` permanently false because the relay persists the doc server-side (debounced + on + // last-disconnect), so `savedContent` never advances and raw `isDirty` would latch true after any + // local OR remote keystroke — surfacing a spurious "Unsaved changes" navigation prompt whose + // "Discard" discards nothing real. With nothing the user can save, there is nothing to warn about. + const isDirtyForCaller = canAutosave && isDirty + useEffect(() => { - onDirtyChangeRef.current?.(isDirty) - }, [isDirty]) + onDirtyChangeRef.current?.(isDirtyForCaller) + }, [isDirtyForCaller]) useEffect(() => { onSaveStatusChangeRef.current?.( @@ -299,6 +314,6 @@ export function useEditableFileContent({ hasContentError: streamingContent === undefined && Boolean(error) && !isInitialized, saveStatus, saveImmediately, - isDirty, + isDirty: isDirtyForCaller, } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index da8ac589312..84c480a4c47 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -77,8 +77,11 @@ import { isPreviewable, isTextEditable, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { FileDocAvatars } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars' +import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' +import { useWorkspaceFilesRoom } from '@/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room' import { filesFilterParsers, filesFilterUrlKeys, @@ -86,6 +89,12 @@ import { filesSortParams, filesUrlKeys, } from '@/app/workspace/[workspaceId]/files/search-params' +import { + DEFAULT_UNTITLED_NAME, + deriveMarkdownFileName, + isUntitledName, + uniqueMarkdownName, +} from '@/app/workspace/[workspaceId]/files/untitled-title' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' @@ -208,6 +217,11 @@ export function Files() { const canEdit = userPermissions.canEdit === true const { config: permissionConfig } = usePermissionConfig() + // Joined for the live file tree: a `workspace-files-changed` broadcast invalidates the + // browser. "Who's in this file" comes from the file-doc room (see FileDocRoomProvider), + // not from who's browsing the Files section. + useWorkspaceFilesRoom(workspaceId) + useEffect(() => { if (permissionConfig.hideFilesTab) { router.replace(`/workspace/${workspaceId}`) @@ -358,6 +372,34 @@ export function Files() { const selectedFileRef = useRef(selectedFile) selectedFileRef.current = selectedFile + /** + * While a file is still untitled, name it after the leading heading the user types in its editor. The + * editor reports the heading text (debounced); here we re-check the file is still untitled, derive a + * unique `.md` name among its folder siblings, and rename. A no-op once the file has a real name. + */ + const handleDeriveTitleFromHeading = useCallback( + (headingText: string) => { + const currentFile = selectedFileRef.current + if (!currentFile || !isUntitledName(currentFile.name)) return + const derived = deriveMarkdownFileName(headingText) + if (!derived) return + const siblingNames = new Set( + filesRef.current + .filter( + (f) => + (f.folderId ?? null) === (currentFile.folderId ?? null) && f.id !== currentFile.id + ) + .map((f) => f.name) + ) + const name = uniqueMarkdownName(derived, siblingNames) + if (name === currentFile.name) return + renameFile + .mutateAsync({ workspaceId, fileId: currentFile.id, name }) + .catch((err) => logger.error('Failed to auto-name file from heading:', err)) + }, + [workspaceId] + ) + const shareFile = shareFileId ? (files.find((f) => f.id === shareFileId) ?? null) : null const shareModal = shareFile ? ( { + // Decorate-sort: compute each key + pinned flag once (O(N)) rather than parsing dates per comparison. + const decorated = searched.map((folder) => ({ + folder, + pinned: pinnedFolderIds.has(folder.id), + key: + col === 'updated' + ? new Date(folder.updatedAt).getTime() + : col === 'created' + ? new Date(folder.createdAt).getTime() + : folder.name, + })) + decorated.sort((a, b) => { // Pinned folders float to the top of every sort/direction — pinning is a // user-declared priority, not another sort key to be inverted by `desc`. - const aPinned = pinnedFolderIds.has(a.id) - const bPinned = pinnedFolderIds.has(b.id) - if (aPinned !== bPinned) return aPinned ? -1 : 1 - - let cmp = 0 - if (col === 'updated') { - cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - } else if (col === 'created') { - cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - } else { - cmp = a.name.localeCompare(b.name) - } + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 + const cmp = + typeof a.key === 'number' && typeof b.key === 'number' + ? a.key - b.key + : String(a.key).localeCompare(String(b.key)) return dir === 'asc' ? cmp : -cmp }) + return decorated.map((d) => d.folder) }, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds]) const filteredFiles = useMemo(() => { @@ -480,38 +527,36 @@ export function Files() { const col = activeSort?.column ?? 'updated' const dir = activeSort?.direction ?? 'desc' - return [...result].sort((a, b) => { + // Decorate-sort: compute each row's sort key + pinned flag ONCE (O(N)), then compare precomputed + // keys — the comparator ran per-comparison work (Date parsing, `formatFileType`, member lookups) at + // O(N log N). Stable sort + pinned-primary ordering are preserved. + const decorated = result.map((f) => ({ + f, + pinned: pinnedFileIds.has(f.id), + key: + col === 'size' + ? f.size + : col === 'type' + ? formatFileType(f.type, f.name) + : col === 'created' + ? new Date(f.uploadedAt).getTime() + : col === 'updated' + ? new Date(f.updatedAt).getTime() + : col === 'owner' + ? (membersById.get(f.uploadedBy)?.name ?? '') + : f.name, + })) + decorated.sort((a, b) => { // Pinned files float to the top of every sort/direction — pinning is a // user-declared priority, not another sort key to be inverted by `desc`. - const aPinned = pinnedFileIds.has(a.id) - const bPinned = pinnedFileIds.has(b.id) - if (aPinned !== bPinned) return aPinned ? -1 : 1 - - let cmp = 0 - switch (col) { - case 'name': - cmp = a.name.localeCompare(b.name) - break - case 'size': - cmp = a.size - b.size - break - case 'type': - cmp = formatFileType(a.type, a.name).localeCompare(formatFileType(b.type, b.name)) - break - case 'created': - cmp = new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime() - break - case 'updated': - cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - break - case 'owner': - cmp = (membersById.get(a.uploadedBy)?.name ?? '').localeCompare( - membersById.get(b.uploadedBy)?.name ?? '' - ) - break - } + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 + const cmp = + typeof a.key === 'number' && typeof b.key === 'number' + ? a.key - b.key + : String(a.key).localeCompare(String(b.key)) return dir === 'asc' ? cmp : -cmp }) + return decorated.map((d) => d.f) }, [ files, currentFolderId, @@ -1248,12 +1293,7 @@ export function Files() { const existingNames = new Set( filesRef.current.filter((f) => (f.folderId ?? null) === currentFolderId).map((f) => f.name) ) - let name = 'untitled.md' - let counter = 1 - while (existingNames.has(name)) { - name = `untitled (${counter}).md` - counter++ - } + const name = uniqueMarkdownName(DEFAULT_UNTITLED_NAME, existingNames) const mimeType = getMimeTypeFromExtension('md') const blob = new Blob([''], { type: mimeType }) @@ -1781,10 +1821,18 @@ export function Files() { ) const contextMenuMoveOptions = useMemo((): MoveOptionNode[] => { + // Index children by parent ONCE (the same pattern used for folder sizes + descendant maps above), + // so building the tree is O(N) instead of a full `folders.filter` scan at every node (O(N²)). + const childrenByParent = new Map() + for (const f of folders) { + const key = f.parentId ?? null + const arr = childrenByParent.get(key) + if (arr) arr.push(f) + else childrenByParent.set(key, [f]) + } const buildSubtree = (parentId: string | null): MoveOptionNode[] => - folders + (childrenByParent.get(parentId) ?? []) .filter((f) => { - if ((f.parentId ?? null) !== parentId) return false if (selectedFolderIds.includes(f.id)) return false return selectedFolderIds.every( (sid) => !descendantFolderIdsByFolderId.get(sid)?.has(f.id) @@ -1979,35 +2027,43 @@ export function Files() { if (selectedFile) { return ( <> - - - + {/* The room provider scopes "who's in this file" presence to the open document: the + editor (inside FileViewer) publishes the server-authenticated roster and the + header's FileDocAvatars reads it — both must be descendants. */} + + + } + /> + - - + + + + invalidateWorkspaceFileBrowsers(queryClient, workspaceId) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.test.ts b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.test.ts new file mode 100644 index 00000000000..e9ee0ba2e43 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_UNTITLED_NAME, + deriveMarkdownFileName, + isUntitledName, + uniqueMarkdownName, +} from './untitled-title' + +describe('untitled format single-source-of-truth', () => { + // Guards against DEFAULT_UNTITLED_NAME / uniqueMarkdownName drifting from the isUntitledName regex: + // the default name and its deduped siblings must always read back as "untitled". + it('recognizes the default name and its deduped siblings as untitled', () => { + expect(isUntitledName(DEFAULT_UNTITLED_NAME)).toBe(true) + const second = uniqueMarkdownName(DEFAULT_UNTITLED_NAME, new Set([DEFAULT_UNTITLED_NAME])) + expect(second).toBe('untitled (1).md') + expect(isUntitledName(second)).toBe(true) + }) +}) + +describe('isUntitledName', () => { + it.each([ + ['untitled.md', true], + ['untitled (1).md', true], + ['untitled (23).md', true], + ['Untitled.md', false], + ['untitled.txt', false], + ['untitled', false], + ['my notes.md', false], + ['untitled draft.md', false], + ['untitled ().md', false], + ])('%s → %s', (name, expected) => { + expect(isUntitledName(name)).toBe(expected) + }) +}) + +describe('deriveMarkdownFileName', () => { + it('turns heading text into a .md file name', () => { + expect(deriveMarkdownFileName('Q3 Planning')).toBe('Q3 Planning.md') + }) + it('strips filesystem-illegal characters and collapses whitespace', () => { + expect(deriveMarkdownFileName('Roadmap: Q3 / Q4 *draft*')).toBe('Roadmap Q3 Q4 draft.md') + }) + it('keeps hyphens and dots inside the title', () => { + expect(deriveMarkdownFileName('v1.2 - release-notes')).toBe('v1.2 - release-notes.md') + }) + it('returns null when nothing usable remains', () => { + expect(deriveMarkdownFileName(' ')).toBeNull() + expect(deriveMarkdownFileName('///')).toBeNull() + }) + + it('does not double the extension when the heading already ends in .md', () => { + expect(deriveMarkdownFileName('README.md')).toBe('README.md') + expect(deriveMarkdownFileName('notes.MD')).toBe('notes.MD') + }) + it('hard-caps the length (no ellipsis) before the extension', () => { + const result = deriveMarkdownFileName('a'.repeat(200)) + expect(result).toBe(`${'a'.repeat(100)}.md`) + }) + + it('re-trims when the hard cap lands on a space (no "foo .md")', () => { + // 99 non-space chars + space at index 99 → truncate(100) leaves a trailing space to re-trim away. + const result = deriveMarkdownFileName(`${'a'.repeat(99)} bcd`) + expect(result).toBe(`${'a'.repeat(99)}.md`) + }) +}) + +describe('uniqueMarkdownName', () => { + it('returns the name unchanged when free', () => { + expect(uniqueMarkdownName('notes.md', new Set())).toBe('notes.md') + }) + it('appends an incrementing suffix before the extension when taken', () => { + expect(uniqueMarkdownName('notes.md', new Set(['notes.md']))).toBe('notes (1).md') + expect(uniqueMarkdownName('notes.md', new Set(['notes.md', 'notes (1).md']))).toBe( + 'notes (2).md' + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts new file mode 100644 index 00000000000..4800fb13e9a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts @@ -0,0 +1,57 @@ +import { truncate } from '@sim/utils/string' + +/** + * The name a freshly-created markdown file is given in `handleCreateFile`: `untitled.md`, or + * `untitled (n).md` when that is taken. A file keeps this "unnamed" status until it is renamed — + * while unnamed, typing a leading heading names the file (one direction only; the reverse + * name→heading seed was removed as unsafe on the shared editor). See {@link isUntitledName}. + */ +export const DEFAULT_UNTITLED_NAME = 'untitled.md' + +const UNTITLED_NAME_RE = /^untitled(?: \(\d+\))?\.md$/ + +/** Longest title kept when deriving a file name from a heading, before the `.md` extension. */ +const MAX_DERIVED_TITLE_LENGTH = 100 + +/** + * Filename characters disallowed across the common platforms (`\ / : * ? " < > |`) plus C0 control + * characters, replaced with a space when deriving a file name from heading text. + */ +const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|\x00-\x1f]/g + +/** True when `name` is still the auto-assigned untitled markdown name (`untitled.md`, `untitled (2).md`). */ +export function isUntitledName(name: string): boolean { + return UNTITLED_NAME_RE.test(name) +} + +/** + * Derives a markdown file name from heading text — illegal filename characters dropped, whitespace + * collapsed, trimmed, hard-capped at {@link MAX_DERIVED_TITLE_LENGTH}, and suffixed with `.md`. + * Returns null when nothing usable remains (e.g. a heading of only slashes), so the caller keeps the + * current name. + */ +export function deriveMarkdownFileName(headingText: string): string | null { + const base = headingText.replace(ILLEGAL_FILENAME_CHARS, ' ').replace(/\s+/g, ' ').trim() + if (!base) return null + // Re-trim after the hard cap: truncation can land mid-word and leave a trailing space (`"foo .md"`). + const capped = truncate(base, MAX_DERIVED_TITLE_LENGTH, '').trim() + if (!capped) return null + // A heading that already ends in `.md` (e.g. `# README.md`) must not become `README.md.md`. + return /\.md$/i.test(capped) ? capped : `${capped}.md` +} + +/** + * Makes `name` unique among `existingNames` by appending ` (n)` before the `.md` extension — the same + * scheme `handleCreateFile` uses for the default untitled name. + */ +export function uniqueMarkdownName(name: string, existingNames: ReadonlySet): string { + if (!existingNames.has(name)) return name + const withoutExt = name.replace(/\.md$/i, '') + let counter = 1 + let candidate = `${withoutExt} (${counter}).md` + while (existingNames.has(candidate)) { + counter++ + candidate = `${withoutExt} (${counter}).md` + } + return candidate +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index 233b0e8d202..50cec53dfa9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -13,7 +13,7 @@ import { import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' -import { getBareIconStyle } from '@/blocks/icon-color' +import { getBareIconStyle } from '@/blocks/brand-icon-style' import { getBlockRegistry } from '@/blocks/registry' interface RenderIconArgs { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 38afd89461b..4e07b66f747 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -12,7 +12,7 @@ import { import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display' -import { getBareIconStyle } from '@/blocks/icon-color' +import { getBareIconStyle } from '@/blocks/brand-icon-style' import { getBlockByToolName } from '@/blocks/registry' import type { ToolCallStatus } from '../../../../types' import { resolveToolDisplayState } from '../../utils' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 66b5c599b75..bdd324efa15 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -246,17 +246,20 @@ describe('parseSpecialTags with ', () => { }) it('does not rescan the interior of a body that carried no markers', () => { - // Pins WHY the two literal reasons resume at different offsets. A - // never-a-payload body resumes past the CLOSE; resuming past the opener - // instead would rescan the interior, and since the marker scan runs on the - // blanked body, a tag quoted inside a JSON string is invisible to it and - // would be re-parsed as a real tag on the second pass — then dropped, - // deleting the very text this parser exists to preserve. + // Pins WHY a settled span resumes past the CLOSE, never past the opener. + // Resuming past the opener would rescan the interior, and since the marker + // scan runs on the blanked body, a tag quoted inside a JSON string is + // invisible to it and would be re-parsed as a REAL tag on the second pass — + // painting the quoted JSON verbatim as raw text (its escaped quotes cannot + // re-parse as a card), the exact failure `discard` exists to prevent. The + // span itself opens `{"` and will not parse (trailing junk), so it is an + // attempted payload and is discarded whole; what must never happen is a + // partial re-parse of its quoted interior. const raw = 'A {"a":"{\\"k\\":{\\"title\\":\\"x\\",\\"description\\":\\"y\\"}}"} junk B' const { segments } = parseSpecialTags(raw, false) expect(segments.every((segment) => segment.type === 'text')).toBe(true) - expect(renderedText(segments)).toBe(raw) + expect(renderedText(segments)).toBe('A B') }) it('keeps prose a tag wrapped instead of a payload', () => { @@ -320,6 +323,88 @@ describe('parseSpecialTags with ', () => { expect(segments.every((segment) => segment.type === 'text')).toBe(true) }) + it('drops a payload one typo away from valid instead of showing raw JSON', () => { + // The first three are verbatim from production screenshots (2026-07-31): an + // extra `}` before the array close, a missing opening quote on a key, and a + // stray `]}` after the map closes; the fourth adds a trailing comma. Each + // fails JSON.parse, so the old was-it-ever-JSON test called them prose and + // rendered the whole payload verbatim — the markdown layer then swallowed + // the tag markers and the reader saw a wall of raw JSON. They open `{"` or + // `[{` and carry key-value colons, which marks them as attempted payloads: + // droppable, like any other broken emission. + const cases = [ + 'Prose before. {"type": "single_select", "prompt": "How should I proceed?", "options": [{"id": "a", "label": "Confirm the id"}}]}', + 'Prose before. {"type":"multi_select","prompt":"What should I build now?",options": [{"id":"lib","label":"Pattern library"}]}', + 'Prose before. {"1": {"title": "Define the criteria", "description": "Populate"}}]}', + 'Prose before. [{"title":"Ship it","description":"Open the PR"},]', + ] + for (const raw of cases) { + const { segments, hasPendingTag } = parseSpecialTags(raw, false) + expect(hasPendingTag, raw).toBe(false) + expect(renderedText(segments), raw).toBe('Prose before. ') + expect( + segments.every((segment) => segment.type === 'text'), + raw + ).toBe(true) + } + }) + + it('drops a broken inline payload rather than dumping it mid-sentence', () => { + // Same treatment for the inline tag: a `{"`-opening body with a syntax + // error reads as an attempted chip, and the sentence survives around the + // hole exactly as it does for a wrong-shape payload today. + const raw = + 'I saved {"type":"file",path:"a.md"} for you.' + expect(renderedText(parseSpecialTags(raw, false).segments)).toBe('I saved for you.') + }) + + it('renders nothing for a message that is only an unparsable payload', () => { + // The discardedTag guard must cover the new class too: with every segment + // discarded, the raw-content fallback would otherwise resurrect the exact + // JSON the discard removed. + const { segments } = parseSpecialTags( + '{"1": {"title": "a", "description": "b"}}]}', + false + ) + expect(segments).toHaveLength(0) + }) + + it('still shows an unparsable body that never opened like a payload', () => { + // The other side of the attempted-payload line: a bare scalar opens with + // its own first character, not `{"`/`[{`, so it reads as prose in quotes + // and must render — same as the brace-wrapped prose cases above. + const raw = 'see "just a phrase" end' + expect(renderedText(parseSpecialTags(raw, false).segments)).toBe(raw) + }) + + it('renders brace-wrapped quoted prose — an opener alone is not an attempt', () => { + // The attempted-payload call takes BOTH kinds of evidence: the `{"` opener + // and a key-value colon outside string literals. `{"the Q4 report"}` has + // the opener but no colon — prose in costume, so it renders; a colon + // inside the quotes changes nothing. The array twin parses as JSON, so it + // was dropped as `wrong-shape` before this heuristic existed and still is — + // that verdict comes from a real parse, not from the opener. + const braceWrapped = 'see {"the Q4 report"} end' + expect(renderedText(parseSpecialTags(braceWrapped, false).segments)).toBe(braceWrapped) + const quotedColon = 'see {"ratio: 4:5"} end' + expect(renderedText(parseSpecialTags(quotedColon, false).segments)).toBe(quotedColon) + const arrayWrapped = 'see ["some list item"] end' + expect(renderedText(parseSpecialTags(arrayWrapped, false).segments)).toBe('see end') + }) + + it('discards a broken payload whose strings legitimately mention tag syntax', () => { + // The prompt quotes a tag name, so a raw scan sees a marker — but the + // body's quotes are balanced, so the blanked scan already proved the marker + // sits inside a string. Treating it as a nested tag would render the broken + // payload as raw JSON, the exact failure `discard` exists to prevent. The + // raw rescan is reserved for mispaired quotes, where blanked offsets lie. + const raw = + 'Prose before. {"type": "single_select", "prompt": "Use the tag", "options": [{"id": "a", "label": "x"}}]}' + const { segments } = parseSpecialTags(raw, false) + expect(renderedText(segments)).toBe('Prose before. ') + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + it('does not flash the payload while the closing tag is still arriving', () => { // Each frame below is a real mid-stream state: the JSON value has closed, so // without tolerating an arriving close the trailing `', () => { } }) + it('never flashes a broken payload at any streamed frame', () => { + // A body that goes non-viable mid-stream (the stray `]}` lands before the + // close does) used to release as literal text at that frame, then vanish + // when the close arrived and classified it not-parsable — raw JSON painted + // on screen only for the close to retract it. Suppression must hold at + // EVERY frame from the completed opener on, and the settled parse must + // agree with what the frames showed. + const raw = + 'Prose before. {"1": {"title": "Define the criteria", "description": "Populate"}}]} after.' + const bodyStart = raw.indexOf('') + ''.length + for (let end = bodyStart; end <= raw.length; end++) { + const { segments } = parseSpecialTags(raw.slice(0, end), true) + expect(renderedText(segments), `frame ${end}`).not.toContain('{') + } + expect(renderedText(parseSpecialTags(raw, false).segments)).toBe('Prose before. after.') + }) + it('still rejects a close whose name is wrong rather than merely unfinished', () => { // The counterpart to the case above: `` can never grow // into ``, so it settles immediately instead of hiding @@ -363,7 +465,7 @@ describe('parseSpecialTags with ', () => { it('finds a nested tag an unbalanced quote hid from the blanked scan', () => { // One stray `"` is enough to make blankJsonStringLiterals treat the REST of // the body as a string literal, hiding the real `` marker from the - // scan. The verdict then degrades from `foreign-markers` to `never-a-payload` + // scan. The verdict then degrades from `foreign-markers` to `not-viable-json` // and resumes past the close, flattening both nested tags into one literal // span — so a card already on screen un-renders when the close arrives. // @@ -901,8 +1003,13 @@ describe('parser properties', () => { /** * Fragments that must survive verbatim. Every one is a shape the parser has to * reject: prose mentions, malformed closes, bodies that never were payloads. - * None is a valid tag and none is a well-formed payload, so nothing here is - * eligible for `discard` — which makes "output equals input" a legal assertion. + * Nothing here is eligible for `discard` — which makes "output equals input" a + * legal assertion. That takes two properties, not one: no fragment is a + * well-formed payload (`wrong-shape`), and the `{"`-opening bodies never land + * in a marker-free matched pair (`not-parsable`) — their own close is + * misspelled, truncated, or absent, so any close they borrow from a later + * fragment drags that fragment's own opener into the body, and the + * nested-marker rule settles the span before the attempted-payload test runs. */ const LOSSLESS_FRAGMENTS = [ 'Plain prose with no markup at all. ', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 2dd4e87562d..cb5dbdec981 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -10,6 +10,7 @@ import { ExpandableContent, SecretInput, SecretReveal, + SquareArrowUpRight, Tooltip, toast, } from '@sim/emcn' @@ -17,9 +18,11 @@ import { Cursor, TerminalWindow } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { ThinkingLoader } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' +import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { isHosted } from '@/lib/core/config/env-flags' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { getDesktopBridge } from '@/lib/desktop' import { @@ -440,12 +443,13 @@ export function parseTextTagBody(body: string): string | null { /** * Whether `body` is syntactically valid JSON, regardless of its shape. * - * Separates "the agent formed a payload that failed its shape guard" from "this - * was never JSON" — the line that decides whether a failed body may be dropped - * or must be shown (see {@link classifyBody}). Costs a second parse of a body - * that already failed one, which is the rare path; the common cases never reach - * it, since a valid payload returns earlier and prose is rejected by the cheaper - * viability rule before this runs. + * Separates "the agent formed a well-formed payload that failed its shape + * guard" (`wrong-shape`) from "this body will not parse"; for the latter, + * {@link wasAttemptedPayload} then decides whether it may be dropped or must be + * shown (see {@link classifyBody}). Costs a second parse of a body that already + * failed one, which is the rare path; the common cases never reach it, since a + * valid payload returns earlier and prose is rejected by the cheaper viability + * rule before this runs. */ function isParseableJson(body: string): boolean { try { @@ -608,8 +612,8 @@ const LONGEST_TAG_MARKER = Math.max(...SPECIAL_TAG_NAMES.map((name) => ` {canManageBilling ? ( {buttonLabel} - + {isHosted ? : } ) : (

diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 0f5bd7849df..2b063f54dc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -250,6 +250,7 @@ export const ResourceContent = memo(function ResourceContent({ } isAgentEditing={isAgentEditing} streamIsIncremental={streamIsIncremental} + streamOperation={previewSession?.operation} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} /> @@ -663,6 +664,7 @@ interface EmbeddedFileProps { streamingContent?: string isAgentEditing?: boolean streamIsIncremental?: boolean + streamOperation?: string disableStreamingAutoScroll?: boolean previewContextKey?: string } @@ -675,6 +677,7 @@ function EmbeddedFile({ streamingContent, isAgentEditing, streamIsIncremental, + streamOperation, disableStreamingAutoScroll = false, previewContextKey, }: EmbeddedFileProps) { @@ -718,8 +721,10 @@ function EmbeddedFile({ streamingContent={streamingContent} isAgentEditing={isAgentEditing} streamIsIncremental={streamIsIncremental} + streamOperation={streamOperation} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + collaborative />

) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 1681f3a07b8..dafefd33d5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -21,7 +21,7 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' +import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style' import { logKeys } from '@/hooks/queries/logs' import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' import { scheduleKeys } from '@/hooks/queries/schedules' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 736bf30fefa..c90e0795ee8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -9,7 +9,6 @@ import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { GmailIcon, SlackIcon } from '@/components/icons' import { - getAllBlockMeta, INTEGRATIONS, type OAuthServiceMatch, resolveOAuthServiceForIntegration, @@ -17,7 +16,8 @@ import { } from '@/lib/integrations' import { captureEvent } from '@/lib/posthog/client' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' -import { getBareIconStyle } from '@/blocks/icon-color' +import { getBareIconStyle } from '@/blocks/brand-icon-style' +import { getAllBlockMeta } from '@/blocks/registry' import type { ModuleTag } from '@/blocks/types' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts index 6406eb9c7ca..e586d4f9c37 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts @@ -95,6 +95,80 @@ describe('deriveFilePreviewSession', () => { expect(next.previewVersion).toBe(9) }) + it('ignores a re-delivered delta (same version) — no double-append (the duplication bug)', () => { + const prev = session({ previewText: 'the story so far.', previewVersion: 7 }) + const replay = deriveFilePreviewSession( + prev, + { + previewPhase: 'file_preview_content', + toolCallId: 'tool-1', + toolName: 'workspace_file', + content: ' the story so far.', + contentMode: 'delta', + previewVersion: 7, // <= prev.previewVersion → a replay, must not re-append + fileName: 'deck.pptx', + }, + 'stream-1', + NOW + ) + expect(replay.previewText).toBe('the story so far.') + expect(replay.previewVersion).toBe(7) + }) + + it('ignores a replayed older snapshot — no regression of accumulated text', () => { + const prev = session({ previewText: 'full accumulated body', previewVersion: 12 }) + const stale = deriveFilePreviewSession( + prev, + { + previewPhase: 'file_preview_content', + toolCallId: 'tool-1', + toolName: 'workspace_file', + content: 'earlier partial', + contentMode: 'snapshot', + previewVersion: 5, // stale replay + fileName: 'deck.pptx', + }, + 'stream-1', + NOW + ) + expect(stale.previewText).toBe('full accumulated body') + expect(stale.previewVersion).toBe(12) + }) + + it('re-processing the SAME delta stream N times yields the content exactly once', () => { + // Simulates a client re-render/re-subscribe replaying the stream: the accumulated text must be stable. + const deltas = [ + { v: 1, c: 'A' }, + { v: 2, c: 'B' }, + { v: 3, c: 'C' }, + ] + const run = (start: FilePreviewSession | undefined) => + deltas.reduce( + (acc, d) => + deriveFilePreviewSession( + acc, + { + previewPhase: 'file_preview_content', + toolCallId: 'tool-1', + toolName: 'workspace_file', + content: d.c, + contentMode: 'delta', + previewVersion: d.v, + fileName: 'deck.pptx', + }, + 'stream-1', + NOW + ), + start + ) + const first = run(undefined) + expect(first?.previewText).toBe('ABC') + const second = run(first) // replay the exact same events + expect(second?.previewText).toBe('ABC') // NOT 'ABCABC' + const third = run(second) + expect(third?.previewText).toBe('ABC') + }) + it('replaces text on a snapshot and carries forward prior fileId', () => { const prev = session({ previewText: 'old', fileId: 'file-9', previewVersion: 4 }) const next = deriveFilePreviewSession( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts index e4bb638bb5c..d4fadb04e86 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts @@ -71,15 +71,23 @@ export function deriveFilePreviewSession( return base case 'file_preview_content': { + const incomingVersion = + typeof payload.previewVersion === 'number' && Number.isFinite(payload.previewVersion) + ? payload.previewVersion + : (prev?.previewVersion ?? 0) + 1 + // Replay-safe accumulation. A content event may be re-delivered or re-processed (a client + // re-render/re-subscribe, or a stream replay); `previewVersion` is monotonic per tool call, so only + // apply when it STRICTLY advances. Without this guard a re-delivered `delta` double-appends (the + // duplicated-tail bug) and a replayed older `snapshot` regresses the text. `base` already carries + // `prev.previewText`, so an ignored replay leaves the accumulated text untouched. + if (prev && incomingVersion <= prev.previewVersion) { + return { ...base, status: 'streaming' } + } const previewText = payload.contentMode === 'delta' ? (prev?.previewText ?? '') + payload.content : payload.content - const previewVersion = - typeof payload.previewVersion === 'number' && Number.isFinite(payload.previewVersion) - ? payload.previewVersion - : (prev?.previewVersion ?? 0) + 1 - return { ...base, status: 'streaming', previewText, previewVersion } + return { ...base, status: 'streaming', previewText, previewVersion: incomingVersion } } case 'file_preview_complete': diff --git a/apps/sim/app/workspace/[workspaceId]/home/layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/layout.tsx index b8bae2ff0af..3f60d94d8d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/layout.tsx @@ -1,6 +1,25 @@ +import { redirect } from 'next/navigation' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { inter } from '@/app/_styles/fonts/inter/inter' -export default function HomeLayout({ children }: { children: React.ReactNode }) { +/** + * Redirects rather than 404s when Chat is disabled: this path is baked into + * already-delivered invitation emails and into the invitation-accept API + * contract, so it has to keep resolving. `/workspace/{id}` re-resolves the + * landing route server-side, so the visitor lands on a workflow instead. + */ +export default async function HomeLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ workspaceId: string }> +}) { + if (!isChatEnabled) { + const { workspaceId } = await params + redirect(`/workspace/${workspaceId}`) + } + return (
{children} diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index a1e1febf0fb..b7a6cc4ea95 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,7 +1,9 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch' import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag' @@ -15,6 +17,12 @@ export const metadata: Metadata = { export default async function HomePage({ params }: { params: Promise<{ workspaceId: string }> }) { const { workspaceId } = await params + // The layout redirects too, but pages and layouts resolve concurrently — without + // this the prefetch below still fires on its way out. + if (!isChatEnabled) { + redirect(`/workspace/${workspaceId}`) + } + const queryClient = getQueryClient() const listsPrefetch = prefetchHomeLists(queryClient, workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts b/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts new file mode 100644 index 00000000000..0c652fcb8d6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts @@ -0,0 +1,111 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { createLogger } from '@sim/logger' +import type { RoomType } from '@sim/realtime-protocol/rooms' +import { useSocket } from '@/app/workspace/providers/socket-provider' + +const logger = createLogger('WorkspaceInvalidationRoom') + +/** Retry cap + base delay for a retryable join failure on an otherwise-live socket. */ +const MAX_JOIN_RETRIES = 3 +const JOIN_RETRY_BASE_MS = 1000 + +interface JoinErrorPayload { + workspaceId: string + error: string + code: string + retryable?: boolean +} + +/** + * Joins a workspace-scoped, presence-free "invalidation room" over the shared socket and runs + * `onChanged` whenever the server broadcasts `${roomType}-changed` for this workspace, so the list + * refetches without waiting for staleness. Shared core behind {@link useWorkspaceFilesRoom} and + * {@link useWorkspaceTablesRoom}; event names derive from `roomType`. + * + * These rooms carry no presence — "who's in a resource" comes from the per-resource room, not from + * who's browsing the section. Mutations happen server-side (HTTP + copilot) and fan out this signal. + */ +export function useWorkspaceInvalidationRoom( + workspaceId: string, + roomType: RoomType, + onChanged: () => void +): void { + const { socket } = useSocket() + // Held by ref so a caller passing a fresh closure each render never re-subscribes the socket. + const onChangedRef = useRef(onChanged) + onChangedRef.current = onChanged + + useEffect(() => { + if (!socket || !workspaceId) return + + const joinEvent = `join-${roomType}` + const successEvent = `${joinEvent}-success` + const errorEvent = `${joinEvent}-error` + const leaveEvent = `leave-${roomType}` + const changedEvent = `${roomType}-changed` + + let retries = 0 + let retryTimer: ReturnType | null = null + + const join = () => socket.emit(joinEvent, { workspaceId }) + + // A fresh (re)connect gets a fresh retry budget, so a prior full exhaustion doesn't leave the + // socket unable to retry a failed re-join until the next success. Cancel any retry still pending + // from before the reconnect so it can't fire a duplicate join after this immediate one. + const handleConnect = () => { + retries = 0 + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + join() + } + + const handleJoinSuccess = (data: { workspaceId: string }) => { + if (data.workspaceId !== workspaceId) return + retries = 0 + // Cancel any retry scheduled by a prior retryable error so it can't fire an extra + // join after we're already in. + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + } + const handleJoinError = (data: JoinErrorPayload) => { + if (data.workspaceId !== workspaceId) return + logger.warn(`Failed to join ${roomType} room`, { code: data.code, error: data.error }) + if (data.retryable && retries < MAX_JOIN_RETRIES) { + retries += 1 + // Clear any still-pending retry before scheduling a new one, so reconnect churn can't + // orphan a timer that fires an extra join(). + if (retryTimer) clearTimeout(retryTimer) + retryTimer = setTimeout(join, JOIN_RETRY_BASE_MS * retries) + } + } + const handleChanged = (data: { workspaceId: string }) => { + if (data.workspaceId === workspaceId) onChangedRef.current() + } + + // Join now if the socket is already connected; `connect` covers (re)connects. + if (socket.connected) join() + socket.on('connect', handleConnect) + socket.on(successEvent, handleJoinSuccess) + socket.on(errorEvent, handleJoinError) + socket.on(changedEvent, handleChanged) + + return () => { + if (retryTimer) clearTimeout(retryTimer) + socket.off('connect', handleConnect) + socket.off(successEvent, handleJoinSuccess) + socket.off(errorEvent, handleJoinError) + socket.off(changedEvent, handleChanged) + + // Leave the room, scoped to THIS workspace: the server no-ops if the socket has already + // switched to another workspace's room (so a workspace A→B switch, where B's join runs first + // and auto-leaves A, can't have A's leave evict B). + socket.emit(leaveEvent, { workspaceId }) + } + }, [socket, workspaceId, roomType]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 1558d9d1ca4..929a673c993 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -7,6 +7,7 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { blockTypeToIconMap, type Integration, @@ -157,11 +158,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration Add to Sim ) - ) : ( + ) : isChatEnabled ? ( Add to Sim - )} + ) : null}
{oauthService && ( @@ -243,7 +244,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration /> )} - {matchingTemplates.length > 0 && ( + {/* Every template hands its prompt to Chat, so the section has no + destination without it. */} + {isChatEnabled && matchingTemplates.length > 0 && ( - { - storeCuratedPrompt(prompt) - router.push(`/workspace/${workspaceId}/home`) - }} - className='absolute right-0 bottom-0 mx-0' - > - Explore in chat - + {isChatEnabled && ( + { + storeCuratedPrompt(prompt) + router.push(`/workspace/${workspaceId}/home`) + }} + className='absolute right-0 bottom-0 mx-0' + > + Explore in chat + + )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index f34ed657dbc..0f014c6a00d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -21,11 +21,7 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { - EMPTY_CELL_PLACEHOLDER, - FloatingOverflowText, - Resource, -} from '@/app/workspace/[workspaceId]/components' +import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components' import { ChunkContextMenu, ChunkEditor, @@ -948,13 +944,9 @@ export function Document({ cells: { content: { content: ( - + - +
), }, index: { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index f5f988ec658..c89a42a609f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -18,9 +18,12 @@ import { chipContentLabelClass, chipVariants, cn, + FloatingTooltip, + isTextClipped, Loader, Tooltip, Trash, + useFloatingTooltip, } from '@sim/emcn' import { Database, DatabaseX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -177,6 +180,39 @@ interface TagValue { value: string } +/** + * Tags cell for the documents table. Shows the joined tag values inline and + * reveals the full `name: value` breakdown only when the inline text is + * actually clipped — an un-truncated cell already says everything the tooltip + * would. + */ +function DocumentTagsCell({ tags }: { tags: TagValue[] }) { + const { state, handlers } = useFloatingTooltip(isTextClipped) + + return ( + <> + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + {...handlers} + > + {tags.map((tag) => tag.value).join(', ')} + + +
+ {tags.map((tag) => ( +
+ {tag.displayName}: {tag.value} +
+ ))} +
+
+ + ) +} + /** * Computes tag values for a document */ @@ -1057,7 +1093,6 @@ export function KnowledgeBase({ const DocIcon = ConnectorIcon || getDocumentIcon(doc.mimeType, doc.filename) const tags = getDocumentTags(doc, tagDefinitions) - const tagsDisplayText = tags.map((t) => t.value).join(', ') const statusCell: ResourceCell = doc.processingStatus === 'failed' && doc.processingError @@ -1076,34 +1111,7 @@ export function KnowledgeBase({ : { content: getStatusBadge(doc) } const tagsCell: ResourceCell = - tags.length === 0 - ? { label: null } - : { - content: ( - - - e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {tagsDisplayText} - - - -
- {tags.map((tag) => ( -
- {tag.displayName}:{' '} - {tag.value} -
- ))} -
-
-
- ), - } + tags.length === 0 ? { label: null } : { content: } return { id: doc.id, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 3dd9a4a9d87..5a353e7c0f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -31,6 +31,7 @@ import { createPortal } from 'react-dom' import type { WorkflowLogRow } from '@/lib/api/contracts/logs' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' @@ -397,7 +398,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP * mothership-triggered logs are excluded — `isLikelyExecution` already encodes * "has an executionId and isn't a mothership run". */ - const canTroubleshoot = log.status === 'failed' && isLikelyExecution + const canTroubleshoot = isChatEnabled && log.status === 'failed' && isLikelyExecution /** * Hands the failed run to Chat. When a chat is already mounted (e.g. the run diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 269993d45bd..f161f672f78 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -33,6 +33,7 @@ import type { WorkflowLogSummary, } from '@/lib/api/contracts/logs' import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { formatDateShort } from '@/lib/core/utils/date-display' import { getEndDateFromTimeRange, getStartDateFromTimeRange, @@ -91,7 +92,6 @@ import { DELETED_WORKFLOW_LABEL, extractRetryInput, formatDate, - formatDateShort, getDisplayStatus, type LogStatus, parseDuration, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 01d336f082a..cadb003f19a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -188,31 +188,6 @@ export function formatLatency(ms: number): string { return formatDuration(ms, { precision: 2 }) ?? '—' } -export function formatDateShort(dateStr: string): string { - const hasTime = dateStr.includes('T') - const [datePart, timePart] = dateStr.split('T') - const [, month, day] = datePart.split('-').map(Number) - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ] - const dateLabel = `${months[month - 1]} ${day}` - if (hasTime && timePart) { - return `${dateLabel} ${timePart.slice(0, 5)}` - } - return dateLabel -} - export const formatDate = (dateString: string) => { const date = new Date(dateString) return { diff --git a/apps/sim/app/workspace/[workspaceId]/not-found.tsx b/apps/sim/app/workspace/[workspaceId]/not-found.tsx index 43dbfbaea36..db69e38864d 100644 --- a/apps/sim/app/workspace/[workspaceId]/not-found.tsx +++ b/apps/sim/app/workspace/[workspaceId]/not-found.tsx @@ -10,7 +10,7 @@ import { ErrorShell } from '@/app/workspace/[workspaceId]/components' export default function WorkspaceNotFound() { const router = useRouter() const { workspaceId } = useParams<{ workspaceId?: string }>() - const homeHref = workspaceId ? `/workspace/${workspaceId}/home` : '/' + const homeHref = workspaceId ? `/workspace/${workspaceId}` : '/' return ( }) { const { workspaceId } = await params - redirect(`/workspace/${workspaceId}/home`) + redirect(`/workspace/${workspaceId}/${isChatEnabled ? 'home' : 'w'}`) } diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 809cae87686..e4c372eb603 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,6 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { listFoldersForWorkspace } from '@/lib/folders/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' @@ -73,14 +74,18 @@ export async function prefetchWorkspaceSidebar( }, staleTime: WORKFLOW_LIST_STALE_TIME, }), - queryClient.prefetchQuery({ - queryKey: mothershipChatKeys.list(workspaceId, 'active'), - queryFn: async () => { - const data = await listMothershipChats(userId, workspaceId) - return data.map(mapChat) - }, - staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, - }), + ...(isChatEnabled + ? [ + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await listMothershipChats(userId, workspaceId) + return data.map(mapChat) + }, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }), + ] + : []), queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active', 'workflow'), queryFn: async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx index 38da2e2294d..b078ff176db 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx @@ -1,5 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { isChatEnabled } from '@/lib/core/config/env-flags' import ScheduledTasksLoading from '@/app/workspace/[workspaceId]/scheduled-tasks/loading' import { ScheduledTasks } from './scheduled-tasks' @@ -14,6 +16,10 @@ export const metadata: Metadata = { * so a suspend never shows a blank frame. */ export default function ScheduledTasksPage() { + // The calendar only surfaces mothership prompt jobs, so with Chat off there is + // nothing this page could ever show. + if (!isChatEnabled) notFound() + return ( }> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx index 320e41f16ff..2c9f36ab1db 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -18,8 +18,8 @@ import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { UsageLogEntry, UsageLogPeriod } from '@/lib/api/contracts/user' import { formatApportionedCreditCost, formatCreditsLabel } from '@/lib/billing/credits/conversion' +import { formatDateShort } from '@/lib/core/utils/date-display' import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' -import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils' import { creditUsageParsers, creditUsageUrlKeys, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index feb46c11b97..df8930fe4ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -9,6 +9,7 @@ import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components' import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' @@ -226,7 +227,12 @@ export function RecentlyDeleted() { const tableFoldersQuery = useFolders(workspaceId, { scope: 'archived', resourceType: 'table' }) const filesQuery = useWorkspaceFiles(workspaceId, 'archived') const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived') - const chatsQuery = useMothershipChats(workspaceId, { scope: 'archived' }) + // Restoring a chat navigates to a route that 404s with Chat off, and this + // query's loading/error state feeds the whole panel's. + const chatsQuery = useMothershipChats(workspaceId, { + scope: 'archived', + enabled: isChatEnabled, + }) const restoreWorkflow = useRestoreWorkflow() const restoreFolder = useRestoreFolder() diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/remote-selection-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/remote-selection-overlay.tsx new file mode 100644 index 00000000000..5461082f3de --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/remote-selection-overlay.tsx @@ -0,0 +1,312 @@ +'use client' + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { getUserColor, withAlpha } from '@/lib/workspaces/colors' +import { + isCellInSelection, + type NormalizedSelection, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils' +import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' + +/** A measured remote selection, positioned in the grid content wrapper's space. */ +interface SelectionBox { + socketId: string + userName: string + color: string + editing: boolean + top: number + left: number + width: number + height: number + /** Viewport-space top/left of the selection, for the body-portaled name label. */ + viewportTop: number + viewportLeft: number + /** Resolved anchor/focus cell indices (undefined when off-window). Coverage by the local + * selection is derived from these in render (see {@link isSelectionCovered}) — no DOM + * re-measure when only the local caret moves. */ + anchorRow: number | undefined + anchorCol: number | undefined + focusRow: number | undefined + focusCol: number | undefined +} + +interface RemoteSelectionOverlayProps { + remoteSelections: RemoteTableSelection[] + /** Column id → its rendered column index (matches the cells' `data-col`). */ + columnIndexById: Map + /** Row id → its index in the current row list, to test local-selection coverage. */ + rowIndexById: Map + /** The local user's own normalized selection, so a co-selected remote cell defers to it. */ + localSelection: NormalizedSelection | null + /** The grid's scroll container (`data-table-scroll`), queried for cell rects. */ + scrollElement: HTMLElement | null +} + +/** The cell `
` for a (rowId, columnIndex), or undefined when virtualized off-window. */ +function cellRect( + scrollEl: HTMLElement, + rowId: string, + columnIndex: number | undefined +): DOMRect | undefined { + if (columnIndex === undefined) return undefined + // `rowId` is a remote peer's value — escape it so a hostile id can't break the + // selector and throw (`columnIndex` is a local numeric index, already safe). + const cell = scrollEl.querySelector( + `[data-row-id="${CSS.escape(rowId)}"][data-col="${columnIndex}"]` + ) + return cell?.getBoundingClientRect() +} + +/** + * Whether a remote selection defers to the local one: true only when the local selection + * fully contains it (both corners inside), so a partial overlap still shows. + */ +function isSelectionCovered( + anchorRow: number | undefined, + anchorCol: number | undefined, + focusRow: number | undefined, + focusCol: number | undefined, + bounds: NormalizedSelection | null +): boolean { + return ( + bounds !== null && + isCellInSelection(anchorRow, anchorCol, bounds) && + isCellInSelection(focusRow, focusCol, bounds) + ) +} + +/** + * Renders remote collaborators' cell selections over the table grid — a colored + * border per user (Google-Sheets style), a darker fill while they are editing, and + * their name on hover. Mounted inside the grid's `relative` content wrapper, so + * content-space coordinates scroll with the grid automatically. + * + * Positions are measured from the live cell rects (the same `[data-row-id][data-col]` + * idiom the reveal effect uses), keyed by stable ids so each client renders under its + * own sort/scroll. A selection whose rows are virtualized off-window is simply not + * drawn. The layer is `pointer-events-none` so it never intercepts cell clicks; the + * name-on-hover is driven by hit-testing pointer moves against the measured boxes. + */ +export function RemoteSelectionOverlay({ + remoteSelections, + columnIndexById, + rowIndexById, + localSelection, + scrollElement, +}: RemoteSelectionOverlayProps) { + const rootRef = useRef(null) + const [boxes, setBoxes] = useState([]) + const [hoveredSocketId, setHoveredSocketId] = useState(null) + + // Latest data read by the subscribe-once effect + the pointer hit-test, so neither + // re-subscribes on every incoming selection delta. + const boxesRef = useRef([]) + boxesRef.current = boxes + const remoteSelectionsRef = useRef(remoteSelections) + remoteSelectionsRef.current = remoteSelections + const columnIndexByIdRef = useRef(columnIndexById) + columnIndexByIdRef.current = columnIndexById + const rowIndexByIdRef = useRef(rowIndexById) + rowIndexByIdRef.current = rowIndexById + // Read only by the pointer hit-test (never in render) to skip a locally-covered box. + const localSelectionRef = useRef(localSelection) + localSelectionRef.current = localSelection + // Cached content-wrapper origin, refreshed on each measure (scroll/resize/data change), + // so the pointer hit-test never forces a layout read per mouse move. + const originRef = useRef({ top: 0, left: 0 }) + + const measure = useCallback(() => { + const scrollEl = scrollElement + const root = rootRef.current + if (!scrollEl || !root) return + const origin = root.getBoundingClientRect() + originRef.current = { top: origin.top, left: origin.left } + const next: SelectionBox[] = [] + for (const selection of remoteSelectionsRef.current) { + const { anchor, focus, editing } = selection.cell + const anchorCol = columnIndexByIdRef.current.get(anchor.columnId) + const focusCol = columnIndexByIdRef.current.get(focus.columnId) + const anchorRow = rowIndexByIdRef.current.get(anchor.rowId) + const focusRow = rowIndexByIdRef.current.get(focus.rowId) + const rects = [ + cellRect(scrollEl, anchor.rowId, anchorCol), + cellRect(scrollEl, focus.rowId, focusCol), + ].filter((rect): rect is DOMRect => rect !== undefined) + if (rects.length === 0) continue + + const viewportTop = Math.min(...rects.map((r) => r.top)) + const viewportLeft = Math.min(...rects.map((r) => r.left)) + const top = viewportTop - origin.top + const left = viewportLeft - origin.left + const bottom = Math.max(...rects.map((r) => r.bottom)) - origin.top + const right = Math.max(...rects.map((r) => r.right)) - origin.left + next.push({ + socketId: selection.socketId, + userName: selection.userName, + color: getUserColor(selection.userId), + editing: editing === true, + top, + left, + width: right - left, + height: bottom - top, + viewportTop, + viewportLeft, + anchorRow, + anchorCol, + focusRow, + focusCol, + }) + } + setBoxes(next) + }, [scrollElement]) + + // Subscribe once per scroll element: re-measure on scroll/resize, and hit-test pointer + // moves against the cached boxes/origin — no layout read per move, stays pointer-events-none. + useEffect(() => { + const scrollEl = scrollElement + if (!scrollEl) return + + let raf = 0 + const schedule = () => { + if (!raf) + raf = requestAnimationFrame(() => { + raf = 0 + measure() + }) + } + const handleMove = (event: PointerEvent) => { + const { top, left } = originRef.current + const x = event.clientX - left + const y = event.clientY - top + const hit = boxesRef.current.find( + (b) => + x >= b.left && + x <= b.left + b.width && + y >= b.top && + y <= b.top + b.height && + // Skip a box the local selection covers — it isn't drawn, so hovering it must not + // pop a name tag over a cell with no visible remote selection. + !isSelectionCovered( + b.anchorRow, + b.anchorCol, + b.focusRow, + b.focusCol, + localSelectionRef.current + ) + ) + setHoveredSocketId((prev) => + prev === (hit?.socketId ?? null) ? prev : (hit?.socketId ?? null) + ) + } + const handleLeave = () => setHoveredSocketId(null) + + // No measure() here — the re-measure layout effect below runs on mount and whenever + // `measure` changes (it depends on `scrollElement`), so it already covers the initial + // and scroll-element-changed measures without a redundant pass. + scrollEl.addEventListener('scroll', schedule, { passive: true }) + scrollEl.addEventListener('pointermove', handleMove, { passive: true }) + scrollEl.addEventListener('pointerleave', handleLeave) + const resizeObserver = new ResizeObserver(schedule) + resizeObserver.observe(scrollEl) + // Also observe the content layer (this overlay fills it): a column resize or a + // row-count change grows/shrinks the content without resizing the scroll container, + // yet moves cell rects — so measure off the content, not just the viewport. + if (rootRef.current) resizeObserver.observe(rootRef.current) + // Re-measure when rows are added/removed/reordered/virtualized (a live refetch moves + // cells without a scroll/resize) — childList only, so a cell-content edit doesn't fire. + const tbody = scrollEl.querySelector('tbody') + const rowObserver = new MutationObserver(schedule) + if (tbody) rowObserver.observe(tbody, { childList: true }) + + return () => { + scrollEl.removeEventListener('scroll', schedule) + scrollEl.removeEventListener('pointermove', handleMove) + scrollEl.removeEventListener('pointerleave', handleLeave) + resizeObserver.disconnect() + rowObserver.disconnect() + if (raf) cancelAnimationFrame(raf) + } + }, [scrollElement, measure]) + + // Re-measure when the remote selections or column layout change (listeners stay + // subscribed). Layout effect so positions update before paint — no one-frame lag as a + // peer moves. NOT keyed on `localSelection`: moving the local caret changes only which + // boxes are `covered`, which the cheap in-memory pass below handles without a reflow. + useLayoutEffect(() => { + measure() + }, [remoteSelections, columnIndexById, measure]) + + // Re-derived in render so it reacts to `localSelection`: when the local selection grows to + // cover the hovered box (its outline is no longer drawn) without another pointer move, the + // floating name tag must drop rather than linger over cells with no visible remote selection. + const hoveredBox = hoveredSocketId + ? boxes.find( + (box) => + box.socketId === hoveredSocketId && + !isSelectionCovered( + box.anchorRow, + box.anchorCol, + box.focusRow, + box.focusCol, + localSelection + ) + ) + : undefined + + return ( + <> +
+ {boxes.map((box) => + // A cell the local user also has selected shows only the local selection — the + // remote box isn't drawn (its `boxes` entry still drives the hover name). The + // border is an inset box-shadow (no layout width, so it never stacks with an + // adjacent cell's border) plus a subtle fill, darker while the peer is editing. + isSelectionCovered( + box.anchorRow, + box.anchorCol, + box.focusRow, + box.focusCol, + localSelection + ) ? null : ( +
+ ) + )} +
+ {/* The name label portals to the body so it floats on top of the grid (and its + sticky header) instead of being clipped by the overlay's overflow-hidden; it's + placed in viewport space, its bottom-left tabbed onto the selection's top-left. */} + {hoveredBox && + createPortal( + // Same chrome as the workflow-canvas presence label (see cursors.tsx): the + // identity color is the only per-user value; text color, font, radius, and + // padding all reuse the canvas tokens so tables + canvas presence look identical. + // `rounded-bl-none` tabs the label's bottom-left corner onto the selection's + // top-left, the one deviation from the free-floating canvas cursor tag. +
+ {hoveredBox.userName} +
, + document.body + )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index e2b8ef12aa7..da0f38bc8dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { cn, toast, useToast } from '@sim/emcn' import { Loader, TableX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import type { TableCellSelection } from '@sim/realtime-protocol/table-presence' import { getErrorMessage } from '@sim/utils/errors' import { useVirtualizer } from '@tanstack/react-virtual' import { useParams } from 'next/navigation' @@ -23,6 +24,7 @@ import { getColumnId } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' import { useTimezone } from '@/hooks/queries/general-settings' import { @@ -53,6 +55,7 @@ import { ExpandedCellPopover } from './cells' import { ADD_COL_WIDTH, COL_WIDTH, SELECTION_TINT_BG } from './constants' import { DataRow } from './data-row' import { ColumnHeaderMenu, WorkflowGroupMetaCell } from './headers' +import { RemoteSelectionOverlay } from './remote-selection-overlay' import { TableFind } from './table-find' import { AddRowButton, SelectAllCheckbox, TableColGroup } from './table-primitives' import type { DisplayColumn } from './types' @@ -65,6 +68,7 @@ import { computeNormalizedSelection, type ExecStatusMix, expandToDisplayColumns, + isCellInSelection, moveCell, ROW_SELECTION_ALL, ROW_SELECTION_NONE, @@ -149,6 +153,10 @@ interface TableGridProps { workspaceId?: string tableId?: string embedded?: boolean + /** Remote collaborators' cell selections, rendered as presence overlays. */ + remoteSelections: RemoteTableSelection[] + /** Broadcast the local viewer's cell selection to the table presence room. */ + emitCellSelection: (cell: TableCellSelection) => void /** The table's mutation locks; gates row/schema affordances alongside `canEdit`. */ locks?: TableLocks /** @@ -365,6 +373,8 @@ export function TableGrid({ workspaceId: propWorkspaceId, tableId: propTableId, embedded, + remoteSelections, + emitCellSelection, locks, onBlockedAction, sidebarReservedPx, @@ -432,6 +442,13 @@ export function TableGrid({ const columnWidthsRef = useRef(columnWidths) columnWidthsRef.current = columnWidths const [resizingColumn, setResizingColumn] = useState(null) + const resizingColumnRef = useRef(resizingColumn) + resizingColumnRef.current = resizingColumn + /** True from a committed local width change until its metadata PUT settles. Keeps a + * concurrent peer-triggered definition refetch (the value-less `metadata` event forces a + * refetch that can carry the not-yet-persisted server widths) from momentarily reverting + * the just-written widths. */ + const pendingWidthWriteRef = useRef(false) const [columnOrder, setColumnOrder] = useState(null) const columnOrderRef = useRef(columnOrder) columnOrderRef.current = columnOrder @@ -797,6 +814,24 @@ export function TableGrid({ return expandToDisplayColumns(ordered, tableWorkflowGroups) }, [columns, columnOrder, hiddenColumns, tableWorkflowGroups]) + /** Column id → its rendered index (matches the cells' `data-col`), for placing overlays. + * Only built when collaborators are present (the overlay it feeds is gated on that too), + * so solo editing never pays the map build. */ + const columnIndexById = useMemo(() => { + const map = new Map() + if (remoteSelections.length > 0) displayColumns.forEach((col, index) => map.set(col.key, index)) + return map + }, [displayColumns, remoteSelections.length]) + + /** Row id → its index in the current row list, for testing local-selection coverage. + * Only built when collaborators are present (the overlay is gated on that too), so + * solo editing never pays the O(n) map build on a refetch. */ + const rowIndexById = useMemo(() => { + const map = new Map() + if (remoteSelections.length > 0) rows.forEach((row, index) => map.set(row.id, index)) + return map + }, [rows, remoteSelections.length]) + const workflowGroupById = useMemo( () => new Map(tableWorkflowGroups.map((g) => [g.id, g])), [tableWorkflowGroups] @@ -971,6 +1006,30 @@ export function TableGrid({ ? (rowsRef.current[selectionFocus.rowIndex]?.id ?? null) : null + // Broadcast the local viewer's cell selection to the presence room. Resolves the + // index-based selection to stable (rowId, columnId), re-running on `rows`/`displayColumns` + // too so a peer's row insert/delete/reorder re-broadcasts the shifted id under the same + // index (the emitter dedups an unchanged result). `editing` marks the active cell so + // peers darken it (the "someone is typing here" signal). + useEffect(() => { + const resolve = (coord: CellCoord | null) => { + if (!coord) return null + const rowId = rows[coord.rowIndex]?.id + const columnId = displayColumns[coord.colIndex]?.key + return rowId && columnId ? { rowId, columnId } : null + } + const anchor = resolve(selectionAnchor) + if (!anchor) { + emitCellSelection(null) + return + } + // A single-cell click leaves `selectionFocus` null; the grid treats that as a + // one-cell selection at the anchor (`focus ?? anchor`). Mirror that — otherwise the + // most common selection would never broadcast and would clear the prior outline. + const focus = resolve(selectionFocus) ?? anchor + emitCellSelection({ anchor, focus, editing: editingCell !== null }) + }, [selectionAnchor, selectionFocus, editingCell, rows, displayColumns, emitCellSelection]) + const { data: findData, isFetching: isFindFetching } = useFindTableRows({ workspaceId, tableId, @@ -1341,12 +1400,7 @@ export function TableGrid({ selectionAnchorRef.current, selectionFocusRef.current ) - const isWithinSelection = - sel !== null && - rowIndex >= sel.startRow && - rowIndex <= sel.endRow && - colIndex >= sel.startCol && - colIndex <= sel.endCol + const isWithinSelection = sel !== null && isCellInSelection(rowIndex, colIndex, sel) if (!isWithinSelection) { setSelectionAnchor({ rowIndex, colIndex }) @@ -1563,7 +1617,11 @@ export function TableGrid({ const handleColumnResizeEnd = useCallback(() => { setResizingColumn(null) - updateMetadataRef.current({ columnWidths: columnWidthsRef.current }) + pendingWidthWriteRef.current = true + updateMetadataRef.current( + { columnWidths: columnWidthsRef.current }, + { onSettled: () => (pendingWidthWriteRef.current = false) } + ) }, []) const handleColumnAutoResize = useCallback((columnKey: string) => { @@ -1616,7 +1674,11 @@ export function TableGrid({ setColumnWidths((prev) => ({ ...prev, [columnKey]: newWidth })) const updated = { ...columnWidthsRef.current, [columnKey]: newWidth } columnWidthsRef.current = updated - updateMetadataRef.current({ columnWidths: updated }) + pendingWidthWriteRef.current = true + updateMetadataRef.current( + { columnWidths: updated }, + { onSettled: () => (pendingWidthWriteRef.current = false) } + ) }, []) const handleColumnDragStart = useCallback((columnName: string) => { @@ -1921,28 +1983,45 @@ export function TableGrid({ return } if (!source) return - // After first load: only re-seed `columnOrder` when the *set of columns* - // changes (e.g. a workflow group adds/removes outputs server-side). Pure - // reorders are left alone so an in-flight optimistic drag isn't clobbered - // by a refetch returning the pre-drag order. + // After first load a collaborator (or our own committed edit) reshaped the layout. + // Re-apply it live from the active layout source (a view's config when one is active, + // else the table's own metadata), but never clobber the gesture the local user is + // mid-way through — their in-progress value leads the server's. Each field is guarded + // by reference: React Query structural sharing keeps an unchanged sub-object + // referentially stable, so an unrelated change (e.g. a peer's pin) doesn't re-apply + // widths/order. + // Width: keep the column being actively resized on its live local value. + const serverWidths = source.columnWidths + if (serverWidths && serverWidths !== columnWidthsRef.current) { + const resizing = resizingColumnRef.current + const localWidth = resizing ? columnWidthsRef.current[resizing] : undefined + if (resizing && localWidth !== undefined) { + setColumnWidths({ ...serverWidths, [resizing]: localWidth }) + } else if (!pendingWidthWriteRef.current) { + setColumnWidths(serverWidths) + } + // else: a just-committed local width write is still in flight — local leads until + // its onSettled invalidation brings back the server's committed (merged) widths. + } + // Pins toggle instantly (no in-progress gesture) — apply on change. + const serverPins = source.pinnedColumns + if (serverPins && serverPins !== pinnedColumnsRef.current) { + setPinnedColumns(serverPins) + } + // Order: apply the server order live (a peer reorder or our own committed edit), + // unless a local column drag is in flight (an optimistic reorder would otherwise be + // reverted to the pre-drag order the refetch returns). Preserve our own just-appended + // ids whose patch is still in flight by appending them — `viewLayout` gets a new + // identity on every save, so a refetch/view-save predating the append must not drop + // them; `displayColumns` harmlessly skips any id with no matching column. const serverOrder = source.columnOrder - if (serverOrder) { + if (serverOrder && serverOrder !== columnOrderRef.current && !dragColumnNameRef.current) { const localOrder = columnOrderRef.current if (!localOrder) { setColumnOrder(serverOrder) } else { - // Re-seed only when the server knows an id the local order lacks — a real - // schema change (a workflow group gained outputs). Ids present locally but - // NOT on the server are our own just-appended columns whose patch is still - // in flight: `viewLayout` gets a new identity on every save, so a refetch - // carrying the pre-append order would otherwise roll them back, and the - // append effect can't re-fire because `columns` is unchanged. Ids the - // server drops stay in the local order harmlessly — `displayColumns` - // skips any id with no matching column. - const localSet = new Set(localOrder) - if (serverOrder.some((id) => !localSet.has(id))) { - setColumnOrder(serverOrder) - } + const localOnly = localOrder.filter((id) => !serverOrder.includes(id)) + setColumnOrder(localOnly.length > 0 ? [...serverOrder, ...localOnly] : serverOrder) } } }, [tableData?.metadata, viewLayout, viewLayoutKey]) @@ -4253,6 +4332,15 @@ export function TableGrid({ })()}
+ {remoteSelections.length > 0 && ( + + )} {resizingColumn && (
= sel.startRow && + row <= sel.endRow && + col >= sel.startCol && + col <= sel.endCol + ) +} + /** A run of consecutive `displayColumns` rendered together in the meta header row. */ export type HeaderGroup = | { kind: 'plain'; size: 1; startColIndex: number } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts index 76addf44c49..190e846254b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts @@ -1,3 +1,4 @@ export * from './use-context-menu' export * from './use-table' export * from './use-table-event-stream' +export * from './use-table-room' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index 67f8a2b54c6..6d7fa21927e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -379,14 +379,38 @@ export function useTableEventStream({ else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event) else if (entry.event?.kind === 'job') applyJob(entry.event) else if (entry.event?.kind === 'usageLimitReached') applyUsageLimit(entry.event) + // A collaborator's manual edit: refetch rows (debounced) so the winning + // last-write value shows live, in this client's own wire format. + else if (entry.event?.kind === 'edit') scheduleRowsInvalidate() + // A collaborator changed the table structure: mirror the local + // invalidateTableSchema set — the definition (exact, so rows stay on the + // debounce), the run-state + enrichment sibling queries under detail (a group + // delete/restructure can otherwise leave a stale running badge or enrichment + // panel), the tables list (column/row counts), and the debounced rows. + else if (entry.event?.kind === 'schema') { + void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) }) + void queryClient.invalidateQueries({ queryKey: tableKeys.enrichmentDetails(tableId) }) + void queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + scheduleRowsInvalidate() + } + // A collaborator changed the column layout (width/pin/order): refetch the + // definition alone (it carries the metadata) — the grid re-applies it without + // a rows refetch. Exact, so rows/run-state stay put. + else if (entry.event?.kind === 'metadata') { + void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + } + // A collaborator toggled a table lock: re-read the definition so every open + // viewer's gating updates. `exact` avoids refetching every rows page + // (rowsRoot nests under detail); no row data changed. else if (entry.event?.kind === 'definition') { - // A lock/schema change on the definition — re-read it so every open - // viewer's gating updates. `exact` avoids refetching every rows page - // (rowsRoot nests under detail); no row data changed. - void queryClient.invalidateQueries({ - queryKey: tableKeys.detail(tableId), - exact: true, - }) + void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + } + // A collaborator changed the table's shared saved views (create/rename/delete/ + // re-save): refetch the views list alone. Views are presentation state layered on + // the already-loaded table, so no rows/definition refetch is needed. + else if (entry.event?.kind === 'views') { + void queryClient.invalidateQueries({ queryKey: tableKeys.views(tableId) }) } } catch (err) { logger.warn('Failed to parse table event', { tableId, err }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room.ts new file mode 100644 index 00000000000..df950dab585 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room.ts @@ -0,0 +1,233 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { presenceEventName, ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { + type JoinTableError, + type JoinTableSuccess, + TABLE_PRESENCE_EVENTS, + type TableCellSelection, + type TableCellSelectionBroadcast, + type TablePresenceUser, +} from '@sim/realtime-protocol/table-presence' +import { generateShortId } from '@sim/utils/id' +import type { PresenceAvatarUser } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars' +import { useSocket } from '@/app/workspace/providers/socket-provider' + +const logger = createLogger('TableRoom') + +/** Retry cap + base delay for a retryable join failure on an otherwise-live socket. */ +const MAX_JOIN_RETRIES = 3 +const JOIN_RETRY_BASE_MS = 1000 +/** Trailing-throttle window for broadcasting local selection changes (smooths drag-select). */ +const SELECTION_EMIT_THROTTLE_MS = 50 + +/** The `table:presence-update` broadcast name, derived from the room type. */ +const TABLE_PRESENCE_UPDATE_EVENT = presenceEventName(ROOM_TYPES.TABLE) + +/** A remote viewer's current cell selection, ready to render as a presence overlay. */ +export interface RemoteTableSelection { + socketId: string + userId: string + userName: string + cell: NonNullable +} + +interface UseTableRoomResult { + /** Collaborators viewing this table, excluding the current socket (for avatars). */ + otherUsers: PresenceAvatarUser[] + /** Remote viewers that currently have a cell selected (for overlays). */ + remoteSelections: RemoteTableSelection[] + /** Broadcast the local viewer's current cell selection (`null` clears it). Throttled. */ + emitCellSelection: (cell: TableCellSelection) => void +} + +/** + * Joins the table presence room for live collaborator avatars + cell-selection + * highlights. Presence rides the shared socket (`useSocket`); table data is + * unchanged (it flows through the one-way durable event stream). The full roster + * arrives via the presence broadcast (join/leave); individual selection moves + * arrive as lower-latency {@link TABLE_PRESENCE_EVENTS.CELL_SELECTION} deltas that + * patch the matching roster entry. + */ +export function useTableRoom(tableId: string): UseTableRoomResult { + const { socket, currentSocketId } = useSocket() + + const [presenceUsers, setPresenceUsers] = useState([]) + + const tabSessionIdRef = useRef('') + if (!tabSessionIdRef.current) tabSessionIdRef.current = generateShortId() + + // The local viewer's current selection, re-broadcast on (re)join. Emits only fire on a + // selection change, and the server drops a CELL_SELECTION for a socket not yet in the + // room — so a selection made before the join completes (or held across a reconnect) + // would otherwise never reach peers until it next changes. + const currentCellRef = useRef(null) + + useEffect(() => { + if (!socket || !tableId) return + + let retries = 0 + let retryTimer: ReturnType | null = null + + const join = () => { + socket.emit(TABLE_PRESENCE_EVENTS.JOIN, { tableId, tabSessionId: tabSessionIdRef.current }) + } + + // A fresh (re)connect gets a fresh retry budget, so a prior full exhaustion doesn't leave the + // socket unable to retry a failed re-join until the next success. + const handleConnect = () => { + retries = 0 + join() + } + + const handleJoinSuccess = (data: JoinTableSuccess) => { + if (data.tableId !== tableId) return + retries = 0 + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + setPresenceUsers(data.presenceUsers ?? []) + // Re-send our current selection now that the room is joined (earlier emits were + // dropped server-side), so peers see it without waiting for the next change. + if (currentCellRef.current) { + socket.emit(TABLE_PRESENCE_EVENTS.CELL_SELECTION, { cell: currentCellRef.current }) + } + } + const handleJoinError = (data: JoinTableError) => { + if (data.tableId !== tableId) return + logger.warn('Failed to join table room', { code: data.code, error: data.error }) + if (data.retryable && retries < MAX_JOIN_RETRIES) { + retries += 1 + // Clear any still-pending retry before scheduling a new one, so reconnect churn can't + // orphan a timer that fires an extra join(). + if (retryTimer) clearTimeout(retryTimer) + retryTimer = setTimeout(join, JOIN_RETRY_BASE_MS * retries) + } + } + const handlePresence = (users: TablePresenceUser[]) => { + // Take membership from the roster snapshot but keep the `cell` we already hold for + // a known socket. Trade-off: a snapshot can lag the lower-latency CELL_SELECTION + // deltas, so a blind replace could revert a fresher selection (the common case). + // The cost is that a *dropped* delta is no longer healed by the next snapshot, only + // by the peer's next delta — fine, since deltas flow continuously during selection + // and a cleared selection also sends `null` via delta. + setPresenceUsers((prev) => { + const cellBySocket = new Map(prev.map((user) => [user.socketId, user.cell])) + return (users ?? []).map((user) => + cellBySocket.has(user.socketId) + ? { ...user, cell: cellBySocket.get(user.socketId) } + : user + ) + }) + } + const handleCellSelection = (data: TableCellSelectionBroadcast) => { + // Patch the matching roster entry's selection. The peer is always already in + // the roster: the server broadcasts their join (→ presence-update) before they + // can select, and Socket.IO preserves that order — so a delta for an unknown + // socket only means a dropped broadcast, which the next presence-update heals. + setPresenceUsers((prev) => + prev.map((user) => (user.socketId === data.socketId ? { ...user, cell: data.cell } : user)) + ) + } + + // Join now if the socket is already connected; `connect` covers (re)connects. + if (socket.connected) join() + socket.on('connect', handleConnect) + socket.on(TABLE_PRESENCE_EVENTS.JOIN_SUCCESS, handleJoinSuccess) + socket.on(TABLE_PRESENCE_EVENTS.JOIN_ERROR, handleJoinError) + socket.on(TABLE_PRESENCE_UPDATE_EVENT, handlePresence) + socket.on(TABLE_PRESENCE_EVENTS.CELL_SELECTION, handleCellSelection) + + return () => { + if (retryTimer) clearTimeout(retryTimer) + socket.off('connect', handleConnect) + socket.off(TABLE_PRESENCE_EVENTS.JOIN_SUCCESS, handleJoinSuccess) + socket.off(TABLE_PRESENCE_EVENTS.JOIN_ERROR, handleJoinError) + socket.off(TABLE_PRESENCE_UPDATE_EVENT, handlePresence) + socket.off(TABLE_PRESENCE_EVENTS.CELL_SELECTION, handleCellSelection) + setPresenceUsers([]) + // Leave scoped to THIS table so a table A→B switch (B joins first, auto-leaving + // A) can't have A's deferred leave evict the fresh B membership. + socket.emit(TABLE_PRESENCE_EVENTS.LEAVE, { tableId }) + } + }, [socket, tableId]) + + const socketRef = useRef(socket) + socketRef.current = socket + /** Presence is disabled when no table id is bound (e.g. the embedded Chat panel surface). */ + const enabledRef = useRef(false) + enabledRef.current = Boolean(tableId) + const lastEmitRef = useRef(0) + const trailingTimerRef = useRef | null>(null) + const pendingCellRef = useRef(null) + /** Key of the last selection sent, to skip re-emitting an unchanged selection. */ + const lastSentKeyRef = useRef(null) + + // Reset the throttle when the table changes (or on unmount): a pending selection for + // the table we're leaving must not flush into the next table's room after a switch. + useEffect( + () => () => { + if (trailingTimerRef.current) { + clearTimeout(trailingTimerRef.current) + trailingTimerRef.current = null + } + pendingCellRef.current = null + currentCellRef.current = null + lastSentKeyRef.current = null + lastEmitRef.current = 0 + }, + [tableId] + ) + + const emitCellSelection = useCallback((cell: TableCellSelection) => { + // No room joined (empty tableId, e.g. embedded mode) — never broadcast; the server + // would drop it anyway. Local selection UI is unaffected (grid-owned state). + if (!enabledRef.current) return + // Skip re-emitting an unchanged selection: the caller re-resolves on every data + // refetch (so a peer's row insert re-broadcasts the shifted rowId), but most refetches + // don't move the selection — dedup those, and the no-selection state on table open. + const key = cell === null ? 'null' : JSON.stringify(cell) + if (key === lastSentKeyRef.current) return + lastSentKeyRef.current = key + currentCellRef.current = cell + pendingCellRef.current = cell + const flush = () => { + lastEmitRef.current = Date.now() + trailingTimerRef.current = null + socketRef.current?.emit(TABLE_PRESENCE_EVENTS.CELL_SELECTION, { + cell: pendingCellRef.current, + }) + } + const elapsed = Date.now() - lastEmitRef.current + if (elapsed >= SELECTION_EMIT_THROTTLE_MS) { + flush() + } else if (!trailingTimerRef.current) { + trailingTimerRef.current = setTimeout(flush, SELECTION_EMIT_THROTTLE_MS - elapsed) + } + }, []) + + const otherUsers = useMemo( + () => presenceUsers.filter((user) => user.socketId !== currentSocketId), + [presenceUsers, currentSocketId] + ) + const remoteSelections = useMemo( + () => + otherUsers + .filter( + (user): user is TablePresenceUser & { cell: NonNullable } => + user.cell != null + ) + .map((user) => ({ + socketId: user.socketId, + userId: user.userId, + userName: user.userName, + cell: user.cell, + })), + [otherUsers] + ) + + return { otherUsers, remoteSelections, emitCellSelection } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 553d032101f..13191a7347a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -28,6 +28,7 @@ import { Resource, type SortConfig, } from '@/app/workspace/[workspaceId]/components' +import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars' import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog' @@ -72,7 +73,7 @@ import { } from './components' import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants' import { columnTypeIcon } from './components/table-grid/headers' -import { useTable, useTableEventStream } from './hooks' +import { useTable, useTableEventStream, useTableRoom } from './hooks' import { type BlockedTableAction, describeBlockedAction, lockedNouns } from './lock-copy' import { ALL_VIEW_PARAM, @@ -230,6 +231,12 @@ export function Table({ } useTableEventStream({ tableId, workspaceId, onUsageLimitReached }) + // Live table presence (cell-selection carets + avatars). Runs in both modes so the + // mothership chat panel shows collaborators' live selections too. The avatar stack lives + // only in the `!embedded` Resource.Header, so the embedded panel gets carets without avatars + // for free — matching the panel's own-chrome layout. + const { otherUsers: presenceUsers, remoteSelections, emitCellSelection } = useTableRoom(tableId) + const [slideout, dispatch] = useReducer(slideoutReducer, { kind: 'none' }) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) const [showLockSettings, setShowLockSettings] = useState(false) @@ -1329,6 +1336,9 @@ export function Table({ breadcrumbs={breadcrumbs} aside={
+ {presenceUsers.length > 0 && ( + + )} {selection.totalRunning > 0 || selection.hasActiveDispatch ? ( { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 94eecb940bd..81a44336bbd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -48,6 +48,7 @@ import { TablesListContextMenu, } from '@/app/workspace/[workspaceId]/tables/components' import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu' +import { useWorkspaceTablesRoom } from '@/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room' import { tablesParsers, tablesSortParams, @@ -112,6 +113,10 @@ export function Tables() { const userPermissions = useUserPermissionsContext() const canEdit = userPermissions.canEdit === true + // Joined for the live tables list: a `workspace-tables-changed` broadcast (fanned out by the table + // mutation service) invalidates the list so this view refetches without waiting for staleness. + useWorkspaceTablesRoom(workspaceId) + const { data: tables = EMPTY_TABLES, error } = useTablesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedTableIds = usePinnedIds(workspaceId, 'table') diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx index af1e0f80f1e..d86ed09d2a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx @@ -1,15 +1,37 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { + buildHostedUpgradeUrl, + isUpgradeReason, + UPGRADE_REASON_PARAM, +} from '@/lib/billing/upgrade-reasons' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade' export const metadata: Metadata = { title: 'Upgrade' } export default async function UpgradePage({ params, + searchParams, }: { params: Promise<{ workspaceId: string }> + searchParams: Promise> }) { - const { workspaceId } = await params + const [{ workspaceId }, query] = await Promise.all([params, searchParams]) + + // Both are build constants, so resolve them here rather than mounting a page + // whose only job would be to navigate away. + if (!isHosted) { + const rawReason = query[UPGRADE_REASON_PARAM] + const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason + redirect(buildHostedUpgradeUrl(isUpgradeReason(reasonValue) ? reasonValue : undefined)) + } + + if (!isBillingEnabled) { + redirect(`/workspace/${workspaceId}/home`) + } + return ( }> diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 20e698d4a2c..d752241b550 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -16,7 +16,6 @@ import { import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants' import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' -import { isBillingEnabled } from '@/lib/core/config/env-flags' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { BillingPeriodToggle, @@ -70,26 +69,19 @@ export function Upgrade({ workspaceId }: UpgradeProps) { const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id) const handleBack = useCallback(() => { - router.replace(origin ?? `/workspace/${workspaceId}/home`) + router.replace(origin ?? `/workspace/${workspaceId}`) }, [origin, router, workspaceId]) - // Enterprise manages billing out-of-band, and self-hosted deployments with - // billing disabled have no plans to surface — redirect to home in both cases. + // Enterprise manages billing out-of-band, so there is no plan to pick here. + // The self-hosted and billing-disabled cases are build constants, not reactive + // state — page.tsx resolves those before this ever mounts. useEffect(() => { - if (!isBillingEnabled) { - router.replace(`/workspace/${workspaceId}/home`) - return - } if (canManageBilling && !state.isLoading && state.subscription.isEnterprise) { - router.replace(`/workspace/${workspaceId}/home`) + router.replace(`/workspace/${workspaceId}`) } }, [canManageBilling, state.isLoading, state.subscription.isEnterprise, router, workspaceId]) - if ( - !isBillingEnabled || - state.isLoading || - (canManageBilling && state.subscription.isEnterprise) - ) { + if (state.isLoading || (canManageBilling && state.subscription.isEnterprise)) { return null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 466224ba63f..46910e0a005 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -24,7 +24,7 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' -import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' +import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style' import type { SubBlockConfig } from '@/blocks/types' import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 1a997731fd8..5b16f42ee9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -40,6 +40,7 @@ import { import { getWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' import { useSession } from '@/lib/auth/auth-client' import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -122,7 +123,12 @@ export const Panel = memo(function Panel() { const panelRef = useRef(null) const fileInputRef = useRef(null) - const { activeTab, setActiveTab, _hasHydrated, setHasHydrated } = usePanelStore( + const { + activeTab: storedActiveTab, + setActiveTab, + _hasHydrated, + setHasHydrated, + } = usePanelStore( useShallow((state) => ({ activeTab: state.activeTab, setActiveTab: state.setActiveTab, @@ -146,6 +152,16 @@ export const Panel = memo(function Panel() { // Hooks const userPermissions = useUserPermissionsContext() const { config: permissionConfig } = usePermissionConfig() + + /** + * The Chat tab is hidden when the deployment has Chat off, or when the user's + * permission group hides it. Tab bodies stay mounted and are toggled with + * `hidden`, so a persisted `activeTab: 'copilot'` would hide all three and + * paint an empty panel — resolve it to the toolbar instead. + */ + const isCopilotTabAvailable = isChatEnabled && !permissionConfig.hideCopilot + const activeTab: PanelTab = + storedActiveTab === 'copilot' && !isCopilotTabAvailable ? 'toolbar' : storedActiveTab const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) const duplicateWorkflowMutation = useDuplicateWorkflowMutation() const { data: workflows = {} } = useWorkflowMap(workspaceId) @@ -257,7 +273,7 @@ export const Panel = memo(function Panel() { ) const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats( - activeWorkflowId ?? undefined + isCopilotTabAvailable ? (activeWorkflowId ?? undefined) : undefined ) const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false) @@ -278,7 +294,10 @@ export const Panel = memo(function Panel() { // chat was deleted in another tab). const autoSelectAttemptedForRef = useRef>(new Set()) useEffect(() => { - if (!activeWorkflowId) return + // The list query is skipped when the tab is unavailable, so an empty list + // there means "not fetched", not "deleted elsewhere" — clearing on it would + // discard the selection and latch the ref against ever restoring it. + if (!activeWorkflowId || !isCopilotTabAvailable) return if (copilotChatId && !copilotChatList.find((c) => c.id === copilotChatId)) { setCopilotChatId(undefined) @@ -290,7 +309,7 @@ export const Panel = memo(function Panel() { if (copilotChatList.length === 0) return autoSelectAttemptedForRef.current.add(activeWorkflowId) setCopilotChatId(copilotChatList[0].id) - }, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId]) + }, [copilotChatList, copilotChatId, activeWorkflowId, isCopilotTabAvailable, setCopilotChatId]) useEffect(() => { posthogRef.current = posthog @@ -456,7 +475,15 @@ export const Panel = memo(function Panel() { setHasHydrated(true) }, [setHasHydrated]) + /** + * Only claims handoffs while the Chat tab can actually receive them. The + * handler's `preventDefault()` is what tells `sendMothershipMessage` a host + * consumed the message, so listening with the tab hidden would swallow it and + * skip the caller's own fallback. + */ useEffect(() => { + if (!isCopilotTabAvailable) return + const handler = (e: Event) => { const detail = (e as CustomEvent).detail if (!detail?.message) return @@ -466,7 +493,7 @@ export const Panel = memo(function Panel() { } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) - }, [setActiveTab, copilotSendMessage]) + }, [isCopilotTabAvailable, setActiveTab, copilotSendMessage]) useEffect(() => { if (activeTab !== 'copilot') return @@ -759,7 +786,7 @@ export const Panel = memo(function Panel() { {/* Tabs */}
- {!permissionConfig.hideCopilot && ( + {isCopilotTabAvailable && ( - )} - - )} -
- )} -
+ })} + {chats.length > 5 && ( + + )} + + )} +
+ )} +
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts index bb22fb2fa01..2a126ea4df5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts @@ -119,7 +119,7 @@ export function useDeleteSelection({ if (nextWorkflowId) { router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) } else { - router.push(`/workspace/${workspaceId}/home`) + router.push(`/workspace/${workspaceId}`) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts index 0e9c5d82cc5..9db4fa5a163 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -98,7 +98,7 @@ export function useDeleteWorkflow({ if (nextWorkflowId) { router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) } else { - router.push(`/workspace/${workspaceId}/home`) + router.push(`/workspace/${workspaceId}`) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index de85beccd5c..301548f6554 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -1,52 +1,111 @@ 'use client' import { useEffect } from 'react' +import { Chip } from '@sim/emcn' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { ReactFlowProvider } from 'reactflow' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { Panel, Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' +import { useWorkflowOperations } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkflows } from '@/hooks/queries/workflows' const logger = createLogger('WorkflowsPage') +function Spinner() { + return ( +
+ ) +} + export default function WorkflowsPage() { const router = useRouter() const params = useParams() const workspaceId = params.workspaceId as string const { data: workflows = [], isLoading, isError, isPlaceholderData } = useWorkflows(workspaceId) + const { handleCreateWorkflow, isCreatingWorkflow } = useWorkflowOperations({ workspaceId }) + const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() + + // An id rather than the filtered array: `data` defaults to a fresh `[]` while + // the query has no data, so an array dependency would re-fire this on every + // render — exactly during the load this page exists to cover. + const firstWorkflowId = workflows.find((w) => w.workspaceId === workspaceId)?.id + const isResolving = isLoading || isPlaceholderData useEffect(() => { - if (isLoading || isPlaceholderData) return + if (isResolving) return if (isError) { logger.error('Failed to load workflows for workspace') return } - const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId) - - if (workspaceWorkflows.length > 0) { - router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0].id}`) + if (firstWorkflowId) { + router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) } - }, [isLoading, isPlaceholderData, workflows, workspaceId, router, isError]) + }, [isResolving, isError, firstWorkflowId, workspaceId, router]) + + /** + * A workspace can legitimately reach zero workflows — deleting the last one, + * archiving them all, or creating a workspace with `skipDefaultWorkflow`. This + * is the terminal state for those paths now that the chat composer is no + * longer a landing option, so it has to offer a way out rather than spin. + */ + const isEmpty = !isResolving && !isError && !firstWorkflowId + const canCreate = !permissionsLoading && canEdit - // Always show loading state until redirect happens - // There should always be a default workflow, so we never show "no workflows found" return (
-
+ {isError ? ( + // This is the landing route now, so a failed list fetch would + // otherwise spin forever with nothing but a log line. +
+
+

Couldn't load workflows

+

Check your connection and try again.

+
+ router.refresh()}> + Retry + +
+ ) : isEmpty ? ( +
+
+

No workflows yet

+

+ {canCreate + ? 'Create one to start building.' + : 'Ask a workspace admin to create one.'} +

+
+ {/* The create mutation navigates optimistically, so offering it + without write access would strand a read-only member on a + workflow the server declined to create. */} + {canCreate && ( + + {isCreatingWorkflow ? 'Creating…' : 'Create workflow'} + + )} +
+ ) : ( + + )}
diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index cb6e4d95953..0f9b725ccc2 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -11,6 +11,11 @@ import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' import { useSession } from '@/lib/auth/auth-client' import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery' +import { + buildUpgradeHref, + isUpgradeReason, + UPGRADE_REASON_PARAM, +} from '@/lib/billing/upgrade-reasons' import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' @@ -115,6 +120,20 @@ export default function WorkspacePage() { if (isWorkspacesLoading || workspacesError || !data) return + const urlParams = new URLSearchParams(window.location.search) + const redirectWorkflowId = urlParams.get('redirect_workflow') + const redirectTarget = urlParams.get('redirect') + const rawReason = urlParams.get(UPGRADE_REASON_PARAM) + + // `?redirect=upgrade` is how a caller that cannot know a workspace id — a + // self-hosted deployment, an email — reaches the plan picker. It has to + // survive workspace creation too: a first-time visitor has no workspace to + // resolve, and dropping the intent lands them on home with no explanation. + const destinationFor = (id: string) => + redirectTarget === 'upgrade' + ? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined) + : `/workspace/${id}` + const { workspaces, lastActiveWorkspaceId, creationPolicy } = data if (workspaces.length === 0) { @@ -135,15 +154,12 @@ export default function WorkspacePage() { return } hasRedirectedRef.current = true - handleNoWorkspaces(router, () => setRecoveryFailed(true)) + handleNoWorkspaces(router, () => setRecoveryFailed(true), destinationFor) return } hasRedirectedRef.current = true - const urlParams = new URLSearchParams(window.location.search) - const redirectWorkflowId = urlParams.get('redirect_workflow') - const localRecentId = WorkspaceRecencyStorage.getMostRecent() const findWorkspace = (id: string | null) => id ? workspaces.find((w) => w.id === id) : undefined @@ -157,7 +173,7 @@ export default function WorkspacePage() { } logger.info(`Redirecting to workspace: ${targetWorkspace.id}`) - router.replace(`/workspace/${targetWorkspace.id}/home`) + router.replace(destinationFor(targetWorkspace.id)) }, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router]) const blockedPolicy = @@ -237,12 +253,13 @@ async function handleWorkflowRedirect( } catch (error) { logger.error('Error fetching workflow for redirect:', error) } - router.replace(`/workspace/${fallbackWorkspaceId}/home`) + router.replace(`/workspace/${fallbackWorkspaceId}`) } async function handleNoWorkspaces( router: ReturnType, - onUnrecoverable: () => void + onUnrecoverable: () => void, + destinationFor: (workspaceId: string) => string ): Promise { logger.warn('No workspaces found, creating default workspace') try { @@ -252,7 +269,7 @@ async function handleNoWorkspaces( if (data.workspace?.id) { logger.info(`Created default workspace: ${data.workspace.id}`) sessionStorage.removeItem(WORKSPACE_RACE_RETRY_KEY) - router.replace(`/workspace/${data.workspace.id}/home`) + router.replace(destinationFor(data.workspace.id)) return } logger.error('Failed to create default workspace') diff --git a/apps/sim/background/schedule-execution.test.ts b/apps/sim/background/schedule-execution.test.ts new file mode 100644 index 00000000000..8ebb5cdd8c4 --- /dev/null +++ b/apps/sim/background/schedule-execution.test.ts @@ -0,0 +1,101 @@ +/** + * Covers the `active -> disabled` edge detection that drives the auto-disable + * email. `applyScheduleFailureUpdate` is the exported seam over the shared + * guarded UPDATE, so asserting on it also pins the behavior of the trigger.dev + * call sites that go through the same helper. + * + * @vitest-environment node + */ +import { databaseMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { notifyMock } = vi.hoisted(() => ({ + notifyMock: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@/lib/workflows/schedules/disable-notifications', () => ({ + notifyScheduleAutoDisabled: notifyMock, +})) + +// This module imports its tables from `@sim/db` directly, which the global mock +// does not re-export. Widen it rather than rewriting the source's imports. +vi.mock('@sim/db', () => ({ ...databaseMock, ...schemaMock })) + +import { applyScheduleFailureUpdate, releaseScheduleLock } from '@/background/schedule-execution' + +const BASE = { + scheduleId: 'schedule-1', + now: new Date('2025-01-01T00:00:00.000Z'), + nextRunAt: new Date('2025-01-01T01:00:00.000Z'), + expectedLastQueuedAt: new Date('2024-12-31T23:00:00.000Z'), + requestId: 'req-1', + context: 'test context', +} + +afterAll(() => { + resetDbChainMock() +}) + +describe('applyScheduleFailureUpdate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('reports disabled when the write returns a disabled row', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'disabled' }]) + + const result = await applyScheduleFailureUpdate(BASE) + + expect(result).toEqual({ updated: true, disabled: true }) + }) + + it('reports not disabled below the threshold (the 99-of-100 case)', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'active' }]) + + const result = await applyScheduleFailureUpdate(BASE) + + expect(result).toEqual({ updated: true, disabled: false }) + }) + + it('reports neither updated nor disabled when the claim guard matches no row', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await applyScheduleFailureUpdate(BASE) + + expect(result).toEqual({ updated: false, disabled: false }) + }) + + it('defers the email so an in-transaction caller can send it after commit', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'disabled' }]) + + await applyScheduleFailureUpdate(BASE) + + expect(notifyMock).not.toHaveBeenCalled() + }) + + it('excludes already-disabled rows so a disable is reported at most once', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'disabled' }]) + + await applyScheduleFailureUpdate(BASE) + + const condition = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect(JSON.stringify(condition)).toContain('disabled') + }) +}) + +describe('releaseScheduleLock', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('never notifies, even when the row it releases is already disabled', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'schedule-1', status: 'disabled' }]) + + const released = await releaseScheduleLock('schedule-1', 'req-1', new Date(), 'release') + + expect(released).toBe(true) + expect(notifyMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index ed8dce6e25b..b684f180dc7 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -30,6 +30,7 @@ import { getExecutionTimeout, getTimeoutErrorMessage, } from '@/lib/core/execution-limits' +import type { DbOrTx } from '@/lib/db/types' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' @@ -40,6 +41,8 @@ import { } from '@/lib/workflows/executor/execution-core' import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' +import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' import { SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, SCHEDULE_EXECUTION_QUEUE_NAME, @@ -70,6 +73,12 @@ type WorkflowScheduleUpdate = Partial> +/** Result of a guarded schedule UPDATE. `status` is the row's value after the write. */ +type ScheduleUpdateOutcome = { + updated: boolean + status: string | null +} + function incrementScheduleFailedCount(): SQL { return sql`COALESCE(${workflowSchedule.failedCount}, 0) + 1` } @@ -141,8 +150,23 @@ async function applyScheduleUpdate( updates: WorkflowScheduleUpdate, requestId: string, context: string, - options: { expectedLastQueuedAt?: Date | null; allowCompleted?: boolean } = {} -): Promise { + options: { + expectedLastQueuedAt?: Date | null + allowCompleted?: boolean + /** + * Set at call sites that can transition the row to `disabled`. Presence both + * opts the site into the auto-disable email and adds a `status <> 'disabled'` + * guard, so the transition fires exactly once per disable. + */ + disableReason?: ScheduleDisableReason + /** Required inside a transaction, where mail must wait for commit. */ + deferNotification?: boolean + /** Join a caller's transaction instead of using the pooled client. */ + executor?: DbOrTx + } = {} +): Promise { + let outcome: ScheduleUpdateOutcome + try { const claimGuard = options.expectedLastQueuedAt === undefined @@ -162,7 +186,17 @@ async function applyScheduleUpdate( ? undefined : ne(workflowSchedule.status, 'completed') - const updatedRows = await db + /** + * `RETURNING` yields the NEW row, so `status === 'disabled'` alone only means + * "is disabled". Excluding rows that were already disabled makes a returned + * row a true `active -> disabled` edge. Scoped to disable-capable call sites + * so lock releases on already-disabled rows still work. + */ + const notAlreadyDisabled = options.disableReason + ? ne(workflowSchedule.status, 'disabled') + : undefined + + const updatedRows = await (options.executor ?? db) .update(workflowSchedule) .set(updates) .where( @@ -170,16 +204,29 @@ async function applyScheduleUpdate( eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt), claimGuard, - notCompletedGuard + notCompletedGuard, + notAlreadyDisabled ) ) - .returning({ id: workflowSchedule.id }) + .returning({ id: workflowSchedule.id, status: workflowSchedule.status }) - return updatedRows.length > 0 + const row = updatedRows[0] + outcome = { updated: Boolean(row), status: row?.status ?? null } } catch (error) { logger.error(`[${requestId}] ${context}`, error, { cause: describeError(error) }) throw error } + + // Outside the try: a mail failure must never surface as a schedule-tick fault. + if (options.disableReason && !options.deferNotification && outcome.status === 'disabled') { + await notifyScheduleAutoDisabled({ + scheduleId, + reason: options.disableReason, + requestId, + }) + } + + return outcome } export async function releaseScheduleLock( @@ -199,7 +246,41 @@ export async function releaseScheduleLock( updates.nextRunAt = nextRunAt } - return applyScheduleUpdate(scheduleId, updates, requestId, context, options) + const outcome = await applyScheduleUpdate(scheduleId, updates, requestId, context, options) + return outcome.updated +} + +/** + * Applies {@link buildScheduleFailureUpdate} through the same guarded write the + * trigger.dev path uses, and reports whether the row just transitioned to + * `disabled`. Callers own the notification so an in-transaction caller can defer + * it until after commit. + */ +export async function applyScheduleFailureUpdate(params: { + scheduleId: string + now: Date + nextRunAt: Date | null + expectedLastQueuedAt: Date + requestId: string + context: string + executor?: DbOrTx +}): Promise<{ updated: boolean; disabled: boolean }> { + const { scheduleId, now, nextRunAt, expectedLastQueuedAt, requestId, context, executor } = params + + const outcome = await applyScheduleUpdate( + scheduleId, + buildScheduleFailureUpdate(now, nextRunAt), + requestId, + context, + { + expectedLastQueuedAt, + disableReason: 'consecutive_failures', + deferNotification: true, + executor, + } + ) + + return { updated: outcome.updated, disabled: outcome.status === 'disabled' } } function getScheduleClaimedAt(payload: ScheduleExecutionPayload): Date | null { @@ -239,7 +320,7 @@ async function retryScheduleAfterInfraFailure({ buildScheduleFailureUpdate(now, nextRunAt), requestId, `Error updating schedule ${payload.scheduleId} after exhausted infrastructure retries`, - { expectedLastQueuedAt: claimedAt } + { expectedLastQueuedAt: claimedAt, disableReason: 'consecutive_failures' } ) return } @@ -664,10 +745,12 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { const updateClaimedSchedule = ( updates: WorkflowScheduleUpdate, - context: string - ): Promise => + context: string, + disableReason?: ScheduleDisableReason + ): Promise => applyScheduleUpdate(payload.scheduleId, updates, requestId, context, { expectedLastQueuedAt: claimedAt, + disableReason, }) try { @@ -793,7 +876,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { status: 'disabled', ...resetScheduleInfraRetryCount(), }, - `Failed to disable schedule ${payload.scheduleId} after authentication error` + `Failed to disable schedule ${payload.scheduleId} after authentication error`, + 'authentication_error' ) return } @@ -810,7 +894,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { status: 'disabled', ...resetScheduleInfraRetryCount(), }, - `Failed to disable schedule ${payload.scheduleId} after authorization error` + `Failed to disable schedule ${payload.scheduleId} after authorization error`, + 'authorization_error' ) return } @@ -824,7 +909,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { status: 'disabled', ...resetScheduleInfraRetryCount(), }, - `Failed to disable schedule ${payload.scheduleId} after missing workflow` + `Failed to disable schedule ${payload.scheduleId} after missing workflow`, + 'workflow_not_found' ) return } @@ -863,7 +949,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { }) await updateClaimedSchedule( buildScheduleFailureUpdate(now, nextRunAt), - `Error updating schedule ${payload.scheduleId} after usage limit check` + `Error updating schedule ${payload.scheduleId} after usage limit check`, + 'consecutive_failures' ) return } @@ -885,7 +972,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { await updateClaimedSchedule( buildScheduleFailureUpdate(now, nextRunAt), - `Error updating schedule ${payload.scheduleId} after preprocessing failure` + `Error updating schedule ${payload.scheduleId} after preprocessing failure`, + 'consecutive_failures' ) return } @@ -953,7 +1041,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { nextRunAt: null, ...resetScheduleInfraRetryCount(), }, - `Failed to disable schedule ${payload.scheduleId} after skip` + `Failed to disable schedule ${payload.scheduleId} after skip`, + 'invalid_schedule' ) return } @@ -983,7 +1072,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { await updateClaimedSchedule( buildScheduleFailureUpdate(now, nextRunAt), - `Error updating schedule ${payload.scheduleId} after failure` + `Error updating schedule ${payload.scheduleId} after failure`, + 'consecutive_failures' ) } catch (error: unknown) { logger.error( @@ -995,7 +1085,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { await updateClaimedSchedule( buildScheduleFailureUpdate(now, nextRunAt), - `Error updating schedule ${payload.scheduleId} after execution error` + `Error updating schedule ${payload.scheduleId} after execution error`, + 'consecutive_failures' ) } } catch (error: unknown) { @@ -1468,7 +1559,10 @@ export async function executeJobInline(payload: JobExecutionPayload) { }, requestId, `Error updating job ${payload.scheduleId} after failure`, - { expectedLastQueuedAt: now } + { + expectedLastQueuedAt: now, + disableReason: shouldDisable ? 'consecutive_failures' : undefined, + } ) } } diff --git a/apps/sim/blocks/blocks/instagram.test.ts b/apps/sim/blocks/blocks/instagram.test.ts index d6f12933a03..1e633446711 100644 --- a/apps/sim/blocks/blocks/instagram.test.ts +++ b/apps/sim/blocks/blocks/instagram.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { InstagramBlock } from '@/blocks/blocks/instagram' +import { InstagramBlock, InstagramBlockMeta } from '@/blocks/blocks/instagram' describe('InstagramBlock', () => { const buildParams = InstagramBlock.tools.config.params! @@ -12,6 +12,10 @@ describe('InstagramBlock', () => { expect(InstagramBlock.hideFromToolbar).toBe(true) }) + it('provides the required integration templates', () => { + expect(InstagramBlockMeta.templates).toHaveLength(7) + }) + it('clears stale operation parameters from the runtime input merge', () => { const inputs = { operation: 'instagram_get_conversation_messages', diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index 2c346d00ce6..1fc62a2df34 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -1125,6 +1125,24 @@ export const InstagramBlockMeta = { category: 'marketing', tags: ['marketing', 'content', 'automation'], }, + { + icon: InstagramIcon, + title: 'Instagram Story publishing queue', + prompt: + 'Create a scheduled workflow that reads approved Story assets from a content table, publishes each image or video to Instagram Stories, checks the container status, and records the resulting media ID and outcome.', + modules: ['tables', 'scheduled', 'workflows'], + category: 'marketing', + tags: ['marketing', 'stories', 'automation'], + }, + { + icon: InstagramIcon, + title: 'Instagram carousel campaign publisher', + prompt: + 'Build a workflow that assembles two to ten approved images or videos into an Instagram carousel, publishes it with a campaign caption, checks processing status, and saves the published media ID to a campaign table.', + modules: ['tables', 'workflows'], + category: 'marketing', + tags: ['marketing', 'content', 'automation'], + }, { icon: InstagramIcon, title: 'Instagram comment moderator', @@ -1152,6 +1170,15 @@ export const InstagramBlockMeta = { category: 'marketing', tags: ['marketing', 'analytics', 'automation'], }, + { + icon: InstagramIcon, + title: 'Instagram media archive', + prompt: + 'Create a scheduled workflow that lists recent Instagram posts and Stories, downloads each media item as durable files, and records the source media ID, type, file references, and download status in an archive table.', + modules: ['scheduled', 'tables', 'workflows'], + category: 'operations', + tags: ['content', 'archive', 'automation'], + }, ], skills: [ { diff --git a/apps/sim/blocks/blocks/knowledge.ts b/apps/sim/blocks/blocks/knowledge.ts index 659f479dc0d..7debc95c938 100644 --- a/apps/sim/blocks/blocks/knowledge.ts +++ b/apps/sim/blocks/blocks/knowledge.ts @@ -88,6 +88,18 @@ export const KnowledgeBlock: BlockConfig = { dependsOn: ['knowledgeBaseSelector'], condition: { field: 'operation', value: 'search' }, }, + { + id: 'searchMode', + title: 'Retrieval Mode', + type: 'dropdown', + options: [ + { label: 'Vector only', id: 'vector' }, + { label: 'Hybrid (full-text + vector)', id: 'hybrid' }, + ], + value: () => 'vector', + mode: 'advanced', + condition: { field: 'operation', value: 'search' }, + }, { id: 'rerankerEnabled', title: 'Rerank Results', @@ -440,6 +452,10 @@ export const KnowledgeBlock: BlockConfig = { limit: { type: 'number', description: 'Max items to return' }, offset: { type: 'number', description: 'Pagination offset' }, tagFilters: { type: 'string', description: 'Tag filter criteria' }, + searchMode: { + type: 'string', + description: 'Retrieval mode: vector only (default) or hybrid (full-text + vector)', + }, rerankerEnabled: { type: 'boolean', description: 'Apply Cohere reranking to search results' }, rerankerModel: { type: 'string', description: 'Cohere rerank model identifier' }, rerankerInputCount: { diff --git a/apps/sim/blocks/blocks/managed_agent.test.ts b/apps/sim/blocks/blocks/managed_agent.test.ts new file mode 100644 index 00000000000..26f5a1e2140 --- /dev/null +++ b/apps/sim/blocks/blocks/managed_agent.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { ManagedAgentBlock } from '@/blocks/blocks/managed_agent' + +const subBlockById = (id: string) => { + const config = ManagedAgentBlock.subBlocks.find((sub) => sub.id === id) + if (!config) throw new Error(`No sub-block '${id}' on the Managed Agent block`) + return config +} + +/** Mirrors how the editor and serializer evaluate visibility: raw stored values. */ +const isVisible = (id: string, values: Record) => + evaluateSubBlockCondition(subBlockById(id).condition, values) + +const selectTool = (params: Record) => + ManagedAgentBlock.tools.config?.tool?.(params as never) + +/** + * The block gained an operation selector after it shipped. Blocks saved before + * that have NO stored `operation` value, so these tests pin the promise that + * they keep behaving — and looking — exactly as they did. + */ +describe('Managed Agent block — legacy blocks without a stored operation', () => { + const legacyValues = { + credential: 'cred-1', + agent: 'agent_1', + environment: 'env_1', + environmentType: 'cloud', + userMessage: 'do the thing', + } + + it('resolves to the run-session tool', () => { + expect(selectTool(legacyValues)).toBe('managed_agent_run_session') + }) + + it('resolves to the run-session tool when operation is explicitly absent', () => { + expect(selectTool({})).toBe('managed_agent_run_session') + expect(selectTool({ operation: undefined })).toBe('managed_agent_run_session') + expect(selectTool({ operation: null })).toBe('managed_agent_run_session') + }) + + it.each(['agent', 'environment', 'environmentType', 'userMessage'])( + 'keeps the %s field visible', + (id) => { + expect(isVisible(id, legacyValues)).toBe(true) + } + ) + + it('keeps cloud-only fields visible', () => { + expect(isVisible('memoryStoreId', legacyValues)).toBe(true) + expect(isVisible('files', legacyValues)).toBe(true) + expect(isVisible('sessionParameters', legacyValues)).toBe(true) + }) + + it('still hides cloud-only fields on a self-hosted legacy block', () => { + const selfHosted = { ...legacyValues, environmentType: 'self_hosted' } + expect(isVisible('memoryStoreId', selfHosted)).toBe(false) + expect(isVisible('files', selfHosted)).toBe(false) + // Metadata applies to both environment models. + expect(isVisible('sessionParameters', selfHosted)).toBe(true) + }) + + it('does not show session-targeting fields', () => { + expect(isVisible('sessionId', legacyValues)).toBe(false) + expect(isVisible('toolUseIds', legacyValues)).toBe(false) + }) + + it('keeps the legacy run-session output shape as the fallback', () => { + expect(Object.keys(ManagedAgentBlock.outputs)).toEqual( + expect.arrayContaining(['content', 'sessionId', 'inputTokens', 'outputTokens']) + ) + }) +}) + +describe('Managed Agent block — operation routing', () => { + it.each([ + ['run_session', 'managed_agent_run_session'], + ['create_session', 'managed_agent_create_session'], + ['send_message', 'managed_agent_send_message'], + ['get_session', 'managed_agent_get_session'], + ['list_events', 'managed_agent_list_events'], + ['update_session', 'managed_agent_update_session'], + ['interrupt_session', 'managed_agent_interrupt_session'], + ['respond_tool_confirmation', 'managed_agent_respond_tool_confirmation'], + ['respond_custom_tool', 'managed_agent_respond_custom_tool'], + ['archive_session', 'managed_agent_archive_session'], + ['delete_session', 'managed_agent_delete_session'], + ])('maps %s to %s', (operation, toolId) => { + expect(selectTool({ operation })).toBe(toolId) + }) + + it('falls back to run session for an unknown operation', () => { + expect(selectTool({ operation: 'not_a_real_operation' })).toBe('managed_agent_run_session') + }) + + it('declares every mapped tool in tools.access', () => { + const operations = subBlockById('operation').options as Array<{ id: string }> + for (const { id } of operations) { + expect(ManagedAgentBlock.tools.access).toContain(selectTool({ operation: id })) + } + }) +}) + +describe('Managed Agent block — per-operation field visibility', () => { + it('shows the session id for operations that target an existing session', () => { + for (const operation of [ + 'send_message', + 'get_session', + 'list_events', + 'update_session', + 'interrupt_session', + 'respond_tool_confirmation', + 'respond_custom_tool', + 'archive_session', + 'delete_session', + ]) { + expect(isVisible('sessionId', { operation })).toBe(true) + } + }) + + it('hides the agent and environment pickers once a session exists', () => { + expect(isVisible('agent', { operation: 'send_message' })).toBe(false) + expect(isVisible('environment', { operation: 'get_session' })).toBe(false) + expect(isVisible('environmentType', { operation: 'archive_session' })).toBe(false) + }) + + it('shows the user message for the three operations that send one', () => { + expect(isVisible('userMessage', { operation: 'run_session' })).toBe(true) + expect(isVisible('userMessage', { operation: 'create_session' })).toBe(true) + expect(isVisible('userMessage', { operation: 'send_message' })).toBe(true) + expect(isVisible('userMessage', { operation: 'get_session' })).toBe(false) + }) + + it('makes the user message optional only for create session', () => { + const required = subBlockById('userMessage').required + expect(evaluateSubBlockCondition(required as never, { operation: 'create_session' })).toBe( + false + ) + expect(evaluateSubBlockCondition(required as never, { operation: 'send_message' })).toBe(true) + expect(evaluateSubBlockCondition(required as never, { operation: 'run_session' })).toBe(true) + // A legacy block has no stored operation and must stay required. + expect(evaluateSubBlockCondition(required as never, {})).toBe(true) + }) + + it('reveals the deny reason only when denying a tool confirmation', () => { + expect( + isVisible('denyMessage', { operation: 'respond_tool_confirmation', decision: 'deny' }) + ).toBe(true) + expect( + isVisible('denyMessage', { operation: 'respond_tool_confirmation', decision: 'allow' }) + ).toBe(false) + expect(isVisible('denyMessage', { operation: 'send_message', decision: 'deny' })).toBe(false) + }) + + it('separates confirmation fields from custom-tool-result fields', () => { + // A confirmation cannot unblock a custom tool, so the two operations must + // not share inputs — otherwise a workflow can silently answer the wrong way. + expect(isVisible('toolUseIds', { operation: 'respond_tool_confirmation' })).toBe(true) + expect(isVisible('toolUseIds', { operation: 'respond_custom_tool' })).toBe(false) + expect(isVisible('customToolUseId', { operation: 'respond_custom_tool' })).toBe(true) + expect(isVisible('customToolUseId', { operation: 'respond_tool_confirmation' })).toBe(false) + expect(isVisible('result', { operation: 'respond_custom_tool' })).toBe(true) + expect(isVisible('decision', { operation: 'respond_custom_tool' })).toBe(false) + }) + + it('shows metadata for update session but not the agent config fields', () => { + expect(isVisible('sessionParameters', { operation: 'update_session' })).toBe(true) + expect(isVisible('title', { operation: 'update_session' })).toBe(true) + expect(isVisible('vaults', { operation: 'update_session' })).toBe(false) + }) +}) diff --git a/apps/sim/blocks/blocks/managed_agent.ts b/apps/sim/blocks/blocks/managed_agent.ts index 894fdd4bc16..9088719af5e 100644 Binary files a/apps/sim/blocks/blocks/managed_agent.ts and b/apps/sim/blocks/blocks/managed_agent.ts differ diff --git a/apps/sim/blocks/brand-icon-style.ts b/apps/sim/blocks/brand-icon-style.ts new file mode 100644 index 00000000000..4e29977cb23 --- /dev/null +++ b/apps/sim/blocks/brand-icon-style.ts @@ -0,0 +1,53 @@ +/** + * Brand-icon styling that needs the block registry, kept apart from the pure + * contrast helpers in `@/blocks/icon-color`. + * + * The split is by dependency, not by topic: this module reads `getAllBlocks()`, + * so importing it pulls `blocks/registry-maps` → all 282 block configs → the + * tool registry. `icon-color.ts` needs none of that, and it is imported by the + * public landing `/integrations` page — which was inheriting the whole registry + * purely because the two lived in one file. + * + * Every consumer here is under `app/workspace/**`, where the registry is + * already legitimately present. + */ + +import type { ComponentType, CSSProperties } from 'react' +import { getAllBlocks } from '@/blocks/registry' + +/** A brand icon component that accepts standard styling props. */ +export type StyleableIcon = ComponentType<{ className?: string; style?: CSSProperties }> + +/** + * Lazily-built lookup from a block's icon component to its theme-safe brand + * {@link BlockConfig.iconColor}. Keyed by component reference so callers that + * already hold the icon (suggested actions, credential pickers, …) never need + * to thread a block type or hand-pick a color. Built once on first read since + * the block registry is static for the app's lifetime. + */ +let iconColorByComponent: Map | null = null + +function getIconColorMap(): Map { + if (iconColorByComponent) return iconColorByComponent + const map = new Map() + for (const block of getAllBlocks()) { + if (block.iconColor) map.set(block.icon, block.iconColor) + } + iconColorByComponent = map + return map +} + +/** + * Inline `style` for rendering a brand icon bare (without its colored tile + * background): the block's theme-safe {@link BlockConfig.iconColor} as `color`, + * or `undefined` when none is defined so the caller keeps its own default + * icon styling. + * + * Single-fill icons drawn with `fill='currentColor'` (e.g. HubSpot) adopt the + * color; multi-color brand icons that hardcode their own fills (Slack, Gmail, + * Jira, Salesforce, Google Calendar) ignore it and keep their own colors. + */ +export function getBareIconStyle(icon: StyleableIcon): CSSProperties | undefined { + const color = getIconColorMap().get(icon) + return color ? { color } : undefined +} diff --git a/apps/sim/blocks/icon-color.ts b/apps/sim/blocks/icon-color.ts index 9e3030127ec..5332a473573 100644 --- a/apps/sim/blocks/icon-color.ts +++ b/apps/sim/blocks/icon-color.ts @@ -1,43 +1,10 @@ -import type { ComponentType, CSSProperties } from 'react' -import { isLightColor } from '@/lib/colors' -import { getAllBlocks } from '@/blocks/registry' - -/** A brand icon component that accepts standard styling props. */ -export type StyleableIcon = ComponentType<{ className?: string; style?: CSSProperties }> - /** - * Lazily-built lookup from a block's icon component to its theme-safe brand - * {@link BlockConfig.iconColor}. Keyed by component reference so callers that - * already hold the icon (suggested actions, credential pickers, …) never need - * to thread a block type or hand-pick a color. Built once on first read since - * the block registry is static for the app's lifetime. + * Contrast helpers for brand tiles. Pure colour maths — deliberately free of any + * `@/blocks/registry` import so the public landing `/integrations` page can use + * these without pulling 282 block configs and the tool registry into its bundle. + * Registry-backed icon styling lives in `@/blocks/brand-icon-style`. */ -let iconColorByComponent: Map | null = null - -function getIconColorMap(): Map { - if (iconColorByComponent) return iconColorByComponent - const map = new Map() - for (const block of getAllBlocks()) { - if (block.iconColor) map.set(block.icon, block.iconColor) - } - iconColorByComponent = map - return map -} - -/** - * Inline `style` for rendering a brand icon bare (without its colored tile - * background): the block's theme-safe {@link BlockConfig.iconColor} as `color`, - * or `undefined` when none is defined so the caller keeps its own default - * icon styling. - * - * Single-fill icons drawn with `fill='currentColor'` (e.g. HubSpot) adopt the - * color; multi-color brand icons that hardcode their own fills (Slack, Gmail, - * Jira, Salesforce, Google Calendar) ignore it and keep their own colors. - */ -export function getBareIconStyle(icon: StyleableIcon): CSSProperties | undefined { - const color = getIconColorMap().get(icon) - return color ? { color } : undefined -} +import { isLightColor } from '@/lib/colors' /** * Brightness above which a brand tile is "clearly light" and a white foreground diff --git a/apps/sim/components/emails/billing/index.ts b/apps/sim/components/emails/billing/index.ts index 9c8faaca3b0..eab1c008435 100644 --- a/apps/sim/components/emails/billing/index.ts +++ b/apps/sim/components/emails/billing/index.ts @@ -6,4 +6,5 @@ export { FreeTierUpgradeEmail } from './free-tier-upgrade-email' export { LimitThresholdEmail } from './limit-threshold-email' export { PaymentFailedEmail } from './payment-failed-email' export { PlanWelcomeEmail } from './plan-welcome-email' +export { UsageLimitReachedEmail } from './usage-limit-reached-email' export { UsageThresholdEmail } from './usage-threshold-email' diff --git a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx new file mode 100644 index 00000000000..8ff73ab52d9 --- /dev/null +++ b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx @@ -0,0 +1,81 @@ +import { Link, Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface UsageLimitReachedEmailProps { + userName?: string + planName: string + /** Drives the remedy copy and CTA label — org admins raise the org limit. */ + scope: 'user' | 'organization' + currentUsage: number + limit: number + ctaLink: string +} + +/** + * Sent at 100% of the usage limit to paid and organization accounts. The + * free-tier equivalent is `CreditsExhaustedEmail`, whose remedy is an upgrade + * rather than a limit change. + */ +export function UsageLimitReachedEmail({ + userName, + planName, + scope, + currentUsage, + limit, + ctaLink, +}: UsageLimitReachedEmailProps) { + const brand = getBrandConfig() + const isOrganization = scope === 'organization' + const previewText = `${brand.name}: You've reached your ${planName} usage limit` + + return ( + + + {userName ? `Hi ${userName},` : 'Hi,'} + + + + {isOrganization + ? `Your organization has reached its monthly usage limit on the ${planName} plan.` + : `You've reached your monthly usage limit on the ${planName} plan.`}{' '} + Workflow runs, knowledge base search, document uploads, and Chat are paused until the limit + goes up. + + +
+ Usage + + {dollarsToCredits(currentUsage).toLocaleString()} of{' '} + {dollarsToCredits(limit).toLocaleString()} credits used + +
+ + {/* Divider */} +
+ + + {isOrganization + ? 'Raise the organization usage limit in billing settings to resume.' + : 'Raise your usage limit in billing settings, or upgrade your plan, to resume.'} + + + + + {isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'} + + + + {/* Divider */} +
+ + + Sent to the people who manage billing for this account. + + + ) +} + +export default UsageLimitReachedEmail diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts index ffbf4d11f22..a3c8bc50a93 100644 --- a/apps/sim/components/emails/index.ts +++ b/apps/sim/components/emails/index.ts @@ -8,6 +8,8 @@ export * from './billing' export * from './components' // Invitation emails export * from './invitations' +// Operational notification emails +export * from './notifications' // Render functions and subjects export * from './render' export * from './subjects' diff --git a/apps/sim/components/emails/notifications/index.ts b/apps/sim/components/emails/notifications/index.ts new file mode 100644 index 00000000000..c930a723ce9 --- /dev/null +++ b/apps/sim/components/emails/notifications/index.ts @@ -0,0 +1 @@ +export { ScheduleDisabledEmail } from './schedule-disabled-email' diff --git a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx new file mode 100644 index 00000000000..a5116be4246 --- /dev/null +++ b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx @@ -0,0 +1,83 @@ +import { Link, Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { + SCHEDULE_DISABLE_REASON_COPY, + type ScheduleDisableReason, +} from '@/lib/workflows/schedules/disable-reasons' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface ScheduleDisabledEmailProps { + recipientName?: string + /** Drives the noun and the CTA label. */ + kind: 'workflow' | 'job' + /** Workflow name or job title. Absent when the source row could not be read. */ + resourceName?: string + reason: ScheduleDisableReason + /** Consecutive failures at disable time. Rendered only for `consecutive_failures`. */ + failedCount?: number + /** Deep link to the workflow or scheduled tasks. Absent when the workspace is unknown. */ + manageLink?: string +} + +/** + * Sent when Sim turns a schedule off on its own — either after repeated + * failures or on a terminal error that cannot resolve itself. + */ +export function ScheduleDisabledEmail({ + recipientName, + kind, + resourceName, + reason, + failedCount, + manageLink, +}: ScheduleDisabledEmailProps) { + const brand = getBrandConfig() + const resourceLabel = + resourceName ?? (kind === 'job' ? 'a scheduled task' : 'a scheduled workflow') + const reasonCopy = + reason === 'consecutive_failures' && failedCount + ? `It failed ${failedCount.toLocaleString()} times in a row.` + : SCHEDULE_DISABLE_REASON_COPY[reason] + const previewText = `${brand.name} turned off the schedule for ${resourceLabel}` + + return ( + + + {recipientName ? `Hi ${recipientName},` : 'Hi,'} + + + + {brand.name} turned off the schedule for {resourceLabel}. It will not run again until you + turn it back on. + + +
+ Reason + {reasonCopy} +
+ + {/* Divider */} +
+ + Fix the problem, then turn the schedule back on. + + {manageLink ? ( + + + {kind === 'job' ? 'Open scheduled tasks' : 'Open workflow'} + + + ) : null} + + {/* Divider */} +
+ + + Sent to workspace admins and the person who created this schedule. + + + ) +} + +export default ScheduleDisabledEmail diff --git a/apps/sim/components/emails/render-notifications.test.ts b/apps/sim/components/emails/render-notifications.test.ts new file mode 100644 index 00000000000..67d45e0736a --- /dev/null +++ b/apps/sim/components/emails/render-notifications.test.ts @@ -0,0 +1,98 @@ +/** + * Executes the notification templates for real. Every other suite mocks the + * render functions, so without this nothing would catch a template that throws + * or silently drops its key copy. + * + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + renderScheduleDisabledEmail, + renderUsageLimitReachedEmail, +} from '@/components/emails/render' + +describe('renderScheduleDisabledEmail', () => { + it('renders the failure count for a threshold disable', async () => { + const html = await renderScheduleDisabledEmail({ + recipientName: 'John', + kind: 'workflow', + resourceName: 'Daily digest', + reason: 'consecutive_failures', + failedCount: 100, + manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456', + }) + + expect(html).toContain('Daily digest') + expect(html).toContain('It failed 100 times in a row.') + expect(html).toContain('Open workflow') + expect(html).toContain('https://sim.ai/workspace/ws_123/w/wf_456') + }) + + it('renders reason copy instead of a count for a single-strike disable', async () => { + const html = await renderScheduleDisabledEmail({ + kind: 'job', + resourceName: 'Weekly report', + reason: 'authentication_error', + manageLink: 'https://sim.ai/workspace/ws_123/scheduled-tasks', + }) + + expect(html).toContain('could not be authenticated') + expect(html).not.toContain('times in a row') + expect(html).toContain('Open scheduled tasks') + }) + + it('falls back to a generic noun and omits the CTA when the source is unknown', async () => { + const html = await renderScheduleDisabledEmail({ + kind: 'workflow', + reason: 'workflow_not_found', + }) + + expect(html).toContain('a scheduled workflow') + expect(html).not.toContain('Open workflow') + }) +}) + +describe('renderUsageLimitReachedEmail', () => { + it('tells a personal payer to raise their limit, never to upgrade to Pro', async () => { + const html = await renderUsageLimitReachedEmail({ + userName: 'John', + planName: 'Pro', + scope: 'user', + currentUsage: 20, + limit: 20, + ctaLink: 'https://sim.ai/account/settings/billing', + }) + + expect(html).toContain('Raise Usage Limit') + expect(html).toContain('upgrade your plan') + expect(html).not.toContain('Upgrade to Pro') + expect(html).not.toContain('free credits') + }) + + it('points an organization at the org limit', async () => { + const html = await renderUsageLimitReachedEmail({ + planName: 'Team', + scope: 'organization', + currentUsage: 500, + limit: 500, + ctaLink: 'https://sim.ai/organization/org_1/settings/billing', + }) + + expect(html).toContain('Raise Organization Limit') + expect(html).toContain('organization usage limit') + expect(html).not.toContain('upgrade your plan') + }) + + it('renders credits, not dollars', async () => { + const html = await renderUsageLimitReachedEmail({ + planName: 'Pro', + scope: 'user', + currentUsage: 20, + limit: 20, + ctaLink: 'https://sim.ai/account/settings/billing', + }) + + expect(html).toContain('credits used') + expect(html).not.toContain('$20') + }) +}) diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 652d6e7df2a..2510be6cbc6 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -15,6 +15,7 @@ import { LimitThresholdEmail, PaymentFailedEmail, PlanWelcomeEmail, + UsageLimitReachedEmail, UsageThresholdEmail, } from '@/components/emails/billing' import { @@ -23,9 +24,11 @@ import { WorkspaceAddedEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' +import { ScheduleDisabledEmail } from '@/components/emails/notifications' import { HelpConfirmationEmail } from '@/components/emails/support' import type { UpgradeReason } from '@/lib/billing/upgrade-reasons' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' export { getEmailSubject, getLimitEmailSubject } from './subjects' @@ -136,6 +139,28 @@ export async function renderUsageThresholdEmail(params: { ) } +export async function renderUsageLimitReachedEmail(params: { + userName?: string + planName: string + scope: 'user' | 'organization' + currentUsage: number + limit: number + ctaLink: string +}): Promise { + return await render(UsageLimitReachedEmail(params)) +} + +export async function renderScheduleDisabledEmail(params: { + recipientName?: string + kind: 'workflow' | 'job' + resourceName?: string + reason: ScheduleDisableReason + failedCount?: number + manageLink?: string +}): Promise { + return await render(ScheduleDisabledEmail(params)) +} + export async function renderFreeTierUpgradeEmail(params: { userName?: string percentUsed: number diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index 8eeb9b06fdf..b5a4dc7ac71 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -21,6 +21,7 @@ export type EmailSubjectType = | 'credit-purchase' | 'abandoned-checkout' | 'free-tier-exhausted' + | 'schedule-disabled' | 'onboarding-followup' | 'welcome' @@ -69,6 +70,8 @@ export function getEmailSubject(type: EmailSubjectType): string { return `Quick question` case 'free-tier-exhausted': return `You've run out of free credits on ${brandName}` + case 'schedule-disabled': + return `A schedule was turned off on ${brandName}` case 'onboarding-followup': return `Quick question about ${brandName}` case 'welcome': diff --git a/apps/sim/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx index bb6fa438ecf..a7942f2a4bf 100644 --- a/apps/sim/ee/audit-logs/components/audit-logs.tsx +++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx @@ -21,10 +21,10 @@ import { createLogger } from '@sim/logger' import { formatDateTime } from '@sim/utils/formatting' import { isRecordLike } from '@sim/utils/object' import { useQueryStates } from 'nuqs' +import { formatDateShort } from '@/lib/core/utils/date-display' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format' -import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils' import { ActivityLog, type ActivityLogEntry, diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index b992730f0cc..0cefcf821ff 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -3,6 +3,10 @@ import '@sim/testing/mocks/executor' import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +const { mockResolveAutoModel } = vi.hoisted(() => ({ + mockResolveAutoModel: vi.fn(), +})) + vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) vi.mock('@/lib/credentials/access', () => ({ @@ -20,6 +24,13 @@ vi.mock('@/lib/credentials/access', () => ({ }), })) +vi.mock('@/lib/model-router/resolve', () => ({ + addAutoRoutingCost: (cost: Record, routingCost: number) => + routingCost > 0 ? { ...cost, routing: routingCost, total: cost.total + routingCost } : cost, + resolveAutoModel: mockResolveAutoModel, + SIM_AUTO_SYSTEM_PREAMBLE: 'Sim auto system preamble', +})) + import { BlockType } from '@/executor/constants' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' import type { ExecutionContext } from '@/executor/types' @@ -82,6 +93,12 @@ describe('EvaluatorBlockHandler', () => { refreshed: false, }) mockGetProviderFromModel.mockReturnValue('openai') + mockResolveAutoModel.mockResolvedValue({ + model: 'fireworks/glm-5.2', + tier: '2', + decidedBy: 'llm', + billableRoutingCost: 0.002, + }) // Set up fetch mock to return a successful response mockFetch.mockImplementation(() => { @@ -101,7 +118,10 @@ describe('EvaluatorBlockHandler', () => { it('should handle evaluator blocks', () => { expect(handler.canHandle(mockBlock)).toBe(true) - const nonEvalBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } } + const nonEvalBlock: SerializedBlock = { + ...mockBlock, + metadata: { id: 'other' }, + } expect(handler.canHandle(nonEvalBlock)).toBe(false) }) @@ -109,8 +129,16 @@ describe('EvaluatorBlockHandler', () => { const inputs = { content: 'This is the content to evaluate.', metrics: [ - { name: 'score1', description: 'First score', range: { min: 0, max: 10 } }, - { name: 'score2', description: 'Second score', range: { min: 0, max: 10 } }, + { + name: 'score1', + description: 'First score', + range: { min: 0, max: 10 }, + }, + { + name: 'score2', + description: 'Second score', + range: { min: 0, max: 10 }, + }, ], model: 'gpt-4o', apiKey: 'test-api-key', @@ -163,6 +191,64 @@ describe('EvaluatorBlockHandler', () => { }) }) + it('resolves sim-auto before executing evaluator and preserves its public identity', async () => { + const inputs = { + content: 'A clear and accurate answer.', + metrics: [ + { + name: 'quality', + description: 'Overall answer quality', + range: { min: 1, max: 5 }, + }, + ], + model: 'sim-auto', + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + content: JSON.stringify({ quality: 5 }), + model: 'fireworks/glm-5.2', + tokens: { input: 80, output: 10, total: 90 }, + cost: { input: 0.001, output: 0.0005, total: 0.0015 }, + }), + }) + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(mockResolveAutoModel).toHaveBeenCalledWith({ + ctx: mockContext, + blockId: mockBlock.id, + signals: expect.objectContaining({ + lastMessage: inputs.content, + messageCount: 1, + toolNames: [], + mediaKind: 'none', + hasResponseFormat: true, + }), + fallbackModel: 'claude-sonnet-5', + }) + expect(mockGetProviderFromModel).toHaveBeenCalledWith('fireworks/glm-5.2') + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(requestBody).toMatchObject({ + provider: 'openai', + model: 'fireworks/glm-5.2', + systemPrompt: expect.stringMatching(/^Sim auto system preamble\n\n/), + }) + expect(result).toMatchObject({ + model: 'sim-auto', + quality: 5, + cost: { + input: 0.001, + output: 0.0005, + routing: 0.002, + total: 0.0035, + }, + }) + }) + it('bills the cost the provider proxy decided rather than recomputing it', async () => { // The proxy already resolved key provenance and the margin; recomputing // here would re-charge a BYOK caller the proxy correctly zeroed. @@ -195,7 +281,13 @@ describe('EvaluatorBlockHandler', () => { const contentObj = { text: 'Evaluate this JSON.', value: 42 } const inputs = { content: JSON.stringify(contentObj), - metrics: [{ name: 'clarity', description: 'Clarity score', range: { min: 1, max: 5 } }], + metrics: [ + { + name: 'clarity', + description: 'Clarity score', + range: { min: 1, max: 5 }, + }, + ], apiKey: 'test-api-key', } @@ -227,7 +319,11 @@ describe('EvaluatorBlockHandler', () => { const inputs = { content: contentObj, metrics: [ - { name: 'completeness', description: 'Data completeness', range: { min: 0, max: 1 } }, + { + name: 'completeness', + description: 'Data completeness', + range: { min: 0, max: 1 }, + }, ], apiKey: 'test-api-key', } @@ -258,7 +354,13 @@ describe('EvaluatorBlockHandler', () => { it('should parse valid JSON response correctly', async () => { const inputs = { content: 'Test content', - metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }], + metrics: [ + { + name: 'quality', + description: 'Quality score', + range: { min: 1, max: 10 }, + }, + ], apiKey: 'test-api-key', } @@ -339,7 +441,13 @@ describe('EvaluatorBlockHandler', () => { it('should extract metric scores ignoring case', async () => { const inputs = { content: 'Test', - metrics: [{ name: 'CamelCaseScore', description: 'Desc', range: { min: 0, max: 10 } }], + metrics: [ + { + name: 'CamelCaseScore', + description: 'Desc', + range: { min: 0, max: 10 }, + }, + ], apiKey: 'test-api-key', } @@ -366,8 +474,16 @@ describe('EvaluatorBlockHandler', () => { const inputs = { content: 'Test', metrics: [ - { name: 'presentScore', description: 'Desc1', range: { min: 0, max: 5 } }, - { name: 'missingScore', description: 'Desc2', range: { min: 0, max: 5 } }, + { + name: 'presentScore', + description: 'Desc1', + range: { min: 0, max: 5 }, + }, + { + name: 'missingScore', + description: 'Desc2', + range: { min: 0, max: 5 }, + }, ], apiKey: 'test-api-key', } @@ -410,7 +526,13 @@ describe('EvaluatorBlockHandler', () => { it('should handle Azure OpenAI models with endpoint and API version', async () => { const inputs = { content: 'Test content to evaluate', - metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }], + metrics: [ + { + name: 'quality', + description: 'Quality score', + range: { min: 1, max: 10 }, + }, + ], model: 'gpt-4o', apiKey: 'test-azure-key', azureEndpoint: 'https://test.openai.azure.com', @@ -450,7 +572,13 @@ describe('EvaluatorBlockHandler', () => { it('should handle Vertex AI models with OAuth credential', async () => { const inputs = { content: 'Test content to evaluate', - metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }], + metrics: [ + { + name: 'quality', + description: 'Quality score', + range: { min: 1, max: 10 }, + }, + ], model: 'gemini-2.0-flash-exp', vertexCredential: 'test-vertex-credential-id', vertexProject: 'test-gcp-project', diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 1f96fff1d6f..a3a4475aa39 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -1,4 +1,10 @@ import { createLogger } from '@sim/logger' +import { + type AutoRoutingResult, + addAutoRoutingCost, + resolveAutoModel, + SIM_AUTO_SYSTEM_PREAMBLE, +} from '@/lib/model-router/resolve' import type { BlockOutput } from '@/blocks/types' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants' @@ -7,6 +13,7 @@ import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executo import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' +import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' @@ -36,19 +43,6 @@ export class EvaluatorBlockHandler implements BlockHandler { bedrockRegion: inputs.bedrockRegion, } - await validateModelProvider(ctx.userId, ctx.workspaceId, evaluatorConfig.model, ctx) - - const providerId = getProviderFromModel(evaluatorConfig.model) - - let finalApiKey: string | undefined = evaluatorConfig.apiKey - if (providerId === 'vertex' && evaluatorConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential( - evaluatorConfig.vertexCredential, - ctx.userId, - 'vertex-evaluator' - ) - } - const processedContent = this.processContent(inputs.content) let systemPromptObj: { systemPrompt: string; responseFormat: any } = { @@ -105,12 +99,55 @@ export class EvaluatorBlockHandler implements BlockHandler { 'Evaluate the content and provide scores for each metric as JSON.' } + let model = evaluatorConfig.model + let autoRouting: AutoRoutingResult | null = null + if (isAutoModel(model)) { + autoRouting = await resolveAutoModel({ + ctx, + blockId: block.id, + signals: { + systemPrompt: systemPromptObj.systemPrompt, + lastMessage: processedContent, + messageCount: 1, + toolNames: [], + mediaKind: 'none', + hasResponseFormat: true, + approxInputTokens: Math.ceil( + (systemPromptObj.systemPrompt.length + processedContent.length) / 4 + ), + }, + fallbackModel: EVALUATOR.DEFAULT_MODEL, + }) + model = autoRouting.model + systemPromptObj.systemPrompt = [SIM_AUTO_SYSTEM_PREAMBLE, systemPromptObj.systemPrompt] + .filter(Boolean) + .join('\n\n') + logger.info('Resolved sim-auto model for evaluator', { + blockId: block.id, + model, + tier: autoRouting.tier, + decidedBy: autoRouting.decidedBy, + }) + } + + await validateModelProvider(ctx.userId, ctx.workspaceId, model, ctx) + const providerId = getProviderFromModel(model) + + let finalApiKey: string | undefined = evaluatorConfig.apiKey + if (providerId === 'vertex' && evaluatorConfig.vertexCredential) { + finalApiKey = await resolveVertexCredential( + evaluatorConfig.vertexCredential, + ctx.userId, + 'vertex-evaluator' + ) + } + try { const url = buildAPIUrl('/api/providers', ctx.userId ? { userId: ctx.userId } : {}) const providerRequest: Record = { provider: providerId, - model: evaluatorConfig.model, + model, systemPrompt: systemPromptObj.systemPrompt, responseFormat: systemPromptObj.responseFormat, context: stringifyJSON([ @@ -155,11 +192,14 @@ export class EvaluatorBlockHandler implements BlockHandler { const outputTokens = result.tokens?.output || result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION - const cost = resolveProxiedModelCost(result.cost) + const cost = addAutoRoutingCost( + resolveProxiedModelCost(result.cost), + autoRouting?.billableRoutingCost ?? 0 + ) return { content: inputs.content, - model: result.model, + model: autoRouting ? SIM_AUTO_MODEL_ID : result.model, tokens: { input: inputTokens, output: outputTokens, @@ -169,6 +209,7 @@ export class EvaluatorBlockHandler implements BlockHandler { input: cost.input, output: cost.output, total: cost.total, + ...(cost.routing === undefined ? {} : { routing: cost.routing }), }, ...metricScores, } diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 364491017b3..69098fae148 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -1,5 +1,6 @@ import '@sim/testing/mocks/executor' +import { resetEnvMock, setEnv } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler' @@ -114,6 +115,8 @@ describe('MothershipBlockHandler', () => { mockIsRedisCancellationEnabled.mockReset() mockIsRedisCancellationEnabled.mockReturnValue(false) mockReadUserFileContent.mockReset() + // The handler refuses to run without the mothership credential. + setEnv({ COPILOT_API_KEY: 'test-copilot-key' }) block = { id: 'mothership-block-1', @@ -146,6 +149,7 @@ describe('MothershipBlockHandler', () => { vi.useRealTimers() vi.clearAllMocks() vi.unstubAllGlobals() + resetEnvMock() }) function createNdjsonResponse(events: unknown[]): Response { @@ -222,6 +226,15 @@ describe('MothershipBlockHandler', () => { }) }) + it('rejects execution before the internal request when COPILOT_API_KEY is unset', async () => { + setEnv({ COPILOT_API_KEY: undefined }) + + await expect( + handler.execute(context, block, { prompt: 'Hello from workflow' }) + ).rejects.toThrow('COPILOT_API_KEY is not configured') + expect(fetchMock).not.toHaveBeenCalled() + }) + it('rejects execution before the internal request when billing attribution is missing', async () => { context.metadata.billingAttribution = undefined diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 866446b2761..7a082b731e1 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 { env } from '@/lib/core/config/env' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' import { @@ -337,6 +338,12 @@ export class MothershipBlockHandler implements BlockHandler { block: SerializedBlock, inputs: Record ): Promise { + // Without the key the mothership rejects every request, so fail with + // something the workflow author can act on instead of a bare 401. + if (!env.COPILOT_API_KEY) { + throw new Error('COPILOT_API_KEY is not configured, so the Sim Chat block cannot run') + } + const prompt = inputs.prompt if (!prompt || typeof prompt !== 'string') { throw new Error('Prompt input is required') diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index 34d78b14c96..20d485e0f5f 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -532,7 +532,43 @@ describe('runCloudPi', () => { return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) } if (command.includes('pi -p')) { - options.onStdout?.('{"type":"error","error":"model exploded"}\n') + options.onStdout?.( + `${[ + JSON.stringify({ + type: 'message_end', + message: { + role: 'assistant', + content: [{ type: 'text', text: '' }], + usage: { input: 0, output: 0, totalTokens: 0 }, + stopReason: 'error', + errorMessage: 'model rejected sk-byok', + }, + }), + JSON.stringify({ + type: 'turn_end', + message: { + role: 'assistant', + usage: { input: 0, output: 0, totalTokens: 0 }, + stopReason: 'error', + errorMessage: 'model rejected sk-byok', + }, + toolResults: [], + }), + JSON.stringify({ + type: 'agent_end', + willRetry: false, + messages: [ + { + role: 'assistant', + content: [{ type: 'text', text: '' }], + usage: { input: 0, output: 0, totalTokens: 0 }, + stopReason: 'error', + errorMessage: 'model rejected sk-byok', + }, + ], + }), + ].join('\n')}\n` + ) return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) } return Promise.resolve({ @@ -543,9 +579,11 @@ describe('runCloudPi', () => { } ) - await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/model exploded/) + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow( + 'model rejected ***' + ) + expect(mockRun).toHaveBeenCalledTimes(2) expect(mockExecuteTool).not.toHaveBeenCalled() - expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) }) it('fails (no PR) when finalize reports neither no-changes nor a push', async () => { diff --git a/apps/sim/executor/handlers/pi/events.test.ts b/apps/sim/executor/handlers/pi/events.test.ts index c34e41549a3..1df73a40035 100644 --- a/apps/sim/executor/handlers/pi/events.test.ts +++ b/apps/sim/executor/handlers/pi/events.test.ts @@ -52,8 +52,51 @@ describe('normalizePiEvent', () => { ).toEqual({ type: 'usage', inputTokens: 3, outputTokens: 2 }) }) + it('maps a settled agent failure to an error', () => { + expect( + normalizePiEvent({ + type: 'agent_end', + willRetry: false, + messages: [ + { + role: 'assistant', + stopReason: 'error', + errorMessage: 'Invalid API key', + }, + ], + }) + ).toEqual({ type: 'error', message: 'Invalid API key' }) + expect( + normalizePiEvent({ + type: 'agent_end', + messages: [{ role: 'assistant', stopReason: 'aborted' }], + }) + ).toEqual({ type: 'error', message: 'Pi request aborted' }) + }) + + it('does not fail an attempt that Pi will retry', () => { + expect( + normalizePiEvent({ + type: 'agent_end', + willRetry: true, + messages: [ + { + role: 'assistant', + stopReason: 'error', + errorMessage: 'Provider overloaded', + }, + ], + }) + ).toEqual({ type: 'other' }) + }) + it('maps agent_end to final and error to error', () => { - expect(normalizePiEvent({ type: 'agent_end' })).toEqual({ type: 'final' }) + expect( + normalizePiEvent({ + type: 'agent_end', + messages: [{ role: 'assistant', stopReason: 'stop' }], + }) + ).toEqual({ type: 'final' }) expect(normalizePiEvent({ type: 'error', error: 'boom' })).toEqual({ type: 'error', message: 'boom', diff --git a/apps/sim/executor/handlers/pi/events.ts b/apps/sim/executor/handlers/pi/events.ts index 3686a23eb52..4d79683f09a 100644 --- a/apps/sim/executor/handlers/pi/events.ts +++ b/apps/sim/executor/handlers/pi/events.ts @@ -136,8 +136,23 @@ export function normalizePiEvent(raw: unknown): PiEvent | null { const usage = extractUsage(ev) return usage ? { type: 'usage', ...usage } : { type: 'other' } } - case 'agent_end': + case 'agent_end': { + if (ev.willRetry === true) return { type: 'other' } + const messages = Array.isArray(ev.messages) ? ev.messages : [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = asRecord(messages[index]) + if (!message || asString(message.role) !== 'assistant') continue + const stopReason = asString(message.stopReason) + if (stopReason === 'error' || stopReason === 'aborted') { + return { + type: 'error', + message: asString(message.errorMessage) || `Pi request ${stopReason}`, + } + } + break + } return { type: 'final' } + } case 'error': return { type: 'error', diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 3c57fe2de8f..2730366e893 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -3,6 +3,10 @@ import '@sim/testing/mocks/executor' import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +const { mockResolveAutoModel } = vi.hoisted(() => ({ + mockResolveAutoModel: vi.fn(), +})) + vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) vi.mock('@/lib/credentials/access', () => ({ @@ -20,6 +24,13 @@ vi.mock('@/lib/credentials/access', () => ({ }), })) +vi.mock('@/lib/model-router/resolve', () => ({ + addAutoRoutingCost: (cost: Record, routingCost: number) => + routingCost > 0 ? { ...cost, routing: routingCost, total: cost.total + routingCost } : cost, + resolveAutoModel: mockResolveAutoModel, + SIM_AUTO_SYSTEM_PREAMBLE: 'Sim auto system preamble', +})) + import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import { BlockType } from '@/executor/constants' import { RouterBlockHandler } from '@/executor/handlers/router/router-handler' @@ -71,8 +82,16 @@ describe('RouterBlockHandler', () => { mockWorkflow = { blocks: [mockBlock, mockTargetBlock1, mockTargetBlock2], connections: [ - { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-then1' }, - { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' }, + { + source: mockBlock.id, + target: mockTargetBlock1.id, + sourceHandle: 'condition-then1', + }, + { + source: mockBlock.id, + target: mockTargetBlock2.id, + sourceHandle: 'condition-else1', + }, ], } @@ -108,6 +127,12 @@ describe('RouterBlockHandler', () => { }) mockGetProviderFromModel.mockReturnValue('openai') mockGenerateRouterPrompt.mockReturnValue('Generated System Prompt') + mockResolveAutoModel.mockResolvedValue({ + model: 'fireworks/glm-5.2', + tier: '2', + decidedBy: 'llm', + billableRoutingCost: 0.002, + }) mockFetch.mockImplementation(() => { return Promise.resolve({ @@ -126,7 +151,10 @@ describe('RouterBlockHandler', () => { it('should handle router blocks', () => { expect(handler.canHandle(mockBlock)).toBe(true) - const nonRouterBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } } + const nonRouterBlock: SerializedBlock = { + ...mockBlock, + metadata: { id: 'other' }, + } expect(handler.canHandle(nonRouterBlock)).toBe(false) }) @@ -438,6 +466,12 @@ describe('RouterBlockHandler V2', () => { }) mockGetProviderFromModel.mockReturnValue('openai') mockGenerateRouterV2Prompt.mockReturnValue('Generated V2 System Prompt') + mockResolveAutoModel.mockResolvedValue({ + model: 'fireworks/glm-5.2', + tier: '2', + decidedBy: 'llm', + billableRoutingCost: 0.002, + }) }) it('should handle router_v2 blocks', () => { @@ -450,8 +484,16 @@ describe('RouterBlockHandler V2', () => { model: 'gpt-4o', apiKey: 'test-api-key', routes: JSON.stringify([ - { id: 'route-support', title: 'Support', value: 'Customer support inquiries' }, - { id: 'route-sales', title: 'Sales', value: 'Sales and pricing questions' }, + { + id: 'route-support', + title: 'Support', + value: 'Customer support inquiries', + }, + { + id: 'route-sales', + title: 'Sales', + value: 'Sales and pricing questions', + }, ]), } @@ -485,6 +527,68 @@ describe('RouterBlockHandler V2', () => { }) }) + it('resolves sim-auto before executing router V2 and preserves its public identity', async () => { + const inputs = { + context: 'How do I get Tableau on my work laptop?', + model: 'sim-auto', + routes: [ + { id: 'route-support', title: 'Support', value: 'Something is broken' }, + { + id: 'route-sales', + title: 'Request', + value: 'User wants something new', + }, + ], + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + content: JSON.stringify({ + route: 'route-sales', + reasoning: 'This is a new request.', + }), + model: 'fireworks/glm-5.2', + tokens: { input: 100, output: 20, total: 120 }, + cost: { input: 0.001, output: 0.0005, total: 0.0015 }, + }), + }) + + const result = await handler.execute(mockContext, mockRouterV2Block, inputs) + + expect(mockResolveAutoModel).toHaveBeenCalledWith({ + ctx: mockContext, + blockId: mockRouterV2Block.id, + signals: expect.objectContaining({ + lastMessage: inputs.context, + messageCount: 1, + toolNames: [], + mediaKind: 'none', + hasResponseFormat: true, + }), + fallbackModel: 'claude-sonnet-5', + }) + expect(mockGetProviderFromModel).toHaveBeenCalledWith('fireworks/glm-5.2') + + const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(requestBody).toMatchObject({ + provider: 'openai', + model: 'fireworks/glm-5.2', + systemPrompt: 'Sim auto system preamble\n\nGenerated V2 System Prompt', + }) + expect(result).toMatchObject({ + model: 'sim-auto', + selectedRoute: 'route-sales', + cost: { + input: 0.001, + output: 0.0005, + routing: 0.002, + total: 0.0035, + }, + }) + }) + it('should include responseFormat in provider request', async () => { const inputs = { context: 'Test context', @@ -498,7 +602,10 @@ describe('RouterBlockHandler V2', () => { ok: true, json: () => Promise.resolve({ - content: JSON.stringify({ route: 'route-1', reasoning: 'Test reasoning' }), + content: JSON.stringify({ + route: 'route-1', + reasoning: 'Test reasoning', + }), model: 'gpt-4o', tokens: { input: 100, output: 20, total: 120 }, }), @@ -572,7 +679,10 @@ describe('RouterBlockHandler V2', () => { ok: true, json: () => Promise.resolve({ - content: JSON.stringify({ route: 'invalid-route', reasoning: 'Some reasoning' }), + content: JSON.stringify({ + route: 'invalid-route', + reasoning: 'Some reasoning', + }), model: 'gpt-4o', tokens: { input: 100, output: 20, total: 120 }, }), @@ -597,7 +707,10 @@ describe('RouterBlockHandler V2', () => { ok: true, json: () => Promise.resolve({ - content: JSON.stringify({ route: 'route-1', reasoning: 'Matched route 1' }), + content: JSON.stringify({ + route: 'route-1', + reasoning: 'Matched route 1', + }), model: 'gpt-4o', tokens: { input: 100, output: 20, total: 120 }, }), diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 55184c40611..0a4961d7095 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -1,5 +1,11 @@ import { createLogger } from '@sim/logger' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { + type AutoRoutingResult, + addAutoRoutingCost, + resolveAutoModel, + SIM_AUTO_SYSTEM_PREAMBLE, +} from '@/lib/model-router/resolve' import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import type { BlockOutput } from '@/blocks/types' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' @@ -14,6 +20,7 @@ import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' +import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' @@ -72,16 +79,23 @@ export class RouterBlockHandler implements BlockHandler { bedrockRegion: inputs.bedrockRegion, } - await validateModelProvider(ctx.userId, ctx.workspaceId, routerConfig.model, ctx) - - const providerId = getProviderFromModel(routerConfig.model) - try { const url = new URL('/api/providers', getInternalApiBaseUrl()) if (ctx.userId) url.searchParams.set('userId', ctx.userId) const messages = [{ role: 'user', content: routerConfig.prompt }] const systemPrompt = generateRouterPrompt(routerConfig.prompt, targetBlocks) + const resolved = await this.resolveModel( + ctx, + block.id, + routerConfig.model, + systemPrompt, + routerConfig.prompt, + false + ) + + await validateModelProvider(ctx.userId, ctx.workspaceId, resolved.model, ctx) + const providerId = getProviderFromModel(resolved.model) let finalApiKey: string | undefined = routerConfig.apiKey if (providerId === 'vertex' && routerConfig.vertexCredential) { @@ -94,8 +108,8 @@ export class RouterBlockHandler implements BlockHandler { const providerRequest: Record = { provider: providerId, - model: routerConfig.model, - systemPrompt: systemPrompt, + model: resolved.model, + systemPrompt: resolved.systemPrompt, context: JSON.stringify(messages), temperature: ROUTER.INFERENCE_TEMPERATURE, apiKey: finalApiKey, @@ -146,11 +160,14 @@ export class RouterBlockHandler implements BlockHandler { total: DEFAULTS.TOKENS.TOTAL, } - const cost = resolveProxiedModelCost(result.cost) + const cost = addAutoRoutingCost( + resolveProxiedModelCost(result.cost), + resolved.autoRouting?.billableRoutingCost ?? 0 + ) return { prompt: inputs.prompt, - model: result.model, + model: resolved.autoRouting ? SIM_AUTO_MODEL_ID : result.model, tokens: { input: tokens.input || DEFAULTS.TOKENS.PROMPT, output: tokens.output || DEFAULTS.TOKENS.COMPLETION, @@ -160,6 +177,7 @@ export class RouterBlockHandler implements BlockHandler { input: cost.input, output: cost.output, total: cost.total, + ...(cost.routing === undefined ? {} : { routing: cost.routing }), }, selectedPath: { blockId: chosenBlock.id, @@ -201,16 +219,23 @@ export class RouterBlockHandler implements BlockHandler { bedrockRegion: inputs.bedrockRegion, } - await validateModelProvider(ctx.userId, ctx.workspaceId, routerConfig.model, ctx) - - const providerId = getProviderFromModel(routerConfig.model) - try { const url = new URL('/api/providers', getInternalApiBaseUrl()) if (ctx.userId) url.searchParams.set('userId', ctx.userId) const messages = [{ role: 'user', content: routerConfig.context }] const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes) + const resolved = await this.resolveModel( + ctx, + block.id, + routerConfig.model, + systemPrompt, + routerConfig.context, + true + ) + + await validateModelProvider(ctx.userId, ctx.workspaceId, resolved.model, ctx) + const providerId = getProviderFromModel(resolved.model) let finalApiKey: string | undefined = routerConfig.apiKey if (providerId === 'vertex' && routerConfig.vertexCredential) { @@ -223,8 +248,8 @@ export class RouterBlockHandler implements BlockHandler { const providerRequest: Record = { provider: providerId, - model: routerConfig.model, - systemPrompt: systemPrompt, + model: resolved.model, + systemPrompt: resolved.systemPrompt, context: JSON.stringify(messages), temperature: ROUTER.INFERENCE_TEMPERATURE, apiKey: finalApiKey, @@ -303,7 +328,10 @@ export class RouterBlockHandler implements BlockHandler { const chosenRoute = routes.find((r) => r.id === chosenRouteId) if (!chosenRoute) { - const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title })) + const availableRoutes = routes.map((r) => ({ + id: r.id, + title: r.title, + })) logger.error( `Invalid routing decision. Response content: "${result.content}". Available routes:`, availableRoutes @@ -327,11 +355,14 @@ export class RouterBlockHandler implements BlockHandler { total: DEFAULTS.TOKENS.TOTAL, } - const cost = resolveProxiedModelCost(result.cost) + const cost = addAutoRoutingCost( + resolveProxiedModelCost(result.cost), + resolved.autoRouting?.billableRoutingCost ?? 0 + ) return { context: inputs.context, - model: result.model, + model: resolved.autoRouting ? SIM_AUTO_MODEL_ID : result.model, tokens: { input: tokens.input || DEFAULTS.TOKENS.PROMPT, output: tokens.output || DEFAULTS.TOKENS.COMPLETION, @@ -341,6 +372,7 @@ export class RouterBlockHandler implements BlockHandler { input: cost.input, output: cost.output, total: cost.total, + ...(cost.routing === undefined ? {} : { routing: cost.routing }), }, selectedRoute: chosenRoute.id, reasoning, @@ -380,6 +412,53 @@ export class RouterBlockHandler implements BlockHandler { } } + private async resolveModel( + ctx: ExecutionContext, + blockId: string, + configuredModel: string, + systemPrompt: string, + lastMessage: unknown, + hasResponseFormat: boolean + ): Promise<{ + model: string + systemPrompt: string + autoRouting: AutoRoutingResult | null + }> { + if (!isAutoModel(configuredModel)) { + return { model: configuredModel, systemPrompt, autoRouting: null } + } + + const message = + typeof lastMessage === 'string' ? lastMessage : JSON.stringify(lastMessage ?? '') + const autoRouting = await resolveAutoModel({ + ctx, + blockId, + signals: { + systemPrompt, + lastMessage: message, + messageCount: 1, + toolNames: [], + mediaKind: 'none', + hasResponseFormat, + approxInputTokens: Math.ceil((systemPrompt.length + message.length) / 4), + }, + fallbackModel: ROUTER.DEFAULT_MODEL, + }) + + logger.info('Resolved sim-auto model for router', { + blockId, + model: autoRouting.model, + tier: autoRouting.tier, + decidedBy: autoRouting.decidedBy, + }) + + return { + model: autoRouting.model, + systemPrompt: [SIM_AUTO_SYSTEM_PREAMBLE, systemPrompt].filter(Boolean).join('\n\n'), + autoRouting, + } + } + private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) { return ctx.workflow?.connections .filter((conn) => conn.source === block.id) diff --git a/apps/sim/hooks/queries/workspace-file-folders.ts b/apps/sim/hooks/queries/workspace-file-folders.ts index 8b8cb87fe6e..40010ab03c8 100644 --- a/apps/sim/hooks/queries/workspace-file-folders.ts +++ b/apps/sim/hooks/queries/workspace-file-folders.ts @@ -40,7 +40,7 @@ async function fetchWorkspaceFileFolders( return data.folders } -function invalidateWorkspaceFileBrowsers( +export function invalidateWorkspaceFileBrowsers( queryClient: ReturnType, workspaceId: string ) { diff --git a/apps/sim/hooks/use-mothership-chat-events.ts b/apps/sim/hooks/use-mothership-chat-events.ts index fe49bcb8283..0110e67a535 100644 --- a/apps/sim/hooks/use-mothership-chat-events.ts +++ b/apps/sim/hooks/use-mothership-chat-events.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' const logger = createLogger('MothershipChatEvents') @@ -125,12 +126,16 @@ export function handleMothershipChatStatusEvent( /** * Subscribes to chat status SSE events and invalidates chat caches on changes. * The SSE event name remains `task_status` for wire compatibility. + * + * No-ops when Chat is disabled — this is mounted from the persistent sidebar, so + * without the guard every session would hold an open connection to an endpoint + * that cannot serve it. */ export function useMothershipChatEvents(workspaceId: string | undefined) { const queryClient = useQueryClient() useEffect(() => { - if (!workspaceId) return + if (!workspaceId || !isChatEnabled) return const eventSource = new EventSource( `/api/mothership/events?workspaceId=${encodeURIComponent(workspaceId)}` diff --git a/apps/sim/lib/api/contracts/file-doc.ts b/apps/sim/lib/api/contracts/file-doc.ts new file mode 100644 index 00000000000..c3f5b1d610c --- /dev/null +++ b/apps/sim/lib/api/contracts/file-doc.ts @@ -0,0 +1,152 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const buildFileDocSeedBodySchema = z.object({ + workspaceId: z.string().min(1, 'workspaceId is required'), + fileId: z.string().min(1, 'fileId is required'), +}) +export type BuildFileDocSeedBody = z.input + +export const buildFileDocSeedResponseSchema = z.object({ + /** + * The base64-encoded Yjs update that seeds the file's collaborative document, or `null` when the + * file is missing/deleted (the caller treats that as an empty document). + */ + update: z.string().nullable(), + /** + * The file's durable `updatedAt` (epoch ms) this seed was built from — the version the relay records + * as what its freshly-seeded live doc is synced to (for the persist optimistic-concurrency guard). + * `null` when the file is missing. + */ + version: z.number().int().nullable(), +}) +export type BuildFileDocSeedResponse = z.output + +/** + * Internal, `x-api-key`-gated: the realtime relay asks the app to build a server-authoritative seed + * for a file's collaborative document (markdown → Yjs, through the exact client engine). Only the app + * can do this — it owns the editor extensions + blob access — so realtime delegates instead of + * embedding a second markdown/ProseMirror stack. + */ +export const buildFileDocSeedContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/file-doc/seed', + body: buildFileDocSeedBodySchema, + response: { + mode: 'json', + schema: buildFileDocSeedResponseSchema, + }, +}) + +export const mergeFileDocBodySchema = z.object({ + /** For logging/observability only — the merge itself is stateless. */ + fileId: z.string().min(1, 'fileId is required'), + /** + * Base64-encoded `Y.encodeStateAsUpdate` of the live document as the relay currently holds it. The + * bound is generous headroom over a max-size collab doc's Yjs state (base64-inflated), not a tuned + * limit — collab is gated to ≤256 KB documents client-side. + */ + docState: z + .string() + .min(1, 'docState is required') + .max(16 * 1024 * 1024, 'docState is too large'), + /** The full markdown body the caller (e.g. copilot) wants the document to become. */ + markdown: z.string().max(8 * 1024 * 1024, 'markdown is too large'), +}) +export type MergeFileDocBody = z.input + +export const mergeFileDocResponseSchema = z.object({ + /** + * Base64-encoded Yjs update — the minimal CRDT diff that transforms the supplied document state + * into `markdown`. The relay applies it to the live doc, which merges it with any concurrent user + * edits and relays it to every connected editor. Empty (a no-op update) when nothing changed. + */ + update: z.string(), +}) +export type MergeFileDocResponse = z.output + +/** + * Internal, `x-api-key`-gated: the realtime relay asks the app to merge new markdown into a live + * collaborative document as a minimal CRDT diff (Stage C — copilot writing into an open doc). The app + * owns the conversion engine; the relay owns the doc — so the relay ships the current doc state here, + * gets back the diff, and applies + relays it. Stateless (no blob/DB access): the caller supplies both + * the current state and the target markdown. + */ +export const mergeFileDocContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/file-doc/merge', + body: mergeFileDocBodySchema, + response: { + mode: 'json', + schema: mergeFileDocResponseSchema, + }, +}) + +export const persistFileDocBodySchema = z.object({ + workspaceId: z.string().min(1, 'workspaceId is required'), + fileId: z.string().min(1, 'fileId is required'), + /** Attribution only (blob metadata) — the last collaborator to touch the live doc. */ + userId: z.string().min(1, 'userId is required'), + /** + * Base64-encoded `Y.encodeStateAsUpdate` of the live document as the relay currently holds it — the + * bound matches the merge contract (generous base64 headroom over a ≤256 KB collab doc's Yjs state). + */ + docState: z + .string() + .min(1, 'docState is required') + .max(16 * 1024 * 1024, 'docState is too large'), + /** + * Optimistic-concurrency guard (RFC 7232 `If-Match`): the durable `updatedAt` (epoch ms) the relay's + * live doc last synced from. The write commits only if the file is still at this version; otherwise + * the response is `status: 'conflict'` and nothing is written. Omit for an unconditional first write. + */ + expectedVersion: z.number().int().optional(), +}) +export type PersistFileDocBody = z.input + +export const persistFileDocResponseSchema = z.discriminatedUnion('status', [ + z.object({ + /** Projected to markdown and written durably. `version` is the new durable `updatedAt` (epoch ms). */ + status: z.literal('persisted'), + version: z.number().int(), + }), + z.object({ + /** The file was missing/deleted — nothing to write. */ + status: z.literal('missing'), + }), + z.object({ + /** + * Couldn't persist safely right now: no `expectedVersion` was supplied for a non-empty file (the + * relay's synced-version token was momentarily unavailable). Nothing was written and NO reconcile + * should run — the live edits stay in the stream and a later persist retries once the version is + * re-established. Distinct from `conflict`, which signals a genuine out-of-band change to merge. + */ + status: z.literal('deferred'), + }), + z.object({ + /** + * The file changed out-of-band since `expectedVersion` — the write was refused to avoid clobbering it. + * The relay leaves the durable content authoritative and does not re-persist here (a later flush + * reconciles once the chokepoint merge lands), so it reads nothing off this result beyond the status. + */ + status: z.literal('conflict'), + }), +]) +export type PersistFileDocResponse = z.output + +/** + * Internal, `x-api-key`-gated: the realtime relay asks the app to project a live collaborative + * document back to durable markdown (Yjs → markdown, through the exact editor engine) and write it to + * the file. The relay owns the live doc but not the conversion engine or blob/DB access, so it ships + * the current doc state here. Called debounced during editing and when the last collaborator leaves — + * the server-authoritative durable path that replaces the editor's client-side autosave. + */ +export const persistFileDocContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/file-doc/persist', + body: persistFileDocBodySchema, + response: { + mode: 'json', + schema: persistFileDocResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/knowledge/search.ts b/apps/sim/lib/api/contracts/knowledge/search.ts index ea1dff75ce0..3550311f21d 100644 --- a/apps/sim/lib/api/contracts/knowledge/search.ts +++ b/apps/sim/lib/api/contracts/knowledge/search.ts @@ -10,6 +10,19 @@ export const knowledgeSearchTagFilterSchema = z.object({ valueTo: z.union([z.string(), z.number()]).optional(), }) +export const KNOWLEDGE_SEARCH_MODES = ['vector', 'hybrid'] as const + +/** + * Shared by the internal and v1 search contracts. Defaults to `vector` so every + * existing caller keeps its current ranking; hybrid is opt-in. + */ +export const knowledgeSearchModeSchema = z + .enum(KNOWLEDGE_SEARCH_MODES) + .optional() + .nullable() + .default('vector') + .transform((val) => val ?? 'vector') + export const knowledgeSearchBodySchema = z .object({ knowledgeBaseIds: z.union([ @@ -34,6 +47,12 @@ export const knowledgeSearchBodySchema = z .optional() .nullable() .transform((val) => val || undefined), + /** + * `vector` (default) is semantic-only retrieval. `hybrid` additionally runs a + * full-text leg and fuses the two by reciprocal rank, which recovers exact + * tokens (error codes, ticket keys, identifiers) that embeddings rank poorly. + */ + searchMode: knowledgeSearchModeSchema, rerankerEnabled: z.boolean().optional().default(false), rerankerModel: rerankerModelSchema.optional().default(DEFAULT_RERANKER_MODEL), /** diff --git a/apps/sim/lib/api/contracts/v1/knowledge/index.ts b/apps/sim/lib/api/contracts/v1/knowledge/index.ts index 0134da1147a..76abfcf16a0 100644 --- a/apps/sim/lib/api/contracts/v1/knowledge/index.ts +++ b/apps/sim/lib/api/contracts/v1/knowledge/index.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { knowledgeSearchModeSchema } from '@/lib/api/contracts/knowledge/search' import { knowledgeBaseParamsSchema, knowledgeDocumentParamsSchema, @@ -133,6 +134,11 @@ export const v1KnowledgeSearchBodySchema = z query: z.string().optional(), topK: z.number().min(1).max(100).default(10), tagFilters: z.array(v1SearchTagFilterSchema).optional(), + /** + * `vector` (default) is semantic-only retrieval; `hybrid` fuses a full-text + * leg with it by reciprocal rank. + */ + searchMode: knowledgeSearchModeSchema, }) .refine( (data) => { diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index d4306a28b34..5aa70b7cd5f 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -24,6 +24,7 @@ import { hasUsableSubscriptionAccess, USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' +import { env } from '@/lib/core/config/env' import { isAccessControlEnabled, isBillingEnabled, @@ -622,7 +623,13 @@ async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise * the workspace's organization, or its billed account for personal workspaces, * is on a Max or enterprise plan. * - * Returns true if: + * Always false without `COPILOT_API_KEY` — inbox tasks are executed by the + * mothership and answered with a link to the resulting chat, so neither half + * works without it. That check comes first because the `!isBillingEnabled` + * shortcut below would otherwise hand every self-hosted deployment a broken + * Inbox. + * + * Otherwise returns true if: * - INBOX_ENABLED env var is set (self-hosted override), OR * - billing is disabled, OR * - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR @@ -630,6 +637,7 @@ async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise */ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { try { + if (!env.COPILOT_API_KEY) return false if (isInboxEnabled) return true if (!isBillingEnabled) return true return await hasMaxTierWorkspaceAccess(workspaceId) diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index c855f49ea94..3d98e15e3c5 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -8,7 +8,14 @@ * * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' afterAll(() => { @@ -56,22 +63,52 @@ vi.mock('@/lib/billing/credits/daily-refresh', () => ({ getOrgMemberRefreshBounds: vi.fn(), })) +const { + mockGetEmailSubject, + mockGetLimitEmailSubject, + mockRenderCreditsExhausted, + mockRenderFreeTierUpgrade, + mockRenderUsageLimitReached, + mockRenderUsageThreshold, + mockSendEmail, + mockGetEmailPreferences, + mockIsOrgAdminRole, +} = vi.hoisted(() => ({ + mockGetEmailSubject: vi.fn(() => 'Subject'), + mockGetLimitEmailSubject: vi.fn(() => 'Limit subject'), + mockRenderCreditsExhausted: vi.fn(() => Promise.resolve('free')), + mockRenderFreeTierUpgrade: vi.fn(() => Promise.resolve('nudge')), + mockRenderUsageLimitReached: vi.fn(() => Promise.resolve('reached')), + mockRenderUsageThreshold: vi.fn(() => Promise.resolve('warning')), + mockSendEmail: vi.fn(() => Promise.resolve({ success: true })), + mockGetEmailPreferences: vi.fn(() => Promise.resolve(null as unknown)), + mockIsOrgAdminRole: vi.fn(() => true), +})) + vi.mock('@/components/emails', () => ({ - getEmailSubject: vi.fn(), - renderCreditsExhaustedEmail: vi.fn(), - renderFreeTierUpgradeEmail: vi.fn(), - renderUsageThresholdEmail: vi.fn(), + getEmailSubject: mockGetEmailSubject, + getLimitEmailSubject: mockGetLimitEmailSubject, + renderCreditsExhaustedEmail: mockRenderCreditsExhausted, + renderFreeTierUpgradeEmail: mockRenderFreeTierUpgrade, + renderUsageLimitReachedEmail: mockRenderUsageLimitReached, + renderUsageThresholdEmail: mockRenderUsageThreshold, })) vi.mock('@/lib/messaging/email/mailer', () => ({ - sendEmail: vi.fn(), + sendEmail: mockSendEmail, })) vi.mock('@/lib/messaging/email/unsubscribe', () => ({ - getEmailPreferences: vi.fn(), + getEmailPreferences: mockGetEmailPreferences, })) -import { getUserUsageLimit, syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +vi.mock('@sim/platform-authz/workspace', () => ({ isOrgAdminRole: mockIsOrgAdminRole })) + +import { + getUserUsageLimit, + maybeSendUsageThresholdEmail, + syncUsageLimitsFromSubscription, +} from '@/lib/billing/core/usage' const PRO_SUBSCRIPTION = { id: 'sub-1', @@ -232,3 +269,159 @@ describe('syncUsageLimitsFromSubscription', () => { expect(expression).toContain('creditBalance') }) }) + +describe('maybeSendUsageThresholdEmail', () => { + const paidUser = { + scope: 'user' as const, + planName: 'Pro', + userId: 'user-1', + userEmail: 'user-1@example.com', + userName: 'Ada', + workspaceId: 'ws-1', + limit: 20, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isBillingEnabled: true }) + mockGetEmailPreferences.mockResolvedValue(null) + mockIsOrgAdminRole.mockReturnValue(true) + }) + + afterAll(() => { + resetEnvFlagsMock() + }) + + it('emails a paid personal account at 100% with the raise-your-limit template', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 20, + }) + + expect(mockRenderUsageLimitReached).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'user', planName: 'Pro' }) + ) + expect(mockRenderCreditsExhausted).not.toHaveBeenCalled() + expect(mockGetLimitEmailSubject).toHaveBeenCalledWith('credits', 'reached') + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + + it('links a paid personal account to billing settings, not the upgrade page', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 20, + }) + + const props = mockRenderUsageLimitReached.mock.calls[0]?.[0] as { ctaLink: string } + expect(props.ctaLink).toContain('/account/settings/billing') + }) + + it('fans out to org admins at 100% and skips non-admin members', async () => { + mockIsOrgAdminRole.mockImplementation((role: unknown) => role === 'admin') + queueTableRows(schemaMock.member, [ + { email: 'admin@example.com', name: 'Admin', enabled: null, role: 'admin' }, + { email: 'member@example.com', name: 'Member', enabled: null, role: 'member' }, + ]) + + await maybeSendUsageThresholdEmail({ + scope: 'organization', + planName: 'Team', + organizationId: 'org-1', + workspaceId: 'ws-1', + percentBefore: 95, + percentAfter: 100, + currentUsageAfter: 500, + limit: 500, + }) + + expect(mockSendEmail).toHaveBeenCalledTimes(1) + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'admin@example.com', emailType: 'notifications' }) + ) + expect(mockRenderUsageLimitReached).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'organization' }) + ) + }) + + it('still sends the free-tier template to a free personal account at 100%', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + planName: 'Free', + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 10, + limit: 10, + }) + + expect(mockRenderCreditsExhausted).toHaveBeenCalledTimes(1) + expect(mockRenderUsageLimitReached).not.toHaveBeenCalled() + expect(mockGetEmailSubject).toHaveBeenCalledWith('free-tier-exhausted') + }) + + it('sends only the reached email when one execution crosses 80 and 100 together', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 70, + percentAfter: 100, + currentUsageAfter: 20, + }) + + expect(mockRenderUsageThreshold).not.toHaveBeenCalled() + expect(mockRenderUsageLimitReached).toHaveBeenCalledTimes(1) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + + it('still sends the 80% warning to a paid account that has not reached 100%', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 70, + percentAfter: 85, + currentUsageAfter: 17, + }) + + expect(mockRenderUsageThreshold).toHaveBeenCalledTimes(1) + expect(mockRenderUsageLimitReached).not.toHaveBeenCalled() + }) + + it('does not send when the recipient unsubscribed from notifications', async () => { + mockGetEmailPreferences.mockResolvedValue({ unsubscribeNotifications: true }) + + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 20, + }) + + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('does not send when the per-user billing toggle is off', async () => { + queueTableRows(schemaMock.settings, [{ enabled: false }]) + + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 90, + percentAfter: 100, + currentUsageAfter: 20, + }) + + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('does nothing when no threshold is crossed', async () => { + await maybeSendUsageThresholdEmail({ + ...paidUser, + percentBefore: 50, + percentAfter: 60, + currentUsageAfter: 12, + }) + + expect(mockSendEmail).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 78b96fe8267..b68d1982623 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -6,8 +6,10 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull, sql } from 'drizzle-orm' import { getEmailSubject, + getLimitEmailSubject, renderCreditsExhaustedEmail, renderFreeTierUpgradeEmail, + renderUsageLimitReachedEmail, renderUsageThresholdEmail, } from '@/components/emails' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' @@ -863,36 +865,18 @@ export async function maybeSendUsageThresholdEmail(params: { // Check for 80% threshold crossing — used for paid users (budget warning) and free users (upgrade nudge) const crosses80 = params.percentBefore < 80 && params.percentAfter >= 80 - // Check for 100% threshold (free users only — credits exhausted) + // Check for 100% threshold — every plan and scope (usage limit reached) const crosses100 = params.percentBefore < 100 && params.percentAfter >= 100 // Skip if no thresholds crossed if (!crosses80 && !crosses100) return - // For 80% threshold email (paid users only) - if (crosses80 && !isFreeUser) { - const ctaLink = billingSettingsLink - const sendTo = async (email: string, name?: string) => { - const prefs = await getEmailPreferences(email) - if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return - - const html = await renderUsageThresholdEmail({ - userName: name, - planName: params.planName, - percentUsed: Math.min(100, Math.round(params.percentAfter)), - currentUsage: params.currentUsageAfter, - limit: params.limit, - ctaLink, - }) - - await sendEmail({ - to: email, - subject: getEmailSubject('usage-threshold'), - html, - emailType: 'notifications', - }) - } - + /** + * Delivers to the account's notification recipients: the payer for personal + * scope, every org admin/owner for organization scope. Honors the per-user + * billing-notification toggle in both. + */ + const deliverToScope = async (send: (email: string, name?: string) => Promise) => { if (params.scope === 'user' && params.userId && params.userEmail) { const rows = await db .select({ enabled: settings.billingUsageNotificationsEnabled }) @@ -900,8 +884,11 @@ export async function maybeSendUsageThresholdEmail(params: { .where(eq(settings.userId, params.userId)) .limit(1) if (rows.length > 0 && rows[0].enabled === false) return - await sendTo(params.userEmail, params.userName) - } else if (params.scope === 'organization' && params.organizationId) { + await send(params.userEmail, params.userName) + return + } + + if (params.scope === 'organization' && params.organizationId) { const admins = await db .select({ email: user.email, @@ -915,19 +902,43 @@ export async function maybeSendUsageThresholdEmail(params: { .where(eq(member.organizationId, params.organizationId)) for (const a of admins) { - const isAdmin = isOrgAdminRole(a.role) - if (!isAdmin) continue + if (!isOrgAdminRole(a.role)) continue if (a.enabled === false) continue if (!a.email) continue - await sendTo(a.email, a.name || undefined) + await send(a.email, a.name || undefined) } } } + // !crosses100: one "reached" email, not a "nearing" and a "reached" in the same moment + if (crosses80 && !isFreeUser && !crosses100) { + const ctaLink = billingSettingsLink + await deliverToScope(async (email, name) => { + const prefs = await getEmailPreferences(email) + if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return + + const html = await renderUsageThresholdEmail({ + userName: name, + planName: params.planName, + percentUsed: Math.min(100, Math.round(params.percentAfter)), + currentUsage: params.currentUsageAfter, + limit: params.limit, + ctaLink, + }) + + await sendEmail({ + to: email, + subject: getEmailSubject('usage-threshold'), + html, + emailType: 'notifications', + }) + }) + } + // For 80% threshold email (free users only — skip if they also crossed 100% in same call) if (crosses80 && isFreeUser && !crosses100) { const upgradeLink = upgradeCreditsLink - const sendFreeTierEmail = async (email: string, name?: string) => { + await deliverToScope(async (email, name) => { const prefs = await getEmailPreferences(email) if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return @@ -952,56 +963,49 @@ export async function maybeSendUsageThresholdEmail(params: { currentUsage: params.currentUsageAfter, limit: params.limit, }) - } - - // Free users are always individual scope (not organization) - if (params.scope === 'user' && params.userId && params.userEmail) { - const rows = await db - .select({ enabled: settings.billingUsageNotificationsEnabled }) - .from(settings) - .where(eq(settings.userId, params.userId)) - .limit(1) - if (rows.length > 0 && rows[0].enabled === false) return - await sendFreeTierEmail(params.userEmail, params.userName) - } + }) } - // For 100% threshold email (free users only — credits exhausted) - if (crosses100 && isFreeUser) { - const upgradeLink = upgradeCreditsLink - const sendExhaustedEmail = async (email: string, name?: string) => { + // Paid and org accounts get raise-your-limit copy — upgrading is not their remedy + if (crosses100) { + const useFreeCopy = isFreeUser && params.scope === 'user' + + await deliverToScope(async (email, name) => { const prefs = await getEmailPreferences(email) if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return - const html = await renderCreditsExhaustedEmail({ - userName: name, - limit: params.limit, - upgradeLink, - }) + const html = useFreeCopy + ? await renderCreditsExhaustedEmail({ + userName: name, + limit: params.limit, + upgradeLink: upgradeCreditsLink, + }) + : await renderUsageLimitReachedEmail({ + userName: name, + planName: params.planName, + scope: params.scope, + currentUsage: params.currentUsageAfter, + limit: params.limit, + ctaLink: billingSettingsLink, + }) await sendEmail({ to: email, - subject: getEmailSubject('free-tier-exhausted'), + subject: useFreeCopy + ? getEmailSubject('free-tier-exhausted') + : getLimitEmailSubject('credits', 'reached'), html, emailType: 'notifications', }) - logger.info('Free tier credits exhausted email sent', { + logger.info('Usage limit reached email sent', { email, + scope: params.scope, + planName: params.planName, currentUsage: params.currentUsageAfter, limit: params.limit, }) - } - - if (params.scope === 'user' && params.userId && params.userEmail) { - const rows = await db - .select({ enabled: settings.billingUsageNotificationsEnabled }) - .from(settings) - .where(eq(settings.userId, params.userId)) - .limit(1) - if (rows.length > 0 && rows[0].enabled === false) return - await sendExhaustedEmail(params.userEmail, params.userName) - } + }) } } catch (error) { logger.error('Failed to send usage threshold email', { diff --git a/apps/sim/lib/billing/upgrade-reasons.test.ts b/apps/sim/lib/billing/upgrade-reasons.test.ts index 5505a576de6..2d5a5b785f4 100644 --- a/apps/sim/lib/billing/upgrade-reasons.test.ts +++ b/apps/sim/lib/billing/upgrade-reasons.test.ts @@ -3,7 +3,9 @@ */ import { describe, expect, it } from 'vitest' import { + buildHostedUpgradeUrl, buildUpgradeHref, + HOSTED_BILLING_SETTINGS_URL, isUpgradeReason, UPGRADE_REASON_COPY, UPGRADE_REASONS, @@ -31,6 +33,12 @@ describe('upgrade-reasons', () => { expect(buildUpgradeHref('ws-1', 'tables')).toBe('/workspace/ws-1/upgrade?reason=tables') }) + it('builds absolute hosted URLs for self-hosted deployments', () => { + expect(buildHostedUpgradeUrl()).toBe('https://www.sim.ai/upgrade') + expect(buildHostedUpgradeUrl('credits')).toBe('https://www.sim.ai/upgrade?reason=credits') + expect(HOSTED_BILLING_SETTINGS_URL).toBe('https://www.sim.ai/account/settings/billing') + }) + it('guards known reasons', () => { expect(isUpgradeReason('storage')).toBe(true) expect(isUpgradeReason('seats')).toBe(true) diff --git a/apps/sim/lib/billing/upgrade-reasons.ts b/apps/sim/lib/billing/upgrade-reasons.ts index 9e8986f1938..7d25d93fe25 100644 --- a/apps/sim/lib/billing/upgrade-reasons.ts +++ b/apps/sim/lib/billing/upgrade-reasons.ts @@ -2,10 +2,11 @@ * Upgrade-reason registry. * * Single source of truth for the language shown when a user is routed to the - * upgrade page after hitting a usage limit. The same copy drives both the - * upgrade-page header and the threshold/limit emails, so the in-app and email - * journeys never drift apart. + * upgrade page after hitting a usage limit, and for where that route points. + * The same copy drives both the upgrade-page header and the threshold/limit + * emails, so the in-app and email journeys never drift apart. */ +import { SITE_URL } from '@/lib/core/utils/urls' /** The limit categories that can route a user to the upgrade page. */ export const UPGRADE_REASONS = ['credits', 'storage', 'tables', 'seats'] as const @@ -86,3 +87,20 @@ export function buildUpgradeHref(workspaceId: string, reason?: UpgradeReason): s const base = `/workspace/${workspaceId}/upgrade` return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base } + +/** + * Absolute upgrade URL on the hosted app. + * + * Self-hosted deployments talk to Chat through a Chat key issued by the user's + * sim.ai account, so their plan and credits live there rather than on the local + * instance. A local workspace id is meaningless on the hosted app, so this + * points at the account-scoped `/upgrade` entry, which resolves the signed-in + * user's own workspace. + */ +export function buildHostedUpgradeUrl(reason?: UpgradeReason): string { + const base = `${SITE_URL}/upgrade` + return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base +} + +/** Account billing settings on the hosted app, for raising a usage limit. */ +export const HOSTED_BILLING_SETTINGS_URL = `${SITE_URL}/account/settings/billing` as const diff --git a/apps/sim/lib/collab-doc/README.md b/apps/sim/lib/collab-doc/README.md new file mode 100644 index 00000000000..4613bb9c3b7 --- /dev/null +++ b/apps/sim/lib/collab-doc/README.md @@ -0,0 +1,53 @@ +# `@/lib/collab-doc` — server-side collaborative-document conversion + +Server-side conversion between a file's **markdown** (the durable source of truth) and its +collaborative **Yjs document**, so the server can own the doc: seed it, project it back to +markdown, and let the agent write into it while a user is typing. + +## Why this exists + +Collaborative file editing had two writers with no shared CRDT: copilot `edit_content` wrote +markdown straight to the file while the user typed into an ephemeral, client-seeded Yjs doc. They +couldn't reconcile — the agent's edit didn't stream into the editor, and last-writer clobbered. The +fix is a **server-authoritative Yjs doc** both sides write into, with markdown as a projection. + +## What Stage A (this module) provides + +| Function | Purpose | +|---|---| +| `markdownToYDoc(md)` | Cold-start seed: file markdown → a fresh `Y.Doc`. | +| `yDocToMarkdown(ydoc)` | Projection: `Y.Doc` → the file's canonical markdown. | +| `applyMarkdownToYDoc(ydoc, md)` | Agent write: merge new content into a live `Y.Doc` as a minimal CRDT diff (no clobber). | + +### Design decisions (why it's not hacky) + +- **Parity by construction.** The markdown↔ProseMirror step reuses the *exact* client engine + (`parseMarkdownToDoc` / `serializeDocToMarkdown`, `@tiptap/markdown` on the shared extension set) — + not a second markdown implementation — so the server can never diverge from what the editor + renders. The custom-fidelity constructs (tables, footnotes, raw HTML, `sim:` mentions) are covered + by the same code that covers them in the browser; the round-trip test asserts equivalence. +- **Same Yjs binding as the browser.** ProseMirror↔Yjs uses `@tiptap/y-tiptap` (what TipTap's + Collaboration extension uses), pinned to the same version and sharing the same `prosemirror-model` + / `yjs` instances (peer deps) — so the structure the server produces is byte-compatible with the + client, targeting the same `'default'` fragment. +- **Merge, not replace.** `applyMarkdownToYDoc` uses `updateYFragment` (the primitive `ySyncPlugin` + runs on every keystroke) to apply only the diff, so Yjs reconciles the agent's write with in-flight + remote edits. The test proves an agent write and a concurrent remote edit both survive. +- **Server-only, DOM via jsdom.** The markdown engine builds a (never-mounted) TipTap editor that + needs a DOM; on the server it's backed by a single lazily-created `jsdom` window. Lazy-required so + the client bundle never pulls jsdom in. + +## Server-authoritative seeding (shipped alongside this module) + +The realtime relay seeds each room's document from this module over an internal endpoint +(`buildFileDocSeed` → `POST /api/internal/file-doc/seed` → `ensureServerSeed`), which let the entire +client-seeder subsystem (election / deadlines / `triedSeeders` / `MAX_SEED_ROUNDS` / the +`SEED_REQUEST` handshake) be deleted. The client's connect-deadline offline fallback is deliberately +**kept** — it is unrelated to seeding. No feature flag: the cutover is all-at-once. + +## Remaining stages (future PRs) + +- **Durable persistence.** A DB column for the Yjs binary + debounced snapshotting, so a document + survives with no collaborators connected instead of being re-seeded from markdown on cold open. +- **Copilot into the doc + projection.** `edit_content` calls `applyMarkdownToYDoc` when a doc is + live; a debounced `yDocToMarkdown` projection keeps the file's markdown current. diff --git a/apps/sim/lib/collab-doc/collab-state.ts b/apps/sim/lib/collab-doc/collab-state.ts new file mode 100644 index 00000000000..97828bece51 --- /dev/null +++ b/apps/sim/lib/collab-doc/collab-state.ts @@ -0,0 +1,61 @@ +import { createHash } from 'crypto' +import { db } from '@sim/db' +import { workspaceFileCollabState } from '@sim/db/schema' +import { eq } from 'drizzle-orm' + +/** + * The cached-collab-state cache (`workspace_file_collab_state`) lets a cold room open load the file's + * last-persisted Yjs binary directly instead of re-converting markdown → Yjs on every open — the + * Hocuspocus load-document pattern. See {@link workspaceFileCollabState} for the full rationale. + */ + +/** sha256 (hex) of a markdown buffer — the freshness tag matching a cached doc state to the live file. */ +export function hashMarkdown(markdown: Buffer): string { + return createHash('sha256').update(markdown).digest('hex') +} + +/** + * Load a file's cached Yjs binary IF it is still fresh — i.e. was derived from markdown whose hash + * matches `sourceHash` (the current file's markdown). Returns the binary to apply directly, or `null` + * when there is no cache or it is stale (the markdown changed externally since it was saved), in which + * case the caller re-converts from markdown. Applying the stored binary — rather than rebuilding a Y.Doc + * from markdown — is what preserves the CRDT's client ids and prevents duplicated content on reconnect. + */ +export async function loadFreshCollabDocState( + fileId: string, + sourceHash: string +): Promise { + const [row] = await db + .select({ + docState: workspaceFileCollabState.docState, + sourceHash: workspaceFileCollabState.sourceHash, + }) + .from(workspaceFileCollabState) + .where(eq(workspaceFileCollabState.fileId, fileId)) + .limit(1) + + if (!row || row.sourceHash !== sourceHash) return null + return new Uint8Array(row.docState) +} + +/** + * Persist a collaborative doc's Yjs binary as the file's cold-start state, tagged with the hash of the + * markdown it was derived from. Upsert — one row per file. Called from the server-side persist right + * after the markdown is written, so the cached binary and its `sourceHash` are always consistent with + * the file that was just saved. + */ +export async function saveCollabDocState( + fileId: string, + docState: Uint8Array, + sourceHash: string +): Promise { + const state = Buffer.from(docState) + const updatedAt = new Date() + await db + .insert(workspaceFileCollabState) + .values({ fileId, docState: state, sourceHash, updatedAt }) + .onConflictDoUpdate({ + target: workspaceFileCollabState.fileId, + set: { docState: state, sourceHash, updatedAt }, + }) +} diff --git a/apps/sim/lib/collab-doc/converter.test.ts b/apps/sim/lib/collab-doc/converter.test.ts new file mode 100644 index 00000000000..ba5606600b6 --- /dev/null +++ b/apps/sim/lib/collab-doc/converter.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment jsdom + */ +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' +import { + applyFrontmatter, + postProcessSerializedMarkdown, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { serializeMarkdownBody } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { + applyMarkdownToYDoc, + markdownToYDoc, + yDocToFileMarkdown, + yDocToMarkdown, +} from './converter' + +/** Representative markdown covering the custom-fidelity constructs (tables, code, lists, marks). */ +const SAMPLES = [ + '# Title\n\nA paragraph with **bold**, *italic*, and `code`.', + '- one\n- two\n - nested\n\n1. first\n2. second', + '> a quote\n\n```ts\nconst x = 1\n```', + '| a | b |\n| --- | --- |\n| 1 | 2 |\n| pipe \\| here | y |', + 'Text with a [link](https://example.com) and a break.\n\nSecond paragraph.', + // Custom-fidelity constructs — the exact cases a second markdown engine would diverge on, and + // that only survive because the server reuses the client's own @tiptap/markdown engine. + 'A footnote reference[^1].\n\n[^1]: the footnote body.', + 'Before.\n\n
untouched raw html
\n\nAfter.', + '- [ ] todo\n- [x] done', +] + +describe('collab-doc converter', () => { + it('round-trips markdown through the Yjs doc identically to the client engine', () => { + for (const md of SAMPLES) { + // yDocToMarkdown(markdownToYDoc(md)) must equal the client's own canonical serialization — + // both go through the exact same @tiptap/markdown engine, so the Yjs hop must be lossless. + expect(yDocToMarkdown(markdownToYDoc(md))).toBe(serializeMarkdownBody(md)) + } + }) + + it('projects an empty doc without throwing', () => { + expect(yDocToMarkdown(markdownToYDoc(''))).toBe(serializeMarkdownBody('')) + }) + + it('applies new content into an existing doc (agent write)', () => { + const ydoc = markdownToYDoc('# Hello\n\nWorld.') + applyMarkdownToYDoc(ydoc, '# Hello\n\nWorld and then some more.') + expect(yDocToMarkdown(ydoc)).toBe(serializeMarkdownBody('# Hello\n\nWorld and then some more.')) + }) + + it('clears an existing doc to empty without throwing (agent write of empty content)', () => { + const ydoc = markdownToYDoc('# Hello\n\nWorld.') + expect(() => applyMarkdownToYDoc(ydoc, '')).not.toThrow() + expect(yDocToMarkdown(ydoc)).toBe(serializeMarkdownBody('')) + }) + + it('merges an agent write with a concurrent remote edit (CRDT, no clobber)', () => { + // Two clients start from the same state. + const server = markdownToYDoc('# Doc\n\nAlpha paragraph.\n\nBeta paragraph.') + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(server)) + + // The remote user edits the FIRST paragraph's text directly on the shared type, concurrently… + const remoteFrag = remote.getXmlFragment('default') + remote.transact(() => { + // second child (index 1) is the first paragraph; append " EDITED" to its text node. + const firstPara = remoteFrag.get(1) as Y.XmlElement + const textNode = firstPara.get(0) as Y.XmlText + textNode.insert(textNode.toString().length, ' EDITED') + }) + + // …while the agent rewrites the SECOND paragraph via the converter on the server doc. + applyMarkdownToYDoc( + server, + '# Doc\n\nAlpha paragraph.\n\nBeta paragraph, expanded by the agent.' + ) + + // Exchange updates both ways (as the relay would) and both edits must survive the merge. + Y.applyUpdate(server, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(server))) + Y.applyUpdate(remote, Y.encodeStateAsUpdate(server, Y.encodeStateVector(remote))) + + const merged = yDocToMarkdown(server) + expect(merged).toContain('Alpha paragraph. EDITED') + expect(merged).toContain('expanded by the agent') + }) + + it('yDocToFileMarkdown matches the client save composition (frontmatter + postProcess body pass)', () => { + // A server-side persist must be byte-identical to the editor save — `applyFrontmatter(frontmatter, + // postProcessSerializedMarkdown(body))` — or it drifts from a client save / the dirty-check baseline + // and churns the blob. This guards against the postProcess pass being dropped from the server path. + const frontmatter = '---\ntitle: Doc\n---\n' + const doc = markdownToYDoc('- one\n- two\n\n> [!NOTE]\n> hi') + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.frontmatterKey, frontmatter) + const expected = applyFrontmatter( + frontmatter, + postProcessSerializedMarkdown(yDocToMarkdown(doc)) + ) + expect(yDocToFileMarkdown(doc)).toBe(expected) + doc.destroy() + }) + + it('yDocToFileMarkdown re-attaches empty frontmatter when the config map has none', () => { + const doc = markdownToYDoc('plain body\n') + expect(yDocToFileMarkdown(doc)).toBe(postProcessSerializedMarkdown(yDocToMarkdown(doc))) + doc.destroy() + }) +}) diff --git a/apps/sim/lib/collab-doc/converter.ts b/apps/sim/lib/collab-doc/converter.ts new file mode 100644 index 00000000000..990e0f906c6 --- /dev/null +++ b/apps/sim/lib/collab-doc/converter.ts @@ -0,0 +1,128 @@ +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { getSchema } from '@tiptap/core' +import { Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model' +import { + initProseMirrorDoc, + prosemirrorJSONToYDoc, + updateYFragment, + yDocToProsemirrorJSON, +} from '@tiptap/y-tiptap' +import type * as Y from 'yjs' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { + applyFrontmatter, + postProcessSerializedMarkdown, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { + parseMarkdownToDoc, + serializeDocToMarkdown, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' + +/** + * Server-side conversion between a file's markdown and its collaborative Yjs document. + * + * The markdown ↔ ProseMirror step reuses the EXACT client engine (`parseMarkdownToDoc` / + * `serializeDocToMarkdown`, both driven by `@tiptap/markdown` on the shared extension set), so the + * server can never diverge from what the editor renders — parity by construction, not by a second + * markdown implementation. The ProseMirror ↔ Yjs step uses `@tiptap/y-tiptap` (the same binding + * TipTap's Collaboration extension uses in the browser), so the Yjs structure the server produces is + * byte-compatible with the client's. + * + * The TipTap editor the markdown engine builds needs a DOM; on the server we back it with `jsdom` + * (see {@link ensureDomForTipTap}). This module is server-only by construction — it must never reach + * the client bundle (jsdom + the full editor would bloat it and break in the browser). It is kept + * out of that bundle by `require`-ing `jsdom` lazily (never a static top-level import) and by being + * imported only from server code (the seed builder + its internal route); there is no `import + * 'server-only'` marker because this repo does not use that package. + */ + +/** + * The Yjs `XmlFragment` name TipTap's Collaboration extension binds to. The client configures + * `Collaboration.configure({ document })` with no explicit `field`, so it uses TipTap's default, + * `'default'`. The server MUST target the same fragment or the client would sync an empty document. + */ +const COLLAB_DOC_FIELD = 'default' + +let cachedSchema: Schema | null = null + +/** The shared ProseMirror schema, built headlessly from the exact client extension set. */ +function markdownSchema(): Schema { + if (!cachedSchema) cachedSchema = getSchema(createMarkdownContentExtensions()) + return cachedSchema +} + +/** + * Ensure a DOM exists for the TipTap editor the markdown engine constructs. In a `jsdom`/browser + * environment `window` + `document` already exist and this is a no-op; in a plain Node server it + * installs a single shared jsdom window's globals. Cheap and idempotent — TipTap only needs + * `window`/`document`/`navigator` to build its (never-mounted) editor for parse/serialize. + * + * Gate on `window` (what TipTap's `elementFromString` actually checks), not just `document`, and hold + * NO cached "ready" flag: the Next server runtime exposes a partial `document` with NO `window`, and a + * `document`-only guard (plus a sticky flag) skipped this setup — leaving TipTap to throw "there is no + * window object available". Re-checking the globals every call means a partial stub can never wedge it. + * When `window` is missing we install a coherent jsdom window+document pair, overwriting any such stub. + */ +function ensureDomForTipTap(): void { + if (typeof window !== 'undefined' && typeof document !== 'undefined') return + // Lazy require so the client bundle never pulls jsdom in. Bind to `jsdomWindow`, NOT `window` — a + // local `const window` would shadow the global and put the `typeof window` guard above in its + // temporal dead zone ("Cannot access 'window' before initialization"). + const { JSDOM } = require('jsdom') as typeof import('jsdom') + const { window: jsdomWindow } = new JSDOM('') + const g = globalThis as unknown as Record + g.window = jsdomWindow + g.document = jsdomWindow.document + g.navigator ??= jsdomWindow.navigator +} + +/** Convert a file's markdown to a fresh collaborative {@link Y.Doc} (cold-start seed). */ +export function markdownToYDoc(markdown: string): Y.Doc { + ensureDomForTipTap() + const json = parseMarkdownToDoc(markdown) + return prosemirrorJSONToYDoc(markdownSchema(), json, COLLAB_DOC_FIELD) +} + +/** Project a collaborative {@link Y.Doc}'s BODY back to markdown (no frontmatter). */ +export function yDocToMarkdown(ydoc: Y.Doc): string { + ensureDomForTipTap() + const json = yDocToProsemirrorJSON(ydoc, COLLAB_DOC_FIELD) + return serializeDocToMarkdown(json) +} + +/** + * Project a collaborative {@link Y.Doc} back to the file's FULL canonical markdown — the body from the + * CRDT re-joined with the frontmatter carried in the config map. Mirrors the editor's save path EXACTLY + * (`applyFrontmatter(resolveSaveFrontmatter(), postProcessSerializedMarkdown(editor.getMarkdown()))`), + * INCLUDING the `postProcessSerializedMarkdown` body fidelity pass (empty list markers, callout + * un-escaping, trailing whitespace) — so a server-side persist is byte-identical to a client save and + * matches the client's dirty-check baseline, with no spurious blob churn on the round-trip. + */ +export function yDocToFileMarkdown(ydoc: Y.Doc): string { + const frontmatter = ydoc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.frontmatterKey) + return applyFrontmatter( + typeof frontmatter === 'string' ? frontmatter : '', + postProcessSerializedMarkdown(yDocToMarkdown(ydoc)) + ) +} + +/** + * Apply new markdown content into an EXISTING collaborative {@link Y.Doc} as a minimal CRDT diff, + * merging with any concurrent user edits rather than replacing the document. This is how the agent + * writes into a live doc: `updateYFragment` computes exactly the changes between the fragment's + * current content and the target and applies them as Yjs operations — the same primitive TipTap's + * `ySyncPlugin` uses on every keystroke — so Yjs reconciles them with in-flight remote edits. + */ +export function applyMarkdownToYDoc(ydoc: Y.Doc, markdown: string): void { + ensureDomForTipTap() + const schema = markdownSchema() + const target = ProseMirrorNode.fromJSON(schema, parseMarkdownToDoc(markdown)) + const fragment = ydoc.getXmlFragment(COLLAB_DOC_FIELD) + // `updateYFragment` diffs against the fragment's CURRENT content, so it needs the fragment↔PM + // binding metadata (the element/mark mapping the live editor's ySyncPlugin normally maintains). + // `initProseMirrorDoc` reconstructs it from the fragment's present state. + const { meta } = initProseMirrorDoc(fragment, schema) + ydoc.transact(() => { + updateYFragment(ydoc, fragment, target, meta) + }) +} diff --git a/apps/sim/lib/collab-doc/merge.test.ts b/apps/sim/lib/collab-doc/merge.test.ts new file mode 100644 index 00000000000..bb4b28196c5 --- /dev/null +++ b/apps/sim/lib/collab-doc/merge.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment jsdom + */ +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' +import { serializeMarkdownBody } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { markdownToYDoc, yDocToMarkdown } from './converter' +import { buildFileDocMergeUpdate } from './merge' + +describe('buildFileDocMergeUpdate', () => { + it('produces a diff that turns the live doc into the target markdown', () => { + const live = markdownToYDoc('# Title\n\nOriginal.') + const update = buildFileDocMergeUpdate(Y.encodeStateAsUpdate(live), '# Title\n\nRewritten.') + Y.applyUpdate(live, update) + expect(yDocToMarkdown(live)).toBe(serializeMarkdownBody('# Title\n\nRewritten.')) + }) + + it('strips frontmatter from the body but stores it in the config for the editor to re-attach', () => { + const live = markdownToYDoc('# Title\n\nBody.') + const update = buildFileDocMergeUpdate( + Y.encodeStateAsUpdate(live), + '---\ntitle: X\n---\n\n# Title\n\nBody rewritten.' + ) + Y.applyUpdate(live, update) + const md = yDocToMarkdown(live) + expect(md).not.toContain('title: X') + expect(md).toBe(serializeMarkdownBody('# Title\n\nBody rewritten.')) + // The updated frontmatter is carried in the config map, so an open editor re-attaches the NEW + // value on autosave instead of reverting to its stale open-time copy. + expect(live.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.frontmatterKey)).toContain( + 'title: X' + ) + }) + + it('returns an empty (no-op) diff when the markdown already matches', () => { + const live = markdownToYDoc('# Same\n\nBody.') + const before = Y.encodeStateVector(live) + const update = buildFileDocMergeUpdate(Y.encodeStateAsUpdate(live), yDocToMarkdown(live)) + Y.applyUpdate(live, update) + // Nothing new was introduced: the doc's state vector is unchanged by applying the diff. + expect(Y.encodeStateVector(live)).toEqual(before) + }) + + it('merges a copilot rewrite with a concurrent remote edit (no clobber)', () => { + // The relay captures the live doc's state and asks the app for the diff… + const live = markdownToYDoc('# Doc\n\nAlpha paragraph.\n\nBeta paragraph.') + const capturedState = Y.encodeStateAsUpdate(live) + + // …meanwhile a remote user edits the FIRST paragraph directly on the live doc, AFTER the capture. + const frag = live.getXmlFragment('default') + live.transact(() => { + const firstPara = frag.get(1) as Y.XmlElement + const textNode = firstPara.get(0) as Y.XmlText + textNode.insert(textNode.toString().length, ' EDITED') + }) + + // Copilot rewrites the SECOND paragraph; the diff is computed against the CAPTURED (older) state. + const update = buildFileDocMergeUpdate( + capturedState, + '# Doc\n\nAlpha paragraph.\n\nBeta paragraph, expanded by the agent.' + ) + // Applying the diff to the now-advanced live doc must preserve BOTH edits. + Y.applyUpdate(live, update) + + const merged = yDocToMarkdown(live) + expect(merged).toContain('Alpha paragraph. EDITED') + expect(merged).toContain('expanded by the agent') + }) +}) diff --git a/apps/sim/lib/collab-doc/merge.ts b/apps/sim/lib/collab-doc/merge.ts new file mode 100644 index 00000000000..85e5bee9733 --- /dev/null +++ b/apps/sim/lib/collab-doc/merge.ts @@ -0,0 +1,41 @@ +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import * as Y from 'yjs' +import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { applyMarkdownToYDoc } from './converter' + +/** + * Compute the minimal Yjs diff that turns `docState` — the live collaborative document as the realtime + * relay currently holds it — into `markdown`. This is the Stage C "copilot writes into the open doc" + * primitive: the relay owns the doc but not the conversion engine, so it ships the current state here + * and applies the returned diff, which Yjs merges with any concurrent user edits before relaying it to + * every connected editor. + * + * `applyMarkdownToYDoc` performs a real `updateYFragment` diff (not a replace), so paragraphs the diff + * does not touch are preserved even while the user edits them. A region the incoming `markdown` DOES + * change is reconciled toward that markdown — a concurrent user edit inside such a region is diffed + * away, since `markdown` is built from a base snapshot, not the user's in-flight text. The returned + * update is relative to the document's state at call time (`Y.encodeStateAsUpdate(doc, before)`), so it + * is exactly the change to apply — and is empty (a no-op update) when `markdown` already matches. + */ +export function buildFileDocMergeUpdate(docState: Uint8Array, markdown: string): Uint8Array { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, docState) + const before = Y.encodeStateVector(doc) + // The collaborative body never includes frontmatter (callers pass full file content, e.g. + // copilot's `finalContent`), so merge only the body, and update the frontmatter in the config map + // — the editor re-attaches THAT on autosave, so a frontmatter change is reflected rather than + // reverted by an open editor's stale, open-time copy. + const { frontmatter, body } = splitFrontmatter(markdown) + applyMarkdownToYDoc(doc, body) + // Only write when it actually changed (treating "unset" as "") so an unchanged edit stays a true + // no-op diff rather than churning the config map on every merge. + const config = doc.getMap(FILE_DOC_SEED.configMap) + if ((config.get(FILE_DOC_SEED.frontmatterKey) ?? '') !== frontmatter) { + config.set(FILE_DOC_SEED.frontmatterKey, frontmatter) + } + return Y.encodeStateAsUpdate(doc, before) + } finally { + doc.destroy() + } +} diff --git a/apps/sim/lib/collab-doc/persist.ts b/apps/sim/lib/collab-doc/persist.ts new file mode 100644 index 00000000000..e07b292d628 --- /dev/null +++ b/apps/sim/lib/collab-doc/persist.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import * as Y from 'yjs' +import { + ContentVersionConflictError, + getWorkspaceFile, + updateWorkspaceFileContent, +} from '@/lib/uploads/contexts/workspace' +import { hashMarkdown, saveCollabDocState } from './collab-state' +import { yDocToFileMarkdown } from './converter' + +const logger = createLogger('FileDocPersist') + +/** + * Outcome of a persist attempt: + * - `persisted` — the live doc was projected to markdown and written; `version` is the new durable + * CONTENT version (`content_updated_at`, epoch ms) the relay records as what its live doc is synced to. + * - `missing` — the file is gone (deleted); nothing to write. + * - `conflict` — the file changed out-of-band since the relay's live doc last synced, so writing the + * projection would clobber that change (RFC 7232 `If-Match` failure). NOT written; the relay leaves the + * durable content authoritative and does not advance its synced version (a later flush reconciles once + * the chokepoint merge lands). No `version` is returned — the relay never reads one on this path. + */ +export type PersistFileDocResult = + | { status: 'persisted'; version: number } + | { status: 'missing' } + | { status: 'conflict' } + | { status: 'deferred' } + +/** + * Project a live collaborative document back to durable markdown and write it to the file. The realtime + * relay owns the live Yjs doc but not the conversion engine or blob/DB access, so it ships the doc state + * here and the app persists it — the server-authoritative durable path that replaces the editor's + * client-side autosave. + * + * `expectedVersion` (the durable CONTENT version, `content_updated_at` epoch ms, the relay's live doc last + * synced from) is the optimistic-concurrency guard: the write commits only if the file is still at that + * content version — a rename/move that only bumps `updatedAt` won't trip it, so a + * projection built from a stale live doc can never silently overwrite an out-of-band edit. On a version + * mismatch this returns `conflict` (the current durable version) instead of writing — the relay adopts + * it as its new If-Match and retries against the current live stream. Omit `expectedVersion` to write + * unconditionally (e.g. the first persist, before any synced version exists). + * + * `userId` is attribution only (blob metadata); the caller is already trusted via the `x-api-key` gate. + */ +export async function persistFileDoc( + workspaceId: string, + fileId: string, + userId: string, + docState: Uint8Array, + expectedVersion?: number +): Promise { + const record = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!record) return { status: 'missing' } + + // Optimistic concurrency needs a version. If none was supplied — the relay's synced-version token was + // momentarily unavailable (a Redis blip on a peer-seeded task) — DEFER rather than write: an + // unconditional write could clobber an out-of-band edit, and a reconcile would wipe live edits even + // when nothing changed out-of-band (the version was merely missing). The edits stay in the stream; a + // later persist writes them once the version is re-established. There is deliberately NO empty-file + // unconditional-write carve-out: every existing file has a `content_updated_at`, so the relay always + // has a real version to send and a missing one is always transient — and `record.size` is read outside + // the write transaction, so trusting it (an empty file "has nothing to clobber") is a TOCTOU race a + // concurrent first content write would lose. + if (expectedVersion === undefined) { + return { status: 'deferred' } + } + + const ydoc = new Y.Doc() + let markdownBuffer: Buffer + try { + Y.applyUpdate(ydoc, docState) + markdownBuffer = Buffer.from(yDocToFileMarkdown(ydoc), 'utf-8') + } finally { + ydoc.destroy() + } + + try { + const updated = await updateWorkspaceFileContent( + workspaceId, + fileId, + userId, + markdownBuffer, + undefined, + { + // This write IS the projection of the live doc, so re-merging it into that same doc would loop. + syncLiveDoc: false, + // If-Match: only if the durable file is still at the version the live doc synced from. + expectedUpdatedAt: expectedVersion !== undefined ? new Date(expectedVersion) : undefined, + } + ) + + // Cache the Yjs binary (tagged with the exact markdown just written) so a later cold room open loads + // it directly instead of re-converting. Best-effort — the markdown is the durable source of truth. + try { + await saveCollabDocState(fileId, docState, hashMarkdown(markdownBuffer)) + } catch (error) { + logger.warn(`Failed to cache collab doc state for file ${fileId}`, { + error: getErrorMessage(error), + }) + } + + logger.info( + `Persisted live collaborative document to file ${fileId} (workspace ${workspaceId})` + ) + // Return the CONTENT version (what the CAS/seed/merge all guard on), not `updatedAt` — the relay + // records this as its new If-Match token, so it must be the same field a later persist is checked + // against. (A content write sets both to the same instant; using the wrong one only bites once they + // diverge — e.g. a metadata write bumping `updatedAt` afterward.) + return { + status: 'persisted', + version: (updated.contentUpdatedAt ?? updated.updatedAt).getTime(), + } + } catch (error) { + if (!(error instanceof ContentVersionConflictError)) throw error + // Out-of-band content change since the live doc last synced — DON'T clobber. The relay leaves the + // durable content authoritative and does NOT re-persist or advance its synced version here (a later + // flush reconciles once the chokepoint merge lands), so it reads nothing off this result beyond the + // `conflict` status — no durable version re-read is needed. + logger.warn( + `Persist conflict for file ${fileId}; durable content changed out-of-band since sync` + ) + return { status: 'conflict' } + } +} diff --git a/apps/sim/lib/collab-doc/seed.test.ts b/apps/sim/lib/collab-doc/seed.test.ts new file mode 100644 index 00000000000..28e8562464a --- /dev/null +++ b/apps/sim/lib/collab-doc/seed.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment jsdom + */ +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +const { mockGetWorkspaceFile, mockFetchBuffer, mockLoadFresh } = vi.hoisted(() => ({ + mockGetWorkspaceFile: vi.fn(), + mockFetchBuffer: vi.fn(), + mockLoadFresh: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + fetchWorkspaceFileBuffer: mockFetchBuffer, +})) + +// The DB-backed cold-start cache is exercised in its own suite; here we default it to a MISS so these +// tests cover the markdown → Yjs conversion path (the cache-hit fast path has its own test below). +vi.mock('./collab-state', () => ({ + hashMarkdown: () => 'test-source-hash', + loadFreshCollabDocState: mockLoadFresh, +})) + +import { serializeMarkdownBody } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { yDocToMarkdown } from './converter' +import { buildFileDocSeed } from './seed' + +describe('buildFileDocSeed', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceFile.mockResolvedValue({ + id: 'file-1', + name: 'note.md', + key: 'k', + context: 'workspace', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }) + // Default: no cached binary → the conversion path runs (the case these tests cover). + mockLoadFresh.mockResolvedValue(null) + }) + + it('builds a seed whose applied update reproduces the file body (through the client engine)', async () => { + mockFetchBuffer.mockResolvedValue(Buffer.from('# Title\n\nHello **world**.', 'utf-8')) + + const seed = await buildFileDocSeed('ws-1', 'file-1') + expect(seed).not.toBeNull() + + const doc = new Y.Doc() + Y.applyUpdate(doc, seed!.update) + expect(yDocToMarkdown(doc)).toBe(serializeMarkdownBody('# Title\n\nHello **world**.')) + }) + + it('cold-start fast path: returns the cached binary directly without re-converting when it is fresh', async () => { + // A cached binary derived from the current markdown → seed returns it verbatim (no conversion), the + // Hocuspocus load-document path that preserves the CRDT's client ids across reopens. + const cachedDoc = new Y.Doc() + cachedDoc.getText('marker').insert(0, 'cached') + const cached = Y.encodeStateAsUpdate(cachedDoc) + mockFetchBuffer.mockResolvedValue(Buffer.from('# Anything', 'utf-8')) + mockLoadFresh.mockResolvedValue(cached) + + const seed = await buildFileDocSeed('ws-1', 'file-1') + + expect(seed?.update).toBe(cached) + const doc = new Y.Doc() + Y.applyUpdate(doc, seed!.update) + expect(doc.getText('marker').toString()).toBe('cached') + }) + + it('falls through to conversion when the cache read fails (never blocks a cold open)', async () => { + // The cache is a best-effort optimization over the durable markdown we already hold; a transient DB + // error or a not-yet-migrated cache table must convert, not abort the seed. + mockFetchBuffer.mockResolvedValue(Buffer.from('# Title\n\ntext.', 'utf-8')) + mockLoadFresh.mockRejectedValue(new Error('cache table missing')) + + const seed = await buildFileDocSeed('ws-1', 'file-1') + expect(seed).not.toBeNull() + + const doc = new Y.Doc() + Y.applyUpdate(doc, seed!.update) + expect(yDocToMarkdown(doc)).toBe(serializeMarkdownBody('# Title\n\ntext.')) + }) + + it('strips frontmatter — only the body seeds the collaborative doc', async () => { + mockFetchBuffer.mockResolvedValue(Buffer.from('---\ntitle: X\n---\n\n# Body\n\ntext.', 'utf-8')) + + const seed = await buildFileDocSeed('ws-1', 'file-1') + const doc = new Y.Doc() + Y.applyUpdate(doc, seed!.update) + const md = yDocToMarkdown(doc) + expect(md).not.toContain('title: X') + expect(md).toBe(serializeMarkdownBody('# Body\n\ntext.')) + }) + + it('marks the seeded doc as initial-content-loaded so the client needs no seeder handshake', async () => { + mockFetchBuffer.mockResolvedValue(Buffer.from('# Body', 'utf-8')) + const seed = await buildFileDocSeed('ws-1', 'file-1') + const doc = new Y.Doc() + Y.applyUpdate(doc, seed!.update) + expect(doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag)).toBe(true) + }) + + it('carries the frontmatter in the config map (not the body)', async () => { + mockFetchBuffer.mockResolvedValue(Buffer.from('---\ntitle: X\n---\n\n# Body', 'utf-8')) + const seed = await buildFileDocSeed('ws-1', 'file-1') + const doc = new Y.Doc() + Y.applyUpdate(doc, seed!.update) + expect(doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.frontmatterKey)).toContain( + 'title: X' + ) + // …and the frontmatter is NOT in the collaborative body. + expect(yDocToMarkdown(doc)).not.toContain('title: X') + }) + + it('returns null for a missing file', async () => { + mockGetWorkspaceFile.mockResolvedValue(null) + expect(await buildFileDocSeed('ws-1', 'missing')).toBeNull() + }) + + it('requests the file with throwOnError so a read failure is not mistaken for an empty file', async () => { + mockGetWorkspaceFile.mockRejectedValue(new Error('db down')) + // Propagates instead of returning null — the relay must retry, never seed blank over a real file. + await expect(buildFileDocSeed('ws-1', 'file-1')).rejects.toThrow('db down') + expect(mockGetWorkspaceFile).toHaveBeenCalledWith('ws-1', 'file-1', { throwOnError: true }) + }) +}) diff --git a/apps/sim/lib/collab-doc/seed.ts b/apps/sim/lib/collab-doc/seed.ts new file mode 100644 index 00000000000..ece65549bf0 --- /dev/null +++ b/apps/sim/lib/collab-doc/seed.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { getErrorMessage } from '@sim/utils/errors' +import * as Y from 'yjs' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { hashMarkdown, loadFreshCollabDocState } from './collab-state' +import { markdownToYDoc } from './converter' + +const logger = createLogger('FileDocSeed') + +/** + * The largest file we will build a collaborative seed for. Beyond this the editor uses its + * non-collaborative path anyway; converting a huge document server-side would be wasted work. + */ +const MAX_SEED_BYTES = 5 * 1024 * 1024 + +/** A collaborative document's initial state, encoded as a Yjs update the relay can apply. */ +export interface FileDocSeed { + /** `Y.encodeStateAsUpdate` of the seeded document — apply with `Y.applyUpdate`. */ + update: Uint8Array + /** + * The file's durable `updatedAt` (epoch ms) this seed was built from — the version the relay records + * as what its freshly-seeded live doc is synced to, for the persist optimistic-concurrency guard. + */ + version: number +} + +/** + * Build the server-side seed for a file's collaborative document: load the file's current markdown + * and convert it — through the exact client engine (see {@link markdownToYDoc}) — into a Yjs update. + * + * This is what makes seeding server-authoritative: the realtime relay applies this to a fresh room's + * document instead of electing a client to import the content, so the whole client-seeder subsystem + * (election / deadlines / retries) goes away. The frontmatter is stripped exactly as the client's + * seed did — it is file metadata, not part of the collaborative body. + * + * Returns `null` ONLY when the file is genuinely absent (deleted/never-existed). A transient read + * failure THROWS (`throwOnError`) rather than returning `null`, so the relay retries instead of + * mistaking a DB blip for an empty file and seeding blank content over the real document. + */ +export async function buildFileDocSeed( + workspaceId: string, + fileId: string +): Promise { + const record = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!record) return null + + // The content-scoped version (advances only on content writes, never on rename/move) is the persist + // If-Match token — so a metadata bump can't make a racing persist reconcile stale content and clobber + // live edits. `getWorkspaceFile` always maps it from the NOT NULL column; coalesce is a type guard only. + const version = (record.contentUpdatedAt ?? record.updatedAt).getTime() + const buffer = await fetchWorkspaceFileBuffer(record, { maxBytes: MAX_SEED_BYTES }) + + // Cold-start fast path: if we hold a cached Yjs binary derived from THIS exact markdown, apply it + // directly (the Hocuspocus load-document pattern) instead of re-converting. This preserves the CRDT's + // client ids across reopens — no duplicated content, no split-brain — and skips the server-side + // headless conversion. A stale/absent cache (markdown edited externally, or first ever open) falls + // through to the conversion below, and the next persist refreshes the cache. + // + // Best-effort read: the cache is an optimization over the durable markdown we already hold, so a + // transient DB error (or a not-yet-migrated cache table) must fall through to conversion rather than + // block the cold open — symmetric with persist's best-effort cache write. + try { + const cached = await loadFreshCollabDocState(fileId, hashMarkdown(buffer)) + if (cached) return { update: cached, version } + } catch (error) { + logger.warn(`Failed to read cached collab doc state for file ${fileId}`, { + error: getErrorMessage(error), + }) + } + + const markdown = buffer.toString('utf-8') + const { frontmatter, body } = splitFrontmatter(markdown) + + const ydoc = markdownToYDoc(body) + try { + const config = ydoc.getMap(FILE_DOC_SEED.configMap) + // Mark the document seeded IN the same doc, so the client's readiness gate + // (`synced && initialContentLoaded === true`) recognizes a server-seeded doc without any + // client-seeder handshake, and a stray re-election can never seed on top of it. + config.set(FILE_DOC_SEED.flag, true) + // Carry the frontmatter in the doc (not the body) so it merges across clients and a later + // server-side edit can update it — the editor re-attaches this on autosave. + config.set(FILE_DOC_SEED.frontmatterKey, frontmatter) + return { update: Y.encodeStateAsUpdate(ydoc), version } + } finally { + ydoc.destroy() + } +} diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts new file mode 100644 index 00000000000..c9409958477 --- /dev/null +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MothershipStreamV1EventType, + MothershipStreamV1ToolExecutor, + MothershipStreamV1ToolMode, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' + +const { peekFileIntentMock } = vi.hoisted(() => ({ + peekFileIntentMock: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/server/files/file-intent-store', () => ({ + peekFileIntent: peekFileIntentMock, +})) + +import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { + createFilePreviewAdapterState, + type FilePreviewAdapterState, + processFilePreviewStreamEvent, +} from '@/lib/copilot/request/go/file-preview-adapter' +import { createEvent, eventToStreamEvent } from '@/lib/copilot/request/session' +import type { ActiveFileIntent, ExecutionContext, StreamEvent } from '@/lib/copilot/request/types' + +const STREAM_ID = 'stream-1' +const EDIT_TOOL_CALL_ID = 'edit-content-1' +const WORKSPACE_FILE_TOOL_CALL_ID = 'workspace-file-1' +const BASE_VERSION_MS = 900_000 + +/** One args_delta chunk of the streamed `edit_content` JSON, as a driveable StreamEvent. */ +function editContentDelta(argumentsDelta: string): StreamEvent { + return eventToStreamEvent( + createEvent({ + streamId: STREAM_ID, + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: EDIT_TOOL_CALL_ID, + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.args_delta, + argumentsDelta, + }, + }) + ) +} + +function makeIntent(overrides: { + operation: string + fileId?: string + fileName: string +}): ActiveFileIntent { + return { + toolCallId: WORKSPACE_FILE_TOOL_CALL_ID, + operation: overrides.operation, + target: { + kind: 'file_id', + ...(overrides.fileId ? { fileId: overrides.fileId } : {}), + fileName: overrides.fileName, + }, + } +} + +const flushMicrotasks = async () => { + await Promise.resolve() + await Promise.resolve() +} + +/** + * The copilot preview adapter no longer merges the growing content into the file's live collaborative + * Y.Doc server-side — that is done client-side by the open editor as minimal CRDT diffs (see + * `applyStreamedMarkdownToLiveDoc`). These tests guard the surviving contract: the adapter still emits + * the growing `file_preview_content` events that drive the chat's inline preview. + */ +describe('processFilePreviewStreamEvent — preview content emission', () => { + let state: FilePreviewAdapterState + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'msg-1', + } + const events: Array<{ payload: Record }> = [] + + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + // An append base is available (a non-empty file) at durable version BASE_VERSION_MS, so the preview + // text is composed as base + streamed content. + peekFileIntentMock.mockResolvedValue({ + existingContent: 'Base.', + fileRecord: { contentUpdatedAt: new Date(BASE_VERSION_MS) }, + }) + state = createFilePreviewAdapterState() + }) + + async function drive(streamEvent: StreamEvent, intent: ActiveFileIntent) { + const context = createStreamingContext() + context.activeFileIntents.set('', intent) + await processFilePreviewStreamEvent({ + streamId: STREAM_ID, + streamEvent, + context, + execContext, + options: { + onEvent: (event) => { + events.push(event as { payload: Record }) + }, + }, + state, + }) + } + + function previewContent(): string { + return events + .filter((e) => e.payload?.previewPhase === 'file_preview_content') + .map((e) => String(e.payload?.content ?? '')) + .join('') + } + + it('emits the growing preview content (base + streamed) for an append stream', async () => { + const intent = makeIntent({ operation: 'append', fileId: 'file-grow', fileName: 'notes.md' }) + + await drive(editContentDelta('{"content":"Hello'), intent) + await flushMicrotasks() + await drive(editContentDelta(' world'), intent) + await flushMicrotasks() + + const combined = previewContent() + expect(combined).toContain('Base.') + expect(combined).toContain('Hello') + expect(combined).toContain('world') + }) +}) diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index 581dd8c8f93..3d16cff4bb1 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -23,6 +23,7 @@ import { peekFileIntent } from '@/lib/copilot/tools/server/files/file-intent-sto import { buildFilePreviewText, loadWorkspaceFileTextForPreview, + type WorkspaceFilePreviewBase, } from '@/lib/copilot/tools/server/files/file-preview' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -386,21 +387,21 @@ export async function processFilePreviewStreamEvent(input: { setIntent(intent) if (isContentOp && previewTargetKind) { - let previewBaseContent: string | undefined + let previewBase: WorkspaceFilePreviewBase | undefined if ( execContext.workspaceId && fileId && (operation === 'append' || operation === 'patch') ) { - previewBaseContent = await loadWorkspaceFileTextForPreview( - execContext.workspaceId, - fileId - ) + previewBase = await loadWorkspaceFileTextForPreview(execContext.workspaceId, fileId) } let session = buildPreviewSessionFromIntent(streamId, intent) - if (previewBaseContent !== undefined) { - session = { ...session, baseContent: previewBaseContent } + if (previewBase !== undefined) { + session = { + ...session, + baseContent: previewBase.text, + } } filePreviewState.set(toolCallId, { session, @@ -458,20 +459,20 @@ export async function processFilePreviewStreamEvent(input: { } setIntent(intent) - let previewBaseContent: string | undefined + let previewBase: WorkspaceFilePreviewBase | undefined if ( execContext.workspaceId && (intent.operation === 'append' || intent.operation === 'patch') ) { - previewBaseContent = await loadWorkspaceFileTextForPreview( - execContext.workspaceId, - result.fileId - ) + previewBase = await loadWorkspaceFileTextForPreview(execContext.workspaceId, result.fileId) } let session = buildPreviewSessionFromIntent(streamId, intent) - if (previewBaseContent !== undefined) { - session = { ...session, baseContent: previewBaseContent } + if (previewBase !== undefined) { + session = { + ...session, + baseContent: previewBase.text, + } } filePreviewState.set(intent.toolCallId, { session, @@ -637,6 +638,13 @@ export async function processFilePreviewStreamEvent(input: { await persistFilePreviewSession(nextSession) + // The growing content is NOT merged into the live collaborative Y.Doc from here. When a + // collaborative editor for this file is open, that client applies the stream to the shared + // doc as minimal CRDT diffs (see `applyStreamedMarkdownToLiveDoc` in the editor), which + // renders smoothly locally AND broadcasts to every peer — so a server-side streaming merge + // would double-write the shared doc. The final `edit_content` durable write still reconciles + // the file and seeds any late joiner. + if ( nextSession.operation === 'patch' && now - currentPreview.lastSnapshotAt < PATCH_PREVIEW_SNAPSHOT_INTERVAL_MS diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index eecd40cde88..33979504936 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -34,7 +34,10 @@ vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => { >('@/lib/copilot/tools/server/files/file-preview') return { ...actual, - loadWorkspaceFileTextForPreview: vi.fn().mockResolvedValue(''), + // Returns the file's preview base as a `WorkspaceFilePreviewBase` ({ text }), NOT a bare string — + // the adapter reads `previewBase.text` to seed an append/patch base. An empty base ('') is defined, + // so a base-less append doesn't fail closed. + loadWorkspaceFileTextForPreview: vi.fn().mockResolvedValue({ text: '' }), } }) diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index 1d5e3629546..cd950eb3406 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -64,6 +64,9 @@ export const createFileServerTool: BaseServerTool { ).toBe('line one\nline two') }) + it('fails closed (returns undefined) for an append when the base content has not loaded', () => { + // A base-less append preview is just the streamed fragment; a collaborative editor applying it as the + // full body would reconcile the seeded doc down to that fragment (a wipe). It must fail closed until + // the base loads, exactly like patch/update. + expect( + buildFilePreviewText({ + operation: 'append', + existingContent: undefined, + streamedContent: 'orphan fragment', + }) + ).toBeUndefined() + }) + + it('still previews an append into an EMPTY file (existingContent is "", not undefined)', () => { + expect( + buildFilePreviewText({ + operation: 'append', + existingContent: '', + streamedContent: 'first line', + }) + ).toBe('first line') + }) + it('applies anchored replace_between previews', () => { expect( buildFilePreviewText({ diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index ecb9fdf08e2..11b09b61c3a 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -141,15 +141,22 @@ function buildAppendPreview(existingContent: string, incomingContent: string): s * before Redis holds `existingContent`, which would make append previews look like * full-file replacement until the intent landed. */ +/** The base content a copilot append/patch edit is computed against, to compose the streamed preview. */ +export interface WorkspaceFilePreviewBase { + text: string +} + export async function loadWorkspaceFileTextForPreview( workspaceId: string, fileId: string -): Promise { +): Promise { try { const record = await getWorkspaceFile(workspaceId, fileId) if (!record) return undefined const buffer = await fetchWorkspaceFileBuffer(record) - return buffer.toString('utf-8') + return { + text: buffer.toString('utf-8'), + } } catch (error) { logger.warn('Failed to load workspace file text for preview', { workspaceId, @@ -175,10 +182,15 @@ export function buildFilePreviewText({ } if (operation === 'append') { - if (existingContent !== undefined) { - return buildAppendPreview(existingContent, streamedContent) + // Fail closed (like `patch`/`update` below) when the base file content has not loaded yet: a base-less + // `append` preview is just the streamed fragment, and a collaborative editor applying it as the full + // body would reconcile the seeded doc down to that fragment (a wipe). Skipping the preview until the + // base is available costs only a brief render delay; the final durable `edit_content` write is + // authoritative. An empty file has `existingContent === ''` (defined), so it is unaffected. + if (existingContent === undefined) { + return undefined } - return streamedContent + return buildAppendPreview(existingContent, streamedContent) } if (existingContent === undefined) { diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index af3374b925b..186e785d29c 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -74,8 +74,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: vi.fn(), })) vi.mock('@/app/api/knowledge/search/utils', () => ({ - getQueryStrategy: vi.fn(), - handleVectorOnlySearch: vi.fn(), + executeKnowledgeSearch: vi.fn(), })) vi.mock('@/app/api/knowledge/utils', () => ({ checkDocumentWriteAccess: vi.fn(), diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index aececd01078..bf19100c9ec 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -50,7 +50,7 @@ import { } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' +import { executeKnowledgeSearch } from '@/app/api/knowledge/search/utils' import { checkDocumentWriteAccess, checkKnowledgeBaseAccess, @@ -221,7 +221,7 @@ export const knowledgeBaseServerTool: BaseServerTool table, requestId ) + signalTableRowsChanged(args.tableId) return { success: true, @@ -615,6 +617,7 @@ export const userTableServerTool: BaseServerTool table, requestId ) + signalTableRowsChanged(args.tableId) return { success: true, @@ -793,6 +796,7 @@ export const userTableServerTool: BaseServerTool // doesn't, so the guard never trips here. Defensive narrowing. return { success: false, message: 'Row update was skipped' } } + signalTableRowsChanged(args.tableId) // Auto-dispatch for user edits is handled inside `updateRow` // (mode: 'new' for newly-cleared groups + cancel+rerun for in-flight // downstream groups). Firing a second mode: 'incomplete' dispatch @@ -832,6 +836,7 @@ export const userTableServerTool: BaseServerTool return { success: false, message: `Table ${args.tableId} not found` } } await deleteRow(deleteRowTable, args.rowId, requestId) + signalTableRowsChanged(args.tableId) return { success: true, @@ -936,6 +941,7 @@ export const userTableServerTool: BaseServerTool }, requestId ) + if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) return { success: true, @@ -1042,6 +1048,7 @@ export const userTableServerTool: BaseServerTool } finally { await releaseJobClaim(table.id, inlineDeleteId).catch(() => {}) } + if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) recordAudit({ workspaceId, @@ -1125,6 +1132,7 @@ export const userTableServerTool: BaseServerTool table, requestId ) + if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) return { success: true, @@ -1164,6 +1172,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, rowIds, workspaceId }, requestId ) + if (result.deletedCount > 0) signalTableRowsChanged(args.tableId) recordAudit({ workspaceId, @@ -1472,6 +1481,7 @@ export const userTableServerTool: BaseServerTool table, requestId ) + signalTableRowsChanged(table.id) logger.info('Rows replaced from file', { tableId: table.id, @@ -1500,6 +1510,7 @@ export const userTableServerTool: BaseServerTool } const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) + if (inserted > 0) signalTableRowsChanged(table.id) logger.info('Rows imported from file', { tableId: table.id, @@ -1570,6 +1581,7 @@ export const userTableServerTool: BaseServerTool ? { ...col, options: normalizeSelectOptionsInput(col.options) } : { ...col, options: undefined } const updated = await addTableColumn(args.tableId, columnToAdd, requestId) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Added column "${col.name}" (${col.type}) to table`, @@ -1599,6 +1611,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, oldName: colName, newName: newColName }, requestId ) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Renamed column "${colName}" to "${newColName}"`, @@ -1630,6 +1643,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, columnName: names[0] }, requestId ) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Deleted column "${names[0]}"`, @@ -1641,6 +1655,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, columnNames: names }, requestId ) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Deleted ${names.length} columns: ${names.join(', ')}`, @@ -1785,6 +1800,7 @@ export const userTableServerTool: BaseServerTool requestId ) } + if (result) signalTableSchemaChanged(args.tableId) return { success: true, message: `Updated column "${colName}"`, @@ -1814,6 +1830,7 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const renamed = await renameTable(args.tableId, newName, requestId, context.userId) + signalTableSchemaChanged(args.tableId) return { success: true, @@ -1937,6 +1954,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, requestId ) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Added workflow group "${name ?? groupId}" with ${outputs.length} output column(s)`, @@ -2009,6 +2027,7 @@ export const userTableServerTool: BaseServerTool }, requestId ) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Updated workflow group ${groupId}`, @@ -2030,6 +2049,7 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await deleteWorkflowGroup({ tableId: args.tableId, groupId }, requestId) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Deleted workflow group ${groupId}`, @@ -2067,6 +2087,7 @@ export const userTableServerTool: BaseServerTool }, requestId ) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Added output to workflow group ${groupId}`, @@ -2095,6 +2116,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, groupId, columnName }, requestId ) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Removed output "${columnName}" from workflow group ${groupId}`, @@ -2308,6 +2330,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, requestId ) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Added enrichment "${name}" with ${outputs.length} output column(s)${ diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 33ad1c3530e..f9f6a5eddd8 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -164,6 +164,12 @@ export async function writeWorkspaceFileByPath(args: { target: WorkspaceFileWriteTarget buffer: Buffer inferredMimeType: string + /** + * Forwarded to {@link updateWorkspaceFileContent} on an overwrite. Defaults to `true` (stream a + * markdown overwrite into any open collaborative editor). Pass `false` for a write whose content is + * only a placeholder — e.g. `create_file`'s empty shell, whose real content lands via a later write. + */ + syncLiveDoc?: boolean }): Promise { const contentType = args.target.mimeType || args.inferredMimeType if (args.target.mode === 'overwrite') { @@ -177,7 +183,8 @@ export async function writeWorkspaceFileByPath(args: { existing.id, args.userId, args.buffer, - contentType || existing.type + contentType || existing.type, + { syncLiveDoc: args.syncLiveDoc } ) return { diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 484cbfc5820..93d12e032b7 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -59,6 +59,23 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy( */ export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED) +/** + * Are the Chat module's surfaces shown. On by default, so a deployment that + * already has `COPILOT_API_KEY` keeps Chat without setting anything; the setup + * wizard writes the opt-out when you skip the key. + * + * This governs presentation only. Whether Chat can actually reach the mothership + * is a separate question answered by `COPILOT_API_KEY`, which gates the paths + * that need it (the Sim Chat block, prompt-job claims, inbox execution). Keeping + * them separate is what lets this be a single variable: the secret key could + * never be read in the browser, but `NEXT_PUBLIC_CHAT_DISABLED` can — no twin to + * keep in sync. + * + * Read at module scope or inline during render only. Resolving it through + * `useState`/`useEffect` would render chat surfaces before removing them. + */ +export const isChatEnabled = !isTruthy(getEnv('NEXT_PUBLIC_CHAT_DISABLED')) + /** * Holds tools the catalog marks `requiresApproval` — shell commands, workflow * runs, sandboxed code, deployments, integration calls — behind an explicit diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a06773654b5..9d16e13ab4f 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -605,6 +605,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted + NEXT_PUBLIC_CHAT_DISABLED: z.boolean().optional(), // Hide the Chat module (Chat is shown when unset) NEXT_PUBLIC_SANDBOXES_ENABLED: z.boolean().optional(), // Enable custom sandboxes on self-hosted NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget @@ -649,6 +650,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS, NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED, + NEXT_PUBLIC_CHAT_DISABLED: process.env.NEXT_PUBLIC_CHAT_DISABLED, NEXT_PUBLIC_SANDBOXES_ENABLED: process.env.NEXT_PUBLIC_SANDBOXES_ENABLED, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, diff --git a/apps/sim/lib/core/utils/date-display.ts b/apps/sim/lib/core/utils/date-display.ts new file mode 100644 index 00000000000..e698dfbe423 --- /dev/null +++ b/apps/sim/lib/core/utils/date-display.ts @@ -0,0 +1,41 @@ +/** + * Compact date labels for dense table rows (`Jul 31`, `Jul 31 14:22`). + * + * Lives in `lib/` rather than beside the logs table because three unrelated + * features render it — logs, credit usage, and audit logs — and the previous + * home (`app/workspace/[workspaceId]/logs/utils.ts`) also exports registry-backed + * badge components. Importing a date formatter from there pulled + * `@/blocks/registry` → all 282 block configs → the tool registry into every + * consumer's bundle. + */ + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const + +/** + * Formats an ISO-ish date string as `MMM D`, appending `HH:mm` when the input + * carries a time component. Parsed by string split rather than `Date` so the + * label reflects the timestamp exactly as stored, with no timezone shifting. + */ +export function formatDateShort(dateStr: string): string { + const hasTime = dateStr.includes('T') + const [datePart, timePart] = dateStr.split('T') + const [, month, day] = datePart.split('-').map(Number) + const dateLabel = `${MONTHS[month - 1]} ${day}` + if (hasTime && timePart) { + return `${dateLabel} ${timePart.slice(0, 5)}` + } + return dateLabel +} diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index 4c6b3d6333e..02e803f5749 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -306,6 +306,8 @@ async function archiveTableChildren(context: CascadeChildrenContext): Promise() -for (const integration of INTEGRATIONS) { - TYPE_TO_NAME.set(integration.type, integration.name) - TYPE_TO_NAME.set(stripVersionSuffix(integration.type), integration.name) -} - -/** - * Curated popular workflow pairs (templates flagged `featured: true` that - * reference at least one other integration). Derived from per-block meta — - * each entry's `from` is the owner block, `to` is the first - * `alsoIntegrations` entry, and `headline`/`description` come from the - * template title and prompt. - */ -export const POPULAR_WORKFLOWS: readonly PopularWorkflow[] = (() => { - const pairs: PopularWorkflow[] = [] - for (const [ownerType, meta] of Object.entries(getAllBlockMeta())) { - for (const template of meta.templates ?? []) { - if (!template.featured) continue - const toType = template.alsoIntegrations?.[0] - if (!toType) continue - const from = TYPE_TO_NAME.get(ownerType) ?? TYPE_TO_NAME.get(stripVersionSuffix(ownerType)) - const to = TYPE_TO_NAME.get(toType) ?? TYPE_TO_NAME.get(stripVersionSuffix(toType)) - if (!from || !to) continue - pairs.push({ from, to, headline: template.title, description: template.prompt }) - } - } - return pairs -})() - /** * Projects a full `Integration` down to the fields the `/integrations` * catalog grid renders and searches by, replacing the full `operations`/ @@ -109,6 +70,5 @@ export { resolveOAuthServiceForSlug, } from '@/lib/integrations/oauth-service' export type { AuthType, FAQItem, Integration, IntegrationSummary } from '@/lib/integrations/types' -export { getAllBlockMeta, getBlockMeta, getTemplatesForBlock } from '@/blocks/registry' export type { BlockMeta, BlockTemplate } from '@/blocks/types' export { formatIntegrationType } from '@/blocks/types' diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index deadb0012bc..8d4fb0149ed 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-30", + "updatedAt": "2026-07-31", "integrations": [ { "type": "onepassword", @@ -2905,8 +2905,53 @@ "bgColor": "#DA7756", "iconName": "ClaudeIcon", "docsUrl": "https://docs.sim.ai/integrations/managed-agent", - "operations": [], - "operationCount": 0, + "operations": [ + { + "name": "Run session (create, send, wait for reply)", + "description": "Open a Claude Platform Managed Agent session and return the assistant response as text." + }, + { + "name": "Create session", + "description": "Create a Claude Platform Managed Agent session and return its id without waiting for a reply." + }, + { + "name": "Send message", + "description": "Send a user message to an existing Claude Platform Managed Agent session." + }, + { + "name": "Get session", + "description": "Read a Managed Agent session: status, stop reason, token usage, metadata, and any tool calls awaiting approval." + }, + { + "name": "List events", + "description": "Read a Managed Agent session's event history and the agent's reply text." + }, + { + "name": "Update session", + "description": "Update a Managed Agent session's title or metadata." + }, + { + "name": "Interrupt session", + "description": "Stop a running Managed Agent session; it stays usable afterwards." + }, + { + "name": "Respond to tool confirmation", + "description": "Allow or deny the tool calls a Managed Agent session is waiting on before it can continue." + }, + { + "name": "Respond to custom tool", + "description": "Return the result of a custom tool a Managed Agent session is waiting on so it can continue." + }, + { + "name": "Archive session", + "description": "Archive a Managed Agent session, preserving its history. Not reversible." + }, + { + "name": "Delete session", + "description": "Permanently delete a Managed Agent session, its events, and its sandbox. Not reversible." + } + ], + "operationCount": 11, "triggers": [], "triggerCount": 0, "authType": "api-key", diff --git a/apps/sim/lib/integrations/popular-workflows.ts b/apps/sim/lib/integrations/popular-workflows.ts new file mode 100644 index 00000000000..31ccd3816f7 --- /dev/null +++ b/apps/sim/lib/integrations/popular-workflows.ts @@ -0,0 +1,58 @@ +/** + * Curated `from → to` block-pair workflows for the landing page, derived from + * each block's `*BlockMeta` export. + * + * Split out of the `@/lib/integrations` barrel rather than living beside the + * rest of the catalog data, because building this list calls `getAllBlockMeta()` + * at module scope. That single call pulls `@/blocks/registry` → + * `blocks/registry-maps` → all 282 block configs → the tool registry, and any + * client component importing *anything* from the barrel inherited the whole + * graph. The landing `/integrations` grid is a client component, so a public + * marketing page was shipping the full registry to render a catalog of names + * and icons. + * + * Keeping it in its own module means the one server component that needs it + * imports it directly, and the barrel stays free of `@/blocks` value imports. + */ + +import { stripVersionSuffix } from '@sim/utils/string' +import { INTEGRATIONS } from '@/lib/integrations' +import { getAllBlockMeta } from '@/blocks/registry' + +/** A curated `from → to` block-pair workflow surfaced on the landing page. */ +export interface PopularWorkflow { + /** Integration display name (matches `Integration.name`). */ + from: string + /** Integration display name. */ + to: string + headline: string + description: string +} + +const TYPE_TO_NAME = new Map() +for (const integration of INTEGRATIONS) { + TYPE_TO_NAME.set(integration.type, integration.name) + TYPE_TO_NAME.set(stripVersionSuffix(integration.type), integration.name) +} + +/** + * Templates flagged `featured: true` that reference at least one other + * integration. Each entry's `from` is the owner block, `to` is the first + * `alsoIntegrations` entry, and `headline`/`description` come from the template + * title and prompt. + */ +export const POPULAR_WORKFLOWS: readonly PopularWorkflow[] = (() => { + const pairs: PopularWorkflow[] = [] + for (const [ownerType, meta] of Object.entries(getAllBlockMeta())) { + for (const template of meta.templates ?? []) { + if (!template.featured) continue + const toType = template.alsoIntegrations?.[0] + if (!toType) continue + const from = TYPE_TO_NAME.get(ownerType) ?? TYPE_TO_NAME.get(stripVersionSuffix(ownerType)) + const to = TYPE_TO_NAME.get(toType) ?? TYPE_TO_NAME.get(stripVersionSuffix(toType)) + if (!from || !to) continue + pairs.push({ from, to, headline: template.title, description: template.prompt }) + } + } + return pairs +})() diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index b90d2669a85..ec014758f1c 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -1131,7 +1131,7 @@ describe('acceptInvitation', () => { expect(result.success).toBe(true) if (result.success) { - expect(result.redirectPath).toBe('/workspace/workspace-1/home') + expect(result.redirectPath).toBe('/workspace/workspace-1') } expect(mockAttachOwnedWorkspacesToOrganizationTx).toHaveBeenCalledWith( expect.anything(), @@ -1580,7 +1580,7 @@ describe('acceptInvitation', () => { expect(result.success).toBe(true) if (result.success) { - expect(result.redirectPath).toBe('/workspace/workspace-1/home') + expect(result.redirectPath).toBe('/workspace/workspace-1') } }) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 13be762010e..bb5f7eea628 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -1328,7 +1328,7 @@ async function acceptLockedInvitation( effects.membershipAlreadyExists = membershipAlreadyExists const redirectPath = - acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}/home` : '/workspace' + acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}` : '/workspace' return { success: true, diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index a49cb8c21c2..ae6565b3d13 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -677,7 +677,7 @@ export interface SendWorkspaceAddedEmailInput { export async function sendWorkspaceAddedEmail( input: SendWorkspaceAddedEmailInput ): Promise { - const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}/home` + const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}` const emailHtml = await renderWorkspaceAddedEmail( input.inviterName, input.workspaceName, diff --git a/apps/sim/lib/managed-agents/session-client.test.ts b/apps/sim/lib/managed-agents/session-client.test.ts index 8eb870d3f69..4cf6184c9d5 100644 --- a/apps/sim/lib/managed-agents/session-client.test.ts +++ b/apps/sim/lib/managed-agents/session-client.test.ts @@ -2,7 +2,18 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { buildSessionCreatePayload, listSessionEvents } from '@/lib/managed-agents/session-client' +import { + archiveSession, + buildSessionCreatePayload, + deleteSession, + listSessionEvents, + listSessionEventsPage, + parseSessionSnapshot, + resolvePendingToolGates, + sendCustomToolResults, + sendToolConfirmations, + updateSession, +} from '@/lib/managed-agents/session-client' const BASE = { apiKey: 'sk-ant-fake', @@ -189,3 +200,551 @@ describe('listSessionEvents — ordering', () => { expect(events.map((e) => e.id)).toEqual(['a', 'b', 'c', 'queued']) }) }) + +describe('buildSessionCreatePayload — initial_events', () => { + it('seeds a single user.message so create+send is one call', () => { + const payload = buildSessionCreatePayload({ ...BASE, initialMessage: 'hello there' }) + expect(payload.initial_events).toEqual([ + { type: 'user.message', content: [{ type: 'text', text: 'hello there' }] }, + ]) + }) + + it('trims the seeded message', () => { + const payload = buildSessionCreatePayload({ ...BASE, initialMessage: ' hi ' }) + expect(payload.initial_events).toEqual([ + { type: 'user.message', content: [{ type: 'text', text: 'hi' }] }, + ]) + }) + + it('omits initial_events entirely when there is no message', () => { + // An empty array is equivalent to omitting the field, and a blank message + // would be rejected — so neither is ever sent. + expect(buildSessionCreatePayload({ ...BASE }).initial_events).toBeUndefined() + expect( + buildSessionCreatePayload({ ...BASE, initialMessage: '' }).initial_events + ).toBeUndefined() + expect( + buildSessionCreatePayload({ ...BASE, initialMessage: ' ' }).initial_events + ).toBeUndefined() + }) +}) + +describe('parseSessionSnapshot', () => { + it('reads status, usage, title and metadata', () => { + const snapshot = parseSessionSnapshot({ + status: 'idle', + title: 'my session', + metadata: { slack_channel: 'C123', retries: 2, ok: true, dropped: { a: 1 } }, + usage: { input_tokens: 10, output_tokens: 20 }, + }) + expect(snapshot.status).toBe('idle') + expect(snapshot.title).toBe('my session') + expect(snapshot.usage).toEqual({ inputTokens: 10, outputTokens: 20 }) + // Scalars are stringified; non-scalars are dropped rather than mangled. + expect(snapshot.metadata).toEqual({ slack_channel: 'C123', retries: '2', ok: 'true' }) + }) + + it('reads the blocking event ids off a requires_action stop reason', () => { + const snapshot = parseSessionSnapshot({ + status: 'idle', + stop_reason: { type: 'requires_action', event_ids: ['sevt_1', 'sevt_2'] }, + }) + expect(snapshot.stopReason).toEqual({ + type: 'requires_action', + eventIds: ['sevt_1', 'sevt_2'], + }) + }) + + it('tolerates an unknown status and a missing body', () => { + expect(parseSessionSnapshot({ status: 'bogus' }).status).toBeUndefined() + expect(parseSessionSnapshot(null)).toEqual({}) + expect(parseSessionSnapshot(undefined)).toEqual({}) + }) +}) + +describe('session lifecycle calls', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + const captureFetch = (body: unknown = {}) => { + const spy = vi.fn(async () => Response.json(body)) as unknown as typeof fetch + global.fetch = spy + return spy as unknown as ReturnType + } + + it('updateSession posts title and metadata', async () => { + const spy = captureFetch({ status: 'idle', title: 'renamed' }) + await updateSession({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + title: 'renamed', + metadata: { slack_ts: '123' }, + }) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.anthropic.com/v1/sessions/sesn_1') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ + title: 'renamed', + metadata: { slack_ts: '123' }, + }) + }) + + it('updateSession refuses a no-op update rather than sending an empty body', async () => { + captureFetch() + await expect(updateSession({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' })).rejects.toThrow( + /requires a title or metadata/ + ) + }) + + it('archiveSession POSTs the archive sub-resource', async () => { + const spy = captureFetch() + await archiveSession({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' }) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.anthropic.com/v1/sessions/sesn_1/archive') + expect(init.method).toBe('POST') + }) + + it('deleteSession issues a DELETE', async () => { + const spy = captureFetch() + await deleteSession({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' }) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.anthropic.com/v1/sessions/sesn_1') + expect(init.method).toBe('DELETE') + }) + + it('surfaces the status code and body when a call fails', async () => { + global.fetch = vi.fn( + async () => new Response('session is running', { status: 400 }) + ) as unknown as typeof fetch + await expect(archiveSession({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' })).rejects.toThrow( + /400.*session is running/ + ) + }) +}) + +describe('sendToolConfirmations', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + const capture = () => { + const spy = vi.fn(async () => Response.json({})) as unknown as typeof fetch + global.fetch = spy + return spy as unknown as ReturnType + } + + it('sends every confirmation in one request', async () => { + const spy = capture() + await sendToolConfirmations({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + confirmations: [ + { toolUseId: 'sevt_1', result: 'allow' }, + { toolUseId: 'sevt_2', result: 'allow' }, + ], + }) + expect(spy).toHaveBeenCalledTimes(1) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.anthropic.com/v1/sessions/sesn_1/events') + expect(JSON.parse(init.body as string)).toEqual({ + events: [ + { type: 'user.tool_confirmation', tool_use_id: 'sevt_1', result: 'allow' }, + { type: 'user.tool_confirmation', tool_use_id: 'sevt_2', result: 'allow' }, + ], + }) + }) + + it('uses deny_message on a denial and omits it on an allow', async () => { + const spy = capture() + await sendToolConfirmations({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + confirmations: [ + { toolUseId: 'sevt_1', result: 'deny', denyMessage: 'not the prod project' }, + { toolUseId: 'sevt_2', result: 'allow', denyMessage: 'ignored' }, + ], + }) + const [, init] = spy.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(init.body as string).events).toEqual([ + { + type: 'user.tool_confirmation', + tool_use_id: 'sevt_1', + result: 'deny', + deny_message: 'not the prod project', + }, + { type: 'user.tool_confirmation', tool_use_id: 'sevt_2', result: 'allow' }, + ]) + }) +}) + +describe('resolvePendingToolGates', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + it('resolves ids to names in the order the API reported them', async () => { + global.fetch = vi.fn(async () => + Response.json({ + data: [ + { id: 'sevt_2', type: 'agent.mcp_tool_use', name: 'create_issue', input: { title: 'x' } }, + { id: 'sevt_1', type: 'agent.tool_use', name: 'bash', input: { command: 'ls' } }, + ], + next_page: null, + }) + ) as unknown as typeof fetch + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_1', 'sevt_2'], + }) + + expect(gates).toEqual([ + { + id: 'sevt_1', + eventType: 'agent.tool_use', + kind: 'confirmation', + name: 'bash', + input: { command: 'ls' }, + }, + { + id: 'sevt_2', + eventType: 'agent.mcp_tool_use', + kind: 'confirmation', + name: 'create_issue', + input: { title: 'x' }, + }, + ]) + }) + + it('filters the events request to tool-use types', async () => { + const spy = vi.fn(async () => + Response.json({ data: [], next_page: null }) + ) as unknown as typeof fetch + global.fetch = spy + + await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_1'], + }) + + const [url] = (spy as unknown as ReturnType).mock.calls[0] as [string] + const types = new URL(url).searchParams.getAll('types[]') + expect(types).toEqual(['agent.tool_use', 'agent.mcp_tool_use', 'agent.custom_tool_use']) + }) + + it('still returns the ids when enrichment fails — they alone can answer a gate', async () => { + global.fetch = vi.fn(async () => { + throw new Error('network down') + }) as unknown as typeof fetch + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_1', 'sevt_2'], + }) + expect(gates).toEqual([{ id: 'sevt_1' }, { id: 'sevt_2' }]) + }) + + it('labels a custom-tool gate as needing a custom tool result, not a confirmation', async () => { + // A confirmation cannot unblock a custom tool — the agent is waiting on the + // tool's actual output — so the kind must route callers to the right op. + global.fetch = vi.fn(async () => + Response.json({ + data: [{ id: 'sevt_9', type: 'agent.custom_tool_use', name: 'lookup_order' }], + next_page: null, + }) + ) as unknown as typeof fetch + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_9'], + }) + expect(gates[0]?.kind).toBe('custom_tool_result') + }) + + it('omits kind when the event could not be resolved', async () => { + global.fetch = vi.fn(async () => { + throw new Error('network down') + }) as unknown as typeof fetch + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_1'], + }) + expect(gates[0]).toEqual({ id: 'sevt_1' }) + }) + + it('finds a gate that lives past the first page', async () => { + // Gates are the most RECENT tool calls. A read that capped instead of + // filtering would keep page 1 and miss exactly the events that matter. + let page = 0 + global.fetch = vi.fn(async () => { + const offset = page * 100 + page += 1 + const data = Array.from({ length: 100 }, (_, i) => ({ + id: `t${offset + i}`, + type: 'agent.tool_use', + name: `tool_${offset + i}`, + })) + return Response.json({ data, next_page: page < 4 ? `c${page}` : null }) + }) as unknown as typeof fetch + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['t399'], + }) + expect(gates).toEqual([ + { id: 't399', eventType: 'agent.tool_use', kind: 'confirmation', name: 'tool_399' }, + ]) + }) + + it('keeps paging when an entire page filters out', async () => { + let page = 0 + const spy = vi.fn(async () => { + page += 1 + // Page 1 holds nothing wanted; the target is only on page 2. + const data = + page === 1 + ? [{ id: 'other', type: 'agent.tool_use', name: 'noise' }] + : [{ id: 'want', type: 'agent.tool_use', name: 'target' }] + return Response.json({ data, next_page: page < 2 ? 'c1' : null }) + }) as unknown as typeof fetch + global.fetch = spy + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['want'], + }) + expect((spy as unknown as ReturnType).mock.calls).toHaveLength(2) + expect(gates[0]?.name).toBe('target') + }) + + it('stops paging as soon as every wanted id is found', async () => { + // The filter keeps `collected` tiny, so no cap can ever trip — without an + // explicit stop the walk runs to the end of the tool history for nothing. + let page = 0 + const spy = vi.fn(async () => { + page += 1 + return Response.json({ + data: [{ id: page === 1 ? 'want' : `other${page}`, type: 'agent.tool_use', name: 'x' }], + next_page: `c${page}`, // never null: only the stop condition ends this + }) + }) as unknown as typeof fetch + global.fetch = spy + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['want'], + }) + expect(gates).toHaveLength(1) + expect((spy as unknown as ReturnType).mock.calls).toHaveLength(1) + }) + + it('short-circuits with no ids', async () => { + const spy = vi.fn() as unknown as typeof fetch + global.fetch = spy + expect( + await resolvePendingToolGates({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1', eventIds: [] }) + ).toEqual([]) + expect(spy).not.toHaveBeenCalled() + }) +}) + +describe('listSessionEvents — bounded reads', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + /** Emits `pages` pages of 100 chronologically-increasing events. */ + const pagedFetch = (pages: number) => { + let page = 0 + return vi.fn(async () => { + const offset = page * 100 + page += 1 + return Response.json({ + data: Array.from({ length: 100 }, (_, i) => ({ + id: `e${offset + i}`, + type: 'agent.message', + processed_at: new Date(Date.UTC(2026, 0, 1) + (offset + i) * 1000).toISOString(), + })), + next_page: page < pages ? `cursor-${page}` : null, + }) + }) as unknown as typeof fetch + } + + it('returns exactly maxItems, never a whole extra page', async () => { + global.fetch = pagedFetch(3) + const events = await listSessionEvents({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 250, + }) + expect(events).toHaveLength(250) + }) + + it('keeps the NEWEST events when capping, not the oldest', async () => { + // Ascending history: e0 (oldest) .. e249 (newest). A cap of 10 must return + // the last ten — capping the fetch instead would return e0..e9 and silently + // drop the agent's most recent reply, which is what callers read this for. + global.fetch = pagedFetch(3) + const events = await listSessionEvents({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 10, + }) + expect(events).toHaveLength(10) + expect(events[0]?.id).toBe('e290') + expect(events.at(-1)?.id).toBe('e299') + }) + + it('reports the untrimmed total so a full history is not mistaken for a tail', async () => { + // A history of exactly `maxItems` dropped nothing — `total === events.length` + // is what lets the caller tell that apart from a genuinely capped read. + global.fetch = pagedFetch(3) + const exact = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 300, + }) + expect(exact.events).toHaveLength(300) + expect(exact.total).toBe(300) + + global.fetch = pagedFetch(3) + const capped = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 120, + }) + expect(capped.events).toHaveLength(120) + expect(capped.total).toBe(300) + }) + + it('never returns the whole history for a zero or negative cap', async () => { + // `slice(-0)` is `slice(0)` — the entire array — so a zero cap must + // short-circuit rather than silently become an unbounded read. + global.fetch = pagedFetch(1) + const zero = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 0, + }) + expect(zero.events).toHaveLength(0) + expect(zero.total).toBe(100) + + global.fetch = pagedFetch(1) + const negative = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: -5, + }) + expect(negative.events).toHaveLength(0) + }) + + it.each([0.5, 0.99, 0, -0.5, -5])( + 'never returns the whole history for the sub-integer cap %p', + async (maxItems) => { + // `slice` truncates its index toward zero, so any cap under 1 becomes + // `slice(-0)` — the entire array — unless it is floored first. + global.fetch = pagedFetch(1) + const res = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems, + }) + expect(res.events).toHaveLength(0) + expect(res.total).toBe(100) + } + ) + + it('floors a fractional cap above 1 rather than widening it', async () => { + global.fetch = pagedFetch(1) + const res = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 10.9, + }) + expect(res.events).toHaveLength(10) + }) + + it('returns the whole history when uncapped', async () => { + global.fetch = pagedFetch(3) + const events = await listSessionEvents({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' }) + expect(events).toHaveLength(300) + }) + + it('passes a types filter through as repeatable types[] params', async () => { + const spy = vi.fn(async () => + Response.json({ data: [], next_page: null }) + ) as unknown as typeof fetch + global.fetch = spy + await listSessionEvents({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + types: ['agent.message', ' agent.tool_use '], + }) + const [url] = (spy as unknown as ReturnType).mock.calls[0] as [string] + expect(new URL(url).searchParams.getAll('types[]')).toEqual(['agent.message', 'agent.tool_use']) + }) +}) + +describe('sendCustomToolResults', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + it('sends a user.custom_tool_result per pending call', async () => { + const spy = vi.fn(async () => Response.json({})) as unknown as typeof fetch + global.fetch = spy + await sendCustomToolResults({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + results: [{ customToolUseId: 'sevt_9', content: 'order #42 shipped', isError: false }], + }) + const [, init] = (spy as unknown as ReturnType).mock.calls[0] as [ + string, + RequestInit, + ] + expect(JSON.parse(init.body as string)).toEqual({ + events: [ + { + type: 'user.custom_tool_result', + custom_tool_use_id: 'sevt_9', + content: [{ type: 'text', text: 'order #42 shipped' }], + is_error: false, + }, + ], + }) + }) + + it('defaults is_error to false and honors an explicit failure', async () => { + const spy = vi.fn(async () => Response.json({})) as unknown as typeof fetch + global.fetch = spy + await sendCustomToolResults({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + results: [ + { customToolUseId: 'a', content: 'ok' }, + { customToolUseId: 'b', content: 'lookup failed', isError: true }, + ], + }) + const [, init] = (spy as unknown as ReturnType).mock.calls[0] as [ + string, + RequestInit, + ] + const events = JSON.parse(init.body as string).events + expect(events[0].is_error).toBe(false) + expect(events[1].is_error).toBe(true) + }) +}) diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index e03a6d3827e..aa3a6c7643e 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -31,7 +31,14 @@ export interface AnthropicSessionEvent { type?: string content?: Array<{ type: string; text?: string }> name?: string - stop_reason?: { type?: string } + /** Tool input, present on `agent.*_tool_use` events. */ + input?: unknown + /** + * On `session.status_idle`. When `type` is `requires_action`, `event_ids` + * lists the blocking tool-use event ids awaiting a `user.tool_confirmation` + * or `user.custom_tool_result`. + */ + stop_reason?: { type?: string; event_ids?: string[] } error?: { message?: string } message?: string /** Server-side record time; `null`/absent means still queued (handled after processed events). */ @@ -51,6 +58,14 @@ export interface SessionAuth { export interface CreateSessionInput extends SessionAuth { agentId: string environmentId: string + /** + * Seeds `initial_events` with a single `user.message`, starting the agent + * loop in the same call — the session is created directly in `running` + * instead of passing through `idle`. Only `user.message` and + * `user.define_outcome` are accepted there, and validation is all-or-nothing. + * https://platform.claude.com/docs/en/managed-agents/sessions + */ + initialMessage?: string /** * Environment execution model. Self-hosted environments reject the * `resources` array, so memory is routed via `metadata` and files are @@ -89,9 +104,21 @@ export type EnvironmentType = 'cloud' | 'self_hosted' /** Authoritative session status per `GET /v1/sessions/{id}`. */ export type SessionStatus = 'idle' | 'running' | 'rescheduling' | 'terminated' +/** Why an idle session stopped, per the session resource / idle event. */ +export interface SessionStopReason { + type?: string + /** Blocking tool-use event ids when `type` is `requires_action`. */ + eventIds?: string[] +} + export interface SessionSnapshot { status?: SessionStatus usage?: SessionUsage + /** Present once the session has stopped at least once. */ + stopReason?: SessionStopReason + /** Session metadata as stored on the Anthropic session. */ + metadata?: Record + title?: string } /** @@ -158,6 +185,15 @@ export function buildSessionCreatePayload(input: CreateSessionInput): Record 0) { payload.metadata = { ...input.sessionParameters } } + + // An empty/whitespace `initial_events` entry would be rejected, and an empty + // array is equivalent to omitting the field — so only seed a real message. + const initialMessage = input.initialMessage?.trim() + if (initialMessage) { + payload.initial_events = [ + { type: 'user.message', content: [{ type: 'text', text: initialMessage }] }, + ] + } return payload } @@ -200,7 +236,29 @@ interface UserInterruptEvent { type: 'user.interrupt' } -export type OutboundSessionEvent = UserMessageEvent | UserCustomToolResultEvent | UserInterruptEvent +/** + * Answers one `always_ask` permission gate. + * + * `tool_use_id` is the *event id* of the blocking `agent.tool_use` / + * `agent.mcp_tool_use` event (the ids listed in the idle event's + * `stop_reason.event_ids`) — NOT a `toolu_...` id. The denial reason field is + * `deny_message`, and it is only meaningful with `result: 'deny'`. + * https://platform.claude.com/docs/en/managed-agents/permission-policies + */ +interface UserToolConfirmationEvent { + type: 'user.tool_confirmation' + tool_use_id: string + result: ToolConfirmationResult + deny_message?: string +} + +export type ToolConfirmationResult = 'allow' | 'deny' + +export type OutboundSessionEvent = + | UserMessageEvent + | UserCustomToolResultEvent + | UserInterruptEvent + | UserToolConfirmationEvent /** POST /v1/sessions/{id}/events with a single `user.message`. */ export async function sendUserMessage( @@ -253,6 +311,157 @@ export async function interruptSession(input: { }) } +/** + * POST /v1/sessions/{id}/events with one `user.tool_confirmation` per blocking + * gate. Several confirmations may be sent in a single request, which is why + * this takes a list — answering all of a turn's gates at once avoids a partial + * resolve that leaves the session parked. + */ +export async function sendToolConfirmations( + input: SessionAuth & { + sessionId: string + confirmations: Array<{ + toolUseId: string + result: ToolConfirmationResult + denyMessage?: string + }> + } +): Promise { + const events: OutboundSessionEvent[] = input.confirmations.map((confirmation) => ({ + type: 'user.tool_confirmation', + tool_use_id: confirmation.toolUseId, + result: confirmation.result, + // `deny_message` is only meaningful on a denial; the API ignores it on an + // allow, but sending it would misrepresent the intent in the event history. + ...(confirmation.result === 'deny' && confirmation.denyMessage + ? { deny_message: confirmation.denyMessage } + : {}), + })) + await sendSessionEvents({ + apiKey: input.apiKey, + ...(input.signal ? { signal: input.signal } : {}), + sessionId: input.sessionId, + events, + }) +} + +/** + * How a blocking gate must be answered. + * + * `confirmation` — an `always_ask` permission gate on a server-executed tool; + * answered with `user.tool_confirmation` (allow/deny). + * `custom_tool_result` — a client-side custom tool the agent invoked; answered + * with `user.custom_tool_result` carrying the tool's actual output. Sending a + * confirmation for one of these does NOT unblock the session. + */ +export type PendingToolGateKind = 'confirmation' | 'custom_tool_result' + +/** A tool call blocking a session, resolved to its name/input. */ +export interface PendingToolGate { + /** Event id — pass this as `tool_use_id` / `custom_tool_use_id` when answering. */ + id: string + /** `agent.tool_use`, `agent.mcp_tool_use`, or `agent.custom_tool_use`. */ + eventType?: string + /** Which reply event unblocks this gate. Absent when the event could not be resolved. */ + kind?: PendingToolGateKind + name?: string + input?: unknown +} + +/** Maps a tool-use event type onto the reply event that unblocks it. */ +function gateKindFor(eventType: string | undefined): PendingToolGateKind | undefined { + if (eventType === 'agent.custom_tool_use') return 'custom_tool_result' + if (eventType === 'agent.tool_use' || eventType === 'agent.mcp_tool_use') return 'confirmation' + return undefined +} + +/** Event types that can block a session pending a client response. */ +const TOOL_USE_EVENT_TYPES = [ + 'agent.tool_use', + 'agent.mcp_tool_use', + 'agent.custom_tool_use', +] as const + +/** + * Resolves the blocking event ids on an idle `requires_action` session into + * named gates by cross-referencing the session's tool-use events. + * + * The ids alone are enough to answer a gate, so a failure to enrich is NOT an + * error — the ids are still returned, just without names. Callers get + * something actionable either way. + */ +export async function resolvePendingToolGates( + input: SessionAuth & { sessionId: string; eventIds: string[] } +): Promise { + const wanted = new Set(input.eventIds) + if (wanted.size === 0) return [] + let events: AnthropicSessionEvent[] = [] + try { + events = await listPaginated({ + apiKey: input.apiKey, + ...(input.signal ? { signal: input.signal } : {}), + path: `/v1/sessions/${input.sessionId}/events`, + searchParams: TOOL_USE_EVENT_TYPES.map((type): [string, string] => ['types[]', type]), + // Keep only the events actually being looked up rather than capping the + // read. A cap would retain the OLDEST page-order events, and blocking + // gates are by definition the most recent tool calls — exactly the ones a + // cap would drop. Filtering instead bounds memory to the id count while + // staying correct however the API orders its pages. + filter: (event) => Boolean(event.id && wanted.has(event.id)), + // Every wanted id is found at most once, so once the count matches there + // is nothing left to look for. Without this the filtered total never + // reaches any cap and the walk runs to the end of the tool history. + stopWhen: (found) => found.length >= wanted.size, + }) + } catch { + // Enrichment is best-effort — fall through to bare ids below. + } + const byId = new Map() + for (const event of events) { + if (event.id && wanted.has(event.id) && !byId.has(event.id)) byId.set(event.id, event) + } + // Preserve the API's `event_ids` order so the caller's prompts are stable. + return input.eventIds.map((id) => { + const event = byId.get(id) + const kind = gateKindFor(event?.type) + return { + id, + ...(event?.type ? { eventType: event.type } : {}), + ...(kind ? { kind } : {}), + ...(event?.name ? { name: event.name } : {}), + ...(event?.input !== undefined ? { input: event.input } : {}), + } + }) +} + +/** + * POST /v1/sessions/{id}/events with one `user.custom_tool_result` per pending + * custom-tool call. + * + * Custom tools are executed by the caller, not Anthropic, so a permission + * confirmation cannot unblock them — the agent is waiting for the tool's actual + * output (or an error). + */ +export async function sendCustomToolResults( + input: SessionAuth & { + sessionId: string + results: Array<{ customToolUseId: string; content: string; isError?: boolean }> + } +): Promise { + const events: OutboundSessionEvent[] = input.results.map((result) => ({ + type: 'user.custom_tool_result', + custom_tool_use_id: result.customToolUseId, + content: [{ type: 'text', text: result.content }], + is_error: result.isError ?? false, + })) + await sendSessionEvents({ + apiKey: input.apiKey, + ...(input.signal ? { signal: input.signal } : {}), + sessionId: input.sessionId, + events, + }) +} + /** GET /v1/sessions/{id}/events/stream — opens the SSE response. */ export async function openSessionStream( input: SessionAuth & { sessionId: string } @@ -285,7 +494,27 @@ interface AnthropicListPage { const MAX_LIST_PAGES = 1000 async function listPaginated( - input: SessionAuth & { path: string; beta?: string; maxItems?: number } + input: SessionAuth & { + path: string + beta?: string + maxItems?: number + /** Extra repeatable query pairs (e.g. `types[]` filters). */ + searchParams?: Array<[string, string]> + /** + * Applied per item as pages arrive, so only matches are retained. Use this + * instead of `maxItems` when the caller needs specific items rather than a + * prefix — a cap keeps whatever the API returned first, which is the oldest + * entries on a chronological endpoint. + */ + filter?: (item: T) => boolean + /** + * Checked after each page against everything collected so far. Lets a + * filtered read stop as soon as it has what it came for — otherwise + * `maxItems` never trips (the filtered total stays small) and the walk runs + * to the end of the history for nothing. + */ + stopWhen?: (collected: T[]) => boolean + } ): Promise { const collected: T[] = [] const maxItems = input.maxItems ?? 2000 @@ -295,6 +524,8 @@ async function listPaginated( for (let pageCount = 0; pageCount < MAX_LIST_PAGES && collected.length < maxItems; pageCount++) { const url = new URL(`${ANTHROPIC_API_BASE}${input.path}`) url.searchParams.set('limit', '100') + // `append`, not `set` — `types[]` is repeatable and each value must survive. + for (const [key, value] of input.searchParams ?? []) url.searchParams.append(key, value) if (page) url.searchParams.set('page', page) const resp = await fetch(url.toString(), { method: 'GET', @@ -307,11 +538,17 @@ async function listPaginated( } const body = (await resp.json()) as AnthropicListPage const items = Array.isArray(body.data) ? body.data : [] - collected.push(...items) + collected.push(...(input.filter ? items.filter(input.filter) : items)) + if (input.stopWhen?.(collected)) break + // Paging continues on the RAW page, not the filtered result: a page whose + // every item was filtered out is not the end of the list. if (!body.next_page || items.length === 0) break page = body.next_page } - return collected + // Pages arrive whole, so the last one can overshoot `maxItems` — trim to the + // exact cap the caller asked for. (`slice(0, Infinity)` is a no-op, so the + // unbounded default is unaffected.) + return collected.length > maxItems ? collected.slice(0, maxItems) : collected } /** @@ -322,12 +559,53 @@ async function listPaginated( * by a page cap. */ export async function listSessionEvents( - input: SessionAuth & { sessionId: string } + input: SessionAuth & { + sessionId: string + types?: string[] + /** + * Caps how many events are RETURNED. Defaults to unbounded, which is what + * the run loop's catch-up needs — it must reach the tail to see the terminal + * event. + * + * The cap keeps the MOST RECENT events, not the first ones the API happens + * to hand back. Capping the fetch instead would return the oldest slice of a + * long session and silently omit the agent's latest reply — the exact thing + * most callers are reading events for. Paging is therefore still exhaustive; + * the bound applies to the returned array. + */ + maxItems?: number + } ): Promise { + return (await listSessionEventsPage(input)).events +} + +/** An event read plus the size of the history it was taken from. */ +export interface SessionEventPage { + events: AnthropicSessionEvent[] + /** + * How many events the session actually has, before any cap. Compare against + * `events.length` to tell a capped read from a complete one — a history that + * happens to be exactly `maxItems` long has dropped nothing. + */ + total: number +} + +/** + * Same read as {@link listSessionEvents}, but also reports the untrimmed + * history size so callers can distinguish "this is a tail" from "this is + * everything, and it happens to be exactly the cap". + */ +export async function listSessionEventsPage( + input: SessionAuth & { sessionId: string; types?: string[]; maxItems?: number } +): Promise { + const types = (input.types ?? []).filter((type) => type.trim().length > 0) const events = await listPaginated({ apiKey: input.apiKey, signal: input.signal, path: `/v1/sessions/${input.sessionId}/events`, + ...(types.length > 0 + ? { searchParams: types.map((type): [string, string] => ['types[]', type.trim()]) } + : {}), maxItems: Number.POSITIVE_INFINITY, }) // The list endpoint's page order is not guaranteed chronological, so order by @@ -335,7 +613,23 @@ export async function listSessionEvents( // loop depends on ascending order both to accumulate assistant text in order // and to read the latest lifecycle event. Still-queued events (null // `processed_at`) are processed after everything else, so they sort last. - return events.sort((a, b) => parseProcessedAt(a.processed_at) - parseProcessedAt(b.processed_at)) + const ordered = events.sort( + (a, b) => parseProcessedAt(a.processed_at) - parseProcessedAt(b.processed_at) + ) + const total = ordered.length + if (input.maxItems === undefined || Number.isNaN(input.maxItems)) { + return { events: ordered, total } + } + // Floor first: `slice` truncates its index toward zero, so a cap between 0 + // and 1 would become `slice(-0)` — i.e. `slice(0)` — and hand back the ENTIRE + // history for what the caller asked to be the tightest possible bound. Doing + // it here means no caller can hit that, whatever it passes. + const maxItems = Math.floor(input.maxItems) + if (maxItems <= 0) return { events: [], total } + if (total <= maxItems) return { events: ordered, total } + // Slice AFTER ordering so the cap is "the newest N", independent of the order + // the API returned pages in. + return { events: ordered.slice(-maxItems), total } } /** Epoch millis for a `processed_at`, or +Infinity when absent/queued/unparseable (sorts last). */ @@ -401,25 +695,153 @@ export async function getSession( signal: input.signal, }) if (!resp.ok) return null - const body = (await resp.json()) as { - status?: unknown - usage?: { input_tokens?: unknown; output_tokens?: unknown } - } - const snapshot: SessionSnapshot = {} - if ( - body.status === 'idle' || - body.status === 'running' || - body.status === 'rescheduling' || - body.status === 'terminated' - ) { - snapshot.status = body.status - } - const usage: SessionUsage = {} - if (typeof body.usage?.input_tokens === 'number') usage.inputTokens = body.usage.input_tokens - if (typeof body.usage?.output_tokens === 'number') usage.outputTokens = body.usage.output_tokens - if (usage.inputTokens !== undefined || usage.outputTokens !== undefined) snapshot.usage = usage - return snapshot + return parseSessionSnapshot(await resp.json()) } catch { return null } } + +/** + * Maps a raw `/v1/sessions/{id}` body onto {@link SessionSnapshot}. Split out + * so it is directly unit-testable and shared by the best-effort `getSession` + * and the strict `retrieveSession`. + */ +export function parseSessionSnapshot(raw: unknown): SessionSnapshot { + const body = (raw ?? {}) as { + status?: unknown + title?: unknown + metadata?: unknown + usage?: { input_tokens?: unknown; output_tokens?: unknown } + stop_reason?: { type?: unknown; event_ids?: unknown } + } + const snapshot: SessionSnapshot = {} + if ( + body.status === 'idle' || + body.status === 'running' || + body.status === 'rescheduling' || + body.status === 'terminated' + ) { + snapshot.status = body.status + } + const usage: SessionUsage = {} + if (typeof body.usage?.input_tokens === 'number') usage.inputTokens = body.usage.input_tokens + if (typeof body.usage?.output_tokens === 'number') usage.outputTokens = body.usage.output_tokens + if (usage.inputTokens !== undefined || usage.outputTokens !== undefined) snapshot.usage = usage + + if (body.stop_reason && typeof body.stop_reason === 'object') { + const stopReason: SessionStopReason = {} + if (typeof body.stop_reason.type === 'string') stopReason.type = body.stop_reason.type + if (Array.isArray(body.stop_reason.event_ids)) { + const eventIds = body.stop_reason.event_ids.filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ) + if (eventIds.length > 0) stopReason.eventIds = eventIds + } + if (stopReason.type !== undefined || stopReason.eventIds !== undefined) { + snapshot.stopReason = stopReason + } + } + + if (typeof body.title === 'string') snapshot.title = body.title + if (body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)) { + const metadata: Record = {} + for (const [key, value] of Object.entries(body.metadata as Record)) { + if (typeof value === 'string') metadata[key] = value + else if (typeof value === 'number' || typeof value === 'boolean') + metadata[key] = String(value) + } + if (Object.keys(metadata).length > 0) snapshot.metadata = metadata + } + return snapshot +} + +/** + * GET /v1/sessions/{id}, but STRICT — throws on a non-2xx instead of returning + * `null`. The block's Get Session operation reports a missing/unauthorized + * session as a block error rather than silently yielding an empty result; + * {@link getSession} keeps its best-effort contract for the run loop. + */ +export async function retrieveSession( + input: SessionAuth & { sessionId: string } +): Promise { + const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/sessions/${input.sessionId}`, { + method: 'GET', + headers: managedAgentsHeaders(input.apiKey), + signal: input.signal, + }) + if (!resp.ok) { + const detail = await resp.text().catch(() => '') + throw new Error(`Anthropic sessions.get failed (${resp.status}): ${detail.slice(0, 400)}`) + } + return parseSessionSnapshot(await resp.json()) +} + +/** + * POST /v1/sessions/{id} — updates session `title` and/or `metadata`. + * + * `metadata` is a FULL REPLACEMENT of the stored map, matching the API's + * replace semantics; callers that want to merge must read the session first. + * The session must be `idle` for agent-config updates; title/metadata updates + * are not gated that way. + * https://platform.claude.com/docs/en/managed-agents/session-operations + */ +export async function updateSession( + input: SessionAuth & { + sessionId: string + title?: string + metadata?: Record + } +): Promise { + const payload: Record = {} + if (input.title !== undefined) payload.title = input.title + if (input.metadata !== undefined) payload.metadata = input.metadata + if (Object.keys(payload).length === 0) { + throw new Error('Update session requires a title or metadata to change.') + } + const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/sessions/${input.sessionId}`, { + method: 'POST', + headers: managedAgentsHeaders(input.apiKey, { json: true }), + body: JSON.stringify(payload), + signal: input.signal, + }) + if (!resp.ok) { + const detail = await resp.text().catch(() => '') + throw new Error(`Anthropic sessions.update failed (${resp.status}): ${detail.slice(0, 400)}`) + } + return parseSessionSnapshot(await resp.json().catch(() => ({}))) +} + +/** + * POST /v1/sessions/{id}/archive — makes the session read-only while + * preserving its history. A `running` session cannot be archived (interrupt + * it first), and archiving is NOT reversible. + */ +export async function archiveSession(input: SessionAuth & { sessionId: string }): Promise { + const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/sessions/${input.sessionId}/archive`, { + method: 'POST', + headers: managedAgentsHeaders(input.apiKey), + signal: input.signal, + }) + if (!resp.ok) { + const detail = await resp.text().catch(() => '') + throw new Error(`Anthropic sessions.archive failed (${resp.status}): ${detail.slice(0, 400)}`) + } +} + +/** + * DELETE /v1/sessions/{id} — permanently removes the session record, its + * events, and its sandbox. A `running` session cannot be deleted (interrupt it + * first). Files, memory stores, vaults, skills, environments, and agents are + * independent resources and are NOT affected. + */ +export async function deleteSession(input: SessionAuth & { sessionId: string }): Promise { + const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/sessions/${input.sessionId}`, { + method: 'DELETE', + headers: managedAgentsHeaders(input.apiKey), + signal: input.signal, + }) + if (!resp.ok) { + const detail = await resp.text().catch(() => '') + throw new Error(`Anthropic sessions.delete failed (${resp.status}): ${detail.slice(0, 400)}`) + } +} diff --git a/apps/sim/lib/managed-agents/wire-shapes.test.ts b/apps/sim/lib/managed-agents/wire-shapes.test.ts new file mode 100644 index 00000000000..69fd77b137d --- /dev/null +++ b/apps/sim/lib/managed-agents/wire-shapes.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + * + * Pins the exact HTTP shape of every Managed Agents call: method, URL, and the + * beta header each endpoint family requires. These are the details that cannot + * be caught by types or by the payload-builder tests, and that silently break + * if someone "tidies" a path or shares a header across endpoint families. + * + * Verified against https://platform.claude.com/docs/en/managed-agents/ + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_MEMORY_BETA, + archiveSession, + createSession, + deleteSession, + getEnvironmentType, + listSessionEvents, + MANAGED_AGENTS_BETA, + managedAgentsList, + openSessionStream, + retrieveSession, + sendCustomToolResults, + sendSessionEvents, + sendToolConfirmations, + updateSession, +} from '@/lib/managed-agents/session-client' + +const originalFetch = global.fetch +afterEach(() => { + global.fetch = originalFetch +}) + +const spyOn = (body: unknown = {}) => { + const spy = vi.fn(async () => Response.json(body)) as unknown as typeof fetch + global.fetch = spy + return spy as unknown as ReturnType +} + +const call = (spy: ReturnType) => { + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + const headers = init.headers as Record + return { url: url.split('?')[0], method: init.method, headers } +} + +const AUTH = { apiKey: 'sk-ant-fake' } +const S = { ...AUTH, sessionId: 'sesn_1' } +const BASE = 'https://api.anthropic.com' + +describe('Managed Agents wire shapes', () => { + it.each([ + [ + 'createSession', + () => createSession({ ...AUTH, agentId: 'a', environmentId: 'e' }), + 'POST', + `${BASE}/v1/sessions`, + ], + ['retrieveSession', () => retrieveSession(S), 'GET', `${BASE}/v1/sessions/sesn_1`], + [ + 'updateSession', + () => updateSession({ ...S, title: 't' }), + 'POST', + `${BASE}/v1/sessions/sesn_1`, + ], + ['deleteSession', () => deleteSession(S), 'DELETE', `${BASE}/v1/sessions/sesn_1`], + ['archiveSession', () => archiveSession(S), 'POST', `${BASE}/v1/sessions/sesn_1/archive`], + ['listSessionEvents', () => listSessionEvents(S), 'GET', `${BASE}/v1/sessions/sesn_1/events`], + [ + 'sendSessionEvents', + () => sendSessionEvents({ ...S, events: [{ type: 'user.interrupt' }] }), + 'POST', + `${BASE}/v1/sessions/sesn_1/events`, + ], + [ + 'sendToolConfirmations', + () => sendToolConfirmations({ ...S, confirmations: [{ toolUseId: 'x', result: 'allow' }] }), + 'POST', + `${BASE}/v1/sessions/sesn_1/events`, + ], + [ + 'sendCustomToolResults', + () => sendCustomToolResults({ ...S, results: [{ customToolUseId: 'x', content: 'y' }] }), + 'POST', + `${BASE}/v1/sessions/sesn_1/events`, + ], + [ + 'getEnvironmentType', + () => getEnvironmentType({ ...AUTH, environmentId: 'env_1' }), + 'GET', + `${BASE}/v1/environments/env_1`, + ], + ])('%s hits %s %s with the managed-agents beta', async (_name, run, method, url) => { + const spy = spyOn({ id: 'sesn_1', config: { type: 'cloud' } }) + await run() + const c = call(spy) + expect(c.method).toBe(method) + expect(c.url).toBe(url) + expect(c.headers['anthropic-beta']).toBe(MANAGED_AGENTS_BETA) + expect(c.headers['anthropic-version']).toBe('2023-06-01') + expect(c.headers['x-api-key']).toBe('sk-ant-fake') + }) + + it('opens the event stream as SSE', async () => { + const spy = spyOn() + await openSessionStream(S) + const c = call(spy) + expect(c.method).toBe('GET') + expect(c.url).toBe(`${BASE}/v1/sessions/sesn_1/events/stream`) + expect(c.headers.accept).toBe('text/event-stream') + }) + + it('sends the SEPARATE memory beta on memory-store reads, never the managed-agents one', async () => { + // Combining the two headers on one request is a documented 400, so the + // memory-store family must carry its own and only its own. + const spy = spyOn({ data: [], next_page: null }) + await managedAgentsList({ ...AUTH, path: '/v1/memory_stores', beta: AGENT_MEMORY_BETA }) + const c = call(spy) + expect(c.headers['anthropic-beta']).toBe(AGENT_MEMORY_BETA) + expect(AGENT_MEMORY_BETA).not.toBe(MANAGED_AGENTS_BETA) + }) + + it('sets content-type only on requests that carry a body', async () => { + const post = spyOn({ id: 'sesn_1' }) + await createSession({ ...AUTH, agentId: 'a', environmentId: 'e' }) + expect(call(post).headers['content-type']).toBe('application/json') + + const get = spyOn({ status: 'idle' }) + await retrieveSession(S) + expect(call(get).headers['content-type']).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/model-router/resolve.test.ts b/apps/sim/lib/model-router/resolve.test.ts index d4f65f7b6e5..a0cbbccd3a1 100644 --- a/apps/sim/lib/model-router/resolve.test.ts +++ b/apps/sim/lib/model-router/resolve.test.ts @@ -40,7 +40,11 @@ vi.mock('@/providers/utils', () => ({ getProviderFromModel: mockGetProviderFromModel, })) -import { type AutoRoutingSignals, resolveAutoModel } from '@/lib/model-router/resolve' +import { + type AutoRoutingSignals, + addAutoRoutingCost, + resolveAutoModel, +} from '@/lib/model-router/resolve' import type { ExecutionContext } from '@/executor/types' const ctx = { @@ -69,6 +73,23 @@ function routerResponse(body: unknown, ok = true, status = 200) { return { ok, status, json: () => Promise.resolve(body) } } +describe('addAutoRoutingCost', () => { + it('adds a billable routing call to a settled provider cost', () => { + expect(addAutoRoutingCost({ input: 0.001, output: 0.002, total: 0.003 }, 0.0005)).toEqual({ + input: 0.001, + output: 0.002, + routing: 0.0005, + total: 0.0035, + }) + }) + + it('leaves provider cost unchanged when routing was not billable', () => { + const cost = { input: 0.001, output: 0.002, total: 0.003 } + + expect(addAutoRoutingCost(cost, 0)).toBe(cost) + }) +}) + describe('resolveAutoModel', () => { beforeEach(() => { vi.clearAllMocks() @@ -78,10 +99,22 @@ describe('resolveAutoModel', () => { }) /** The full pool, exactly as specified: media kind → tier → model. */ - const POOL_CASES: Array<{ mediaKind: AutoRoutingSignals['mediaKind']; byTier: string[] }> = [ - { mediaKind: 'none', byTier: ['fireworks/glm-5.2', 'fireworks/glm-5.2', 'fireworks/kimi-k3'] }, - { mediaKind: 'image', byTier: ['gemini-3.6-flash', 'fireworks/kimi-k3', 'fireworks/kimi-k3'] }, - { mediaKind: 'file', byTier: ['gemini-3.6-flash', 'claude-sonnet-5', 'gpt-5.6-sol'] }, + const POOL_CASES: Array<{ + mediaKind: AutoRoutingSignals['mediaKind'] + byTier: string[] + }> = [ + { + mediaKind: 'none', + byTier: ['fireworks/glm-5.2', 'fireworks/glm-5.2', 'fireworks/kimi-k3'], + }, + { + mediaKind: 'image', + byTier: ['gemini-3.6-flash', 'fireworks/kimi-k3', 'fireworks/kimi-k3'], + }, + { + mediaKind: 'file', + byTier: ['gemini-3.6-flash', 'claude-sonnet-5', 'gpt-5.6-sol'], + }, ] for (const { mediaKind, byTier } of POOL_CASES) { @@ -148,7 +181,11 @@ describe('resolveAutoModel', () => { const result = await resolveAutoModel({ ctx, blockId: 'b1', - signals: makeSignals({ approxInputTokens: 20, toolNames: [], hasResponseFormat: false }), + signals: makeSignals({ + approxInputTokens: 20, + toolNames: [], + hasResponseFormat: false, + }), fallbackModel: 'claude-sonnet-5', }) diff --git a/apps/sim/lib/model-router/resolve.ts b/apps/sim/lib/model-router/resolve.ts index 9a4a1921570..bba46bdfe38 100644 --- a/apps/sim/lib/model-router/resolve.ts +++ b/apps/sim/lib/model-router/resolve.ts @@ -7,6 +7,7 @@ import { env } from '@/lib/core/config/env' import { getCostMultiplier, isHosted } from '@/lib/core/config/env-flags' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import type { ExecutionContext } from '@/executor/types' +import type { ModelCost } from '@/providers/cost-policy' import { getProviderFromModel } from '@/providers/utils' const logger = createLogger('ModelRouter') @@ -83,7 +84,7 @@ const MAX_SIGNAL_CHARS = 2000 const DECISION_CACHE_TTL_MS = 5 * 60 * 1000 const DECISION_CACHE_MAX_ENTRIES = 500 -/** Compact facts about the pending agent-block task; never the full payload. */ +/** Compact facts about the pending LLM-backed block task; never the full payload. */ export interface AutoRoutingSignals { systemPrompt?: string lastMessage?: string @@ -128,6 +129,19 @@ export interface AutoRoutingResult { usage?: ModelRouterUsage } +export type AutoRoutedModelCost = ModelCost & { routing?: number } + +/** Adds a successful sim-auto classifier call to a settled provider cost. */ +export function addAutoRoutingCost(cost: ModelCost, routingCost: number): AutoRoutedModelCost { + if (routingCost <= 0) return cost + + return { + ...cost, + routing: routingCost, + total: cost.total + routingCost, + } +} + const decisionCache = new Map() function cacheKey(signals: AutoRoutingSignals): string { @@ -192,7 +206,9 @@ async function pickModelForTier( await validateModelProvider(ctx.userId, ctx.workspaceId ?? undefined, model, ctx) return model } catch { - logger.info('sim-auto candidate unavailable, trying the next one', { model }) + logger.info('sim-auto candidate unavailable, trying the next one', { + model, + }) } } } @@ -242,7 +258,7 @@ async function callModelRouter( } /** - * Resolves the sim-auto pseudo-model to a concrete model for one agent-block + * Resolves the sim-auto pseudo-model to a concrete model for one block * execution. Never throws and never fails the workflow: any error, timeout, * non-hosted deployment, or fully unavailable pool column falls back to * `fallbackModel` (the block's standard default). @@ -273,7 +289,12 @@ export async function resolveAutoModel(args: { if (cachedTier) { const model = await pickModelForTier(signals.mediaKind, cachedTier, ctx) if (!model) return fallback - return { model, tier: cachedTier, decidedBy: 'cache', billableRoutingCost: 0 } + return { + model, + tier: cachedTier, + decidedBy: 'cache', + billableRoutingCost: 0, + } } const response = await callModelRouter(signals, ctx, blockId) diff --git a/apps/sim/lib/realtime/event-log.test.ts b/apps/sim/lib/realtime/event-log.test.ts new file mode 100644 index 00000000000..20b8ab44cf8 --- /dev/null +++ b/apps/sim/lib/realtime/event-log.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env', () => ({ env: { REDIS_URL: undefined } })) +vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => null })) + +import { + appendEvent, + type EventLogConfig, + type EventLogEntry, + getLatestEventId, + readEventsSince, + resetEventLogMemoryForTesting, +} from '@/lib/realtime/event-log' + +interface TestEntry extends EventLogEntry { + eventId: number + streamId: string + value: string +} + +const config: EventLogConfig = { prefix: 'test:stream:', ttlSeconds: 3600, cap: 3, readChunk: 500 } + +function serializerFor(streamId: string, value: string) { + return { + entryPrefix: '{"eventId":', + entrySuffix: `,"streamId":${JSON.stringify(streamId)},"value":${JSON.stringify(value)}}`, + buildEntry: (eventId: number): TestEntry => ({ eventId, streamId, value }), + } +} + +describe('event-log (memory fallback)', () => { + beforeEach(() => resetEventLogMemoryForTesting()) + + it('assigns monotonically increasing event ids', async () => { + const first = await appendEvent(config, 's1', serializerFor('s1', 'a')) + const second = await appendEvent(config, 's1', serializerFor('s1', 'b')) + expect(first?.eventId).toBe(1) + expect(second?.eventId).toBe(2) + }) + + it('isolates streams by id', async () => { + await appendEvent(config, 's1', serializerFor('s1', 'a')) + const other = await appendEvent(config, 's2', serializerFor('s2', 'x')) + expect(other?.eventId).toBe(1) + expect(await getLatestEventId(config, 's1')).toBe(1) + expect(await getLatestEventId(config, 's2')).toBe(1) + }) + + it('reads only events after the cursor', async () => { + await appendEvent(config, 's1', serializerFor('s1', 'a')) + await appendEvent(config, 's1', serializerFor('s1', 'b')) + const result = await readEventsSince(config, 's1', 1) + expect(result.status).toBe('ok') + if (result.status === 'ok') { + expect(result.events).toHaveLength(1) + expect(result.events[0].eventId).toBe(2) + expect(result.events[0].value).toBe('b') + } + }) + + it('tails from the latest id and returns nothing for a fresh cursor', async () => { + await appendEvent(config, 's1', serializerFor('s1', 'a')) + await appendEvent(config, 's1', serializerFor('s1', 'b')) + const latest = await getLatestEventId(config, 's1') + const result = await readEventsSince(config, 's1', latest) + expect(result).toEqual({ status: 'ok', events: [] }) + }) + + it('reports pruned when the cursor falls behind the cap-trimmed buffer', async () => { + // cap = 3; append 5, so the earliest retained id is 3. + for (const v of ['a', 'b', 'c', 'd', 'e']) { + await appendEvent(config, 's1', serializerFor('s1', v)) + } + const result = await readEventsSince(config, 's1', 1) + expect(result.status).toBe('pruned') + if (result.status === 'pruned') expect(result.earliestEventId).toBe(3) + }) + + it('reports pruned for a non-zero cursor against a never-seen stream', async () => { + const result = await readEventsSince(config, 'missing', 5) + expect(result.status).toBe('pruned') + }) +}) diff --git a/apps/sim/lib/realtime/event-log.ts b/apps/sim/lib/realtime/event-log.ts new file mode 100644 index 00000000000..f7b470c33a2 --- /dev/null +++ b/apps/sim/lib/realtime/event-log.ts @@ -0,0 +1,283 @@ +/** + * Generic durable event log over Redis (sorted set + monotonic id + TTL), with an + * in-memory fallback for dev/tests. This is the reusable core extracted from the + * Tables cell-event buffer; a domain adapter (e.g. `lib/table/events.ts`) supplies + * its Redis key prefix and how to serialize an entry, and gets append/read/tail + * semantics for free — including replay-on-reconnect and prune detection. + * + * The core is deliberately domain-neutral: it only knows an entry has a numeric + * `eventId`. Everything else in the entry is opaque bytes the adapter owns, so a + * domain can keep its exact wire shape (and its existing Redis keys) unchanged. + * + * Modeled after `apps/sim/lib/execution/event-buffer.ts` but stripped of what an + * always-on stream doesn't need (no id-reservation batching, no write-queue + * serialization, no per-entity terminal lifecycle, no byte budgeting). + */ + +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { getRedisClient } from '@/lib/core/config/redis' + +const logger = createLogger('EventLog') + +/** + * Atomic append: INCR the seq counter to mint a new eventId, splice it into the + * adapter-supplied entry JSON, ZADD it, refresh TTLs, trim to cap, and record the + * resulting earliestEventId in meta — one round-trip. Without atomicity a slow + * reader could observe the trim before the meta update and miss the prune signal. + * + * KEYS: [events, seq, meta] + * ARGV: [ttlSec, cap, updatedAtIso, entryPrefix, entrySuffix] + * The new eventId is spliced between prefix/suffix to form the entry JSON. + * Returns the new eventId. + */ +const APPEND_EVENT_SCRIPT = ` +local eventId = redis.call('INCR', KEYS[2]) +local entry = ARGV[4] .. eventId .. ARGV[5] +redis.call('ZADD', KEYS[1], eventId, entry) +redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1])) +redis.call('EXPIRE', KEYS[2], tonumber(ARGV[1])) +redis.call('ZREMRANGEBYRANK', KEYS[1], 0, -tonumber(ARGV[2]) - 1) +local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES') +if oldest[2] then + redis.call('HSET', KEYS[3], 'earliestEventId', tostring(math.floor(tonumber(oldest[2]))), 'updatedAt', ARGV[3]) + redis.call('EXPIRE', KEYS[3], tonumber(ARGV[1])) +end +return eventId +` + +/** Configuration for a durable event-log stream family (e.g. Tables). */ +export interface EventLogConfig { + /** + * Redis key prefix, e.g. `table:stream:`. STABLE per family — renaming it resets + * the seq counter, which silently strands live clients holding a higher + * in-memory `lastEventId` (their `?from=` never matches). Never change it. + */ + prefix: string + ttlSeconds: number + cap: number + /** Max entries returned by one read; the SSE route drains in chunks. */ + readChunk: number +} + +/** Minimal shape the core requires; adapters extend it with their own fields. */ +export interface EventLogEntry { + eventId: number +} + +/** + * How an adapter serializes one entry. `entryPrefix`/`entrySuffix` are spliced + * around the minted `eventId` in Lua (`prefix + eventId + suffix`) for the Redis + * write; `buildEntry` returns the equivalent object. It is the canonical entry + * builder for BOTH paths — the in-memory fallback AND the Redis success path, + * which returns `buildEntry(eventId)` rather than re-parsing the stored string — + * so it MUST produce the byte-identical shape to the spliced JSON, or dev/no-Redis + * behaves differently from prod. + */ +export interface EntrySerializer { + entryPrefix: string + entrySuffix: string + buildEntry: (eventId: number) => E +} + +export type EventLogReadResult = + | { status: 'ok'; events: E[] } + | { status: 'pruned'; earliestEventId: number | undefined } + | { status: 'unavailable'; error: string } + +function eventsKey(config: EventLogConfig, streamId: string) { + return `${config.prefix}${streamId}:events` +} +function seqKey(config: EventLogConfig, streamId: string) { + return `${config.prefix}${streamId}:seq` +} +function metaKey(config: EventLogConfig, streamId: string) { + return `${config.prefix}${streamId}:meta` +} + +interface MemoryStream { + events: E[] + earliestEventId?: number + nextEventId: number + expiresAt: number +} + +/** In-memory fallback keyed by `${prefix}${streamId}`, shared across all families. */ +const memoryStreams = new Map>() + +function memoryKey(config: EventLogConfig, streamId: string) { + return `${config.prefix}${streamId}` +} + +function canUseMemoryBuffer(): boolean { + return typeof window === 'undefined' && !env.REDIS_URL +} + +function pruneExpiredMemoryStreams(now = Date.now()): void { + for (const [key, stream] of memoryStreams) { + if (stream.expiresAt <= now) memoryStreams.delete(key) + } +} + +function getMemoryStream(config: EventLogConfig, streamId: string): MemoryStream { + pruneExpiredMemoryStreams() + const key = memoryKey(config, streamId) + let stream = memoryStreams.get(key) + if (!stream) { + stream = { events: [], nextEventId: 1, expiresAt: Date.now() + config.ttlSeconds * 1000 } + memoryStreams.set(key, stream) + } + return stream +} + +/** + * Append an event. Fire-and-forget from the caller — never throws, returns null on + * failure. A Redis blip must not fail the originating mutation. + */ +export async function appendEvent( + config: EventLogConfig, + streamId: string, + serializer: EntrySerializer +): Promise { + const redis = getRedisClient() + if (!redis) { + if (canUseMemoryBuffer()) { + try { + const stream = getMemoryStream(config, streamId) + const entry = serializer.buildEntry(stream.nextEventId++) + stream.events.push(entry) + if (stream.events.length > config.cap) { + stream.events = stream.events.slice(-config.cap) + stream.earliestEventId = stream.events[0]?.eventId + } + stream.expiresAt = Date.now() + config.ttlSeconds * 1000 + return entry + } catch (error) { + logger.warn('appendEvent: memory append failed', { + streamId, + error: toError(error).message, + }) + return null + } + } + return null + } + try { + const result = await redis.eval( + APPEND_EVENT_SCRIPT, + 3, + eventsKey(config, streamId), + seqKey(config, streamId), + metaKey(config, streamId), + config.ttlSeconds, + config.cap, + new Date().toISOString(), + serializer.entryPrefix, + serializer.entrySuffix + ) + const eventId = typeof result === 'number' ? result : Number(result) + if (!Number.isFinite(eventId)) return null + return serializer.buildEntry(eventId) + } catch (error) { + logger.warn('appendEvent: Redis append failed', { streamId, error: toError(error).message }) + return null + } +} + +/** + * The latest eventId assigned for a stream, or 0 when empty/expired. Used by the + * stream route to tail from "now" when a client connects without a replay cursor. + * Redis errors propagate so the route errors the stream instead of replaying the + * whole buffer over freshly-fetched state. + */ +export async function getLatestEventId(config: EventLogConfig, streamId: string): Promise { + const redis = getRedisClient() + if (!redis) { + if (canUseMemoryBuffer()) { + const stream = memoryStreams.get(memoryKey(config, streamId)) + return stream ? stream.nextEventId - 1 : 0 + } + return 0 + } + const raw = await redis.get(seqKey(config, streamId)) + if (!raw) return 0 + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 +} + +/** + * Read events where eventId > afterEventId. Returns 'pruned' if the caller has + * fallen off the back of the buffer (TTL expired or cap rolled past their cursor); + * the caller should full-refetch and resume from the new earliest id. + */ +export async function readEventsSince( + config: EventLogConfig, + streamId: string, + afterEventId: number +): Promise> { + const redis = getRedisClient() + if (!redis) { + if (canUseMemoryBuffer()) { + pruneExpiredMemoryStreams() + const stream = memoryStreams.get(memoryKey(config, streamId)) + if (!stream) { + if (afterEventId > 0) return { status: 'pruned', earliestEventId: undefined } + return { status: 'ok', events: [] } + } + if (stream.earliestEventId !== undefined && afterEventId + 1 < stream.earliestEventId) { + return { status: 'pruned', earliestEventId: stream.earliestEventId } + } + return { + status: 'ok', + events: stream.events + .filter((entry) => entry.eventId > afterEventId) + .slice(0, config.readChunk) as E[], + } + } + return { status: 'unavailable', error: 'Redis client unavailable' } + } + try { + const meta = await redis.hgetall(metaKey(config, streamId)) + const earliestEventId = + meta?.earliestEventId !== undefined ? Number(meta.earliestEventId) : undefined + if (earliestEventId !== undefined && afterEventId + 1 < earliestEventId) { + return { status: 'pruned', earliestEventId } + } + const raw = await redis.zrangebyscore( + eventsKey(config, streamId), + afterEventId + 1, + '+inf', + 'LIMIT', + 0, + config.readChunk + ) + if (raw.length === 0 && afterEventId > 0) { + const seqExists = await redis.exists(seqKey(config, streamId)) + if (seqExists === 0) { + return { status: 'pruned', earliestEventId: undefined } + } + } + return { + status: 'ok', + events: raw + .map((entry) => { + try { + return JSON.parse(entry) as E + } catch { + return null + } + }) + .filter((entry): entry is E => entry !== null), + } + } catch (error) { + const message = toError(error).message + logger.warn('readEventsSince failed', { streamId, error: message }) + return { status: 'unavailable', error: message } + } +} + +/** Test-only: clear the in-memory streams between cases. */ +export function resetEventLogMemoryForTesting(): void { + memoryStreams.clear() +} diff --git a/apps/sim/lib/realtime/event-stream-route.ts b/apps/sim/lib/realtime/event-stream-route.ts new file mode 100644 index 00000000000..ad4fd2e8a82 --- /dev/null +++ b/apps/sim/lib/realtime/event-stream-route.ts @@ -0,0 +1,160 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { NextResponse } from 'next/server' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import type { EventLogEntry, EventLogReadResult } from '@/lib/realtime/event-log' + +const logger = createLogger('EventStreamRoute') + +const POLL_INTERVAL_MS = 500 +const HEARTBEAT_INTERVAL_MS = 15_000 +/** Defensive ceiling; the client reconnects (resuming from lastEventId) past this. */ +const MAX_STREAM_DURATION_MS = 4 * 60 * 60 * 1000 + +export interface EventStreamResponseOptions { + requestId: string + /** The durable-log stream id (e.g. a tableId). */ + streamId: string + /** Replay cursor from `?from=`; `undefined` tails from the latest event id. */ + fromEventId: number | undefined + getLatestEventId: (streamId: string) => Promise + readEventsSince: (streamId: string, afterEventId: number) => Promise> + /** Extra response headers (e.g. `{ 'X-Table-Id': id }`). */ + extraHeaders?: Record + /** Short label for logs (e.g. 'table'). */ + label: string +} + +/** + * Shared SSE stream for any durable event log (`@/lib/realtime/event-log`). Handles + * replay-on-reconnect (`?from=`), tail-from-latest on a fresh mount, chunked poll + + * forward, heartbeats, graceful `pruned`/`rotate` close, and error propagation. + * + * Auth and contract parsing stay in the route (they are domain-specific); this + * owns only the streaming mechanics, so every durable-log surface streams + * identically. The poll loop mirrors the execution stream; pub/sub wakeups are an + * optimization that can replace the 500ms poll later without changing this shape. + */ +export function createEventStreamResponse( + options: EventStreamResponseOptions +): NextResponse { + const { requestId, streamId, fromEventId, getLatestEventId, readEventsSince, label } = options + + logger.info(`[${requestId}] ${label} event stream opened`, { streamId, fromEventId }) + + const encoder = new TextEncoder() + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + let lastEventId = fromEventId ?? 0 + const deadline = Date.now() + MAX_STREAM_DURATION_MS + let nextHeartbeatAt = Date.now() + HEARTBEAT_INTERVAL_MS + + const enqueue = (text: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(text)) + } catch { + closed = true + } + } + + const sendEvents = (events: E[]) => { + for (const entry of events) { + if (closed) return + enqueue(`data: ${JSON.stringify(entry)}\n\n`) + lastEventId = entry.eventId + } + } + + const sendPrunedAndClose = (earliestEventId: number | undefined) => { + enqueue( + `event: pruned\ndata: ${JSON.stringify({ earliestEventId: earliestEventId ?? null })}\n\n` + ) + if (!closed) { + closed = true + try { + controller.close() + } catch {} + } + } + + const sendHeartbeat = () => { + // SSE comment line — keeps proxies (ALB default 60s idle) from closing + // the connection during quiet periods. + enqueue(`: ping ${Date.now()}\n\n`) + } + + try { + // No replay cursor → tail from the latest event id. Resolved inside the + // try so a Redis failure errors the stream (client reconnects with + // backoff) rather than silently replaying the whole buffer. + if (fromEventId === undefined) { + lastEventId = await getLatestEventId(streamId) + } + + const initial = await readEventsSince(streamId, lastEventId) + if (initial.status === 'pruned') { + sendPrunedAndClose(initial.earliestEventId) + return + } + if (initial.status === 'unavailable') { + throw new Error(`${label} event buffer unavailable: ${initial.error}`) + } + sendEvents(initial.events) + + while (!closed && Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS) + if (closed) return + + const result = await readEventsSince(streamId, lastEventId) + if (result.status === 'pruned') { + sendPrunedAndClose(result.earliestEventId) + return + } + if (result.status === 'unavailable') { + throw new Error(`${label} event buffer unavailable: ${result.error}`) + } + if (result.events.length > 0) { + sendEvents(result.events) + } + + if (Date.now() >= nextHeartbeatAt) { + sendHeartbeat() + nextHeartbeatAt = Date.now() + HEARTBEAT_INTERVAL_MS + } + } + + // Reached the defensive duration ceiling — close cleanly so the client + // reconnects with the latest lastEventId. + if (!closed) { + enqueue(`event: rotate\ndata: {}\n\n`) + closed = true + try { + controller.close() + } catch {} + } + } catch (error) { + logger.error(`[${requestId}] ${label} event stream error`, { + streamId, + error: toError(error).message, + }) + if (!closed) { + try { + controller.error(error) + } catch {} + } + } + }, + cancel() { + closed = true + logger.info(`[${requestId}] Client disconnected from ${label} event stream`, { streamId }) + }, + }) + + return new NextResponse(stream, { + headers: { ...SSE_HEADERS, ...(options.extraHeaders ?? {}) }, + }) +} diff --git a/apps/sim/lib/realtime/notify.test.ts b/apps/sim/lib/realtime/notify.test.ts new file mode 100644 index 00000000000..98196492257 --- /dev/null +++ b/apps/sim/lib/realtime/notify.test.ts @@ -0,0 +1,112 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/utils/urls', () => ({ getSocketServerUrl: () => 'http://realtime' })) +vi.mock('@/lib/core/config/env', () => ({ env: { INTERNAL_API_SECRET: 'secret' } })) + +import { mergeEditIntoLiveFileDoc } from './notify' + +describe('mergeEditIntoLiveFileDoc', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('POSTs the edit to the realtime apply-edit endpoint with the api key', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + + await mergeEditIntoLiveFileDoc('file-1', '# hello', { version: 42 }) + + expect(fetchMock).toHaveBeenCalledWith( + 'http://realtime/api/file-doc/apply-edit', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'x-api-key': 'secret' }), + // A durable write sends `version`; an unversioned (legacy) call would drop it via JSON.stringify. + body: JSON.stringify({ fileId: 'file-1', markdown: '# hello', version: 42 }), + }) + ) + }) + + it('never throws when the realtime call fails (best-effort)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket pod down'))) + await expect( + mergeEditIntoLiveFileDoc('file-1', '# hello', { version: 42 }) + ).resolves.toBeUndefined() + }) + + it('never throws on a non-2xx response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 })) + await expect( + mergeEditIntoLiveFileDoc('file-1', '# hello', { version: 42 }) + ).resolves.toBeUndefined() + }) + + it('a later durable merge waits for an in-flight earlier one, then applies last', async () => { + let resolveFirst: (value: { ok: boolean }) => void = () => {} + const fetchMock = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + + const first = mergeEditIntoLiveFileDoc('file-durable', 'earlier', { version: 99 }) // in flight + await Promise.resolve() + const durable = mergeEditIntoLiveFileDoc('file-durable', 'final content', { version: 100 }) + await Promise.resolve() + await Promise.resolve() + + // The later write waits for the in-flight earlier one → its fetch has not fired yet, so it cannot be + // reordered before a straggler and cannot be clobbered by one. + expect(fetchMock).toHaveBeenCalledTimes(1) + + resolveFirst({ ok: true }) + await first + await durable + + // Only after the earlier merge completed does the later (final) merge apply — always last. + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1][1].body).toBe( + JSON.stringify({ fileId: 'file-durable', markdown: 'final content', version: 100 }) + ) + }) + + it('serializes concurrent durable writes to a file strictly in order', async () => { + const applied: number[] = [] + const resolvers: Array<() => void> = [] + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init: { body: string }) => { + applied.push(JSON.parse(init.body).version) + return new Promise<{ ok: boolean }>((resolve) => + resolvers.push(() => resolve({ ok: true })) + ) + }) + ) + const flush = async () => { + for (let i = 0; i < 6; i++) await Promise.resolve() + } + + const s = mergeEditIntoLiveFileDoc('file-order', 's', { version: 0 }) // in flight + await flush() + // Two later durable writes arrive while the first merge is in flight — both must chain, not both + // resume-and-fire concurrently. + const a = mergeEditIntoLiveFileDoc('file-order', 'a', { version: 1 }) + const b = mergeEditIntoLiveFileDoc('file-order', 'b', { version: 2 }) + await flush() + expect(applied).toEqual([0]) // A and B queued behind the in-flight first merge + + resolvers[0]() // finish first → A applies next (not B) + await flush() + expect(applied).toEqual([0, 1]) + + resolvers[1]() // finish A → B applies after A + await flush() + expect(applied).toEqual([0, 1, 2]) + + resolvers[2]() + await Promise.all([s, a, b]) + }) +}) diff --git a/apps/sim/lib/realtime/notify.ts b/apps/sim/lib/realtime/notify.ts new file mode 100644 index 00000000000..58a6c46f4f7 --- /dev/null +++ b/apps/sim/lib/realtime/notify.ts @@ -0,0 +1,191 @@ +import { createLogger } from '@sim/logger' +import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { getErrorMessage } from '@sim/utils/errors' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import { env } from '@/lib/core/config/env' +import { getSocketServerUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('RealtimeNotify') + +/** Bound the wait on the realtime server so a slow/hung socket pod can't stall a file mutation. */ +const NOTIFY_TIMEOUT_MS = 2000 + +/** + * Bound the wait on the live-doc merge. This OUTER call wraps the relay's inner relay→app `/merge` + * request (`FILE_DOC_TIMEOUTS.mergeRequestMs`), so it must stay comfortably ABOVE that — the shared + * constant + its test enforce the ordering. It leaves the inner merge plus the two network hops. + */ +const APPLY_EDIT_TIMEOUT_MS = FILE_DOC_TIMEOUTS.applyEditMs + +/** + * Best-effort fan-out to the realtime server that a workspace's file tree changed, + * so every browser currently viewing that workspace's files refetches. File + * mutations happen over the HTTP API (not the socket); this is a lossy liveness + * signal — a dropped notification only degrades to stale-until-refetch. + * + * Never throws. Callers `await` it (rather than fire-and-forget) so the fetch is + * guaranteed to dispatch before a Node route handler returns — a floating promise + * can be dropped after the response is sent. It is a normally-sub-millisecond + * local call and is hard-bounded to {@link NOTIFY_TIMEOUT_MS}, so it adds that + * latency only when the socket pod is unreachable. + */ +export async function notifyWorkspaceFilesChanged(workspaceId: string): Promise { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workspace-files-changed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workspaceId }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workspace-files-changed notify failed', { + workspaceId, + status: response.status, + }) + } + } catch (error) { + logger.warn('workspace-files-changed notify error', { + workspaceId, + error: getErrorMessage(error), + }) + } +} + +/** + * Best-effort fan-out to the realtime server that a workspace's table list changed (a table was + * created, renamed, moved, deleted, or restored), so every browser currently viewing that + * workspace's tables refetches. The list-level counterpart to {@link notifyWorkspaceFilesChanged}; + * table mutations happen server-side (HTTP routes AND copilot), so this fires from the shared table + * service, not a socket. Lossy — a dropped notification only degrades to stale-until-refetch. + * + * Never throws. Callers `await` it so the fetch is guaranteed to dispatch before the mutation + * returns; hard-bounded to {@link NOTIFY_TIMEOUT_MS}, so it adds that latency only when the socket + * pod is unreachable. + */ +export async function notifyWorkspaceTablesChanged(workspaceId: string): Promise { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workspace-tables-changed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workspaceId }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workspace-tables-changed notify failed', { + workspaceId, + status: response.status, + }) + } + } catch (error) { + logger.warn('workspace-tables-changed notify error', { + workspaceId, + error: getErrorMessage(error), + }) + } +} + +/** + * Folder resource types whose list is kept live by a workspace invalidation room: a folder mutation + * (create/rename/move/delete/restore) for one of these must fan out the same list-changed signal as a + * direct resource mutation, because a new/renamed/removed folder changes what that resource's browser + * shows. Extend this map as more resource lists adopt an invalidation room — `file` and + * `knowledge_base` currently refetch through their own paths, and `workflow` has no such list room. + */ +const FOLDER_RESOURCE_NOTIFIERS: Partial< + Record Promise> +> = { + table: notifyWorkspaceTablesChanged, +} + +/** + * Fan out the workspace live-list signal for a folder mutation, dispatched on the folder's resource + * type. A no-op for resource types without an invalidation room. Never throws (the underlying notify + * is best-effort). Callers `await` it so the dispatch is guaranteed before the mutation returns. + */ +export async function notifyFolderResourceChanged( + resourceType: FolderResourceType, + workspaceId: string +): Promise { + await FOLDER_RESOURCE_NOTIFIERS[resourceType]?.(workspaceId) +} + +/** + * How a durable live-doc merge is positioned on the file's monotonic version line. Omit `version` to + * apply the merge without ordering it (legacy). + */ +interface LiveFileDocMergeOrder { + /** A durable write's `contentUpdatedAt` (epoch ms): applied only if newer than the version the doc + * already incorporates, AND recorded as the synced version (the persist If-Match guard). */ + version?: number +} + +/** + * Best-effort: ask the realtime relay to merge a durable copilot/file write into a file's LIVE + * collaborative document, so open editors reconcile to it as a CRDT merge rather than the file changing + * underneath them, and a late joiner is seeded from it. No-op when no doc is (or was recently) live (the + * relay reports `applied: false`). The file itself is written durably by the caller regardless — this + * only drives the live view. Never throws. + * + * (Streaming copilot output is NOT merged here: the open editor applies the stream client-side as minimal + * CRDT diffs — see `applyStreamedMarkdownToLiveDoc` — which renders smoothly and broadcasts to peers. This + * merge is the stream-end durable reconcile, and by then it is usually a noop diff.) + * + * The former clobber gap — an open editor's autosave dropping this edit — is closed: a collaborative + * editor no longer client-autosaves (the relay persists the shared doc to markdown server-side), and the + * relay applies this merge THROUGH the shared Redis stream, so it reaches the live doc on whichever task + * holds it and can't go stale relative to this direct write. + * + * The caller awaits this so the fetch dispatches before the route handler returns. Bounded to + * {@link APPLY_EDIT_TIMEOUT_MS}, so it adds latency only when the socket pod is unreachable. + * + * `order.version` positions the merge so a stale write never regresses the doc: it applies only if newer + * than the version the doc already incorporates, and is recorded as the synced version. Ordering is + * enforced at two scales: within this process, merges for a file run on a single serialized chain (each + * chained after the current tail) so writes never apply concurrently; across processes the relay orders + * by that monotonic version under a cluster-wide lock. + */ +export async function mergeEditIntoLiveFileDoc( + fileId: string, + markdown: string, + order: LiveFileDocMergeOrder = {} +): Promise { + const tail = liveDocMergeChain.get(fileId) ?? Promise.resolve() + const run = tail.then(() => applyLiveFileDocMerge(fileId, markdown, order)) + liveDocMergeChain.set(fileId, run) + try { + await run + } finally { + if (liveDocMergeChain.get(fileId) === run) liveDocMergeChain.delete(fileId) + } +} + +/** Per file, the tail of the serialized merge chain (each merge applies after it); never rejects + * because {@link applyLiveFileDocMerge} never throws. Absent when the file's chain is idle. */ +const liveDocMergeChain = new Map>() + +/** POST the merge to the relay. Never throws (a live-doc merge is best-effort). */ +async function applyLiveFileDocMerge( + fileId: string, + markdown: string, + order: LiveFileDocMergeOrder +): Promise { + try { + const response = await fetch(`${getSocketServerUrl()}/api/file-doc/apply-edit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + // `version` (durable `contentUpdatedAt`) records the synced version the live doc now incorporates + // (the persist If-Match guard). JSON.stringify drops it when undefined (an unordered legacy merge). + body: JSON.stringify({ + fileId, + markdown, + version: order.version, + }), + signal: AbortSignal.timeout(APPLY_EDIT_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('file-doc apply-edit failed', { fileId, status: response.status }) + } + } catch (error) { + logger.warn('file-doc apply-edit error', { fileId, error: getErrorMessage(error) }) + } +} diff --git a/apps/sim/lib/table/delete-runner.test.ts b/apps/sim/lib/table/delete-runner.test.ts index 33f3f7cdc23..aa19a0faac4 100644 --- a/apps/sim/lib/table/delete-runner.test.ts +++ b/apps/sim/lib/table/delete-runner.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TableLockedError } from '@/lib/table/mutation-locks' const { mockGetTableById, @@ -13,6 +14,7 @@ const { mockMarkJobFailed, mockMarkJobCanceled, mockAppendTableEvent, + mockSignalTableRowsChanged, mockBuildFilterClause, } = vi.hoisted(() => ({ mockGetTableById: vi.fn(), @@ -24,6 +26,7 @@ const { mockMarkJobFailed: vi.fn(), mockMarkJobCanceled: vi.fn(), mockAppendTableEvent: vi.fn(), + mockSignalTableRowsChanged: vi.fn(), mockBuildFilterClause: vi.fn(), })) @@ -41,7 +44,10 @@ vi.mock('@/lib/table/rows/ordering', () => ({ selectRowIdPage: mockSelectRowIdPage, deletePageByIds: mockDeletePageByIds, })) -vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) +vi.mock('@/lib/table/events', () => ({ + appendTableEvent: mockAppendTableEvent, + signalTableRowsChanged: mockSignalTableRowsChanged, +})) vi.mock('@/lib/table/sql', () => ({ buildFilterClause: mockBuildFilterClause })) vi.mock('@/lib/table/constants', () => ({ TABLE_LIMITS: { DELETE_PAGE_SIZE: 2 }, @@ -89,6 +95,8 @@ describe('runTableDelete', () => { expect(mockAppendTableEvent).toHaveBeenCalledWith( expect.objectContaining({ kind: 'job', type: 'delete', status: 'canceled' }) ) + // Nothing was deleted, so the grid must NOT be needlessly refetched. + expect(mockSignalTableRowsChanged).not.toHaveBeenCalled() }) it('stops mid-run when the delete lock is enabled between pages', async () => { @@ -111,6 +119,21 @@ describe('runTableDelete', () => { ) expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockMarkJobReady).not.toHaveBeenCalled() + // Even though the run was cancelled before completion, the first page WAS deleted — the `finally` + // must still refetch the grid so open editors don't keep showing those deleted rows. + expect(mockSignalTableRowsChanged).toHaveBeenCalledWith('tbl_1') + }) + + it('signals a grid refetch when a page throws a mid-page lock after committing rows', async () => { + mockSelectRowIdPage.mockResolvedValueOnce(['a', 'b']) + // `deletePageByIds` commits in internal batches, so a lock landing mid-page can persist earlier + // batches and THEN throw — it returns no count. The grid must still be refetched. + mockDeletePageByIds.mockRejectedValueOnce(new TableLockedError('delete')) + + await expect(runTableDelete(basePayload())).resolves.toBeUndefined() + + expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') + expect(mockSignalTableRowsChanged).toHaveBeenCalledWith('tbl_1') }) it('deletes every matching page then marks the job ready', async () => { @@ -141,6 +164,9 @@ describe('runTableDelete', () => { expect(mockAppendTableEvent).toHaveBeenCalledWith( expect.objectContaining({ kind: 'job', type: 'delete', status: 'ready', progress: 3 }) ) + // The live grid must be told rows changed so deleted rows drop out of every open editor — + // the `job` progress event only drives the delete meter, not the rows query. + expect(mockSignalTableRowsChanged).toHaveBeenCalledWith('tbl_1') }) it('stops once maxRows is reached and caps the final page fetch to the remaining budget', async () => { diff --git a/apps/sim/lib/table/delete-runner.ts b/apps/sim/lib/table/delete-runner.ts index e2ff5e6773e..a1c14302bed 100644 --- a/apps/sim/lib/table/delete-runner.ts +++ b/apps/sim/lib/table/delete-runner.ts @@ -4,7 +4,7 @@ import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' import type { Filter, TableDefinition } from '@/lib/table' import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' -import { appendTableEvent } from '@/lib/table/events' +import { appendTableEvent, signalTableRowsChanged } from '@/lib/table/events' import { getJobProgress, markJobCanceled, @@ -65,6 +65,12 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const requestId = generateId().slice(0, 8) const budget = maxRows ?? Number.POSITIVE_INFINITY + // Whether any row was actually deleted this run. Signalled in `finally` so open editors refetch the + // grid on EVERY exit path — normal completion, a cancel/supersede between batches, a mid-batch lock, + // or a rethrown error after a partial delete — not only the throttled/`ready` paths (which a cancel + // landing after a committed batch would bypass, leaving deleted rows on screen). + let deletedAny = false + try { const table = await getTableById(tableId, { includeArchived: true }) if (!table) throw new Error(`Delete target table ${tableId} not found`) @@ -158,6 +164,11 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const toDelete = excluded.size > 0 ? page.filter((id) => !excluded.has(id)) : page if (toDelete.length > 0) { + // Mark BEFORE the call, not from its return: `deletePageByIds` commits in internal batches, so a + // mid-page lock can persist earlier batches and THEN throw — the catch below returns without a + // count. Setting this up front guarantees the `finally` grid refetch fires whether the call + // returns or throws. (An attempt that ends up committing nothing only over-refetches — harmless.) + deletedAny = true try { processed += await deletePageByIds(tableId, workspaceId, toDelete, pageProof, revalidate) } catch (err) { @@ -183,6 +194,10 @@ export async function runTableDelete(payload: TableDeletePayload): Promise status: 'running', progress: processed, }) + // Refetch the live grid as rows drop out (throttled with the progress event above) — the `job` + // event only drives the progress meter, not the rows query. The `finally` below guarantees a + // final refetch on every exit, so a cancel after the last un-throttled batch can't leave stale rows. + signalTableRowsChanged(tableId) } } @@ -222,6 +237,10 @@ export async function runTableDelete(payload: TableDeletePayload): Promise const error = cause ? toError(cause) : toError(err) logger.error(`[${requestId}] Delete failed for table ${tableId}:`, error) throw error + } finally { + // Guaranteed final grid refetch on every exit — completion, cancel/supersede, mid-batch lock, or a + // rethrown error — whenever this run deleted anything, so no open editor keeps showing deleted rows. + if (deletedAny) signalTableRowsChanged(tableId) } } diff --git a/apps/sim/lib/table/events.test.ts b/apps/sim/lib/table/events.test.ts index 48c8a7bd61d..5bb93f23130 100644 --- a/apps/sim/lib/table/events.test.ts +++ b/apps/sim/lib/table/events.test.ts @@ -11,7 +11,12 @@ beforeAll(() => { afterAll(resetEnvMock) import type { TableEvent } from '@/lib/table/events' -import { appendTableEvent, getLatestTableEventId, readTableEventsSince } from '@/lib/table/events' +import { + appendTableEvent, + getLatestTableEventId, + readTableEventsSince, + signalTableViewsChanged, +} from '@/lib/table/events' /** Module-level memory buffer can't be reset without vi.resetModules — use a * unique tableId per test to avoid cross-test bleed. */ @@ -75,3 +80,23 @@ describe('getLatestTableEventId (memory buffer)', () => { } }) }) + +describe('signalTableViewsChanged', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('appends a single views event carrying the tableId', async () => { + const tableId = uniqueTableId() + // The memory-buffer append is synchronous, so the fire-and-forget signal is + // observable immediately without awaiting the (unreturned) append promise. + signalTableViewsChanged(tableId) + + const result = await readTableEventsSince(tableId, 0) + expect(result.status).toBe('ok') + if (result.status === 'ok') { + expect(result.events).toHaveLength(1) + expect(result.events[0].event).toEqual({ kind: 'views', tableId }) + } + }) +}) diff --git a/apps/sim/lib/table/events.ts b/apps/sim/lib/table/events.ts index c088a28793d..be2a16990a7 100644 --- a/apps/sim/lib/table/events.ts +++ b/apps/sim/lib/table/events.ts @@ -1,69 +1,38 @@ /** * Per-table event buffer for live cell-state updates. * - * The grid subscribes to a per-table SSE stream and patches its React Query - * cache as events arrive. This buffer is the durable mid-tier between the - * cell-write paths (`writeWorkflowGroupState`, `cancelWorkflowGroupRuns`) and - * the SSE consumers — every status transition appends here with a monotonic - * eventId; SSE clients resume on reconnect via `?from=` and the - * server replays from this buffer. + * The grid subscribes to a per-table SSE stream and patches its React Query cache + * as events arrive. This is a thin domain adapter over the generic durable event + * log (`@/lib/realtime/event-log`): it owns the Redis key prefix (`table:stream:`) + * and the entry wire shape (`{ eventId, tableId, event }`), and gets append/read/ + * tail + replay + prune semantics from the core. Every status transition appends + * here with a monotonic eventId; SSE clients resume on reconnect via + * `?from=` and the server replays from this buffer. * - * Modeled after `apps/sim/lib/execution/event-buffer.ts` but stripped of - * complexity tables don't need: no per-execution lifecycle, no id reservation - * batching, no write-queue serialization. Tables are always-on; cell writes - * are sparse and independent. + * The `table:stream:` prefix and the entry shape are a wire contract — renaming + * the prefix resets the seq counter and silently strands connected clients (their + * in-memory `lastEventId` no longer matches), so both are intentionally fixed here. */ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { env } from '@/lib/core/config/env' -import { getRedisClient } from '@/lib/core/config/redis' +import { + appendEvent, + type EventLogConfig, + type EventLogReadResult, + getLatestEventId, + readEventsSince, +} from '@/lib/realtime/event-log' -const logger = createLogger('TableEventBuffer') - -const REDIS_PREFIX = 'table:stream:' export const TABLE_EVENT_TTL_SECONDS = 60 * 60 // 1 hour export const TABLE_EVENT_CAP = 5000 /** Max events returned by a single read; the SSE route drains in chunks. */ export const TABLE_EVENT_READ_CHUNK = 500 -/** - * Atomic append: INCR the seq counter to mint a new eventId, build the entry - * JSON inline, ZADD it, refresh TTL on events + seq + meta, trim to cap, then - * write the resulting earliestEventId to meta. Single round-trip per event. - * Without atomicity a slow reader could observe the trim before the meta - * update and miss the prune signal. - * - * KEYS: [events, seq, meta] - * ARGV: [ttlSec, cap, updatedAtIso, entryPrefix, entrySuffix] - * The new eventId is spliced between prefix/suffix to form the entry JSON. - * Returns the new eventId. - */ -const APPEND_EVENT_SCRIPT = ` -local eventId = redis.call('INCR', KEYS[2]) -local entry = ARGV[4] .. eventId .. ARGV[5] -redis.call('ZADD', KEYS[1], eventId, entry) -redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1])) -redis.call('EXPIRE', KEYS[2], tonumber(ARGV[1])) -redis.call('ZREMRANGEBYRANK', KEYS[1], 0, -tonumber(ARGV[2]) - 1) -local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES') -if oldest[2] then - redis.call('HSET', KEYS[3], 'earliestEventId', tostring(math.floor(tonumber(oldest[2]))), 'updatedAt', ARGV[3]) - redis.call('EXPIRE', KEYS[3], tonumber(ARGV[1])) -end -return eventId -` - -function getEventsKey(tableId: string) { - return `${REDIS_PREFIX}${tableId}:events` -} - -function getSeqKey(tableId: string) { - return `${REDIS_PREFIX}${tableId}:seq` -} - -function getMetaKey(tableId: string) { - return `${REDIS_PREFIX}${tableId}:meta` +/** Wire contract — see the file header; the prefix must never change. */ +const TABLE_EVENT_LOG: EventLogConfig = { + prefix: 'table:stream:', + ttlSeconds: TABLE_EVENT_TTL_SECONDS, + cap: TABLE_EVENT_CAP, + readChunk: TABLE_EVENT_READ_CHUNK, } export type TableCellStatus = 'pending' | 'queued' | 'running' | 'completed' | 'cancelled' | 'error' @@ -141,14 +110,52 @@ export type TableEvent = dispatchId?: string message: string } + | { + /** A user changed row data manually (a cell edit, or an added/deleted row) — + * not an execution. Signals collaborators to refetch the rows so the change + * shows live; last-write-wins is simply the DB's committed order. Carries no + * value: peers refetch in their own wire format, avoiding auth-specific value + * translation on the wire. */ + kind: 'edit' + tableId: string + } + | { + /** A user changed the table's structure (added/updated/deleted a column, or + * renamed the table). Signals collaborators to refetch the table definition + * and rows, since a schema change reshapes how rows render. Value-less, same + * refetch-in-own-format rationale as {@link kind} `edit`. */ + kind: 'schema' + tableId: string + } + | { + /** A user changed the table's UI metadata (column width, pin, or order). Signals + * collaborators to re-apply the new layout live. Lighter than {@link kind} + * `schema`: only the definition carries metadata, so peers refetch the definition + * alone — no rows/run-state refetch. Value-less, same refetch-in-own-format + * rationale as {@link kind} `edit`. */ + kind: 'metadata' + tableId: string + } | { /** The table definition changed in a way that isn't row/cell data — a - * lock toggle or a schema mutation. Carries no payload; the client just - * invalidates the table-detail query so every open viewer re-reads the - * fresh locks/schema (otherwise an idle grid stays stale and writes 423). */ + * lock toggle. Carries no payload; the client just invalidates the + * table-detail query so every open viewer re-reads the fresh locks + * (otherwise an idle grid stays stale and writes 423). Emitted only by + * `updateTableLocks` in the service; schema and metadata changes use the + * dedicated `schema`/`metadata` kinds above. */ kind: 'definition' tableId: string - reason: 'locks' | 'schema' + reason: 'locks' + } + | { + /** A user created, renamed, deleted, or re-saved a shared saved view (a named + * filter/sort/layout preset). Views are table-wide collaborative state — every + * reader of the table sees every view — so peers refetch the views list to pick + * up the change live. Value-less, same refetch-in-own-format rationale as + * {@link kind} `edit`; no rows/definition refetch, since a view is presentation + * state layered on top of the already-loaded table. */ + kind: 'views' + tableId: string } export interface TableEventEntry { @@ -157,220 +164,81 @@ export interface TableEventEntry { event: TableEvent } -export type TableEventsReadResult = - | { status: 'ok'; events: TableEventEntry[] } - | { status: 'pruned'; earliestEventId: number | undefined } - | { status: 'unavailable'; error: string } - -/** In-memory fallback for dev/tests when Redis isn't configured. */ -interface MemoryTableStream { - events: TableEventEntry[] - earliestEventId?: number - nextEventId: number - expiresAt: number -} - -const memoryTableStreams = new Map() +export type TableEventsReadResult = EventLogReadResult -function canUseMemoryBuffer(): boolean { - return typeof window === 'undefined' && !env.REDIS_URL +/** + * Append an event to the table's buffer. Fire-and-forget — never throws, returns + * null on failure (a Redis blip must not fail a cell-write). The Redis (Lua splice) + * and in-memory paths are built to produce byte-identical entries. + */ +export async function appendTableEvent(event: TableEvent): Promise { + return appendEvent(TABLE_EVENT_LOG, event.tableId, { + entryPrefix: '{"eventId":', + entrySuffix: `,"tableId":${JSON.stringify(event.tableId)},"event":${JSON.stringify(event)}}`, + buildEntry: (eventId) => ({ eventId, tableId: event.tableId, event }), + }) } -function pruneExpiredMemoryStreams(now = Date.now()): void { - for (const [tableId, stream] of memoryTableStreams) { - if (stream.expiresAt <= now) { - memoryTableStreams.delete(tableId) - } - } -} +// The mutating client receives its own signal too (the stream carries no originator id) +// and self-refetches. Data-correct — signals fire after the write commits, so the refetch +// returns the committed state, and in-flight edits are protected by the update/delete +// hooks' cancelQueries. The one caveat is an own row-CREATE on a scrolled, multi-page +// table, which can briefly reshuffle loaded pages (the create hook otherwise skips that +// refetch); if that ever proves visible, stamp an originator id so the actor ignores its +// own signal. -function getMemoryStream(tableId: string): MemoryTableStream { - pruneExpiredMemoryStreams() - let stream = memoryTableStreams.get(tableId) - if (!stream) { - stream = { - events: [], - nextEventId: 1, - expiresAt: Date.now() + TABLE_EVENT_TTL_SECONDS * 1000, - } - memoryTableStreams.set(tableId, stream) - } - return stream +/** + * Signal collaborators that a user changed row data so they refetch the rows live. + * Fire-and-forget — a Redis blip must never fail the write that triggered it. + */ +export function signalTableRowsChanged(tableId: string): void { + void appendTableEvent({ kind: 'edit', tableId }) } -function appendMemory(event: TableEvent): TableEventEntry { - const stream = getMemoryStream(event.tableId) - const entry: TableEventEntry = { - eventId: stream.nextEventId++, - tableId: event.tableId, - event, - } - stream.events.push(entry) - if (stream.events.length > TABLE_EVENT_CAP) { - stream.events = stream.events.slice(-TABLE_EVENT_CAP) - stream.earliestEventId = stream.events[0]?.eventId - } - stream.expiresAt = Date.now() + TABLE_EVENT_TTL_SECONDS * 1000 - return entry +/** + * Signal collaborators that a user changed the table structure so they refetch the + * definition + rows live. Fire-and-forget for the same reason as + * {@link signalTableRowsChanged}. + */ +export function signalTableSchemaChanged(tableId: string): void { + void appendTableEvent({ kind: 'schema', tableId }) } -function readMemory(tableId: string, afterEventId: number): TableEventsReadResult { - pruneExpiredMemoryStreams() - const stream = memoryTableStreams.get(tableId) - if (!stream) { - // Mirror the Redis path: a non-zero afterEventId with no buffer at all - // means TTL expired or the stream never existed; either way the caller's - // cursor is stale. - if (afterEventId > 0) return { status: 'pruned', earliestEventId: undefined } - return { status: 'ok', events: [] } - } - if (stream.earliestEventId !== undefined && afterEventId + 1 < stream.earliestEventId) { - return { status: 'pruned', earliestEventId: stream.earliestEventId } - } - return { - status: 'ok', - events: stream.events - .filter((entry) => entry.eventId > afterEventId) - .slice(0, TABLE_EVENT_READ_CHUNK), - } +/** + * Signal collaborators that a user changed the table's UI metadata (column width, pin, + * or order) so they re-apply the new layout live. Fire-and-forget for the same reason as + * {@link signalTableRowsChanged}. + */ +export function signalTableMetadataChanged(tableId: string): void { + void appendTableEvent({ kind: 'metadata', tableId }) } /** - * Append an event to the table's buffer. Fire-and-forget from the caller — - * this never throws, returns null on failure. A Redis blip must not fail a - * cell-write. + * Signal collaborators that a user changed the table's shared saved views (created, + * renamed, deleted, or re-saved one) so they refetch the views list live. Fire-and-forget + * for the same reason as {@link signalTableRowsChanged}. */ -export async function appendTableEvent(event: TableEvent): Promise { - const redis = getRedisClient() - if (!redis) { - if (canUseMemoryBuffer()) { - try { - return appendMemory(event) - } catch (error) { - logger.warn('appendTableEvent: memory append failed', { - tableId: event.tableId, - error: toError(error).message, - }) - return null - } - } - return null - } - try { - // Build the entry JSON in two halves so Lua can splice the new eventId - // between them without us needing a round-trip just to mint the id first. - const tail = `,"tableId":${JSON.stringify(event.tableId)},"event":${JSON.stringify(event)}}` - const head = `{"eventId":` - const result = await redis.eval( - APPEND_EVENT_SCRIPT, - 3, - getEventsKey(event.tableId), - getSeqKey(event.tableId), - getMetaKey(event.tableId), - TABLE_EVENT_TTL_SECONDS, - TABLE_EVENT_CAP, - new Date().toISOString(), - head, - tail - ) - const eventId = typeof result === 'number' ? result : Number(result) - if (!Number.isFinite(eventId)) return null - return { eventId, tableId: event.tableId, event } - } catch (error) { - logger.warn('appendTableEvent: Redis append failed', { - tableId: event.tableId, - error: toError(error).message, - }) - return null - } +export function signalTableViewsChanged(tableId: string): void { + void appendTableEvent({ kind: 'views', tableId }) } /** * The latest eventId assigned for a table, or 0 when the buffer is empty or * expired. Used by the stream route to tail from "now" when a client connects - * without a replay cursor (fresh mount — its caches were just fetched from - * the DB, so replaying history would only rewind them). - * - * Redis errors propagate: silently falling back to 0 would replay the whole - * buffer over fresh state — the exact churn tail-from-latest exists to avoid. - * The stream route errors the stream instead and the client reconnects with - * backoff. + * without a replay cursor. */ -export async function getLatestTableEventId(tableId: string): Promise { - const redis = getRedisClient() - if (!redis) { - if (canUseMemoryBuffer()) { - // Pure read — getMemoryStream() would allocate a stream as a side effect. - const stream = memoryTableStreams.get(tableId) - return stream ? stream.nextEventId - 1 : 0 - } - return 0 - } - const raw = await redis.get(getSeqKey(tableId)) - if (!raw) return 0 - const parsed = Number.parseInt(raw, 10) - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 +export function getLatestTableEventId(tableId: string): Promise { + return getLatestEventId(TABLE_EVENT_LOG, tableId) } /** - * Read events for a table where eventId > afterEventId. Returns 'pruned' if - * the caller has fallen off the back of the buffer (TTL expired or cap rolled - * past their lastEventId). Caller should respond by full-refetching from DB - * and resuming streaming from the new earliestEventId. + * Read events for a table where eventId > afterEventId. Returns 'pruned' if the + * caller has fallen off the back of the buffer (TTL expired or cap rolled past + * their lastEventId). */ -export async function readTableEventsSince( +export function readTableEventsSince( tableId: string, afterEventId: number ): Promise { - const redis = getRedisClient() - if (!redis) { - if (canUseMemoryBuffer()) { - return readMemory(tableId, afterEventId) - } - return { status: 'unavailable', error: 'Redis client unavailable' } - } - try { - const meta = await redis.hgetall(getMetaKey(tableId)) - const earliestEventId = - meta?.earliestEventId !== undefined ? Number(meta.earliestEventId) : undefined - if (earliestEventId !== undefined && afterEventId + 1 < earliestEventId) { - return { status: 'pruned', earliestEventId } - } - // Read in capped chunks so a 5000-event backlog doesn't materialize as one - // multi-MB Redis reply + JSON parse + SSE flush. The route loop drains - // chunks across ticks. - const raw = await redis.zrangebyscore( - getEventsKey(tableId), - afterEventId + 1, - '+inf', - 'LIMIT', - 0, - TABLE_EVENT_READ_CHUNK - ) - if (raw.length === 0 && afterEventId > 0) { - // Total TTL expiry: events + meta both gone. The seq counter has the - // same TTL — its absence means the buffer was wiped and the caller's - // `afterEventId` is stale. Signal pruned so the client refetches. - const seqExists = await redis.exists(getSeqKey(tableId)) - if (seqExists === 0) { - return { status: 'pruned', earliestEventId: undefined } - } - } - return { - status: 'ok', - events: raw - .map((entry) => { - try { - return JSON.parse(entry) as TableEventEntry - } catch { - return null - } - }) - .filter((entry): entry is TableEventEntry => Boolean(entry)), - } - } catch (error) { - const message = toError(error).message - logger.warn('readTableEventsSince failed', { tableId, error: message }) - return { status: 'unavailable', error: message } - } + return readEventsSince(TABLE_EVENT_LOG, tableId, afterEventId) } diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 5d23ec56931..2f5baf0f46c 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -17,6 +17,7 @@ import { and, count, eq, isNull, sql } from 'drizzle-orm' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' import { resolveRestoredFolderId } from '@/lib/folders/queries' +import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' @@ -424,6 +425,9 @@ export async function createTable( logger.info(`[${requestId}] Created table ${tableId} in workspace ${data.workspaceId}`) + // Live tables list: tell everyone viewing this workspace's tables to refetch. + await notifyWorkspaceTablesChanged(data.workspaceId) + return { id: newTable.id, name: newTable.name, @@ -612,6 +616,10 @@ export async function renameTable( } logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) + + // Live tables list: a rename changes the list result, so everyone viewing refetches. + if (workspaceId) await notifyWorkspaceTablesChanged(workspaceId) + return { id: tableId, name: newName } } catch (error: unknown) { if (getPostgresErrorCode(error) === '23505') { @@ -687,6 +695,9 @@ export async function moveTableToFolder( } logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) + + // Live tables list: a move changes each table's folder placement in the list result. + await notifyWorkspaceTablesChanged(workspaceId) } /** @@ -759,13 +770,13 @@ export async function updateTableLocks( * @param tableId - Table ID to update * @param metadata - Partial metadata object (merged with existing) * @param existingMetadata - Existing metadata from a prior fetch (avoids redundant DB read) - * @returns Updated metadata + * @returns The merged metadata plus whether the reorder also mutated the schema */ export async function updateTableMetadata( tableId: string, metadata: TableMetadata, existingMetadata?: TableMetadata | null -): Promise { +): Promise<{ metadata: TableMetadata; schemaChanged: boolean }> { const merged: TableMetadata = { ...(existingMetadata ?? {}), ...metadata } // When `columnOrder` is in the patch, scrub any workflow-group dependency @@ -814,7 +825,7 @@ export async function updateTableMetadata( .set(nextSchema ? { metadata: merged, schema: nextSchema } : { metadata: merged }) .where(eq(userTableDefinitions.id, tableId)) - return merged + return { metadata: merged, schemaChanged: nextSchema !== null } } /** @@ -831,7 +842,7 @@ export async function deleteTable( tableId: string, requestId: string, actingUserId?: string, - options?: { archivedAt?: Date } + options?: { archivedAt?: Date; skipNotify?: boolean } ): Promise { const now = options?.archivedAt ?? new Date() // Archiving destroys access to every row, so it is gated on the delete lock. @@ -889,6 +900,12 @@ export async function deleteTable( } logger.info(`[${requestId}] Archived table ${tableId}`) + + // Live tables list: only on a genuine archive (a no-op/already-archived delete changes nothing). + // Skipped under a folder cascade — deleteFolder fires one folder-level notify for the whole subtree, + // so we don't spam one relay call per archived table. + if (deleted?.workspaceId && !options?.skipNotify) + await notifyWorkspaceTablesChanged(deleted.workspaceId) } /** @@ -903,7 +920,7 @@ export async function deleteTable( export async function restoreTable( tableId: string, requestId: string, - options?: { restoringFolderIds?: ReadonlySet } + options?: { restoringFolderIds?: ReadonlySet; skipNotify?: boolean } ): Promise { const table = await getTableById(tableId, { includeArchived: true }) if (!table) { @@ -988,4 +1005,9 @@ export async function restoreTable( } logger.info(`[${requestId}] Restored table ${tableId} as "${attemptedRestoreName}"`) + + // Live tables list: a restore re-adds the table to the active list. Skipped under a folder cascade — + // restoreFolder fires one folder-level notify for the whole subtree (see deleteTable). + if (table.workspaceId && !options?.skipNotify) + await notifyWorkspaceTablesChanged(table.workspaceId) } diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 3405f73bd90..0033d7b2ce2 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -1,9 +1,25 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { tableViews } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ColumnDefinition, TableViewConfig } from '@/lib/table/types' -import { normalizeStoredViewConfig, pruneViewConfig } from '@/lib/table/views/service' + +const { mockSignalTableViewsChanged } = vi.hoisted(() => ({ + mockSignalTableViewsChanged: vi.fn(), +})) +vi.mock('@/lib/table/events', () => ({ + signalTableViewsChanged: mockSignalTableViewsChanged, +})) + +import { + createTableView, + deleteTableView, + normalizeStoredViewConfig, + pruneViewConfig, + updateTableView, +} from '@/lib/table/views/service' const columns: ColumnDefinition[] = [ { id: 'col_a', name: 'Name', type: 'text' }, @@ -84,3 +100,87 @@ describe('normalizeStoredViewConfig', () => { expect(out.filter).toBeNull() }) }) + +describe('table-view mutations signal collaborators', () => { + const columns: ColumnDefinition[] = [] + const viewRow = { + id: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('createTableView signals the table after inserting', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([viewRow]) + + await createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: {}, + userId: 'user-1', + columns, + }) + + expect(mockSignalTableViewsChanged).toHaveBeenCalledTimes(1) + expect(mockSignalTableViewsChanged).toHaveBeenCalledWith('table-1') + }) + + it('updateTableView signals when the target view exists', async () => { + queueTableRows(tableViews, [{ id: 'view-1' }]) // the in-transaction existence pre-check + dbChainMockFns.returning.mockResolvedValueOnce([viewRow]) // the update returning + + const result = await updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + name: 'Renamed', + columns, + }) + + expect(result).not.toBeNull() + expect(mockSignalTableViewsChanged).toHaveBeenCalledTimes(1) + expect(mockSignalTableViewsChanged).toHaveBeenCalledWith('table-1') + }) + + it('updateTableView does NOT signal a no-op update on a missing view', async () => { + // No queued existence row → the pre-check finds nothing → returns null before any write. + const result = await updateTableView({ + viewId: 'missing', + tableId: 'table-1', + name: 'Renamed', + columns, + }) + + expect(result).toBeNull() + expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() + }) + + it('deleteTableView signals when a row was actually deleted', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'view-1' }]) + + const deleted = await deleteTableView('view-1', 'table-1') + + expect(deleted).toBe(true) + expect(mockSignalTableViewsChanged).toHaveBeenCalledTimes(1) + expect(mockSignalTableViewsChanged).toHaveBeenCalledWith('table-1') + }) + + it('deleteTableView does NOT signal when nothing was deleted', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const deleted = await deleteTableView('missing', 'table-1') + + expect(deleted).toBe(false) + expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 1c8684a414c..1487be7dd35 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -16,6 +16,7 @@ import { generateId } from '@sim/utils/id' import { and, asc, eq, ne, sql } from 'drizzle-orm' import { getColumnId } from '@/lib/table/column-keys' import { NAME_PATTERN } from '@/lib/table/constants' +import { signalTableViewsChanged } from '@/lib/table/events' import { filterRulesToPredicate, filterToRules } from '@/lib/table/query-builder/converters' import type { ColumnDefinition, @@ -181,6 +182,8 @@ export async function createTableView(data: CreateTableViewData): Promise 0) logger.info('Deleted table view', { tableId, viewId }) + if (deleted.length > 0) { + logger.info('Deleted table view', { tableId, viewId }) + // Only signal a real deletion — a missing view (nothing deleted) changed nothing. + signalTableViewsChanged(tableId) + } return deleted.length > 0 } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 483ad7f4616..c0dbbbecce9 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -22,6 +22,7 @@ import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' +import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' import { deleteFile, @@ -31,6 +32,7 @@ import { uploadFile, } from '@/lib/uploads/core/storage-service' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { isUuid, sanitizeFileName } from '@/executor/constants' import type { UserFile } from '@/executor/types' @@ -71,6 +73,13 @@ export interface WorkspaceFileRecord { deletedAt?: Date | null uploadedAt: Date updatedAt: Date + /** + * Content-scoped version (see the `content_updated_at` column): advances only on content writes, never + * on metadata. The collab persist's optimistic-concurrency validator. Optional on the DTO because + * display/mothership constructors don't carry it — the real DB mapper (the only source seed/persist + * read) always populates it from the NOT NULL column. + */ + contentUpdatedAt?: Date | null /** Pass-through to `downloadFile` when not default `workspace` (e.g. chat mothership uploads). */ storageContext?: 'workspace' | 'mothership' /** Public share state, attached at the API boundary. `null` when never shared. */ @@ -163,6 +172,8 @@ async function insertWorkspaceFileMetadataInTx( deletedAt: null, uploadedAt: new Date(), updatedAt: new Date(), + // Creation IS the first content write, so stamp the content version too (metadata writes never do). + contentUpdatedAt: new Date(), }) .onConflictDoNothing() .returning() @@ -378,6 +389,11 @@ export async function uploadWorkspaceFile( const pathPrefix = getServePathPrefix() const serveUrl = `${pathPrefix}${encodeURIComponent(uploadResult.key)}?context=workspace` + // Fan out the live-tree signal for the direct-upload paths (multipart + // fallback, copilot create, /api/files/upload, v1 files) — the presigned + // path already notifies from its register route. + await notifyWorkspaceFilesChanged(workspaceId) + return { id: fileId, name: uniqueName, @@ -694,6 +710,7 @@ function mapWorkspaceFileRecord( deletedAt: file.deletedAt, uploadedAt: file.uploadedAt, updatedAt: file.updatedAt, + contentUpdatedAt: file.contentUpdatedAt, } } @@ -905,12 +922,18 @@ export async function resolveWorkspaceFileReference( } /** - * Get a specific workspace file + * Get a specific workspace file. + * + * By default a DB error is logged and swallowed to `null` — for most callers "couldn't load it" + * and "doesn't exist" are handled the same way. Pass `{ throwOnError: true }` when the caller must + * distinguish a genuinely-absent file (`null`) from a transient read failure (throws): the + * collaborative-doc seed builder relies on this so a DB blip never looks like an empty file and gets + * seeded as blank content over the real document. */ export async function getWorkspaceFile( workspaceId: string, fileId: string, - options?: { includeDeleted?: boolean } + options?: { includeDeleted?: boolean; throwOnError?: boolean } ): Promise { try { const { includeDeleted = false } = options ?? {} @@ -938,6 +961,7 @@ export async function getWorkspaceFile( return mapSingleWorkspaceFileRecord(files[0], workspaceId) } catch (error) { logger.error(`Failed to get workspace file ${fileId}:`, error) + if (options?.throwOnError) throw error return null } } @@ -1002,6 +1026,18 @@ export async function fetchWorkspaceFileBuffer( } } +/** + * Thrown by {@link updateWorkspaceFileContent} when its `expectedUpdatedAt` optimistic-concurrency + * guard fails — the file changed out-of-band since the caller read it. Callers catch this to reconcile + * (re-read + merge) rather than overwrite. Not a failure: the durable file is left untouched. + */ +export class ContentVersionConflictError extends Error { + constructor(readonly fileId: string) { + super(`Workspace file ${fileId} changed since it was read (optimistic-concurrency conflict)`) + this.name = 'ContentVersionConflictError' + } +} + /** * Updates a workspace file through a versioned object swap. Blob I/O completes * before the short metadata-and-ledger transaction. @@ -1011,7 +1047,26 @@ export async function updateWorkspaceFileContent( fileId: string, userId: string, content: Buffer, - contentType?: string + contentType?: string, + options?: { + /** + * Whether to stream this write into any open collaborative editor as a live CRDT merge. Defaults + * to `true`, so EVERY external write path (copilot tools, the file tool, the content route) reaches + * an open editor through this one chokepoint — no per-writer wiring to forget. Pass `false` only + * for a write that must NOT touch the live doc: the relay's own project-to-markdown persist (which + * would otherwise merge the doc back into itself in a loop), and empty-shell creates whose real + * content arrives via a subsequent write. + */ + syncLiveDoc?: boolean + /** + * Optimistic-concurrency guard (RFC 7232 `If-Match` semantics). When set, the write commits only + * if the file's `updatedAt` still equals this value — nothing else wrote in between; otherwise it + * throws {@link ContentVersionConflictError} without clobbering. Checked against the + * `SELECT … FOR UPDATE`-locked row, so it is atomic with the write. Used by the collab persist so + * projecting the live doc back to markdown can never silently overwrite an out-of-band edit. + */ + expectedUpdatedAt?: Date + } ): Promise { logger.info(`Updating workspace file content: ${fileId} for workspace ${workspaceId}`) @@ -1070,14 +1125,39 @@ export async function updateWorkspaceFileContent( throw new Error('File not found') } + // Optimistic-concurrency guard: the row is `FOR UPDATE`-locked, so comparing its committed + // CONTENT version to the caller's expected value and then writing is atomic — a racing writer + // blocks here until this transaction resolves. Compare `contentUpdatedAt` (which advances only on + // content writes), NOT `updatedAt` (which a rename/move also bumps): guarding on `updatedAt` let a + // metadata bump masquerade as an out-of-band CONTENT change, so a racing live-doc persist would + // reconcile stale durable content and clobber in-flight edits. Coalesce to `updatedAt` for rows + // predating the column. A mismatch means the CONTENT changed out-of-band; abort rather than clobber. + if ( + options?.expectedUpdatedAt && + currentFile.contentUpdatedAt.getTime() !== options.expectedUpdatedAt.getTime() + ) { + throw new ContentVersionConflictError(fileId) + } + const sizeDiff = content.length - currentFile.size + const now = new Date() + // `contentUpdatedAt` is the persist If-Match token, so it MUST be strictly monotonic per file — a + // bare `new Date()` is not: cross-instance clock skew can stamp a later write with an earlier time, + // breaking the version ordering the whole optimistic-concurrency scheme relies on (stuck If-Match, + // wrong reconcile). We hold this row's FOR UPDATE lock, so `currentFile.contentUpdatedAt` is the + // latest committed value; stamp strictly after it. (Also removes same-millisecond collisions.) + // `updatedAt` stays plain wall-clock — it is display/sort only, never the concurrency token. + const contentUpdatedAt = new Date( + Math.max(now.getTime(), currentFile.contentUpdatedAt.getTime() + 1) + ) const [updatedFile] = await tx .update(workspaceFiles) .set({ key: uploadResult.key, size: content.length, contentType: nextContentType, - updatedAt: new Date(), + updatedAt: now, + contentUpdatedAt, }) .where( and( @@ -1130,6 +1210,24 @@ export async function updateWorkspaceFileContent( await cleanupWorkspaceStorageObject(finalized.oldKey, 'version replacement') } + // Stream this write into any open collaborative editor as a CRDT merge, so a copilot/tool edit + // shows up live instead of the file silently changing underneath the reader. Gated to markdown (the + // only format the collaborative editor renders) and best-effort (a no-op when nobody has the file + // open; never throws). This is the single chokepoint every external writer shares — the relay's own + // persist and empty-shell creates pass `syncLiveDoc: false` to stay out of it. + if ( + options?.syncLiveDoc !== false && + isMarkdownFile({ type: nextContentType, name: finalized.file.originalName }) + ) { + // Pass the new CONTENT version this write produced, so the relay records that its live doc now + // incorporates this durable version — the collab persist's optimistic-concurrency guard then won't + // treat this (already-merged) write as an out-of-band conflict. Must be the SAME field the CAS + // guards on (`contentUpdatedAt`), not `updatedAt`, or the relay's token wouldn't match the CAS. + await mergeEditIntoLiveFileDoc(fileId, content.toString('utf-8'), { + version: finalized.file.contentUpdatedAt.getTime(), + }) + } + const pathPrefix = getServePathPrefix() const currentFolderPath = finalized.file.folderId === fileRecord.folderId ? fileRecord.folderPath : null @@ -1150,8 +1248,13 @@ export async function updateWorkspaceFileContent( deletedAt: finalized.file.deletedAt, uploadedAt: finalized.file.uploadedAt, updatedAt: finalized.file.updatedAt, + contentUpdatedAt: finalized.file.contentUpdatedAt, } } catch (error) { + // Preserve the typed conflict so callers can catch it and reconcile — it's an expected outcome of + // the optimistic-concurrency guard, not a failure to wrap. The orphan upload was already cleaned up + // by the inner finalization catch before it propagated here. + if (error instanceof ContentVersionConflictError) throw error logger.error(`Failed to update workspace file content ${fileId}:`, error) throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`) } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index 9ac40d5587f..cf0b5cf7189 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -12,6 +12,8 @@ const { mockHeadObject, mockIncrementStorageUsageForBillingContextInTx, mockMaybeNotifyStorageLimitForBillingContext, + mockMergeEditIntoLiveFileDoc, + mockNotifyWorkspaceFilesChanged, mockResolveStorageBillingContext, mockUploadFile, } = vi.hoisted(() => ({ @@ -22,10 +24,17 @@ const { mockHeadObject: vi.fn(), mockIncrementStorageUsageForBillingContextInTx: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), + mockMergeEditIntoLiveFileDoc: vi.fn(), + mockNotifyWorkspaceFilesChanged: vi.fn(), mockResolveStorageBillingContext: vi.fn(), mockUploadFile: vi.fn(), })) +vi.mock('@/lib/realtime/notify', () => ({ + mergeEditIntoLiveFileDoc: mockMergeEditIntoLiveFileDoc, + notifyWorkspaceFilesChanged: mockNotifyWorkspaceFilesChanged, +})) + vi.mock('@/lib/billing/storage', () => ({ decrementStorageUsageForBillingContextInTx: mockDecrementStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx: mockIncrementStorageUsageForBillingContextInTx, @@ -60,6 +69,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ })) import { + ContentVersionConflictError, deleteWorkspaceFile, registerUploadedWorkspaceFile, restoreWorkspaceFile, @@ -90,6 +100,7 @@ const FILE_ROW = { deletedAt: null, uploadedAt: new Date('2026-07-01T00:00:00.000Z'), updatedAt: new Date('2026-07-01T00:00:00.000Z'), + contentUpdatedAt: new Date('2026-07-01T00:00:00.000Z'), } describe('workspace file metadata and storage accounting', () => { @@ -105,6 +116,8 @@ describe('workspace file metadata and storage accounting', () => { mockDecrementStorageUsageForBillingContextInTx.mockResolvedValue(undefined) mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) mockDeleteFile.mockResolvedValue(undefined) + mockMergeEditIntoLiveFileDoc.mockResolvedValue(undefined) + mockNotifyWorkspaceFilesChanged.mockResolvedValue(undefined) }) it('cleans up a newly uploaded object when atomic metadata finalization rolls back', async () => { @@ -328,4 +341,106 @@ describe('workspace file metadata and storage accounting', () => { expect(mockDeleteFile).toHaveBeenCalledTimes(1) expect(mockDeleteFile).toHaveBeenCalledWith({ key: replacementKey, context: 'workspace' }) }) + + const MD_ROW = { ...FILE_ROW, originalName: 'note.md', contentType: 'text/markdown' } + + it('streams a markdown overwrite into any open collaborative editor (the shared merge chokepoint)', async () => { + // Distinct updatedAt vs contentUpdatedAt so the assertion proves the merge carries the CONTENT + // version (the persist If-Match token), not `updatedAt` — reverting that wiring would fail here. + const updatedFile = { + ...MD_ROW, + size: 12, + updatedAt: new Date('2026-07-05T00:00:00.000Z'), + contentUpdatedAt: new Date('2026-07-04T00:00:00.000Z'), + } + dbChainMockFns.limit.mockResolvedValueOnce([MD_ROW]).mockResolvedValueOnce([MD_ROW]) + dbChainMockFns.returning.mockResolvedValueOnce([updatedFile]) + mockUploadFile.mockResolvedValueOnce({ key: MD_ROW.key }) + + await updateWorkspaceFileContent( + MD_ROW.workspaceId, + MD_ROW.id, + MD_ROW.userId, + Buffer.from('# new content', 'utf-8') + ) + + expect(mockMergeEditIntoLiveFileDoc).toHaveBeenCalledWith(MD_ROW.id, '# new content', { + version: updatedFile.contentUpdatedAt.getTime(), + }) + }) + + it('does NOT merge when syncLiveDoc is false (the relay persist / empty-shell opt-out)', async () => { + const updatedFile = { ...MD_ROW, size: 12 } + dbChainMockFns.limit.mockResolvedValueOnce([MD_ROW]).mockResolvedValueOnce([MD_ROW]) + dbChainMockFns.returning.mockResolvedValueOnce([updatedFile]) + mockUploadFile.mockResolvedValueOnce({ key: MD_ROW.key }) + + await updateWorkspaceFileContent( + MD_ROW.workspaceId, + MD_ROW.id, + MD_ROW.userId, + Buffer.from('# new content', 'utf-8'), + undefined, + { syncLiveDoc: false } + ) + + expect(mockMergeEditIntoLiveFileDoc).not.toHaveBeenCalled() + }) + + it('does NOT merge a non-markdown write (the collaborative editor only renders markdown)', async () => { + const updatedFile = { ...FILE_ROW, size: 10 } + dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW]) + dbChainMockFns.returning.mockResolvedValueOnce([updatedFile]) + mockUploadFile.mockResolvedValueOnce({ key: FILE_ROW.key }) + + await updateWorkspaceFileContent( + FILE_ROW.workspaceId, + FILE_ROW.id, + FILE_ROW.userId, + Buffer.alloc(10), + 'application/octet-stream' + ) + + expect(mockMergeEditIntoLiveFileDoc).not.toHaveBeenCalled() + }) + + it('writes when the expectedUpdatedAt optimistic-concurrency guard matches', async () => { + const updatedFile = { ...FILE_ROW, size: 12 } + dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW]) + dbChainMockFns.returning.mockResolvedValueOnce([updatedFile]) + mockUploadFile.mockResolvedValueOnce({ key: FILE_ROW.key }) + + const updated = await updateWorkspaceFileContent( + FILE_ROW.workspaceId, + FILE_ROW.id, + FILE_ROW.userId, + Buffer.alloc(12), + undefined, + { expectedUpdatedAt: FILE_ROW.updatedAt } + ) + + expect(updated.size).toBe(12) + expect(dbChainMockFns.returning).toHaveBeenCalled() + }) + + it('throws ContentVersionConflictError and does not write when the guard mismatches', async () => { + // The locked row's updatedAt differs from the caller's expected value → out-of-band edit. + dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW]) + mockUploadFile.mockResolvedValueOnce({ key: FILE_ROW.key }) + + await expect( + updateWorkspaceFileContent( + FILE_ROW.workspaceId, + FILE_ROW.id, + FILE_ROW.userId, + Buffer.alloc(12), + undefined, + { expectedUpdatedAt: new Date('2020-01-01T00:00:00.000Z') } + ) + ).rejects.toBeInstanceOf(ContentVersionConflictError) + + // Never advanced the row, and cleaned up the orphan upload it staged before the conflict. + expect(dbChainMockFns.returning).not.toHaveBeenCalled() + expect(mockDeleteFile).toHaveBeenCalledWith({ key: FILE_ROW.key, context: 'workspace' }) + }) }) diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index 7c9e62d9504..c771d7e29fe 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -8,6 +8,7 @@ import { inferContextFromKey, isAbortError, isInternalFileUrl, + isMarkdownFile, isNetworkError, processSingleFileToUserFile, resolveTrustedFileContext, @@ -15,6 +16,26 @@ import { const logger = createLogger('FileUtilsTest') +describe('isMarkdownFile', () => { + it('is true for .md and .markdown (case-insensitive)', () => { + expect(isMarkdownFile({ name: 'notes.md' })).toBe(true) + expect(isMarkdownFile({ name: 'README.MD' })).toBe(true) + expect(isMarkdownFile({ name: 'doc.markdown' })).toBe(true) + }) + + it('is true for a text/markdown MIME even without a .md name', () => { + expect(isMarkdownFile({ type: 'text/markdown', name: 'notes' })).toBe(true) + expect(isMarkdownFile({ type: 'text/markdown', name: 'doc.txt' })).toBe(true) + }) + + it('is false for non-markdown files', () => { + expect(isMarkdownFile({ type: 'text/javascript', name: 'script.js' })).toBe(false) + expect(isMarkdownFile({ name: 'report.docx' })).toBe(false) + expect(isMarkdownFile({ type: 'text/plain', name: 'notes.txt' })).toBe(false) + expect(isMarkdownFile({ name: 'noext' })).toBe(false) + }) +}) + describe('extractStorageKey', () => { it('strips every provider serve prefix', () => { expect(extractStorageKey('/api/files/serve/s3/workspace%2Fws-1%2Ffile.txt')).toBe( diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index cd36052fdbe..9e331c10854 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -210,6 +210,19 @@ export function getFileExtension(filename: string): string { return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' } +/** + * Whether a file renders in the collaborative rich markdown editor. Server-safe counterpart to the + * client's `isMarkdownFile` (which uses `resolvePreviewType`): the editor treats a file as markdown by + * its `text/markdown` MIME *or* a `.md`/`.markdown` extension — MIME first, matching the client — so a + * `text/markdown` file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc + * merge) to exactly the files that can be open in that editor. + */ +export function isMarkdownFile(file: { type?: string | null; name: string }): boolean { + if (file.type === 'text/markdown') return true + const ext = getFileExtension(file.name) + return ext === 'md' || ext === 'markdown' +} + /** * Extensions whose stored bytes may be a generation source that renders to a larger * binary. Everything else stores exactly what it serves, so its declared size is diff --git a/apps/sim/lib/workflows/schedules/disable-notifications.test.ts b/apps/sim/lib/workflows/schedules/disable-notifications.test.ts new file mode 100644 index 00000000000..020105c8ccb --- /dev/null +++ b/apps/sim/lib/workflows/schedules/disable-notifications.test.ts @@ -0,0 +1,227 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetUrlsMock, + schemaMock, + urlsMockFns, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { sendEmailSpy, renderMock, subjectMock, getUsersWithPermissionsMock } = vi.hoisted(() => ({ + sendEmailSpy: vi.fn(() => Promise.resolve({ success: true })), + renderMock: vi.fn(() => Promise.resolve('')), + subjectMock: vi.fn(() => 'A schedule was turned off'), + getUsersWithPermissionsMock: vi.fn(() => Promise.resolve([] as unknown[])), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: sendEmailSpy })) +vi.mock('@/components/emails', () => ({ + renderScheduleDisabledEmail: renderMock, + getEmailSubject: subjectMock, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUsersWithPermissions: getUsersWithPermissionsMock, +})) + +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' + +const WORKFLOW_SCHEDULE_ROW = { + sourceType: 'workflow', + jobTitle: null, + failedCount: 100, + sourceUserId: null, + sourceWorkspaceId: null, + workflowId: 'wf-1', + workflowName: 'Daily digest', + workflowUserId: 'creator-1', + workflowWorkspaceId: 'ws-1', +} + +const CREATOR = { email: 'creator@example.com', name: 'Ada' } + +function admin(email: string, name = 'Admin') { + return { userId: `u-${email}`, email, name, permissionType: 'admin' } +} + +beforeAll(() => { + urlsMockFns.mockGetBaseUrl.mockReturnValue('https://app.sim.ai') +}) + +afterAll(() => { + resetDbChainMock() + resetUrlsMock() +}) + +describe('notifyScheduleAutoDisabled', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + getUsersWithPermissionsMock.mockResolvedValue([]) + }) + + it('emails the creator and the workspace admins, one send per address', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue([ + admin('admin-a@example.com'), + admin('admin-b@example.com'), + ]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(3) + for (const call of sendEmailSpy.mock.calls) { + // Never a `to` array — prepare.ts only checks to[0] for unsubscribe. + expect(typeof (call[0] as { to: unknown }).to).toBe('string') + expect(call[0]).toMatchObject({ emailType: 'notifications' }) + } + }) + + it('sends once when the creator is also a workspace admin', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue([admin('CREATOR@example.com')]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + }) + + it('skips non-admin workspace members', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue([ + { userId: 'u-2', email: 'writer@example.com', name: 'W', permissionType: 'write' }, + ]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: 'creator@example.com' }) + ) + }) + + it('resolves a job schedule through sourceUserId and links to scheduled tasks', async () => { + queueTableRows(schemaMock.workflowSchedule, [ + { + ...WORKFLOW_SCHEDULE_ROW, + sourceType: 'job', + jobTitle: 'Weekly report', + sourceUserId: 'job-owner', + sourceWorkspaceId: 'ws-9', + workflowName: null, + workflowUserId: null, + workflowWorkspaceId: null, + }, + ]) + queueTableRows(schemaMock.user, [CREATOR]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'authentication_error' }) + + expect(renderMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'job', + resourceName: 'Weekly report', + reason: 'authentication_error', + manageLink: 'https://app.sim.ai/workspace/ws-9/scheduled-tasks', + }) + ) + }) + + it('falls back to the creator alone when the workflow has no workspace', async () => { + queueTableRows(schemaMock.workflowSchedule, [ + { ...WORKFLOW_SCHEDULE_ROW, workflowWorkspaceId: null }, + ]) + queueTableRows(schemaMock.user, [CREATOR]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(getUsersWithPermissionsMock).not.toHaveBeenCalled() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ manageLink: undefined })) + }) + + it('sends nothing when the workflow row is gone (404 disable)', async () => { + queueTableRows(schemaMock.workflowSchedule, [ + { + ...WORKFLOW_SCHEDULE_ROW, + workflowName: null, + workflowUserId: null, + workflowWorkspaceId: null, + }, + ]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'workflow_not_found' }) + + expect(sendEmailSpy).not.toHaveBeenCalled() + }) + + it('sends nothing when the schedule row cannot be read', async () => { + queueTableRows(schemaMock.workflowSchedule, []) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).not.toHaveBeenCalled() + }) + + it('caps the fan-out at 20 recipients', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue( + Array.from({ length: 40 }, (_, i) => admin(`admin-${i}@example.com`)) + ) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(20) + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: 'creator@example.com' }) + ) + }) + + it('keeps sending after one recipient fails', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockResolvedValue([admin('admin-a@example.com')]) + sendEmailSpy.mockRejectedValueOnce(new Error('smtp down')) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + }) + + it('still emails the creator when the admin lookup fails', async () => { + queueTableRows(schemaMock.workflowSchedule, [WORKFLOW_SCHEDULE_ROW]) + queueTableRows(schemaMock.user, [CREATOR]) + getUsersWithPermissionsMock.mockRejectedValue(new Error('db down')) + + await expect( + notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + ).resolves.toBeUndefined() + + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: 'creator@example.com' }) + ) + }) + + it('still emails the admins when the creator lookup fails', async () => { + // First .limit() is the schedule row, second is the creator lookup. + dbChainMockFns.limit + .mockResolvedValueOnce([WORKFLOW_SCHEDULE_ROW]) + .mockRejectedValueOnce(new Error('db down')) + getUsersWithPermissionsMock.mockResolvedValue([admin('admin-a@example.com')]) + + await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'consecutive_failures' }) + + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: 'admin-a@example.com' }) + ) + }) +}) diff --git a/apps/sim/lib/workflows/schedules/disable-notifications.ts b/apps/sim/lib/workflows/schedules/disable-notifications.ts new file mode 100644 index 00000000000..2bebe8bfd80 --- /dev/null +++ b/apps/sim/lib/workflows/schedules/disable-notifications.ts @@ -0,0 +1,175 @@ +import { db } from '@sim/db' +import { user, workflow, workflowSchedule } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { getEmailSubject, renderScheduleDisabledEmail } from '@/components/emails' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { sendEmail } from '@/lib/messaging/email/mailer' +import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' +import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('ScheduleDisableNotifications') + +/** + * Fan-out cap so one tick over a large workspace can't turn into hundreds of + * inline sends. The schedule's creator is always kept. + */ +const MAX_SCHEDULE_DISABLE_RECIPIENTS = 20 + +interface Recipient { + email: string + name: string | null +} + +/** + * Emails the schedule's creator and the workspace admins after Sim turns a + * schedule off on its own. + * + * Best-effort by contract: this never throws, because a mail failure must not + * fault the schedule tick that called it. + */ +export async function notifyScheduleAutoDisabled(params: { + scheduleId: string + reason: ScheduleDisableReason + requestId?: string +}): Promise { + const { scheduleId, reason, requestId } = params + + try { + const rows = await db + .select({ + sourceType: workflowSchedule.sourceType, + jobTitle: workflowSchedule.jobTitle, + failedCount: workflowSchedule.failedCount, + sourceUserId: workflowSchedule.sourceUserId, + sourceWorkspaceId: workflowSchedule.sourceWorkspaceId, + workflowId: workflowSchedule.workflowId, + workflowName: workflow.name, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + }) + .from(workflowSchedule) + .leftJoin(workflow, eq(workflow.id, workflowSchedule.workflowId)) + .where(eq(workflowSchedule.id, scheduleId)) + .limit(1) + + const row = rows[0] + if (!row) { + logger.warn('Schedule auto-disabled but the row could not be read', { scheduleId, reason }) + return + } + + const isJob = row.sourceType === 'job' + const kind = isJob ? 'job' : 'workflow' + const ownerUserId = isJob ? row.sourceUserId : row.workflowUserId + const workspaceId = isJob ? row.sourceWorkspaceId : row.workflowWorkspaceId + const resourceName = (isJob ? row.jobTitle : row.workflowName) ?? undefined + + const recipients = await resolveRecipients(ownerUserId, workspaceId) + if (recipients.length === 0) { + logger.warn('Schedule auto-disabled but no recipients could be resolved', { + scheduleId, + reason, + workspaceId, + }) + return + } + + const manageLink = buildManageLink(workspaceId, isJob ? null : row.workflowId) + const subject = getEmailSubject('schedule-disabled') + + for (const recipient of recipients) { + try { + const html = await renderScheduleDisabledEmail({ + recipientName: recipient.name ?? undefined, + kind, + resourceName, + reason, + failedCount: row.failedCount, + manageLink, + }) + + await sendEmail({ + to: recipient.email, + subject, + html, + emailType: 'notifications', + }) + } catch (error) { + logger.error('Failed to send schedule-disabled email', error, { + scheduleId, + requestId, + }) + } + } + + logger.info('Sent schedule-disabled notification', { + scheduleId, + reason, + recipientCount: recipients.length, + requestId, + }) + } catch (error) { + logger.error('Failed to notify schedule auto-disable', error, { scheduleId, reason, requestId }) + } +} + +/** + * Creator first, then workspace admins. Deduped on lowercased email so someone + * who is both only receives one message. + * + * The two lookups are independent and each failure is contained: losing one + * must not zero out the other, or a blip in either turns an auto-disable back + * into the silent event this notification exists to prevent. + */ +async function resolveRecipients( + ownerUserId: string | null, + workspaceId: string | null +): Promise { + const recipients: Recipient[] = [] + const seen = new Set() + + const add = (email: string | null, name: string | null) => { + if (!email) return + const key = email.toLowerCase() + if (seen.has(key)) return + seen.add(key) + recipients.push({ email, name }) + } + + if (ownerUserId) { + try { + const ownerRows = await db + .select({ email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, ownerUserId)) + .limit(1) + add(ownerRows[0]?.email ?? null, ownerRows[0]?.name ?? null) + } catch (error) { + logger.error('Failed to resolve schedule creator for disable email', error, { ownerUserId }) + } + } + + if (workspaceId) { + try { + const members = await getUsersWithPermissions(workspaceId) + for (const member of members) { + if (member.permissionType !== 'admin') continue + add(member.email, member.name) + } + } catch (error) { + logger.error('Failed to resolve workspace admins for disable email', error, { workspaceId }) + } + } + + return recipients.slice(0, MAX_SCHEDULE_DISABLE_RECIPIENTS) +} + +function buildManageLink( + workspaceId: string | null, + workflowId: string | null +): string | undefined { + if (!workspaceId) return undefined + const base = `${getBaseUrl()}/workspace/${workspaceId}` + return workflowId ? `${base}/w/${workflowId}` : `${base}/scheduled-tasks` +} diff --git a/apps/sim/lib/workflows/schedules/disable-reasons.ts b/apps/sim/lib/workflows/schedules/disable-reasons.ts new file mode 100644 index 00000000000..d6d20e67591 --- /dev/null +++ b/apps/sim/lib/workflows/schedules/disable-reasons.ts @@ -0,0 +1,23 @@ +/** + * Why Sim turned a schedule off. Threaded from the disable call site into the + * notification, because `workflow_schedule` has no column that records it. + */ +export type ScheduleDisableReason = + | 'consecutive_failures' + | 'authentication_error' + | 'authorization_error' + | 'workflow_not_found' + | 'invalid_schedule' + +/** + * User-facing sentence for each reason. Fixed copy rather than the underlying + * error string: the threshold paths carry arbitrary workflow-execution errors + * that can contain credentials or customer data. + */ +export const SCHEDULE_DISABLE_REASON_COPY: Record = { + consecutive_failures: 'It failed too many times in a row.', + authentication_error: 'The run could not be authenticated.', + authorization_error: 'The run was not authorized.', + workflow_not_found: 'The workflow could not be found.', + invalid_schedule: 'The schedule is no longer valid.', +} diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index ce11157ccb7..4abfd980bcf 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, @@ -194,6 +195,7 @@ export async function performDeleteWorkspaceFileItems( }) } + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, deletedItems } } catch (error) { logger.error('Failed to delete workspace file items', { error }) @@ -254,6 +256,7 @@ export async function performMoveWorkspaceFileItems( }) } + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, movedItems } } catch (error) { logger.error('Failed to move workspace file items', { error }) @@ -298,6 +301,7 @@ export async function performRenameWorkspaceFile( description: `Renamed file to "${file.name}"`, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, file } } catch (error) { logger.error('Failed to rename workspace file', { error }) @@ -361,6 +365,7 @@ export async function performMoveRenameWorkspaceFile( }) } + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, file } } catch (error) { logger.error('Failed to move/rename workspace file', { error }) @@ -394,6 +399,7 @@ export async function performRestoreWorkspaceFile( description: `Restored workspace file ${fileId}`, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true } } catch (error) { logger.error('Failed to restore workspace file', { error }) @@ -424,6 +430,7 @@ export async function performCreateWorkspaceFileFolder( description: `Created file folder "${folder.name}"`, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, folder } } catch (error) { logger.error('Failed to create workspace file folder', { error }) @@ -463,6 +470,7 @@ export async function performUpdateWorkspaceFileFolder( description: `Updated file folder "${folder.name}"`, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, folder } } catch (error) { logger.error('Failed to update workspace file folder', { error }) @@ -509,6 +517,7 @@ export async function performRestoreWorkspaceFileFolder( }, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, folder, restoredItems } } catch (error) { logger.error('Failed to restore workspace file folder', { error }) diff --git a/apps/sim/lib/workspaces/colors.ts b/apps/sim/lib/workspaces/colors.ts index 61401b68ad1..87b1fa34be8 100644 --- a/apps/sim/lib/workspaces/colors.ts +++ b/apps/sim/lib/workspaces/colors.ts @@ -17,15 +17,6 @@ export function getRandomWorkspaceColor(): string { return randomItem(WORKSPACE_COLORS) } -const APP_COLORS = [ - { from: '#4F46E5', to: '#7C3AED' }, // indigo to purple - { from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia - { from: '#EC4899', to: '#F97316' }, // pink to orange - { from: '#14B8A6', to: '#10B981' }, // teal to emerald - { from: '#6366F1', to: '#8B5CF6' }, // indigo to violet - { from: '#F59E0B', to: '#F97316' }, // amber to orange -] - /** * User color palette matching terminal.tsx RUN_ID_COLORS * These colors are used consistently across cursors, avatars, and terminal run IDs @@ -39,12 +30,6 @@ export const USER_COLORS = [ '#FCD34D', // Yellow ] as const -interface PresenceColorPalette { - gradient: string - accentColor: string - baseColor: string -} - const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/ function hashIdentifier(identifier: string | number): number { @@ -59,7 +44,7 @@ function hashIdentifier(identifier: string | number): number { return 0 } -function withAlpha(hexColor: string, alpha: number): string { +export function withAlpha(hexColor: string, alpha: number): string { if (!HEX_COLOR_REGEX.test(hexColor)) { return hexColor } @@ -68,39 +53,6 @@ function withAlpha(hexColor: string, alpha: number): string { return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})` } -function buildGradient(fromColor: string, toColor: string, rotationSeed: number): string { - const rotation = (rotationSeed * 25) % 360 - return `linear-gradient(${rotation}deg, ${fromColor}, ${toColor})` -} - -export function getPresenceColors( - identifier: string | number, - explicitColor?: string -): PresenceColorPalette { - const paletteIndex = hashIdentifier(identifier) - - if (explicitColor) { - const normalizedColor = explicitColor.trim() - const lighterShade = HEX_COLOR_REGEX.test(normalizedColor) - ? withAlpha(normalizedColor, 0.85) - : normalizedColor - - return { - gradient: buildGradient(lighterShade, normalizedColor, paletteIndex), - accentColor: normalizedColor, - baseColor: lighterShade, - } - } - - const colorPair = APP_COLORS[paletteIndex % APP_COLORS.length] - - return { - gradient: buildGradient(colorPair.from, colorPair.to, paletteIndex), - accentColor: colorPair.to, - baseColor: colorPair.from, - } -} - /** * Gets a consistent color for a user based on their ID. * The same user will always get the same color across cursors, avatars, and terminal. @@ -112,23 +64,3 @@ export function getUserColor(userId: string): string { const hash = hashIdentifier(userId) return USER_COLORS[hash % USER_COLORS.length] } - -/** - * Creates a stable mapping of user IDs to color indices for a list of users. - * Useful when you need to maintain consistent color assignments across renders. - * - * @param userIds - Array of user IDs to map - * @returns Map of user ID to color index - */ -export function createUserColorMap(userIds: string[]): Map { - const colorMap = new Map() - let colorIndex = 0 - - for (const userId of userIds) { - if (!colorMap.has(userId)) { - colorMap.set(userId, colorIndex++) - } - } - - return colorMap -} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 6bb047ebbdb..097fc99960d 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -174,9 +174,43 @@ const nextConfig: NextConfig = { '@daytona/sdk', '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', + // The collab-doc seed converter lazily `require`s jsdom for a headless TipTap editor. Keep it + // external so webpack doesn't try to bundle jsdom's dynamic internal requires. + 'jsdom', + // The collab-doc converter runs TipTap + Yjs headlessly server-side. Two reasons these must be + // external (native Node require), not bundled: (1) the server bundler gives bundled TipTap a + // `window` that does NOT read `globalThis`, so `elementFromString` throws "no window object" even + // after the converter installs a jsdom window; (2) bundling would load a SECOND copy of `yjs`, so + // `@tiptap/y-tiptap`'s `item instanceof Y.XmlElement` checks — against the external `yjs` — would + // fail on nodes the app created with the bundled `yjs` ("Unexpected case"). One external copy fixes + // both. Server-only — the client editor bundles its own copies for the browser. + 'yjs', + 'y-protocols', + 'lib0', + '@tiptap/core', + '@tiptap/pm', + '@tiptap/markdown', + '@tiptap/y-tiptap', + '@tiptap/starter-kit', + '@tiptap/extension-code', + '@tiptap/extension-code-block', + '@tiptap/extension-image', + '@tiptap/extension-list', + '@tiptap/extension-paragraph', + '@tiptap/extension-table', + '@tiptap/extension-highlight', ], outputFileTracingIncludes: { '/api/tools/stagehand/*': ['./node_modules/ws/**/*'], + // The seed, merge, and persist endpoints all lazily `require('jsdom')` (via the collab-doc + // converter), which is invisible to the standalone file tracer, so force jsdom (and its transitive + // deps, followed from its static requires) into the trace — otherwise a Docker/standalone build + // omits it and the endpoint 500s with MODULE_NOT_FOUND. (The Yjs external stack — yjs/lib0/ + // y-protocols — is copied whole in docker/app.Dockerfile: its glob would resolve against apps/sim + // but those deps hoist to the monorepo root, so a trace include can't reach them.) + '/api/internal/file-doc/seed': ['./node_modules/jsdom/**/*'], + '/api/internal/file-doc/merge': ['./node_modules/jsdom/**/*'], + '/api/internal/file-doc/persist': ['./node_modules/jsdom/**/*'], '/*': [ './node_modules/sharp/**/*', './node_modules/@img/**/*', diff --git a/apps/sim/package.json b/apps/sim/package.json index 2fc8a509e82..f67948673a7 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -124,6 +124,8 @@ "@tanstack/react-virtual": "3.13.24", "@tiptap/core": "3.26.1", "@tiptap/extension-code-block": "3.26.1", + "@tiptap/extension-collaboration": "3.26.1", + "@tiptap/extension-collaboration-caret": "3.26.1", "@tiptap/extension-image": "3.26.1", "@tiptap/extension-list": "3.26.1", "@tiptap/extension-placeholder": "3.26.1", @@ -133,7 +135,8 @@ "@tiptap/react": "3.26.1", "@tiptap/starter-kit": "3.26.1", "@tiptap/suggestion": "3.26.1", - "@trigger.dev/sdk": "4.4.3", + "@tiptap/y-tiptap": "3.0.7", + "@trigger.dev/sdk": "4.5.7", "@typescript/typescript6": "^6.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/addon-unicode11": "0.9.0", @@ -178,7 +181,9 @@ "jose": "6.0.11", "js-tiktoken": "1.0.21", "js-yaml": "4.3.0", + "jsdom": "^26.0.0", "jszip": "3.10.1", + "lib0": "0.2.117", "lru-cache": "11.3.6", "lucide-react": "^0.511.0", "mammoth": "^1.9.0", @@ -230,6 +235,8 @@ "undici": "7.28.0", "unpdf": "1.4.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "y-protocols": "1.0.7", + "yjs": "13.6.31", "zod": "4.3.6", "zustand": "^5.0.13" }, @@ -238,7 +245,7 @@ "@sim/tsconfig": "workspace:*", "@tailwindcss/typography": "0.5.19", "@testing-library/jest-dom": "^6.6.3", - "@trigger.dev/build": "4.4.3", + "@trigger.dev/build": "4.5.7", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", "@types/fluent-ffmpeg": "2.1.28", @@ -256,7 +263,6 @@ "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^4.1.0", - "jsdom": "^26.0.0", "postcss": "^8", "react-email": "6.9.0", "tailwindcss": "^3.4.1", diff --git a/apps/sim/scripts/build-pi-daytona-snapshot.ts b/apps/sim/scripts/build-pi-daytona-snapshot.ts index 99cce3bf645..e746ab92ac7 100644 --- a/apps/sim/scripts/build-pi-daytona-snapshot.ts +++ b/apps/sim/scripts/build-pi-daytona-snapshot.ts @@ -28,9 +28,10 @@ import { Daytona, Image } from '@daytona/sdk' import { getErrorMessage } from '@sim/utils/errors' import { PI_APT, + PI_BUN_VERSION_ASSERT, + PI_GLOBAL_NPM_PACKAGES, PI_NODE_MAJOR, PI_NODE_VERSION_ASSERT, - PI_NPM, PI_SANDBOX_CPU_COUNT, PI_SANDBOX_MEMORY_GB, } from '@/scripts/pi-sandbox-packages' @@ -63,7 +64,8 @@ export const piImage = Image.base(BASE_IMAGE).runCommands( // ships an older Node fails here rather than at the first agent run. `apt-get update && curl -fsSL https://deb.nodesource.com/setup_${PI_NODE_MAJOR}.x | bash - && ${APT_PREFIX} nodejs && rm -rf /var/lib/apt/lists/* && ${PI_NODE_VERSION_ASSERT}`, `apt-get update && ${APT_PREFIX} ${PI_APT.join(' ')} && rm -rf /var/lib/apt/lists/*`, - `npm install -g ${PI_NPM.join(' ')}`, + `npm install -g ${PI_GLOBAL_NPM_PACKAGES.join(' ')}`, + PI_BUN_VERSION_ASSERT, // The clone target. E2B's base ships a world-writable /code; Pi writes to // /workspace (cloud-review-tools.ts:14), so create it explicitly. 'mkdir -p /workspace' diff --git a/apps/sim/scripts/build-pi-e2b-template.ts b/apps/sim/scripts/build-pi-e2b-template.ts index 53283566ba6..9ab2fa2e52b 100644 --- a/apps/sim/scripts/build-pi-e2b-template.ts +++ b/apps/sim/scripts/build-pi-e2b-template.ts @@ -19,9 +19,10 @@ import { defaultBuildLogger, Template, waitForTimeout } from '@e2b/code-interpreter' import { PI_APT, + PI_BUN_VERSION_ASSERT, + PI_GLOBAL_NPM_PACKAGES, PI_NODE_MAJOR, PI_NODE_VERSION_ASSERT, - PI_NPM, PI_SANDBOX_CPU_COUNT, PI_SANDBOX_MEMORY_MB, } from '@/scripts/pi-sandbox-packages' @@ -38,7 +39,8 @@ const piTemplate = Template() .fromTemplate('code-interpreter-v1') .runCmd(INSTALL_NODE_COMMAND, { user: 'root' }) .aptInstall([...PI_APT]) - .npmInstall([...PI_NPM], { g: true }) + .npmInstall([...PI_GLOBAL_NPM_PACKAGES], { g: true }) + .runCmd(PI_BUN_VERSION_ASSERT, { user: 'root' }) .setStartCmd(START_COMMAND, waitForTimeout(1_000)) async function main() { diff --git a/apps/sim/scripts/pi-sandbox-packages.ts b/apps/sim/scripts/pi-sandbox-packages.ts index 90f3567219b..59d7c29632d 100644 --- a/apps/sim/scripts/pi-sandbox-packages.ts +++ b/apps/sim/scripts/pi-sandbox-packages.ts @@ -13,8 +13,12 @@ * owns the Pi block. */ -/** Exact first-party Pi versions mirrored from bun.lock — image builds run npm independently. */ -export const PI_NPM = [ +/** Bun version mirrored from the root packageManager field. */ +export const PI_BUN_VERSION = '1.3.13' + +/** Exact global package versions mirrored from package.json and bun.lock. */ +export const PI_GLOBAL_NPM_PACKAGES = [ + `bun@${PI_BUN_VERSION}`, '@earendil-works/pi-coding-agent@0.80.10', '@earendil-works/pi-agent-core@0.80.10', '@earendil-works/pi-ai@0.80.10', @@ -51,6 +55,9 @@ export const PI_NODE_MAJOR = 22 export const PI_NODE_VERSION_ASSERT = 'node -e "const [major, minor] = process.versions.node.split(\'.\').map(Number); if (major < 22 || (major === 22 && minor < 19)) process.exit(1)"' +/** Fails the build loudly if the sandbox does not contain the repository's Bun version. */ +export const PI_BUN_VERSION_ASSERT = `test "$(bun --version)" = "${PI_BUN_VERSION}"` + /** * The review tools run `python3 /workspace/sim-review-tools.py` * (`cloud-review-tools.ts:15`). E2B's `code-interpreter-v1` base ships Python, so diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts index c8257f355c7..80d0c618f4b 100644 --- a/apps/sim/stores/panel/store.ts +++ b/apps/sim/stores/panel/store.ts @@ -1,12 +1,14 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { PANEL_WIDTH } from '@/stores/constants' import type { PanelState, PanelTab } from '@/stores/panel/types' /** - * Default panel tab + * Default panel tab. Falls back to the toolbar when Chat is disabled, since the + * copilot tab is not rendered then and would leave the panel body empty. */ -const DEFAULT_TAB: PanelTab = 'copilot' +const DEFAULT_TAB: PanelTab = isChatEnabled ? 'copilot' : 'toolbar' export const usePanelStore = create()( persist( diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index c21cf67b472..101fc8cc9d5 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -8,6 +8,7 @@ import { type AgentStreamToolTerminalStatus, settleRunningToolCallList, } from '@/components/agent-stream/tool-call-lifecycle' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { redactApiKeys } from '@/lib/core/security/redaction' import { sendMothershipMessage } from '@/lib/mothership/events' import { getQueryClient } from '@/app/_shell/providers/query-provider' @@ -310,10 +311,12 @@ const notifyBlockError = ({ toast.error(displayName, { description: errorMessage, - action: { - label: 'Fix in Chat', - onClick: () => sendMothershipMessage(copilotMessage), - }, + action: isChatEnabled + ? { + label: 'Fix in Chat', + onClick: () => sendMothershipMessage(copilotMessage), + } + : undefined, }) } catch (notificationError) { logger.error('Failed to create block error notification', { diff --git a/apps/sim/lib/integrations/instagram/constants.ts b/apps/sim/tools/instagram/constants.ts similarity index 100% rename from apps/sim/lib/integrations/instagram/constants.ts rename to apps/sim/tools/instagram/constants.ts diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index 6e4a7777672..176affa8209 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -5,7 +5,7 @@ import { readResponseJsonWithLimit, readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' -import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants' +import { INSTAGRAM_GRAPH_BASE } from '@/tools/instagram/constants' import type { InstagramPublishResponse } from '@/tools/instagram/types' export const INSTAGRAM_RESPONSE_MAX_BYTES = 2 * 1024 * 1024 diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index f3a028c8b5b..206a3804042 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -42,6 +42,13 @@ export const knowledgeSearchTool: ToolConfig = { }, }, }, + searchMode: { + type: 'string', + required: false, + visibility: 'user-only', + description: + "Retrieval mode: 'vector' (default) uses semantic similarity only, 'hybrid' also runs a full-text leg and fuses both", + }, rerankerEnabled: { type: 'boolean', required: false, @@ -114,6 +121,7 @@ export const knowledgeSearchTool: ToolConfig = { query: params.query, topK: params.topK ? Math.max(1, Math.min(100, Number(params.topK))) : 10, ...(structuredFilters.length > 0 && { tagFilters: structuredFilters }), + ...(params.searchMode === 'hybrid' && { searchMode: 'hybrid' }), ...(rerankerEnabled && { rerankerEnabled: true, rerankerModel, diff --git a/apps/sim/tools/managed_agent/archive_session.ts b/apps/sim/tools/managed_agent/archive_session.ts new file mode 100644 index 00000000000..396fe7197c9 --- /dev/null +++ b/apps/sim/tools/managed_agent/archive_session.ts @@ -0,0 +1,66 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { archiveSession } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentArchiveSessionParams, + ManagedAgentArchiveSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Archives a session — it becomes read-only but keeps its full history. + * + * This is the cleanup step a long-lived integration needs: without it every + * run leaves a live session behind in the Claude workspace forever. Archiving + * is NOT reversible, and a `running` session is rejected — interrupt it first. + */ +export const managedAgentArchiveSessionTool: ToolConfig< + ManagedAgentArchiveSessionParams, + ManagedAgentArchiveSessionResponse +> = { + id: 'managed_agent_archive_session', + name: 'Managed Agent Archive Session', + description: 'Archive a Managed Agent session, preserving its history. Not reversible.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', archived: false }, error: target.error } + } + + try { + await archiveSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, archived: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, archived: false }, + error: getErrorMessage(error, 'Failed to archive Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was archived.' }, + archived: { type: 'boolean', description: 'True when the archive was accepted.' }, + }, +} diff --git a/apps/sim/tools/managed_agent/create_session.ts b/apps/sim/tools/managed_agent/create_session.ts new file mode 100644 index 00000000000..72ef392d962 --- /dev/null +++ b/apps/sim/tools/managed_agent/create_session.ts @@ -0,0 +1,201 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { + type CreateSessionInput, + createSession, + getEnvironmentType, +} from '@/lib/managed-agents/session-client' +import { + isTruthyAck, + normalizeFiles, + normalizeMemoryAccess, + normalizeSessionParameters, + normalizeStringList, +} from '@/tools/managed_agent/normalizers' +import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, UNUSED_REQUEST } from '@/tools/managed_agent/shared' +import type { + ManagedAgentCreateSessionParams, + ManagedAgentCreateSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Creates a Managed Agent session and returns its id WITHOUT waiting for the + * agent to finish. + * + * This is the non-blocking counterpart to `managed_agent_run_session`: it makes + * the session a durable handle a later workflow run can address (via + * `managed_agent_send_message` / `..._get_session`), which is what a + * conversational or webhook-driven integration needs. Supplying a first message + * seeds `initial_events`, so create-and-start is a single API call. + */ +export const managedAgentCreateSessionTool: ToolConfig< + ManagedAgentCreateSessionParams, + ManagedAgentCreateSessionResponse +> = { + id: 'managed_agent_create_session', + name: 'Managed Agent Create Session', + description: + 'Create a Claude Platform Managed Agent session and return its id without waiting for a reply.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + agent: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Managed-agent id inside the linked Claude workspace.', + }, + environment: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Environment id inside the linked Claude workspace.', + }, + environmentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Environment execution model hint ('cloud' | 'self_hosted').", + }, + userMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional first message; seeds initial_events and starts the agent immediately.', + }, + vaults: { + type: 'array', + required: false, + visibility: 'user-only', + description: 'Zero or more vault ids for MCP tool auth.', + }, + vaultsAck: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Acknowledgement that the author may use the attached vaults.', + }, + memoryStoreId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Optional Agent Memory Store id.', + }, + memoryAccess: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Memory store access mode: 'read_write' (default) or 'read_only'.", + }, + memoryInstructions: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Per-attachment guidance for how the agent should use the memory store.', + }, + files: { + type: 'array', + required: false, + visibility: 'user-only', + description: 'File attachments (cloud envs only), as [{fileId, mountPath?}].', + }, + sessionParameters: { + type: 'object', + required: false, + visibility: 'user-only', + description: 'Key/value session metadata forwarded to the session.', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const apiKey = params.accessToken + if (!apiKey) { + return { + success: false, + output: { sessionId: '', started: false }, + error: 'No Claude Platform credential is selected, or it could not be resolved.', + } + } + + const agentId = params.agent?.trim() + const environmentId = params.environment?.trim() + if (!agentId || !environmentId) { + return { + success: false, + output: { sessionId: '', started: false }, + error: 'An agent and an environment are required.', + } + } + + const vaultIds = normalizeStringList(params.vaults) + if (vaultIds.length > 0 && !isTruthyAck(params.vaultsAck)) { + return { + success: false, + output: { sessionId: '', started: false }, + error: + 'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).', + } + } + + const files = normalizeFiles(params.files) + const sessionParameters = normalizeSessionParameters(params.sessionParameters) + const memoryStoreId = params.memoryStoreId?.trim() || undefined + const memoryAccess = normalizeMemoryAccess(params.memoryAccess) + const memoryInstructions = params.memoryInstructions?.trim() || undefined + const initialMessage = (params.userMessage ?? '').toString().trim() || undefined + + const workflowId = params._context?.workflowId?.trim() + const title = workflowId ? `Sim workflow ${workflowId}` : undefined + + // Self-hosted environments reject `resources`, so the payload must know the + // execution model. The API is authoritative; the block's hint is a fallback. + const hinted = + params.environmentType === 'self_hosted' || params.environmentType === 'cloud' + ? params.environmentType + : undefined + const environmentType = + (await getEnvironmentType({ apiKey, environmentId, ...(signal ? { signal } : {}) })) ?? hinted + + const createInput: CreateSessionInput = { + apiKey, + agentId, + environmentId, + ...(environmentType ? { environmentType } : {}), + ...(title ? { title } : {}), + ...(vaultIds.length > 0 ? { vaultIds } : {}), + ...(memoryStoreId ? { memoryStoreId } : {}), + ...(memoryStoreId && memoryAccess ? { memoryAccess } : {}), + ...(memoryStoreId && memoryInstructions ? { memoryInstructions } : {}), + ...(files.length > 0 ? { files } : {}), + ...(sessionParameters ? { sessionParameters } : {}), + ...(initialMessage ? { initialMessage } : {}), + ...(signal ? { signal } : {}), + } + + try { + const session = await createSession(createInput) + return { + success: true, + output: { sessionId: session.id, started: Boolean(initialMessage) }, + } + } catch (error) { + return { + success: false, + output: { sessionId: '', started: false }, + error: getErrorMessage(error, 'Failed to create Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'Anthropic session id (sesn_...).' }, + started: { + type: 'boolean', + description: 'True when a first message was seeded, so the agent is already running.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/delete_session.ts b/apps/sim/tools/managed_agent/delete_session.ts new file mode 100644 index 00000000000..d50fed72302 --- /dev/null +++ b/apps/sim/tools/managed_agent/delete_session.ts @@ -0,0 +1,68 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { deleteSession } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentDeleteSessionParams, + ManagedAgentDeleteSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Permanently deletes a session, its event history, and its sandbox. + * + * Files, memory stores, vaults, skills, environments, and agents are separate + * resources and are NOT affected. A `running` session is rejected — interrupt + * it first. Prefer archiving when the transcript still has value; this is the + * right choice when the session held sensitive input that should not persist. + */ +export const managedAgentDeleteSessionTool: ToolConfig< + ManagedAgentDeleteSessionParams, + ManagedAgentDeleteSessionResponse +> = { + id: 'managed_agent_delete_session', + name: 'Managed Agent Delete Session', + description: + 'Permanently delete a Managed Agent session, its events, and its sandbox. Not reversible.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', deleted: false }, error: target.error } + } + + try { + await deleteSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, deleted: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, deleted: false }, + error: getErrorMessage(error, 'Failed to delete Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was deleted.' }, + deleted: { type: 'boolean', description: 'True when the delete was accepted.' }, + }, +} diff --git a/apps/sim/tools/managed_agent/get_session.ts b/apps/sim/tools/managed_agent/get_session.ts new file mode 100644 index 00000000000..ebca831595f --- /dev/null +++ b/apps/sim/tools/managed_agent/get_session.ts @@ -0,0 +1,157 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { resolvePendingToolGates, retrieveSession } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentGetSessionParams, + ManagedAgentGetSessionResponse, + ManagedAgentPendingTool, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ManagedAgentGetSession') + +/** `stop_reason.type` meaning the session is parked awaiting a client response. */ +const REQUIRES_ACTION = 'requires_action' + +/** + * Reads a Managed Agent session's current state, and — when it is blocked on an + * `always_ask` permission gate — resolves the blocking tool calls to their + * names and inputs. + * + * That enrichment is the point of this tool. A session that stops with + * `stop_reason.type === 'requires_action'` waits INDEFINITELY for a + * `user.tool_confirmation`, so a workflow that cannot see which tools are + * pending has no way to build an approval prompt and the session hangs. The + * blocking event ids come straight from `stop_reason.event_ids`; the tool + * names are cross-referenced from the session's tool-use events. + */ +export const managedAgentGetSessionTool: ToolConfig< + ManagedAgentGetSessionParams, + ManagedAgentGetSessionResponse +> = { + id: 'managed_agent_get_session', + name: 'Managed Agent Get Session', + description: + 'Read a Managed Agent session: status, stop reason, token usage, metadata, and any tool calls awaiting approval.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { + sessionId: '', + status: '', + requiresAction: false, + pendingTools: [] as ManagedAgentPendingTool[], + } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + try { + const snapshot = await retrieveSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + + const requiresAction = snapshot.stopReason?.type === REQUIRES_ACTION + const eventIds = snapshot.stopReason?.eventIds ?? [] + // Only pay for the events call when the session is actually blocked. + const pendingTools = + requiresAction && eventIds.length > 0 + ? await resolvePendingToolGates({ + apiKey: target.apiKey, + sessionId: target.sessionId, + eventIds, + ...(signal ? { signal } : {}), + }) + : [] + + // A blocked session that names no blocking events is an anomaly: it waits + // indefinitely, but nothing here can say for what. `requiresAction` stays + // true because that is the truth — reporting false would tell a workflow + // the session is fine while it is parked forever — so log it instead, so + // the dead end is visible rather than silent. + if (requiresAction && pendingTools.length === 0) { + logger.warn('Managed Agent session requires action but reported no blocking event ids', { + sessionId: target.sessionId, + }) + } + + return { + success: true, + output: { + sessionId: target.sessionId, + status: snapshot.status ?? '', + ...(snapshot.stopReason?.type ? { stopReason: snapshot.stopReason.type } : {}), + requiresAction, + pendingTools, + ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), + ...(snapshot.title ? { title: snapshot.title } : {}), + ...(snapshot.usage?.inputTokens !== undefined + ? { inputTokens: snapshot.usage.inputTokens } + : {}), + ...(snapshot.usage?.outputTokens !== undefined + ? { outputTokens: snapshot.usage.outputTokens } + : {}), + }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to read Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was read.' }, + status: { + type: 'string', + description: "Session status — 'idle', 'running', 'rescheduling', or 'terminated'.", + }, + stopReason: { + type: 'string', + description: "Why the session last stopped, e.g. 'end_turn' or 'requires_action'.", + optional: true, + }, + requiresAction: { + type: 'boolean', + description: + 'True when the session is waiting on a tool confirmation or custom tool result. If this is true while pendingTools is empty, the session is blocked but the API named no blocking events — surface it rather than treating the session as done.', + }, + pendingTools: { + type: 'json', + description: + "Blocking tool calls — [{id, eventType, kind, name, input}]. Route by kind: 'confirmation' ids go to Respond To Tool Confirmation, 'custom_tool_result' ids go to Respond To Custom Tool.", + }, + metadata: { type: 'json', description: 'Session metadata.', optional: true }, + title: { type: 'string', description: 'Session title.', optional: true }, + inputTokens: { + type: 'number', + description: 'Cumulative input tokens.', + optional: true, + }, + outputTokens: { + type: 'number', + description: 'Cumulative output tokens.', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/managed_agent/index.ts b/apps/sim/tools/managed_agent/index.ts index 2b2a3212ada..a4e62fad19f 100644 --- a/apps/sim/tools/managed_agent/index.ts +++ b/apps/sim/tools/managed_agent/index.ts @@ -1,2 +1,12 @@ +export { managedAgentArchiveSessionTool } from './archive_session' +export { managedAgentCreateSessionTool } from './create_session' +export { managedAgentDeleteSessionTool } from './delete_session' +export { managedAgentGetSessionTool } from './get_session' +export { managedAgentInterruptSessionTool } from './interrupt_session' +export { managedAgentListEventsTool } from './list_events' +export { managedAgentRespondCustomToolTool } from './respond_custom_tool' +export { managedAgentRespondToolConfirmationTool } from './respond_tool_confirmation' export { managedAgentRunSessionTool } from './run_session' +export { managedAgentSendMessageTool } from './send_message' export * from './types' +export { managedAgentUpdateSessionTool } from './update_session' diff --git a/apps/sim/tools/managed_agent/interrupt_session.ts b/apps/sim/tools/managed_agent/interrupt_session.ts new file mode 100644 index 00000000000..0fd536e8225 --- /dev/null +++ b/apps/sim/tools/managed_agent/interrupt_session.ts @@ -0,0 +1,80 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendSessionEvents } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentInterruptSessionParams, + ManagedAgentInterruptSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Upper bound on the interrupt request itself. Interrupting is the prerequisite + * for archiving or deleting a running session, so it must fail fast and visibly + * rather than hang on a stalled connection. + */ +const INTERRUPT_TIMEOUT_MS = 15_000 + +/** + * Stops a running session at its next safe boundary. + * + * The interrupt jumps ahead of any queued user events, and the session stays + * usable afterwards — send another message to carry on. This is also the + * prerequisite for archiving or deleting a session that is still `running`, + * since both of those reject a running session. + */ +export const managedAgentInterruptSessionTool: ToolConfig< + ManagedAgentInterruptSessionParams, + ManagedAgentInterruptSessionResponse +> = { + id: 'managed_agent_interrupt_session', + name: 'Managed Agent Interrupt Session', + description: 'Stop a running Managed Agent session; it stays usable afterwards.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', interrupted: false }, error: target.error } + } + + try { + await sendSessionEvents({ + apiKey: target.apiKey, + sessionId: target.sessionId, + events: [{ type: 'user.interrupt' }], + // Bounded so a stalled connection can't hang the operation. The + // workflow's own signal still cancels earlier when present; `any` + // resolves on whichever fires first. + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(INTERRUPT_TIMEOUT_MS)]) + : AbortSignal.timeout(INTERRUPT_TIMEOUT_MS), + }) + return { success: true, output: { sessionId: target.sessionId, interrupted: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, interrupted: false }, + error: getErrorMessage(error, 'Failed to interrupt Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was interrupted.' }, + interrupted: { type: 'boolean', description: 'True when the interrupt was accepted.' }, + }, +} diff --git a/apps/sim/tools/managed_agent/list_events.test.ts b/apps/sim/tools/managed_agent/list_events.test.ts new file mode 100644 index 00000000000..a0f9d88e0d7 --- /dev/null +++ b/apps/sim/tools/managed_agent/list_events.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { managedAgentListEventsTool } from '@/tools/managed_agent/list_events' + +const originalFetch = global.fetch +afterEach(() => { + global.fetch = originalFetch +}) + +/** One page of `count` events, no next page. */ +const historyOf = (count: number) => + vi.fn(async () => + Response.json({ + data: Array.from({ length: count }, (_, i) => ({ + id: `e${i}`, + type: 'agent.message', + processed_at: new Date(Date.UTC(2026, 0, 1) + i * 1000).toISOString(), + content: [{ type: 'text', text: `m${i}` }], + })), + next_page: null, + }) + ) as unknown as typeof fetch + +const run = (limit: unknown) => + managedAgentListEventsTool.directExecution!( + { credential: 'c', accessToken: 'sk-ant-fake', sessionId: 'sesn_1', limit } as never, + undefined + ) + +describe('managed_agent_list_events — limit handling', () => { + it.each([0.5, 0, -1, Number.NaN, 'abc', null, undefined])( + 'falls back to the 500 default for the invalid limit %p rather than reading unbounded', + async (limit) => { + global.fetch = historyOf(600) + const res = (await run(limit)) as { output: { count: number; truncated: boolean } } + expect(res.output.count).toBe(500) + expect(res.output.truncated).toBe(true) + } + ) + + it('honors a valid limit and keeps the newest events', async () => { + global.fetch = historyOf(50) + const res = (await run(10)) as { + output: { count: number; truncated: boolean; assistantText: string } + } + expect(res.output.count).toBe(10) + expect(res.output.truncated).toBe(true) + // Newest ten are m40..m49, concatenated in chronological order. + expect(res.output.assistantText).toBe( + Array.from({ length: 10 }, (_, i) => `m${40 + i}`).join('') + ) + }) + + it('reports a complete history as not truncated even at exactly the limit', async () => { + global.fetch = historyOf(10) + const res = (await run(10)) as { output: { count: number; truncated: boolean } } + expect(res.output.count).toBe(10) + expect(res.output.truncated).toBe(false) + }) +}) diff --git a/apps/sim/tools/managed_agent/list_events.ts b/apps/sim/tools/managed_agent/list_events.ts new file mode 100644 index 00000000000..d2858ddbd75 --- /dev/null +++ b/apps/sim/tools/managed_agent/list_events.ts @@ -0,0 +1,143 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { listSessionEventsPage } from '@/lib/managed-agents/session-client' +import { normalizeStringList } from '@/tools/managed_agent/normalizers' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentListEventsParams, + ManagedAgentListEventsResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Default cap on how many events land in the workflow output. + * + * A long-running agent session accumulates thousands of events, and tool inputs + * and results can each be large — returning the whole history unbounded would + * put an unpredictable payload into the workflow. Callers that genuinely need + * more can raise it. + */ +const DEFAULT_EVENT_LIMIT = 500 + +/** + * Reads a session's event history, oldest first. + * + * `assistantText` is precomputed because reading the agent's reply is the + * overwhelmingly common reason to call this, and doing it correctly is fiddly: + * only persisted (id-bearing) `agent.message` events count, since stream-only + * previews are never deduped and would double the text. + */ +export const managedAgentListEventsTool: ToolConfig< + ManagedAgentListEventsParams, + ManagedAgentListEventsResponse +> = { + id: 'managed_agent_list_events', + name: 'Managed Agent List Events', + description: "Read a Managed Agent session's event history and the agent's reply text.", + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + eventTypes: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + "Optional event-type filter, e.g. ['agent.message']. Omit to return every event.", + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + // Written out rather than interpolated: the docs generator extracts this + // string statically, so a template literal ships as a raw `${...}` to + // readers. `DEFAULT_EVENT_LIMIT` is asserted against this in tests. + description: 'Maximum events to return, keeping the most recent (default 500).', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { + sessionId: '', + events: [] as unknown[], + count: 0, + assistantText: '', + truncated: false, + } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const types = normalizeStringList(params.eventTypes) + // Floor BEFORE the positivity check: a fractional limit like 0.5 would pass + // `> 0` and then floor to 0, which reads as "no cap" downstream and returns + // the whole history. Anything that does not floor to a positive integer + // falls back to the default rather than silently becoming unbounded. + const requested = Math.floor(Number(params.limit)) + const maxItems = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_EVENT_LIMIT + + try { + const { events, total } = await listSessionEventsPage({ + apiKey: target.apiKey, + sessionId: target.sessionId, + maxItems, + ...(types.length > 0 ? { types } : {}), + ...(signal ? { signal } : {}), + }) + + let assistantText = '' + for (const event of events) { + // Skip idless events: those are stream-only previews, and the persisted + // copy carrying the same text arrives separately. + if (event.type !== 'agent.message' || !event.id || !Array.isArray(event.content)) continue + for (const block of event.content) { + if (block?.type === 'text' && typeof block.text === 'string') assistantText += block.text + } + } + + return { + success: true, + output: { + sessionId: target.sessionId, + events, + count: events.length, + assistantText, + // Compared against the untrimmed history size, not the limit: a + // session holding exactly `maxItems` events dropped nothing and must + // not be reported as a partial read. + truncated: total > events.length, + }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to list Managed Agent session events'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was read.' }, + events: { type: 'json', description: 'Session events, oldest first.' }, + count: { type: 'number', description: 'Number of events returned.' }, + assistantText: { + type: 'string', + description: 'Concatenated text of every persisted agent.message, in order.', + }, + truncated: { + type: 'boolean', + description: 'True when the limit was hit and older events were dropped.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/respond_custom_tool.ts b/apps/sim/tools/managed_agent/respond_custom_tool.ts new file mode 100644 index 00000000000..0f96d269cd6 --- /dev/null +++ b/apps/sim/tools/managed_agent/respond_custom_tool.ts @@ -0,0 +1,117 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendCustomToolResults } from '@/lib/managed-agents/session-client' +import { isTruthyAck } from '@/tools/managed_agent/normalizers' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentCustomToolResultParams, + ManagedAgentCustomToolResultResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Returns the result of a client-side custom tool the agent invoked. + * + * Custom tools run in the caller's application, not Anthropic's sandbox, so the + * agent parks on `agent.custom_tool_use` until the caller supplies the output. + * A permission confirmation does NOT unblock these — that is a different event + * for a different kind of gate. `managed_agent_get_session` labels each pending + * gate with `kind`, so a workflow can route to the right operation. + * + * Deliberately answers ONE call per invocation. Each pending custom tool has + * its own output, so accepting a list here would force every one of them to + * share a single result — silently wrong whenever more than one is pending. + * Answer several by iterating this operation over `pendingTools`. + */ +export const managedAgentRespondCustomToolTool: ToolConfig< + ManagedAgentCustomToolResultParams, + ManagedAgentCustomToolResultResponse +> = { + id: 'managed_agent_respond_custom_tool', + name: 'Managed Agent Respond To Custom Tool', + description: + 'Return the result of a custom tool a Managed Agent session is waiting on so it can continue.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + customToolUseId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + "The custom tool-use EVENT id being answered, from Get Session pendingTools[].id where kind is 'custom_tool_result'.", + }, + result: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "The tool's output, returned to the agent as text.", + }, + isError: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Mark the result as a failure so the agent can adjust its approach.', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { sessionId: '', answeredToolUseId: '' } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const customToolUseId = params.customToolUseId?.trim() + if (!customToolUseId) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: + 'A custom tool-use event id is required. Read it from Get Session pendingTools[].id.', + } + } + + // The result may legitimately be empty (a tool that returns nothing), so + // only the id is required — an absent result is sent as an empty string. + const result = (params.result ?? '').toString() + const isError = isTruthyAck(params.isError) + + try { + await sendCustomToolResults({ + apiKey: target.apiKey, + sessionId: target.sessionId, + results: [{ customToolUseId, content: result, isError }], + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { sessionId: target.sessionId, answeredToolUseId: customToolUseId }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to send custom tool result'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was answered.' }, + answeredToolUseId: { + type: 'string', + description: 'The custom tool-use event id that was answered.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/respond_tool_confirmation.ts b/apps/sim/tools/managed_agent/respond_tool_confirmation.ts new file mode 100644 index 00000000000..5156a74af19 --- /dev/null +++ b/apps/sim/tools/managed_agent/respond_tool_confirmation.ts @@ -0,0 +1,125 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendToolConfirmations } from '@/lib/managed-agents/session-client' +import { normalizeStringList } from '@/tools/managed_agent/normalizers' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentToolConfirmationParams, + ManagedAgentToolConfirmationResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Answers the `always_ask` permission gates blocking a session. + * + * Without this, an agent configured with an `always_ask` policy on any tool + * parks forever: it emits `agent.tool_use`, the session idles with + * `stop_reason.type === 'requires_action'`, and it waits indefinitely for a + * `user.tool_confirmation`. Pair with `managed_agent_get_session`, which + * surfaces the blocking ids in `pendingTools`. + * + * All ids are answered in a single request: resolving only some of a turn's + * gates leaves the session parked on the rest. + */ +export const managedAgentRespondToolConfirmationTool: ToolConfig< + ManagedAgentToolConfirmationParams, + ManagedAgentToolConfirmationResponse +> = { + id: 'managed_agent_respond_tool_confirmation', + name: 'Managed Agent Respond To Tool Confirmation', + description: + 'Allow or deny the tool calls a Managed Agent session is waiting on before it can continue.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + toolUseIds: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: + "Blocking tool-use EVENT ids, from Get Session pendingTools[].id where kind is 'confirmation' (not toolu_ ids).", + }, + decision: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "'allow' to let the tools run, or 'deny' to reject them.", + }, + denyMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reason surfaced to the agent. Only sent when the decision is deny.', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { sessionId: '', decision: '', confirmedToolUseIds: [] as string[] } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const decision = (params.decision ?? '').toString().trim().toLowerCase() + if (decision !== 'allow' && decision !== 'deny') { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: "Decision must be 'allow' or 'deny'.", + } + } + + const toolUseIds = normalizeStringList(params.toolUseIds) + if (toolUseIds.length === 0) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId, decision }, + error: + 'At least one tool-use event id is required. Read them from Get Session pendingTools[].id.', + } + } + + const denyMessage = params.denyMessage?.trim() + try { + await sendToolConfirmations({ + apiKey: target.apiKey, + sessionId: target.sessionId, + confirmations: toolUseIds.map((toolUseId) => ({ + toolUseId, + result: decision, + ...(denyMessage ? { denyMessage } : {}), + })), + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { sessionId: target.sessionId, decision, confirmedToolUseIds: toolUseIds }, + } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, decision, confirmedToolUseIds: [] }, + error: getErrorMessage(error, 'Failed to send tool confirmation'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was answered.' }, + decision: { type: 'string', description: "The decision applied — 'allow' or 'deny'." }, + confirmedToolUseIds: { + type: 'json', + description: 'The tool-use event ids that were answered.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/send_message.ts b/apps/sim/tools/managed_agent/send_message.ts new file mode 100644 index 00000000000..cd853a2de91 --- /dev/null +++ b/apps/sim/tools/managed_agent/send_message.ts @@ -0,0 +1,87 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendUserMessage } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentSendMessageParams, + ManagedAgentSendMessageResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Sends a user turn into an EXISTING Managed Agent session and returns as soon + * as the event is accepted. + * + * This is what makes a session multi-turn across workflow runs: the session id + * comes from the caller (a thread lookup, a webhook payload, a prior + * `managed_agent_create_session`), so a conversation can span many executions + * instead of being trapped inside one blocking block. + * + * Events are queued server-side and processed in order, so there is no need to + * wait for the agent to go idle before sending the next one. + */ +export const managedAgentSendMessageTool: ToolConfig< + ManagedAgentSendMessageParams, + ManagedAgentSendMessageResponse +> = { + id: 'managed_agent_send_message', + name: 'Managed Agent Send Message', + description: 'Send a user message to an existing Claude Platform Managed Agent session.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + userMessage: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The user message to send to the session.', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', sent: false }, error: target.error } + } + + const text = (params.userMessage ?? '').toString().trim() + if (!text) { + return { + success: false, + output: { sessionId: target.sessionId, sent: false }, + error: 'A user message is required.', + } + } + + try { + await sendUserMessage({ + apiKey: target.apiKey, + sessionId: target.sessionId, + text, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, sent: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, sent: false }, + error: getErrorMessage(error, 'Failed to send message to Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session the message was sent to.' }, + sent: { type: 'boolean', description: 'True when the event was accepted by the API.' }, + }, +} diff --git a/apps/sim/tools/managed_agent/shared.ts b/apps/sim/tools/managed_agent/shared.ts new file mode 100644 index 00000000000..ea045474071 --- /dev/null +++ b/apps/sim/tools/managed_agent/shared.ts @@ -0,0 +1,87 @@ +/** + * Shared scaffolding for the Managed Agent session-operation tools. + * + * Every operation authenticates the same way (a Claude Platform + * service-account credential the executor resolves into `accessToken`) and + * most address an existing session by id, so those parameter definitions and + * the guard that reads them live here rather than being restated per tool. + */ + +import type { ToolConfig } from '@/tools/types' + +/** Credential picker value; the executor swaps it for `accessToken` at run time. */ +export const CREDENTIAL_PARAM = { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Claude Platform credential (Anthropic workspace API key) to act with.', +} as const + +/** Decrypted workspace API key injected by the executor. Never set by the author. */ +export const ACCESS_TOKEN_PARAM = { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Workspace API key injected by the executor from the selected credential.', +} as const + +export const SESSION_ID_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Anthropic session id (sesn_...) to act on.', +} as const + +/** + * `ToolConfig` requires a `request` shape even when `directExecution` + * short-circuits the HTTP path, so every session-operation tool reuses this + * inert stub instead of repeating it. + */ +export const UNUSED_REQUEST: ToolConfig['request'] = { + url: () => '', + method: 'POST', + headers: () => ({}), +} + +/** Params common to every session-operation tool. */ +export interface ManagedAgentSessionParams { + credential: string + accessToken?: string + sessionId?: string +} + +/** + * Validates the two things every session operation needs. Returns a message on + * failure so each tool can surface it as a normal tool error rather than + * throwing — a missing credential is an author mistake, not an exception. + */ +export function resolveSessionTarget( + params: ManagedAgentSessionParams +): { ok: true; apiKey: string; sessionId: string } | { ok: false; error: string } { + const apiKey = params.accessToken + if (!apiKey) { + return { + ok: false, + error: 'No Claude Platform credential is selected, or it could not be resolved.', + } + } + const sessionId = params.sessionId?.trim() + if (!sessionId) { + return { ok: false, error: 'A session id is required.' } + } + return { ok: true, apiKey, sessionId } +} + +/** Validates the credential alone, for operations that do not target a session. */ +export function resolveApiKey( + params: Pick +): { ok: true; apiKey: string } | { ok: false; error: string } { + const apiKey = params.accessToken + if (!apiKey) { + return { + ok: false, + error: 'No Claude Platform credential is selected, or it could not be resolved.', + } + } + return { ok: true, apiKey } +} diff --git a/apps/sim/tools/managed_agent/types.ts b/apps/sim/tools/managed_agent/types.ts index 39dd27be591..d35e39d6ffc 100644 --- a/apps/sim/tools/managed_agent/types.ts +++ b/apps/sim/tools/managed_agent/types.ts @@ -37,6 +37,141 @@ export interface ManagedAgentRunSessionParams { _context?: { workflowId?: string } } +/** Shape shared by the session-operation tools' params. */ +interface ManagedAgentSessionOpParams { + credential: string + accessToken?: string + sessionId?: string +} + +export interface ManagedAgentCreateSessionParams + extends Omit { + /** Optional first turn, seeded via `initial_events` so create+send is one call. */ + userMessage?: string +} + +export interface ManagedAgentCreateSessionResponse extends ToolResponse { + output: { + sessionId: string + /** True when a first message was seeded and the agent loop already started. */ + started: boolean + } +} + +export interface ManagedAgentSendMessageParams extends ManagedAgentSessionOpParams { + userMessage: string +} + +export interface ManagedAgentSendMessageResponse extends ToolResponse { + output: { sessionId: string; sent: boolean } +} + +export interface ManagedAgentGetSessionParams extends ManagedAgentSessionOpParams {} + +/** One tool call blocking a session. */ +export interface ManagedAgentPendingTool { + id: string + eventType?: string + /** + * Which operation unblocks this gate: `confirmation` (Respond To Tool + * Confirmation) or `custom_tool_result` (Respond To Custom Tool). + */ + kind?: 'confirmation' | 'custom_tool_result' + name?: string + input?: unknown +} + +export interface ManagedAgentGetSessionResponse extends ToolResponse { + output: { + sessionId: string + status: string + /** `stop_reason.type` from the session, when it has stopped at least once. */ + stopReason?: string + /** True when the session is parked awaiting a tool confirmation or result. */ + requiresAction: boolean + /** Blocking tool calls, resolved to names where possible. Empty unless `requiresAction`. */ + pendingTools: ManagedAgentPendingTool[] + metadata?: Record + title?: string + inputTokens?: number + outputTokens?: number + } +} + +export interface ManagedAgentListEventsParams extends ManagedAgentSessionOpParams { + /** Optional `types[]` filter (array, JSON string, or comma list). */ + eventTypes?: unknown + /** Cap on events returned; guards against an unbounded history read. */ + limit?: unknown +} + +export interface ManagedAgentListEventsResponse extends ToolResponse { + output: { + sessionId: string + events: unknown[] + count: number + /** Concatenated text of every `agent.message`, in order — the usual thing callers want. */ + assistantText: string + /** True when `limit` was reached and older events were dropped. */ + truncated: boolean + } +} + +export interface ManagedAgentUpdateSessionParams extends ManagedAgentSessionOpParams { + title?: string + sessionParameters?: unknown + /** Explicitly removes all stored metadata; an empty map cannot express this. */ + clearMetadata?: boolean | string +} + +export interface ManagedAgentUpdateSessionResponse extends ToolResponse { + output: { sessionId: string; updated: boolean; metadata?: Record; title?: string } +} + +export interface ManagedAgentInterruptSessionParams extends ManagedAgentSessionOpParams {} + +export interface ManagedAgentInterruptSessionResponse extends ToolResponse { + output: { sessionId: string; interrupted: boolean } +} + +export interface ManagedAgentToolConfirmationParams extends ManagedAgentSessionOpParams { + /** Blocking tool-use EVENT ids (array, JSON string, or comma list). */ + toolUseIds?: unknown + /** `allow` or `deny`. */ + decision?: string + /** Reason surfaced to the agent; only sent on a denial. */ + denyMessage?: string +} + +export interface ManagedAgentToolConfirmationResponse extends ToolResponse { + output: { sessionId: string; decision: string; confirmedToolUseIds: string[] } +} + +export interface ManagedAgentCustomToolResultParams extends ManagedAgentSessionOpParams { + /** The blocking custom tool-use EVENT id being answered. */ + customToolUseId?: string + /** The tool's output, returned to the agent. */ + result?: string + /** Marks the result as a failure. */ + isError?: boolean | string +} + +export interface ManagedAgentCustomToolResultResponse extends ToolResponse { + output: { sessionId: string; answeredToolUseId: string } +} + +export interface ManagedAgentArchiveSessionParams extends ManagedAgentSessionOpParams {} + +export interface ManagedAgentArchiveSessionResponse extends ToolResponse { + output: { sessionId: string; archived: boolean } +} + +export interface ManagedAgentDeleteSessionParams extends ManagedAgentSessionOpParams {} + +export interface ManagedAgentDeleteSessionResponse extends ToolResponse { + output: { sessionId: string; deleted: boolean } +} + export interface ManagedAgentRunSessionResponse extends ToolResponse { output: { /** Final assistant text from the Managed Agent session. */ diff --git a/apps/sim/tools/managed_agent/update_session.test.ts b/apps/sim/tools/managed_agent/update_session.test.ts new file mode 100644 index 00000000000..ddb8486106d --- /dev/null +++ b/apps/sim/tools/managed_agent/update_session.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { managedAgentUpdateSessionTool } from '@/tools/managed_agent/update_session' + +const originalFetch = global.fetch +afterEach(() => { + global.fetch = originalFetch +}) + +const capture = () => { + const spy = vi.fn(async () => Response.json({ status: 'idle' })) as unknown as typeof fetch + global.fetch = spy + return spy as unknown as ReturnType +} + +const run = (params: Record) => + managedAgentUpdateSessionTool.directExecution!( + { credential: 'c', accessToken: 'sk-ant-fake', sessionId: 'sesn_1', ...params } as never, + undefined + ) + +const bodyOf = (spy: ReturnType) => + JSON.parse((spy.mock.calls[0] as [string, RequestInit])[1].body as string) + +describe('managed_agent_update_session — metadata clearing', () => { + it('does not touch metadata on a title-only update', async () => { + const spy = capture() + await run({ title: 'renamed' }) + expect(bodyOf(spy)).toEqual({ title: 'renamed' }) + }) + + it.each([[[]], [{}], [undefined]])( + 'leaves stored metadata alone for the empty value %p rather than wiping it', + async (sessionParameters) => { + // An untouched metadata table is also empty, so emptiness cannot mean + // "clear" — inferring it would wipe metadata on every title-only update. + const spy = capture() + await run({ title: 'renamed', sessionParameters }) + expect(bodyOf(spy).metadata).toBeUndefined() + } + ) + + it('clears metadata only when explicitly asked', async () => { + const spy = capture() + await run({ clearMetadata: true }) + expect(bodyOf(spy)).toEqual({ metadata: {} }) + }) + + it('lets an explicit clear win over a supplied map', async () => { + const spy = capture() + await run({ clearMetadata: true, sessionParameters: { a: 'b' } }) + expect(bodyOf(spy).metadata).toEqual({}) + }) + + it('still refuses a no-op update', async () => { + capture() + const res = (await run({})) as { success: boolean; error?: string } + expect(res.success).toBe(false) + expect(res.error).toMatch(/Clear metadata/) + }) +}) diff --git a/apps/sim/tools/managed_agent/update_session.ts b/apps/sim/tools/managed_agent/update_session.ts new file mode 100644 index 00000000000..2520405148a --- /dev/null +++ b/apps/sim/tools/managed_agent/update_session.ts @@ -0,0 +1,125 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { updateSession } from '@/lib/managed-agents/session-client' +import { isTruthyAck, normalizeSessionParameters } from '@/tools/managed_agent/normalizers' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentUpdateSessionParams, + ManagedAgentUpdateSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Updates an existing session's title and/or metadata. + * + * Metadata is settable at create time too, but some of what you want to store + * only exists AFTER the session does — the canonical case is writing back the + * id of a message/thread that was posted to announce the session. This closes + * that ordering gap. + * + * Metadata is a FULL REPLACEMENT of the stored map, matching the API. To add + * one key, read the session first and send the merged map. Removing metadata + * entirely takes an explicit `clearMetadata`, because an empty map is + * indistinguishable from a field the author never filled in. + */ +export const managedAgentUpdateSessionTool: ToolConfig< + ManagedAgentUpdateSessionParams, + ManagedAgentUpdateSessionResponse +> = { + id: 'managed_agent_update_session', + name: 'Managed Agent Update Session', + description: "Update a Managed Agent session's title or metadata.", + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New session title.', + }, + sessionParameters: { + type: 'object', + required: false, + visibility: 'user-or-llm', + description: + 'Replacement metadata map (replaces all stored metadata, not merged). Leaving it empty leaves the stored metadata unchanged — use clearMetadata to remove it.', + }, + clearMetadata: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + "Removes all of the session's stored metadata. Overrides any map supplied above.", + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', updated: false }, error: target.error } + } + + // A whitespace-only title is treated as "not provided", not as a request to + // blank the session's title — otherwise a stray space in the field would + // both slip past the guard below and silently clear an existing title. + const trimmedTitle = params.title?.trim() + const title = trimmedTitle ? trimmedTitle : undefined + + // Clearing metadata needs its own explicit signal. An empty metadata table + // cannot mean "clear": a table the author never touched is also empty, so + // inferring intent from emptiness would wipe a session's metadata on every + // title-only update. `{}` is only sent when the author asks for it. + const clearMetadata = isTruthyAck(params.clearMetadata) + const metadata = clearMetadata ? {} : normalizeSessionParameters(params.sessionParameters) + if (title === undefined && metadata === undefined) { + return { + success: false, + output: { sessionId: target.sessionId, updated: false }, + error: 'Provide a title or metadata to update, or check "Clear metadata".', + } + } + + try { + const snapshot = await updateSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(title !== undefined ? { title } : {}), + ...(metadata !== undefined ? { metadata } : {}), + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { + sessionId: target.sessionId, + updated: true, + ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), + ...(snapshot.title ? { title: snapshot.title } : {}), + }, + } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, updated: false }, + error: getErrorMessage(error, 'Failed to update Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was updated.' }, + updated: { type: 'boolean', description: 'True when the update was accepted.' }, + metadata: { type: 'json', description: 'Metadata after the update.', optional: true }, + title: { type: 'string', description: 'Title after the update.', optional: true }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 06fdf3eb441..329a51f793c 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2477,7 +2477,19 @@ import { mailgunListMessagesTool, mailgunSendMessageTool, } from '@/tools/mailgun' -import { managedAgentRunSessionTool } from '@/tools/managed_agent' +import { + managedAgentArchiveSessionTool, + managedAgentCreateSessionTool, + managedAgentDeleteSessionTool, + managedAgentGetSessionTool, + managedAgentInterruptSessionTool, + managedAgentListEventsTool, + managedAgentRespondCustomToolTool, + managedAgentRespondToolConfirmationTool, + managedAgentRunSessionTool, + managedAgentSendMessageTool, + managedAgentUpdateSessionTool, +} from '@/tools/managed_agent' import { mem0AddMemoriesTool, mem0GetMemoriesTool, mem0SearchMemoriesTool } from '@/tools/mem0' import { memoryAddTool, memoryDeleteTool, memoryGetAllTool, memoryGetTool } from '@/tools/memory' import { @@ -5511,7 +5523,17 @@ export const tools: Record = { mailgun_add_list_member: mailgunAddListMemberTool, mailgun_list_domains: mailgunListDomainsTool, mailgun_get_domain: mailgunGetDomainTool, + managed_agent_archive_session: managedAgentArchiveSessionTool, + managed_agent_create_session: managedAgentCreateSessionTool, + managed_agent_delete_session: managedAgentDeleteSessionTool, + managed_agent_get_session: managedAgentGetSessionTool, + managed_agent_interrupt_session: managedAgentInterruptSessionTool, + managed_agent_list_events: managedAgentListEventsTool, + managed_agent_respond_custom_tool: managedAgentRespondCustomToolTool, + managed_agent_respond_tool_confirmation: managedAgentRespondToolConfirmationTool, managed_agent_run_session: managedAgentRunSessionTool, + managed_agent_send_message: managedAgentSendMessageTool, + managed_agent_update_session: managedAgentUpdateSessionTool, sms_send: smsSendTool, jira_retrieve: jiraRetrieveTool, jira_update: jiraUpdateTool, diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 470884e3dce..2cc7c6e28dc 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -48,7 +48,7 @@ const grafanaTelemetry = grafanaFullyConfigured export default defineConfig({ project: env.TRIGGER_PROJECT_ID!, - runtime: 'node-22', + runtime: 'node-24', logLevel: 'log', maxDuration: 5400, retries: { diff --git a/bun.lock b/bun.lock index 1842309a2e6..7670781748c 100644 --- a/bun.lock +++ b/bun.lock @@ -121,9 +121,12 @@ "@sim/workflow-types": "workspace:*", "@socket.io/redis-adapter": "8.3.0", "drizzle-orm": "^0.45.2", + "lib0": "0.2.117", "postgres": "^3.4.5", "redis": "5.10.0", "socket.io": "^4.8.1", + "y-protocols": "1.0.7", + "yjs": "13.6.31", "zod": "4.3.6", }, "devDependencies": { @@ -228,6 +231,8 @@ "@tanstack/react-virtual": "3.13.24", "@tiptap/core": "3.26.1", "@tiptap/extension-code-block": "3.26.1", + "@tiptap/extension-collaboration": "3.26.1", + "@tiptap/extension-collaboration-caret": "3.26.1", "@tiptap/extension-image": "3.26.1", "@tiptap/extension-list": "3.26.1", "@tiptap/extension-placeholder": "3.26.1", @@ -237,7 +242,8 @@ "@tiptap/react": "3.26.1", "@tiptap/starter-kit": "3.26.1", "@tiptap/suggestion": "3.26.1", - "@trigger.dev/sdk": "4.4.3", + "@tiptap/y-tiptap": "3.0.7", + "@trigger.dev/sdk": "4.5.7", "@typescript/typescript6": "^6.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/addon-unicode11": "0.9.0", @@ -282,7 +288,9 @@ "jose": "6.0.11", "js-tiktoken": "1.0.21", "js-yaml": "4.3.0", + "jsdom": "^26.0.0", "jszip": "3.10.1", + "lib0": "0.2.117", "lru-cache": "11.3.6", "lucide-react": "^0.511.0", "mammoth": "^1.9.0", @@ -334,6 +342,8 @@ "undici": "7.28.0", "unpdf": "1.4.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "y-protocols": "1.0.7", + "yjs": "13.6.31", "zod": "4.3.6", "zustand": "^5.0.13", }, @@ -342,7 +352,7 @@ "@sim/tsconfig": "workspace:*", "@tailwindcss/typography": "0.5.19", "@testing-library/jest-dom": "^6.6.3", - "@trigger.dev/build": "4.4.3", + "@trigger.dev/build": "4.5.7", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", "@types/fluent-ffmpeg": "2.1.28", @@ -360,7 +370,6 @@ "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^4.1.0", - "jsdom": "^26.0.0", "postcss": "^8", "react-email": "6.9.0", "tailwindcss": "^3.4.1", @@ -533,6 +542,7 @@ "version": "0.1.0", "dependencies": { "@sim/db": "workspace:*", + "@sim/realtime-protocol": "workspace:*", "drizzle-orm": "^0.45.2", }, "devDependencies": { @@ -550,6 +560,7 @@ "devDependencies": { "@sim/tsconfig": "workspace:*", "typescript": "^7.0.2", + "vitest": "^4.1.0", }, }, "packages/runtime-secrets": { @@ -1439,7 +1450,7 @@ "@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg=="], - "@opentelemetry/host-metrics": ["@opentelemetry/host-metrics@0.37.0", "", { "dependencies": { "systeminformation": "5.23.8" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gf6nRFci0PTni9R1QQKjZ2uZE4Y6olLKhlwdM0qqLbbn3SBVKyP2jyBMiosBTHtRNLjY7s8hzQ44eLdK5wkGNQ=="], + "@opentelemetry/host-metrics": ["@opentelemetry/host-metrics@0.38.3", "", { "dependencies": { "systeminformation": "^5.31.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8iSOA8VPGoB5p/RIC8n/dcSe4cluCEWoznWENZfXR8sWQOQvergFu7v798xp7S5WQlZo1zfn1nVXx8dbyQ9m6Q=="], "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.217.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.217.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w=="], @@ -1707,7 +1718,7 @@ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], - "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.5", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ=="], + "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.10", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-dtm+oFHVE8szINwOUoNQdx9xpGSJOrcAEvsxspPFvomjYKGnmhIRmU4OX8o6kxcPoiK76S1tPeU0smjZdmOngA=="], "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], @@ -1931,6 +1942,10 @@ "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ=="], + "@tiptap/extension-collaboration": ["@tiptap/extension-collaboration@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1", "@tiptap/y-tiptap": "^3.0.5", "yjs": "^13" } }, "sha512-NLF3tWPla1bg9VAaICTrheEjDi9ZFGk1HhoALfVHWSwIqnUpVxSubyBxw/PFWyHYZNQrxaGvypf457NHHINolA=="], + + "@tiptap/extension-collaboration-caret": ["@tiptap/extension-collaboration-caret@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1", "@tiptap/y-tiptap": "^3.0.5" } }, "sha512-fJpIO8eXYksiTyxck3e7vS2Fsddo+sJNaQdNHXuDykVZzH4LPXAHyGGdSRrLM4aMUsK1IZh8CPyti1CHwbz7vA=="], + "@tiptap/extension-document": ["@tiptap/extension-document@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA=="], "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.26.1", "", { "peerDependencies": { "@tiptap/extensions": "3.26.1" } }, "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA=="], @@ -1983,15 +1998,17 @@ "@tiptap/suggestion": ["@tiptap/suggestion@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-Bg8IyuDC92InSPzcHvCT3+ZDCJSMJIEINdFg513RPQzwZTw1dsrU0K00XYcDT6lOhZwLM2IVTiE6sZl2GY25Rg=="], + "@tiptap/y-tiptap": ["@tiptap/y-tiptap@3.0.7", "", { "dependencies": { "lib0": "^0.2.100" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-3VG01F7i2JDghWsBZSBKi7ypMiN5UVVSyD18IwoXx7ilm0ZynrTS+XNpmgxfuiSBjdauWk7tUlP04xfM8Bw4Vw=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], - "@trigger.dev/build": ["@trigger.dev/build@4.4.3", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.4.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-t/hYmQiv2SdrUao9scoczrvfhyzSLkuT8DNyiBt9q29GKct37zytWyAo16hpN2Uf+yXh0EkdnkHbfR9odF0YtQ=="], + "@trigger.dev/build": ["@trigger.dev/build@4.5.7", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.5.7", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-rlUhq0ivhCHRahB60g1agmj3ZNaVfcATvv71WnNd+28D3fcCsDaIx8+CajdghiQpccZUjhzvR/rnLJQyK3GjQQ=="], - "@trigger.dev/core": ["@trigger.dev/core@4.4.3", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/exporter-logs-otlp-http": "0.203.0", "@opentelemetry/exporter-metrics-otlp-http": "0.203.0", "@opentelemetry/exporter-trace-otlp-http": "0.203.0", "@opentelemetry/host-metrics": "^0.37.0", "@opentelemetry/instrumentation": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "@opentelemetry/sdk-trace-node": "2.0.1", "@opentelemetry/semantic-conventions": "1.36.0", "@s2-dev/streamstore": "0.22.5", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "execa": "^8.0.1", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.8", "prom-client": "^15.1.0", "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" } }, "sha512-4srm2UGoDEcHO29Lqp4Isioq+b6au0EjW9/pjYmzOSxXqGPFDjPquK0BnKYGHyAbKYxuBx8wr2T/ru+zbY0/Jg=="], + "@trigger.dev/core": ["@trigger.dev/core@4.5.7", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.1", "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/exporter-logs-otlp-http": "0.218.0", "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", "@opentelemetry/exporter-trace-otlp-http": "0.218.0", "@opentelemetry/host-metrics": "^0.38.3", "@opentelemetry/instrumentation": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/sdk-trace-node": "2.7.1", "@opentelemetry/semantic-conventions": "1.41.1", "@s2-dev/streamstore": "0.22.10", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "execa": "^8.0.1", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.8", "prom-client": "^15.1.0", "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" } }, "sha512-Q5vI1zLtIxZMLq3LzdbrWNHtLkhyIvUtQ1toX0Bayn71RdMraTl3zhB95I/6LHuz7ZS7HSyO/zDy6Cs0gIm5rw=="], - "@trigger.dev/sdk": ["@trigger.dev/sdk@4.4.3", "", { "dependencies": { "@opentelemetry/api": "1.9.0", "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/core": "4.4.3", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", "evt": "^2.4.13", "slug": "^6.0.0", "ulid": "^2.3.0", "uncrypto": "^0.1.3", "uuid": "^9.0.0", "ws": "^8.11.0" }, "peerDependencies": { "ai": "^4.2.0 || ^5.0.0 || ^6.0.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai"] }, "sha512-ghJkak+PTBJJ9HiHMcnahJmzjsgCzYiIHu5Qj5R7I9q5LS6i7mkx169rB/tOE9HLadd4HSu3yYA5DrH4wXhZuw=="], + "@trigger.dev/sdk": ["@trigger.dev/sdk@4.5.7", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", "@trigger.dev/core": "4.5.7", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", "evt": "^2.4.13", "slug": "^6.0.0", "ulid": "^2.3.0", "uncrypto": "^0.1.3", "ws": "^8.11.0" }, "peerDependencies": { "@ai-sdk/otel": ">=1.0.0-0 <2", "ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8", "react": "^18.0 || ^19.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/otel", "ai", "react"] }, "sha512-aYUQ/U6wyf96Bkm/beJte4HPO5E5bWTWIxQkF4Hjrj7r2M0iOuCdYZ0OOhVwUn9OmqCkBshGbzWTXF4IzNhQ5Q=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.9.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A=="], @@ -3233,6 +3250,8 @@ "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], @@ -3309,6 +3328,8 @@ "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="], "libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="], @@ -4267,7 +4288,7 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "systeminformation": ["systeminformation@5.23.8", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Osd24mNKe6jr/YoXLLK3k8TMdzaxDffhpCxgkfgBHcapykIkd50HXThM3TCEuHO2pPuCsSx2ms/SunqhU5MmsQ=="], + "systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -4523,6 +4544,8 @@ "xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="], + "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], @@ -4535,6 +4558,8 @@ "yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="], + "yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], @@ -4901,31 +4926,17 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@trigger.dev/core/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - - "@trigger.dev/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ=="], - - "@trigger.dev/core/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], - - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.203.0", "@opentelemetry/otlp-transformer": "0.203.0", "@opentelemetry/sdk-logs": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-s0hys1ljqlMTbXx2XiplmMJg9wG570Z5lH7wMvrZX6lcODI56sG4HL03jklF63tBeyNwK2RV1/ntXGo3HgG4Qw=="], - - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.203.0", "@opentelemetry/otlp-transformer": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-metrics": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HFSW10y8lY6BTZecGNpV3GpoSy7eaO0Z6GATwZasnT4bEsILp8UJXNG5OmEsz4SdwCSYvyCbTJdNbZP3/8LGCQ=="], + "@trigger.dev/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.203.0", "@opentelemetry/otlp-transformer": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZDiaswNYo0yq/cy1bBLJFe691izEJ6IgNmkjm4C6kE9ub/OMQqDXORx2D2j8fzTBTxONyzusbaZlqtfmyqURPw=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/sdk-logs": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw=="], - "@trigger.dev/core/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-metrics": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-bV7d2OuMpZu2+gAaxUAhzfZ0h3WVZk8ETQUEE3DNSntbTaMpuITjtm8I0rNyHFdm7Ax57K6ty7SgFXlBmOLIvQ=="], - "@trigger.dev/core/@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw=="], - "@trigger.dev/core/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw=="], + "@trigger.dev/core/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg=="], - "@trigger.dev/core/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="], - - "@trigger.dev/core/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ=="], - - "@trigger.dev/core/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.1", "@opentelemetry/core": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-UhdbPF19pMpBtCWYP5lHbTogLWx9N0EBxtdagvkn5YtsAnCBZzL7SjktG+ZmupRgifsHMjwUaCCaVmqGfSADmA=="], - - "@trigger.dev/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="], + "@trigger.dev/core/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="], "@trigger.dev/core/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], @@ -4939,14 +4950,8 @@ "@trigger.dev/core/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "@trigger.dev/sdk/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - - "@trigger.dev/sdk/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="], - "@trigger.dev/sdk/cronstrue": ["cronstrue@2.61.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA=="], - "@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@types/archiver/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/babel__core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -5543,31 +5548,17 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "@trigger.dev/core/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - - "@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], - - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A=="], - - "@trigger.dev/core/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="], - - "@trigger.dev/core/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], - - "@trigger.dev/core/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "@trigger.dev/core/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "@trigger.dev/core/@opentelemetry/sdk-trace-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], "@trigger.dev/core/socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -5855,8 +5846,6 @@ "@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@trigger.dev/core/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], - "@trigger.dev/core/socket.io-client/engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], "@trigger.dev/core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.0.0", "", {}, "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="], diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 78e2f591597..1a4d19df80c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -23,6 +23,7 @@ services: - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index 43f5cdcbff8..e425cb3aa64 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -19,6 +19,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-sim_auth_secret_$(openssl rand -hex 16)} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-$(openssl rand -hex 32)} - COPILOT_API_KEY=${COPILOT_API_KEY} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=http://ollama:11434 - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fa187e0aa47..363422c3013 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,6 +32,7 @@ services: - INTERNAL_API_SECRET=${INTERNAL_API_SECRET} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 5a5c8e01cdb..c580bcd02e9 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -120,6 +120,16 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/content ./apps/sim/conte # Copy isolated-vm native module (compiled for Node.js in deps stage) COPY --from=deps --chown=nextjs:nodejs /app/node_modules/isolated-vm ./node_modules/isolated-vm +# The collab-doc seed/merge/persist routes run the converter (markdown <-> Yjs) server-side. `yjs` is a +# serverExternalPackage, and the Next standalone tracer copies it only partially — it misses ESM subpath +# files that `yjs/dist/yjs.mjs` imports through `lib0`'s exports map (e.g. `lib0/logging`), so the seed +# 500s ("Cannot find module 'lib0/logging'") and every collaborative doc is stuck read-only. Overwrite +# the partial trace with the complete packages from the full install (outputFileTracingIncludes can't: +# its globs resolve against apps/sim, but these deps hoist to the monorepo-root node_modules). +COPY --from=deps --chown=nextjs:nodejs /app/node_modules/lib0 ./node_modules/lib0 +COPY --from=deps --chown=nextjs:nodejs /app/node_modules/yjs ./node_modules/yjs +COPY --from=deps --chown=nextjs:nodejs /app/node_modules/y-protocols ./node_modules/y-protocols + # Copy the isolated-vm worker script COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-vm-worker.cjs ./apps/sim/lib/execution/isolated-vm-worker.cjs diff --git a/package.json b/package.json index fec0621c543..e6baf996389 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "skills:sync": "bun run scripts/sync-skills.ts", "skills:check": "bun run scripts/sync-skills.ts --check", "setup": "bun install && bun run scripts/setup/index.ts setup", - "sim": "bun run scripts/setup/index.ts", + "sim": "bun install && bun run scripts/setup/index.ts", "doctor": "bun run scripts/setup/index.ts doctor", "agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts", "agent-stream-docs:check": "bun run scripts/sync-agent-stream-docs.ts --check", diff --git a/packages/db/migrations/0278_charming_imperial_guard.sql b/packages/db/migrations/0278_charming_imperial_guard.sql new file mode 100644 index 00000000000..b3cb63bade6 --- /dev/null +++ b/packages/db/migrations/0278_charming_imperial_guard.sql @@ -0,0 +1,14 @@ +-- Both tables carry `workflow_id ... ON DELETE SET NULL` (migration 0212) with no index leading +-- on that column. Postgres implements SET NULL as an AFTER ROW referential trigger running +-- `UPDATE ... SET workflow_id = NULL WHERE workflow_id = $1` once per deleted parent row, so +-- every `DELETE FROM workflow` sequentially scans both tables per workflow — enough to blow the +-- statement timeout once the soft-delete retention job starts hard-deleting archived workflows. +-- Both indexes exist to make that trigger index-driven. +-- +-- Replay-safety: this file is only CONCURRENTLY index builds below an embedded COMMIT, so a +-- failure replays the whole file — both statements are idempotent. +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "execution_large_value_references_workflow_id_idx" ON "execution_large_value_references" USING btree ("workflow_id");--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "execution_large_values_workflow_id_idx" ON "execution_large_values" USING btree ("workflow_id");--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/0279_collab_doc_state_and_content_version.sql b/packages/db/migrations/0279_collab_doc_state_and_content_version.sql new file mode 100644 index 00000000000..0ff597bcc9c --- /dev/null +++ b/packages/db/migrations/0279_collab_doc_state_and_content_version.sql @@ -0,0 +1,9 @@ +CREATE TABLE "workspace_file_collab_state" ( + "file_id" text PRIMARY KEY NOT NULL, + "doc_state" "bytea" NOT NULL, + "source_hash" text NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_files" ADD COLUMN "content_updated_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace_file_collab_state" ADD CONSTRAINT "workspace_file_collab_state_file_id_workspace_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."workspace_files"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/migrations/meta/0278_snapshot.json b/packages/db/migrations/meta/0278_snapshot.json new file mode 100644 index 00000000000..51c02945c69 --- /dev/null +++ b/packages/db/migrations/meta/0278_snapshot.json @@ -0,0 +1,18315 @@ +{ + "id": "bf6b474b-067f-435c-b7c6-afad310421a3", + "prevId": "3944d26f-f7b1-48e8-87a5-fd9bc577e10a", + "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 + }, + "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 + }, + "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_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()" + } + }, + "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/0279_snapshot.json b/packages/db/migrations/meta/0279_snapshot.json new file mode 100644 index 00000000000..7f9a0e83316 --- /dev/null +++ b/packages/db/migrations/meta/0279_snapshot.json @@ -0,0 +1,18370 @@ +{ + "id": "4b619949-ee98-4251-b621-5f37a9fa23a3", + "prevId": "bf6b474b-067f-435c-b7c6-afad310421a3", + "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 + }, + "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 + }, + "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 d9cc85fe28e..30be907c184 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1940,6 +1940,20 @@ "when": 1785358812851, "tag": "0277_workspace_sandboxes", "breakpoints": true + }, + { + "idx": 278, + "version": "7", + "when": 1785536864363, + "tag": "0278_charming_imperial_guard", + "breakpoints": true + }, + { + "idx": 279, + "version": "7", + "when": 1785542556609, + "tag": "0279_collab_doc_state_and_content_version", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 6f872b2d3a2..87805620307 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -31,6 +31,16 @@ export const tsvector = customType<{ }, }) +/** Raw binary column. Postgres `bytea` ↔ Node `Buffer` (the pg driver handles the encoding). */ +export const bytea = customType<{ + data: Buffer + driverData: Buffer +}>({ + dataType() { + return 'bytea' + }, +}) + export const user = pgTable('user', { id: text('id').primaryKey(), name: text('name').notNull(), @@ -501,6 +511,12 @@ export const executionLargeValues = pgTable( tombstoneCleanupIdx: index('execution_large_values_tombstone_cleanup_idx') .on(table.workspaceId, table.deletedAt, table.key) .where(sql`${table.deletedAt} IS NOT NULL`), + /** + * Backs the `ON DELETE SET NULL` referential trigger, which runs + * `UPDATE ... WHERE workflow_id = $1` once per deleted workflow row and + * would otherwise sequentially scan this table each time. + */ + workflowIdIdx: index('execution_large_values_workflow_id_idx').on(table.workflowId), }) ) @@ -521,6 +537,8 @@ export const executionLargeValueReferences = pgTable( workspaceExecutionSourceIdx: index( 'execution_large_value_references_workspace_execution_source_idx' ).on(table.workspaceId, table.executionId, table.source), + /** Backs the `ON DELETE SET NULL` referential trigger — see `executionLargeValues`. */ + workflowIdIdx: index('execution_large_value_references_workflow_id_idx').on(table.workflowId), }) ) @@ -1896,6 +1914,17 @@ export const workspaceFiles = pgTable( deletedAt: timestamp('deleted_at'), uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), + /** + * Content-scoped version: advances ONLY when the file's CONTENT changes (upload / content + * overwrite), never on metadata writes (rename, move, soft-delete, restore). It is the + * optimistic-concurrency validator the collaborative-document persist guards on (RFC 7232 `If-Match` + * semantics: validate the representation, not the row) — so a rename can't make a racing live-doc + * persist see a stale token, reconcile stale durable content, and clobber in-flight edits. NOT NULL + * with a `now()` default: Postgres applies this as a fast-default (no table rewrite), existing rows + * get a stable timestamp that — like every metadata write — never advances it, and every insert path + * is covered without per-call plumbing. Only a content write (upload / overwrite) advances it. + */ + contentUpdatedAt: timestamp('content_updated_at').notNull().defaultNow(), }, (table) => ({ keyActiveUniqueIdx: uniqueIndex('workspace_files_key_active_unique') @@ -1932,6 +1961,26 @@ export const workspaceFiles = pgTable( }) ) +/** + * Cached collaborative-document state for a workspace markdown file: the last-persisted Yjs binary and + * a hash of the markdown it was derived from. On a cold room open the seed loads this binary directly + * (the Hocuspocus load-document pattern) rather than re-converting markdown → Yjs — which avoids the + * "recreate the CRDT from a non-binary format" anti-pattern (fresh client ids / content duplication on + * reconnect) and the server-side headless-editor conversion. The row is STALE, and the seed re-converts + * from markdown, when `sourceHash` no longer matches the file's current markdown (edited externally by a + * copilot write or a direct save). One row per file; dropped by FK cascade when the file is deleted. + */ +export const workspaceFileCollabState = pgTable('workspace_file_collab_state', { + fileId: text('file_id') + .primaryKey() + .references(() => workspaceFiles.id, { onDelete: 'cascade' }), + /** `Y.encodeStateAsUpdate` of the collaborative doc at last persist — apply with `Y.applyUpdate`. */ + docState: bytea('doc_state').notNull(), + /** sha256 (hex) of the markdown `docState` was derived from — the freshness tag for cold-start. */ + sourceHash: text('source_hash').notNull(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}) + /** * Public share links for workspace resources. Polymorphic on `resourceType` so a * single mechanism serves files now and folders later. One row per resource diff --git a/packages/platform-authz/package.json b/packages/platform-authz/package.json index fd989954ec1..d8d0bca5b03 100644 --- a/packages/platform-authz/package.json +++ b/packages/platform-authz/package.json @@ -21,6 +21,10 @@ "./workflow": { "types": "./src/workflow.ts", "default": "./src/workflow.ts" + }, + "./rooms": { + "types": "./src/rooms.ts", + "default": "./src/rooms.ts" } }, "scripts": { @@ -32,6 +36,7 @@ }, "dependencies": { "@sim/db": "workspace:*", + "@sim/realtime-protocol": "workspace:*", "drizzle-orm": "^0.45.2" }, "devDependencies": { diff --git a/packages/platform-authz/src/rooms.ts b/packages/platform-authz/src/rooms.ts new file mode 100644 index 00000000000..6d76fdceb4f --- /dev/null +++ b/packages/platform-authz/src/rooms.ts @@ -0,0 +1,169 @@ +import { db, userTableDefinitions, workspace, workspaceFiles } from '@sim/db' +import { ROOM_TYPES, type RoomRef, type RoomType } from '@sim/realtime-protocol/rooms' +import { and, eq, isNull } from 'drizzle-orm' +import { + type PermissionType, + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from './workspace' + +export type { PermissionType, RoomRef, RoomType } + +/** + * The owning workspace of a room, plus the org that owns that workspace — the + * exact inputs {@link resolveEffectiveWorkspacePermission} needs. + */ +export interface RoomWorkspace { + workspaceId: string + workspaceOrganizationId: string | null +} + +/** + * Resolves a room's owning workspace from its {@link RoomRef.id}. Returns `null` + * when the underlying resource is missing/archived (→ a 404 authorization + * result). One resolver per workspace-scoped {@link RoomType}; this is the single + * place a new such room type declares its resource→workspace lookup. + */ +export type RoomWorkspaceResolver = (roomId: string) => Promise + +async function resolveWorkspaceRoomWorkspace(workspaceId: string): Promise { + const [row] = await db + .select({ id: workspace.id, organizationId: workspace.organizationId }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + return row ? { workspaceId: row.id, workspaceOrganizationId: row.organizationId } : null +} + +/** + * Resolves a collaborative file-document room to its owning workspace. The room + * id is the file id; look up its (active) workspace file, then reuse the + * workspace resolver so archival is honored uniformly. Returns `null` when the + * file is missing/soft-deleted or is not workspace-scoped (copilot/chat uploads + * carry a null `workspaceId` and have no collaborative editor). + */ +async function resolveFileDocWorkspace(fileId: string): Promise { + const [file] = await db + .select({ workspaceId: workspaceFiles.workspaceId }) + .from(workspaceFiles) + .where(and(eq(workspaceFiles.id, fileId), isNull(workspaceFiles.deletedAt))) + .limit(1) + + if (!file?.workspaceId) return null + return resolveWorkspaceRoomWorkspace(file.workspaceId) +} + +/** + * Resolves a table presence room to its owning workspace. The room id is the + * table id; look up its (non-archived) definition, then reuse the workspace + * resolver so archival is honored uniformly. Returns `null` when the table is + * missing or archived. + */ +async function resolveTableWorkspace(tableId: string): Promise { + const [table] = await db + .select({ workspaceId: userTableDefinitions.workspaceId }) + .from(userTableDefinitions) + .where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))) + .limit(1) + + if (!table?.workspaceId) return null + return resolveWorkspaceRoomWorkspace(table.workspaceId) +} + +/** + * Maps each workspace-scoped room type to its resource→workspace lookup. These + * rooms authorize uniformly through {@link authorizeRoom}: resolve *which* + * workspace the room belongs to, then gate on effective workspace permission. + * + * Workflow is intentionally absent: it authorizes through its own dedicated path + * (`authorizeWorkflowByWorkspacePermission` + the realtime role cache in + * `middleware/permissions`) and never flows through {@link authorizeRoom}. A + * workflow ref reaching here is therefore an unknown type (400) by design. + * Adding a *workspace-scoped* room type = adding one entry here. + */ +const ROOM_WORKSPACE_RESOLVERS: Partial> = { + // A workspace-files room is addressed directly by its workspace id. + [ROOM_TYPES.WORKSPACE_FILES]: resolveWorkspaceRoomWorkspace, + // A workspace-tables room is addressed directly by its workspace id. + [ROOM_TYPES.WORKSPACE_TABLES]: resolveWorkspaceRoomWorkspace, + // A file-doc room is addressed by file id; resolve it to its workspace. + [ROOM_TYPES.WORKSPACE_FILE_DOC]: resolveFileDocWorkspace, + // A table room is addressed by table id; resolve it to its workspace. + [ROOM_TYPES.TABLE]: resolveTableWorkspace, +} + +export interface RoomAuthorizationResult { + allowed: boolean + status: number + message?: string + workspaceId: string | null + workspacePermission: PermissionType | null +} + +/** + * Authorizes a user against a workspace-scoped realtime room (workspace-files, + * file-doc, table). Mirrors `authorizeWorkflowByWorkspacePermission` (the + * exemplary workflow authorizer) but generalized over room type: resolve the + * room's workspace, then gate on the user's effective workspace permission under + * the read < write < admin ordering. Workflow rooms use their own authorizer and + * do not pass through here (see {@link ROOM_WORKSPACE_RESOLVERS}). + * + * Returns a denial (never throws) for unknown room type (400), missing/archived + * resource (404), and insufficient permission (403), so realtime handlers and + * SSE routes can map the `status` to a wire error uniformly. + */ +export async function authorizeRoom(params: { + userId: string + room: RoomRef + action?: PermissionType +}): Promise { + const { userId, room, action = 'read' } = params + + const resolver = ROOM_WORKSPACE_RESOLVERS[room.type] + if (!resolver) { + // Either an unknown type or one deliberately outside this authorizer (workflow authorizes via + // its own path); both are not-authorizable-here → 400. + return { + allowed: false, + status: 400, + message: `Room type not authorizable here: ${room.type}`, + workspaceId: null, + workspacePermission: null, + } + } + + const roomWorkspace = await resolver(room.id) + if (!roomWorkspace) { + return { + allowed: false, + status: 404, + message: 'Room not found', + workspaceId: null, + workspacePermission: null, + } + } + + const workspacePermission = await resolveEffectiveWorkspacePermission( + userId, + roomWorkspace.workspaceId, + roomWorkspace.workspaceOrganizationId + ) + + if (!permissionSatisfies(workspacePermission, action)) { + return { + allowed: false, + status: 403, + message: `Access denied to ${action} this room`, + workspaceId: roomWorkspace.workspaceId, + workspacePermission, + } + } + + return { + allowed: true, + status: 200, + workspaceId: roomWorkspace.workspaceId, + workspacePermission, + } +} diff --git a/packages/realtime-protocol/package.json b/packages/realtime-protocol/package.json index a4248404adc..e1995aae687 100644 --- a/packages/realtime-protocol/package.json +++ b/packages/realtime-protocol/package.json @@ -21,10 +21,24 @@ "./schemas": { "types": "./src/schemas.ts", "default": "./src/schemas.ts" + }, + "./rooms": { + "types": "./src/rooms.ts", + "default": "./src/rooms.ts" + }, + "./file-doc": { + "types": "./src/file-doc.ts", + "default": "./src/file-doc.ts" + }, + "./table-presence": { + "types": "./src/table-presence.ts", + "default": "./src/table-presence.ts" } }, "scripts": { "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", "format": "biome format --write .", @@ -35,6 +49,7 @@ }, "devDependencies": { "@sim/tsconfig": "workspace:*", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^4.1.0" } } diff --git a/packages/realtime-protocol/src/file-doc.test.ts b/packages/realtime-protocol/src/file-doc.test.ts new file mode 100644 index 00000000000..48fd7db5b8a --- /dev/null +++ b/packages/realtime-protocol/src/file-doc.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { FILE_DOC_TIMEOUTS } from './file-doc' + +describe('FILE_DOC_TIMEOUTS ordering invariants', () => { + it('bounds every nested call below the one that wraps it', () => { + // The relay's inner `/merge` fetch must finish before the app's outer `apply-edit` call gives up, + // or the relay applies a merge after the caller has already returned (racing a follow-on edit). + expect(FILE_DOC_TIMEOUTS.mergeRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.applyEditMs) + // The relay's `/seed` fetch must finish before the client's readiness deadline lapses into its + // read-only fallback, or a late-but-successful seed can never reach the client. + expect(FILE_DOC_TIMEOUTS.seedRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.readinessDeadlineMs) + }) +}) diff --git a/packages/realtime-protocol/src/file-doc.ts b/packages/realtime-protocol/src/file-doc.ts new file mode 100644 index 00000000000..3e50cf097df --- /dev/null +++ b/packages/realtime-protocol/src/file-doc.ts @@ -0,0 +1,176 @@ +/** + * Wire protocol for the collaborative file-document room + * ({@link ROOM_TYPES.WORKSPACE_FILE_DOC}). Live carets and text selection ride + * Yjs document sync + awareness over the shared Socket.IO connection. These are + * the event names, binary message tags, and join payloads that the server + * (`apps/realtime/src/handlers/file-doc.ts`) and the client provider + * (`apps/sim/.../file-doc`) must agree on exactly — the single source of truth + * for both sides so they can never drift. + * + * The binary channel uses the standard Yjs "websocket" framing: every + * {@link FILE_DOC_EVENTS.MESSAGE} payload is a `Uint8Array` whose first varUint + * is a {@link FILE_DOC_MESSAGE_TYPE} tag (sync protocol vs awareness protocol), + * so the client provider can reuse `y-protocols` verbatim. + */ + +/** Socket.IO event names for the file-document collaboration channel. */ +export const FILE_DOC_EVENTS = { + /** Client → server: join a file's collaborative session ({@link JoinFileDocPayload}). */ + JOIN: 'join-file-doc', + /** Server → client: join accepted ({@link JoinFileDocSuccess}). */ + JOIN_SUCCESS: 'join-file-doc-success', + /** Server → client: join rejected ({@link JoinFileDocError}). */ + JOIN_ERROR: 'join-file-doc-error', + /** Client → server: leave the session ({@link LeaveFileDocPayload}). */ + LEAVE: 'leave-file-doc', + /** Both directions: a framed Yjs message (binary), tagged by {@link FILE_DOC_MESSAGE_TYPE}. */ + MESSAGE: 'file-doc-message', + /** + * Server → client: the roster of collaborators currently in the document + * ({@link FileDocPresence}), for the avatar stack. Identity is server-authenticated (from + * each socket's session), so — unlike the client-set awareness `user` field — a peer + * cannot spoof or suppress another user's entry. Re-sent on every join and leave. + */ + PRESENCE: 'file-doc-presence', +} as const + +/** + * The tag carried in the first varUint of a {@link FILE_DOC_EVENTS.MESSAGE} + * payload — the standard Yjs websocket framing distinguishing a document-sync + * message from an awareness (cursor/selection) message. + */ +export const FILE_DOC_MESSAGE_TYPE = { + SYNC: 0, + AWARENESS: 1, + /** + * Client → server: a Yjs sync UPDATE (same framing as {@link FILE_DOC_MESSAGE_TYPE.SYNC}) that the + * server must apply and fan out to peers WITHOUT treating it as a durable user edit — no + * `schedulePersist`, no `edited`/`lastEditorUserId` bookkeeping. Used for agent-streamed frames: the + * copilot's final `edit_content` write is the authoritative durable persist, so the live stream must + * not also durably write partial content (attributed to the watching user). The server never sends + * this type; replies always use {@link FILE_DOC_MESSAGE_TYPE.SYNC}. + */ + SYNC_NO_PERSIST: 2, +} as const + +/** + * Where "the document's initial content has been seeded" is recorded, stored inside the Yjs + * document as `doc.getMap(configMap).get(flag) === true`. It lives in the CRDT so it merges across + * clients; the client reads it as half of its readiness gate (`synced && initialContentLoaded`). + * + * The server seeds authoritatively: it builds the document from the file's stored markdown and sets + * this flag in the SAME update, so a seeded document arrives content-and-flag together and the + * client needs no seed handshake. `configMap` is a reserved top-level Y.Map name; the editor must + * not use a top-level type of the same name (TipTap uses `getXmlFragment('default')`, no collision). + * + * Consumer (editor hook) contract: + * 1. **Never overwrite content with an unseeded doc.** The markdown-mirror autosave MUST be gated on + * the document being both synced AND seeded — otherwise an empty/still-syncing doc could be saved + * over the real file (the one true data-loss path). + * 2. **One provider per socket.** Destroy the previous provider before creating the next (document + * switch), so a stale provider's binary-frame listener can't apply another document's updates. + * 3. **Treat a fatal (`retryable: false`) join error as terminal.** Latch it and fall back to a + * read-only view of the file's stored content — do not keep rejoining. The server auto-reclaims a + * same-user client-id collision silently (the reconnecting socket succeeds), so `CLIENT_ID_IN_USE` + * surfaces to a client only for the rare case of a *different* user colliding on the same random + * Yjs `clientID`; a page reload mints a new id and recovers. + */ +export const FILE_DOC_SEED = { + configMap: 'config', + flag: 'initialContentLoaded', + /** + * The file's raw YAML frontmatter string, stored in the config map so it lives in the CRDT and + * merges across clients. The collaborative document body never contains frontmatter (it is stripped + * on seed); the editor re-attaches THIS value on autosave. Keeping it in the doc — rather than + * locked at open time — is what lets a server-side edit (e.g. copilot) that changes the frontmatter + * be reflected instead of reverted by an open editor's autosave. + */ + frontmatterKey: 'frontmatter', +} as const + +/** + * The timeouts for the file-doc conversion round-trips, in ONE place because two of the pairs form an + * ordering invariant that would otherwise live only in prose comments across two apps. A nested call's + * bound must exceed the bound of the call it wraps, or the outer aborts while the inner is still + * running — orphaning work that lands late (see the assertion in the accompanying test): + * + * - `applyEditMs` (app → relay `apply-edit`, in `apps/sim`) wraps `mergeRequestMs` (relay → app + * `/merge`, in `apps/realtime`), so `mergeRequestMs < applyEditMs`. + * - `readinessDeadlineMs` (the client's give-up-and-fall-back-read-only deadline) must outlast + * `seedRequestMs` (relay → app `/seed`), so `seedRequestMs < readinessDeadlineMs`. + * + * The seed request gets more headroom than the merge because it reads a (possibly cold) blob before + * converting; the merge is a pure in-memory conversion the caller fully supplies. + * + * `persistRequestMs` (relay → app `/persist`) stands alone — no client waits on it (the relay flushes + * the live doc to durable markdown debounced during editing and on the last collaborator leaving), so + * it forms no ordering invariant. It gets seed-level headroom because, like the seed, it crosses a + * durable blob write (Yjs → markdown → storage), not just an in-memory conversion. + */ +export const FILE_DOC_TIMEOUTS = { + seedRequestMs: 8_000, + mergeRequestMs: 3_000, + applyEditMs: 6_000, + readinessDeadlineMs: 12_000, + persistRequestMs: 8_000, +} as const + +/** Client → server join request. `fileId` is the `workspace_files.id`. */ +export interface JoinFileDocPayload { + fileId: string + /** + * The joining Yjs document's `clientID`. The server binds it to this socket so + * a client can only publish/remove awareness (cursor/selection) for its own + * client — an authenticated peer cannot forge or clear another's presence. + */ + clientId: number +} + +/** Server → client acceptance of a {@link FILE_DOC_EVENTS.JOIN}. */ +export interface JoinFileDocSuccess { + fileId: string +} + +/** Server → client rejection of a {@link FILE_DOC_EVENTS.JOIN}. */ +export interface JoinFileDocError { + fileId: string + error: string + code: string + retryable?: boolean +} + +/** Client → server leave request. */ +export interface LeaveFileDocPayload { + fileId: string +} + +/** One collaborator session in a {@link FileDocPresence} roster — server-authenticated identity. + * Keyed per socket (session), not per user: the client excludes its OWN `socketId` and then + * dedupes the rest per user for the avatar stack, so a second tab of the same account still + * registers as present (mirroring the canvas presence model). */ +export interface FileDocPresenceUser { + /** The collaborator's socket id, so a client can exclude its own session (not every session + * that happens to share its userId). */ + socketId: string + userId: string + userName: string + avatarUrl: string | null +} + +/** Server → client roster of who is in the document ({@link FILE_DOC_EVENTS.PRESENCE}). */ +export interface FileDocPresence { + fileId: string + users: FileDocPresenceUser[] +} + +/** + * Coerce a Socket.IO binary payload to a `Uint8Array`, or `null` if it is + * neither. Shared by the server relay and the client provider so the two agree + * on how an inbound {@link FILE_DOC_EVENTS.MESSAGE} frame is read (Socket.IO may + * deliver a `Uint8Array`/`Buffer` or an `ArrayBuffer` depending on runtime). + */ +export function toFileDocBytes(data: unknown): Uint8Array | null { + if (data instanceof Uint8Array) return data + if (data instanceof ArrayBuffer) return new Uint8Array(data) + return null +} diff --git a/packages/realtime-protocol/src/rooms.test.ts b/packages/realtime-protocol/src/rooms.test.ts new file mode 100644 index 00000000000..bb3651e9978 --- /dev/null +++ b/packages/realtime-protocol/src/rooms.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + ALL_ROOM_TYPES, + isRoomType, + parseRoomName, + presenceEventName, + ROOM_TYPES, + type RoomRef, + roomName, +} from './rooms' + +describe('roomName', () => { + it('maps a workflow room to its bare id (backward compatibility)', () => { + expect(roomName({ type: ROOM_TYPES.WORKFLOW, id: 'wf-123' })).toBe('wf-123') + }) + + it('namespaces every non-workflow room type', () => { + expect(roomName({ type: ROOM_TYPES.WORKSPACE_FILES, id: 'ws-123' })).toBe( + 'workspace-files:ws-123' + ) + expect(roomName({ type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-123' })).toBe( + 'workspace-file-doc:file-123' + ) + }) + + it('keeps the file-doc and file-browser namespaces distinct for the same id', () => { + // `workspace-file-doc` must not be parsed as the `workspace-files` browser + // room (or vice versa): the prefix match is the whole segment before `:`. + const id = 'a1b2c3d4-uuid' + const doc = roomName({ type: ROOM_TYPES.WORKSPACE_FILE_DOC, id }) + const browser = roomName({ type: ROOM_TYPES.WORKSPACE_FILES, id }) + expect(doc).not.toBe(browser) + expect(parseRoomName(doc)).toEqual({ type: ROOM_TYPES.WORKSPACE_FILE_DOC, id }) + expect(parseRoomName(browser)).toEqual({ type: ROOM_TYPES.WORKSPACE_FILES, id }) + }) + + it('never collides a namespaced room with a bare workflow id for real ids', () => { + // Room ids in Sim are opaque tokens without a colon (UUIDs / short ids), so a + // bare workflow id can never look like a `${type}:${id}` namespaced name. + const workflow = roomName({ type: ROOM_TYPES.WORKFLOW, id: 'a1b2c3d4-uuid' }) + const files = roomName({ type: ROOM_TYPES.WORKSPACE_FILES, id: 'a1b2c3d4-uuid' }) + expect(workflow).not.toBe(files) + expect(workflow.includes(':')).toBe(false) + }) +}) + +describe('parseRoomName', () => { + it('round-trips every room type through roomName', () => { + const refs: RoomRef[] = [ + { type: ROOM_TYPES.WORKFLOW, id: 'wf-123' }, + { type: ROOM_TYPES.WORKSPACE_FILES, id: 'ws-456' }, + { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-789' }, + { type: ROOM_TYPES.TABLE, id: 'table-abc' }, + ] + for (const ref of refs) { + expect(parseRoomName(roomName(ref))).toEqual(ref) + } + }) + + it('treats an unprefixed name as a workflow room', () => { + expect(parseRoomName('bare-uuid')).toEqual({ type: ROOM_TYPES.WORKFLOW, id: 'bare-uuid' }) + }) + + it('preserves ids that themselves contain colons', () => { + expect(parseRoomName('workspace-files:a:b:c')).toEqual({ + type: ROOM_TYPES.WORKSPACE_FILES, + id: 'a:b:c', + }) + }) + + it('does not treat an unknown prefix as a room type', () => { + expect(parseRoomName('unknown:x')).toEqual({ type: ROOM_TYPES.WORKFLOW, id: 'unknown:x' }) + }) + + it('returns null for the empty string', () => { + expect(parseRoomName('')).toBeNull() + }) +}) + +describe('isRoomType', () => { + it('accepts known types and rejects others', () => { + expect(isRoomType(ROOM_TYPES.WORKFLOW)).toBe(true) + expect(isRoomType(ROOM_TYPES.WORKSPACE_FILES)).toBe(true) + expect(isRoomType('nope')).toBe(false) + }) +}) + +describe('ALL_ROOM_TYPES', () => { + it('contains every declared room type', () => { + expect([...ALL_ROOM_TYPES].sort()).toEqual([...Object.values(ROOM_TYPES)].sort()) + }) +}) + +describe('presenceEventName', () => { + it('keeps the bare historical name for workflow and namespaces the rest', () => { + // Workflow keeps `presence-update` for client back-compat; every other type is namespaced so a + // socket in more than one room can demux its presence streams. + expect(presenceEventName(ROOM_TYPES.WORKFLOW)).toBe('presence-update') + expect(presenceEventName(ROOM_TYPES.TABLE)).toBe('table:presence-update') + expect(presenceEventName(ROOM_TYPES.WORKSPACE_FILES)).toBe('workspace-files:presence-update') + // No two types share an event name. + const names = ALL_ROOM_TYPES.map(presenceEventName) + expect(new Set(names).size).toBe(names.length) + }) +}) diff --git a/packages/realtime-protocol/src/rooms.ts b/packages/realtime-protocol/src/rooms.ts new file mode 100644 index 00000000000..afc3edde5a8 --- /dev/null +++ b/packages/realtime-protocol/src/rooms.ts @@ -0,0 +1,115 @@ +/** + * Room identity for the realtime layer. + * + * A {@link RoomRef} is the universal address shared by every realtime mechanism + * in Sim — the Socket.IO presence server (`apps/realtime`), the durable SSE + * event log, and the ephemeral pub/sub fanout. Each mechanism encodes a room + * differently on the wire, but they all agree on this `{ type, id }` identity + * and authorize it through the same workspace-permission resolver + * (`@sim/platform-authz/rooms`). + * + * This module is pure (no runtime dependencies) so both `apps/sim` and + * `apps/realtime` can import it. + */ + +/** + * The kinds of realtime room. Each value is a stable wire token — changing one + * is a breaking protocol change (it renames Socket.IO rooms and Redis keys), so + * treat these like enum values that ship to clients. + */ +export const ROOM_TYPES = { + /** The collaborative workflow editor canvas (one room per workflow). */ + WORKFLOW: 'workflow', + /** The workspace file browser (one room per workspace). */ + WORKSPACE_FILES: 'workspace-files', + /** + * A single collaborative file document — the rich-text editor for one file + * (one room per file). Carries Yjs document sync + awareness (live carets and + * text selection), so its id space is the file id, distinct from the + * workspace-scoped {@link ROOM_TYPES.WORKSPACE_FILES} browser room. + */ + WORKSPACE_FILE_DOC: 'workspace-file-doc', + /** + * A single table's grid (one room per table). Carries live cell-selection + * presence — which cells each viewer has selected — so its id space is the + * table id. + */ + TABLE: 'table', + /** + * The workspace tables browser (one room per workspace). The list-level + * counterpart to {@link ROOM_TYPES.TABLE}: it carries NO presence, only a + * lossy `workspace-tables-changed` invalidation signal so every viewer's + * tables list refetches when a table is created/renamed/moved/deleted. Its id + * space is the workspace id, mirroring {@link ROOM_TYPES.WORKSPACE_FILES}. + */ + WORKSPACE_TABLES: 'workspace-tables', +} as const + +export type RoomType = (typeof ROOM_TYPES)[keyof typeof ROOM_TYPES] + +/** Every known room type, for exhaustive iteration/validation. */ +export const ALL_ROOM_TYPES = Object.values(ROOM_TYPES) as readonly RoomType[] + +/** Universal address of a realtime room. */ +export interface RoomRef { + type: RoomType + id: string +} + +/** Type guard: whether an arbitrary string is a known {@link RoomType}. */ +export function isRoomType(value: string): value is RoomType { + return (ALL_ROOM_TYPES as readonly string[]).includes(value) +} + +/** + * The Socket.IO room name (and default key segment) for a room. + * + * `WORKFLOW` maps to the **bare id** — deliberately. The workflow editor has + * ~40 existing `io.to(workflowId)` / `socket.join(workflowId)` callsites that + * pass the bare workflow id, plus stale-cleanup that cross-references + * `io.in(workflowId).fetchSockets()` against Redis presence state. Preserving + * the bare name keeps every one of those callsites correct with zero diff and + * zero presence-state migration. Every *other* room type is namespaced + * (`${type}:${id}`) so a new id space can never collide with a workflow UUID. + * + * The inverse ({@link parseRoomName}) relies on this: an unprefixed name is a + * workflow, a prefixed name splits on the first `:`. + * + * Precondition: room ids are opaque tokens that never contain `:` — satisfied by + * every id in Sim (`generateId()` UUIDs, `generateShortId()` URL-safe tokens, + * workspace ids). This is what makes a bare workflow id unambiguous against a + * `${type}:${id}` namespace and keeps {@link parseRoomName} lossless. + */ +export function roomName(room: RoomRef): string { + return room.type === ROOM_TYPES.WORKFLOW ? room.id : `${room.type}:${room.id}` +} + +/** + * Inverse of {@link roomName}. A name carrying a known `${type}:` prefix parses + * to that type; any other (unprefixed) name is a {@link ROOM_TYPES.WORKFLOW} + * room whose id is the whole string — see {@link roomName} for why workflow is + * unprefixed. Returns `null` only for the empty string. + */ +export function parseRoomName(name: string): RoomRef | null { + if (!name) return null + + const separatorIndex = name.indexOf(':') + if (separatorIndex > 0) { + const maybeType = name.slice(0, separatorIndex) + if (isRoomType(maybeType) && maybeType !== ROOM_TYPES.WORKFLOW) { + return { type: maybeType, id: name.slice(separatorIndex + 1) } + } + } + + return { type: ROOM_TYPES.WORKFLOW, id: name } +} + +/** + * The `presence-update` broadcast event name for a room type. `WORKFLOW` keeps + * the historical bare `presence-update` name (client backward-compat); every + * other type is namespaced so a socket joined to more than one room can tell the + * presence streams apart on a single connection. + */ +export function presenceEventName(type: RoomType): string { + return type === ROOM_TYPES.WORKFLOW ? 'presence-update' : `${type}:presence-update` +} diff --git a/packages/realtime-protocol/src/table-presence.ts b/packages/realtime-protocol/src/table-presence.ts new file mode 100644 index 00000000000..45d06a94dc7 --- /dev/null +++ b/packages/realtime-protocol/src/table-presence.ts @@ -0,0 +1,103 @@ +/** + * Wire protocol for live table presence — which cell(s) each viewer has selected + * in a table grid. Carried over the shared, already-authenticated Socket.IO + * connection (the server relay is `apps/realtime/src/handlers/tables.ts`), + * separate from the one-way durable cell-status stream (`lib/table/events.ts`). + * + * Centralized here so the server emits and the client subscriptions cannot drift. + * This module is pure so both `apps/sim` and `apps/realtime` can import it. + */ + +/** Socket.IO event names for the table presence channel. */ +export const TABLE_PRESENCE_EVENTS = { + JOIN: 'join-table', + JOIN_SUCCESS: 'join-table-success', + JOIN_ERROR: 'join-table-error', + LEAVE: 'leave-table', + /** + * The sender's current cell selection. Sent client→server, then relayed + * server→peers with the sender's identity attached. + */ + CELL_SELECTION: 'table-cell-selection', +} as const + +/** + * A single cell address. Keyed by stable ids (never positional indices): row and + * column order differ per client under their own sort/filter, so an index would + * point at the wrong cell on the receiver. + */ +export interface TableCellRef { + rowId: string + columnId: string +} + +/** + * A viewer's grid selection: the `anchor` and `focus` corners of a rectangular + * range (a single-cell selection has `anchor` equal to `focus`). `null` clears + * the selection (the viewer has nothing selected). + */ +export type TableCellSelection = { + anchor: TableCellRef + focus: TableCellRef + /** + * True while the viewer is actively editing the `focus` cell (they double-clicked + * or started typing). Peers render the cell with a slightly darker fill — the + * Google-Sheets "someone is typing here" signal — on top of the color border. + */ + editing?: boolean +} | null + +/** Client→server join request for a table presence room. */ +export interface JoinTablePayload { + tableId: string + /** Stable per-tab id so a reconnecting tab replaces its own stale presence entry. */ + tabSessionId?: string +} + +/** Server→client rejection of a {@link TABLE_PRESENCE_EVENTS.JOIN}. */ +export interface JoinTableError { + tableId: string + error: string + code: + | 'AUTHENTICATION_REQUIRED' + | 'ROOM_MANAGER_UNAVAILABLE' + | 'INVALID_PAYLOAD' + | 'VERIFY_ACCESS_FAILED' + | 'NOT_FOUND' + | 'ACCESS_DENIED' + | 'JOIN_FAILED' + /** Whether re-attempting the join (e.g. after reconnect) could succeed. */ + retryable: boolean +} + +/** + * A remote viewer of a table, as carried in the join ack and every + * `table:presence-update` broadcast. `cell` is the viewer's current selection at + * broadcast time (absent until they select something). + */ +export interface TablePresenceUser { + socketId: string + userId: string + userName: string + avatarUrl?: string | null + cell?: TableCellSelection +} + +/** Server→client ack of a successful join, carrying the room's current viewers. */ +export interface JoinTableSuccess { + tableId: string + socketId: string + presenceUsers: TablePresenceUser[] +} + +/** + * A single remote viewer's cell-selection delta, relayed to peers on + * {@link TABLE_PRESENCE_EVENTS.CELL_SELECTION}. Lower-latency than a full presence + * broadcast for the frequent case of just moving the selection. Carries only the + * socket id + selection — peers already hold the viewer's identity (name, color) from + * the presence roster, so it is not repeated on this high-frequency channel. + */ +export interface TableCellSelectionBroadcast { + socketId: string + cell: TableCellSelection +} diff --git a/packages/realtime-protocol/vitest.config.ts b/packages/realtime-protocol/vitest.config.ts new file mode 100644 index 00000000000..471771e48fe --- /dev/null +++ b/packages/realtime-protocol/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 0ca0af53921..455c90f66c8 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -5,11 +5,16 @@ import { vi } from 'vitest' * Mimics drizzle-orm's sql tagged template. */ export function createMockSql() { - const sqlFn = (strings: TemplateStringsArray, ...values: any[]) => ({ - strings, - values, - toSQL: () => ({ sql: strings.join('?'), params: values }), - }) + const sqlFn = (strings: TemplateStringsArray, ...values: any[]) => { + const fragment = { + strings, + values, + toSQL: () => ({ sql: strings.join('?'), params: values }), + /** Mirrors drizzle's `sql``…`.as(alias)` for aliased select expressions. */ + as: (alias: string) => ({ ...fragment, alias }), + } + return fragment + } sqlFn.raw = (rawSql: string) => ({ rawSql, diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index d62fd102717..1b413fc52e3 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -3,8 +3,9 @@ import { vi } from 'vitest' /** * Mutable value-export state for the shared `@/lib/core/config/env-flags` mock. * Defaults mirror the real module evaluated under the vitest environment - * (NODE_ENV=test, no feature env vars set): only `isTest` and - * `isEmailPasswordEnabled` are true. + * (NODE_ENV=test, no feature env vars set): only `isTest`, + * `isEmailPasswordEnabled`, and `isChatEnabled` are true — the last because it + * is an opt-out flag, on unless `NEXT_PUBLIC_CHAT_DISABLED` is set. */ export interface EnvFlagsMockState { isProd: boolean @@ -13,6 +14,7 @@ export interface EnvFlagsMockState { isHosted: boolean isCopilotBillingAttributionV1Enabled: boolean isCopilotBillingProtocolRequired: boolean + isChatEnabled: boolean isCopilotToolPermissionsEnabled: boolean isBillingEnabled: boolean isEmailVerificationEnabled: boolean @@ -58,6 +60,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isHosted: false, isCopilotBillingAttributionV1Enabled: false, isCopilotBillingProtocolRequired: false, + isChatEnabled: true, isCopilotToolPermissionsEnabled: false, isBillingEnabled: false, isEmailVerificationEnabled: false, diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index e0803f29e05..edbb9fa4469 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1163,6 +1163,17 @@ export const schemaMock = { updatedAt: 'updatedAt', completedAt: 'completedAt', }, + tableViews: { + id: 'id', + tableId: 'tableId', + workspaceId: 'workspaceId', + name: 'name', + config: 'config', + isDefault: 'isDefault', + createdBy: 'createdBy', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, tableRowExecutions: { tableId: 'tableId', rowId: 'rowId', diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx index 4b0a3e50159..c66369f9e97 100644 --- a/packages/workflow-renderer/src/note/note-block-view.tsx +++ b/packages/workflow-renderer/src/note/note-block-view.tsx @@ -4,6 +4,7 @@ import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import { cn, handleKeyboardActivation } from '@sim/emcn' import { getEmbedInfo } from '@sim/utils/media-embed' +import { OverflowSpan } from '../lib/overflow-span' const EMBED_SCALE = 0.78 const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%` @@ -217,15 +218,13 @@ export function NoteBlockView({
- - {name} - + />
diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index 335a97b4591..f2eea5eef62 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -3,6 +3,7 @@ import { Badge, cn, handleKeyboardActivation } from '@sim/emcn' import { RepeatIcon, SplitIcon } from 'lucide-react' import { Handle, Position } from 'reactflow' import { HANDLE_POSITIONS } from '../dimensions' +import { OverflowSpan } from '../lib/overflow-span' import { tileIconColorClass } from '../lib/tile-icon-color' import type { BlockRunStatus, DiffStatus } from '../types' @@ -171,15 +172,13 @@ export function SubflowNodeView({ )} />
- - {blockName} - + />
{!isEnabled && disabled} diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx index 87c4ac824b4..9fac04f93f7 100644 --- a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx @@ -33,13 +33,14 @@ export function SubBlockRowView({ title, displayValue, isMonospace }: SubBlockRo className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize' /> {displayValue !== undefined && ( - + > + {displayValue} + )}
) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 6c84d43eca0..7c744ed7c6b 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1000, - zodRoutes: 1000, + totalRoutes: 1003, + zodRoutes: 1003, nonZodRoutes: 0, } as const @@ -24,8 +24,8 @@ const BOUNDARY_POLICY_BASELINE = { clientHookLocalSchemaConstructors: 0, clientHookRawFetches: 0, clientSameOriginApiFetches: 0, - doubleCasts: 8, - rawJsonReads: 6, + doubleCasts: 9, + rawJsonReads: 5, untypedResponses: 0, annotationsMissingReason: 0, } as const diff --git a/scripts/setup/cli-auth.ts b/scripts/setup/cli-auth.ts index 9ffba88255c..c9f3dfec9ee 100644 --- a/scripts/setup/cli-auth.ts +++ b/scripts/setup/cli-auth.ts @@ -76,7 +76,7 @@ export async function browserKeyFlow(origin: string): Promise { 'Confirm this code in your browser' ) p.log.info( - `Opening your browser — sign in and approve; the key comes back automatically.\n If it doesn't open: ${link(authUrl, authUrl)}` + `Opening your browser — create your account (or sign in) and approve; the key comes back automatically.\n If it doesn't open: ${link(authUrl, authUrl)}` ) openBrowser(authUrl) diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 6e07706f48d..711cbe77ffd 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -7,6 +7,7 @@ import { ensurePortsFree } from '../ports.ts' import { httpHealth, waitFor } from '../probes.ts' import * as p from '../prompter.ts' import { + chatFlagValues, collectSecrets, mothershipOverride, promptCopilotKey, @@ -112,6 +113,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom Object.assign(values, mothershipOverride()) const copilotKey = await promptCopilotKey(root.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey + Object.assign(values, chatFlagValues(copilotKey)) Object.assign(values, await promptLlmKeys(detection, !quick)) if (!quick) { const storage = await promptStorage(root.vars, true) diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index d0545e064ee..5364c165891 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -9,6 +9,7 @@ import { pgProbe } from '../probes.ts' import * as p from '../prompter.ts' import { ensureRedis, resolveRedis } from '../redis.ts' import { + chatFlagValues, collectSecrets, mothershipOverride, promptCopilotKey, @@ -124,6 +125,7 @@ export async function runDevMode( Object.assign(values, mothershipOverride()) const copilotKey = await promptCopilotKey(simAfter.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey + Object.assign(values, chatFlagValues(copilotKey)) Object.assign(values, await promptLlmKeys(detection, !quick)) // Redis is set up in every mode, quick included. Storage falls back to diff --git a/scripts/setup/modes/k8s.ts b/scripts/setup/modes/k8s.ts index 59d31effd7c..c76e1c6ba80 100644 --- a/scripts/setup/modes/k8s.ts +++ b/scripts/setup/modes/k8s.ts @@ -6,6 +6,7 @@ import { generateSecret, ROOT } from '../env-files.ts' import { SetupError } from '../errors.ts' import { waitFor } from '../probes.ts' import * as p from '../prompter.ts' +import { chatFlagValues, mothershipOverride, promptCopilotKey } from '../steps.ts' import { glyph, theme } from '../theme.ts' const APP_URL = 'http://localhost:3000' @@ -276,15 +277,28 @@ async function helmInstall( } } -function existingReleaseSecrets(context: string): Record | null { +interface ReleaseValues { + app?: { env?: Record } + postgresql?: { auth?: { password?: string } } +} + +function existingReleaseValues(context: string): ReleaseValues | null { const scope = ['--kube-context', context, '-n', NAMESPACE] const status = spawnSync('helm', ['status', RELEASE, ...scope], { stdio: 'ignore' }) if (status.status !== 0) return null - const values = JSON.parse( + return JSON.parse( run('helm', ['get', 'values', RELEASE, ...scope, '-o', 'json'], 'helm get values failed') - ) as { app?: { env?: Record }; postgresql?: { auth?: { password?: string } } } - const env = values.app?.env ?? {} - const password = values.postgresql?.auth?.password + ) as ReleaseValues +} + +/** + * The previous release's secrets, or `null` when any are missing — a partial set + * cannot be reused, since regenerating only some of them invalidates sessions + * and stored credentials encrypted under the originals. + */ +function reusableSecrets(values: ReleaseValues | null): Record | null { + const env = values?.app?.env ?? {} + const password = values?.postgresql?.auth?.password if ( !env.BETTER_AUTH_SECRET || !env.ENCRYPTION_KEY || @@ -323,7 +337,8 @@ export async function runK8sMode(detection: Detection): Promise { // credentials to an unintended cluster. const context = await ensureLocalContext(detection) - const reused = existingReleaseSecrets(context) + const releaseValues = existingReleaseValues(context) + const reused = reusableSecrets(releaseValues) const secrets = reused ?? { BETTER_AUTH_SECRET: generateSecret(), ENCRYPTION_KEY: generateSecret(), @@ -333,6 +348,21 @@ export async function runK8sMode(detection: Detection): Promise { } if (reused) p.log.step('Reusing secrets from the existing release') + // Before the key is minted: a half-set override mints against one environment + // and validates against the other, and warning afterwards is too late — the + // bad key is already deployed. + const overrides = mothershipOverride() + const copilotKey = await promptCopilotKey(releaseValues?.app?.env?.COPILOT_API_KEY) + + // `helm upgrade` without `--reuse-values` keeps only what this document + // carries, so a key the user chose to keep has to be re-supplied here. + const appEnv: Record = { + ...secrets, + ...overrides, + ...(copilotKey ? { COPILOT_API_KEY: copilotKey } : {}), + ...chatFlagValues(copilotKey), + } + const spin = p.spinner() spin.start('helm upgrade --install (first run pulls images — this can take several minutes)…') try { @@ -355,7 +385,7 @@ export async function runK8sMode(detection: Detection): Promise { '--timeout', '15m', ], - secretValues(secrets), + secretValues(appEnv), context, spin ) diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index 457c830675b..1d3edf6afb9 100644 --- a/scripts/setup/steps.ts +++ b/scripts/setup/steps.ts @@ -57,17 +57,33 @@ export async function promptCopilotKey(existing?: string): Promise