feat(mothership): Chat-scoped outputs/ folder in VFS + Fork/Duplicate Chat#5401
feat(mothership): Chat-scoped outputs/ folder in VFS + Fork/Duplicate Chat#5401j15z wants to merge 14 commits into
Conversation
Adds Duplicate to the chat context menu. POST /api/mothership/chats/ [chatId]/duplicate clones the chat row, all messages, and the chat-owned files (uploads + outputs) under new ids/keys, rewriting every in-transcript file reference (attachment chips, embedded serve/view URLs, context chips, file resources) so the copy survives deletion of the original. Copied bytes are quota-checked up front and counted on success — deliberately diverging from the workspace-fork precedent (see comment at the increment site). Agent-side conversation state clones best-effort via the Go fork endpoint's new whole-chat mode. Duplicating navigates into the copy, titled "<name> (Copy)". Also contract-binds the vfs outputs route (surfaced by check:api-validation): listChatOutputsContract, storageContext enum + folderId alignment, and useChatOutputs upgraded from raw fetch to requestJson. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidebar Duplicate action is replaced by a Fork button on each assistant
reply (next to feedback). Forking copies the conversation up to and including
the clicked message, plus the chat's uploads born at-or-before that point:
each copy gets a fresh row id and storage key, the same message_id, physically
copied bytes counted against the storage quota, and every in-transcript file
reference re-pointed at the copies. Agent outputs/ stay behind.
- workspace_files gains a nullable message_id provenance column (drizzle
migration 0254); trackChatUpload stamps it from the sending user message
- fork route gains the quota gate + file copy + reference rewrite, reusing
the machinery built for duplicate (fork-chat-files.ts, rewrite helper)
- materialize_file nulls message_id alongside chatId
- duplicate route/contract/hook/tests removed; sidebar Duplicate reverted to
its pre-branch disabled state (showDuplicate={false})
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clicking a #wsres-file outputs/ link minted a resource with an empty id (the outputs lookup ran with the route chatId, which stays undefined on the home surface), which was persisted and attached to the next send. The chat POST then 400'd on attachment validation before creating a run, and the send's catch "recovered" by reconnecting to its own never- registered stream id — 10 backoff retries against stream_not_found, ~3 minutes of stuck "running" UI with the real error swallowed. - resolve outputs/ file links with the stream-resolved chat id, and drop file resources that still have no id after resolution - reject empty-id resources in addResource, hydration merge, and the send's resourceAttachments - only retry-reconnect when the stream actually started; a failed POST now rolls back the optimistic send and surfaces the error - treat resume 404 (stream_not_found) as terminal instead of retrying - require min(1) resource ids in the add/reorder contracts (remove stays permissive so legacy empty-id rows can be deleted) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryHigh Risk Overview Fork and duplicate share one route: optional Authorization fixes a cross-member leak: Supporting fixes: reject empty resource ids on add/send/reorder, roll back optimistic sends when the chat POST never starts a stream, treat stream resume 404 as terminal, stamp uploads with Reviewed by Cursor Bugbot for commit 4abf5d8. Bugbot is set up for automated code reviews on this repo. Configure here. |
Greptile SummaryThis PR adds two related capabilities on the chat/VFS surface: a chat-scoped
Confidence Score: 5/5Safe to merge after the deploy note is actioned: verify no duplicate (chat_id, display_name) output rows exist before migration 0255 runs. The fork/duplicate engine, outputs/ namespace, and authorization changes are all well-structured and internally consistent. The security-sensitive paths — output file ownership enforcement, quota gating, write-once rejection, and the chat-id FK guard for non-interactive runs — are each applied at the correct layer and covered by tests. The two migrations are additive and backward-compatible; the CONCURRENTLY index is properly sequenced with an explicit COMMIT. Test coverage is comprehensive (107 files, 1067 tests) and the PR self-documents all deliberate trade-offs. The deploy note warrants attention before applying migration 0255 in production: a one-off query checking for existing duplicate (chat_id, display_name) output rows must run first, or the CONCURRENTLY build will leave an INVALID index. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant ForkRoute as fork/route.ts
participant DB as Database (tx)
participant BlobStore as Storage
participant GoService as Copilot Service (Go)
Client->>ForkRoute: "POST /chats/[id]/fork {upToMessageId?}"
Note over ForkRoute: Determine mode:<br/>upToMessageId → branch fork<br/>omitted → whole-chat duplicate
ForkRoute->>DB: listDuplicableChatFiles (mothership + output)
DB-->>ForkRoute: chatOwnedFiles
ForkRoute->>ForkRoute: filterForkableChatFiles (branch) or use all (duplicate)
ForkRoute->>ForkRoute: checkStorageQuota(totalBytes)
ForkRoute->>DB: BEGIN TRANSACTION
DB->>DB: INSERT copilotChats (new chat row)
DB->>DB: planChatFileCopies → INSERT workspace_files (fresh ids + keys)
DB->>DB: rewriteResourceFileRefs → UPDATE copilotChats.resources
DB->>DB: appendCopilotChatMessages (rewritten transcript)
DB->>DB: COMMIT
ForkRoute->>BlobStore: "executeChatFileBlobCopies (concurrency=4)"
Note over BlobStore: headObject replay guard → skip if already exists<br/>downloadFile → uploadFile → incrementStorageUsage
alt blob copies failed
ForkRoute->>DB: DELETE workspace_files WHERE id IN failedCopyIds
ForkRoute->>DB: removeChatResources (drop dead chips)
end
ForkRoute->>GoService: "POST /api/chats/fork {sourceChatId, newChatId, upToMessageId?}"
Note over GoService: Whole-chat: preserves compacted memory verbatim<br/>Branch: truncate-and-rebuild path
ForkRoute-->>Client: "{success, id: newChatId, failedFileCopies?}"
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client
participant ForkRoute as fork/route.ts
participant DB as Database (tx)
participant BlobStore as Storage
participant GoService as Copilot Service (Go)
Client->>ForkRoute: "POST /chats/[id]/fork {upToMessageId?}"
Note over ForkRoute: Determine mode:<br/>upToMessageId → branch fork<br/>omitted → whole-chat duplicate
ForkRoute->>DB: listDuplicableChatFiles (mothership + output)
DB-->>ForkRoute: chatOwnedFiles
ForkRoute->>ForkRoute: filterForkableChatFiles (branch) or use all (duplicate)
ForkRoute->>ForkRoute: checkStorageQuota(totalBytes)
ForkRoute->>DB: BEGIN TRANSACTION
DB->>DB: INSERT copilotChats (new chat row)
DB->>DB: planChatFileCopies → INSERT workspace_files (fresh ids + keys)
DB->>DB: rewriteResourceFileRefs → UPDATE copilotChats.resources
DB->>DB: appendCopilotChatMessages (rewritten transcript)
DB->>DB: COMMIT
ForkRoute->>BlobStore: "executeChatFileBlobCopies (concurrency=4)"
Note over BlobStore: headObject replay guard → skip if already exists<br/>downloadFile → uploadFile → incrementStorageUsage
alt blob copies failed
ForkRoute->>DB: DELETE workspace_files WHERE id IN failedCopyIds
ForkRoute->>DB: removeChatResources (drop dead chips)
end
ForkRoute->>GoService: "POST /api/chats/fork {sourceChatId, newChatId, upToMessageId?}"
Note over GoService: Whole-chat: preserves compacted memory verbatim<br/>Branch: truncate-and-rebuild path
ForkRoute-->>Client: "{success, id: newChatId, failedFileCopies?}"
Reviews (5): Last reviewed commit: "fix(db): build the 0255 output-name inde..." | Re-trigger Greptile |
a03ff62 to
e4f59b3
Compare
|
@cursor review |
5615eff to
9d8756c
Compare
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 9d8756c. Configure here.
Address the 8 findings from the chat-scoped-outputs review: - Reject outputs/ + overwrite in BOTH interactive and headless modes; the headless files/ redirect can no longer silently replace a same-named workspace file - Resolve outputs/ and uploads/ refs — and bare wf_ ids via a new chat-scoped by-id fallback — in open_resource, table import, and knowledge add-document; presigning and the background CSV import honor the record's storage context (TableImportPayload.fileContext) - Branch forks copy outputs like uploads: outputs are stamped with the requesting user message id and join the fork timeline cut, so inline embeds are re-pointed and the fork survives source-chat deletion - create_file guards the RAW fileName before files/ prefixing, so "outputs/notes.md" can no longer create a literal files/outputs/ folder - Partial unique index on (chat_id, display_name) WHERE context='output' (migration 0255) + 23505 retry in uploadChatOutput, closing the concurrent same-name generation race - EmbeddedFile and EmbeddedFileActions share one resolver hook (list → by-id → chat outputs by leaf name), fixing dead Download/Open buttons on path-referenced outputs - Consolidate the upload/output readers into one context-parameterized chat-file-reader (~200 duplicated lines removed) and share isOutputsPath/leaf helpers across the five inline call sites - Fork blob copies run through a bounded pool with a headObject replay guard (no double-copy or double-charge on retry); the fork route reads workspace_files once and applies the branch cut in memory Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also document the fork-rewrite timeline invariant on rewriteMessageFileRefs: a kept message can only reference chat-owned files born at-or-before the cut, which are always in the rewrite maps — so unmapped pass-through cannot leave the fork pointing at uncopied chat-owned files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
workspace_files is an existing (hot) table, so the plain CREATE UNIQUE INDEX that drizzle-kit generated would hold ACCESS EXCLUSIVE for the whole build. Rewritten to the runner convention: COMMIT breakpoint, lock_timeout 0, CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS, restore lock_timeout (mirrors migration 0250). A pre-existing duplicate pair fails the build into an INVALID index that migrate.ts WARNs about — dedupe, drop, re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 4abf5d8. Configure here.
Summary
Adds two related capabilities on the chat/VFS surface, plus the field fixes found while testing them. (1) A chat-scoped
outputs/namespace for one-off generated files (images, audio, video, ffmpeg results) — separate from workspacefiles/, living with the chat, saveable to the workspace on request; agent-side steering is behind the mothershipchat-scoped-outputsflag, so it dark-launches. (2) Fork and Duplicate — one engine, two gestures. The Fork button on each assistant reply copies the conversation up to and including that message into a fresh chat (Fork | <name>); the right-click Duplicate action is the same fork route with no cut point (upToMessageIdis now optional) — a whole-chat copy titled<name> (Copy)that keeps every message and copies agent outputs too. In both modes, copied files get fresh row ids and storage keys, bytes are physically copied and quota-charged, and every in-transcript file reference is re-pointed at the copies so the copy survives deletion of the original. Duplicates ask the mothership fork endpoint for its whole-chat clone mode (compacted working memory preserved verbatim); branch forks keep the truncate-and-rebuild path. Schema: two additive migrations —0254adds nullableworkspace_files.message_id, the provenance column that lets a branch fork copy only the chat-owned files (uploads AND outputs) born at-or-before the cut, and0255adds a partial unique index on output display names (see review-response changes below).Field fixes included (found via live testing of the above):
resourcesjsonb wholesale, keeping chips for files it doesn't have; file resources whose chat-owned file wasn't copied are now dropped.live-assistant:<streamId>id and forking it 400'd; the Fork button now waits for the persisted id.generate_image/video/audioandffmpegresolvedinputs.filesworkspace-only, souploads//outputs/references never loaded — andgenerate_imagesilently generated from the prompt alone. New sharedresolveToolInputFilecovers all three VFS namespaces, and unresolvable explicit references now fail the call instead of being silently skipped.outputs/link click on the home surface minted an empty-id resource that 400'd the next send and wedged the UI in "running" for ~3 minutes; empty-id resources are now rejected and a failed POST rolls back instead of retry-reconnecting.Review-response changes (
e43fe9c34)Addresses all 8 findings from code review:
outputs/+overwriteis now rejected in headless runs too — the files/ redirect can no longer silently replace a same-named workspace file.message_idat creation and join the same timeline cut as uploads, so inline embeds are re-pointed and forks fully survive source-chat deletion. The rule for every chat-owned file: it travels with the fork iff the user message that carried/requested it is kept.open_resource, table import, and knowledge add-document resolveoutputs//uploads/refs and barewf_ids (new chat-scoped by-id fallback inresolveToolInputFile); presigning and the background CSV import honor chat-scoped storage contexts (TableImportPayload.fileContext, backward-compatible).create_fileguard: checks the rawfileNamebeforefiles/prefixing, sooutputs/notes.mdcan no longer create a literalfiles/outputs/folder.0255: partial unique index on(chat_id, display_name) WHERE context='output'+ a 23505 retry inuploadChatOutput, closing the concurrent same-name generation race (mirrors the existing uploads index/retry; separate index so the two namespaces stay independent).upload-file-reader+output-file-readermerged into one context-parameterizedchat-file-reader(~200 duplicated lines removed, all public names preserved); sharedisOutputsPath/leaf helpers replace 5 inline prefix checks.headObjectreplay guard (no double-copy/double-charge on retry) + bounded concurrency (4) + a singleworkspace_filesread per fork with the branch cut applied in memory.EmbeddedFile/EmbeddedFileActionsshare one resolver hook (list → by-id → chat outputs by leaf name), so Download/Open work on path-referenced outputs.Deploy note: run a one-off duplicate check for
(chat_id, display_name)output rows before applying0255— the unique index build fails if race-produced dupes already exist (unlikely: young, flag-gated feature).Type of Change
Testing
vitest run lib/copilot lib/table lib/uploads app/api/mothershipfromapps/sim): 107 files, 1067 tests passing, including fork route, fork-chat-files, rewrite-file-references, effective-transcript, resolve-input-file, chat-file-reader (the merged upload/output readers), vfs, materialize-file, create-file, resources, and track-chat-upload. New coverage from the review pass: write-once rejection in both interactive and headless modes, the headless files/ redirect, thecreate_fileraw-fileName guard, thewf_id chat-scoped fallback, outputs joining the fork timeline cut (pre-cut copied + re-pointed, post-cut ghosts dropped), the blob-copy replay guard, and the bounded concurrency pool — alongside the earlier whole-chat duplicate, ghost-resource, and resolver cases.tsc --noEmit✅ 0 errors ·biome check✅ clean ·check:api-validation✅ ·check:react-query✅. (apps/docstype-check noise is pre-existing — missing generated.sourceartifact; this branch touches no docs files.)<name> (Copy)with every message; reference-image generation actually uses the uploaded reference.fork/route.ts(isWholeChatDuplicatedrives the message cut, file listing, title, Go body, and analytics — now a singleworkspace_filesread cut in memory byfilterForkableChatFiles); the ghost-resource drop rule inrewriteResourceFileRefs(chat-owned ∧ not-copied ⇒ dropped; shared workspace files pass through); the deliberate quota divergence from the workspace-fork precedent (forked bytes ARE counted — see the comment at the increment site); the write-once check ordering inresource-writer.ts(rejection must precede the headless redirect); and the two migrations (0254: additive, no backfill, NULL = birth-unknown ⇒ included in every fork;0255: partial unique index, see deploy note).Checklist
Screenshots/Videos
Companion PR
Companion (mothership): https://github.com/simstudioai/mothership/pull/342