Skip to content

feat(server): export and import threads between T3 state directories - #7708

Open
olafura wants to merge 2 commits into
pingdotgg:mainfrom
olafura:olafura/thread-transfer-v1
Open

feat(server): export and import threads between T3 state directories#7708
olafura wants to merge 2 commits into
pingdotgg:mainfrom
olafura:olafura/thread-transfer-v1

Conversation

@olafura

@olafura olafura commented Aug 20, 2026

Copy link
Copy Markdown

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/userdata database, 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 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 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 dev selection as thread:list. Import refuses a database whose server is running (the renamed dev-db guard from migrate-dev-db.ts), and refuses the live ~/.t3/userdata database 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 in docs/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.created fallback, 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/userdata is blocked unless --dangerous-allow-t3-directory is passed. Missing worktree paths are cleared.

Also exports ensureDevDbNotInUse from migrate-dev-db for the import liveness check, and documents the commands in docs/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

  • Introduces thread:list, thread:export, and thread:import CLI tools in thread-transfer.ts to move individual threads across T3 state directories via a versioned JSON archive.
  • exportThread collects canonical events, attachments, and optional terminal logs from a source database; importThread writes them transactionally into a destination project, rewriting project IDs, validating file checksums and orchestration contract compatibility, and creating a pre-import DB backup via VACUUM INTO.
  • listThreads reads projection tables to show live projects and threads with tabular or --json output.
  • Shared threadTransferFlags and resolveThreadTransferStateLocation accept base .t3 dirs, direct state dirs, or <state> subdirs.
  • Risk: importThread refuses to mutate the shared ~/.t3/userdata database unless --dangerousAllowT3Directory is 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

olafura and others added 2 commits August 20, 2026 19:14
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>
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bdc74c4-42df-46db-bd43-97c3000bc45e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 20, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment on lines +93 to +94
operation: Schema.String,
detail: Schema.String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment on lines +196 to +197
const transferError = (operation: string, detail: string, cause?: unknown): ThreadTransferError =>
new ThreadTransferError({ operation, detail, ...(cause === undefined ? {} : { cause }) });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment on lines +779 to +780
yield* ensureDevDbNotInUse(location.databasePath).pipe(
Effect.mapError((cause) => transferError("import thread", cause.message, cause)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment on lines +631 to +632
const pending: Array<{ readonly path: string; readonly data: Uint8Array }> = [];
for (const file of files) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

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 using high effort and found 1 potential issue.

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 94240e5. Configure here.

(filePath) => fs.remove(filePath).pipe(Effect.orElseSucceed(() => undefined)),
{ discard: true },
),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 94240e5. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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:

  • 2 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

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

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant