feat(server): export and import threads between T3 state directories - #7708
feat(server): export and import threads between T3 state directories#7708olafura wants to merge 2 commits into
Conversation
Add `vp run thread:list`, which prints the live threads of an existing T3 state database with their workspace roots and titles, or the full records as JSON. The source may be a workspace containing `.t3`, the T3 base directory, or a direct state directory, and `--state dev` selects a main-checkout dev database. The database is opened read-only. This is the first step of thread transfer; export and import build on the same state-directory resolution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add `vp run thread:export` and `vp run thread:import`. Export writes one thread's orchestration events and image attachments (terminal logs only with `--include-terminal-logs`, since they may hold credentials) into a self-contained JSON archive with per-file checksums. Import validates the archive (every event belongs to the thread, decodes against the orchestration contract, and carries unique ids and stream versions), refuses a destination that already holds the thread, backs the destination database up with VACUUM INTO, remaps the thread onto the target project, clears worktree paths that do not exist on the destination, and writes only the events: the destination server replays them above the projectors' recorded sequence on its next start and rebuilds the read model itself. Copying projection rows too would make that replay append onto already-complete rows. The live ~/.t3/userdata database is refused unless `--dangerous-allow-t3-directory` is passed, which is how a thread moves from a dev checkout back into the real install once its server is stopped. The source and destination accept the same directory forms and `--state dev` selection as `thread:list`. `ensureDevDbNotInUse` is the renamed dev-db guard from migrate-dev-db.ts, reused to refuse importing into a running server's database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Reviewed the new apps/server/scripts/thread-transfer.ts and its CLI wrappers against the Effect service conventions. Imports, Effect.fn usage, and the CLI runtime boundaries (NodeRuntime.runMain only in import.meta.main blocks) look correct. Three findings on the error model, all in thread-transfer.ts.
Posted via Macroscope — Effect Service Conventions
| operation: Schema.String, | ||
| detail: Schema.String, |
There was a problem hiding this comment.
detail: Schema.String carries a free-form prose sentence, and message is ${operation}: ${detail}, so the unstructured message is effectively the only payload — the surrounding scripts (migrate-dev-db.ts, t3-sqlite-state.ts) instead model failures with structural attributes and derive the message from them.
Suggest capturing the context that is already known at each failure site (thread id, database/archive/output path, file name, operation as a Schema.Literals union of the actual stages) and building message from those fields, so callers and tests can match on structure rather than on a sentence.
Posted via Macroscope — Effect Service Conventions
| const transferError = (operation: string, detail: string, cause?: unknown): ThreadTransferError => | ||
| new ThreadTransferError({ operation, detail, ...(cause === undefined ? {} : { cause }) }); |
There was a problem hiding this comment.
transferError is a helper whose only behavior is (...args) => new ThreadTransferError({ ...args }); the conventions ask that errors be constructed at the failure boundary so their attributes and cause stay visible. Consider dropping it and using new ThreadTransferError({ operation, detail, cause }) directly at each site (the isSqlError classification in withThreadDatabase is the real normalization and can stay there).
Posted via Macroscope — Effect Service Conventions
| yield* ensureDevDbNotInUse(location.databasePath).pipe( | ||
| Effect.mapError((cause) => transferError("import thread", cause.message, cause)), |
There was a problem hiding this comment.
detail here is just cause.message, and message is then built from that detail — the wrapper's text is derived from the cause, and the already-structured upstream errors (MigrateDevDbServerRunningError with databasePath/pid, MigrateDevDbDestinationBusyError with reason) are flattened into a string.
Since these are structured domain errors, consider letting them pass through and surface in importThread's error channel instead of re-wrapping:
- yield* ensureDevDbNotInUse(location.databasePath).pipe(
- Effect.mapError((cause) => transferError("import thread", cause.message, cause)),
- );
+ yield* ensureDevDbNotInUse(location.databasePath);If a wrapper is preferred, give it structural attributes known at this site (e.g. the database path) and keep the original error as cause rather than copying its message.
Posted via Macroscope — Effect Service Conventions
| const pending: Array<{ readonly path: string; readonly data: Uint8Array }> = []; | ||
| for (const file of files) { |
There was a problem hiding this comment.
🟡 Medium scripts/thread-transfer.ts:631
Duplicate archive entries are both staged and written, so the later entry silently overwrites the earlier file while the import reports both as imported. Because fs.exists(destination) cannot detect duplicates already accumulated in pending, a crafted archive with repeated fileName values bypasses the conflict check; reject duplicate names while staging.
const pending: Array<{ readonly path: string; readonly data: Uint8Array }> = [];
+ const seenFileNames = new Set<string>();
for (const file of files) {
+ if (seenFileNames.has(file.fileName)) {
+ return yield* transferError(
+ "import thread",
+ `${kind.label} '${file.fileName}' is duplicated.`,
+ );
+ }
+ seenFileNames.add(file.fileName);🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/scripts/thread-transfer.ts around lines 631-632:
Duplicate archive entries are both staged and written, so the later entry silently overwrites the earlier file while the import reports both as imported. Because `fs.exists(destination)` cannot detect duplicates already accumulated in `pending`, a crafted archive with repeated `fileName` values bypasses the conflict check; reject duplicate names while staging.
| if (archive.events.length === 0) { | ||
| return transferError("read archive", `Thread '${archive.thread.id}' has no events.`); | ||
| } | ||
| const eventIds = new Set<string>(); |
There was a problem hiding this comment.
🟠 High scripts/thread-transfer.ts:422
A reordered archive is imported with events projected in the wrong order, so a later event can be replayed before thread.created and leave the destination thread missing or corrupted. validateArchiveEvents only checks uniqueness, while insertEvents preserves JSON array order for SQLite sequence; require ascending contiguous streamVersion values before insertion.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/scripts/thread-transfer.ts around line 422:
A reordered archive is imported with events projected in the wrong order, so a later event can be replayed before `thread.created` and leave the destination thread missing or corrupted. `validateArchiveEvents` only checks uniqueness, while `insertEvents` preserves JSON array order for SQLite `sequence`; require ascending contiguous `streamVersion` values before insertion.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 94240e5. Configure here.
| (filePath) => fs.remove(filePath).pipe(Effect.orElseSucceed(() => undefined)), | ||
| { discard: true }, | ||
| ), | ||
| ), |
There was a problem hiding this comment.
Failed writes leave blocking files
Medium Severity
A failed writeFile can leave a partial attachment or terminal log that is never added to writtenFiles, so onError cleanup does not remove it. Retry then hits the existing-file checksum check and refuses the import even though the database was rolled back.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 94240e5. Configure here.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR introduces a substantial new feature (~1800+ lines) for exporting and importing threads between T3 state directories, involving database operations, file handling, and event migration. New capabilities of this scope warrant human review. Additionally, there is a High-severity unresolved finding about event ordering that could cause thread corruption. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |


Stacked on #7707 (
thread:list); review the last commit only until that one lands. The third PR in the stack adds Orchestrator v2 support on top of this one.Problem
Threads are stuck in the state directory they were created in. That hurts in two ways: a change cannot be validated against a real thread without pointing a dev server at the live
~/.t3/userdatadatabase, and someone who has had to live on a dev checkout for a while (to dodge a bug, or to use an in-progress branch) has no way to bring those threads back into the real install. Several discussions ask for some form of this; I can't post there, so here it is as code.Fix
Adds two scripts that share the plumbing from #7707:
vp run thread:export --source <dir> --thread-id <id> --output <archive.json>writes one thread's orchestration events and image attachments into a self-contained JSON archive with per-file checksums. Terminal history only comes along with--include-terminal-logs, since it can contain credentials.vp run thread:import --archive <archive.json> --destination <dir> [--target-project-id <id>]validates the archive (every event belongs to the thread, decodes against the orchestration contract of the running checkout, and has unique ids and stream versions), refuses a destination that already holds the thread, backs the destination database up withVACUUM INTO, remaps the thread onto the target project, clears worktree paths that do not exist on the destination, and writes only the events. The destination server replays them on its next start and rebuilds the read model itself; copying projection rows too would make that replay append onto already-complete rows. A failed insert rolls every event back and removes the files it wrote.Both accept the same directory forms and
--state devselection asthread:list. Import refuses a database whose server is running (the renamed dev-db guard frommigrate-dev-db.ts), and refuses the live~/.t3/userdatadatabase unless you pass--dangerous-allow-t3-directory. That flag is the way back from a dev checkout into the real install: stop the desktop app or server, import, start it again. Docs indocs/internals/scripts.md.Verified end to end by exporting a real thread from
~/.t3/userdata(read-only), importing it into~/.t3/dev, starting the dev server, and comparing the resulting rows against the source.Tests:
apps/server/scripts/thread-transfer.test.ts(round trip with attachments and projection remap, worktree path handling,thread.createdfallback, terminal logs, every refusal, archive validation, corrupt archive, rollback).Claude Fable 5 via Claude Code
Note
High Risk
Import writes orchestration events and files into SQLite state, including an explicit path to mutate live ~/.t3/userdata. A bad archive or concurrent server could corrupt thread history despite backups and guards.
Overview
Adds maintainer CLIs (
thread:list,thread:export,thread:import) so a thread can move between T3 state directories instead of being stuck in the DB where it was created.Export writes a checksummed JSON archive of the thread’s orchestration events and image attachments. Terminal history is opt-in (
--include-terminal-logs) because it can contain secrets.Import remaps the thread onto a destination project, validates events against this checkout’s orchestration contract, refuses collisions and a running server, and backs up the destination DB first. It writes only events and files; the destination server rebuilds the read model on next start. Live
~/.t3/userdatais blocked unless--dangerous-allow-t3-directoryis passed. Missing worktree paths are cleared.Also exports
ensureDevDbNotInUsefrommigrate-dev-dbfor the import liveness check, and documents the commands indocs/internals/scripts.md.Reviewed by Cursor Bugbot for commit 94240e5. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add CLI commands to list, export, and import threads between T3 state directories
thread:list,thread:export, andthread:importCLI tools in thread-transfer.ts to move individual threads across T3 state directories via a versioned JSON archive.exportThreadcollects canonical events, attachments, and optional terminal logs from a source database;importThreadwrites them transactionally into a destination project, rewriting project IDs, validating file checksums and orchestration contract compatibility, and creating a pre-import DB backup viaVACUUM INTO.listThreadsreads projection tables to show live projects and threads with tabular or--jsonoutput.threadTransferFlagsandresolveThreadTransferStateLocationaccept base.t3dirs, direct state dirs, or<state>subdirs.importThreadrefuses to mutate the shared~/.t3/userdatadatabase unless--dangerousAllowT3Directoryis set; on mid-stream insert failure, transactional rollback removes staged files but leaves the timestamped VACUUM INTO backup on disk.📊 Macroscope summarized 94240e5. 6 files reviewed, 3 issues evaluated, 0 issues filtered, 2 comments posted
🗂️ Filtered Issues