Time travel
Event-granular session forking and state reconstruction — the read/write API over the checkpointer's history.
TimeTravelAPI is the navigable view over the checkpoint history a
Checkpointer writes. It reconstructs StateSnapshot records from
raw checkpoint rows, walks history newest-first, applies synthetic
supersteps for replay, and forks a checkpoint into a new session.
import {
TimeTravelAPI,
type StateSnapshot,
type StateSnapshotMetadata,
type StateSnapshotConfig,
type GetStateHistoryOptions,
} from "@pleach/core/time-travel";This is the substrate-level fork surface. runtime.checkpoints.rollback
is the higher-level path that rebuilds session state in place and bumps
the version vector;TimeTravelAPI is what you reach for when you want
to branch into a new session id or inspect snapshot internals without
touching the live session.
The canonical runtime entry point is runtime.timeTravel.api — the
facet returns the lazily-constructed TimeTravelAPI instance (or
undefined when the runtime has no checkpointer). All four
methods — getState, getStateHistory, bulkUpdateState, fork —
live on that instance.
const api = runtime.timeTravel.api;
if (api) {
const fork = await api.fork(sourceSessionId, checkpointId, newSessionId);
}How it differs from runtime.checkpoints.rollback
runtime.checkpoints.rollback | TimeTravelAPI.fork | |
|---|---|---|
| Target session | Same session id | New session id |
| Live session | Mutated in place | Untouched |
| Version vector | Bumps; writes a new source: "rollback" checkpoint | Forked checkpoint has source: "fork", parent_id: source.id (cross-session edge) |
| Lineage edge | parentCheckpointId on the new rollback checkpoint | metadata.forkedFrom: { sessionId, checkpointId } + cross-session parent_id |
| Event-log cursor | Inherited from the rollback target | Inherited from the source checkpoint's metadata.lastEventSequence |
| Use case | Recover from a stuck turn, retry from earlier state | Explore an alternative branch without losing the original |
Both write to the same checkpointer. Forks land in the new session's namespace; the source session keeps its own history intact.
Constructor
new TimeTravelAPI(
checkpointer: CheckpointerLike,
createChannels: () => Record<string, ChannelLike>,
nodeSubscriptions: Record<string, string[]> = {},
);createChannels is a factory that returns a fresh channel map keyed by
channel name — the API uses it to replay reducer logic when applying
synthetic supersteps. nodeSubscriptions maps node name to the set of
channels each node subscribes to; it drives the next array on every
returned StateSnapshot.
If you only need read methods (getState, getStateHistory, fork),
an empty nodeSubscriptions is fine — next will simply be empty.
Methods
| Method | Returns | Notes |
|---|---|---|
getState(sessionId, checkpointId?) | Promise<StateSnapshot | null> | Latest when checkpointId is omitted. |
getStateHistory(sessionId, options?) | AsyncGenerator<StateSnapshot> | Newest-first; limit caps iteration, before cursors, source filters by node. |
bulkUpdateState(sessionId, supersteps) | Promise<StateSnapshot> | Replays writes through createChannels(), persists with source: "bulk_update". |
fork(sourceSessionId, checkpointId, newSessionId) | Promise<StateSnapshot> | Copies channel state into a new session; sets parent_id: source.id (cross-session lineage edge). |
StateSnapshot shape
interface StateSnapshot<S = Record<string, unknown>> {
values: S; // reconstructed channel values
pendingTasks: Array<{ id; name; input }>; // in-flight at snapshot time
pendingWrites: Array<{ taskId; channel; value }>; // buffered but not committed
metadata: StateSnapshotMetadata;
config: StateSnapshotConfig;
next: string[]; // nodes eligible to run next
channelVersions: Record<string, number>;
manifest?: ChatManifestSnapshot; // chat-manifest ledger AS-OF this checkpoint (inspection-only)
}
interface StateSnapshotMetadata {
step: number;
timestamp: string; // ISO-8601
source: string; // node or trigger that produced this state
writtenBy: string[];
parents: string[]; // parent checkpoint ids; cross-session for forks
lastEventSequence?: number; // event-log cursor at snapshot time
}next is computed from channelVersions + versions_seen against
the constructor's nodeSubscriptions — a node appears if any channel
it subscribes to has advanced past the version it last saw. That's
what makes a snapshot replayable: you can pick up exactly where the
graph left off without re-running already-applied work.
metadata.parents is the parent-link chain — a single-element array
for ordinary checkpoints, multi-element for merge scenarios. Forked
snapshots populate parents with the source checkpoint id (a
cross-session edge), so traversals walking metadata.parents no
longer hide the fork's origin.
manifest is the chat-manifest ledger (tool/job invocations, notices,
provider switches) AS-OF the checkpoint — surfaced for inspection
(getState/getStateHistory/fork/bulkUpdate). It is OPTIONAL
(undefined for an empty ledger or a legacy checkpoint). Inspection-only:
it does NOT feed the next generation — that functional rehydration runs
through rollbackToCheckpoint/resumeSession into the live
ChatManifestProvider. The snapshot rides both the checkpoint metadata
(this field) and state.extensions (the functional path) for the two
distinct purposes.
Event-log cursor on the snapshot
metadata.lastEventSequence carries the event-log sequence_number
the snapshot was taken at. A host hydrating from this snapshot can
fast-forward intermediate state without re-folding the event log from
genesis: pass the cursor straight to runtime.events.iterate({ chatId, fromSequenceNumber: snapshot.metadata.lastEventSequence })
and replay events forward from there. The same value flows into
hydrateFromEvents() as fromSequenceNumber.
The field is undefined on checkpoints written before the cursor
field landed — consumers must back-compat to "no skip" when it's
missing.
Fork semantics
Which channels survive a fork is decided by the underlying
channel_values blob: every channel in the source checkpoint copies
verbatim into the new session. There is no per-channel survival
policy at this layer — that lives in the reducer definition for each
channel, and the fork respects whatever the snapshot already has.
const api = new TimeTravelAPI(checkpointer, createDefaultChannels);
const snapshot = await api.fork(
sourceSessionId,
"cp_018f...",
crypto.randomUUID(),
);
snapshot.config.sessionId; // new session id
snapshot.metadata.source; // "fork"
snapshot.metadata.parents; // [source.id] — cross-session edge
snapshot.metadata.lastEventSequence; // inherited from the source checkpoint
// the lineage edge on the raw checkpoint metadata:
// metadata.forkedFrom = { sessionId: sourceSessionId, checkpointId: "cp_018f..." }Fork lineage
A forked checkpoint records its origin in two places:
metadata.forkedFrom: { sessionId, checkpointId }— the semantic lineage edge. Audit-ledger queries join against this to walk forked sessions back to their origin. The forked session keeps its own audit rows under the newsessionId;forkedFromon the first checkpoint is the one place the cross-session relationship is recorded.parent_id: source.idon the raw checkpoint row (surfaced asmetadata.parents: [source.id]on the snapshot). This is a cross-session parent edge — distinct fromforkedFromonly by shape (a parent id vs. a{sessionId, checkpointId}pair). Same-session supersteps already setparent_idto the previous checkpoint; fork now sets it to the source checkpoint so traversals walkingmetadata.parentssee the origin instead of treating forked snapshots as orphan roots.
Event-log cursor carry
The fork inherits metadata.lastEventSequence from the source
checkpoint. A consumer hydrating the forked session passes that
cursor to runtime.events.iterate({ chatId, fromSequenceNumber })
exactly as it would for the source — the cursor is the join point
between snapshot state and the event log.
Materializing the forked session row
TimeTravelAPI accepts an optional createForkedSession hook on
its constructor options. After the forked checkpoint lands, the API
invokes the hook so the host can materialize a Session record at
newSessionId — typically by cloning the source session's
SessionConfig. Without the hook, a subsequent
runtime.sessions.resume(newSessionId) will throw on a missing
session row; legacy callers must materialize the row themselves
before resume.
The hook is best-effort: a thrown error is caught and logged via
console.warn, and the fork's checkpoint write is not rolled back.
The checkpoint is the durable artifact; the session row is a derived
convenience.
Walking history
for await (const snapshot of api.getStateHistory(sessionId, { limit: 50 })) {
console.log(snapshot.metadata.step, snapshot.metadata.source, snapshot.config.checkpointId);
}Pass source: "tool-loop" to filter to checkpoints written by the
tool-loop stage; pass before: "cp_..." to page backwards from a
known checkpoint id.
Replaying synthetic supersteps
bulkUpdateState is how a test harness or migration script writes a
sequence of channel updates without running the graph. Each
superstep is an array of writes; the API replays them through
createChannels() so reducers fire normally, then persists a
single source: "bulk_update" checkpoint at the end.
const snapshot = await api.bulkUpdateState(sessionId, [
// Superstep 1 — seed the corpus channel.
[{ channel: "search_results", value: { docs: [{ id: "doc_001" }] } }],
// Superstep 2 — append a summary.
[{ channel: "summary", value: "Initial pass complete." }],
]);
snapshot.metadata.source; // "bulk_update"
snapshot.channelVersions.summary; // bumped once per superstep that touched itbulkUpdateState throws if the target session has no prior
checkpoint — it needs an anchor to write parent_id against. Use
fork for the first checkpoint in a new session id.
Side effects don't roll back
A snapshot captures channel state, not external writes. If a tool called between the target snapshot and now wrote to a database, sent an email, or charged a card, rolling back or forking from the earlier snapshot does not unwind those writes. Tools that need true rollback have to implement compensating actions themselves — Pleach doesn't have a transactional surface across third-party systems.
Where to go next
Checkpointing
The savers that produce the history this API navigates; `lastEventSequence` is the join point.
Event-log projections
Replay events forward from `snapshot.metadata.lastEventSequence` via `runtime.events.iterate({ fromSequenceNumber })`.
Channels
The reducers `createChannels()` reconstructs during bulkUpdateState.
Lineage
How fork edges and audit rows join across forked sessions.
DevTools
`__HARNESS_DEVTOOLS__.rollback` calls through to this API in the browser.