Import and reversible cutover
August 13, 2026 ยท View on GitHub
This workflow moves an existing Session without dual writes or event merging. The old backend remains untouched and is the rollback authority until the first new Teleport write.
1. Produce a portable snapshot
Let the active turn finish and flush, then stop the source profile. Capture the final consistent snapshot with:
dsh-teleport-import capture <source-profile> <session-id> \
session-id.session-import.json
The command boots that profile once with a temporary absolute file://
overlay. The overlay injects the profile's existing sessionPersistence, calls
inspect(sessionId) and writes into an owner-only staging directory. The
parent validates the digest and shape, then atomically publishes the requested
mode 0600 no-clobber file and requests normal DSH shutdown. A failed source
boot therefore does not leave a final bundle. It never edits the
profile patch, activates the Teleport backend there, or writes to the source
backend. Target database, Teleport admin and package-manager credential
environment variables are removed from the source child.
For embedding or specialized orchestration, the same consistent seam remains available programmatically:
import { exportSessionImportBundle } from "@mattheliu/session-teleport/importer";
const bundle = await exportSessionImportBundle(sourcePersistence, sessionId);
The document contains the complete
Session history and may contain prompts, tool results and credentials emitted
by tools. Never commit it, upload it to an issue, or retain it longer than the
migration requires. The repository ignores *.session-import.json.
Capture refuses to overwrite an existing path. The importer rejects empty, partial or non-contiguous prefixes. It preserves every event object and unknown extension field rather than filtering or renumbering them.
2. Dry-run
Keep the old profile active while preparing the bundle, but stop new writes before the final snapshot. Then run:
dsh-teleport-import dry-run session-id.session-import.json
The command validates the file and reads the target. It does not create a
Session, writer credential or rollback receipt. targetStatus is:
ready: the Session id is absent and can be imported;already-present: exact content already exists; apply is allowed only when this device has retry material from its own earlier import;conflict: the id exists with different content and will never be overwritten.
Record the reported digest, event count, next sequence and byte size. The
service accepts 4 MiB request bodies by default. For a larger reviewed import,
raise TELEPORT_MAX_BODY_BYTES on the service explicitly; this version keeps
the all-or-nothing one-request transaction and does not stage chunked imports.
3. Apply and switch
With the old writer stopped and flushed:
dsh-teleport-import apply session-id.session-import.json
Apply saves retry credentials before making the request, materializes the header and complete event prefix in one PostgreSQL transaction, reads the target back, and stores an owner-only rollback receipt. A lost HTTP response is safe to retry with the same command.
Only after apply verifies exact content should the profile be switched to the pinned Teleport plugin commit. Installation still uses the controlled restart procedure in DSH integration; it is not a hot replacement of an active writer.
4. Roll back a failed cutover
If the new profile fails its read-only smoke check, stop it before it appends, hands off, or recovers the writer. Restore the old profile configuration, then remove the unchanged imported target:
dsh-teleport-import rollback <session-id> "cutover smoke check failed"
Rollback requires DSH_TELEPORT_ADMIN_TOKEN. PostgreSQL locks the Session and
checks the receipt, import mutation, revision, next sequence and writer epoch
before deletion. The action is audited and idempotent. On success, local
Teleport writer credentials and the receipt are removed; the old backend was
never changed.
Rollback deliberately fails with IMPORT_NOT_ROLLBACKABLE after any Teleport
append, accepted handoff or writer recovery. At that point the old backend is a
stale prefix, so silently switching back would lose history. Recovery then
requires a deliberate export/migration operation, not this lightweight path.