WeaveScope

Time Travel

Time travel lets you continue a graph from a prior checkpoint. Use it to replay past execution, fork state, debug a branch, or rerun later nodes after changing intermediate state.

BeamWeaver supports two time-travel patterns:

  • Replay: continue from an older checkpoint config. Nodes before the checkpoint are not re-executed; nodes after it run again.

  • Fork: create a new checkpoint from an older checkpoint with modified state, then continue from the returned config.

Both require a graph compiled with a checkpointer and a stable thread_id.

Setup

This two-step graph is used throughout the guide:

alias BeamWeaver.Checkpoint.ETS, as: CheckpointETS
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled

checkpointer = CheckpointETS.new()
config = %{"configurable" => %{"thread_id" => "time-travel-1"}}

graph =
  Graph.new(name: "JokeFlow")
  |> Graph.add_node(:generate_topic, fn _state ->
    %{topic: "socks in the dryer"}
  end)
  |> Graph.add_node(:write_joke, fn state ->
    %{joke: "Why do #{state.topic} disappear? They elope!"}
  end)
  |> Graph.add_edge(:generate_topic, :write_joke)
  |> Graph.add_edge(Graph.start(), :generate_topic)
  |> Graph.add_edge(:write_joke, Graph.end_node())
  |> Graph.compile!(checkpointer: checkpointer)

{:ok, result} =
  Compiled.invoke(graph, %{}, config: config)

State history is newest first:

history =
  Compiled.get_state_history(graph, config)

for snapshot <- history do
  IO.inspect(
    {snapshot.next, get_in(snapshot.config, ["configurable", "checkpoint_id"])},
    label: "checkpoint"
  )
end

Replay

Replay from a prior checkpoint by invoking the graph with that checkpoint's config and an empty input update:

before_joke =
  Enum.find(history, fn snapshot ->
    snapshot.next == ["write_joke"]
  end)

{:ok, replayed} =
  Compiled.invoke(graph, %{}, config: before_joke.config)

generate_topic does not run again because its output is already in the checkpoint. write_joke runs again because it is scheduled in snapshot.next.

When replaying side-effecting workflows, use idempotency keys, dedupe records, or human review before external writes.

Fork

Forking creates a new checkpoint from a prior checkpoint. It does not mutate or roll back the original history.

{:ok, fork_config} =
  Compiled.update_state(
    graph,
    before_joke.config,
    %{topic: "chickens"},
    as_node: :generate_topic
  )

{:ok, forked} =
  Compiled.invoke(graph, %{}, config: fork_config)

forked.joke
#=> "Why do chickens disappear? They elope!"

The original thread history remains intact. The fork appears as a new checkpoint whose parent is the older checkpoint you selected.

From A Specific Node

update_state/4 applies values using the same channel merge logic as a node write. When a key has a reducer, the update accumulates. Otherwise, the update overwrites the current channel value.

BeamWeaver usually infers the node that produced the update from checkpoint metadata. Pass as_node: when inference is ambiguous or when you intentionally want the graph to resume from a specific node's successors:

{:ok, fork_config} =
  Compiled.update_state(
    graph,
    before_joke.config,
    %{topic: "chickens"},
    as_node: :generate_topic
  )

Use as_node: when:

  • multiple parallel nodes wrote in the same step

  • you are creating state on a fresh or synthetic thread

  • you want to skip a node by treating it as already completed

  • you want reducers to apply as if a particular node produced the update

If BeamWeaver cannot infer the source node and you do not pass as_node:, it returns a tagged {:error, %BeamWeaver.Core.Error{type: :ambiguous_state_update}} instead of raising a Python InvalidUpdateError.

Interrupts

Interrupts are re-triggered during time travel. The node containing BeamWeaver.Graph.interrupt/1 runs again and pauses for a new resume value.

graph =
  Graph.new(name: "InterruptReplay")
  |> Graph.add_reducer(:value, fn existing, update ->
    List.wrap(existing) ++ List.wrap(update)
  end)
  |> Graph.add_node(:ask_human, fn _state ->
    answer = Graph.interrupt("What is your name?")
    %{value: ["Hello, #{answer}!"]}
  end)
  |> Graph.add_node(:final_step, fn _state ->
    %{value: ["Done"]}
  end)
  |> Graph.add_edge(:ask_human, :final_step)
  |> Graph.add_edge(Graph.start(), :ask_human)
  |> Graph.add_edge(:final_step, Graph.end_node())
  |> Graph.compile!(checkpointer: checkpointer)

{:interrupted, first_interrupt} =
  Compiled.invoke(graph, %{value: []}, config: config)

{:ok, _state} =
  Compiled.resume(graph, "Alice", config: first_interrupt.config)

before_ask =
  graph
  |> Compiled.get_state_history(config)
  |> Enum.find(fn snapshot -> snapshot.next == ["ask_human"] end)

{:interrupted, replay_interrupt} =
  Compiled.invoke(graph, %{}, config: before_ask.config)

{:ok, replayed_state} =
  Compiled.resume(graph, "Bob", config: replay_interrupt.config)

Use the interrupt's returned config when resuming a replayed or forked interrupt. That config carries the checkpoint target needed to continue the right branch.

Multiple Interrupts

If a graph collects human input at multiple points, fork between interrupts to preserve earlier answers and re-ask later questions.

graph =
  Graph.new(name: "FormFlow")
  |> Graph.add_reducer(:value, fn existing, update ->
    List.wrap(existing) ++ List.wrap(update)
  end)
  |> Graph.add_node(:ask_name, fn _state ->
    name = Graph.interrupt("What is your name?")
    %{value: ["name:#{name}"]}
  end)
  |> Graph.add_node(:ask_age, fn _state ->
    age = Graph.interrupt("How old are you?")
    %{value: ["age:#{age}"]}
  end)
  |> Graph.add_edge(:ask_name, :ask_age)
  |> Graph.add_edge(Graph.start(), :ask_name)
  |> Graph.add_edge(:ask_age, Graph.end_node())
  |> Graph.compile!(checkpointer: checkpointer)

# After completing the original run, find the checkpoint between questions.
between_questions =
  graph
  |> Compiled.get_state_history(config)
  |> Enum.find(fn snapshot -> snapshot.next == ["ask_age"] end)

{:ok, fork_config} =
  Compiled.update_state(graph, between_questions.config, %{value: ["modified"]})

{:interrupted, age_interrupt} =
  Compiled.invoke(graph, %{}, config: fork_config)

{:ok, forked_state} =
  Compiled.resume(graph, 42, config: age_interrupt.config)

The earlier ask_name result remains in state. The later ask_age interrupt fires again and accepts a new answer.

Subgraphs

Subgraph time travel depends on the compiled subgraph's checkpoint scope.

Subgraph Compile Option BeamWeaver Behavior
checkpointer: nil or omitted The child inherits the parent adapter and uses a task-scoped checkpoint namespace. Replaying from the parent checkpoint before the subgraph re-executes the child work.
checkpointer: true or :shared The child uses the inherited adapter with a stable subgraph namespace. This is the best match for time travel inside a subgraph.
checkpointer: false or :disabled The child does not checkpoint internally. Re-entering the parent starts the child fresh.
checkpointer: adapter The child uses its own local checkpointer adapter.

Parent-level replay looks like ordinary replay:

before_subgraph =
  parent_graph
  |> Compiled.get_state_history(config)
  |> Enum.find(fn snapshot -> snapshot.next == ["subgraph"] end)

{:ok, replayed} =
  Compiled.invoke(parent_graph, %{}, config: before_subgraph.config)

To time travel inside a shared subgraph, compile the child with checkpointer: true, then use the checkpoint namespace config from state history:

child =
  Graph.new(name: "Child")
  |> Graph.add_node(:step_a, step_a)
  |> Graph.add_node(:step_b, step_b)
  |> Graph.add_edge(:step_a, :step_b)
  |> Graph.add_edge(Graph.start(), :step_a)
  |> Graph.add_edge(:step_b, Graph.end_node())
  |> Graph.compile!(checkpointer: true)

parent_graph =
  Graph.new(name: "Parent")
  |> Graph.add_node(:subgraph, child)
  |> Graph.add_edge(Graph.start(), :subgraph)
  |> Graph.add_edge(:subgraph, Graph.end_node())
  |> Graph.compile!(checkpointer: checkpointer)

inside_child =
  parent_graph
  |> Compiled.get_state_history(config)
  |> Enum.find(fn snapshot ->
    get_in(snapshot.config, ["configurable", "checkpoint_ns"]) == "subgraph" and
      snapshot.next == ["step_b"]
  end)

{:ok, fork_config} =
  Compiled.update_state(parent_graph, inside_child.config, %{value: ["forked"]})

{:ok, result} =
  Compiled.invoke(parent_graph, %{}, config: fork_config)

For nested shared subgraphs, checkpoint namespaces are path-like strings such as "outer/inner". The config also carries a checkpoint_map so the parent and child checkpoints stay aligned when you replay or fork inside the nested graph.

Async APIs

The async graph APIs support the same patterns:

{:ok, fork_config} =
  graph
  |> Compiled.async_update_state(before_joke.config, %{topic: "chickens"})
  |> BeamWeaver.Core.Async.await()

{:ok, forked} =
  graph
  |> Compiled.async_invoke(%{}, config: fork_config)
  |> BeamWeaver.Core.Async.await()

Use Compiled.async_resume/3 for interrupted checkpoints.