Skip to content

improvement(copilot): make copilot_messages the sole transcript store, remove JSONB dual-write#4826

Open
waleedlatif1 wants to merge 1 commit into
stagingfrom
waleedlatif1/copilot-drop-dual-write
Open

improvement(copilot): make copilot_messages the sole transcript store, remove JSONB dual-write#4826
waleedlatif1 wants to merge 1 commit into
stagingfrom
waleedlatif1/copilot-drop-dual-write

Conversation

@waleedlatif1
Copy link
Copy Markdown
Collaborator

Summary

  • Stop reading and writing the legacy copilot_chats.messages JSONB column now that all transcript reads are cut over to the normalized copilot_messages table.
  • Make appendCopilotChatMessages / replaceCopilotChatMessages the primary store and throw on failure (previously best-effort/swallowed — a swallowed write now means permanent message loss).
  • Persist the assistant turn inside finalizeAssistantTurn's transaction so it commits atomically with the stream-marker clear (closes the "marker cleared but message lost" window).
  • Repoint every remaining direct JSONB reader to copilot_messages: workspace VFS task materialization, chat-cleanup file collection, data-drains export (batched per-page assembly, no N+1), chat fork, and superuser workflow import.
  • Renamed messages-dual-write.tsmessages-store.ts (no longer "dual"); dropped the now-impossible user-existence dedup check in finalizeAssistantTurn.
  • Column drop (ALTER TABLE copilot_chats DROP COLUMN messages) + reconcile-script removal are intentionally deferred to a follow-up migration PR so this can bake with a safe rollback.

Type of Change

  • Improvement / refactor

Testing

  • 126 unit tests pass across lib/copilot/chat, lib/cleanup, app/api/copilot/chat, app/api/mothership.
  • tsc --noEmit clean (0 errors), biome clean, check:api-validation passes.
  • DB-verified on staging: transcripts in copilot_messages are byte-identical and correctly ordered; the JSONB column is no longer written.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…, remove JSONB dual-write

Stop writing/reading the legacy copilot_chats.messages JSONB column now that
reads are cut over to copilot_messages. Make appendCopilotChatMessages the
primary write (throws on failure instead of swallowing), repoint peripheral
readers (workspace VFS, chat cleanup, data drains, fork, superuser import) to
copilot_messages, and persist the assistant turn inside finalizeAssistantTurn's
transaction so it commits atomically with the stream-marker clear. The column
itself is dropped in a follow-up migration after this bakes.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped May 31, 2026 4:53am

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 31, 2026

PR Summary

Medium Risk
Changes core chat persistence and failure semantics (writes now fail the request on store errors); assistant finalization and dedup logic moved to transactions against copilot_messages, though behavior is covered by extensive test updates.

Overview
This PR completes cutover of copilot chat transcripts from the legacy copilot_chats.messages JSONB column to copilot_messages only. Writes no longer touch JSONB; reads that still used the blob (lifecycle, fork, import, cleanup, VFS tasks, data drains) now load or assemble transcripts from normalized rows.

messages-dual-write.ts is replaced by messages-store.ts: appendCopilotChatMessages and replaceCopilotChatMessages are the authoritative API and propagate DB failures instead of swallowing them, so a failed write cannot silently diverge from what clients see.

Turn finalization is tighter: finalizeAssistantTurn dedupes via an assistant row on streamId under FOR UPDATE, clears conversationId on the chat row only, and appends the assistant message inside the same transaction (optional executor on append). User turns on chat POST set the stream marker on copilot_chats and append via messages-store before the SSE stream opens.

Chat create/fork/import inserts omit messages; snapshots go through replace/append on the new table. Tests are updated to assert copilot_messages calls and no JSONB messages in updates. Dropping the JSONB column is deferred to a follow-up migration.

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 8 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

if (updated) {
// `copilot_messages` is the sole store — propagate failures so a lost
// user message fails the request before the stream opens, rather than
// silently dropping the turn.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User persist lacks transaction

High Severity

persistUserMessage sets conversationId on copilot_chats in one statement, then calls appendCopilotChatMessages separately. If the append fails after the update commits, the chat shows an active stream with no user row in copilot_messages, which can block later turns and lose the message despite the HTTP error.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

})
}
} catch (err) {
logger.warn('Failed to persist chat messages', {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inbox swallows message failures

High Severity

Background inbox persistence still catches all errors after only bumping updatedAt, but it no longer writes the legacy JSONB transcript. When appendCopilotChatMessages throws, the handler logs and returns while the orchestration may already have completed, so the task transcript can be permanently lost with no surfaced failure.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

// Update chat metadata (plan artifact / config); the transcript itself is
// written to copilot_messages via replaceCopilotChatMessages below.
const updateData: Record<string, unknown> = {
messages: normalizedMessages,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update metadata before messages

Medium Severity

The route commits planArtifact, config, and updatedAt on copilot_chats before replaceCopilotChatMessages. If replace throws, the handler returns 500 but metadata changes remain while the normalized transcript may be unchanged or partially updated, diverging client state from the database.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

[assistantMessage],
{ streamId: userMessageId, chatModel },
tx
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finalize misses existing assistant

High Severity

finalizeAssistantTurn treats a turn as already answered only when an assistant row has stream_id equal to the user message id. Assistants saved earlier via replaceCopilotChatMessages (e.g. update-messages) keep a null stream_id, so finalize can append a second assistant for the same user turn.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

})
}

return messagesAfter
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completion swallows finalize errors

High Severity

buildOnComplete catches all errors from finalizeAssistantTurn and only logs them. After moving the assistant write into the finalize transaction, a failed appendCopilotChatMessages rolls back the marker clear and assistant insert, leaving conversation_id set while the stream appears finished to the client.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

type: parent.type,
title,
model: parent.model,
messages: forkedMessages,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fork leaves empty chat

Medium Severity

Fork inserts the new copilot_chats row first, then calls appendCopilotChatMessages for the sliced transcript. If the append throws, the handler returns 500 but the new chat row remains with no copilot_messages, producing an empty forked task.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

if (imported && Array.isArray(chat.messages) && chat.messages.length > 0) {
await appendCopilotChatMessages(imported.id, chat.messages as PersistedMessage[], {
if (imported && sourceMessages.length > 0) {
await appendCopilotChatMessages(imported.id, sourceMessages, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import chat missing transcript

Medium Severity

Each imported copilot chat is inserted before appendCopilotChatMessages runs. If append fails for one chat, the route errors out while earlier chats may already be committed, and the failing chat row can remain without any rows in copilot_messages.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

})
}

return messagesAfter
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error path swallows finalize

High Severity

When orchestration fails, buildOnError calls finalizeAssistantTurn to clear conversation_id, but its catch only logs failures. If finalize now throws (e.g. copilot_messages write fails inside the transaction), the marker stays set and later requests can hit a stuck in-progress stream even though the error path already ran.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a0d4c. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 31, 2026

Greptile Summary

This PR completes the cutover from copilot_chats.messages (JSONB) to the normalized copilot_messages table as the sole transcript store. The dual-write module is deleted, store writes now throw on failure, and the assistant turn is persisted atomically inside finalizeAssistantTurn's FOR UPDATE transaction to close the previous "marker cleared but message lost" window.

  • messages-store.ts (renamed from messages-dual-write.ts): removes the try/catch error-swallowing so callers propagate failures; adds an executor parameter so finalizeAssistantTurn can enlist the write in its existing transaction.
  • All JSONB readers updated: workspace-vfs, chat-cleanup, data-drains (via a clean batched query + in-memory join), fork, and import-workflow all now read from copilot_messages. The dedup logic in finalizeAssistantTurn is rewritten to query copilot_messages by streamId under the row lock instead of scanning the JSONB array.
  • workspace-vfs.ts uses two correlated subqueries per outer row while data-drains in the same PR uses the more efficient batched approach; and import-workflow introduces N sequential loadCopilotChatMessages calls in its chat loop where one batched read would suffice.

Confidence Score: 4/5

The core migration is sound: the atomic transaction in finalizeAssistantTurn closes a real data-loss window, error propagation is correct, and the dedup logic correctly relies on the row-level lock. The two remaining concerns are performance-shaped rather than correctness-shaped.

The critical paths (stream finalization, user-message persistence, cleanup) are all correctly migrated with appropriate error handling and transactional guarantees. The workspace-vfs correlated subqueries and the import-workflow N+1 sequential reads are both non-blocking quality issues that won't cause incorrect behavior, just potential slowness under load.

apps/sim/lib/copilot/vfs/workspace-vfs.ts (two correlated subqueries per row) and apps/sim/app/api/superuser/import-workflow/route.ts (sequential loadCopilotChatMessages per chat in loop)

Important Files Changed

Filename Overview
apps/sim/lib/copilot/chat/messages-store.ts New sole-store module replacing messages-dual-write.ts; removes try/catch error-swallowing so callers must handle failures. Logic is identical to the deleted file except errors now propagate, and a new optional executor parameter lets callers enlist the write in an existing transaction.
apps/sim/lib/copilot/chat/terminal-state.ts Key change: assistant-dedup check now queries copilot_messages by streamId=userMessageId under the FOR UPDATE lock, replacing the JSONB array scan. The stream-marker clear and appendCopilotChatMessages are now inside the same transaction for atomicity. StaleUserMessage outcome removed (no longer applicable without JSONB position tracking).
apps/sim/lib/copilot/chat/lifecycle.ts loadCopilotChatMessages exported with full canonical seq/created_at/id ordering; copilotChatDetailReturningColumns removed; fresh chat insert no longer writes messages:[]; return object synthetically adds messages:[] for consumers that expect the field.
apps/sim/lib/copilot/chat/post.ts persistUserMessage return type changed from unknown[] to void; JSONB append removed from the UPDATE; conversationHistory now stays as loaded by resolveOrCreateChat (pre-turn snapshot) rather than being rebuilt from the persisted messages result.
apps/sim/lib/copilot/vfs/workspace-vfs.ts messageCount and messages columns replaced with correlated subqueries against copilot_messages. Logically correct but uses two separate scans per outer row; data-drains in the same PR uses a more efficient batched approach.
apps/sim/lib/data-drains/sources/copilot-chats.ts Transcript assembly moved from JSONB column to a single batched copilot_messages query per page with in-memory grouping — clean no-N+1 implementation.
apps/sim/app/api/superuser/import-workflow/route.ts Import now reads transcripts from copilot_messages via loadCopilotChatMessages. The import loop adds one extra sequential DB round-trip per chat vs. the previous single-SELECT approach, introducing an N+1 read pattern for multi-chat workflow imports.
apps/sim/lib/cleanup/chat-cleanup.ts File-key collection switches from iterating JSONB messages arrays to querying copilot_messages rows directly; deletedAt filter correctly added; logic is otherwise unchanged.
apps/sim/lib/mothership/inbox/executor.ts JSONB concat removed from persistChatMessages UPDATE; appendCopilotChatMessages now called unconditionally (on updated != null); log level promoted from warn to error on failure.
apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts Fork transcript source changed from parent.messages JSONB to loadCopilotChatMessages; explicit column projection added to avoid selecting the legacy messages column; logic otherwise unchanged.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Post as post.ts
    participant TS as terminal-state.ts
    participant MS as messages-store.ts
    participant DB as PostgreSQL

    Client->>Post: POST /copilot/chat
    Post->>DB: "UPDATE copilot_chats SET conversationId=userMsgId"
    Post->>MS: "appendCopilotChatMessages(userMsg, streamId=userMsgId)"
    MS->>DB: SELECT MAX(seq) FROM copilot_messages
    MS->>DB: INSERT INTO copilot_messages ON CONFLICT DO UPDATE

    Note over Client,DB: Stream completes

    Post->>TS: finalizeAssistantTurn(chatId, userMessageId, assistantMsg)
    TS->>DB: BEGIN TRANSACTION
    DB-->>TS: SELECT copilot_chats FOR UPDATE
    TS->>DB: "SELECT copilot_messages WHERE streamId=userMsgId AND role=assistant"
    alt No existing assistant
        TS->>DB: "UPDATE copilot_chats SET conversationId=NULL"
        TS->>MS: "appendCopilotChatMessages(assistantMsg, executor=tx)"
        MS->>DB: SELECT MAX(seq) inside tx
        MS->>DB: INSERT INTO copilot_messages inside tx
        DB-->>TS: COMMIT (marker clear + message insert atomic)
    else Assistant already persisted
        TS->>DB: "UPDATE copilot_chats SET conversationId=NULL"
        DB-->>TS: COMMIT (marker clear only)
    end
Loading

Comments Outside Diff (2)

  1. apps/sim/lib/copilot/vfs/workspace-vfs.ts, line 1648-1684 (link)

    P2 Two separate correlated subqueries per outer row

    Both messageCount and messages are inline correlated subqueries against copilot_messages. PostgreSQL evaluates each one for every row in the outer result. Under the same chat_id index these are cheap, but they still represent two independent index seeks per row — unlike the old JSONB read which was a single column detoast.

    The data-drains source in this same PR correctly avoids this by issuing one batched IN (chat_ids) query and grouping in JS. A lateral join or a single correlated CTE that computes both COUNT and the aggregated array in one scan would halve the number of sub-scans here.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. apps/sim/app/api/superuser/import-workflow/route.ts, line 507-529 (link)

    P2 N+1 sequential reads introduced for multi-chat imports

    Previously chat.messages came from the same SELECT that fetched sourceCopilotChats, so the entire import was one query per page. Now loadCopilotChatMessages(chat.id) is awaited serially inside the loop, adding one extra round-trip per chat before each insert. For a workflow with many copilot chats this serialises what could be a single batched read.

    Consider loading all transcripts with one IN (chat_ids) query before the loop (the same approach data-drains/sources/copilot-chats.ts uses in this PR) to avoid the regression.

Reviews (1): Last reviewed commit: "improvement(copilot): make copilot_messa..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant