Nodes and transitions
Here you build a linear task from two closures and a node type of your own, run it, and find out which node failed when something goes wrong.
Import the task types from swiftide::tasks. Tasks overview shows the feature flag to turn on.
use std::num::ParseIntError;
use swiftide::{ reexports::async_trait::async_trait, tasks::{DynNodeId, NodeError, Task, TaskError, TaskNode, TaskRunOutcome, Transition},};Source: snippets/src/tasks/nodes_and_transitions.rs#L4-L9
Register the nodes, connect them, and run the task:
let mut task: Task<String, String> = Task::new();
// A sync closure node. `Ok::<_, NodeError>` names the error type.let trim = task.register_node_fn(|text: &String| Ok::<_, NodeError>(text.trim().to_string()));
// A node type that implements `TaskNode`.let count = task.register_node(CountWords);
// A closure that returns a pinned, boxed future. Copy what it needs before `async move`.let label = task.register_node_async_fn(|words: &usize| { let words = *words; Box::pin(async move { Ok::<_, NodeError>(format!("{words} words")) })});
task.starts_with(trim);task.register_transition(trim, move |text: String| { if text.is_empty() { return Transition::error("there is no text to count"); } count.transitions_with(text).into()})?;task.register_transition(count, move |words| label.transitions_with(words))?;task.register_transition(label, task.transitions_to_finish())?;
match task.run(" nodes and transitions ").await? { TaskRunOutcome::Completed(summary) => println!("{summary}"), TaskRunOutcome::Paused => println!("paused; call task.resume() to continue"),}Source: snippets/src/tasks/nodes_and_transitions.rs#L36-L63
The run prints 3 words.
Task<String, String> fixes the types at both ends. Each node declares its own input and output, so a String becomes a usize and then a String again.
register_node_fn turns a synchronous closure into a node, and register_node_async_fn does the same for a closure that returns a pinned, boxed future (Box::pin(async move { .. })). register_node takes any value that implements TaskNode. All three return a NodeId, the typed handle you wire the node with. NodeId is Copy, so you move it into transition closures without cloning. id() returns its number, assigned in registration order from 0.
starts_with picks the start node, and register_transition says where to go after a node. transitions_with moves on to another node with a value, and transitions_to_finish hands the task its output. Inside a fan-out branch, that output goes to the join instead of ending the task.
run returns a TaskRunOutcome. Completed carries the output. Paused is covered in Pause and resume.
Task nodes
Section titled “Task nodes”A task node is a type that implements TaskNode:
/// Counts the words in a text.#[derive(Clone, Debug)]pub struct CountWords;
#[async_trait]impl TaskNode for CountWords { type Input = String; type Output = usize; type Error = std::convert::Infallible;
async fn evaluate( &self, _node_id: &DynNodeId<Self>, text: &Self::Input, ) -> Result<Self::Output, Self::Error> { Ok(text.split_whitespace().count()) }}Source: snippets/src/tasks/nodes_and_transitions.rs#L13-L30
Input is what the node receives and Output is what it returns. Error can be any std::error::Error + Send + Sync + 'static; CountWords cannot fail, so it uses Infallible.
evaluate borrows both the node and the input. The &DynNodeId<Self> argument is the node’s own id with its concrete type erased, and most nodes ignore it. Write the impl with #[async_trait], which Swiftide re-exports as swiftide::reexports::async_trait.
Input and Output must implement NodeArg, which covers every type that is Send + Sync + Debug + Clone + 'static. A missing derive shows up as an unmet NodeArg bound. register_node also needs the node itself to be Clone.
A run does not clone your node. Every evaluation calls the same instance through &self, so keep per-run state out of the struct.
Closure nodes
Section titled “Closure nodes”The workspace-architect showcase registers its nodes in one block. discover and write are closure nodes:
let discover = task.register_node_fn({ let progress = progress.clone(); move |root: &WorkspaceRoot| { let members = workspace::discover(root, settings.selection)?; progress.step("discover", format_args!("{members} in {root}")); Ok::<_, workspace::DiscoveryError>(members) }});
let analyse = task.register_node(CrateAnalyst::new(llm.clone(), progress.clone()));
let join = task.register_node_fn({ let progress = progress.clone(); move |summaries: &JoinInput<CrateSummary>| { progress.joined(summaries.len()); architecture_prompt(summaries) }});
// `Arc<dyn SimplePrompt>` is a task node out of the box: `Prompt` in, `String` out.let render = task.register_node(Arc::new(llm.clone()) as Arc<dyn SimplePrompt>);
let review = task.register_node(ApprovalGate::new(settings.pre_approved, progress.clone()));
let write = task.register_node_async_fn({ let output = settings.output; let progress = progress.clone(); move |draft: &ArchitectureDraft| { let output = output.clone(); let progress = progress.clone(); Box::pin(async move { let written = draft.write_to(&output).await?; progress.step( "write", format_args!("{} ({} bytes)", written.path.display(), written.bytes), ); Ok::<_, std::io::Error>(written) }) }});Source: examples/workspace-architect/src/graph.rs#L47-L86
discoveris a sync closure.Ok::<_, workspace::DiscoveryError>(members)pins its error type, so the?inside converts into it.writeis a plain closure that returnsBox::pin(async move { .. }).
The other four nodes have their own pages: Agents as task nodes, Fan-out and join, Built-in nodes and Pause and resume.
A closure node needs a concrete error type. Name it with Ok::<_, E>(..) or a return type such as -> Result<i32, NodeError>, unless the closure already returns a typed Result, as str::parse does.
The closure is Fn and runs once per evaluation, so each future needs its own copy of the captures. That is why write clones output and progress before async move. The future may still borrow the input, here draft.
For reusable domain logic, implement TaskNode instead.
Transitions
Section titled “Transitions”examples/tasks.rs chains a prompt node, an agent node and a second prompt node:
// Tasks are typed from input to output. This task starts with a `Prompt` and finishes with the// final teammate-facing hand-off note.let mut task: Task<Prompt, String> = Task::new();
// Convenience helper: `Arc<dyn SimplePrompt>` already implements `TaskNode`, so prompt-model// steps can be registered directly without any wrapper code.let prompt_step: Arc<dyn SimplePrompt> = Arc::new(openai.clone());let brief_prompt_id = task.register_node(prompt_step.clone());let handoff_prompt_id = task.register_node(prompt_step);
// Custom node: the agent step owns the stop-tool contract and returns structured task data.let agent_id = task.register_node(BriefingAgent::new(agent));
task.starts_with(brief_prompt_id);
// Step 1: turn the incoming request into a short brief that the agent can work from.task.register_transition(brief_prompt_id, move |brief| { agent_id.transitions_with(brief)})?;
// Step 2: use the structured stop payload to drive a final LLM rendering step.task.register_transition(agent_id, move |decision: BriefingDecision| { Transition::next( &handoff_prompt_id, Prompt::from( "Write a teammate-facing hand-off note.\n\ Audience: {{audience}}\n\ Summary: {{summary}}\n\ Next steps:\n\ {% for step in next_steps %}- {{ step }}\n{% endfor %}", ) .with_context_value("audience", decision.audience) .with_context_value("summary", decision.summary) .with_context_value("next_steps", decision.next_steps), )})?;
// Step 3: the rendered hand-off note becomes the task output.task.register_transition(handoff_prompt_id, task.transitions_to_finish())?;Source: examples/tasks.rs#L157-L195
A transition closure receives the node’s output by value and returns where to go next. agent_id.transitions_with(brief) is shorthand for Transition::next(&agent_id, brief). Both return a MarkedTransition that carries the target’s type, so the compiler checks the value against the target’s Input.
Each node has exactly one transition, and registering a second one returns TaskError::InvalidState. To branch, return different transitions from that one closure. When the arms point at nodes of different types, or one arm pauses or fails, convert each MarkedTransition into a Transition with .into(), as the linear task does.
Transition::error fails the whole run, drops any in-flight fan-out branches and leaves nothing to resume. It accepts anything that converts into Box<dyn Error + Send + Sync>, including a &str. Transition::pause() pauses the branch.
When the decision needs to await something, use register_transition_async. Its closure returns a future, such as move |value| async move { next.transitions_with(value) }.
Start and finish
Section titled “Start and finish”The start node’s Input must be the task’s Input, or starts_with does not compile. run takes impl Into<Input>, so the linear task can pass a &str to a Task<String, String>.
transitions_to_finish() returns a closure that takes the task’s Output. The finish constructor on Transition is private, so the compiler checks the value against that task’s output type. A finish closure taken from a different task still compiles, and run then fails with TaskError::TypeError.
Code from 0.32 called transitions_to_done and got an Option from run. Upgrading lists the 0.33 changes.
Errors
Section titled “Errors”Match on the result of run to tell a failing node from a wiring mistake:
let mut task: Task<String, u32> = Task::new();
let parse = task.register_node_fn(|text: &String| text.trim().parse::<u32>());task.starts_with(parse);task.register_transition(parse, task.transitions_to_finish())?;
match task.run("forty-two").await { Ok(TaskRunOutcome::Completed(number)) => println!("parsed {number}"), Ok(TaskRunOutcome::Paused) => println!("paused"), Err(TaskError::NodeError(failed)) => { // `node_error` is the error the node returned, boxed. if let Some(error) = failed.node_error.downcast_ref::<ParseIntError>() { eprintln!("node {} could not parse the input: {error}", failed.node_id); } } Err(wiring @ (TaskError::NoSteps | TaskError::MissingTransition(_))) => { // `run` checks the graph before the first node runs. panic!("the task is wired wrong: {wiring}"); } Err(other) => return Err(other.into()),}Source: snippets/src/tasks/nodes_and_transitions.rs#L72-L92
A failing node makes run return TaskError::NodeError. The NodeError inside carries node_id, the number of the failing node, and node_error, the node’s own error boxed. Downcast node_error to read your error type. The error displays as Node error in node 0: invalid digit found in string. A Transition::error produces the same variant, with the id of the node whose transition failed.
run and resume check the graph before any node runs. Without a start node you get TaskError::NoSteps. When any registered node has no transition, you get TaskError::MissingTransition, even when no path reaches that node, with a message such as Node 3 is missing a transition.
TaskError::Incomplete means all work drained without a finish or a pause.