Skip to main content
When an AI workflow fails at step 47, you don’t want to stare at logs and guess what went wrong. You want to rewind to step 46, see exactly what changed, and try again with different inputs. That’s time travel. Smithers captures a snapshot of the entire workflow state at every frame commit. You can diff any two snapshots, fork a run from any point in its history, and replay from a checkpoint with modified inputs. Combined with jj (Jujutsu VCS) integration, you get versioned filesystem state alongside versioned workflow state.

Snapshots

A snapshot is a frozen picture of everything that matters at a specific frame in a run:
  • Node states — which tasks are pending, running, finished, or failed
  • Output rows — the actual data each completed task produced
  • Loop (Ralph) state — loop iteration counters and completion flags (<Loop> renders as a <smithers:ralph> host element internally)
  • Input data — the original input row that started the run
  • VCS pointer — the jj change_id at the time of capture (if the repo uses jj)
  • Workflow hash — hash of the workflow definition at capture time (null if unavailable)
  • Content hash — SHA-256 of the serialized state, so you can detect identical snapshots cheaply
Snapshots are captured automatically after every frame commit in the engine loop. You never call a “checkpoint” function. The engine does it for you.
Frame 0 ──snapshot──> Frame 1 ──snapshot──> Frame 2 ──snapshot──> ...
Each snapshot is stored in _smithers_snapshots with a composite key of (run_id, frame_no). The serialized JSON blobs are self-contained — you can reconstruct the full workflow state from any single snapshot without reading the event log.

Loading the Latest Snapshot

To load the most recent snapshot for a run (regardless of frame number), use loadLatestSnapshot:
import { loadLatestSnapshot } from "smithers-orchestrator";

const snapshot = await loadLatestSnapshot(adapter, runId);
// Returns the snapshot with the highest frame_no, or undefined if none exist.
This is what the engine uses internally when resuming a suspended run — it picks up from the last committed snapshot instead of replaying the event log.

Why Not Just Use Events?

Events tell you what happened. Snapshots tell you what the world looked like. Replaying an event log to reconstruct state is expensive and error-prone. A snapshot gives you the answer in one read.

Diffing

Given two snapshots, diffSnapshots computes a structured diff:
const diff = diffSnapshots(snapshotA, snapshotB);
// diff.nodesAdded    — nodes present in B but not A
// diff.nodesRemoved  — nodes present in A but not B
// diff.nodesChanged  — nodes whose state or output changed
// diff.outputsAdded  — new output rows in B
// diff.outputsRemoved — output rows gone in B
// diff.outputsChanged — output rows with different values
// diff.ralphChanged   — loop state differences
// diff.inputChanged   — whether the input row changed
// diff.vcsPointerChanged — whether the VCS pointer changed
This is a pure function — no database access needed. You pass in two snapshot objects and get back a structured diff you can render in the TUI, dump as JSON, or use programmatically. The CLI exposes this as smithers diff:
# Diff two frames in the same run
smithers diff myworkflow.tsx abc123:3 abc123:7

# Diff the latest frames of two different runs
smithers diff myworkflow.tsx abc123 def456

Forking

Forking creates a new run that starts from the state of an existing run at a specific frame. Think git branch but for workflow execution.
Run A:  Frame 0 → Frame 1 → Frame 2 → Frame 3 (failed)

Run B:                    Frame 0 → Frame 1 → ... (forked from A at frame 2)
When you fork:
  1. A new run is created with a fresh runId
  2. The snapshot from the parent run at the specified frame is copied as the initial state
  3. Optionally, specific nodes are reset to “pending” — they and their downstream dependents will re-execute
  4. Optionally, the input is overridden with new values
  5. Optionally, a forkDescription is attached for traceability
  6. The parent-child relationship is recorded in _smithers_branches
Forking is cheap because it copies a single snapshot row, not the entire event history.

Reset Nodes

The resetNodes parameter lets you selectively re-execute specific tasks. When a node is reset:
  • Its state is set to pending
  • Its output row is cleared
  • Any downstream nodes that depend on it are also reset (transitively)
This is the key mechanism for “what-if” experiments. Change one task’s behavior and see how it ripples through the rest of the workflow.

Listing Branches

To list all forks that branched from a given run:
import { listBranches } from "smithers-orchestrator";

const branches = await listBranches(adapter, parentRunId);
// Returns BranchInfo[] — one entry per child run forked from this parent.
Each BranchInfo contains the child runId, parentRunId, parentFrameNo, optional branchLabel and forkDescription, and a createdAtMs timestamp.

Looking Up Branch Info

To check whether a run is itself a fork and retrieve its parent relationship:
import { getBranchInfo } from "smithers-orchestrator";

const info = await getBranchInfo(adapter, childRunId);
// Returns BranchInfo | undefined — undefined if the run is not a fork.

Replay

Replay combines forking with execution. It creates a forked run and immediately starts running it:
# Replay from frame 5 of a specific run
smithers replay workflow.tsx --run-id abc123 --frame 5

# Replay with a specific node reset
smithers replay workflow.tsx --run-id abc123 --frame 5 --node implement

# Replay with new input
smithers replay workflow.tsx --run-id abc123 --frame 5 --input '{"prompt":"Try a different approach"}'

# Replay with VCS state restored
smithers replay workflow.tsx --run-id abc123 --frame 5 --restore-vcs
With --restore-vcs, Smithers also restores the filesystem to the jj revision that was active at the source frame. This means the code that runs the workflow is the same code that was running when the snapshot was taken.

VCS Integration

Every snapshot records the jj change_id and operation ID at capture time. This creates a parallel timeline: workflow state in SQLite, filesystem state in jj. The _smithers_vcs_tags table maps (run_id, frame_no) to VCS metadata. To look up a specific tag:
import { loadVcsTag } from "smithers-orchestrator";

const tag = await loadVcsTag(adapter, runId, frameNo);
// Returns VcsTag | undefined — includes vcsType, vcsPointer, vcsRoot, jjOperationId.
When you replay with --restore-vcs, Smithers:
  1. Looks up the VCS pointer for the source frame
  2. Creates a jj workspace at that revision
  3. Executes the workflow from the workspace
This means you can replay a workflow exactly as it ran — same code, same state, same inputs — even if the codebase has changed since then.

Timeline Visualization

The timeline shows the complete execution history of a run and all its forks:
# View timeline for a single run
smithers timeline abc123

# View the full branch tree
smithers timeline abc123 --tree

# JSON output for programmatic use
smithers timeline abc123 --json
The --tree flag recursively includes all child runs (forks), building a tree of execution history. The TUI’s run detail view shows this automatically.

Database Tables

Time travel adds three tables:
TablePrimary KeyPurpose
_smithers_snapshots(run_id, frame_no)Full state capture at each frame
_smithers_branchesrun_idParent-child fork relationships
_smithers_vcs_tags(run_id, frame_no)jj revision metadata per snapshot
The _smithers_runs table also gains three columns: parent_run_id, parent_frame_no, and branch_label, making fork relationships queryable directly from the runs table.

Selecting a Specific Attempt

When you time-travel to a node, by default Smithers picks the most recent attempt. Pass an explicit attempt number to target a different one:
# Travel back to attempt 2 of the "implement" node
smithers travel workflow.tsx --run-id abc123 --node implement --attempt 2
In code, this is the attempt field on TimeTravelOptions. If the specified attempt does not exist, the operation fails with success: false and no changes are made.

Run Reset

The smithers reset command resets an entire run back to its starting state without creating a fork. Unlike smithers travel (which targets a specific node), a run reset re-queues every node and clears all outputs:
# Reset a stalled or failed run to re-execute from scratch
smithers reset workflow.tsx --run-id abc123
This is a destructive operation on the run itself — no child run is created. Use smithers replay when you want to preserve the original run history alongside the re-execution.

Metrics

Time travel operations export four metrics:
MetricTypeDescription
smithers.snapshots.capturedcounterTotal snapshots written to the database
smithers.snapshot.duration_mshistogramTime to serialize and write a single snapshot
smithers.forks.createdcounterTotal fork operations completed
smithers.replays.startedcounterTotal replay operations initiated
These appear in Prometheus exports and OpenTelemetry traces alongside all other Smithers metrics.

Reset Dependents Toggle

By default, when you time-travel to a specific node, every downstream node that ran after the target attempt is also reset. You can disable cascade reset with --no-deps:
# Reset only the "analyze" node, leave its dependents as-is
smithers travel workflow.tsx --run-id abc123 --node analyze --no-deps
In code, set resetDependents: false on TimeTravelOptions. This is useful when you want to re-run a single task without disturbing work that is downstream but was not actually affected by the target node’s output. When resetDependents is true (the default), Smithers identifies all nodes whose attempts started at or after the target attempt’s start timestamp and resets them too.

Frame History Truncation on Revert

When a time-travel operation completes, Smithers truncates the frame log to match. All frames with a created_at_ms after the target attempt’s start timestamp are deleted from _smithers_frames. This keeps the frame history consistent with the reset node states — if you render the workflow after time travel, the frame log reflects the point in time you reverted to.

Snapshot Restoration on Resume

When a suspended run resumes (for example, after a <WaitForEvent> unblocks), the engine calls restoreDurableStateFromSnapshot before re-entering the render loop. It loads the most recent snapshot for the run, re-inserts the input row, and rebuilds node state from the snapshot data. This means a resumed run does not need to replay the entire event log — it picks up from the last committed snapshot.

Events

Three new event types track time travel operations:
  • SnapshotCaptured — emitted after each automatic snapshot. Carries runId, frameNo, and contentHash.
  • RunForked — emitted when a fork is created. Carries the parent run ID, parent frame, and optional branch label.
  • ReplayStarted — emitted when a replay begins. Carries the source run ID and frame.

Next Steps