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 22 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 Security & reliability: Reviewed by Cursor Bugbot for commit 4a2333a. Bugbot is set up for automated code reviews on this repo. Configure here. |
Greptile SummaryThis PR adds two major capabilities to the chat/VFS surface: (1) a chat-scoped
Confidence Score: 5/5Safe to merge. The fork/duplicate logic, authorization changes, and outputs namespace are well-structured, consistently applied, and backed by thorough test coverage. The two migrations are additive and carefully constructed. No data-correctness or security defects were found. The authorization additions — ownership check for output files in both verifyWorkspaceFileAccess and verifyRegularFileAccess, WORKSPACE_FILE_LOOKUP_CONTEXTS filter, getPreviewableWorkspaceFile userId guard — are consistently applied and correct. The fork transaction correctly rolls back on any failure; post-commit blob copies are best-effort with proper dead-row cleanup and user-visible warnings. The rewrite functions correctly handle the ghost-resource drop and never leave cross-chat dangling references. The only findings are a missing 'output' entry in the schema comment and a missing isPending guard on the Duplicate context-menu action. No files require special attention. The schema.ts context comment omission and the sidebar double-click guard are the only items worth addressing before shipping. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[POST /fork chatId] --> B{upToMessageId set?}
B -- No --> C[Whole-chat duplicate\nAll files, title: name Copy]
B -- Yes --> D[Branch fork\nSlice messages to cut point\ntitle: Fork name]
C --> E[listDuplicableChatFiles\nALL chat-owned files\ncontext IN mothership,output]
D --> F[listDuplicableChatFiles then filterForkableChatFiles\nmessageId IN keptSet OR NULL]
E --> G[checkStorageQuota]
F --> G
G --> H[DB Transaction: INSERT copilot_chats]
H --> I[planChatFileCopies\nINSERT workspace_files copies\nfresh id + key per row]
I --> J[rewriteResourceFileRefs\nremap chips to new ids\ndrop ghost chat-owned refs]
J --> K[rewriteMessageFileRefs\nremap transcript URLs and attachments]
K --> L[appendCopilotChatMessages in tx]
L --> M[COMMIT]
M --> N[executeChatFileBlobCopies\nbounded concurrency 4\nbest-effort post-commit]
N --> O{Any failed?}
O -- Yes --> P[DELETE dead workspace_files rows\nremoveChatResources\nreturn failedFileCopies in response]
O -- No --> Q[Fork copilot-service Go endpoint\nbest-effort]
P --> Q
Q --> R[publishStatusChanged chatPubSub]
R --> S[Return id and optional 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"}}}%%
flowchart TD
A[POST /fork chatId] --> B{upToMessageId set?}
B -- No --> C[Whole-chat duplicate\nAll files, title: name Copy]
B -- Yes --> D[Branch fork\nSlice messages to cut point\ntitle: Fork name]
C --> E[listDuplicableChatFiles\nALL chat-owned files\ncontext IN mothership,output]
D --> F[listDuplicableChatFiles then filterForkableChatFiles\nmessageId IN keptSet OR NULL]
E --> G[checkStorageQuota]
F --> G
G --> H[DB Transaction: INSERT copilot_chats]
H --> I[planChatFileCopies\nINSERT workspace_files copies\nfresh id + key per row]
I --> J[rewriteResourceFileRefs\nremap chips to new ids\ndrop ghost chat-owned refs]
J --> K[rewriteMessageFileRefs\nremap transcript URLs and attachments]
K --> L[appendCopilotChatMessages in tx]
L --> M[COMMIT]
M --> N[executeChatFileBlobCopies\nbounded concurrency 4\nbest-effort post-commit]
N --> O{Any failed?}
O -- Yes --> P[DELETE dead workspace_files rows\nremoveChatResources\nreturn failedFileCopies in response]
O -- No --> Q[Fork copilot-service Go endpoint\nbest-effort]
P --> Q
Q --> R[publishStatusChanged chatPubSub]
R --> S[Return id and optional failedFileCopies]
Reviews (9): Last reviewed commit: "test(contracts): pin mothership↔copilot ..." | 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.
Fixes from the multi-agent review of #5401 (all subagent-verified, plus Cursor Bugbot's queued-send finding): - Thread chatId/interactive/messageId through the generic tool-output writer (run_code/user_table outputs.files), so interactive outputs/ writes become chat-scoped outputs instead of silently landing in workspace files/ while the agent references a nonexistent outputs/ path - Stream resume: reserve 404 for a true miss — run-lookup failures now return 503 so a transient DB error can't permanently kill reconnection (client treats 404 as terminal by design) - materialize_file save: accept uploads//outputs/ prefixes and error on bare-name ambiguity instead of silently promoting the upload - open_resource: reject chat uploads (no client surface can preview mothership rows — prevents permanently-broken resource tabs) - uploadChatOutput: delete the just-uploaded blob when the row insert never lands (no more orphaned bytes invisible to chat cleanup); replace the per-candidate name pre-SELECT with one names read + 23505 retry - knowledge add_file: chat-scoped refs only resolve into KBs in the chat's own workspace (matches open_resource's boundary) - resolveToolInputFile: uploads//outputs/ refs fall through to the workspace resolver when no chatId exists; reader tries the decoded name spelling in the indexed query (no more guaranteed full-scan for encoded names) - resource-writer: outputs leaf = FIRST path segment (readers' rule); write-once error names the files/outputs/ spelling for one-turn recovery - rename/delete/move tools: explicit policy errors for chat-scoped paths (and no more resolving 'outputs/x' against a real files/outputs folder) - vfs: unified chat-scoped read branch (restores the dropped guidance sentence), grep leaf via chatScopedLeafSegment - fork: remove the dead headObject guard (no replay path exists here), swap the hand-rolled pool for mapWithConcurrency, batch the copy-row insert, drop the dead keyMap clause - use-chat: rolled-back sends return false so queued messages are restored (Cursor finding); hydration deletes legacy empty-id resource rows server-side (unbreaks reorder); shared isPersistedChatResource predicate - preview mapping carries the row's real storageContext (fixes serve context for by-id output previews); hide Open-in-Files for outputs; byId query-key factory entry; Set-based dedup in the resource dropdown Adds ISSUES.md: verified findings not yet fixed on this branch (quota refund design, interactive-delete blob cleanup, fork durability/memory, >100MB fork copies, chat-scoped glob pattern filtering, sandbox-export outputs threading, namespace shadowing, home.tsx click drop, and the consolidated-bucket purge edge), each with mechanism and fix design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cursor review |
- The hydration cleanup of legacy empty-id resource rows was a silent client-side no-op: requestJson validates the outbound body, and the mothership remove contract still required resourceId.min(1) while only its copilot twin was permissive. Relax the mothership schema to match (the shim route delegates to the copilot handler, which already tolerates empty ids) so the cleanup actually reaches the server and reorder unbreaks. - uploadChatOutput's orphan-blob cleanup could delete a COMMITTED row's bytes on a commit-ack race (insert commits, connection resets before the ack, driver throws, displayName stays null). Verify no row references the storage key before deleting; if the check fails, prefer an orphaned blob over a broken live file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cursor review |
import only resolved chat uploads while save was extended to both namespaces — agent-generated workflow JSON under outputs/ always returned upload-not-found. Same routing as save: uploads//outputs/ prefixes target a namespace, bare-name collisions error as ambiguous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mothership resource routes are shims delegating to the copilot handlers, so the two contract families must describe the same boundary: - copilotResourceTypeSchema was missing filefolder/task/integration/ generic — valid MothershipResourceType rows of those types 400'd at the delegated parse, silently re-breaking the legacy empty-id cleanup (and reorder) for chats holding them. Enum now covers every member. - mothership add/reorder REQUEST items now require a non-empty id like the delegated copilot schemas (the client contract was advertising acceptance of payloads the server rejects); RESPONSE items stay permissive so replies containing not-yet-cleaned legacy rows still validate. Also records the confirmed _context-whitelist mechanism on ISSUES.md item 7 (sandbox-export chat threading). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mothership resource routes delegate to the copilot handlers, so the two contract families describe one physical boundary. These tests turn the next one-sided schema edit into a red test instead of a silent client-side ZodError or delegated 400: the copilot resource-type enum must cover every MothershipResourceType, remove stays permissive on both sides (legacy empty-id deletion), add/reorder reject empty ids on both sides, and mothership responses stay permissive for not-yet-cleaned legacy rows. 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!
5 issues from previous reviews remain unresolved.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 8afecd5. Configure here.
Round-4 review: the enum widening fully fixed remove/reorder (and made integration adds work), but POST keeps its own narrower VALID_RESOURCE_TYPES allow-list — scope the comment so it doesn't overclaim, ledger the pre-existing filefolder-add 400 as ISSUES.md item 11, and fix a stale 'from upload' log line in materialize import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eviews Cursor auto-reviews each push; four findings landed between polling windows and surfaced via unresolved threads: - useChatOutputs no longer swallows list failures into a successful [] — errors throw so React Query retries instead of caching "no outputs" for the stale window (masked path-based output link resolution) - flushPendingResources and the post-flush reorder now use the shared isPersistedChatResource predicate (empty-id resources could reach the tightened API validation via the flush path) - fork quota gate only counts rows the plan will copy (workspaceId-less legacy rows are skipped by planChatFileCopies but their bytes could reject an otherwise-in-quota fork) - duplicate titles strip a leading "Fork | " like branch forks do: duplicating a forked chat yields "Name (Copy)", not "Fork | Name (Copy)" The fifth finding (copied-counter "race") is refuted on-thread: JS is single-threaded and the increment has no await between read and write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Optimistic duplicate titles strip "Fork | " like the server now does (no brief sidebar rename on refetch) - The hydration localOnly merge uses isPersistedChatResource — the last bare-predicate site that could readmit empty-id local resources Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4a2333a. Configure here.
| error: toError(err).message, | ||
| }) | ||
| return [] | ||
| } |
There was a problem hiding this comment.
Outputs API hides list failures
Medium Severity
The new chat-outputs HTTP route calls listChatOutputs, which catches database errors and returns an empty array. The route then responds with success: true and files: [], so transient DB failures look like “no outputs” instead of a 500. That undermines useChatOutputs throwing on failure, because the client never sees an error to retry.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4a2333a. Configure here.
| const isResolving = | ||
| isLoading || | ||
| (isFetching && !file) || | ||
| (listSettled && !listFile && (fallbackLoading || outputsLoading)) |
There was a problem hiding this comment.
Errored outputs query shows not found
Medium Severity
After useChatOutputs fails (network or 500), callers still default data to [] and never read isError. useResolvedEmbeddedFile then stops resolving once list and by-id lookups miss, so the embedded preview shows “File not found” instead of loading or error/retry—even when outputs exist.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4a2333a. 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