moonstate

    Durable state graphs for MoonBit: nodes, merging, checkpoints, interrupts and resume. No model in sight.

    state-graph
    graph
    graph-engine
    workflow
    workflow-engine
    state-machine
    superstep
    checkpoint
    checkpointing
    durable-execution
    resume
    interrupt
    human-in-the-loop
    time-travel
    fan-out
    send
    subgraph
    executor
    pregel
    langgraph
    agent
    orchestration
    json
    sql-checkpoint
    checkpoint-saver
    sqlite
    postgres
    postgresql
    mysql
    mariadb
    database
    durable-workflow
    opentelemetry
    otel
    otel-tracing
    tracing
    observability
    moonbit
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    4 days ago
    Downloads
    136

    #moonstate

    Durable state graphs for MoonBit: nodes, merging, checkpoints, interrupts and resume. No model in sight.

    A run is a sequence of supersteps. In each one a set of nodes runs, each returns the updates it wants applied, and the updates are folded into the state in task order. Every superstep is a checkpoint. When a process dies, the next run opens the last checkpoint and continues from there; when a node asks a question, the run stops until it is answered.

    #derive(@json.ToJson, @json.FromJson)
    struct Counter {
    n : Int32
    log : Array[String]
    }

    #derive(@json.ToJson, @json.FromJson)
    struct Patch {
    n : Int32?
    log : Array[String]?
    }

    fn merge(state : Counter, patch : Patch) -> Counter {
    { n: patch.n.unwrap_or(state.n), log: state.log + patch.log.unwrap_or([]) }
    }

    async fn hello() -> Counter raise {
    let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => {
    [@moonstate.Write({ n: Some(1), log: Some(["seed"]) })]
    })
    .node("double", (state, _run) => {
    [@moonstate.Write({ n: Some(state.n * 2), log: None })]
    })
    .edge("seed", "double")
    .compile("hello")
    graph.run({ n: None, log: None })
    }

    async test "the first graph" {
    let out = hello() catch { error => abort((error : Error).to_string()) }
    assert_eq(out.n, 2)
    assert_eq(out.log, ["seed"])
    }

    #Install

    [dependencies] "moonbitstack/moonstate" = "0.1.0"

    import { "moonbitstack/moonstate" }

    Add moonbitlang/async to the project as well: a run is asynchronous, and the executor runs a superstep's tasks at the same time.

    #The state and the update

    The graph is written over two types of yours: the state S, and the update U a node writes. Merging is a function, merge : (S, U) -> S, so a run's result does not depend on the order the executor happened to finish the tasks in: the engine folds the writes in task order, and a task order is the order the nodes were queued.

    The two types go through JSON, because a checkpoint stores the state and an executor that ships a task elsewhere sends the input and receives the steps as JSON. #derive(@json.ToJson, @json.FromJson) is enough for records of numbers, strings, lists and other records.

    A node writes only the fields it touched. Patch above does that with Option: None leaves the field alone, which is what makes two nodes writing in one superstep mergeable without knowing about each other.

    #Supersteps

    A superstep runs the nodes that are queued, merges what they wrote, and then decides what to run next:

    • an edge a -> b queues b after a;
    • a join ["a", "b"] -> c is a gate: c runs only when both wrote in the same superstep;
    • a branch is called with the state as merged at the end of the superstep and returns the nodes to queue;
    • Goto inside a node replaces the edges that node declared;
    • Send queues a node once per input, each with its own state.

    A node may return several steps; they are applied in the order it wrote them.

    #derive(@json.ToJson, @json.FromJson)
    struct Report {
    parts : Array[Array[Int32]]
    total : Int32
    }

    #derive(@json.ToJson, @json.FromJson)
    struct Part {
    value : Int32
    }

    fn report_merge(state : Report, part : Part) -> Report {
    { parts: state.parts + [[part.value]], total: state.total + part.value }
    }

    async fn fan_out() -> Report raise {
    let graph = @moonstate.Builder::new(merge=report_merge, initial={ parts: [], total: 0 })
    .entry("fan")
    .node("fan", (state, _run) => {
    let steps : Array[@moonstate.Step[Report, Part]] = []
    for index = 0; index < 3; index = index + 1 {
    steps.push(@moonstate.Send("shard", { parts: state.parts, total: index }))
    }
    steps
    })
    .node("shard", (state, _run) => {
    let steps : Array[@moonstate.Step[Report, Part]] = []
    steps.push(@moonstate.Write({ value: state.total * 10 }))
    steps
    })
    .routed("shard")
    .compile("fan")
    graph.run({ value: 0 })
    }

    async test "one node, three inputs" {
    let out = fan_out() catch { error => abort((error : Error).to_string()) }
    assert_eq(out.total, 30)
    }

    compile refuses a graph that could not run: an edge to a node that is not defined, a node nothing reaches, a graph whose every node has a successor, the same node name twice. A node entered at run time — by Send, Goto or a route — is declared with routed, so the check does not mistake it for dead code.

    The number of supersteps a run may take is default_limit, 25 unless compile is given another number. A cycle that never leaves itself stops with StepLimit rather than hanging.

    #Checkpoints

    Compile with a Saver and every superstep is written down before it runs. The next run on the same thread opens the last checkpoint instead of starting over.

    async fn durable() -> Array[String] raise {
    let saver = @moonstate.MemorySaver::new()
    let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => {
    [@moonstate.Write({ n: Some(1), log: Some(["seed"]) })]
    })
    .node("double", (state, _run) => {
    [@moonstate.Write({ n: Some(state.n * 2), log: None })]
    })
    .edge("seed", "double")
    .compile("durable", saver=saver)
    let _ = graph.run({ n: None, log: None }, thread="job")
    let second = graph.run({ n: None, log: None }, thread="job")
    let history = graph.history("job")
    [second.n.to_string(), history.length().to_string(), history[2].id]
    }

    async test "a finished thread starts a new turn" {
    let out = durable() catch { error => abort((error : Error).to_string()) }
    assert_eq(out, ["2", "6", "cp-000002"])
    }

    • put_writes records a task's result as soon as it finishes, so a task that completed is not run twice after a crash, even when the rest of its superstep did not survive.
    • state(thread) is the thread as it stands; history(thread) is every checkpoint it went through, oldest first, each one naming the checkpoint it was written on top of.
    • update(thread, patch, as_node=...) writes into the latest state as if a node had written it, and queues what would have run after that node. A run afterwards continues from there, which is how a finished thread is taken back to an earlier decision.

    Saver is a contract, not a database: five methods over records of JSON. MemorySaver is the one that ships in this module.

    #Checkpoints in a database

    moonbitstack/moonstate/sql puts the same two services on any backend the database seam speaks — SQLite, PostgreSQL and MySQL run the same code, and the SQL is written once against the dialect the driver reports.

    async fn [T : @moondb.AsyncDriver] on_a_database(
    connection : T,
    ) -> @moonstate.Graph[Counter, Patch] raise {
    let saver = @sql.saver(connection)
    // Creates the tables and their indexes when they are not there; safe to run on
    // every start.
    saver.setup()
    @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => {
    [@moonstate.Write({ n: Some(1), log: Some(["seed"]) })]
    })
    .compile("on-a-database", saver=saver)
    }

    @sql.store(connection) is the long-term memory on the same tables: namespaces, keys and JSON, with a search over a namespace and everything below it.

    Two things are worth knowing before pointing it at a server:

    • One connection, one saver. The saver serialises its own statements, but a driver's connection carries one exchange at a time: a saver and a store that share a connection take turns, and two savers on one connection do not.
    • The dialect. A driver that numbers its parameters reports Dialect::Postgres and the saver asks it. A driver that does not report its dialect builds the statements for SQLite's ? and is refused by the server at the first statement; pass dialect=@moondb.Dialect::Postgres to @sql.saver in that case.
    • A driver that blocks. SQLite's driver calls into C and implements the synchronous Driver; @sql.blocking(connection) is the bridge, and it is a bridge for any blocking driver, not a special case for SQLite.

    #Interrupts

    A node returns Interrupt(payload) to stop the run and wait. The run ends with GraphError::Interrupted, the checkpoint carries the payload, and resume_with continues the thread: the node that asked runs again and reads the answer from Run::answer.

    async fn approve() -> Int32 raise {
    let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("approve")
    .node("approve", (_state, run) => match run.answer() {
    Some(answer) => {
    let decided : Int32 = @json.from_json(answer)
    [@moonstate.Write({ n: Some(decided), log: None })]
    }
    None => [@moonstate.Interrupt({ "ask": "approve?" })]
    })
    .compile("approval", saver=@moonstate.MemorySaver::new())
    let stopped = try {
    let _ = graph.run({ n: None, log: None }, thread="review")
    "finished"
    } catch {
    @moonstate.GraphError::Interrupted(value~, ..) => value.stringify()
    _ => abort("the run failed for another reason")
    }
    assert_eq(stopped, "{\"ask\":\"approve?\"}")
    let out = graph.resume_with("review", Json(7))
    out.n
    }

    async test "a question stops the run and the answer continues it" {
    let out = approve() catch { error => abort((error : Error).to_string()) }
    assert_eq(out, 7)
    }

    The tasks of the interrupted superstep whose writes were already applied are kept; the interrupting task is not recorded, so it runs again instead of replaying its own question.

    #Where the tasks run

    An Executor decides where a superstep's tasks run. The engine hands it the whole superstep, so a fan-out is parallel, and takes the results back in the order it gave them.

    @moonstate.inline is the default: every task in this process, at the same time. An executor is a trait with one method, each, so a different policy is a small type:

    struct OneAtATime {}

    pub impl @moonstate.Executor for OneAtATime with fn each(_self, works) {
    let done : Array[@moonstate.Done] = []
    for work in works {
    let outcome = try {
    Ok((work.run)())
    } catch {
    error => Err(error)
    }
    done.push(match outcome {
    Ok(values) => @moonstate.Done::{ values: Some(values), error: None }
    Err(error) =>
    @moonstate.Done::{ values: None, error: Some((error : Error).to_string()) }
    })
    }
    done
    }

    // The trait's method is not promoted to a plain method of the type: call it
    // through the trait, which is what the engine does.
    pub extend OneAtATime with @moonstate.Executor::{each}

    async fn one_at_a_time() -> Int32 raise {
    let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => [@moonstate.Write({ n: Some(5), log: None })])
    .compile("serial", executor=OneAtATime::{})
    let out = graph.run({ n: None, log: None })
    out.n
    }

    async test "an executor of one's own" {
    let out = one_at_a_time() catch { error => abort((error : Error).to_string()) }
    assert_eq(out, 5)
    }

    Work carries the graph name, the node name, the thread, the step and the attempt, plus two functions: input encodes the state only when it is asked for, and run is what an in-process executor calls. An executor that ships the task elsewhere ignores run and finds the node by name on the far side; Done comes back as JSON either way.

    #Traces

    moonbitstack/moonstate/otel wraps an executor so that every superstep and every node in it is a span, named with the OpenTelemetry GenAI semantic conventions:

    fn clock() -> Int64 {
    @env.now().reinterpret_as_int64() * 1_000_000L
    }

    fn random_bytes(width : Int32) -> Bytes {
    match @env.rand(width) {
    Some(bytes) => bytes
    None => abort("the platform has no entropy source")
    }
    }

    async fn traced_run() -> Array[String] raise {
    let spans = @trace.Recorder::new()
    let tracer = @trace.Tracer::new(
    rand=random_bytes,
    sink=spans,
    sampler=@trace.parent_based(root=@trace.always),
    )
    let executor = @otel.traced(@moonstate.inline, tracer~, now=clock)
    let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => [@moonstate.Write({ n: Some(2), log: None })])
    .compile("traced", executor=executor)
    let _ = graph.run({ n: None, log: None })
    spans.spans().map(span => span.name)
    }

    async test "a run is a tree of spans" {
    let names = traced_run() catch { error => abort((error : Error).to_string()) }
    assert_eq(names, ["seed", "superstep 0"])
    }

    A trace follows a thread from superstep to superstep: each superstep span is a child of the previous one, so a run is one trace however many supersteps it takes, and a node's span sits under the superstep that ran it. The span names come from the conventions rather than from this library: gen_ai.operation.name is invoke_agent on a superstep and execute_tool on a node, gen_ai.agent.name is the graph, gen_ai.tool.name is the node, and gen_ai.conversation.id is the thread. Message payloads are never recorded.

    #Functional form

    A function with durable steps instead of a graph:

    let fetch = @moonstate.task("fetch", async fn(url : String) -> String { ... })
    let score = @moonstate.task("score", async fn(text : String) -> Int { ... })

    let workflow = @moonstate.entrypoint("rank", async fn(url : String, ctx : @moonstate.Context) -> Int {
    let text = fetch.call(ctx, url)
    score.call(ctx, text)
    }, saver=saver)

    Each call is recorded when it finishes. A run that dies replays the entrypoint from the start — the body has to be deterministic apart from the task calls — and the calls that already finished return their recorded result instead of running again. A task takes a retry policy the way a node does.

    #Around a run

    Run is what a node is handed besides the state: the thread, the superstep, the checkpoint its writes belong to, the long-term Store, emit for progress, and cancel.

    stream reports events as they happen: Started and Finished per node, Updates per write, Values after a superstep, Custom for what a node emitted, Interrupted when the run stops to ask. run is stream without a listener.

    Cancelling stops the thread at the next superstep boundary, never inside a node, so what a node wrote before asking to stop is kept.

    #Documentation

    The site is https://moonbitstack.github.io/moonstate/: an instrument that runs this library in the browser (a ring of the twenty-five supersteps a run may take, the checkpoints as notches, a demo that plays itself in six stages, and a last stage that reloads the tab mid-run), a guide that walks the same path the code does, and the reference generated from the interface files. Every block on the guide is the README's block, compiled by CI; every page follows the reader's light or dark setting, and the setting follows the system until the reader chooses one.

    #API

    ItemPurpose
    Builder::new(merge~, initial~)The empty state, and how an update folds into it.
    Builder::node(name, run, retry?)A node: state and Run in, steps out.
    Builder::edge(from, to)Run to after from.
    Builder::join(from, to)Run to once every node in from ran in the same superstep.
    Builder::branch(from, route)route picks the next nodes from the merged state.
    Builder::subgraph(name, graph, enter~, leave~)A whole graph as one node.
    Builder::routed(name)name is entered by Send, Goto or a route.
    Builder::entry(name)The node a run starts at.
    Builder::compile(name, saver?, store?, executor?, limit?)Check the graph and freeze it.
    StepWrite, Goto, Send, Interrupt: what a node returns.
    Graph::run(input, thread?)Run to the end, or continue a crashed thread.
    Graph::stream(input, on, thread?)The same, reporting events.
    Graph::resume_with(thread, answer)Continue a thread that is waiting.
    Graph::resume_stream(thread, answer, on)The same, reporting events.
    Graph::state(thread) / Graph::history(thread)The thread now, and every checkpoint it took.
    Graph::update(thread, patch, as_node?)Rewrite the latest state, then continue after a node.
    Saver / MemorySaverWhere checkpoints live.
    Store / MemoryStoreLong-term memory: namespaced JSON with a search.
    Executor / inlineWhere a superstep's tasks run.
    RunThread, step, checkpoint, store, emit, cancel.
    task / entrypoint / Task / ContextThe functional form.
    @sql.saver(driver, names?, dialect?)Checkpoints in SQLite, PostgreSQL or MySQL.
    @sql.store(driver, names?, dialect?)Long-term memory on the same tables.
    @sql.blocking(driver)A blocking driver, as one the saver can await.
    @otel.traced(executor, tracer~, now~)An executor that reports spans.

    #Conformance

    The behaviour is pinned by 24 graphs — a line, fan-outs, joins, cycles, branches, Send, Goto, retries, interrupts with resume, subgraphs and time travel — replayed against vectors generated from LangGraph 1.2.20 by scripts/gen_vectors.py. For each graph the suite compares the sequence of finished nodes and what each wrote, the state after every superstep, the interrupt payload, the resumed run, and the state a rewrite produces. 88 checkpoints are compared one by one: the state each holds, the tasks it says run next, its superstep number, and whether it is waiting on an answer. All of them match.

    The checkpoint scenarios — an interrupt and its answer, a task whose neighbour failed, a page of history, a rewrite, a deleted thread, and long-term memory — run against the in-memory saver, SQLite, PostgreSQL and MySQL in integration/, and the transcripts have to match line for line. The spans of a run are checked against the OpenTelemetry semantic conventions registry by weaver registry live-check in CI: the names on them are the conventions' names, and a name this library invented would fail the job. Against the GenAI registry the eight spans of the example raise 28 advisories and no violation; every one of the 28 says an attribute is at development stability, which is the state of that family of conventions rather than anything about the name.

    Two behaviours are deliberately different, and are not in the vectors:

    • A write conflict. Two tasks writing the same field in one superstep is an error there and a fold in task order here. Merging is a function of (S, U) in this library, so a conflict is always resolvable and always deterministic.
    • A join across supersteps. A node fed by two others runs once per trigger there; here join holds it until both upstream nodes ran in the same superstep, which is what makes a diamond run its join once.

    The tests run on every backend; the asynchronous ones are skipped on wasm-gc, whose test driver does not run asynchronous tests. scripts/kill_test.sh runs the durable example, kills it mid-run, and checks that the restarted run continues from its checkpoint.

    Executor

    pub(open) trait Executor {
    async fn each(Self, Array[Work]) -> Array[Done]
    }

    Where a superstep's tasks run. The engine hands over the whole superstep at once, which is what makes a fan-out parallel; the results come back in the order the work was given, so merging stays deterministic.

    Saver

    pub(open) trait Saver {
    async fn get(Self, thread : String, id? : String) -> Checkpoint?
    async fn put(Self, Checkpoint) -> Unit
    async fn put_writes(Self, thread : String, id : String, task : String, values : Array[
    Json
    ]) -> Unit
    async fn list(Self, thread : String, limit? : Int, before? : String) -> Array[Checkpoint]
    async fn delete(Self, thread : String) -> Unit
    }

    Where a thread's checkpoints live. The engine writes one per superstep and reads the last one back to resume, so an implementation only has to store records and hand them back in order.

    Store

    pub(open) trait Store {
    async fn get(Self, space : Array[String], key : String) -> Item?
    async fn put(Self, space : Array[String], key : String, value :
    Json
    ) -> Unit
    async fn delete(Self, space : Array[String], key : String) -> Unit
    async fn search(Self, space : Array[String], query? : String, filter? : Map[String,
    Json
    ], limit? : Int) -> Array[Item]
    }

    Long-term memory that outlives a thread: spaced key-value pairs with a search over them. A node reaches the store through Run::store.

    GraphError

    pub(all) suberror GraphError {
    StepLimit(limit~ : Int)
    NodeFailed(node~ : String, message~ : String)
    Interrupted(thread~ : String, value~ :
    Json
    )
    NotInterrupted(thread~ : String)
    MissingThread(thread~ : String)
    MissingNode(node~ : String)
    Unreadable(reason~ : String)
    Cancelled(thread~ : String)
    Backend(reason~ : String)
    }

    Why a run could not finish.

    Refused

    pub(all) suberror Refused {
    NoNodes
    NoEntry
    UnknownEntry(node~ : String)
    DuplicateNode(node~ : String)
    DanglingEdge(from~ : String, to~ : String)
    Unreachable(node~ : String)
    NoExit
    }

    Why a graph definition was refused when it was compiled. Every check here is structural: the nodes and edges are known then, the runs are not.

    Builder

    pub struct Builder[S, U] {
    merge : (S, U) -> S
    initial : S
    entry : String?
    nodes : Map[String, NodeSpec[S, U]]
    edges : Array[(String, String)]
    joins : Array[(Array[String], String)]
    branches : Array[(String, async (S) -> Array[String])]
    routed : Array[String]
    duplicates : Array[String]
    }

    Accumulates nodes and edges, then checks them once in compile. Every method returns the builder, so a graph reads as one chain.

    Builder::branch

    fn[S, U] Builder::branch(self : Builder[S, U], from : String, route : async (S) -> Array[String]) -> Builder[S, U]

    Let route decide where to go after from. It is called with the state as merged at the end of the superstep, so it sees what the nodes wrote.

    Builder::compile

    fn[S, U] Builder::compile(self : Builder[S, U], name : String, saver? : &Saver, store? : &Store, executor? : &Executor, limit? : Int) -> Graph[S, U] raise Refused

    Fold the collected structure into a graph, refusing a definition that cannot run: an edge to nowhere, a node nothing reaches, a graph with no end.

    Builder::edge

    fn[S, U] Builder::edge(self : Builder[S, U], from : String, to : String) -> Builder[S, U]

    Run to after from, unconditionally.

    Builder::entry

    fn[S, U] Builder::entry(self : Builder[S, U], name : String) -> Builder[S, U]

    Make name the node a run starts at. Without it a graph of exactly one node starts there, and anything larger is refused.

    Builder::join

    fn[S, U] Builder::join(self : Builder[S, U], from : Array[String], to : String) -> Builder[S, U]

    Run to once every node in from has run in the same superstep, and not before: a join is a gate, not just an edge.

    Builder::new

    fn[S, U] Builder::new(merge~ : (S, U) -> S, initial~ : S) -> Builder[S, U]

    A builder whose state starts at initial and whose every merged write goes through merge. initial is what a run starts from before the input is merged in, so it is the empty state: zeroed counters, empty lists.

    Builder::node

    fn[S, U] Builder::node(self : Builder[S, U], name : String, run : async (S, Run) -> Array[Step[S, U]], retry? : Retry) -> Builder[S, U]

    Add node name. run gets the state and a Run handle and returns the steps it wants applied; retry gives it more than one try.

    Builder::routed

    fn[S, U] Builder::routed(self : Builder[S, U], name : String) -> Builder[S, U]

    Declare that name is entered at run time — by a Goto, a Send or a route — rather than by an edge. Without this the compile-time reachability check has no way to tell such a node from dead code, and refuses the graph.

    Builder::subgraph

    fn[T :
    ToJson
    +
    FromJson
    , V :
    ToJson
    +
    FromJson
    , S, U] Builder::subgraph(self : Builder[S, U], name : String, graph : Graph[T, V], enter~ : (S) -> T, leave~ : (T) -> U) -> Builder[S, U]

    Run a whole compiled graph as one node of this one: enter reads the child's input out of the parent's state, leave folds the child's result back into the parent's update.

    Checkpoint

    pub(all) struct Checkpoint {
    thread : String
    id : String
    parent : String?
    seq : Int
    step : Int
    state :
    Json

    pending : Array[Pending]
    interrupt :
    Json
    ?
    created_at : Int64
    writes : Array[TaskWrite]?
    } derive(
    FromJson
    ,
    ToJson
    )

    One durable point in a thread's run: the state after step supersteps, the tasks the next superstep will run, and the writes already collected for them. A Saver stores these verbatim; the engine is the only thing that reads the state back into its own type.

    Checkpoint::to_json

    Context

    pub struct Context {
    run : Run
    saver : &Saver?
    executor : &Executor
    graph : String
    index : Int
    recorded : Map[String,
    Json
    ]
    }

    What an entrypoint hands its body: the run it belongs to, and the machinery a task call needs. One context per attempt; call advances it, which is how a replayed run lines its calls up with the recorded ones.

    Context::index

    fn Context::index(self : Context) -> Int

    How many task calls this attempt has made so far.

    Context::run

    fn Context::run(self : Context) -> Run

    The run this entrypoint belongs to.

    Done

    pub(all) struct Done {
    values : Array[
    Json
    ]?
    error : String?
    }

    What an executor reports back for one work item. Exactly one of values and error is set: a failed node's error travels as text, because the executor may not share a process with the graph that would have to match on it.

    Entrypoint

    pub struct Entrypoint[I, O] {
    graph : Graph[Frame, Frame]
    encode : (I) ->
    Json

    decode : (
    Json
    ) -> O raise GraphError
    }

    A graph built from one function: the function is the only node, and every Task::call inside it is a durable step of that node.

    Entrypoint::run

    async fn[I, O] Entrypoint::run(self : Entrypoint[I, O], input : I, thread? : String) -> O raise GraphError

    Run the entrypoint and return what its body returned.

    Event

    pub(all) enum Event[S, U] {
    Values(S)
    Updates(node~ : String, U)
    Custom(node~ : String,
    Json
    )
    Started(node~ : String)
    Finished(node~ : String)
    Interrupted(
    Json
    )
    }

    What a run reports while it happens. The stream modes of a graph engine are one type here: the caller takes the variants it needs and ignores the rest.

    Frame

    The state a functional graph runs on: the input the entrypoint was called with, and the value it returned. Keeping both as JSON is what makes a half-finished entrypoint resumable — the input is still there to replay from.

    Frame::to_json

    Graph

    pub struct Graph[S, U] {
    name : String
    merge : (S, U) -> S
    initial : S
    entry : String
    nodes : Map[String, NodeSpec[S, U]]
    edges : Map[String, Array[String]]
    joins : Array[(Array[String], String)]
    branches : Map[String, Array[async (S) -> Array[String]]]
    saver : &Saver?
    store : &Store?
    executor : &Executor
    limit : Int
    }

    A compiled graph: the nodes, the edges between them, and the three services a run leans on — a saver for checkpoints, a store for long-term memory, and an executor for where the nodes run. Everything structural was checked in compile, so a run only fails for reasons the run itself discovers.

    Graph::history

    async fn[S :
    FromJson
    , U] Graph::history(self : Graph[S, U], thread : String) -> Array[Snapshot[S]] raise GraphError

    Every checkpoint of the thread, oldest first: the path a run took, including the ones an update forked away from.

    Graph::limit

    fn[S, U] Graph::limit(self : Graph[S, U]) -> Int

    The superstep bound this graph was compiled with.

    Graph::name

    fn[S, U] Graph::name(self : Graph[S, U]) -> String

    Graph::resume_stream

    resume_with, with every event of the continued run reported to on.

    Graph::resume_with

    Continue a thread that stopped at an interrupt, handing the waiting node answer through Run::answer.

    Graph::run

    Run input through the graph under thread and return the final state.

    A thread with a checkpoint continues from it: a run that crashed is picked up at its last superstep, and one that finished starts a new turn. Checkpoints are written only when the graph was compiled with a saver; without one the run is still superstep-based, it just has nothing to come back to.

    Graph::state

    async fn[S :
    FromJson
    , U] Graph::state(self : Graph[S, U], thread : String) -> Snapshot[S]? raise GraphError

    The thread as it stands, or None when it has no checkpoint.

    Graph::stream

    async fn[S :
    ToJson
    +
    FromJson
    , U :
    ToJson
    +
    FromJson
    ] Graph::stream(self : Graph[S, U], input : U, on : async (Event[S, U]) -> Unit, thread? : String) -> S raise GraphError

    run, with every event reported to on as it happens.

    Graph::update

    async fn[S :
    ToJson
    +
    FromJson
    , U] Graph::update(self : Graph[S, U], thread : String, update : U, as_node? : String) -> Unit raise GraphError

    Write update into the thread's latest state as if a node had written it, and queue as_node — or, when absent, the tasks of the step that produced the checkpoint — to run next. A run afterwards continues from there, which is how a finished thread is taken back to an earlier decision.

    Item

    pub(all) struct Item {
    space : Array[String]
    key : String
    value :
    Json

    }

    One remembered value: a space, a key inside it, and the value itself.

    MemorySaver

    pub struct MemorySaver {
    threads : Map[String, Array[Checkpoint]]
    writes : Map[String, Array[TaskWrite]]
    }

    A saver that keeps everything in memory: enough for a run that has to survive a failed node, not the process it runs in.

    MemorySaver::delete

    async fn MemorySaver::delete(self : MemorySaver, thread : String) -> Unit

    MemorySaver::get

    async fn MemorySaver::get(self : MemorySaver, thread : String, id? : String) -> Checkpoint?

    MemorySaver::list

    async fn MemorySaver::list(self : MemorySaver, thread : String, limit? : Int, before? : String) -> Array[Checkpoint]

    MemorySaver::new

    MemorySaver::put

    async fn MemorySaver::put(self : MemorySaver, checkpoint : Checkpoint) -> Unit

    MemorySaver::put_writes

    async fn MemorySaver::put_writes(self : MemorySaver, thread : String, id : String, task : String, values : Array[
    Json
    ]) -> Unit

    MemoryStore

    pub struct MemoryStore {
    items : Map[String, Item]
    }

    A store that keeps everything in memory, for tests and for short-lived processes.

    MemoryStore::delete

    async fn MemoryStore::delete(self : MemoryStore, space : Array[String], key : String) -> Unit

    MemoryStore::get

    async fn MemoryStore::get(self : MemoryStore, space : Array[String], key : String) -> Item?

    MemoryStore::new

    MemoryStore::put

    async fn MemoryStore::put(self : MemoryStore, space : Array[String], key : String, value :
    Json
    ) -> Unit

    MemoryStore::search

    async fn MemoryStore::search(self : MemoryStore, space : Array[String], query? : String, filter? : Map[String,
    Json
    ], limit? : Int) -> Array[Item]

    NodeSpec

    type NodeSpec[S, U]

    One node as the builder collected it: what to run, and how often to try.

    Pending

    A node queued for a superstep, as a checkpoint records it. input is the state the node is handed: absent means the state as merged at that point, present means a Send gave it its own.

    Pending::to_json

    Retry

    pub(all) struct Retry {
    attempts : Int
    delay : Int
    }

    How often a node is tried before its failure ends the run, and how long to wait between tries. attempts counts the first try: attempts of 1 means no retry at all.

    Run

    pub struct Run {
    thread : String
    step : Int
    checkpoint : String
    answer :
    Json
    ?
    store : &Store?
    saver : &Saver?
    cancelled :
    Ref
    [Bool]
    emit : (
    Json
    ) -> Unit
    writes : Array[TaskWrite]
    }

    What a node is handed besides the state: which thread it belongs to, which superstep it is running in, where to put long-term memory, how to report progress, and how to stop.

    A run handle belongs to one task. Calling cancel on it stops the thread at the next superstep boundary — never in the middle of a node, so what a node wrote before asking to stop is kept.

    Run::answer

    fn Run::answer(self : Run) ->
    Json
    ?

    The answer resume was called with, for the node that asked for it.

    Run::cancel

    fn Run::cancel(self : Run) -> Unit

    Ask the thread to stop at the next superstep boundary.

    Run::checkpoint

    fn Run::checkpoint(self : Run) -> String

    The checkpoint this superstep's writes are filed under. A node that keeps its own progress in Saver::put_writes needs it.

    Run::emit

    fn Run::emit(self : Run, value :
    Json
    ) -> Unit

    Report progress to whoever is streaming this run. Does nothing when nobody is.

    Run::is_cancelled

    fn Run::is_cancelled(self : Run) -> Bool

    Run::saver

    fn Run::saver(self : Run) -> &Saver?

    The saver the graph was compiled with, if any. A node that runs another graph hands it on, so the child's checkpoints land next to the parent's.

    Run::step

    fn Run::step(self : Run) -> Int

    The superstep this task belongs to, counting from zero.

    Run::store

    fn Run::store(self : Run) -> &Store?

    The long-term store the graph was compiled with, if any.

    Run::thread

    fn Run::thread(self : Run) -> String

    Run::writes

    fn Run::writes(self : Run) -> Array[TaskWrite]

    The writes already recorded for this checkpoint's tasks. A node that runs other durable steps itself — the functional form does — reads them back here to skip work a crashed run had already finished.

    Snapshot

    pub(all) struct Snapshot[S] {
    thread : String
    id : String
    parent : String?
    step : Int
    state : S
    next : Array[String]
    interrupt :
    Json
    ?
    created_at : Int64
    }

    A checkpoint seen through the graph's own state type: what a caller asks for when it wants to look at a thread, not to resume it.

    Step

    pub(all) enum Step[S, U] {
    Write(U)
    Goto(String)
    Send(String, S)
    Interrupt(
    Json
    )
    }

    What one node contributes to the superstep it took part in. A node returns a list of these, applied in the order it wrote them: writes fold into the state, Goto and Send decide who runs next, and Interrupt stops the run there.

    Step::decode

    Read back a step written by encode. A step that cannot be decoded cannot be applied, and guessing at it would corrupt the state silently.

    Step::encode

    The wire form of a step: what a Work carries back from wherever the node ran. Executors only ever see this, which is what lets a node run in another process without the graph itself crossing the boundary.

    Task

    pub struct Task[I, O] {
    name : String
    run : async (I) -> O
    retry : Retry?
    }

    A step that is recorded when it finishes. Calling one through Task::call writes its result to the checkpoint before the entrypoint moves on, so a run that replays after a crash reuses the result instead of doing the work twice.

    Task::call

    Call a task: return its recorded result if this call already ran before the crash, otherwise run it and record what it returned.

    TaskWrite

    The steps one task produced, kept apart from the checkpoint record so that a finished task is never run twice after a crash.

    TaskWrite::to_json

    Work

    pub(all) struct Work {
    graph : String
    node : String
    thread : String
    step : Int
    attempt : Int
    input : () ->
    Json

    run : async () -> Array[
    Json
    ]
    }

    One node's turn to run, as the executor sees it. input is a function so the state is encoded only when it actually crosses a process boundary; run is what an in-process executor calls, and what an executor that ships the task elsewhere ignores — it finds the node by graph and node on the far side.

    default_limit

    let default_limit : Int

    How many supersteps a run may take before it is stopped. This is the bound a graph made of cycles relies on; it is LangGraph 1.2's default recursion limit of 25 (libs/langgraph/langgraph/constants.py), kept the same so a graph ported from there needs no new number.

    entrypoint

    fn[I :
    ToJson
    +
    FromJson
    , O :
    ToJson
    +
    FromJson
    ] entrypoint(name : String, body : async (I, Context) -> O, saver? : &Saver, store? : &Store, executor? : &Executor, limit? : Int) -> Entrypoint[I, O] raise Refused

    Wrap body as a graph with one node. The body may call tasks; each call is recorded, and a resumed run replays the body from the start and reuses the results. The body itself has to be deterministic apart from those calls — replaying it is how the run gets back to where it stopped.

    inline

    let inline : &Executor

    The default executor: every task in this process, at the same time.

    retry

    let retry : Retry

    Three tries with no pause, the default a durable workflow wants for a step that talks to something outside the process.

    task

    fn[I, O] task(name : String, run : async (I) -> O, retry? : Retry) -> Task[I, O]