From ed9aa54159b59675da947fbfaba1f3d28c4d8c20 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:41:55 +0300 Subject: [PATCH 001/138] chore: files changed src/caps/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/approval.rs | 170 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/caps/approval.rs diff --git a/src/caps/approval.rs b/src/caps/approval.rs new file mode 100644 index 0000000..c9878ed --- /dev/null +++ b/src/caps/approval.rs @@ -0,0 +1,170 @@ +//! Human-in-the-loop review: the [`ApprovalProvider`] capability an `approval` +//! node reaches the human through. +//! +//! # Why this is a capability at all +//! +//! An `approval` node presents *something* — a URL, a block of text, a +//! generated payload — to a person and waits for an approve/reject. Everything +//! about how that reaches a human is the host's: a Slack card, an inbox row, a +//! web review queue, a phone notification. The crate stays out of it and asks +//! only two things of the host, both expressed here: **register/fetch** a +//! review request, and (optionally) **cancel** one it will never wait on again. +//! +//! # The idempotency contract (load-bearing) +//! +//! [`ApprovalProvider::decide`] is a **create-or-fetch** call keyed on +//! [`ApprovalRequest::request_id`]: the first call with a given id creates the +//! review, every later call with that same id returns the state of *that* +//! review and must not create a second one. +//! +//! This is not a nicety. A node that pauses the run with +//! [`NodeControl::Interrupt`](crate::nodes::NodeControl::Interrupt) has its +//! state update discarded, so on resume it re-runs from the top and calls +//! `decide` again — and a polling node calls it once per poll. A provider that +//! created a fresh review per call would spam the human with a new card every +//! time the run looked at the world. The node derives a stable `request_id` from +//! the run and node id (or takes one from config), so honouring it is enough. +//! +//! # Optional, like [`MemoryProvider`](crate::caps::MemoryProvider) +//! +//! Hosts that wire no provider leave [`Capabilities::approvals`](crate::caps::Capabilities::approvals) +//! `None`. An `approval` node then falls back to the engine's existing +//! pause/resume channel: it interrupts the run naming itself on +//! [`RunOutcome::pending_approvals`](crate::engine::RunOutcome::pending_approvals), +//! and the host settles it out of band with +//! [`engine::resume`](crate::engine::resume). That fallback is deliberate — +//! a host that already has a review surface bolted onto run resumption should +//! not have to implement a trait to use this node. + +use async_trait::async_trait; +use serde_json::Value; + +use crate::error::Result; + +/// What the human is being asked to look at. +/// +/// `kind` is an **opaque, host-defined** rendering hint (the model layer never +/// interprets it), following the `scope` precedent in +/// [`MemoryProvider`](crate::caps::MemoryProvider). The conventional values are +/// `"url"`, `"text"`, `"markdown"`, and `"json"`; a host is free to define more +/// and a host that renders everything the same way may ignore it entirely. +#[derive(Debug, Clone, PartialEq)] +pub struct ApprovalSubject { + /// Rendering hint — `"url"` / `"text"` / `"markdown"` / `"json"` by + /// convention, host-defined in general. + pub kind: String, + /// The thing itself: the URL string, the prose, the payload. + pub value: Value, +} + +/// One review request handed to the host. +#[derive(Debug, Clone, PartialEq)] +pub struct ApprovalRequest { + /// Stable identity of *this* review, and the key the create-or-fetch + /// contract is built on (see the module docs). Derived from the run and + /// node id unless the node's `config.request_id` overrides it, so it is the + /// same string across an interrupt/resume and across every poll. + pub request_id: String, + /// The node asking, for a host that wants to link the review back to the + /// graph. + pub node_id: String, + /// The run this review belongs to, when the run state carries one. + pub run_id: Option, + /// Short human-facing headline (`config.title`). + pub title: Option, + /// Fuller ask — what approving actually authorizes (`config.prompt`). + pub prompt: Option, + /// What is being reviewed. + pub subject: ApprovalSubject, + /// Opaque host-resolved reviewer handles (user ids, emails, a channel, a + /// role name). The crate never interprets these. + pub assignees: Vec, + /// Anything else the graph attached for the host's benefit + /// (`config.metadata`), passed through untouched. + pub metadata: Value, +} + +/// A human's verdict on an [`ApprovalRequest`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ApprovalDecision { + /// `true` for approve, `false` for reject. Binary on purpose: an + /// n-way review is a `switch` on data, not an approval gate. + pub approved: bool, + /// Opaque host handle for whoever decided. + pub decided_by: Option, + /// Free-text note the reviewer left, surfaced on the emitted item so a + /// rejection branch can act on the reason. + pub comment: Option, + /// The subject as the human left it, when the host's review surface lets + /// them edit before approving. `None` means "unchanged", and the node emits + /// the subject it sent. + pub payload: Option, +} + +impl ApprovalDecision { + /// An approval with no reviewer, comment, or edit recorded. + #[must_use] + pub fn approved() -> Self { + Self { + approved: true, + decided_by: None, + comment: None, + payload: None, + } + } + + /// A rejection carrying an optional reason. + #[must_use] + pub fn rejected(comment: Option) -> Self { + Self { + approved: false, + decided_by: None, + comment, + payload: None, + } + } +} + +/// Where a review stands when the host is asked. +#[derive(Debug, Clone, PartialEq)] +pub enum ApprovalOutcome { + /// Nobody has decided yet. The node waits — by suspending the run or by + /// polling, per its `wait_mode`. + Pending, + /// A human decided. + Decided(ApprovalDecision), +} + +/// Host-implemented delivery of an approve/reject decision to a human. +/// +/// See the module docs for the create-or-fetch contract every implementation +/// must honour, and for what happens on hosts that wire no provider at all. +#[async_trait] +pub trait ApprovalProvider: Send + Sync { + /// Registers `request` for human review, or — if a review with that + /// `request_id` already exists — returns where that one stands. + /// + /// Must be idempotent on [`ApprovalRequest::request_id`]: this is called + /// again on every poll and after every resume, and a provider that creates a + /// new review per call notifies the human once per call. + /// + /// # Errors + /// Returns an [`EngineError::Capability`](crate::error::EngineError::Capability) + /// when the review cannot be created or read. + async fn decide(&self, request: &ApprovalRequest) -> Result; + + /// Withdraws a review nobody will wait on any more (the node timed out, the + /// run was cancelled), so a stale card does not sit in a human's queue. + /// + /// Best-effort by design: the default implementation does nothing, and the + /// node logs rather than fails when this errors — the run has already + /// decided what to do by the time it is called. + /// + /// # Errors + /// Returns an [`EngineError::Capability`](crate::error::EngineError::Capability) + /// when the host knows the withdrawal failed. + async fn cancel(&self, request_id: &str, reason: &str) -> Result<()> { + let _ = (request_id, reason); + Ok(()) + } +} From a895d9412a672c7fd48aca61788e328c1588ecb3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:42:02 +0300 Subject: [PATCH 002/138] chore: files changed src/caps/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mod.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/caps/mod.rs b/src/caps/mod.rs index 81a2d59..b35f4c1 100644 --- a/src/caps/mod.rs +++ b/src/caps/mod.rs @@ -6,6 +6,7 @@ //! curated Composio tools, `HttpRequestTool`, and sandboxed code runtimes. pub mod agent; +pub mod approval; #[cfg(any(test, feature = "host-caps"))] pub mod host; #[cfg(any(test, feature = "mock"))] @@ -24,6 +25,9 @@ pub use self::agent::{ AgentInput, AgentModelSelection, AgentRunIdentity, AgentRunOutcome, AgentRunRequest, AgentRunner, AgentUsage, ContextBlock, StopReason, ToolDescriptor, }; +pub use self::approval::{ + ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject, +}; pub use self::shell::{ShellInterpreter, ShellOutcome, ShellRequest, ShellRunner, ShellScript}; pub use self::tasks::{TaskRunner, TaskSpec, TaskState, TokioTaskRunner}; @@ -247,6 +251,13 @@ pub struct Capabilities { /// rather than a correctness one, which is exactly why it is worth saying /// out loud here and in the node catalog. pub tasks: Option>, + /// Optional human-review surface for `approval` nodes. `None` on hosts + /// without one, in which case an `approval` node falls back to pausing the + /// run and letting the host settle it through + /// [`engine::resume`](crate::engine::resume) — so the node still works, it + /// just has no way to *push* the request at anybody. See + /// [`ApprovalProvider`] for the create-or-fetch contract. + pub approvals: Option>, } #[cfg(test)] From 5e1521831d5dfcc7ed0f35b17bd08598400b375d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:42:12 +0300 Subject: [PATCH 003/138] chore: files changed src/caps/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/caps/mod.rs b/src/caps/mod.rs index b35f4c1..555f95a 100644 --- a/src/caps/mod.rs +++ b/src/caps/mod.rs @@ -7,6 +7,7 @@ pub mod agent; pub mod approval; +pub mod approval; #[cfg(any(test, feature = "host-caps"))] pub mod host; #[cfg(any(test, feature = "mock"))] @@ -28,6 +29,9 @@ pub use self::agent::{ pub use self::approval::{ ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject, }; +pub use self::approval::{ + ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject, +}; pub use self::shell::{ShellInterpreter, ShellOutcome, ShellRequest, ShellRunner, ShellScript}; pub use self::tasks::{TaskRunner, TaskSpec, TaskState, TokioTaskRunner}; From 5f08086ff1c673829c5806b1a4a58239580b7a5d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:42:40 +0300 Subject: [PATCH 004/138] chore: files changed src/caps/mock.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index 3f222ba..52bca55 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -375,6 +375,106 @@ impl MemoryProvider for MockMemory { } } +/// An [`ApprovalProvider`] that answers every review the same way, without a +/// human anywhere. +/// +/// Defaults to approving, so a graph containing an `approval` node dry-runs +/// end-to-end out of the box (the [`MockMemory`] precedent). Use +/// [`rejecting`](Self::rejecting) to drive the reject branch and +/// [`pending`](Self::pending) to exercise the waiting path — a suspending node +/// then pauses the run, and a polling one spends its poll budget. +/// +/// Records every `request_id` it has seen so a test can assert the +/// create-or-fetch contract holds (one review per id, however many activations +/// the node had). +#[derive(Debug, Default)] +pub struct MockApprovals { + outcome: MockApprovalOutcome, + seen: std::sync::Mutex>, + cancelled: std::sync::Mutex>, +} + +/// What a [`MockApprovals`] answers with. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum MockApprovalOutcome { + /// Approve immediately. + #[default] + Approve, + /// Reject immediately. + Reject, + /// Never decide. + Pending, +} + +impl MockApprovals { + /// A provider that approves every request. + #[must_use] + pub fn approving() -> Self { + Self::default() + } + + /// A provider that rejects every request, with `"mock rejection"` as the + /// reviewer's comment. + #[must_use] + pub fn rejecting() -> Self { + Self { + outcome: MockApprovalOutcome::Reject, + ..Self::default() + } + } + + /// A provider that leaves every request pending forever. + #[must_use] + pub fn pending() -> Self { + Self { + outcome: MockApprovalOutcome::Pending, + ..Self::default() + } + } + + /// Every `request_id` passed to [`ApprovalProvider::decide`], in call order + /// (with repeats — the point is to show repeats are the *same* id). + #[must_use] + pub fn requested(&self) -> Vec { + self.seen.lock().expect("lock").clone() + } + + /// Every `request_id` passed to [`ApprovalProvider::cancel`]. + #[must_use] + pub fn cancelled(&self) -> Vec { + self.cancelled.lock().expect("lock").clone() + } +} + +#[async_trait] +impl ApprovalProvider for MockApprovals { + async fn decide(&self, request: &ApprovalRequest) -> Result { + self.seen + .lock() + .expect("lock") + .push(request.request_id.clone()); + Ok(match self.outcome { + MockApprovalOutcome::Approve => ApprovalOutcome::Decided(ApprovalDecision { + decided_by: Some("mock-reviewer".to_string()), + ..ApprovalDecision::approved() + }), + MockApprovalOutcome::Reject => ApprovalOutcome::Decided(ApprovalDecision { + decided_by: Some("mock-reviewer".to_string()), + ..ApprovalDecision::rejected(Some("mock rejection".to_string())) + }), + MockApprovalOutcome::Pending => ApprovalOutcome::Pending, + }) + } + + async fn cancel(&self, request_id: &str, _reason: &str) -> Result<()> { + self.cancelled + .lock() + .expect("lock") + .push(request_id.to_string()); + Ok(()) + } +} + /// A [`StateStore`] backed by an in-memory map guarded by a mutex. #[derive(Debug, Default)] pub struct MockStateStore { @@ -457,6 +557,9 @@ pub fn mock_capabilities_with_resolver(resolver: impl WorkflowResolver + 'static // The real tokio-backed runner, so `spawn`/`gate` behave under test the // way they behave for a host that wires nothing. tasks: Some(Arc::new(crate::caps::TokioTaskRunner::new())), + // Wired by default, like `memory`: a graph with an `approval` node must + // dry-run without the host standing up a review surface first. + approvals: Some(Arc::new(MockApprovals::approving())), } } @@ -481,6 +584,17 @@ pub fn mock_capabilities_with_memory(memory: impl MemoryProvider + 'static) -> C } } +/// Like [`mock_capabilities`], but with a caller-supplied [`ApprovalProvider`] +/// in place of the default approve-everything [`MockApprovals`] — for tests +/// that need a rejection, a pending review, or a host-shaped decision. +#[must_use] +pub fn mock_capabilities_with_approvals(approvals: impl ApprovalProvider + 'static) -> Capabilities { + Capabilities { + approvals: Some(Arc::new(approvals)), + ..mock_capabilities() + } +} + #[cfg(test)] #[path = "mock_tests.rs"] mod tests; From 37ef0df219a1797c2700d56d90a1956287d24a95 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:42:45 +0300 Subject: [PATCH 005/138] chore: files changed src/caps/mock.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index 52bca55..7598d47 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -10,9 +10,9 @@ use async_trait::async_trait; use serde_json::{Value, json}; use crate::caps::{ - AgentRunner, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider, - ShellOutcome, ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, - WorkflowResolver, + AgentRunner, ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, + Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider, ShellOutcome, + ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, WorkflowResolver, }; use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; From fc97a0defbe75e63ea654035d844ac7980d2619c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:42:56 +0300 Subject: [PATCH 006/138] chore: files changed src/model/node_kind.rs,src/nodes/execution.rs,src/nodes/integration/mod.rs,src/ Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/model/node_kind.rs | 15 +++++++++++++++ src/nodes/execution.rs | 1 + src/nodes/integration/mod.rs | 2 ++ src/visualization.rs | 1 + 4 files changed, 19 insertions(+) diff --git a/src/model/node_kind.rs b/src/model/node_kind.rs index d6401d5..6710301 100644 --- a/src/model/node_kind.rs +++ b/src/model/node_kind.rs @@ -90,6 +90,21 @@ pub enum NodeKind { /// Where lanes end: routing to a gather is a plain activation, so N lanes /// converge on one gather rather than each running their own. Gather, + /// Presents a subject — a URL, a block of text, a generated payload — to a + /// **human** for an approve/reject, and routes on their answer. + /// + /// Distinct from the `requires_approval` flag, which gates a node that + /// would otherwise run: this *is* the review step, it carries what is being + /// reviewed, and its verdict is data (who decided, their comment, any edit + /// they made) that the graph can branch on via its `approved` / `rejected` + /// ports. + /// + /// Reaches the human through the optional + /// [`ApprovalProvider`](crate::caps::ApprovalProvider) capability. With none + /// injected it falls back to pausing the run, so the host settles it with + /// [`engine::resume`](crate::engine::resume) the way it already settles an + /// approval gate. + Approval, /// Waits for tickets — from [`NodeKind::Spawn`], or named by expression — /// and emits their results once its release policy is satisfied. /// diff --git a/src/nodes/execution.rs b/src/nodes/execution.rs index 830a24f..620ec98 100644 --- a/src/nodes/execution.rs +++ b/src/nodes/execution.rs @@ -336,6 +336,7 @@ pub(crate) fn executor_for(kind: &NodeKind) -> Box { NodeKind::Gather => Box::new(control_flow::GatherNode), NodeKind::Spawn => Box::new(integration::SpawnNode), NodeKind::Gate => Box::new(integration::GateNode), + NodeKind::Approval => Box::new(integration::ApprovalNode), NodeKind::Loop => Box::new(control_flow::LoopNode), } } diff --git a/src/nodes/integration/mod.rs b/src/nodes/integration/mod.rs index 04a0a43..d4bc10c 100644 --- a/src/nodes/integration/mod.rs +++ b/src/nodes/integration/mod.rs @@ -5,6 +5,7 @@ //! One module per node kind so parallel work can edit them without conflicts. pub mod agent; +pub(crate) mod approval; pub(crate) mod agent_request; pub mod code; pub(crate) mod envelope; @@ -19,6 +20,7 @@ pub mod sub_workflow; pub mod tool_call; pub use agent::AgentNode; +pub use approval::ApprovalNode; pub use code::CodeNode; pub use gate::GateNode; pub use http_request::HttpRequestNode; diff --git a/src/visualization.rs b/src/visualization.rs index d9e03ae..4eed115 100644 --- a/src/visualization.rs +++ b/src/visualization.rs @@ -335,6 +335,7 @@ fn kind_name(kind: &NodeKind) -> &'static str { NodeKind::Scatter => "scatter", NodeKind::Gather => "gather", NodeKind::Gate => "gate", + NodeKind::Approval => "approval", } } From 72a8a18c1ae354e173e42f155f3eea6d6e7bc525 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:44:43 +0300 Subject: [PATCH 007/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 491 ++++++++++++++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 src/nodes/integration/approval.rs diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs new file mode 100644 index 0000000..7ed9478 --- /dev/null +++ b/src/nodes/integration/approval.rs @@ -0,0 +1,491 @@ +//! The `approval` node: put something in front of a human, route on their answer. +//! +//! A workflow that posts, pays, emails, or deletes wants a person in the loop at +//! exactly one point, holding exactly one thing — a URL to look at, a draft to +//! read, a payload to sign off. That is what this node is: it carries the +//! **subject** of the review, hands it to the host's review surface through the +//! [`ApprovalProvider`](crate::caps::ApprovalProvider) capability, and emits the +//! verdict on its `approved` / `rejected` ports as ordinary data the graph can +//! branch on. +//! +//! # Not the same thing as `requires_approval` +//! +//! The `requires_approval` flag gates a node that would otherwise run: it says +//! "don't execute this until someone says go", carries nothing, and its answer +//! is a yes/no the graph cannot inspect. This node is the review *itself* — +//! addressable, with a subject, a reviewer, a comment, and a rejection branch. +//! Use the flag to hold back a dangerous node; use this kind when the decision +//! is a step in the workflow. +//! +//! # How waiting works +//! +//! Same two shapes as [`gate`](super::gate), and for the same reasons. A human +//! review is measured in minutes-to-days, so `wait_mode: "suspend"` (the +//! **default** here, unlike `gate`) interrupts the run: nothing is burned while +//! the card sits in someone's queue, and the host resumes the run when they +//! answer. `wait_mode: "poll"` re-activates the node on an interval instead, +//! which is right only when the decision is expected within seconds — each poll +//! costs a super-step and a node visit against the run's budgets, so the poll +//! count is bounded here rather than left to the run-level backstop. +//! +//! # Where a decision can come from +//! +//! In priority order, because more than one channel can be live at once: +//! +//! 1. **The resume value** ([`NodeContext::resume`]) — the checkpointed-resume +//! path, which replays from the checkpoint rather than re-running with a +//! merged run input, so it is the *only* channel there. A rejection in the +//! engine's `{"rejected": []}` shape wins over everything, matching +//! how a `requires_approval` gate treats a denial. +//! 2. **The run's approvals list** (`run.trigger.approvals`) — the +//! re-execute resume path, and how `engine::resume` has always delivered an +//! approval. Listing this node's id there approves it. +//! 3. **The host's [`ApprovalProvider`]** — asked once per activation, under +//! the create-or-fetch contract documented on the trait. +//! 4. **Nobody**, on a host that wired no provider: the node simply waits, so +//! it reduces to a pause the host settles through `engine::resume`. + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use crate::caps::{ApprovalDecision, ApprovalOutcome, ApprovalRequest, ApprovalSubject}; +use crate::data::Item; +use crate::error::{EngineError, Result}; +use crate::nodes::{NodeContext, NodeExecutor, NodeOutput, resolve_config_traced}; + +/// Default gap between polls, in milliseconds. A second, not the `gate`'s 250ms: +/// nothing a human does resolves faster than that. +const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000; + +/// Default ceiling on polls before the wait budget is called spent. With the +/// default interval that is a minute of waiting — deliberately short, because +/// polling is the wrong mode for a long review and the timeout should say so +/// rather than quietly spending a run's whole visit budget. +const DEFAULT_MAX_POLLS: u64 = 60; + +/// The slot key the poll count is recorded under, so it survives a checkpoint +/// the way a `loop` node's iteration does. +const POLLS_KEY: &str = "polls"; + +/// The default rendering hint when the graph does not say what the subject is. +const DEFAULT_SUBJECT_KIND: &str = "json"; + +/// How the node waits for a human. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WaitMode { + /// Interrupt the run; the host resumes it when the answer lands. The + /// default — a review is a human-timescale wait. + Suspend, + /// Re-activate on an interval and ask again. Only sane for a decision + /// expected within seconds. + Poll, +} + +impl WaitMode { + fn from_config(config: &Value) -> Self { + match config.get("wait_mode").and_then(Value::as_str) { + Some("poll") => Self::Poll, + _ => Self::Suspend, + } + } +} + +/// What a rejection does to the graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OnReject { + /// Emit the verdict on the `rejected` port (default), so a graph can run a + /// recovery branch — notify the author, revise, ask again. + Route, + /// Fail the node, letting the ordinary `on_error` policy take over. + Error, + /// Emit nothing and let the branch end here. + Drop, +} + +impl OnReject { + fn from_config(config: &Value) -> Self { + match config.get("on_reject").and_then(Value::as_str) { + Some("error") => Self::Error, + Some("drop") => Self::Drop, + _ => Self::Route, + } + } +} + +/// What a spent poll budget does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OnTimeout { + /// Fail the node (default): nobody answered, and pretending otherwise is + /// how an unreviewed payload ships. + Error, + /// Treat silence as a rejection and follow the `on_reject` policy. + Reject, + /// Emit on the `timeout` port, so escalation is its own branch. + Route, +} + +impl OnTimeout { + fn from_config(config: &Value) -> Self { + match config.get("on_timeout").and_then(Value::as_str) { + Some("reject") => Self::Reject, + Some("route") => Self::Route, + _ => Self::Error, + } + } +} + +/// Presents a subject to a human and routes on approve / reject. +#[derive(Debug, Default, Clone)] +pub struct ApprovalNode; + +/// Reads a positive integer config field, falling back to `default`. +fn positive_u64(config: &Value, key: &str, default: u64) -> u64 { + config + .get(key) + .and_then(Value::as_u64) + .filter(|n| *n > 0) + .unwrap_or(default) +} + +/// The run's host id, when the state carries one under any of the spellings a +/// host might seed (`run.id`, `run.run_id`, `run.trigger.run_id`). +fn run_id(ctx: &NodeContext<'_>) -> Option { + ["id", "run_id"] + .iter() + .find_map(|key| ctx.run.get(*key).and_then(Value::as_str)) + .or_else(|| { + ctx.run + .get("trigger") + .and_then(|t| t.get("run_id")) + .and_then(Value::as_str) + }) + .map(str::to_string) +} + +/// Builds the review request from the node's resolved config. +/// +/// The `request_id` is what makes the provider's create-or-fetch contract +/// work, so it must be **stable across activations**: an interrupt discards the +/// activation's state update, so the node cannot remember an id it generated, +/// and anything derived from the clock or a counter would create a fresh review +/// on every resume. Hence run id + node id, or an explicit `config.request_id` +/// for a host that wants to key reviews its own way. +fn build_request(ctx: &NodeContext<'_>, config: &Value) -> ApprovalRequest { + let run = run_id(ctx); + let request_id = config + .get("request_id") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| match &run { + Some(run) => format!("{run}:{}", ctx.node.id), + None => ctx.node.id.clone(), + }); + + // The subject defaults to the item that arrived, which is the common case: + // a node upstream produced the thing, and the human looks at it. + let value = config + .get("subject") + .cloned() + .or_else(|| ctx.input.first().map(|item| item.json.clone())) + .unwrap_or(Value::Null); + + ApprovalRequest { + request_id, + node_id: ctx.node.id.clone(), + run_id: run, + title: string_field(config, "title"), + prompt: string_field(config, "prompt"), + subject: ApprovalSubject { + kind: config + .get("subject_kind") + .and_then(Value::as_str) + .unwrap_or(DEFAULT_SUBJECT_KIND) + .to_string(), + value, + }, + assignees: config + .get("assignees") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + metadata: config.get("metadata").cloned().unwrap_or(Value::Null), + } +} + +/// A config field read as a string, ignoring a non-string (an unresolved +/// expression that came back `null`, say). +fn string_field(config: &Value, key: &str) -> Option { + config.get(key).and_then(Value::as_str).map(str::to_string) +} + +/// Whether `list` (an array of strings) names this node or its review. +fn names(list: Option<&Value>, request: &ApprovalRequest) -> bool { + list.and_then(Value::as_array).is_some_and(|ids| { + ids.iter() + .filter_map(Value::as_str) + .any(|id| id == request.node_id || id == request.request_id) + }) +} + +/// Reads a decision out of a resume value, if it carries one. +/// +/// Accepts the engine's own `{"rejected": […]}` denial shape (checked +/// first, so a denial always beats an approval delivered in the same value), +/// the mirror `{"approved": […]}`, and a full verdict object — either +/// inline or nested under `decision`. +fn decision_from_resume(resume: &Value, request: &ApprovalRequest) -> Option { + if names(resume.get("rejected"), request) { + return Some(ApprovalDecision::rejected( + resume + .get("comment") + .and_then(Value::as_str) + .map(str::to_string), + )); + } + if names(resume.get("approved"), request) { + return Some(ApprovalDecision::approved()); + } + + let verdict = resume.get("decision").unwrap_or(resume); + let approved = verdict.get("approved").and_then(Value::as_bool)?; + Some(ApprovalDecision { + approved, + decided_by: string_field(verdict, "decided_by"), + comment: string_field(verdict, "comment"), + payload: verdict.get("payload").cloned(), + }) +} + +/// The decision already in hand before the host is asked: a resume value, or +/// this node's id on the run's approvals list. +fn delivered(ctx: &NodeContext<'_>, request: &ApprovalRequest) -> Option { + if let Some(decision) = ctx + .resume + .as_ref() + .and_then(|resume| decision_from_resume(resume, request)) + { + return Some(decision); + } + + // The re-execute resume path: `engine::resume` merges newly-approved ids + // into the run input, where they arrive as `run.trigger.approvals`. The + // top-level `run.approvals` is the same list seeded through the explicit + // channel; read both, because which one carries the id depends on how the + // host started the run. + let trigger_approvals = ctx.run.get("trigger").and_then(|t| t.get("approvals")); + if names(trigger_approvals, request) || names(ctx.run.get("approvals"), request) { + return Some(ApprovalDecision::approved()); + } + None +} + +/// The item a settled review emits. +/// +/// `subject` is what the human actually signed off on — their edit when the +/// host's surface allowed one, otherwise exactly what was sent — so a +/// downstream node reads one field regardless. The original input is kept under +/// `input` so nothing is lost when the subject was a projection of it. +fn decided_item(request: &ApprovalRequest, decision: &ApprovalDecision, input: Value) -> Item { + Item::new(json!({ + "approved": decision.approved, + "subject": decision + .payload + .clone() + .unwrap_or_else(|| request.subject.value.clone()), + "subject_kind": request.subject.kind, + "edited": decision.payload.is_some(), + "decided_by": decision.decided_by, + "comment": decision.comment, + "request_id": request.request_id, + "input": input, + })) +} + +/// The slot state a settled review records, so `=nodes..decision.approved` +/// resolves from anywhere in the graph — including from a branch that did not +/// receive the emitted item (a `drop`ped rejection has no item at all). +fn decision_meta(decision: &ApprovalDecision, request: &ApprovalRequest) -> Value { + json!({ + "decision": { + "approved": decision.approved, + "decided_by": decision.decided_by, + "comment": decision.comment, + "request_id": request.request_id, + } + }) +} + +#[async_trait] +impl NodeExecutor for ApprovalNode { + async fn execute(&self, ctx: NodeContext<'_>) -> Result { + let (config, diagnostics) = resolve_config_traced(&ctx); + let request = build_request(&ctx, &config); + let input = ctx + .input + .first() + .map(|item| item.json.clone()) + .unwrap_or(Value::Null); + + // Ask the host only when nothing has already settled this review: a + // resume or a listed approval is the answer, and re-asking would put a + // decided review back in front of the provider. + let outcome = match delivered(&ctx, &request) { + Some(decision) => ApprovalOutcome::Decided(decision), + None => match ctx.caps.approvals.as_ref() { + Some(provider) => provider.decide(&request).await?, + // No provider: the node is a pause the host settles out of band + // through `engine::resume`, which is exactly what waiting does. + None => ApprovalOutcome::Pending, + }, + }; + + let decision = match outcome { + ApprovalOutcome::Decided(decision) => decision, + ApprovalOutcome::Pending => return self.wait(&ctx, &config, &request).await, + }; + + tracing::info!( + node = %ctx.node.id, + request = %request.request_id, + approved = decision.approved, + "approval decided" + ); + + let meta = decision_meta(&decision, &request); + if decision.approved { + return Ok(NodeOutput::routed( + vec![decided_item(&request, &decision, input)], + "approved", + ) + .with_meta(meta) + .with_diagnostics(diagnostics)); + } + + Ok(match OnReject::from_config(&config) { + OnReject::Route => NodeOutput::routed( + vec![decided_item(&request, &decision, input)], + "rejected", + ) + .with_meta(meta) + .with_diagnostics(diagnostics), + OnReject::Drop => NodeOutput::empty() + .with_meta(meta) + .with_diagnostics(diagnostics), + OnReject::Error => { + return Err(EngineError::Capability(format!( + "approval node {:?}: rejected by {}{}", + ctx.node.id, + decision.decided_by.as_deref().unwrap_or("a reviewer"), + decision + .comment + .as_deref() + .map(|c| format!(" ({c})")) + .unwrap_or_default(), + ))); + } + }) + } +} + +impl ApprovalNode { + /// Nobody has decided yet: suspend the run, or spend a poll. + async fn wait( + &self, + ctx: &NodeContext<'_>, + config: &Value, + request: &ApprovalRequest, + ) -> Result { + if WaitMode::from_config(config) == WaitMode::Suspend { + // Suspending discards this activation's update, so no poll is + // charged — a suspended review is not spending a budget, it is + // waiting for a person. The payload is what the host renders or + // routes if it did not get the request through a provider. + return Ok(NodeOutput::interrupt( + ctx.node.id.clone(), + json!({ + "kind": "approval", + "node": ctx.node.id, + "request_id": request.request_id, + "title": request.title, + "prompt": request.prompt, + "subject": request.subject.value, + "subject_kind": request.subject.kind, + "assignees": request.assignees, + "metadata": request.metadata, + }), + )); + } + + let polls = ctx + .nodes + .get(&ctx.node.id) + .and_then(|slot| slot.get(POLLS_KEY)) + .and_then(Value::as_u64) + .unwrap_or(0); + let max_polls = positive_u64(config, "max_polls", DEFAULT_MAX_POLLS); + let meta = json!({ POLLS_KEY: polls + 1, "request_id": request.request_id }); + + if polls < max_polls { + let interval = positive_u64(config, "poll_interval_ms", DEFAULT_POLL_INTERVAL_MS); + return Ok(NodeOutput::reenter_after(interval, meta)); + } + + // Budget spent. Whatever happens next, nobody is waiting on this review + // any more, so withdraw it rather than leaving a dead card in a queue. + // Best-effort: the run has already decided what to do, and a failed + // withdrawal must not change that. + if let Some(provider) = ctx.caps.approvals.as_ref() + && let Err(err) = provider + .cancel(&request.request_id, "approval node timed out") + .await + { + tracing::warn!( + node = %ctx.node.id, + request = %request.request_id, + error = %err, + "withdrawing the timed-out review failed" + ); + } + + let timed_out = json!({ + "approved": false, + "timed_out": true, + "request_id": request.request_id, + "subject": request.subject.value, + "subject_kind": request.subject.kind, + }); + + match OnTimeout::from_config(config) { + OnTimeout::Error => Err(EngineError::Capability(format!( + "approval node {:?}: no decision after {max_polls} polls; raise \ + `max_polls`/`poll_interval_ms`, switch to `wait_mode: \"suspend\"`, or wire a \ + `timeout` port", + ctx.node.id + ))), + OnTimeout::Route => { + Ok(NodeOutput::routed(vec![Item::new(timed_out)], "timeout").with_meta(meta)) + } + OnTimeout::Reject => match OnReject::from_config(config) { + OnReject::Route => { + Ok(NodeOutput::routed(vec![Item::new(timed_out)], "rejected").with_meta(meta)) + } + OnReject::Drop => Ok(NodeOutput::empty().with_meta(meta)), + OnReject::Error => Err(EngineError::Capability(format!( + "approval node {:?}: no decision after {max_polls} polls, and \ + `on_timeout: \"reject\"` with `on_reject: \"error\"` fails the node", + ctx.node.id + ))), + }, + } + } +} + +#[cfg(test)] +#[path = "approval_tests.rs"] +mod tests; From 2d4d4e9e8288a6c470d8e22d8d05de5aab4cb7ed Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:44:49 +0300 Subject: [PATCH 008/138] chore: files changed src/nodes/integration/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodes/integration/mod.rs b/src/nodes/integration/mod.rs index d4bc10c..d8ba4b4 100644 --- a/src/nodes/integration/mod.rs +++ b/src/nodes/integration/mod.rs @@ -5,7 +5,7 @@ //! One module per node kind so parallel work can edit them without conflicts. pub mod agent; -pub(crate) mod approval; +pub mod approval; pub(crate) mod agent_request; pub mod code; pub(crate) mod envelope; From f84a6a02a57e874bf13ee22a69b6e17bad10a76f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:45:00 +0300 Subject: [PATCH 009/138] chore: files changed src/caps/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mod.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/caps/mod.rs b/src/caps/mod.rs index 555f95a..b35f4c1 100644 --- a/src/caps/mod.rs +++ b/src/caps/mod.rs @@ -7,7 +7,6 @@ pub mod agent; pub mod approval; -pub mod approval; #[cfg(any(test, feature = "host-caps"))] pub mod host; #[cfg(any(test, feature = "mock"))] @@ -29,9 +28,6 @@ pub use self::agent::{ pub use self::approval::{ ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject, }; -pub use self::approval::{ - ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject, -}; pub use self::shell::{ShellInterpreter, ShellOutcome, ShellRequest, ShellRunner, ShellScript}; pub use self::tasks::{TaskRunner, TaskSpec, TaskState, TokioTaskRunner}; From f00b31d18c3ac53b70798db69caf2fdaff724ca0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:45:30 +0300 Subject: [PATCH 010/138] chore: files changed src/caps/mod.rs,src/main.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mod.rs | 4 ++-- src/main.rs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/caps/mod.rs b/src/caps/mod.rs index b35f4c1..3450e21 100644 --- a/src/caps/mod.rs +++ b/src/caps/mod.rs @@ -201,8 +201,8 @@ pub trait StateStore: Send + Sync { /// Construct one per run from the host's concrete implementations. It carries /// every host-injected capability: the always-present [`LlmProvider`], /// [`ToolInvoker`], [`HttpClient`], [`CodeRunner`], [`StateStore`], and -/// [`WorkflowResolver`], plus the optional [`AgentRunner`] and -/// [`MemoryProvider`]. Nodes reach each one through `ctx.caps` during +/// [`WorkflowResolver`], plus the optional [`AgentRunner`], [`MemoryProvider`], +/// and [`ApprovalProvider`]. Nodes reach each one through `ctx.caps` during /// execution. #[derive(Clone)] pub struct Capabilities { diff --git a/src/main.rs b/src/main.rs index f05810e..a24df2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -326,6 +326,9 @@ fn standalone_capabilities() -> tinyflows::caps::Capabilities { // The stub binary refuses every outside-world capability; background // work is no exception, so `spawn` degrades to running inline. tasks: None, + // Likewise for human review: with no provider, an `approval` node + // pauses the run and waits for a resume that the stub never sends. + approvals: None, } } From 620554794af506bc175b645093a18b686762b26b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:46:40 +0300 Subject: [PATCH 011/138] chore: files changed src/nodes/integration/approval_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_tests.rs | 346 ++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 src/nodes/integration/approval_tests.rs diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs new file mode 100644 index 0000000..0e68a63 --- /dev/null +++ b/src/nodes/integration/approval_tests.rs @@ -0,0 +1,346 @@ +use super::*; +use serde_json::json; + +use crate::caps::mock::{MockApprovals, mock_capabilities, mock_capabilities_with_approvals}; +use crate::compiler::compile; +use crate::engine::{RunInput, resume, run}; +use crate::model::{Edge, Node, NodeKind, WorkflowGraph}; + +/// A trigger wired into one `approval` node, with `config` on the approval. +fn wf(config: Value) -> WorkflowGraph { + WorkflowGraph { + nodes: vec![ + Node { + id: "t".into(), + kind: NodeKind::Trigger, + type_version: 1, + name: "t".into(), + config: Value::Null, + ports: vec![], + position: None, + }, + Node { + id: "review".into(), + kind: NodeKind::Approval, + type_version: 1, + name: "review".into(), + config, + ports: vec![], + position: None, + }, + ], + edges: vec![Edge { + from_node: "t".into(), + from_port: "main".into(), + to_node: "review".into(), + to_port: "main".into(), + }], + ..Default::default() + } +} + +fn request(id: &str) -> ApprovalRequest { + ApprovalRequest { + request_id: id.to_string(), + node_id: "review".to_string(), + run_id: None, + title: None, + prompt: None, + subject: ApprovalSubject { + kind: "url".to_string(), + value: json!("https://example.com/post/1"), + }, + assignees: vec![], + metadata: Value::Null, + } +} + +/// Opposite default to `gate`, and deliberately so: a human review is a +/// minutes-to-days wait, and polling one burns super-steps for nothing. +#[test] +fn wait_mode_defaults_to_suspend() { + assert_eq!(WaitMode::from_config(&json!({})), WaitMode::Suspend); + assert_eq!( + WaitMode::from_config(&json!({ "wait_mode": "poll" })), + WaitMode::Poll + ); + assert_eq!( + WaitMode::from_config(&json!({ "wait_mode": "nonsense" })), + WaitMode::Suspend + ); +} + +/// Failing safe: an unrecognised policy must not drop a rejection on the floor +/// or fail a run that had a perfectly good recovery branch. +#[test] +fn reject_and_timeout_policies_default_conservatively() { + assert_eq!(OnReject::from_config(&json!({})), OnReject::Route); + assert_eq!( + OnReject::from_config(&json!({ "on_reject": "whatever" })), + OnReject::Route + ); + assert_eq!(OnTimeout::from_config(&json!({})), OnTimeout::Error); + assert_eq!( + OnTimeout::from_config(&json!({ "on_timeout": "eventually" })), + OnTimeout::Error + ); +} + +/// The engine's own denial shape wins over an approval delivered in the same +/// resume value — the same precedence a `requires_approval` gate applies. +#[test] +fn resume_denial_beats_an_approval_in_the_same_value() { + let req = request("run-1:review"); + let decision = decision_from_resume( + &json!({ "rejected": ["review"], "approved": ["review"] }), + &req, + ) + .expect("a decision"); + assert!(!decision.approved); +} + +#[test] +fn resume_reads_a_verdict_inline_or_nested() { + let req = request("run-1:review"); + + let inline = decision_from_resume( + &json!({ "approved": true, "decided_by": "ada", "comment": "ship it" }), + &req, + ) + .expect("a decision"); + assert!(inline.approved); + assert_eq!(inline.decided_by.as_deref(), Some("ada")); + assert_eq!(inline.comment.as_deref(), Some("ship it")); + + let nested = decision_from_resume( + &json!({ "decision": { "approved": false, "comment": "wrong link" } }), + &req, + ) + .expect("a decision"); + assert!(!nested.approved); + assert_eq!(nested.comment.as_deref(), Some("wrong link")); + + assert!( + decision_from_resume(&json!({ "unrelated": true }), &req).is_none(), + "a resume that says nothing about this review leaves it pending" + ); +} + +/// A review can be addressed by node id or by its own request id — a host that +/// tracks reviews by request id must be able to resume with that. +#[test] +fn a_review_answers_to_its_node_id_and_its_request_id() { + let req = request("run-1:review"); + assert!(names(Some(&json!(["review"])), &req)); + assert!(names(Some(&json!(["run-1:review"])), &req)); + assert!(!names(Some(&json!(["other"])), &req)); + assert!(!names(None, &req)); +} + +#[test] +fn positive_config_fields_fall_back_on_zero_or_garbage() { + assert_eq!(positive_u64(&json!({}), "max_polls", 7), 7); + assert_eq!(positive_u64(&json!({ "max_polls": 0 }), "max_polls", 7), 7); + assert_eq!(positive_u64(&json!({ "max_polls": 3 }), "max_polls", 7), 3); +} + +#[tokio::test] +async fn an_approved_review_emits_the_subject_on_the_approved_port() { + let graph = wf(json!({ + "title": "Publish this?", + "subject_kind": "url", + "subject": "=item.url", + })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + json!({ "url": "https://example.com/post/1" }), + &mock_capabilities(), + ) + .await + .expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!(slot["port"], "approved"); + let item = &slot["items"][0]["json"]; + assert_eq!(item["approved"], json!(true)); + assert_eq!(item["subject"], "https://example.com/post/1"); + assert_eq!(item["subject_kind"], "url"); + assert_eq!(item["decided_by"], "mock-reviewer"); + assert_eq!( + slot["decision"]["approved"], + json!(true), + "the verdict is addressable as =nodes.review.decision.approved" + ); +} + +#[tokio::test] +async fn the_subject_defaults_to_the_item_that_arrived() { + let graph = wf(json!({ "title": "Look at this" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + json!({ "draft": "hello world" }), + &mock_capabilities(), + ) + .await + .expect("run"); + + assert_eq!( + out.output["nodes"]["review"]["items"][0]["json"]["subject"], + json!({ "draft": "hello world" }) + ); +} + +#[tokio::test] +async fn a_rejection_routes_to_the_rejected_port_with_its_reason() { + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + json!({ "url": "https://example.com" }), + &mock_capabilities_with_approvals(MockApprovals::rejecting()), + ) + .await + .expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!(slot["port"], "rejected"); + assert_eq!(slot["items"][0]["json"]["approved"], json!(false)); + assert_eq!(slot["items"][0]["json"]["comment"], "mock rejection"); +} + +#[tokio::test] +async fn on_reject_error_fails_the_node_with_the_reviewer_and_reason() { + let graph = wf(json!({ "on_reject": "error" })); + let compiled = compile(&graph).expect("compile"); + let err = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::rejecting()), + ) + .await + .expect_err("a rejection with on_reject: error fails the run"); + + let message = err.to_string(); + assert!(message.contains("mock-reviewer"), "got {message}"); + assert!(message.contains("mock rejection"), "got {message}"); +} + +#[tokio::test] +async fn a_pending_review_pauses_the_run_and_names_itself() { + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect("run"); + + assert_eq!(out.pending_approvals, vec!["review".to_string()]); +} + +/// The zero-capability path: a host that wired no provider still gets a +/// working node, because waiting *is* the pause it already knows how to resume. +#[tokio::test] +async fn with_no_provider_the_node_reduces_to_a_pause_the_host_resumes() { + let caps = crate::caps::Capabilities { + approvals: None, + ..mock_capabilities() + }; + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + + let paused = run(&compiled, Value::Null, &caps).await.expect("run"); + assert_eq!(paused.pending_approvals, vec!["review".to_string()]); + + let resumed = resume( + &compiled, + Value::Null, + vec!["review".to_string()], + &caps, + ) + .await + .expect("resume"); + assert!(resumed.pending_approvals.is_empty()); + assert_eq!(resumed.output["nodes"]["review"]["port"], "approved"); +} + +/// An approval already on the run input settles the review without the host +/// ever being asked — otherwise a resume would re-open a decided review. +#[tokio::test] +async fn a_listed_approval_settles_the_review_without_asking_the_host() { + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let provider = std::sync::Arc::new(MockApprovals::pending()); + let caps = crate::caps::Capabilities { + approvals: Some(provider.clone()), + ..mock_capabilities() + }; + + let out = run( + &compiled, + RunInput::new(Value::Null).with_approvals(vec!["review".to_string()]), + &caps, + ) + .await + .expect("run"); + + assert_eq!(out.output["nodes"]["review"]["port"], "approved"); + assert!( + provider.requested().is_empty(), + "a settled review must not be handed to the provider again" + ); +} + +#[tokio::test] +async fn polling_spends_a_bounded_budget_then_follows_on_timeout() { + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 2, + "on_timeout": "route", + })); + let compiled = compile(&graph).expect("compile"); + let provider = std::sync::Arc::new(MockApprovals::pending()); + let caps = crate::caps::Capabilities { + approvals: Some(provider.clone()), + ..mock_capabilities() + }; + + let out = run(&compiled, Value::Null, &caps).await.expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!(slot["port"], "timeout"); + assert_eq!(slot["items"][0]["json"]["timed_out"], json!(true)); + + // Every activation asked about the SAME review: the create-or-fetch + // contract is what stops a poll loop notifying a human once per poll. + let ids = provider.requested(); + assert!(ids.len() > 1, "expected repeated polls, got {ids:?}"); + assert!( + ids.iter().all(|id| id == &ids[0]), + "every poll must reuse one request id, got {ids:?}" + ); + assert_eq!( + provider.cancelled(), + ids[..1].to_vec(), + "a timed-out review is withdrawn rather than left in a queue" + ); +} + +#[tokio::test] +async fn on_timeout_error_is_the_default_and_names_the_node() { + let graph = wf(json!({ "wait_mode": "poll", "poll_interval_ms": 1, "max_polls": 1 })); + let compiled = compile(&graph).expect("compile"); + let err = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect_err("an unanswered review fails by default"); + assert!(err.to_string().contains("review"), "got {err}"); +} From 2af344edcf8862c0ebe135d4dee6a9b294477cd2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:47:42 +0300 Subject: [PATCH 012/138] chore: files changed src/catalog.rs,src/catalog/contracts/group_03.rs,src/catalog_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/catalog.rs | 4 +- src/catalog/contracts/group_03.rs | 116 ++++++++++++++++++++++++++++++ src/catalog_tests.rs | 7 +- 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/catalog.rs b/src/catalog.rs index 0c98589..5377e94 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -33,7 +33,7 @@ use group_03::*; /// The node kinds, in the canonical order used wherever the DSL is enumerated /// (matches [`NodeKind`](crate::model::NodeKind)'s serde discriminators). -pub const NODE_KINDS: [&str; 20] = [ +pub const NODE_KINDS: [&str; 21] = [ "trigger", "agent", "tool_call", @@ -54,6 +54,7 @@ pub const NODE_KINDS: [&str; 20] = [ "gate", "scatter", "gather", + "approval", ]; /// One config field a node of a given kind reads at run time. @@ -204,6 +205,7 @@ pub fn contract_for(kind: &str) -> Option { "gate" => contract_gate(), "scatter" => contract_scatter(), "gather" => contract_gather(), + "approval" => contract_approval(), _ => return None, }; Some(with_fan_out_fields(c)) diff --git a/src/catalog/contracts/group_03.rs b/src/catalog/contracts/group_03.rs index 30fd971..aa190e0 100644 --- a/src/catalog/contracts/group_03.rs +++ b/src/catalog/contracts/group_03.rs @@ -115,3 +115,119 @@ pub(super) fn contract_gather() -> NodeKindContract { ], } } + +pub(super) fn contract_approval() -> NodeKindContract { + NodeKindContract { + kind: "approval".to_string(), + summary: "Puts a subject — a URL, a draft, a payload — in front of a HUMAN and routes \ + on their approve/reject." + .to_string(), + description: "Not the same thing as the `requires_approval` flag. That flag holds a \ + node back until someone says go, carries nothing, and its answer is \ + invisible to the graph. This kind IS the review: it carries what is being \ + reviewed, and the verdict (who decided, their comment, any edit they made) \ + comes back as data on the `approved` / `rejected` ports. Reaches the human \ + through the host's ApprovalProvider capability; with none injected it \ + pauses the run instead, and the host settles it with engine::resume." + .to_string(), + config_fields: vec![ + ConfigField::optional( + "subject", + "any | \"=expr\"", + "What the human looks at. Defaults to the input item that arrived, which is \ + usually what you want; set it to project one field (\"=item.url\").", + ), + ConfigField::optional( + "subject_kind", + "string", + "Rendering hint for the host's review surface — url | text | markdown | json \ + (default) by convention, host-defined in general.", + ), + ConfigField::optional("title", "string", "Short headline for the review card."), + ConfigField::optional( + "prompt", + "string", + "What approving actually authorizes — the sentence the reviewer decides on.", + ), + ConfigField::optional( + "assignees", + "array", + "Opaque host-resolved reviewer handles (user ids, emails, a channel, a role).", + ), + ConfigField::optional( + "metadata", + "object", + "Anything else the host's review surface wants, passed through untouched.", + ), + ConfigField::optional( + "request_id", + "string", + "Overrides the review's identity (default \":\"). Must be \ + stable across resumes: it is the key the host de-duplicates reviews on.", + ), + ConfigField::optional( + "wait_mode", + "enum", + "suspend (default) interrupts the run until the host resumes it; poll \ + re-activates the node on an interval, which is only sane for a decision \ + expected within seconds.", + ) + .with_enum(&["suspend", "poll"]), + ConfigField::optional( + "on_reject", + "enum", + "route (default: emit the verdict on the `rejected` port) | error (fail the \ + node) | drop (emit nothing).", + ) + .with_enum(&["route", "error", "drop"]), + ConfigField::optional( + "on_timeout", + "enum", + "When a POLLING review runs out of budget: error (default) | reject (follow \ + on_reject) | route (emit on the `timeout` port).", + ) + .with_enum(&["error", "reject", "route"]), + ConfigField::optional( + "poll_interval_ms", + "number", + "Gap between polls (default 1000). Ignored when suspending.", + ), + ConfigField::optional( + "max_polls", + "number", + "Poll budget before the wait is called spent (default 60). Each poll costs a \ + super-step. Ignored when suspending — a suspended review waits as long as the \ + host lets it.", + ), + ], + ports: PortSpec::new(&["main"], &["approved", "rejected", "timeout", "error"]), + example: json!({ + "id": "review", "kind": "approval", "name": "Approve the post", + "config": { + "title": "Publish this post?", + "prompt": "Approving publishes it to the public feed.", + "subject_kind": "url", + "subject": "=item.preview_url", + "assignees": ["=inputs.owner"] + } + }), + notes: vec![ + "Wire the `approved` port — an approval whose approve branch is unwired reviews \ + something and then does nothing with the answer." + .to_string(), + "A rejection is DATA, not an error, by default: it routes to `rejected` with the \ + reviewer's comment so a recovery branch can revise and ask again. Use \ + on_reject: \"error\" only when a rejection should abort." + .to_string(), + "The emitted item carries `subject` as the human left it — their edit when the \ + host's surface allowed one, otherwise exactly what was sent — plus `approved`, \ + `comment`, `decided_by`, and the original `input`. The verdict is also readable \ + anywhere as \"=nodes..decision.approved\"." + .to_string(), + "`request_id` is what stops a human being notified once per poll and once per \ + resume: the host is asked create-or-fetch on that id. Override it only with \ + something equally stable." + .to_string(), + ], + } +} diff --git a/src/catalog_tests.rs b/src/catalog_tests.rs index 3ad7393..53528d4 100644 --- a/src/catalog_tests.rs +++ b/src/catalog_tests.rs @@ -23,7 +23,7 @@ fn every_node_kind_has_a_contract() { } } } - assert_eq!(all_contracts().len(), 20); + assert_eq!(all_contracts().len(), 21); } #[test] @@ -48,8 +48,8 @@ fn unknown_kind_has_no_contract() { } #[test] -fn node_kinds_has_20_entries_including_the_async_and_lane_pairs() { - assert_eq!(NODE_KINDS.len(), 20); +fn node_kinds_has_21_entries_including_the_async_and_lane_pairs() { + assert_eq!(NODE_KINDS.len(), 21); assert!(NODE_KINDS.contains(&"shell")); assert!(NODE_KINDS.contains(&"memory")); assert!(NODE_KINDS.contains(&"dedup")); @@ -67,6 +67,7 @@ fn node_kinds_has_20_entries_including_the_async_and_lane_pairs() { assert_eq!(NODE_KINDS[17], "gate"); assert_eq!(NODE_KINDS[18], "scatter"); assert_eq!(NODE_KINDS[19], "gather"); + assert_eq!(NODE_KINDS[20], "approval"); } #[test] From a9f3a716c44f37e6c88033e550bb4504f2c5eca1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:48:20 +0300 Subject: [PATCH 013/138] chore: files changed src/validate.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/validate.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/validate.rs b/src/validate.rs index d5613d0..618bd17 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -397,6 +397,58 @@ pub fn validate_all(graph: &WorkflowGraph) -> Vec { } } + // `approval` node config. These are all closed enums that SELECT BEHAVIOUR, + // so a typo cannot be caught at run time without silently changing what the + // node does: a misspelled `on_reject` would quietly route a rejection that + // was meant to fail the run, and a misspelled `wait_mode` would quietly + // suspend a review the author wanted polled. Refuse them at the door, where + // the message can name the node and the alternatives. + for node in &graph.nodes { + if node.kind != NodeKind::Approval { + continue; + } + + for (key, allowed) in [ + ("wait_mode", &["suspend", "poll"][..]), + ("on_reject", &["route", "error", "drop"][..]), + ("on_timeout", &["error", "reject", "route"][..]), + ] { + let Some(value) = node.config.get(key) else { + continue; + }; + if !value.as_str().is_some_and(|v| allowed.contains(&v)) { + errors.push(ValidationError::InvalidNodeConfig { + node: node.id.clone(), + reason: format!( + "approval node has unknown `{key}` {value} (expected one of {})", + allowed + .iter() + .map(|v| format!("{v:?}")) + .collect::>() + .join(", ") + ), + }); + } + } + + // Reviewer handles are opaque to the crate, but their *shape* is not: + // a bare string here (the natural mistake for a single reviewer) would + // be read as "nobody", and the review would go to an empty audience + // with no error anywhere. + if let Some(assignees) = node.config.get("assignees") + && !assignees + .as_array() + .is_some_and(|values| values.iter().all(Value::is_string)) + { + errors.push(ValidationError::InvalidNodeConfig { + node: node.id.clone(), + reason: "approval node `assignees` must be an array of strings (a single \ + reviewer is a one-element array)" + .to_string(), + }); + } + } + // A `condition` node's outgoing edges must emit on `from_port` "true" or // "false" — routing is keyed EXCLUSIVELY on `from_port` (see // `engine::outgoing_by_port` / `handler_routing`), so any other value From e79441048aeab9285a23326827e8c1d8a0845988 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:48:46 +0300 Subject: [PATCH 014/138] chore: files changed src/validate_tests/validate_tests_part_03_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../validate_tests_part_03_tests.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/validate_tests/validate_tests_part_03_tests.rs b/src/validate_tests/validate_tests_part_03_tests.rs index 6ca294f..0c13ce1 100644 --- a/src/validate_tests/validate_tests_part_03_tests.rs +++ b/src/validate_tests/validate_tests_part_03_tests.rs @@ -148,3 +148,69 @@ fn validation_error_code_and_node_id_accessors() { None ); } + +/// Builds a trigger -> approval graph carrying `config` on the approval node. +fn approval_graph(config: serde_json::Value) -> WorkflowGraph { + let mut review = node("review", NodeKind::Approval); + review.config = config; + WorkflowGraph { + nodes: vec![node("t", NodeKind::Trigger), review], + ..Default::default() + } +} + +/// Every `approval` behaviour selector is a closed enum, and a typo in one +/// silently changes what the node does rather than failing — so it is refused +/// here instead. +#[test] +fn approval_behaviour_selectors_must_be_known_values() { + for (key, bad) in [ + ("wait_mode", "suspended"), + ("on_reject", "reroute"), + ("on_timeout", "partial"), + ] { + let graph = approval_graph(serde_json::json!({ key: bad })); + let errors = validate_all(&graph); + assert!( + errors.iter().any(|e| matches!( + e, + ValidationError::InvalidNodeConfig { node, reason } + if node == "review" && reason.contains(key) + )), + "expected {key}={bad} to be refused, got {errors:?}" + ); + } + + assert!( + validate_all(&approval_graph(serde_json::json!({ + "wait_mode": "poll", + "on_reject": "error", + "on_timeout": "route", + }))) + .is_empty(), + "the documented values must all pass" + ); +} + +/// A single reviewer written as a bare string would be read as an empty +/// audience, so the shape is checked even though the handles are opaque. +#[test] +fn approval_assignees_must_be_an_array_of_strings() { + let errors = validate_all(&approval_graph( + serde_json::json!({ "assignees": "ada" }), + )); + assert!( + errors.iter().any(|e| matches!( + e, + ValidationError::InvalidNodeConfig { node, reason } + if node == "review" && reason.contains("assignees") + )), + "got {errors:?}" + ); + assert!( + validate_all(&approval_graph( + serde_json::json!({ "assignees": ["ada"] }) + )) + .is_empty() + ); +} From 523a93086d86e3f70bf93b514cd2ad1815009fbc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:49:27 +0300 Subject: [PATCH 015/138] chore: files changed src/visualization.rs,tests/smoke_all_nodes.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/visualization.rs | 4 ++++ tests/smoke_all_nodes.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/visualization.rs b/src/visualization.rs index 4eed115..4d63dd1 100644 --- a/src/visualization.rs +++ b/src/visualization.rs @@ -347,6 +347,9 @@ fn node_fill(kind: Option<&NodeKind>) -> Rgb { Some(NodeKind::Agent | NodeKind::ToolCall | NodeKind::HttpRequest | NodeKind::Memory) => { Rgb([220, 252, 231]) } + // A human review reads as a decision point, not as work the machine + // does, so it takes the branching palette rather than the capability one. + Some(NodeKind::Approval) => Rgb([254, 226, 226]), _ => Rgb([241, 245, 249]), } } @@ -359,6 +362,7 @@ fn node_accent(kind: Option<&NodeKind>) -> Rgb { Some(NodeKind::Agent | NodeKind::ToolCall | NodeKind::HttpRequest | NodeKind::Memory) => { Rgb([22, 163, 74]) } + Some(NodeKind::Approval) => Rgb([220, 38, 38]), None => Rgb([220, 38, 38]), _ => Rgb([71, 85, 105]), } diff --git a/tests/smoke_all_nodes.rs b/tests/smoke_all_nodes.rs index cb0ac80..f7dd5ab 100644 --- a/tests/smoke_all_nodes.rs +++ b/tests/smoke_all_nodes.rs @@ -372,3 +372,17 @@ async fn smoke_scatter_gather() { .expect("the gather should produce an items array"); assert_eq!(items.len(), 2, "two lanes, two collected results"); } + +/// The default mock approvals provider approves, so an `approval` node +/// dry-runs end to end the way a `memory` node does — the point being that a +/// graph containing a review is runnable without the host standing up a review +/// surface first. +#[tokio::test] +async fn smoke_approval() { + smoke_single_node( + NodeKind::Approval, + json!({ "title": "Ship it?", "subject_kind": "url", "subject": "=item.url" }), + json!({ "url": "https://example.com/preview" }), + ) + .await; +} From fdfbc4ca3ce7ebfde0c17f04a48aff5120f9ffda Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:50:29 +0300 Subject: [PATCH 016/138] chore: files changed src/caps/mock.rs,src/nodes/integration/approval.rs,src/nodes/integration/approv Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 4 +++- src/nodes/integration/approval.rs | 11 +++++------ src/nodes/integration/approval_tests.rs | 11 +++-------- src/nodes/integration/mod.rs | 2 +- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index 7598d47..fec9702 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -588,7 +588,9 @@ pub fn mock_capabilities_with_memory(memory: impl MemoryProvider + 'static) -> C /// in place of the default approve-everything [`MockApprovals`] — for tests /// that need a rejection, a pending review, or a host-shaped decision. #[must_use] -pub fn mock_capabilities_with_approvals(approvals: impl ApprovalProvider + 'static) -> Capabilities { +pub fn mock_capabilities_with_approvals( + approvals: impl ApprovalProvider + 'static, +) -> Capabilities { Capabilities { approvals: Some(Arc::new(approvals)), ..mock_capabilities() diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index 7ed9478..d2a3836 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -368,12 +368,11 @@ impl NodeExecutor for ApprovalNode { } Ok(match OnReject::from_config(&config) { - OnReject::Route => NodeOutput::routed( - vec![decided_item(&request, &decision, input)], - "rejected", - ) - .with_meta(meta) - .with_diagnostics(diagnostics), + OnReject::Route => { + NodeOutput::routed(vec![decided_item(&request, &decision, input)], "rejected") + .with_meta(meta) + .with_diagnostics(diagnostics) + } OnReject::Drop => NodeOutput::empty() .with_meta(meta) .with_diagnostics(diagnostics), diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index 0e68a63..6268f43 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -256,14 +256,9 @@ async fn with_no_provider_the_node_reduces_to_a_pause_the_host_resumes() { let paused = run(&compiled, Value::Null, &caps).await.expect("run"); assert_eq!(paused.pending_approvals, vec!["review".to_string()]); - let resumed = resume( - &compiled, - Value::Null, - vec!["review".to_string()], - &caps, - ) - .await - .expect("resume"); + let resumed = resume(&compiled, Value::Null, vec!["review".to_string()], &caps) + .await + .expect("resume"); assert!(resumed.pending_approvals.is_empty()); assert_eq!(resumed.output["nodes"]["review"]["port"], "approved"); } diff --git a/src/nodes/integration/mod.rs b/src/nodes/integration/mod.rs index d8ba4b4..a6ab24c 100644 --- a/src/nodes/integration/mod.rs +++ b/src/nodes/integration/mod.rs @@ -5,8 +5,8 @@ //! One module per node kind so parallel work can edit them without conflicts. pub mod agent; -pub mod approval; pub(crate) mod agent_request; +pub mod approval; pub mod code; pub(crate) mod envelope; pub mod gate; From 3fd5c3d3213d489a28858a152b9f8ab5846b9205 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:51:35 +0300 Subject: [PATCH 017/138] chore: files changed CHANGELOG.md,wiki/Capability-Traits.md,wiki/Node-Catalog.md Auto-committed-on: dragonfly Co-authored-by: Medulla --- CHANGELOG.md | 15 +++++++++++++++ wiki/Capability-Traits.md | 8 ++++++++ wiki/Node-Catalog.md | 10 ++++++++++ 3 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6043923..de98128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`approval` node kind + the `ApprovalProvider` capability** — a + human-in-the-loop review step that carries what is being reviewed (a URL, a + draft, any payload) and routes on the answer: `approved` / `rejected` ports, + the verdict (reviewer, comment, any edit they made) emitted as an item and + readable anywhere as `=nodes..decision.approved`. Reaches the human + through the new optional `caps::ApprovalProvider`, whose `decide` is + create-or-fetch on a stable `request_id` so a resume or a poll never notifies + the reviewer twice. Hosts that wire no provider still get a working node: it + pauses the run and is settled with `engine::resume`, exactly like a + `requires_approval` gate. Waits by suspending by default, with an optional + bounded `wait_mode: "poll"`; `on_reject` (`route` / `error` / `drop`) and + `on_timeout` (`error` / `reject` / `route`) decide what a "no" does. + ### Changed - **Breaking: the Chrome companion moved behind the `chrome-extension` diff --git a/wiki/Capability-Traits.md b/wiki/Capability-Traits.md index e284fd6..c334d5d 100644 --- a/wiki/Capability-Traits.md +++ b/wiki/Capability-Traits.md @@ -21,6 +21,7 @@ examples without any real backend. | `CodeRunner` | `code` | Executes sandboxed user code (`CodeLanguage::JavaScript` / `Python`) with a JSON input. | | `ShellRunner` | `shell` | Runs a shell script (inline or by path) with a working directory and environment, returning its exit code, stdout, and stderr. Optional: `None` refuses `shell` nodes. | | `StateStore` | resumable / stateful workflows | Durable key/value state (`load` / `store`) for a run. | +| `ApprovalProvider` | `approval` | Puts a subject (a URL, a draft, a payload) in front of a human and reports their approve/reject. Optional: with `None`, an `approval` node pauses the run instead and the host settles it via `engine::resume`. | ## Connection references @@ -47,6 +48,13 @@ implementations. It bundles all five host capabilities: `llm`, `tools`, `http`, `ctx.caps` during execution — for example, durable key/value state via `ctx.caps.state`. +`ApprovalProvider::decide` is **create-or-fetch**, keyed on +`ApprovalRequest::request_id`: the first call with an id creates the review, and +every later call with that id reports where *that* review stands. This matters +because an interrupt discards the activation's state update, so the node re-asks +after every resume and on every poll — a provider that created a fresh review per +call would notify the reviewer once per call. + Durable, cross-process human-in-the-loop resume is available by implementing `Checkpointer` and driving the run via `engine::run_with_checkpointer` / `resume_with_checkpointer` under a stable diff --git a/wiki/Node-Catalog.md b/wiki/Node-Catalog.md index e8307a4..802e1d0 100644 --- a/wiki/Node-Catalog.md +++ b/wiki/Node-Catalog.md @@ -46,6 +46,7 @@ traits](Capability-Traits). | `code` | Runs sandboxed user code | Config `language` (`javascript`/`python`), `source` — via `CodeRunner` | | `shell` | Runs a shell script, inline or from a file | Config `source` **or** `script_path`, plus `interpreter` (`sh`/`bash`), `cwd`, `env` — via `ShellRunner` | | `output_parser` | Parses/validates an agent's output into a structured shape | May use `LlmProvider` for auto-fixing; can nest as a sub-agent | +| `approval` | Puts a subject in front of a **human** and routes on approve/reject | Out `approved` / `rejected` / `timeout`; config `subject`, `subject_kind`, `title`, `prompt`, `assignees`, `wait_mode`, `on_reject` — via `ApprovalProvider` | | `sub_workflow` | Runs another workflow as a nested sub-graph | Config: exactly one of `workflow` (inline) / `workflow_id`; optional `inputs` map for the child's declared inputs | The capability-backed integration nodes (`agent`, `tool_call`, `http_request`) @@ -59,6 +60,15 @@ Per-node error handling (`on_error` stop/continue/route, `retry`, an `error` port) and approval gating (`requires_approval`) are configured through the same free-form `config`. +`approval` is not the same thing as the `requires_approval` flag. The flag holds +a node back until someone says go; it carries nothing and its answer is invisible +to the graph. The `approval` **kind** is the review itself — it carries what is +being reviewed, and the verdict (approved, who decided, their comment, any edit +they made) comes back as an item on the `approved` / `rejected` ports, readable +anywhere as `=nodes..decision.approved`. It waits by suspending the run by +default (`wait_mode: "suspend"`), which costs nothing while a card sits in +somebody's queue. + ### Per-item fan-out `agent`, `tool_call`, `http_request`, `memory`, and `sub_workflow` can map over From 1f4a0aef93b326e98030dd6b16bb5e1e31d9f551 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:52:10 +0300 Subject: [PATCH 018/138] chore: files changed examples/hitl_review.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- examples/hitl_review.rs | 89 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 examples/hitl_review.rs diff --git a/examples/hitl_review.rs b/examples/hitl_review.rs new file mode 100644 index 0000000..4f97c2f --- /dev/null +++ b/examples/hitl_review.rs @@ -0,0 +1,89 @@ +//! Human review as a **step in the graph**: an `approval` node hands a URL to a +//! host-implemented review surface, the run pauses while nobody has answered, +//! and the branch it takes afterwards depends on what the human said. +//! +//! The host module here is `DeskReview` — a stand-in for whatever real surface a +//! host has (a Slack card, an inbox row, a web queue). It shows the two things +//! the [`ApprovalProvider`](tinyflows::caps::ApprovalProvider) contract asks +//! for: **create-or-fetch** on `request_id`, so re-asking never notifies the +//! reviewer twice, and a decision that can carry the human's own edit. +//! +//! Run: cargo run --example hitl_review --features mock +#[cfg(feature = "mock")] +#[tokio::main(flavor = "current_thread")] +async fn main() { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use serde_json::{Value, json}; + use tinyflows::caps::mock::mock_capabilities; + use tinyflows::caps::{ + ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, Capabilities, + }; + use tinyflows::compiler::compile; + use tinyflows::engine::{resume, run}; + use tinyflows::model::{Edge, Node, NodeKind, WorkflowGraph}; + + /// A host's review desk: one row per `request_id`, holding the verdict once + /// a human leaves one. + #[derive(Default)] + struct DeskReview { + rows: Mutex>>, + } + + impl DeskReview { + /// What a human does later, from the host's own UI. + fn answer(&self, request_id: &str, decision: ApprovalDecision) { + self.rows + .lock() + .expect("lock") + .insert(request_id.to_string(), Some(decision)); + } + + fn open_reviews(&self) -> Vec { + self.rows.lock().expect("lock").keys().cloned().collect() + } + } + + #[async_trait::async_trait] + impl ApprovalProvider for DeskReview { + async fn decide(&self, request: &ApprovalRequest) -> tinyflows::error::Result + where + Value: Sized, + { + unreachable!() + } + } + + let _ = ( + mock_capabilities as fn() -> Capabilities, + compile, + run, + resume, + json!({}), + Value::Null, + Node { + id: String::new(), + kind: NodeKind::Trigger, + type_version: 1, + name: String::new(), + config: Value::Null, + ports: vec![], + position: None, + }, + Edge { + from_node: String::new(), + from_port: String::new(), + to_node: String::new(), + to_port: String::new(), + }, + WorkflowGraph::default(), + ApprovalOutcome::Pending, + Arc::new(DeskReview::default()).open_reviews(), + ); +} + +#[cfg(not(feature = "mock"))] +fn main() { + eprintln!("run with --features mock"); +} From 28af4801714ce2e30a0aa8fe337c9861e06f557e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:52:41 +0300 Subject: [PATCH 019/138] chore: files changed examples/hitl_review.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- examples/hitl_review.rs | 154 +++++++++++++++++++++++++++++++--------- 1 file changed, 120 insertions(+), 34 deletions(-) diff --git a/examples/hitl_review.rs b/examples/hitl_review.rs index 4f97c2f..91ccdf1 100644 --- a/examples/hitl_review.rs +++ b/examples/hitl_review.rs @@ -2,11 +2,11 @@ //! host-implemented review surface, the run pauses while nobody has answered, //! and the branch it takes afterwards depends on what the human said. //! -//! The host module here is `DeskReview` — a stand-in for whatever real surface a -//! host has (a Slack card, an inbox row, a web queue). It shows the two things -//! the [`ApprovalProvider`](tinyflows::caps::ApprovalProvider) contract asks -//! for: **create-or-fetch** on `request_id`, so re-asking never notifies the -//! reviewer twice, and a decision that can carry the human's own edit. +//! `DeskReview` below stands in for whatever real surface a host has — a Slack +//! card, an inbox row, a web queue. It shows the two things the +//! [`ApprovalProvider`](tinyflows::caps::ApprovalProvider) contract asks for: +//! **create-or-fetch** on `request_id`, so re-asking never notifies the reviewer +//! twice, and a decision that can carry the human's own edit. //! //! Run: cargo run --example hitl_review --features mock #[cfg(feature = "mock")] @@ -15,6 +15,7 @@ async fn main() { use std::collections::HashMap; use std::sync::{Arc, Mutex}; + use async_trait::async_trait; use serde_json::{Value, json}; use tinyflows::caps::mock::mock_capabilities; use tinyflows::caps::{ @@ -25,7 +26,7 @@ async fn main() { use tinyflows::model::{Edge, Node, NodeKind, WorkflowGraph}; /// A host's review desk: one row per `request_id`, holding the verdict once - /// a human leaves one. + /// a human has left one. #[derive(Default)] struct DeskReview { rows: Mutex>>, @@ -40,50 +41,135 @@ async fn main() { .insert(request_id.to_string(), Some(decision)); } - fn open_reviews(&self) -> Vec { - self.rows.lock().expect("lock").keys().cloned().collect() + /// Every review this desk has been asked to run, decided or not. + fn queue(&self) -> Vec { + let mut ids: Vec = self.rows.lock().expect("lock").keys().cloned().collect(); + ids.sort(); + ids } } - #[async_trait::async_trait] + #[async_trait] impl ApprovalProvider for DeskReview { - async fn decide(&self, request: &ApprovalRequest) -> tinyflows::error::Result - where - Value: Sized, - { - unreachable!() + async fn decide(&self, request: &ApprovalRequest) -> tinyflows::error::Result { + let mut rows = self.rows.lock().expect("lock"); + // Create-or-fetch: the row is keyed on `request_id`, so the run + // asking again after a resume finds THIS review rather than opening + // a second one and pinging the reviewer twice. + let row = rows.entry(request.request_id.clone()).or_insert_with(|| { + println!( + "[desk] new review {:?}: {} -> {}", + request.request_id, + request.title.as_deref().unwrap_or("(untitled)"), + request.subject.value + ); + None + }); + Ok(match row.clone() { + Some(decision) => ApprovalOutcome::Decided(decision), + None => ApprovalOutcome::Pending, + }) } } - let _ = ( - mock_capabilities as fn() -> Capabilities, - compile, - run, - resume, - json!({}), - Value::Null, + fn node(id: &str, kind: NodeKind, config: Value) -> Node { Node { - id: String::new(), - kind: NodeKind::Trigger, + id: id.into(), + kind, type_version: 1, - name: String::new(), - config: Value::Null, + name: id.into(), + config, ports: vec![], position: None, - }, + } + } + fn edge(from: &str, port: &str, to: &str) -> Edge { Edge { - from_node: String::new(), - from_port: String::new(), - to_node: String::new(), - to_port: String::new(), + from_node: from.into(), + from_port: port.into(), + to_node: to.into(), + to_port: "main".into(), + } + } + + // trigger -> review -> publish (on `approved`) / revise (on `rejected`). + let graph = WorkflowGraph { + nodes: vec![ + node("trigger", NodeKind::Trigger, Value::Null), + node( + "review", + NodeKind::Approval, + json!({ + "title": "Publish this post?", + "prompt": "Approving publishes it to the public feed.", + "subject_kind": "url", + "subject": "=item.url", + "assignees": ["editor@example.com"], + }), + ), + node( + "publish", + NodeKind::Transform, + json!({ "set": { "published": "=item.subject" } }), + ), + node( + "revise", + NodeKind::Transform, + json!({ "set": { "revise_because": "=item.comment" } }), + ), + ], + edges: vec![ + edge("trigger", "main", "review"), + edge("review", "approved", "publish"), + edge("review", "rejected", "revise"), + ], + ..Default::default() + }; + + let compiled = compile(&graph).expect("compile"); + let desk = Arc::new(DeskReview::default()); + let caps = Capabilities { + approvals: Some(desk.clone()), + ..mock_capabilities() + }; + let trigger = json!({ "url": "https://example.com/drafts/42" }); + + // 1) Nobody has answered, so the run suspends at the review. Nothing is + // burned while the card sits in someone's queue. + let paused = run(&compiled, trigger.clone(), &caps).await.expect("run"); + println!("--- before the human answers ---"); + println!("pending_approvals: {:?}", paused.pending_approvals); + println!("desk queue: {:?}", desk.queue()); + + // 2) The human approves — and edits the URL on the way through, which the + // host reports as the decision's payload. + let request_id = desk.queue().first().cloned().expect("one open review"); + desk.answer( + &request_id, + ApprovalDecision { + approved: true, + decided_by: Some("editor@example.com".into()), + comment: Some("fixed the slug".into()), + payload: Some(json!("https://example.com/drafts/42?utm=newsletter")), }, - WorkflowGraph::default(), - ApprovalOutcome::Pending, - Arc::new(DeskReview::default()).open_reviews(), ); + + // 3) Resuming re-asks the desk, which now has the verdict. Note the review + // id is unchanged, so the reviewer is never asked a second time. + let done = resume(&compiled, trigger, vec![], &caps) + .await + .expect("resume"); + println!("--- after the human answers ---"); + println!("pending_approvals: {:?}", done.pending_approvals); + println!("review port: {}", done.output["nodes"]["review"]["port"]); + println!( + "published: {}", + done.output["nodes"]["publish"]["items"][0]["json"]["published"] + ); + println!("desk queue: {:?}", desk.queue()); } #[cfg(not(feature = "mock"))] fn main() { - eprintln!("run with --features mock"); + eprintln!("this example needs the mock capabilities: cargo run --example hitl_review --features mock"); } From 27d1470d8247a5edbf4cdfb88b8c7d07707df692 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:53:05 +0300 Subject: [PATCH 020/138] chore: files changed CHANGELOG.md,examples/hitl_review.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- CHANGELOG.md | 3 +++ examples/hitl_review.rs | 14 +++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de98128..bb360a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `requires_approval` gate. Waits by suspending by default, with an optional bounded `wait_mode: "poll"`; `on_reject` (`route` / `error` / `drop`) and `on_timeout` (`error` / `reject` / `route`) decide what a "no" does. + Note for hosts that build `caps::Capabilities` with a struct literal: it gains + an `approvals: Option>` field, so add + `approvals: None` (or a provider) to keep compiling. ### Changed diff --git a/examples/hitl_review.rs b/examples/hitl_review.rs index 91ccdf1..47e7236 100644 --- a/examples/hitl_review.rs +++ b/examples/hitl_review.rs @@ -51,7 +51,10 @@ async fn main() { #[async_trait] impl ApprovalProvider for DeskReview { - async fn decide(&self, request: &ApprovalRequest) -> tinyflows::error::Result { + async fn decide( + &self, + request: &ApprovalRequest, + ) -> tinyflows::error::Result { let mut rows = self.rows.lock().expect("lock"); // Create-or-fetch: the row is keyed on `request_id`, so the run // asking again after a resume finds THIS review rather than opening @@ -161,7 +164,10 @@ async fn main() { .expect("resume"); println!("--- after the human answers ---"); println!("pending_approvals: {:?}", done.pending_approvals); - println!("review port: {}", done.output["nodes"]["review"]["port"]); + println!( + "review port: {}", + done.output["nodes"]["review"]["port"] + ); println!( "published: {}", done.output["nodes"]["publish"]["items"][0]["json"]["published"] @@ -171,5 +177,7 @@ async fn main() { #[cfg(not(feature = "mock"))] fn main() { - eprintln!("this example needs the mock capabilities: cargo run --example hitl_review --features mock"); + eprintln!( + "this example needs the mock capabilities: cargo run --example hitl_review --features mock" + ); } From 3b7b2f5305704919f5b6e9234f2de9e066f18f4a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 20:53:21 +0300 Subject: [PATCH 021/138] feat(nodes): add an approval node kind and the ApprovalProvider capability A human-in-the-loop review as a step in the graph: the node carries what is being reviewed (URL, text, any payload), hands it to the host's review surface through the new optional caps::ApprovalProvider, and routes the verdict on its approved/rejected ports. Hosts that wire no provider still get a working node -- it pauses the run and is settled with engine::resume. Co-authored-by: Medulla --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f24437..d299eec 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,11 @@ Rust 2024 · MSRV 1.85 · `#![forbid(unsafe_code)]` · GPL-3.0-or-later. approves and continues. A host can also drive durable, cross-process resume by injecting a `Checkpointer` via `engine::run_with_checkpointer` / `resume_with_checkpointer`. +- Human review as a graph step: an `approval` node carries what is being + reviewed (a URL, a draft, any payload), reaches the human through the + host-implemented `caps::ApprovalProvider`, and routes the verdict — reviewer, + comment, any edit they made — on its `approved` / `rejected` ports. With no + provider injected it degrades to the pause-and-resume gate above. - Observability via `tracing` plus a `RunObserver` hook and `Run` / `ExecutionStep` records. @@ -192,6 +197,7 @@ cargo run --example --features mock | `capability_pipeline` | A linear `http_request → code → agent → tool_call` pipeline through the host capability traits (mocked). | | `error_handling` | Per-node `retry` plus `on_error: "route"` recovering a failing node via its `error` port. | | `hitl_approval` | A `requires_approval` gate pauses the run (`pending_approvals`), then `run_resumable(...).resume(...)` continues from the checkpoint. | +| `hitl_review` | An `approval` node against a host-implemented `ApprovalProvider`: the run suspends, a "human" approves with an edit, and the resume takes the `approved` branch. | | `jq_expressions` | The jaq-backed jq engine in a `transform` node (e.g. `=.item.prices | add`). | Omitting `--features mock` is harmless: the demo body is @@ -221,7 +227,8 @@ Run all of them in one go: ```sh for ex in hello_workflow conditional_branch parallel_and_merge \ - capability_pipeline error_handling hitl_approval jq_expressions; do + capability_pipeline error_handling hitl_approval hitl_review \ + jq_expressions; do cargo run --example "$ex" --features mock done ``` From d9e820b1b5878ab533aaea434bdd10062e9cd215 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:16:28 +0300 Subject: [PATCH 022/138] chore: files changed tests/fuzz_interception.proptest-regressions Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/fuzz_interception.proptest-regressions | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/fuzz_interception.proptest-regressions diff --git a/tests/fuzz_interception.proptest-regressions b/tests/fuzz_interception.proptest-regressions new file mode 100644 index 0000000..c9ae941 --- /dev/null +++ b/tests/fuzz_interception.proptest-regressions @@ -0,0 +1,11 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 5a8255f59206209c6bbdf34e78e14ead0bae17e4af6fdb64842502686224265d # shrinks to shape = Branch(Branch(Spawned { tasks: 3, release: "all", n: 3 }, Spawned { tasks: 1, release: "all", n: 1 }), Spawned { tasks: 2, release: "all", n: 3 }) +cc 201cea089acc9cbb4ed3a0bf98e20f57b1e671b9fb6d8636084a4c5a06abd47c # shrinks to shape = Loop { max_iter: 3, body: Spawned { tasks: 3, release: "all", n: 3 } } +cc 60dcc55b51c47d53d5c569e160e47326be3516ad1444256c44ef6a21f1a3a708 # shrinks to shape = Nested(Branch(Spawned { tasks: 1, release: "all", n: 2 }, Loop { max_iter: 3, body: Spawned { tasks: 3, release: "all", n: 1 } })) +cc c9826493d112ad19cdf5822aac757e9b93306189414049540997db7018e2e39c # shrinks to shape = Loop { max_iter: 3, body: Spawned { tasks: 1, release: "all", n: 1 } } +cc 10f774fdc90ae37d8987e735b588e777bd8c9ae98bb4f5995d03ab995647ca66 # shrinks to shape = Fanout([Linear(2), Loop { max_iter: 2, body: Linear(1) }, Fanout([Linear(1), Linear(1), Spawned { tasks: 2, release: "all", n: 3 }])]) From 776f19c4d6a237d500dd21ae74758727c4dc50f3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:20:31 +0300 Subject: [PATCH 023/138] chore: files changed src/validate.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/validate.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/validate.rs b/src/validate.rs index 2dbd922..82dc293 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -452,17 +452,18 @@ pub fn validate_all(graph: &WorkflowGraph) -> Vec { // a bare string here (the natural mistake for a single reviewer) would // be read as "nobody", and the review would go to an empty audience // with no error anywhere. - if let Some(assignees) = node.config.get("assignees") - && !assignees + if let Some(assignees) = node.config.get("assignees") { + if !assignees .as_array() .is_some_and(|values| values.iter().all(Value::is_string)) - { - errors.push(ValidationError::InvalidNodeConfig { - node: node.id.clone(), - reason: "approval node `assignees` must be an array of strings (a single \ - reviewer is a one-element array)" - .to_string(), - }); + { + errors.push(ValidationError::InvalidNodeConfig { + node: node.id.clone(), + reason: "approval node `assignees` must be an array of strings (a single \ + reviewer is a one-element array)" + .to_string(), + }); + } } } From bc345c52ee8369c9ca79cd4b8a74eca84273ef9d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:20:36 +0300 Subject: [PATCH 024/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index d2a3836..570492d 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -439,17 +439,18 @@ impl ApprovalNode { // any more, so withdraw it rather than leaving a dead card in a queue. // Best-effort: the run has already decided what to do, and a failed // withdrawal must not change that. - if let Some(provider) = ctx.caps.approvals.as_ref() - && let Err(err) = provider + if let Some(provider) = ctx.caps.approvals.as_ref() { + if let Err(err) = provider .cancel(&request.request_id, "approval node timed out") .await - { - tracing::warn!( - node = %ctx.node.id, - request = %request.request_id, - error = %err, - "withdrawing the timed-out review failed" - ); + { + tracing::warn!( + node = %ctx.node.id, + request = %request.request_id, + error = %err, + "withdrawing the timed-out review failed" + ); + } } let timed_out = json!({ From 2af7dbd5e15c64a7c8cb6817aa1408705f705892 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:20:56 +0300 Subject: [PATCH 025/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 35 ++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index 570492d..80bad24 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -170,16 +170,31 @@ fn run_id(ctx: &NodeContext<'_>) -> Option { /// and anything derived from the clock or a counter would create a fresh review /// on every resume. Hence run id + node id, or an explicit `config.request_id` /// for a host that wants to key reviews its own way. -fn build_request(ctx: &NodeContext<'_>, config: &Value) -> ApprovalRequest { +/// +/// Falling back to the bare node id when *neither* is available would let two +/// different runs of the same graph collide on the same `request_id`: since +/// [`ApprovalProvider::decide`](crate::caps::ApprovalProvider::decide) is +/// create-or-fetch, a later run would silently inherit an earlier run's +/// decision and route an unreviewed subject straight through `approved`. So a +/// node with no `config.request_id` and no run-scoped identity is a +/// configuration error, not a degraded default. +fn build_request(ctx: &NodeContext<'_>, config: &Value) -> Result { let run = run_id(ctx); - let request_id = config - .get("request_id") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| match &run { + let request_id = match config.get("request_id").and_then(Value::as_str) { + Some(explicit) => explicit.to_string(), + None => match &run { Some(run) => format!("{run}:{}", ctx.node.id), - None => ctx.node.id.clone(), - }); + None => { + return Err(EngineError::Capability(format!( + "approval node {:?}: no `request_id` configured and no run-scoped identity \ + available (expected `run.id`, `run.run_id`, or `run.trigger.run_id`); set \ + `config.request_id` explicitly or seed a run id, otherwise later runs could \ + reuse an earlier run's decision", + ctx.node.id + ))); + } + }, + }; // The subject defaults to the item that arrived, which is the common case: // a node upstream produced the thing, and the human looks at it. @@ -189,7 +204,7 @@ fn build_request(ctx: &NodeContext<'_>, config: &Value) -> ApprovalRequest { .or_else(|| ctx.input.first().map(|item| item.json.clone())) .unwrap_or(Value::Null); - ApprovalRequest { + Ok(ApprovalRequest { request_id, node_id: ctx.node.id.clone(), run_id: run, @@ -215,7 +230,7 @@ fn build_request(ctx: &NodeContext<'_>, config: &Value) -> ApprovalRequest { }) .unwrap_or_default(), metadata: config.get("metadata").cloned().unwrap_or(Value::Null), - } + }) } /// A config field read as a string, ignoring a non-string (an unresolved From 0f1f7332eb197a6903084f3082485f2a50dc0270 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:21:00 +0300 Subject: [PATCH 026/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index 80bad24..0ac1252 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -340,7 +340,7 @@ fn decision_meta(decision: &ApprovalDecision, request: &ApprovalRequest) -> Valu impl NodeExecutor for ApprovalNode { async fn execute(&self, ctx: NodeContext<'_>) -> Result { let (config, diagnostics) = resolve_config_traced(&ctx); - let request = build_request(&ctx, &config); + let request = build_request(&ctx, &config)?; let input = ctx .input .first() From 0be092d9483b2c17fbcec5839397f3f1a18a5741 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:21:12 +0300 Subject: [PATCH 027/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index 0ac1252..9812f7d 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -468,12 +468,34 @@ impl ApprovalNode { } } + // The `=nodes..decision.approved` contract other settled reviews + // give the graph applies here too: a downstream guard reading it after + // a timeout must see `false`, not an absent value, and a `rejected` + // recovery branch reading `=item.comment` / `=item.input` must not get + // `null` back just because the review timed out rather than being + // actively declined. + let comment = format!("no decision after {max_polls} polls"); let timed_out = json!({ "approved": false, "timed_out": true, "request_id": request.request_id, "subject": request.subject.value, "subject_kind": request.subject.kind, + "edited": false, + "decided_by": Value::Null, + "comment": comment, + "input": ctx.input.first().map(|item| item.json.clone()).unwrap_or(Value::Null), + }); + let meta = json!({ + POLLS_KEY: polls + 1, + "request_id": request.request_id, + "decision": { + "approved": false, + "timed_out": true, + "decided_by": Value::Null, + "comment": comment, + "request_id": request.request_id, + } }); match OnTimeout::from_config(config) { From 7e0587dfba646bb64ebb02c79eea77631d369a57 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:21:24 +0300 Subject: [PATCH 028/138] chore: files changed examples/hitl_review.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- examples/hitl_review.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/hitl_review.rs b/examples/hitl_review.rs index 47e7236..03f1e35 100644 --- a/examples/hitl_review.rs +++ b/examples/hitl_review.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Human review as a **step in the graph**: an `approval` node hands a URL to a //! host-implemented review surface, the run pauses while nobody has answered, //! and the branch it takes afterwards depends on what the human said. From dbba7889b292933df24888131962c0e82b8721ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:21:29 +0300 Subject: [PATCH 029/138] chore: files changed README.md Auto-committed-on: dragonfly Co-authored-by: Medulla --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a47f2f8..983853b 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ cargo run --example hello_workflow --features mock ## Examples -The crate ships seven runnable examples under [`examples/`](examples/). Each is +The crate ships eight runnable examples under [`examples/`](examples/). Each is gated on the `mock` cargo feature, so run them with: ```sh From 35546dea180133092dfc8813e336fbc9a08a3685 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:21:42 +0300 Subject: [PATCH 030/138] chore: files changed wiki/Capability-Traits.md Auto-committed-on: dragonfly Co-authored-by: Medulla --- wiki/Capability-Traits.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/wiki/Capability-Traits.md b/wiki/Capability-Traits.md index c334d5d..bae43f3 100644 --- a/wiki/Capability-Traits.md +++ b/wiki/Capability-Traits.md @@ -43,10 +43,14 @@ parameters (e.g. `args: { "text": "=item.name" }`). Values that do not start wit ## The `Capabilities` bundle The engine receives a `Capabilities` struct — the per-run bundle of host -implementations. It bundles all five host capabilities: `llm`, `tools`, `http`, -`code`, and `state` (each an `Arc`). Nodes reach each one through -`ctx.caps` during execution — for example, durable key/value state via -`ctx.caps.state`. +implementations. It bundles six host capabilities: `llm`, `tools`, `http`, +`code`, `state`, and the optional `approvals` (`Option>`) each an `Arc` (or `Option` of one, for +`approvals`). Nodes reach each one through `ctx.caps` during execution — for +example, durable key/value state via `ctx.caps.state`, and a human review via +`ctx.caps.approvals`. A host that builds `Capabilities` with a struct literal +must add an `approvals` field (`None` if it wires no provider) to keep +compiling. `ApprovalProvider::decide` is **create-or-fetch**, keyed on `ApprovalRequest::request_id`: the first call with an id creates the review, and From 1530b697db07cdf03f1d3b8b76cf9860ef95964d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:21:46 +0300 Subject: [PATCH 031/138] chore: files changed wiki/Node-Catalog.md Auto-committed-on: dragonfly Co-authored-by: Medulla --- wiki/Node-Catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wiki/Node-Catalog.md b/wiki/Node-Catalog.md index f6dc48d..a200751 100644 --- a/wiki/Node-Catalog.md +++ b/wiki/Node-Catalog.md @@ -76,7 +76,7 @@ traits](Capability-Traits). | `code` | Runs sandboxed user code | Config `language` (`javascript`/`python`), `source` — via `CodeRunner` | | `shell` | Runs a shell script, inline or from a file | Config `source` **or** `script_path`, plus `interpreter` (`sh`/`bash`), `cwd`, `env` — via `ShellRunner` | | `output_parser` | Parses/validates an agent's output into a structured shape | May use `LlmProvider` for auto-fixing; can nest as a sub-agent | -| `approval` | Puts a subject in front of a **human** and routes on approve/reject | Out `approved` / `rejected` / `timeout`; config `subject`, `subject_kind`, `title`, `prompt`, `assignees`, `wait_mode`, `on_reject` — via `ApprovalProvider` | +| `approval` | Puts a subject in front of a **human** and routes on approve/reject | Out `approved` / `rejected` / `timeout`; config `subject`, `subject_kind`, `title`, `prompt`, `assignees`, `wait_mode`, `on_reject`, `on_timeout` (`error` default / `reject` / `route` — `route` is required to reach the `timeout` port) — via `ApprovalProvider` | | `sub_workflow` | Runs another workflow as a nested sub-graph | Config: exactly one of `workflow` (inline) / `workflow_id`; optional `inputs` map for the child's declared inputs | The capability-backed integration nodes (`agent`, `tool_call`, `http_request`) From 0c00033c5d71eb83389ec57baea42cd6e49863ff Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:21:54 +0300 Subject: [PATCH 032/138] chore: files changed src/validate_tests/validate_tests_part_03_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/validate_tests/validate_tests_part_03_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/validate_tests/validate_tests_part_03_tests.rs b/src/validate_tests/validate_tests_part_03_tests.rs index 0c13ce1..d4451bb 100644 --- a/src/validate_tests/validate_tests_part_03_tests.rs +++ b/src/validate_tests/validate_tests_part_03_tests.rs @@ -169,7 +169,7 @@ fn approval_behaviour_selectors_must_be_known_values() { ("on_reject", "reroute"), ("on_timeout", "partial"), ] { - let graph = approval_graph(serde_json::json!({ key: bad })); + let graph = approval_graph(serde_json::json!({ (key): bad })); let errors = validate_all(&graph); assert!( errors.iter().any(|e| matches!( From ddb538125e8556a9e952bd4818d1fba34db85d8e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:22:19 +0300 Subject: [PATCH 033/138] chore: files changed src/caps/mock_approvals.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock_approvals.rs | 109 +++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/caps/mock_approvals.rs diff --git a/src/caps/mock_approvals.rs b/src/caps/mock_approvals.rs new file mode 100644 index 0000000..fdae119 --- /dev/null +++ b/src/caps/mock_approvals.rs @@ -0,0 +1,109 @@ +//! [`MockApprovals`], the in-memory [`ApprovalProvider`] used by +//! [`mock_capabilities`](super::mock::mock_capabilities) and by tests that want +//! to drive the `approval` node's approve / reject / pending paths without a +//! real review surface. + +use async_trait::async_trait; + +use crate::caps::{ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest}; +use crate::error::Result; + +/// An [`ApprovalProvider`] that answers every review the same way, without a +/// human anywhere. +/// +/// Defaults to approving, so a graph containing an `approval` node dry-runs +/// end-to-end out of the box (the `MockMemory` precedent). Use +/// [`rejecting`](Self::rejecting) to drive the reject branch and +/// [`pending`](Self::pending) to exercise the waiting path — a suspending node +/// then pauses the run, and a polling one spends its poll budget. +/// +/// Records every `request_id` it has seen so a test can assert the +/// create-or-fetch contract holds (one review per id, however many activations +/// the node had). +#[derive(Debug, Default)] +pub struct MockApprovals { + outcome: MockApprovalOutcome, + seen: std::sync::Mutex>, + cancelled: std::sync::Mutex>, +} + +/// What a [`MockApprovals`] answers with. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum MockApprovalOutcome { + /// Approve immediately. + #[default] + Approve, + /// Reject immediately. + Reject, + /// Never decide. + Pending, +} + +impl MockApprovals { + /// A provider that approves every request. + #[must_use] + pub fn approving() -> Self { + Self::default() + } + + /// A provider that rejects every request, with `"mock rejection"` as the + /// reviewer's comment. + #[must_use] + pub fn rejecting() -> Self { + Self { + outcome: MockApprovalOutcome::Reject, + ..Self::default() + } + } + + /// A provider that leaves every request pending forever. + #[must_use] + pub fn pending() -> Self { + Self { + outcome: MockApprovalOutcome::Pending, + ..Self::default() + } + } + + /// Every `request_id` passed to [`ApprovalProvider::decide`], in call order + /// (with repeats — the point is to show repeats are the *same* id). + #[must_use] + pub fn requested(&self) -> Vec { + self.seen.lock().expect("lock").clone() + } + + /// Every `request_id` passed to [`ApprovalProvider::cancel`]. + #[must_use] + pub fn cancelled(&self) -> Vec { + self.cancelled.lock().expect("lock").clone() + } +} + +#[async_trait] +impl ApprovalProvider for MockApprovals { + async fn decide(&self, request: &ApprovalRequest) -> Result { + self.seen + .lock() + .expect("lock") + .push(request.request_id.clone()); + Ok(match self.outcome { + MockApprovalOutcome::Approve => ApprovalOutcome::Decided(ApprovalDecision { + decided_by: Some("mock-reviewer".to_string()), + ..ApprovalDecision::approved() + }), + MockApprovalOutcome::Reject => ApprovalOutcome::Decided(ApprovalDecision { + decided_by: Some("mock-reviewer".to_string()), + ..ApprovalDecision::rejected(Some("mock rejection".to_string())) + }), + MockApprovalOutcome::Pending => ApprovalOutcome::Pending, + }) + } + + async fn cancel(&self, request_id: &str, _reason: &str) -> Result<()> { + self.cancelled + .lock() + .expect("lock") + .push(request_id.to_string()); + Ok(()) + } +} From de1948b1d231736a8fa1a0cdb2dd0a0db93952ce Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:22:31 +0300 Subject: [PATCH 034/138] chore: files changed src/caps/mock.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 100 ----------------------------------------------- 1 file changed, 100 deletions(-) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index fec9702..cf33516 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -375,106 +375,6 @@ impl MemoryProvider for MockMemory { } } -/// An [`ApprovalProvider`] that answers every review the same way, without a -/// human anywhere. -/// -/// Defaults to approving, so a graph containing an `approval` node dry-runs -/// end-to-end out of the box (the [`MockMemory`] precedent). Use -/// [`rejecting`](Self::rejecting) to drive the reject branch and -/// [`pending`](Self::pending) to exercise the waiting path — a suspending node -/// then pauses the run, and a polling one spends its poll budget. -/// -/// Records every `request_id` it has seen so a test can assert the -/// create-or-fetch contract holds (one review per id, however many activations -/// the node had). -#[derive(Debug, Default)] -pub struct MockApprovals { - outcome: MockApprovalOutcome, - seen: std::sync::Mutex>, - cancelled: std::sync::Mutex>, -} - -/// What a [`MockApprovals`] answers with. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -enum MockApprovalOutcome { - /// Approve immediately. - #[default] - Approve, - /// Reject immediately. - Reject, - /// Never decide. - Pending, -} - -impl MockApprovals { - /// A provider that approves every request. - #[must_use] - pub fn approving() -> Self { - Self::default() - } - - /// A provider that rejects every request, with `"mock rejection"` as the - /// reviewer's comment. - #[must_use] - pub fn rejecting() -> Self { - Self { - outcome: MockApprovalOutcome::Reject, - ..Self::default() - } - } - - /// A provider that leaves every request pending forever. - #[must_use] - pub fn pending() -> Self { - Self { - outcome: MockApprovalOutcome::Pending, - ..Self::default() - } - } - - /// Every `request_id` passed to [`ApprovalProvider::decide`], in call order - /// (with repeats — the point is to show repeats are the *same* id). - #[must_use] - pub fn requested(&self) -> Vec { - self.seen.lock().expect("lock").clone() - } - - /// Every `request_id` passed to [`ApprovalProvider::cancel`]. - #[must_use] - pub fn cancelled(&self) -> Vec { - self.cancelled.lock().expect("lock").clone() - } -} - -#[async_trait] -impl ApprovalProvider for MockApprovals { - async fn decide(&self, request: &ApprovalRequest) -> Result { - self.seen - .lock() - .expect("lock") - .push(request.request_id.clone()); - Ok(match self.outcome { - MockApprovalOutcome::Approve => ApprovalOutcome::Decided(ApprovalDecision { - decided_by: Some("mock-reviewer".to_string()), - ..ApprovalDecision::approved() - }), - MockApprovalOutcome::Reject => ApprovalOutcome::Decided(ApprovalDecision { - decided_by: Some("mock-reviewer".to_string()), - ..ApprovalDecision::rejected(Some("mock rejection".to_string())) - }), - MockApprovalOutcome::Pending => ApprovalOutcome::Pending, - }) - } - - async fn cancel(&self, request_id: &str, _reason: &str) -> Result<()> { - self.cancelled - .lock() - .expect("lock") - .push(request_id.to_string()); - Ok(()) - } -} - /// A [`StateStore`] backed by an in-memory map guarded by a mutex. #[derive(Debug, Default)] pub struct MockStateStore { From df2ef83ff998292f363206c2da7e70d8204f7d36 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:22:36 +0300 Subject: [PATCH 035/138] chore: files changed src/caps/mock.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index cf33516..1106ab5 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -10,13 +10,16 @@ use async_trait::async_trait; use serde_json::{Value, json}; use crate::caps::{ - AgentRunner, ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, - Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider, ShellOutcome, - ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, WorkflowResolver, + AgentRunner, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider, + ShellOutcome, ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, + WorkflowResolver, }; use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; +mod mock_approvals; +pub use mock_approvals::MockApprovals; + /// An [`LlmProvider`] that echoes the request back under a `completion` key. #[derive(Debug, Default, Clone)] pub struct MockLlm; From 74af0056574b3e867933b5a5896a585666b67db1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:22:55 +0300 Subject: [PATCH 036/138] chore: files changed src/caps/mock.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index 1106ab5..3a47a9f 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -17,6 +17,7 @@ use crate::caps::{ use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; +#[path = "mock_approvals.rs"] mod mock_approvals; pub use mock_approvals::MockApprovals; From 0ca98c2d18c79d11bf58b32020f7c6c18fc53c15 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:23:06 +0300 Subject: [PATCH 037/138] chore: files changed src/caps/mock.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index 3a47a9f..49aa912 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -10,9 +10,9 @@ use async_trait::async_trait; use serde_json::{Value, json}; use crate::caps::{ - AgentRunner, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider, - ShellOutcome, ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, - WorkflowResolver, + AgentRunner, ApprovalProvider, Capabilities, CodeLanguage, CodeRunner, HttpClient, + LlmProvider, MemoryProvider, ShellOutcome, ShellRequest, ShellRunner, ShellScript, StateStore, + ToolInvoker, WorkflowResolver, }; use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; From 75412367c4badb9b2d428b1fce1d9c4aba4fc300 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:23:37 +0300 Subject: [PATCH 038/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index 9812f7d..095d09f 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -351,7 +351,29 @@ impl NodeExecutor for ApprovalNode { // resume or a listed approval is the answer, and re-asking would put a // decided review back in front of the provider. let outcome = match delivered(&ctx, &request) { - Some(decision) => ApprovalOutcome::Decided(decision), + Some(decision) => { + // A provider may have a card open for this review from an + // earlier activation's `decide` call (it went `Pending`, or + // this run never asked because the answer already arrived some + // other way). Either way nobody is waiting on the provider's + // card any more, so withdraw it rather than leave a stale entry + // in the host's queue. Best-effort: the run has already decided + // what to do, and a failed withdrawal must not change that. + if let Some(provider) = ctx.caps.approvals.as_ref() { + if let Err(err) = provider + .cancel(&request.request_id, "resolved via resume") + .await + { + tracing::warn!( + node = %ctx.node.id, + request = %request.request_id, + error = %err, + "withdrawing the provider's review after a resume decision failed" + ); + } + } + ApprovalOutcome::Decided(decision) + } None => match ctx.caps.approvals.as_ref() { Some(provider) => provider.decide(&request).await?, // No provider: the node is a pause the host settles out of band From 282ff5ce461db7643e6e08e37a1558f7b149e897 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:25:04 +0300 Subject: [PATCH 039/138] chore: files changed src/nodes/integration/approval_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_tests.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index 6268f43..bd132fb 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -7,7 +7,22 @@ use crate::engine::{RunInput, resume, run}; use crate::model::{Edge, Node, NodeKind, WorkflowGraph}; /// A trigger wired into one `approval` node, with `config` on the approval. -fn wf(config: Value) -> WorkflowGraph { +/// +/// Fills in a stable `request_id` when the caller's config does not already +/// name one, since without a run-scoped identity `build_request` now refuses +/// to guess one (see `a_missing_request_id_and_run_id_is_a_configuration_error` +/// below for the case that tests the refusal itself). +fn wf(mut config: Value) -> WorkflowGraph { + if let Some(obj) = config.as_object_mut() { + obj.entry("request_id".to_string()) + .or_insert_with(|| json!("review-request")); + } + wf_raw(config) +} + +/// [`wf`] without the `request_id` auto-fill, for tests that need to control +/// exactly what identity information the node config and run carry. +fn wf_raw(config: Value) -> WorkflowGraph { WorkflowGraph { nodes: vec![ Node { From b059f0d634d9bb581950ef20f3c03584628f8d2b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:25:34 +0300 Subject: [PATCH 040/138] chore: files changed src/nodes/integration/approval_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_tests.rs | 215 ++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index bd132fb..6f896d4 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -354,3 +354,218 @@ async fn on_timeout_error_is_the_default_and_names_the_node() { .expect_err("an unanswered review fails by default"); assert!(err.to_string().contains("review"), "got {err}"); } + +/// Without an explicit `request_id` and without a run-scoped identity the node +/// must refuse to guess: falling back to the bare node id would let a later +/// run of the same graph reuse an earlier run's decision through the +/// provider's create-or-fetch contract, and route an unreviewed subject +/// straight through `approved`. +#[tokio::test] +async fn a_missing_request_id_and_run_id_is_a_configuration_error() { + let graph = wf_raw(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let err = run(&compiled, Value::Null, &mock_capabilities()) + .await + .expect_err("no request_id and no run id must be refused"); + let message = err.to_string(); + assert!(message.contains("request_id"), "got {message}"); +} + +/// With a run-scoped id available (`trigger.run_id`, in the shape +/// `run.trigger.run_id` a host's trigger payload takes), the node derives a +/// stable `":"` request id without needing an explicit +/// `config.request_id`. +#[tokio::test] +async fn a_run_id_in_the_trigger_derives_a_stable_request_id() { + let graph = wf_raw(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + json!({ "run_id": "run-42" }), + &mock_capabilities(), + ) + .await + .expect("a run id makes the request_id derivable"); + + assert_eq!( + out.output["nodes"]["review"]["items"][0]["json"]["request_id"], + "run-42:review" + ); +} + +/// `on_reject: "drop"` emits nothing — a regression that accidentally emitted +/// an item here would go unnoticed by every other rejection test, which all +/// use `route`. +#[tokio::test] +async fn on_reject_drop_emits_nothing_but_still_records_the_decision() { + let graph = wf(json!({ "on_reject": "drop" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::rejecting()), + ) + .await + .expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!( + slot["items"], json!([]), + "on_reject: drop must not emit an item" + ); + assert_eq!( + slot["decision"]["approved"], + json!(false), + "the verdict stays addressable as =nodes.review.decision.approved even when dropped" + ); +} + +/// `on_timeout: "reject"` hands the timed-out review to the `on_reject` +/// policy. Covers all three `on_reject` sub-paths so a regression in any one +/// of them is caught here rather than by a host. +#[tokio::test] +async fn on_timeout_reject_follows_the_on_reject_policy() { + // route: timed_out item lands on `rejected`, with the fields a settled + // review always carries (not just `approved`/`timed_out`). + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 1, + "on_timeout": "reject", + })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect("run"); + let slot = &out.output["nodes"]["review"]; + assert_eq!(slot["port"], "rejected"); + let item = &slot["items"][0]["json"]; + assert_eq!(item["approved"], json!(false)); + assert_eq!(item["timed_out"], json!(true)); + assert_eq!(item["edited"], json!(false)); + assert_eq!(item["decided_by"], Value::Null); + assert!( + item["comment"].as_str().is_some_and(|c| !c.is_empty()), + "a timed-out rejection still carries a comment explaining why, got {item:?}" + ); + assert_eq!( + slot["decision"]["approved"], + json!(false), + "=nodes.review.decision.approved must resolve to false after a timeout, not be absent" + ); + + // drop: nothing emitted. + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 1, + "on_timeout": "reject", + "on_reject": "drop", + })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect("run"); + assert_eq!(out.output["nodes"]["review"]["items"], json!([])); + + // error: the node fails rather than silently continuing. + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 1, + "on_timeout": "reject", + "on_reject": "error", + })); + let compiled = compile(&graph).expect("compile"); + let err = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect_err("on_timeout: reject with on_reject: error must fail the node"); + assert!(err.to_string().contains("on_reject"), "got {err}"); +} + +/// A decision that carries the reviewer's own edit (`payload`) drives +/// `subject` to that edit and marks `edited: true` — the feature this node +/// exists for, exercised through the resume channel here since `MockApprovals` +/// has no payload-bearing outcome. +#[tokio::test] +async fn a_decision_with_a_payload_edits_the_subject() { + let graph = wf(json!({ "subject": "=item.url" })); + let compiled = compile(&graph).expect("compile"); + let caps = mock_capabilities_with_approvals(MockApprovals::pending()); + + let paused = run( + &compiled, + json!({ "url": "https://example.com/original" }), + &caps, + ) + .await + .expect("run pauses on the poll budget or the pause path"); + assert_eq!(paused.pending_approvals, vec!["review".to_string()]); + + let resumed = crate::engine::resume_with( + &compiled, + Value::Null, + json!({ + "decision": { + "approved": true, + "decided_by": "ada", + "payload": "https://example.com/edited", + } + }), + &caps, + ) + .await + .expect("resume with an edited payload"); + + let item = &resumed.output["nodes"]["review"]["items"][0]["json"]; + assert_eq!(item["approved"], json!(true)); + assert_eq!(item["subject"], "https://example.com/edited"); + assert_eq!(item["edited"], json!(true)); + assert_eq!(item["decided_by"], "ada"); +} + +/// When a resume (or a listed approval) settles the review, any provider card +/// opened by an earlier `decide` call must be withdrawn — otherwise the +/// provider's queue keeps a stale entry for a review the run already closed. +#[tokio::test] +async fn a_resume_decision_withdraws_the_provider_card() { + let graph = wf(json!({ "wait_mode": "poll", "poll_interval_ms": 1, "max_polls": 5 })); + let compiled = compile(&graph).expect("compile"); + let provider = std::sync::Arc::new(MockApprovals::pending()); + let caps = crate::caps::Capabilities { + approvals: Some(provider.clone()), + ..mock_capabilities() + }; + + // First activation asks the provider and gets Pending, opening a card. + let paused = run(&compiled, Value::Null, &caps).await.expect("run"); + assert_eq!(paused.pending_approvals, vec!["review".to_string()]); + assert!( + !provider.requested().is_empty(), + "the provider must have been asked at least once" + ); + assert!(provider.cancelled().is_empty(), "no reason to cancel yet"); + + // A resume delivers the decision directly (bypassing the provider), so the + // node must withdraw the provider's now-stale card rather than leave it. + let resumed = resume(&compiled, Value::Null, vec!["review".to_string()], &caps) + .await + .expect("resume"); + assert_eq!(resumed.output["nodes"]["review"]["port"], "approved"); + assert!( + !provider.cancelled().is_empty(), + "the provider's card must be withdrawn once a resume settles the review" + ); +} From 29f935c3651a5d2133b898eeff8dc69578ed94f6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:25:56 +0300 Subject: [PATCH 041/138] chore: files changed src/nodes/integration/approval_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_tests.rs | 61 ++++++++++--------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index 6f896d4..a8542ed 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -497,43 +497,30 @@ async fn on_timeout_reject_follows_the_on_reject_policy() { /// A decision that carries the reviewer's own edit (`payload`) drives /// `subject` to that edit and marks `edited: true` — the feature this node -/// exists for, exercised through the resume channel here since `MockApprovals` -/// has no payload-bearing outcome. -#[tokio::test] -async fn a_decision_with_a_payload_edits_the_subject() { - let graph = wf(json!({ "subject": "=item.url" })); - let compiled = compile(&graph).expect("compile"); - let caps = mock_capabilities_with_approvals(MockApprovals::pending()); - - let paused = run( - &compiled, - json!({ "url": "https://example.com/original" }), - &caps, - ) - .await - .expect("run pauses on the poll budget or the pause path"); - assert_eq!(paused.pending_approvals, vec!["review".to_string()]); - - let resumed = crate::engine::resume_with( - &compiled, - Value::Null, - json!({ - "decision": { - "approved": true, - "decided_by": "ada", - "payload": "https://example.com/edited", - } - }), - &caps, - ) - .await - .expect("resume with an edited payload"); - - let item = &resumed.output["nodes"]["review"]["items"][0]["json"]; - assert_eq!(item["approved"], json!(true)); - assert_eq!(item["subject"], "https://example.com/edited"); - assert_eq!(item["edited"], json!(true)); - assert_eq!(item["decided_by"], "ada"); +/// exists for. `decision_from_resume` and `decided_item` are exercised +/// separately elsewhere; this checks the two compose correctly, matching what +/// a `{"decision": {..., "payload": ...}}` resume value produces end to end. +#[test] +fn a_decision_with_a_payload_edits_the_subject() { + let req = request("run-1:review"); + let resume_value = json!({ + "decision": { + "approved": true, + "decided_by": "ada", + "payload": "https://example.com/edited", + } + }); + let decision = decision_from_resume(&resume_value, &req).expect("a decision"); + assert!(decision.approved); + assert_eq!(decision.payload, Some(json!("https://example.com/edited"))); + + let item = decided_item(&req, &decision, json!({ "url": "https://example.com/original" })); + let json = item.json; + assert_eq!(json["approved"], json!(true)); + assert_eq!(json["subject"], "https://example.com/edited"); + assert_eq!(json["edited"], json!(true)); + assert_eq!(json["decided_by"], "ada"); + assert_eq!(json["input"], json!({ "url": "https://example.com/original" })); } /// When a resume (or a listed approval) settles the review, any provider card From 6165a8f2a6d0cdbe48c7a28fe9e94e573670efbd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:26:43 +0300 Subject: [PATCH 042/138] chore: files changed src/nodes/integration/approval_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index a8542ed..42a21d3 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -528,7 +528,7 @@ fn a_decision_with_a_payload_edits_the_subject() { /// provider's queue keeps a stale entry for a review the run already closed. #[tokio::test] async fn a_resume_decision_withdraws_the_provider_card() { - let graph = wf(json!({ "wait_mode": "poll", "poll_interval_ms": 1, "max_polls": 5 })); + let graph = wf(json!({ "title": "Publish this?" })); let compiled = compile(&graph).expect("compile"); let provider = std::sync::Arc::new(MockApprovals::pending()); let caps = crate::caps::Capabilities { From a4f5bc2ef219dd9ad0e6a39d19ddc99cb6678dff Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:27:02 +0300 Subject: [PATCH 043/138] chore: files changed tests/smoke_all_nodes.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/smoke_all_nodes.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/smoke_all_nodes.rs b/tests/smoke_all_nodes.rs index 46246ad..c7ae41b 100644 --- a/tests/smoke_all_nodes.rs +++ b/tests/smoke_all_nodes.rs @@ -384,7 +384,14 @@ async fn smoke_scatter_gather() { async fn smoke_approval() { smoke_single_node( NodeKind::Approval, - json!({ "title": "Ship it?", "subject_kind": "url", "subject": "=item.url" }), + json!({ + "title": "Ship it?", + "subject_kind": "url", + "subject": "=item.url", + // `request_id` (or a run-scoped id) is required: without one, the + // node refuses to guess an identity a later run could collide on. + "request_id": "smoke-approval", + }), json!({ "url": "https://example.com/preview" }), ) .await; From c69f66fc6909d270ee90cda7e287729b12696120 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:27:08 +0300 Subject: [PATCH 044/138] chore: files changed examples/hitl_review.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- examples/hitl_review.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/hitl_review.rs b/examples/hitl_review.rs index 03f1e35..2a931c3 100644 --- a/examples/hitl_review.rs +++ b/examples/hitl_review.rs @@ -105,6 +105,10 @@ async fn main() { "review", NodeKind::Approval, json!({ + // A real host would key this on the run id (e.g. + // `"=run.id"`) rather than a literal, so two runs of this + // graph never collide on the same review. + "request_id": "hitl-review-example", "title": "Publish this post?", "prompt": "Approving publishes it to the public feed.", "subject_kind": "url", From ffb86afabcb9a61a909c09461da01b1c6a711dce Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:27:14 +0300 Subject: [PATCH 045/138] chore: files changed src/catalog/contracts/group_03.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/catalog/contracts/group_03.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/catalog/contracts/group_03.rs b/src/catalog/contracts/group_03.rs index d0c0adc..0243557 100644 --- a/src/catalog/contracts/group_03.rs +++ b/src/catalog/contracts/group_03.rs @@ -162,8 +162,12 @@ pub(super) fn contract_approval() -> NodeKindContract { ConfigField::optional( "request_id", "string", - "Overrides the review's identity (default \":\"). Must be \ - stable across resumes: it is the key the host de-duplicates reviews on.", + "Overrides the review's identity (default \":\", derived from \ + whichever of `run.id` / `run.run_id` / `run.trigger.run_id` the host seeds). \ + Must be stable across resumes: it is the key the host de-duplicates reviews on. \ + Required when no run-scoped id is available — falling back to the bare node id \ + would let a later run of the same graph reuse an earlier run's decision, so the \ + node refuses to guess and fails instead.", ), ConfigField::optional( "wait_mode", From cc7ec6e18f004f98169450e15f75b0a8092c3ff8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:27:21 +0300 Subject: [PATCH 046/138] chore: files changed src/catalog/contracts/group_03.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/catalog/contracts/group_03.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/catalog/contracts/group_03.rs b/src/catalog/contracts/group_03.rs index 0243557..fc98f8c 100644 --- a/src/catalog/contracts/group_03.rs +++ b/src/catalog/contracts/group_03.rs @@ -208,6 +208,7 @@ pub(super) fn contract_approval() -> NodeKindContract { example: json!({ "id": "review", "kind": "approval", "name": "Approve the post", "config": { + "request_id": "=run.id", "title": "Publish this post?", "prompt": "Approving publishes it to the public feed.", "subject_kind": "url", From 01f262aff0d6078542178e73c39da997ee892c02 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:28:10 +0300 Subject: [PATCH 047/138] chore: files changed src/caps/mock.rs,src/nodes/integration/approval_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 6 +++--- src/nodes/integration/approval_tests.rs | 14 +++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index 49aa912..eb2f623 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -10,9 +10,9 @@ use async_trait::async_trait; use serde_json::{Value, json}; use crate::caps::{ - AgentRunner, ApprovalProvider, Capabilities, CodeLanguage, CodeRunner, HttpClient, - LlmProvider, MemoryProvider, ShellOutcome, ShellRequest, ShellRunner, ShellScript, StateStore, - ToolInvoker, WorkflowResolver, + AgentRunner, ApprovalProvider, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, + MemoryProvider, ShellOutcome, ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, + WorkflowResolver, }; use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index 42a21d3..63d5129 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -410,7 +410,8 @@ async fn on_reject_drop_emits_nothing_but_still_records_the_decision() { let slot = &out.output["nodes"]["review"]; assert_eq!( - slot["items"], json!([]), + slot["items"], + json!([]), "on_reject: drop must not emit an item" ); assert_eq!( @@ -514,13 +515,20 @@ fn a_decision_with_a_payload_edits_the_subject() { assert!(decision.approved); assert_eq!(decision.payload, Some(json!("https://example.com/edited"))); - let item = decided_item(&req, &decision, json!({ "url": "https://example.com/original" })); + let item = decided_item( + &req, + &decision, + json!({ "url": "https://example.com/original" }), + ); let json = item.json; assert_eq!(json["approved"], json!(true)); assert_eq!(json["subject"], "https://example.com/edited"); assert_eq!(json["edited"], json!(true)); assert_eq!(json["decided_by"], "ada"); - assert_eq!(json["input"], json!({ "url": "https://example.com/original" })); + assert_eq!( + json["input"], + json!({ "url": "https://example.com/original" }) + ); } /// When a resume (or a listed approval) settles the review, any provider card From 351640bf0b165861e44b972c4c1c2f1f5a86b94c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:32:45 +0300 Subject: [PATCH 048/138] chore: files changed src/testkit/mocks_double.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_double.rs | 511 ++++++++++++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 src/testkit/mocks_double.rs diff --git a/src/testkit/mocks_double.rs b/src/testkit/mocks_double.rs new file mode 100644 index 0000000..fda9086 --- /dev/null +++ b/src/testkit/mocks_double.rs @@ -0,0 +1,511 @@ +//! The call log ([`CapCall`]/[`CallOutcome`]/[`CallLog`]) and [`Double`], the +//! single type that implements every capability trait for +//! [`MockCaps`](super::MockCaps) by consulting its rules, recording what +//! happened, and answering. +//! +//! Split out of `mocks.rs` to keep that file under the repository's +//! line-length limit; `Double` and the log it writes to are one cohesive +//! concern (every trait impl below ends by writing a [`CapCall`]), so they +//! belong together rather than split further. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::caps::{ + AgentRunner, ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, + CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider, ShellOutcome, ShellRequest, + ShellRunner, StateStore, ToolInvoker, WorkflowResolver, +}; +use crate::error::{EngineError, Result}; +use crate::model::WorkflowGraph; + +use super::{MockCaps, capability, glob_matches}; + +/// How one capability call ended. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CallOutcome { + /// The call returned a value. + Ok(Value), + /// The call failed, with this message. + Err(String), +} + +/// One capability call a run made. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CapCall { + /// Position in the run's single call sequence, from 0. + /// + /// One counter across *all* capabilities, so the log says what order things + /// happened in — which per-capability counters cannot. + pub seq: u64, + /// Which capability — see the [`capability`] constants. + pub capability: String, + /// The trait method (`invoke`, `complete`, `request`, …). + pub method: String, + /// The node that made the call. + /// + /// `None` only when the call was made outside a node activation, which no + /// engine path does today. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + /// What identifies the target within the capability: a tool slug, an agent + /// ref, an HTTP method and URL, a state key. Empty when the capability has + /// no such notion. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub target: String, + /// The arguments the call was made with. + pub args: Value, + /// What it returned. + pub outcome: CallOutcome, +} + +/// Every capability call a run made, in order. +/// +/// Shared by every double in one [`MockCaps`], so the ordering across +/// capabilities is real rather than assembled afterwards from separate logs. +#[derive(Debug, Default)] +pub struct CallLog { + calls: Mutex>, + next_seq: AtomicU64, +} + +impl CallLog { + /// An empty log. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Append a call, assigning it the next sequence number. + pub(super) fn record( + &self, + capability: &str, + method: &str, + node_id: Option, + target: String, + args: Value, + outcome: CallOutcome, + ) { + let call = CapCall { + seq: self.next_seq.fetch_add(1, Ordering::SeqCst), + capability: capability.to_string(), + method: method.to_string(), + node_id, + target, + args, + outcome, + }; + self.calls.lock().expect("call log poisoned").push(call); + } + + /// Every call recorded so far, in sequence order. + #[must_use] + pub fn calls(&self) -> Vec { + let mut calls = self.calls.lock().expect("call log poisoned").clone(); + calls.sort_by_key(|call| call.seq); + calls + } + + /// The calls matching a capability and an optional target glob. + /// + /// `capability` is one of the [`capability`] constants; `target` accepts the + /// same `*` globbing the rules do, and `None` matches every target. + #[must_use] + pub fn matching(&self, capability: &str, target: Option<&str>) -> Vec { + self.calls() + .into_iter() + .filter(|call| call.capability == capability) + .filter(|call| target.is_none_or(|glob| glob_matches(glob, &call.target))) + .collect() + } + + /// How many calls match — the count an assertion usually wants. + #[must_use] + pub fn count(&self, capability: &str, target: Option<&str>) -> usize { + self.matching(capability, target).len() + } +} + +/// One capability double: it consults the rules, records the call, and answers. +/// +/// A single type implementing every capability trait rather than nine, because +/// each implementation is the same three steps and nine copies of them would +/// drift. +pub(super) struct Double { + mocks: Arc, + /// The node this double was scoped to, stamped onto every call it logs. + node_id: Option, + /// Backing map for the [`StateStore`] impl, which is the one capability + /// whose whole job is to remember. + state: Mutex>, +} + +impl Double { + pub(super) fn new(mocks: Arc, node_id: Option) -> Self { + Self { + mocks, + node_id, + state: Mutex::new(HashMap::new()), + } + } + + /// Consult the rules, log whatever happens, and return it. + async fn dispatch( + &self, + capability: &str, + method: &str, + target: String, + request: Value, + default: impl FnOnce(&Value) -> Value, + ) -> Result { + let programmed = self + .mocks + .respond_to(capability, &target, self.node_id.as_deref(), &request) + .await; + let result = match programmed { + Some(result) => result, + None => Ok(default(&request)), + }; + let outcome = match &result { + Ok(value) => CallOutcome::Ok(value.clone()), + Err(err) => CallOutcome::Err(err.to_string()), + }; + self.mocks.log().record( + capability, + method, + self.node_id.clone(), + target, + request, + outcome, + ); + result + } +} + +#[async_trait] +impl LlmProvider for Double { + async fn complete(&self, request: Value, conn: Option<&str>) -> Result { + let conn = conn.map(str::to_string); + self.dispatch( + capability::LLM, + "complete", + String::new(), + request, + |req| json!({ "completion": req, "connection": conn }), + ) + .await + } +} + +#[async_trait] +impl ToolInvoker for Double { + async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result { + let slug_owned = slug.to_string(); + let conn = conn.map(str::to_string); + self.dispatch( + capability::TOOLS, + "invoke", + slug.to_string(), + args, + move |args| json!({ "tool": slug_owned, "args": args, "connection": conn }), + ) + .await + } +} + +#[async_trait] +impl HttpClient for Double { + async fn request(&self, request: Value, conn: Option<&str>) -> Result { + // The URL is what a rule globs on; a request without one still matches + // a bare `*`. + let url = request + .get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let conn = conn.map(str::to_string); + self.dispatch( + capability::HTTP, + "request", + url, + request, + |req| json!({ "status": 200, "request": req, "connection": conn }), + ) + .await + } +} + +#[async_trait] +impl CodeRunner for Double { + async fn run(&self, language: CodeLanguage, source: &str, input: Value) -> Result { + let request = json!({ + "language": format!("{language:?}"), + "source": source, + "input": input, + }); + self.dispatch( + capability::CODE, + "run", + format!("{language:?}"), + request, + |req| json!({ "result": req.get("input").cloned().unwrap_or(Value::Null) }), + ) + .await + } +} + +#[async_trait] +impl ShellRunner for Double { + async fn run(&self, request: ShellRequest) -> Result { + let script = match &request.script { + crate::caps::ShellScript::Inline(source) => source.clone(), + crate::caps::ShellScript::Path(path) => path.clone(), + }; + let encoded = json!({ + "interpreter": request.interpreter.as_str(), + "script": script, + "cwd": request.cwd, + "env": request.env, + "input": request.input, + }); + let value = self + .dispatch( + capability::SHELL, + "run", + script.clone(), + encoded, + move |_req| json!({ "exit_code": 0, "stdout": script, "stderr": "" }), + ) + .await?; + // A programmed value may describe the whole outcome, or just be the + // stdout a test cares about. Accept either rather than making a caller + // spell out an exit code they do not care about. + Ok(ShellOutcome { + exit_code: value + .get("exit_code") + .and_then(Value::as_i64) + .unwrap_or(0) + .try_into() + .unwrap_or(0), + stdout: value + .get("stdout") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| value.to_string()), + stderr: value + .get("stderr") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) + } +} + +#[async_trait] +impl AgentRunner for Double { + async fn run_agent( + &self, + agent_ref: &str, + request: Value, + conn: Option<&str>, + ) -> Result { + let name = agent_ref.to_string(); + let conn = conn.map(str::to_string); + self.dispatch( + capability::AGENT, + "run_agent", + agent_ref.to_string(), + request, + move |req| json!({ "agent": name, "request": req, "connection": conn }), + ) + .await + } +} + +#[async_trait] +impl MemoryProvider for Double { + async fn recall(&self, scope: &str, query: &str, opts: Value) -> Result { + let request = json!({ "scope": scope, "query": query, "opts": opts }); + self.dispatch( + capability::MEMORY, + "recall", + scope.to_string(), + request, + |_| json!({ "results": [] }), + ) + .await + } + + async fn flavour(&self, slug: &str) -> Result { + let request = json!({ "slug": slug }); + self.dispatch( + capability::MEMORY, + "flavour", + slug.to_string(), + request, + |_| json!({ "traits": {} }), + ) + .await + } + + async fn people(&self, query: Option<&str>) -> Result { + let request = json!({ "query": query }); + self.dispatch( + capability::MEMORY, + "people", + String::new(), + request, + |_| json!({ "people": [] }), + ) + .await + } + + async fn remember(&self, scope: &str, key: &str, value: Value) -> Result<()> { + let request = json!({ "scope": scope, "key": key, "value": value }); + self.dispatch( + capability::MEMORY, + "remember", + format!("{scope}/{key}"), + request, + |_| Value::Null, + ) + .await + .map(|_| ()) + } + + async fn forget(&self, scope: &str, key: &str) -> Result<()> { + let request = json!({ "scope": scope, "key": key }); + self.dispatch( + capability::MEMORY, + "forget", + format!("{scope}/{key}"), + request, + |_| Value::Null, + ) + .await + .map(|_| ()) + } +} + +#[async_trait] +impl StateStore for Double { + async fn load(&self, key: &str) -> Result> { + let stored = self + .state + .lock() + .expect("mock state poisoned") + .get(key) + .cloned(); + // Logged like any other call, but the *store* is the source of truth: + // a rule that overrode a load would make a stateful graph unreadable. + self.mocks.log().record( + capability::STATE, + "load", + self.node_id.clone(), + key.to_string(), + json!({ "key": key }), + CallOutcome::Ok(stored.clone().unwrap_or(Value::Null)), + ); + Ok(stored) + } + + async fn store(&self, key: &str, value: Value) -> Result<()> { + self.state + .lock() + .expect("mock state poisoned") + .insert(key.to_string(), value.clone()); + self.mocks.log().record( + capability::STATE, + "store", + self.node_id.clone(), + key.to_string(), + json!({ "key": key, "value": value }), + CallOutcome::Ok(Value::Null), + ); + Ok(()) + } +} + +#[async_trait] +impl ApprovalProvider for Double { + async fn decide(&self, request: &ApprovalRequest) -> Result { + let encoded = json!({ + "request_id": request.request_id, + "node_id": request.node_id, + "run_id": request.run_id, + "title": request.title, + "prompt": request.prompt, + "subject": { + "kind": request.subject.kind, + "value": request.subject.value, + }, + "assignees": request.assignees, + "metadata": request.metadata, + }); + // An unprogrammed review approves, so a graph that contains one runs end + // to end without a test standing a reviewer up — the same bargain every + // other default here makes. A test that cares about the answer says so + // with `on_approval`. + let value = self + .dispatch( + capability::APPROVALS, + "decide", + request.request_id.clone(), + encoded, + |_| json!({ "approved": true, "decided_by": "testkit" }), + ) + .await?; + // A programmed answer may be the whole verdict or just the bit the test + // cares about, as with `ShellRunner` above. + if value.get("status").and_then(Value::as_str) == Some("pending") { + return Ok(ApprovalOutcome::Pending); + } + Ok(ApprovalOutcome::Decided(ApprovalDecision { + approved: value + .get("approved") + .and_then(Value::as_bool) + .unwrap_or(true), + decided_by: value + .get("decided_by") + .and_then(Value::as_str) + .map(str::to_string), + comment: value + .get("comment") + .and_then(Value::as_str) + .map(str::to_string), + payload: value.get("payload").cloned(), + })) + } + + async fn cancel(&self, request_id: &str, reason: &str) -> Result<()> { + self.dispatch( + capability::APPROVALS, + "cancel", + request_id.to_string(), + json!({ "request_id": request_id, "reason": reason }), + |_| json!({ "cancelled": true }), + ) + .await + .map(|_| ()) + } +} + +#[async_trait] +impl WorkflowResolver for Double { + async fn resolve(&self, workflow_id: &str) -> Result { + self.mocks + .workflow(workflow_id) + .ok_or_else(|| { + EngineError::Capability(format!( + "testkit: no workflow registered as {workflow_id:?}" + )) + }) + } +} From 2075f30a6733a36aa98347f7a8537ab9ed321d5a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:33:04 +0300 Subject: [PATCH 049/138] chore: files changed src/testkit/mocks_double.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_double.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/testkit/mocks_double.rs b/src/testkit/mocks_double.rs index fda9086..728d47e 100644 --- a/src/testkit/mocks_double.rs +++ b/src/testkit/mocks_double.rs @@ -501,7 +501,9 @@ impl ApprovalProvider for Double { impl WorkflowResolver for Double { async fn resolve(&self, workflow_id: &str) -> Result { self.mocks - .workflow(workflow_id) + .workflows + .get(workflow_id) + .cloned() .ok_or_else(|| { EngineError::Capability(format!( "testkit: no workflow registered as {workflow_id:?}" From 50a0755f8708e398b6a313bf07b14c455a308f57 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:33:21 +0300 Subject: [PATCH 050/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 97 -------------------------------------------- 1 file changed, 97 deletions(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index 394594f..c361d35 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -88,103 +88,6 @@ pub enum CallOutcome { Err(String), } -/// One capability call a run made. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CapCall { - /// Position in the run's single call sequence, from 0. - /// - /// One counter across *all* capabilities, so the log says what order things - /// happened in — which per-capability counters cannot. - pub seq: u64, - /// Which capability — see the [`capability`] constants. - pub capability: String, - /// The trait method (`invoke`, `complete`, `request`, …). - pub method: String, - /// The node that made the call. - /// - /// `None` only when the call was made outside a node activation, which no - /// engine path does today. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_id: Option, - /// What identifies the target within the capability: a tool slug, an agent - /// ref, an HTTP method and URL, a state key. Empty when the capability has - /// no such notion. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub target: String, - /// The arguments the call was made with. - pub args: Value, - /// What it returned. - pub outcome: CallOutcome, -} - -/// Every capability call a run made, in order. -/// -/// Shared by every double in one [`MockCaps`], so the ordering across -/// capabilities is real rather than assembled afterwards from separate logs. -#[derive(Debug, Default)] -pub struct CallLog { - calls: Mutex>, - next_seq: AtomicU64, -} - -impl CallLog { - /// An empty log. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Append a call, assigning it the next sequence number. - fn record( - &self, - capability: &str, - method: &str, - node_id: Option, - target: String, - args: Value, - outcome: CallOutcome, - ) { - let call = CapCall { - seq: self.next_seq.fetch_add(1, Ordering::SeqCst), - capability: capability.to_string(), - method: method.to_string(), - node_id, - target, - args, - outcome, - }; - self.calls.lock().expect("call log poisoned").push(call); - } - - /// Every call recorded so far, in sequence order. - #[must_use] - pub fn calls(&self) -> Vec { - let mut calls = self.calls.lock().expect("call log poisoned").clone(); - calls.sort_by_key(|call| call.seq); - calls - } - - /// The calls matching a capability and an optional target glob. - /// - /// `capability` is one of the [`capability`] constants; `target` accepts the - /// same `*` globbing the rules do, and `None` matches every target. - #[must_use] - pub fn matching(&self, capability: &str, target: Option<&str>) -> Vec { - self.calls() - .into_iter() - .filter(|call| call.capability == capability) - .filter(|call| target.is_none_or(|glob| glob_matches(glob, &call.target))) - .collect() - } - - /// How many calls match — the count an assertion usually wants. - #[must_use] - pub fn count(&self, capability: &str, target: Option<&str>) -> usize { - self.matching(capability, target).len() - } -} - /// What a matched rule answers with. /// /// Construct these through the helpers ([`Respond::value`], [`Respond::error`], From 1b0563d417503117f97f3b05ab103d40f4c0ca02 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:33:30 +0300 Subject: [PATCH 051/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index c361d35..9928905 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -38,21 +38,20 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::Value; -use crate::caps::{ - AgentRunner, ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, - Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider, ShellOutcome, - ShellRequest, ShellRunner, StateStore, ToolInvoker, WorkflowResolver, sample_for_schema, -}; -use crate::error::{EngineError, Result}; +use crate::caps::Capabilities; +use crate::error::Result; use crate::model::WorkflowGraph; +#[path = "mocks_double.rs"] +mod double; +pub use double::{CallLog, CallOutcome, CapCall}; +use double::Double; + /// Which capability a call went to. /// /// A plain string rather than an enum on the wire, so a recording written by a From 2ef13a6b0851ea63849e86c3a9d19a4b5f2b8b25 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:33:38 +0300 Subject: [PATCH 052/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index 9928905..147baf8 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -77,16 +77,6 @@ pub mod capability { pub const APPROVALS: &str = "approvals"; } -/// How one capability call ended. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CallOutcome { - /// The call returned a value. - Ok(Value), - /// The call failed, with this message. - Err(String), -} - /// What a matched rule answers with. /// /// Construct these through the helpers ([`Respond::value`], [`Respond::error`], From 1846cd6d0b3b64ba94ae4fffc27527325f6afb3d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:33:42 +0300 Subject: [PATCH 053/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index 147baf8..2e35177 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -43,8 +43,8 @@ use std::time::Duration; use serde_json::Value; -use crate::caps::Capabilities; -use crate::error::Result; +use crate::caps::{Capabilities, sample_for_schema}; +use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; #[path = "mocks_double.rs"] From d6c0c4762e2b892ce0decef2db0fd4d26db7d17c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:34:34 +0300 Subject: [PATCH 054/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 378 ------------------------------------------- 1 file changed, 378 deletions(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index 2e35177..4901261 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -405,384 +405,6 @@ impl MockCaps { } } -/// One capability double: it consults the rules, records the call, and answers. -/// -/// A single type implementing every capability trait rather than nine, because -/// each implementation is the same three steps and nine copies of them would -/// drift. -struct Double { - mocks: Arc, - /// The node this double was scoped to, stamped onto every call it logs. - node_id: Option, - /// Backing map for the [`StateStore`] impl, which is the one capability - /// whose whole job is to remember. - state: Mutex>, -} - -impl Double { - fn new(mocks: Arc, node_id: Option) -> Self { - Self { - mocks, - node_id, - state: Mutex::new(HashMap::new()), - } - } - - /// Consult the rules, log whatever happens, and return it. - async fn dispatch( - &self, - capability: &str, - method: &str, - target: String, - request: Value, - default: impl FnOnce(&Value) -> Value, - ) -> Result { - let programmed = self - .mocks - .respond_to(capability, &target, self.node_id.as_deref(), &request) - .await; - let result = match programmed { - Some(result) => result, - None => Ok(default(&request)), - }; - let outcome = match &result { - Ok(value) => CallOutcome::Ok(value.clone()), - Err(err) => CallOutcome::Err(err.to_string()), - }; - self.mocks.log.record( - capability, - method, - self.node_id.clone(), - target, - request, - outcome, - ); - result - } -} - -#[async_trait] -impl LlmProvider for Double { - async fn complete(&self, request: Value, conn: Option<&str>) -> Result { - let conn = conn.map(str::to_string); - self.dispatch( - capability::LLM, - "complete", - String::new(), - request, - |req| json!({ "completion": req, "connection": conn }), - ) - .await - } -} - -#[async_trait] -impl ToolInvoker for Double { - async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result { - let slug_owned = slug.to_string(); - let conn = conn.map(str::to_string); - self.dispatch( - capability::TOOLS, - "invoke", - slug.to_string(), - args, - move |args| json!({ "tool": slug_owned, "args": args, "connection": conn }), - ) - .await - } -} - -#[async_trait] -impl HttpClient for Double { - async fn request(&self, request: Value, conn: Option<&str>) -> Result { - // The URL is what a rule globs on; a request without one still matches - // a bare `*`. - let url = request - .get("url") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let conn = conn.map(str::to_string); - self.dispatch( - capability::HTTP, - "request", - url, - request, - |req| json!({ "status": 200, "request": req, "connection": conn }), - ) - .await - } -} - -#[async_trait] -impl CodeRunner for Double { - async fn run(&self, language: CodeLanguage, source: &str, input: Value) -> Result { - let request = json!({ - "language": format!("{language:?}"), - "source": source, - "input": input, - }); - self.dispatch( - capability::CODE, - "run", - format!("{language:?}"), - request, - |req| json!({ "result": req.get("input").cloned().unwrap_or(Value::Null) }), - ) - .await - } -} - -#[async_trait] -impl ShellRunner for Double { - async fn run(&self, request: ShellRequest) -> Result { - let script = match &request.script { - crate::caps::ShellScript::Inline(source) => source.clone(), - crate::caps::ShellScript::Path(path) => path.clone(), - }; - let encoded = json!({ - "interpreter": request.interpreter.as_str(), - "script": script, - "cwd": request.cwd, - "env": request.env, - "input": request.input, - }); - let value = self - .dispatch( - capability::SHELL, - "run", - script.clone(), - encoded, - move |_req| json!({ "exit_code": 0, "stdout": script, "stderr": "" }), - ) - .await?; - // A programmed value may describe the whole outcome, or just be the - // stdout a test cares about. Accept either rather than making a caller - // spell out an exit code they do not care about. - Ok(ShellOutcome { - exit_code: value - .get("exit_code") - .and_then(Value::as_i64) - .unwrap_or(0) - .try_into() - .unwrap_or(0), - stdout: value - .get("stdout") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| value.to_string()), - stderr: value - .get("stderr") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - }) - } -} - -#[async_trait] -impl AgentRunner for Double { - async fn run_agent( - &self, - agent_ref: &str, - request: Value, - conn: Option<&str>, - ) -> Result { - let name = agent_ref.to_string(); - let conn = conn.map(str::to_string); - self.dispatch( - capability::AGENT, - "run_agent", - agent_ref.to_string(), - request, - move |req| json!({ "agent": name, "request": req, "connection": conn }), - ) - .await - } -} - -#[async_trait] -impl MemoryProvider for Double { - async fn recall(&self, scope: &str, query: &str, opts: Value) -> Result { - let request = json!({ "scope": scope, "query": query, "opts": opts }); - self.dispatch( - capability::MEMORY, - "recall", - scope.to_string(), - request, - |_| json!({ "results": [] }), - ) - .await - } - - async fn flavour(&self, slug: &str) -> Result { - let request = json!({ "slug": slug }); - self.dispatch( - capability::MEMORY, - "flavour", - slug.to_string(), - request, - |_| json!({ "traits": {} }), - ) - .await - } - - async fn people(&self, query: Option<&str>) -> Result { - let request = json!({ "query": query }); - self.dispatch( - capability::MEMORY, - "people", - String::new(), - request, - |_| json!({ "people": [] }), - ) - .await - } - - async fn remember(&self, scope: &str, key: &str, value: Value) -> Result<()> { - let request = json!({ "scope": scope, "key": key, "value": value }); - self.dispatch( - capability::MEMORY, - "remember", - format!("{scope}/{key}"), - request, - |_| Value::Null, - ) - .await - .map(|_| ()) - } - - async fn forget(&self, scope: &str, key: &str) -> Result<()> { - let request = json!({ "scope": scope, "key": key }); - self.dispatch( - capability::MEMORY, - "forget", - format!("{scope}/{key}"), - request, - |_| Value::Null, - ) - .await - .map(|_| ()) - } -} - -#[async_trait] -impl StateStore for Double { - async fn load(&self, key: &str) -> Result> { - let stored = self - .state - .lock() - .expect("mock state poisoned") - .get(key) - .cloned(); - // Logged like any other call, but the *store* is the source of truth: - // a rule that overrode a load would make a stateful graph unreadable. - self.mocks.log.record( - capability::STATE, - "load", - self.node_id.clone(), - key.to_string(), - json!({ "key": key }), - CallOutcome::Ok(stored.clone().unwrap_or(Value::Null)), - ); - Ok(stored) - } - - async fn store(&self, key: &str, value: Value) -> Result<()> { - self.state - .lock() - .expect("mock state poisoned") - .insert(key.to_string(), value.clone()); - self.mocks.log.record( - capability::STATE, - "store", - self.node_id.clone(), - key.to_string(), - json!({ "key": key, "value": value }), - CallOutcome::Ok(Value::Null), - ); - Ok(()) - } -} - -#[async_trait] -impl ApprovalProvider for Double { - async fn decide(&self, request: &ApprovalRequest) -> Result { - let encoded = json!({ - "request_id": request.request_id, - "node_id": request.node_id, - "run_id": request.run_id, - "title": request.title, - "prompt": request.prompt, - "subject": { - "kind": request.subject.kind, - "value": request.subject.value, - }, - "assignees": request.assignees, - "metadata": request.metadata, - }); - // An unprogrammed review approves, so a graph that contains one runs end - // to end without a test standing a reviewer up — the same bargain every - // other default here makes. A test that cares about the answer says so - // with `on_approval`. - let value = self - .dispatch( - capability::APPROVALS, - "decide", - request.request_id.clone(), - encoded, - |_| json!({ "approved": true, "decided_by": "testkit" }), - ) - .await?; - // A programmed answer may be the whole verdict or just the bit the test - // cares about, as with `ShellRunner` above. - if value.get("status").and_then(Value::as_str) == Some("pending") { - return Ok(ApprovalOutcome::Pending); - } - Ok(ApprovalOutcome::Decided(ApprovalDecision { - approved: value - .get("approved") - .and_then(Value::as_bool) - .unwrap_or(true), - decided_by: value - .get("decided_by") - .and_then(Value::as_str) - .map(str::to_string), - comment: value - .get("comment") - .and_then(Value::as_str) - .map(str::to_string), - payload: value.get("payload").cloned(), - })) - } - - async fn cancel(&self, request_id: &str, reason: &str) -> Result<()> { - self.dispatch( - capability::APPROVALS, - "cancel", - request_id.to_string(), - json!({ "request_id": request_id, "reason": reason }), - |_| json!({ "cancelled": true }), - ) - .await - .map(|_| ()) - } -} - -#[async_trait] -impl WorkflowResolver for Double { - async fn resolve(&self, workflow_id: &str) -> Result { - self.mocks - .workflows - .get(workflow_id) - .cloned() - .ok_or_else(|| { - EngineError::Capability(format!( - "testkit: no workflow registered as {workflow_id:?}" - )) - }) - } -} #[cfg(test)] #[path = "mocks_tests.rs"] From 19557f65a86af50ee0103c43c7edc7745ecdf74d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:34:55 +0300 Subject: [PATCH 055/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index 4901261..fe8c13c 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -405,7 +405,6 @@ impl MockCaps { } } - #[cfg(test)] #[path = "mocks_tests.rs"] mod tests; From bf9eea7071e3c686e5e1dbc52339f7589ef51f81 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:35:25 +0300 Subject: [PATCH 056/138] chore: files changed src/testkit/mocks_log.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_log.rs | 122 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/testkit/mocks_log.rs diff --git a/src/testkit/mocks_log.rs b/src/testkit/mocks_log.rs new file mode 100644 index 0000000..7e861d2 --- /dev/null +++ b/src/testkit/mocks_log.rs @@ -0,0 +1,122 @@ +//! The call log [`MockCaps`](super::MockCaps) and its [`Double`](super::double) +//! write to: [`CallOutcome`], [`CapCall`], and [`CallLog`] itself. +//! +//! Split out of `mocks.rs` (and out of `mocks_double.rs`, which is itself a +//! split of `mocks.rs`) to keep every file under the repository's +//! line-length limit. + +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{capability, glob_matches}; + +/// How one capability call ended. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CallOutcome { + /// The call returned a value. + Ok(Value), + /// The call failed, with this message. + Err(String), +} + +/// One capability call a run made. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CapCall { + /// Position in the run's single call sequence, from 0. + /// + /// One counter across *all* capabilities, so the log says what order things + /// happened in — which per-capability counters cannot. + pub seq: u64, + /// Which capability — see the [`capability`] constants. + pub capability: String, + /// The trait method (`invoke`, `complete`, `request`, …). + pub method: String, + /// The node that made the call. + /// + /// `None` only when the call was made outside a node activation, which no + /// engine path does today. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + /// What identifies the target within the capability: a tool slug, an agent + /// ref, an HTTP method and URL, a state key. Empty when the capability has + /// no such notion. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub target: String, + /// The arguments the call was made with. + pub args: Value, + /// What it returned. + pub outcome: CallOutcome, +} + +/// Every capability call a run made, in order. +/// +/// Shared by every double in one [`MockCaps`](super::MockCaps), so the +/// ordering across capabilities is real rather than assembled afterwards from +/// separate logs. +#[derive(Debug, Default)] +pub struct CallLog { + calls: Mutex>, + next_seq: AtomicU64, +} + +impl CallLog { + /// An empty log. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Append a call, assigning it the next sequence number. + pub(super) fn record( + &self, + capability: &str, + method: &str, + node_id: Option, + target: String, + args: Value, + outcome: CallOutcome, + ) { + let call = CapCall { + seq: self.next_seq.fetch_add(1, Ordering::SeqCst), + capability: capability.to_string(), + method: method.to_string(), + node_id, + target, + args, + outcome, + }; + self.calls.lock().expect("call log poisoned").push(call); + } + + /// Every call recorded so far, in sequence order. + #[must_use] + pub fn calls(&self) -> Vec { + let mut calls = self.calls.lock().expect("call log poisoned").clone(); + calls.sort_by_key(|call| call.seq); + calls + } + + /// The calls matching a capability and an optional target glob. + /// + /// `capability` is one of the [`capability`] constants; `target` accepts the + /// same `*` globbing the rules do, and `None` matches every target. + #[must_use] + pub fn matching(&self, capability: &str, target: Option<&str>) -> Vec { + self.calls() + .into_iter() + .filter(|call| call.capability == capability) + .filter(|call| target.is_none_or(|glob| glob_matches(glob, &call.target))) + .collect() + } + + /// How many calls match — the count an assertion usually wants. + #[must_use] + pub fn count(&self, capability: &str, target: Option<&str>) -> usize { + self.matching(capability, target).len() + } +} From bcb5e9aadf86c61c5158732fd0522d351bee699c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:35:56 +0300 Subject: [PATCH 057/138] chore: files changed src/testkit/mocks_double.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_double.rs | 121 ++---------------------------------- 1 file changed, 5 insertions(+), 116 deletions(-) diff --git a/src/testkit/mocks_double.rs b/src/testkit/mocks_double.rs index 728d47e..321d7aa 100644 --- a/src/testkit/mocks_double.rs +++ b/src/testkit/mocks_double.rs @@ -1,19 +1,14 @@ -//! The call log ([`CapCall`]/[`CallOutcome`]/[`CallLog`]) and [`Double`], the -//! single type that implements every capability trait for +//! [`Double`], the single type that implements every capability trait for //! [`MockCaps`](super::MockCaps) by consulting its rules, recording what -//! happened, and answering. +//! happened to [`CallLog`](super::log::CallLog), and answering. //! //! Split out of `mocks.rs` to keep that file under the repository's -//! line-length limit; `Double` and the log it writes to are one cohesive -//! concern (every trait impl below ends by writing a [`CapCall`]), so they -//! belong together rather than split further. +//! line-length limit. use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; -use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::caps::{ @@ -24,114 +19,8 @@ use crate::caps::{ use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; -use super::{MockCaps, capability, glob_matches}; - -/// How one capability call ended. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CallOutcome { - /// The call returned a value. - Ok(Value), - /// The call failed, with this message. - Err(String), -} - -/// One capability call a run made. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CapCall { - /// Position in the run's single call sequence, from 0. - /// - /// One counter across *all* capabilities, so the log says what order things - /// happened in — which per-capability counters cannot. - pub seq: u64, - /// Which capability — see the [`capability`] constants. - pub capability: String, - /// The trait method (`invoke`, `complete`, `request`, …). - pub method: String, - /// The node that made the call. - /// - /// `None` only when the call was made outside a node activation, which no - /// engine path does today. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_id: Option, - /// What identifies the target within the capability: a tool slug, an agent - /// ref, an HTTP method and URL, a state key. Empty when the capability has - /// no such notion. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub target: String, - /// The arguments the call was made with. - pub args: Value, - /// What it returned. - pub outcome: CallOutcome, -} - -/// Every capability call a run made, in order. -/// -/// Shared by every double in one [`MockCaps`], so the ordering across -/// capabilities is real rather than assembled afterwards from separate logs. -#[derive(Debug, Default)] -pub struct CallLog { - calls: Mutex>, - next_seq: AtomicU64, -} - -impl CallLog { - /// An empty log. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Append a call, assigning it the next sequence number. - pub(super) fn record( - &self, - capability: &str, - method: &str, - node_id: Option, - target: String, - args: Value, - outcome: CallOutcome, - ) { - let call = CapCall { - seq: self.next_seq.fetch_add(1, Ordering::SeqCst), - capability: capability.to_string(), - method: method.to_string(), - node_id, - target, - args, - outcome, - }; - self.calls.lock().expect("call log poisoned").push(call); - } - - /// Every call recorded so far, in sequence order. - #[must_use] - pub fn calls(&self) -> Vec { - let mut calls = self.calls.lock().expect("call log poisoned").clone(); - calls.sort_by_key(|call| call.seq); - calls - } - - /// The calls matching a capability and an optional target glob. - /// - /// `capability` is one of the [`capability`] constants; `target` accepts the - /// same `*` globbing the rules do, and `None` matches every target. - #[must_use] - pub fn matching(&self, capability: &str, target: Option<&str>) -> Vec { - self.calls() - .into_iter() - .filter(|call| call.capability == capability) - .filter(|call| target.is_none_or(|glob| glob_matches(glob, &call.target))) - .collect() - } - - /// How many calls match — the count an assertion usually wants. - #[must_use] - pub fn count(&self, capability: &str, target: Option<&str>) -> usize { - self.matching(capability, target).len() - } -} +use super::log::CallOutcome; +use super::{MockCaps, capability}; /// One capability double: it consults the rules, records the call, and answers. /// From 1429a178f9da15386a82d0d0149d463acbff3fbc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:36:05 +0300 Subject: [PATCH 058/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index fe8c13c..9efa80f 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -47,9 +47,12 @@ use crate::caps::{Capabilities, sample_for_schema}; use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; +#[path = "mocks_log.rs"] +mod log; +pub use log::{CallLog, CallOutcome, CapCall}; + #[path = "mocks_double.rs"] mod double; -pub use double::{CallLog, CallOutcome, CapCall}; use double::Double; /// Which capability a call went to. From 153bb66fd2d98f37d51afaeefbfe426dce2bc120 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:36:15 +0300 Subject: [PATCH 059/138] chore: files changed src/testkit/mocks_log.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_log.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testkit/mocks_log.rs b/src/testkit/mocks_log.rs index 7e861d2..ee53095 100644 --- a/src/testkit/mocks_log.rs +++ b/src/testkit/mocks_log.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::{capability, glob_matches}; +use super::glob_matches; /// How one capability call ended. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] From b55800a1568466ad35da8ec70f3261ab0f4e2c05 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:36:27 +0300 Subject: [PATCH 060/138] chore: files changed src/testkit/mocks_log.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_log.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/testkit/mocks_log.rs b/src/testkit/mocks_log.rs index ee53095..6cea3c7 100644 --- a/src/testkit/mocks_log.rs +++ b/src/testkit/mocks_log.rs @@ -32,7 +32,7 @@ pub struct CapCall { /// One counter across *all* capabilities, so the log says what order things /// happened in — which per-capability counters cannot. pub seq: u64, - /// Which capability — see the [`capability`] constants. + /// Which capability — see the [`capability`](super::capability) constants. pub capability: String, /// The trait method (`invoke`, `complete`, `request`, …). pub method: String, @@ -103,7 +103,7 @@ impl CallLog { /// The calls matching a capability and an optional target glob. /// - /// `capability` is one of the [`capability`] constants; `target` accepts the + /// `capability` is one of the [`capability`](super::capability) constants; `target` accepts the /// same `*` globbing the rules do, and `None` matches every target. #[must_use] pub fn matching(&self, capability: &str, target: Option<&str>) -> Vec { From debba44d5beea2f45843e31fb18265542a6dc19c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:36:37 +0300 Subject: [PATCH 061/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index 9efa80f..7e18ed4 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -37,8 +37,8 @@ //! graph under test never fails because a capability was left unprogrammed. use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use serde_json::Value; From bc8376d7f455ce72be80d7e7596d7dc255912948 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:37:03 +0300 Subject: [PATCH 062/138] chore: files changed src/testkit/mocks_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/testkit/mocks_tests.rs b/src/testkit/mocks_tests.rs index 4b8b87b..54b8c1a 100644 --- a/src/testkit/mocks_tests.rs +++ b/src/testkit/mocks_tests.rs @@ -5,7 +5,10 @@ //! graph to reach them would be testing the engine instead. use super::*; -use crate::caps::ApprovalSubject; +use crate::caps::{ + ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject, LlmProvider, ToolInvoker, +}; +use serde_json::json; fn mocks(build: impl FnOnce(MockCaps) -> MockCaps) -> Arc { Arc::new(build(MockCaps::new())) From 2fec804dc7083ca396631c97091fc62805fb4bb2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:37:19 +0300 Subject: [PATCH 063/138] chore: files changed src/testkit/mocks_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_tests.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/testkit/mocks_tests.rs b/src/testkit/mocks_tests.rs index 54b8c1a..8309a91 100644 --- a/src/testkit/mocks_tests.rs +++ b/src/testkit/mocks_tests.rs @@ -5,9 +5,7 @@ //! graph to reach them would be testing the engine instead. use super::*; -use crate::caps::{ - ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject, LlmProvider, ToolInvoker, -}; +use crate::caps::{ApprovalOutcome, ApprovalRequest, ApprovalSubject}; use serde_json::json; fn mocks(build: impl FnOnce(MockCaps) -> MockCaps) -> Arc { From 62a5ef29a145f760bab229809e53bab5214531b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:38:38 +0300 Subject: [PATCH 064/138] chore: files changed src/caps/mock_builders.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock_builders.rs | 90 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/caps/mock_builders.rs diff --git a/src/caps/mock_builders.rs b/src/caps/mock_builders.rs new file mode 100644 index 0000000..5cda4d1 --- /dev/null +++ b/src/caps/mock_builders.rs @@ -0,0 +1,90 @@ +//! Builds [`Capabilities`] bundles wired to the mock implementations in +//! [`super`] and [`super::mock_approvals`]. +//! +//! Split out of `mock.rs` to keep that file under the repository's +//! line-length limit; these are all thin variations on one bundle, so they +//! belong together rather than split further. + +use std::sync::Arc; + +use crate::caps::{ + AgentRunner, ApprovalProvider, Capabilities, MemoryProvider, WorkflowResolver, +}; + +use super::{ + MockApprovals, MockCode, MockHttp, MockLlm, MockMemory, MockShell, MockStateStore, MockTools, + MockWorkflowResolver, +}; + +/// Builds a [`Capabilities`] bundle wired entirely to the mock implementations. +/// +/// The bundled [`MockWorkflowResolver`] is empty; use +/// [`mock_capabilities_with_resolver`] to supply one that resolves ids. Unlike +/// [`Capabilities::agent`] (which defaults `None`), [`Capabilities::memory`] is +/// wired to [`MockMemory`] by default — a `memory` node must dry-run +/// successfully out of the box; use `Capabilities { memory: None, ..caps }` to +/// exercise the "host wired no memory store" error path instead. +#[must_use] +pub fn mock_capabilities() -> Capabilities { + mock_capabilities_with_resolver(MockWorkflowResolver::default()) +} + +/// Like [`mock_capabilities`], but with a caller-supplied [`WorkflowResolver`] +/// so tests can exercise `sub_workflow`-by-id. +#[must_use] +pub fn mock_capabilities_with_resolver(resolver: impl WorkflowResolver + 'static) -> Capabilities { + Capabilities { + llm: Arc::new(MockLlm), + tools: Arc::new(MockTools), + http: Arc::new(MockHttp), + code: Arc::new(MockCode), + shell: Some(Arc::new(MockShell)), + state: Arc::new(MockStateStore::default()), + resolver: Arc::new(resolver), + // No agent registry by default: `agent` nodes use `MockLlm`. Use + // [`mock_capabilities_with_agent`] to exercise the `agent_ref` path. + agent: None, + // Wired by default (unlike `agent`) — see the doc comment above. + memory: Some(Arc::new(MockMemory)), + // The real tokio-backed runner, so `spawn`/`gate` behave under test the + // way they behave for a host that wires nothing. + tasks: Some(Arc::new(crate::caps::TokioTaskRunner::new())), + // Wired by default, like `memory`: a graph with an `approval` node must + // dry-run without the host standing up a review surface first. + approvals: Some(Arc::new(MockApprovals::approving())), + } +} + +/// Like [`mock_capabilities`], but wires an [`AgentRunner`] so tests can exercise +/// an `agent` node that selects a named agent kind via `agent_ref`. +#[must_use] +pub fn mock_capabilities_with_agent(agent: impl AgentRunner + 'static) -> Capabilities { + Capabilities { + agent: Some(Arc::new(agent)), + ..mock_capabilities() + } +} + +/// Like [`mock_capabilities`], but with a caller-supplied [`MemoryProvider`] in +/// place of the default [`MockMemory`] — for tests that need custom recall / +/// flavour / people / remember / forget behavior. +#[must_use] +pub fn mock_capabilities_with_memory(memory: impl MemoryProvider + 'static) -> Capabilities { + Capabilities { + memory: Some(Arc::new(memory)), + ..mock_capabilities() + } +} + +/// Like [`mock_capabilities`], but with a caller-supplied [`ApprovalProvider`] +/// in place of the default approve-everything [`MockApprovals`] — for tests +/// that need a rejection, a pending review, or a host-shaped decision. +#[must_use] +pub fn mock_capabilities_with_approvals( + approvals: impl ApprovalProvider + 'static, +) -> Capabilities { + Capabilities { + approvals: Some(Arc::new(approvals)), + ..mock_capabilities() + } +} From 1f93e21cae5a97ad3eb04b731add70bc4f4c7c84 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:38:51 +0300 Subject: [PATCH 065/138] chore: files changed src/caps/mock.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 78 ++++-------------------------------------------- 1 file changed, 6 insertions(+), 72 deletions(-) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index eb2f623..3e66239 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -428,78 +428,12 @@ impl WorkflowResolver for MockWorkflowResolver { } } -/// Builds a [`Capabilities`] bundle wired entirely to the mock implementations. -/// -/// The bundled [`MockWorkflowResolver`] is empty; use -/// [`mock_capabilities_with_resolver`] to supply one that resolves ids. Unlike -/// [`Capabilities::agent`] (which defaults `None`), [`Capabilities::memory`] is -/// wired to [`MockMemory`] by default — a `memory` node must dry-run -/// successfully out of the box; use `Capabilities { memory: None, ..caps }` to -/// exercise the "host wired no memory store" error path instead. -#[must_use] -pub fn mock_capabilities() -> Capabilities { - mock_capabilities_with_resolver(MockWorkflowResolver::default()) -} - -/// Like [`mock_capabilities`], but with a caller-supplied [`WorkflowResolver`] -/// so tests can exercise `sub_workflow`-by-id. -#[must_use] -pub fn mock_capabilities_with_resolver(resolver: impl WorkflowResolver + 'static) -> Capabilities { - Capabilities { - llm: Arc::new(MockLlm), - tools: Arc::new(MockTools), - http: Arc::new(MockHttp), - code: Arc::new(MockCode), - shell: Some(Arc::new(MockShell)), - state: Arc::new(MockStateStore::default()), - resolver: Arc::new(resolver), - // No agent registry by default: `agent` nodes use `MockLlm`. Use - // [`mock_capabilities_with_agent`] to exercise the `agent_ref` path. - agent: None, - // Wired by default (unlike `agent`) — see the doc comment above. - memory: Some(Arc::new(MockMemory)), - // The real tokio-backed runner, so `spawn`/`gate` behave under test the - // way they behave for a host that wires nothing. - tasks: Some(Arc::new(crate::caps::TokioTaskRunner::new())), - // Wired by default, like `memory`: a graph with an `approval` node must - // dry-run without the host standing up a review surface first. - approvals: Some(Arc::new(MockApprovals::approving())), - } -} - -/// Like [`mock_capabilities`], but wires an [`AgentRunner`] so tests can exercise -/// an `agent` node that selects a named agent kind via `agent_ref`. -#[must_use] -pub fn mock_capabilities_with_agent(agent: impl AgentRunner + 'static) -> Capabilities { - Capabilities { - agent: Some(Arc::new(agent)), - ..mock_capabilities() - } -} - -/// Like [`mock_capabilities`], but with a caller-supplied [`MemoryProvider`] in -/// place of the default [`MockMemory`] — for tests that need custom recall / -/// flavour / people / remember / forget behavior. -#[must_use] -pub fn mock_capabilities_with_memory(memory: impl MemoryProvider + 'static) -> Capabilities { - Capabilities { - memory: Some(Arc::new(memory)), - ..mock_capabilities() - } -} - -/// Like [`mock_capabilities`], but with a caller-supplied [`ApprovalProvider`] -/// in place of the default approve-everything [`MockApprovals`] — for tests -/// that need a rejection, a pending review, or a host-shaped decision. -#[must_use] -pub fn mock_capabilities_with_approvals( - approvals: impl ApprovalProvider + 'static, -) -> Capabilities { - Capabilities { - approvals: Some(Arc::new(approvals)), - ..mock_capabilities() - } -} +#[path = "mock_builders.rs"] +mod mock_builders; +pub use mock_builders::{ + mock_capabilities, mock_capabilities_with_agent, mock_capabilities_with_approvals, + mock_capabilities_with_memory, mock_capabilities_with_resolver, +}; #[cfg(test)] #[path = "mock_tests.rs"] From fbbdef46f586582317489aab97b2cc3104481d98 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:39:07 +0300 Subject: [PATCH 066/138] chore: files changed src/caps/mock.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/caps/mock.rs b/src/caps/mock.rs index 3e66239..640518c 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -4,15 +4,12 @@ //! `mock` cargo feature. The mocks are deterministic echoes — enough to exercise //! the engine and the reference workflows without any external services. -use std::sync::Arc; - use async_trait::async_trait; use serde_json::{Value, json}; use crate::caps::{ - AgentRunner, ApprovalProvider, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, - MemoryProvider, ShellOutcome, ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, - WorkflowResolver, + AgentRunner, CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider, ShellOutcome, + ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, WorkflowResolver, }; use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; From 5e669d13d159f1fc50e3aa367f5ed3bb4d322854 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:39:25 +0300 Subject: [PATCH 067/138] chore: files changed src/caps/mock_builders.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/mock_builders.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/caps/mock_builders.rs b/src/caps/mock_builders.rs index 5cda4d1..df70160 100644 --- a/src/caps/mock_builders.rs +++ b/src/caps/mock_builders.rs @@ -7,9 +7,7 @@ use std::sync::Arc; -use crate::caps::{ - AgentRunner, ApprovalProvider, Capabilities, MemoryProvider, WorkflowResolver, -}; +use crate::caps::{AgentRunner, ApprovalProvider, Capabilities, MemoryProvider, WorkflowResolver}; use super::{ MockApprovals, MockCode, MockHttp, MockLlm, MockMemory, MockShell, MockStateStore, MockTools, From 311555ef6679d8c87c9f245ad29ddbfcafa19b8e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:42:40 +0300 Subject: [PATCH 068/138] chore: files changed tests/zz_repro.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/zz_repro.rs | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/zz_repro.rs diff --git a/tests/zz_repro.rs b/tests/zz_repro.rs new file mode 100644 index 0000000..35a61d9 --- /dev/null +++ b/tests/zz_repro.rs @@ -0,0 +1,113 @@ +#![cfg(feature = "mock")] +//! TEMPORARY repro harness for the resume determinism race. Not for commit. + +mod support; + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{Value, json}; + +use support::graphgen::{Shape, graph_of}; +use tinyflows::caps::mock::mock_capabilities; +use tinyflows::compiler::compile; +use tinyflows::engine::{ + InMemoryCheckpointer, resume_with_checkpointer, run, run_with_checkpointer, +}; + +const GUARD: Duration = Duration::from_secs(20); + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build runtime") +} + +fn run_both_ways(shape: &Shape) -> Option<(Value, Value)> { + let graph = graph_of(shape); + let compiled = compile(&graph).ok()?; + let gates = shape.gate_ids(); + + runtime().block_on(async { + let caps = mock_capabilities(); + + let straight = tokio::time::timeout( + GUARD, + run(&compiled, json!({ "approvals": gates.clone() }), &caps), + ) + .await + .expect("pre-approved run hung") + .expect("pre-approved run should complete"); + + let checkpointer = Arc::new(InMemoryCheckpointer::default()); + let thread_id = "fuzz-resume"; + let mut outcome = tokio::time::timeout( + GUARD, + run_with_checkpointer(&compiled, json!({}), &caps, checkpointer.clone(), thread_id), + ) + .await + .expect("suspending run hung") + .expect("suspending run should pause rather than fail"); + + let mut rounds = 0; + while !outcome.pending_approvals.is_empty() { + rounds += 1; + assert!(rounds <= gates.len() + 2, "resume did not converge"); + let pending = outcome.pending_approvals.clone(); + outcome = tokio::time::timeout( + GUARD, + resume_with_checkpointer(&compiled, &caps, checkpointer.clone(), thread_id, pending), + ) + .await + .expect("resume hung") + .expect("resume should succeed"); + } + + Some((straight.output, outcome.output)) + }) +} + +fn shape() -> Shape { + Shape::Gate(Box::new(Shape::Branch( + Box::new(Shape::Spawned { + tasks: 3, + release: "all", + n: 3, + }), + Box::new(Shape::Fanout(vec![ + Shape::Linear(2), + Shape::Linear(0), + Shape::Spawned { + tasks: 3, + release: "all", + n: 3, + }, + ])), + ))) +} + +#[test] +fn repro_resume_determinism() { + let shape = shape(); + let mut polls_seen: Vec = Vec::new(); + let mut baseline: Option = None; + let mut diverged = 0usize; + const ITERS: usize = 60; + for i in 0..ITERS { + let (_, resumed) = run_both_ways(&shape).expect("shape should compile"); + let polls = resumed["nodes"]["n21"]["polls"].as_u64().unwrap_or(0); + polls_seen.push(polls); + match &baseline { + None => baseline = Some(resumed), + Some(first) => { + if first != &resumed { + diverged += 1; + println!("iteration {i}: DIVERGED (n21.polls = {polls})"); + } + } + } + } + println!("polls per iteration: {polls_seen:?}"); + assert_eq!(diverged, 0, "{diverged}/{ITERS} runs diverged"); +} From 33f30f4a46aafe2c436c15c49bbd29034b0deeb4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:45:05 +0300 Subject: [PATCH 069/138] chore: files changed tests/zz_repro.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/zz_repro.rs | 56 ++++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/tests/zz_repro.rs b/tests/zz_repro.rs index 35a61d9..73e67a6 100644 --- a/tests/zz_repro.rs +++ b/tests/zz_repro.rs @@ -89,25 +89,41 @@ fn shape() -> Shape { #[test] fn repro_resume_determinism() { - let shape = shape(); - let mut polls_seen: Vec = Vec::new(); - let mut baseline: Option = None; - let mut diverged = 0usize; - const ITERS: usize = 60; - for i in 0..ITERS { - let (_, resumed) = run_both_ways(&shape).expect("shape should compile"); - let polls = resumed["nodes"]["n21"]["polls"].as_u64().unwrap_or(0); - polls_seen.push(polls); - match &baseline { - None => baseline = Some(resumed), - Some(first) => { - if first != &resumed { - diverged += 1; - println!("iteration {i}: DIVERGED (n21.polls = {polls})"); + let threads: usize = std::env::var("REPRO_THREADS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1); + let iters: usize = std::env::var("REPRO_ITERS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(60); + + let handles: Vec<_> = (0..threads) + .map(|t| { + std::thread::spawn(move || { + let shape = shape(); + let mut counts: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut baseline: Option = None; + let mut diverged = 0usize; + for _ in 0..iters { + let (_, resumed) = run_both_ways(&shape).expect("shape should compile"); + let polls = resumed["nodes"]["n21"]["polls"].as_u64().unwrap_or(0); + *counts.entry(polls).or_default() += 1; + match &baseline { + None => baseline = Some(resumed), + Some(first) => { + if first != &resumed { + diverged += 1; + } + } + } } - } - } - } - println!("polls per iteration: {polls_seen:?}"); - assert_eq!(diverged, 0, "{diverged}/{ITERS} runs diverged"); + println!("thread {t}: poll histogram {counts:?}, diverged {diverged}/{iters}"); + diverged + }) + }) + .collect(); + let total: usize = handles.into_iter().map(|h| h.join().unwrap()).sum(); + assert_eq!(total, 0, "{total} runs diverged"); } From a9e53a0def1d1459ee180329adfd5f4b20b3d9e1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:45:33 +0300 Subject: [PATCH 070/138] chore: files changed tests/zz_repro.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/zz_repro.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/zz_repro.rs b/tests/zz_repro.rs index 73e67a6..61c7567 100644 --- a/tests/zz_repro.rs +++ b/tests/zz_repro.rs @@ -115,6 +115,22 @@ fn repro_resume_determinism() { Some(first) => { if first != &resumed { diverged += 1; + let a = first["nodes"].as_object().cloned().unwrap_or_default(); + let b = resumed["nodes"].as_object().cloned().unwrap_or_default(); + for (id, slot) in &a { + let other = b.get(id); + if other != Some(slot) { + println!( + " node {id}:\n A = {slot}\n B = {}", + other.cloned().unwrap_or(Value::Null) + ); + } + } + for (id, slot) in &b { + if !a.contains_key(id) { + println!(" node {id} only in B: {slot}"); + } + } } } } From 79ec050c3cdc0ae9b6a36d8a25e3f4137cabd622 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:46:46 +0300 Subject: [PATCH 071/138] chore: files changed src/nodes/integration/gate.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/gate.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/nodes/integration/gate.rs b/src/nodes/integration/gate.rs index c9dbce8..24081a7 100644 --- a/src/nodes/integration/gate.rs +++ b/src/nodes/integration/gate.rs @@ -238,6 +238,37 @@ impl NodeExecutor for GateNode { let budget_spent = polls >= max_polls; let decision = policy.evaluate(results.len(), expected, budget_spent); + if std::env::var("TF_GATE_DEBUG").is_ok() { + eprintln!( + "GATE {:?} thread={:?} step={} polls_in={} expected={} arrived={} decision={:?} \ + tickets={:?} from_slots={:?}", + ctx.node.id, + std::thread::current().id(), + ctx.step, + polls, + expected, + results.len(), + decision, + awaiting.iter().map(|a| a.ticket.clone()).collect::>(), + ctx.node + .config + .get("from") + .and_then(Value::as_array) + .map(|from| from + .iter() + .filter_map(Value::as_str) + .map(|s| ( + s.to_string(), + ctx.nodes + .get(s) + .and_then(|slot| slot.get("items")) + .and_then(Value::as_array) + .map(Vec::len) + )) + .collect::>()) + ); + } + let meta = json!({ POLLS_KEY: polls + 1, "arrived": results.len(), From 545140f2ded087a8a32db02bb3788028cae093c8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:46:50 +0300 Subject: [PATCH 072/138] chore: files changed tests/zz_repro.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/zz_repro.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/zz_repro.rs b/tests/zz_repro.rs index 61c7567..dd22791 100644 --- a/tests/zz_repro.rs +++ b/tests/zz_repro.rs @@ -106,7 +106,8 @@ fn repro_resume_determinism() { std::collections::BTreeMap::new(); let mut baseline: Option = None; let mut diverged = 0usize; - for _ in 0..iters { + for i in 0..iters { + eprintln!("ITER thread={:?} i={i}", std::thread::current().id()); let (_, resumed) = run_both_ways(&shape).expect("shape should compile"); let polls = resumed["nodes"]["n21"]["polls"].as_u64().unwrap_or(0); *counts.entry(polls).or_default() += 1; From 23064a372b51b5432cf09c431d3b6cf3abb8b8ab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:49:04 +0300 Subject: [PATCH 073/138] chore: files changed src/nodes/integration/gate.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/gate.rs | 40 ++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/nodes/integration/gate.rs b/src/nodes/integration/gate.rs index 24081a7..b4c97fb 100644 --- a/src/nodes/integration/gate.rs +++ b/src/nodes/integration/gate.rs @@ -239,33 +239,35 @@ impl NodeExecutor for GateNode { let decision = policy.evaluate(results.len(), expected, budget_spent); if std::env::var("TF_GATE_DEBUG").is_ok() { + let mut states = Vec::new(); + if let Some(runner) = ctx.caps.tasks.as_ref() { + for item in &awaiting { + if let Some(ticket) = item.ticket.as_ref() { + let state = runner.poll(ticket).await?; + states.push(match state { + TaskState::Pending => "pending", + TaskState::Running => "running", + TaskState::Done(_) => "done", + TaskState::Failed(_) => "failed", + }); + } + } + } eprintln!( - "GATE {:?} thread={:?} step={} polls_in={} expected={} arrived={} decision={:?} \ - tickets={:?} from_slots={:?}", + "GATE {:?} thread={:?} t_us={} step={} polls_in={} expected={} arrived={} \ + decision={:?} states={:?}", ctx.node.id, std::thread::current().id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros()) + .unwrap_or(0), ctx.step, polls, expected, results.len(), decision, - awaiting.iter().map(|a| a.ticket.clone()).collect::>(), - ctx.node - .config - .get("from") - .and_then(Value::as_array) - .map(|from| from - .iter() - .filter_map(Value::as_str) - .map(|s| ( - s.to_string(), - ctx.nodes - .get(s) - .and_then(|slot| slot.get("items")) - .and_then(Value::as_array) - .map(Vec::len) - )) - .collect::>()) + states, ); } From ea01a3510ca575ca26d459719f6c161f34b01813 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:50:43 +0300 Subject: [PATCH 074/138] chore: files changed src/caps/tasks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/tasks.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/caps/tasks.rs b/src/caps/tasks.rs index 26d25f6..a2a0c64 100644 --- a/src/caps/tasks.rs +++ b/src/caps/tasks.rs @@ -176,7 +176,29 @@ impl TaskRunner for TokioTaskRunner { // for, and a host that wants real execution implements `TaskRunner` // against its own stack. Keeping that honest — rather than silently // producing nothing — is why the payload is echoed rather than dropped. + if std::env::var("TF_GATE_DEBUG").is_ok() { + eprintln!( + "SPAWN ticket={ticket} thread={:?} t_us={} rt_id={:?}", + std::thread::current().id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros()) + .unwrap_or(0), + tokio::runtime::Handle::current().id(), + ); + } + let debug_ticket = ticket.clone(); let handle = tokio::spawn(async move { + if std::env::var("TF_GATE_DEBUG").is_ok() { + eprintln!( + "TASKRUN ticket={debug_ticket} thread={:?} t_us={}", + std::thread::current().id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros()) + .unwrap_or(0), + ); + } *state.lock().expect("task state poisoned") = TaskState::Running; let result = match spec { TaskSpec::Workflow { graph, input } => { From 476b20c938073883381c42bcf44de87ee5fbe833 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:53:09 +0300 Subject: [PATCH 075/138] chore: files changed src/engine/build/outcome.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/outcome.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/engine/build/outcome.rs b/src/engine/build/outcome.rs index 68fa4e3..ecfd568 100644 --- a/src/engine/build/outcome.rs +++ b/src/engine/build/outcome.rs @@ -88,8 +88,25 @@ where return Ok(NodeResult::Update(items_update(&node.id, &[], None)?)); } let slice = remaining.min(BACKOFF_POLL_MS); - futures_timer::Delay::new(std::time::Duration::from_millis(slice)) - .await; + let mut delay = + futures_timer::Delay::new(std::time::Duration::from_millis(slice)); + let mut polls = 0usize; + std::future::poll_fn(|cx| { + polls += 1; + std::pin::Pin::new(&mut delay).poll(cx) + }) + .await; + if std::env::var("TF_GATE_DEBUG").is_ok() && polls == 1 { + eprintln!( + "DELAY-NO-YIELD node={} thread={:?} t_us={}", + node.id, + std::thread::current().id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros()) + .unwrap_or(0), + ); + } remaining -= slice; } } From 4251ad674ad8545a69742a5dcf71869a06d815f7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:54:11 +0300 Subject: [PATCH 076/138] chore: files changed src/engine/build/outcome.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/outcome.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/engine/build/outcome.rs b/src/engine/build/outcome.rs index ecfd568..20c1f34 100644 --- a/src/engine/build/outcome.rs +++ b/src/engine/build/outcome.rs @@ -2,6 +2,9 @@ use super::super::*; const BACKOFF_POLL_MS: u64 = 25; +pub(crate) static TF_DEBUG: std::sync::LazyLock = + std::sync::LazyLock::new(|| std::env::var("TF_GATE_DEBUG").is_ok()); + #[allow(clippy::too_many_arguments)] pub(super) async fn finish_execution( output: Option, From 1783d30cd7f8b1506a4fa87aafabe02fa8b38c9d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:54:16 +0300 Subject: [PATCH 077/138] chore: files changed src/caps/tasks.rs,src/engine/build/outcome.rs,src/nodes/integration/gate.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/tasks.rs | 4 ++-- src/engine/build/outcome.rs | 2 +- src/nodes/integration/gate.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/caps/tasks.rs b/src/caps/tasks.rs index a2a0c64..cb47069 100644 --- a/src/caps/tasks.rs +++ b/src/caps/tasks.rs @@ -176,7 +176,7 @@ impl TaskRunner for TokioTaskRunner { // for, and a host that wants real execution implements `TaskRunner` // against its own stack. Keeping that honest — rather than silently // producing nothing — is why the payload is echoed rather than dropped. - if std::env::var("TF_GATE_DEBUG").is_ok() { + if *crate::engine::build::outcome::TF_DEBUG { eprintln!( "SPAWN ticket={ticket} thread={:?} t_us={} rt_id={:?}", std::thread::current().id(), @@ -189,7 +189,7 @@ impl TaskRunner for TokioTaskRunner { } let debug_ticket = ticket.clone(); let handle = tokio::spawn(async move { - if std::env::var("TF_GATE_DEBUG").is_ok() { + if *crate::engine::build::outcome::TF_DEBUG { eprintln!( "TASKRUN ticket={debug_ticket} thread={:?} t_us={}", std::thread::current().id(), diff --git a/src/engine/build/outcome.rs b/src/engine/build/outcome.rs index 20c1f34..aa43593 100644 --- a/src/engine/build/outcome.rs +++ b/src/engine/build/outcome.rs @@ -99,7 +99,7 @@ where std::pin::Pin::new(&mut delay).poll(cx) }) .await; - if std::env::var("TF_GATE_DEBUG").is_ok() && polls == 1 { + if polls == 1 && *TF_DEBUG { eprintln!( "DELAY-NO-YIELD node={} thread={:?} t_us={}", node.id, diff --git a/src/nodes/integration/gate.rs b/src/nodes/integration/gate.rs index b4c97fb..6a9f28a 100644 --- a/src/nodes/integration/gate.rs +++ b/src/nodes/integration/gate.rs @@ -238,7 +238,7 @@ impl NodeExecutor for GateNode { let budget_spent = polls >= max_polls; let decision = policy.evaluate(results.len(), expected, budget_spent); - if std::env::var("TF_GATE_DEBUG").is_ok() { + if *crate::engine::build::outcome::TF_DEBUG { let mut states = Vec::new(); if let Some(runner) = ctx.caps.tasks.as_ref() { for item in &awaiting { From c10de5fce39c9af8d87db8a7d1fbbbf6ad8706d5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 21:54:38 +0300 Subject: [PATCH 078/138] chore: files changed src/caps/tasks.rs,src/engine/build/outcome.rs,src/lib.rs,src/nodes/integration/ Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/tasks.rs | 4 ++-- src/engine/build/outcome.rs | 5 +---- src/lib.rs | 3 +++ src/nodes/integration/gate.rs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/caps/tasks.rs b/src/caps/tasks.rs index cb47069..6b0d0be 100644 --- a/src/caps/tasks.rs +++ b/src/caps/tasks.rs @@ -176,7 +176,7 @@ impl TaskRunner for TokioTaskRunner { // for, and a host that wants real execution implements `TaskRunner` // against its own stack. Keeping that honest — rather than silently // producing nothing — is why the payload is echoed rather than dropped. - if *crate::engine::build::outcome::TF_DEBUG { + if *crate::TF_DEBUG { eprintln!( "SPAWN ticket={ticket} thread={:?} t_us={} rt_id={:?}", std::thread::current().id(), @@ -189,7 +189,7 @@ impl TaskRunner for TokioTaskRunner { } let debug_ticket = ticket.clone(); let handle = tokio::spawn(async move { - if *crate::engine::build::outcome::TF_DEBUG { + if *crate::TF_DEBUG { eprintln!( "TASKRUN ticket={debug_ticket} thread={:?} t_us={}", std::thread::current().id(), diff --git a/src/engine/build/outcome.rs b/src/engine/build/outcome.rs index aa43593..101f38f 100644 --- a/src/engine/build/outcome.rs +++ b/src/engine/build/outcome.rs @@ -2,9 +2,6 @@ use super::super::*; const BACKOFF_POLL_MS: u64 = 25; -pub(crate) static TF_DEBUG: std::sync::LazyLock = - std::sync::LazyLock::new(|| std::env::var("TF_GATE_DEBUG").is_ok()); - #[allow(clippy::too_many_arguments)] pub(super) async fn finish_execution( output: Option, @@ -99,7 +96,7 @@ where std::pin::Pin::new(&mut delay).poll(cx) }) .await; - if polls == 1 && *TF_DEBUG { + if polls == 1 && *crate::TF_DEBUG { eprintln!( "DELAY-NO-YIELD node={} thread={:?} t_us={}", node.id, diff --git a/src/lib.rs b/src/lib.rs index 8a63888..a606df5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,9 @@ pub mod data; /// empty agent prompts, errors an `on_error` policy swallowed, and nodes a /// branch routed past. pub mod diagnostics; +pub(crate) static TF_DEBUG: std::sync::LazyLock = + std::sync::LazyLock::new(|| std::env::var("TF_GATE_DEBUG").is_ok()); + pub mod engine; pub mod error; /// Bounding what a run hands back — durable records and tool replies alike — diff --git a/src/nodes/integration/gate.rs b/src/nodes/integration/gate.rs index 6a9f28a..049faef 100644 --- a/src/nodes/integration/gate.rs +++ b/src/nodes/integration/gate.rs @@ -238,7 +238,7 @@ impl NodeExecutor for GateNode { let budget_spent = polls >= max_polls; let decision = policy.evaluate(results.len(), expected, budget_spent); - if *crate::engine::build::outcome::TF_DEBUG { + if *crate::TF_DEBUG { let mut states = Vec::new(); if let Some(runner) = ctx.caps.tasks.as_ref() { for item in &awaiting { From 779d977ce5bd24530a6d89d3c8846882d61bfbc2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:01:20 +0300 Subject: [PATCH 079/138] chore: files changed src/engine/build/outcome.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/outcome.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/engine/build/outcome.rs b/src/engine/build/outcome.rs index 101f38f..e31fdae 100644 --- a/src/engine/build/outcome.rs +++ b/src/engine/build/outcome.rs @@ -90,6 +90,16 @@ where let slice = remaining.min(BACKOFF_POLL_MS); let mut delay = futures_timer::Delay::new(std::time::Duration::from_millis(slice)); + let mut yielded = false; + std::future::poll_fn(|cx| { + if yielded { + return std::task::Poll::Ready(()); + } + yielded = true; + cx.waker().wake_by_ref(); + std::task::Poll::Pending + }) + .await; let mut polls = 0usize; std::future::poll_fn(|cx| { polls += 1; From 0a5627fa04a3bc814f5a43354f4e531ae03fc9ad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:02:32 +0300 Subject: [PATCH 080/138] chore: files changed src/engine/build/backoff.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/backoff.rs | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/engine/build/backoff.rs diff --git a/src/engine/build/backoff.rs b/src/engine/build/backoff.rs new file mode 100644 index 0000000..05bc609 --- /dev/null +++ b/src/engine/build/backoff.rs @@ -0,0 +1,58 @@ +//! The engine's waiting primitive: one slice of a backoff. +//! +//! Two places in the engine wait without holding the executor: the retry +//! backoff between a failed attempt and the next one, and the `Reenter` backoff +//! a polling node (a `gate`) asks for between activations. Both chop their wait +//! into short slices so a cancel is seen promptly, and both call this for a +//! slice. +//! +//! # Why a wait has to yield, not merely elapse +//! +//! `futures_timer::Delay` arms its timer when it is *constructed*, not when it +//! is first polled, and its `poll` returns `Ready` straight away if the timer +//! already fired. So a task descheduled for longer than the slice between +//! constructing the `Delay` and awaiting it finds the wait already over and +//! completes it without ever returning `Pending` — a "wait" during which the +//! executor was never given a turn. +//! +//! That is not a cosmetic difference. The engine runs on the caller's executor, +//! and a backoff is the only point at which it hands that executor back. On a +//! single-threaded runtime the background work a `gate` is waiting on — the +//! tasks a `spawn` node started — can *only* progress while the engine is +//! yielded. A backoff that skips the yield therefore returns the gate to a world +//! that has not moved: it observes the same unsettled tickets, spends another +//! poll against its bounded budget, and the run takes a different number of +//! super-steps than an identical run whose backoff did yield. Under enough load +//! a gate could burn its whole poll budget and time out while the tasks it +//! waited on never once ran. +//! +//! Yielding unconditionally makes the wait mean what it says, and makes the +//! number of polls a gate needs a property of the graph rather than of how the +//! OS happened to schedule the process. + +use std::task::Poll; + +/// Waits `ms` milliseconds, always giving the executor at least one turn. +/// +/// The yield is unconditional and comes first, so it happens even when the +/// timer has already fired by the time this is awaited (see the module docs). +pub(super) async fn wait_slice(ms: u64) { + let delay = futures_timer::Delay::new(std::time::Duration::from_millis(ms)); + let mut yielded = false; + std::future::poll_fn(|cx| { + if yielded { + return Poll::Ready(()); + } + yielded = true; + // Re-queue behind whatever else the executor has ready, rather than + // being polled straight back. + cx.waker().wake_by_ref(); + Poll::Pending + }) + .await; + delay.await; +} + +#[cfg(test)] +#[path = "backoff_tests.rs"] +mod tests; From 997469e90efcd024598bd4d5e6542c54c8fb68e2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:02:40 +0300 Subject: [PATCH 081/138] chore: files changed src/engine/build/outcome.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/outcome.rs | 33 ++++----------------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/src/engine/build/outcome.rs b/src/engine/build/outcome.rs index e31fdae..065d4a4 100644 --- a/src/engine/build/outcome.rs +++ b/src/engine/build/outcome.rs @@ -88,35 +88,10 @@ where return Ok(NodeResult::Update(items_update(&node.id, &[], None)?)); } let slice = remaining.min(BACKOFF_POLL_MS); - let mut delay = - futures_timer::Delay::new(std::time::Duration::from_millis(slice)); - let mut yielded = false; - std::future::poll_fn(|cx| { - if yielded { - return std::task::Poll::Ready(()); - } - yielded = true; - cx.waker().wake_by_ref(); - std::task::Poll::Pending - }) - .await; - let mut polls = 0usize; - std::future::poll_fn(|cx| { - polls += 1; - std::pin::Pin::new(&mut delay).poll(cx) - }) - .await; - if polls == 1 && *crate::TF_DEBUG { - eprintln!( - "DELAY-NO-YIELD node={} thread={:?} t_us={}", - node.id, - std::thread::current().id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_micros()) - .unwrap_or(0), - ); - } + // Always yields, so the tasks this node is polling + // for get a turn before it looks again — see + // `super::backoff`. + super::backoff::wait_slice(slice).await; remaining -= slice; } } From 32d589781fa15de2d5ed3079e4f0046d32dde530 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:02:45 +0300 Subject: [PATCH 082/138] chore: files changed src/engine/build/activation.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/activation.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/engine/build/activation.rs b/src/engine/build/activation.rs index 92db200..c9c61e3 100644 --- a/src/engine/build/activation.rs +++ b/src/engine/build/activation.rs @@ -549,7 +549,10 @@ impl HandlerData { return Ok(NodeResult::Update(items_update(&node.id, &[], None)?)); } let step = remaining.min(BACKOFF_POLL_MS); - futures_timer::Delay::new(std::time::Duration::from_millis(step)).await; + // Yields even when the timer has already fired, so a retry + // backoff never starves the executor it shares — see + // `super::backoff`. + super::backoff::wait_slice(step).await; remaining -= step; } } From 5bb65cfd09198a432dffe994974c53eff09864b7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:02:51 +0300 Subject: [PATCH 083/138] chore: files changed src/engine/build.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/engine/build.rs b/src/engine/build.rs index 0118a86..383b8f1 100644 --- a/src/engine/build.rs +++ b/src/engine/build.rs @@ -1,6 +1,7 @@ use super::*; mod activation; +mod backoff; mod handlers; mod outcome; mod wiring; From 34458918f4edd191fdc2f377fe6871a3fb4d02d1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:03:03 +0300 Subject: [PATCH 084/138] chore: files changed src/engine/build/backoff_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/backoff_tests.rs | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/engine/build/backoff_tests.rs diff --git a/src/engine/build/backoff_tests.rs b/src/engine/build/backoff_tests.rs new file mode 100644 index 0000000..9bc7fa6 --- /dev/null +++ b/src/engine/build/backoff_tests.rs @@ -0,0 +1,57 @@ +//! Unit tests for the backoff slice primitive. + +use super::wait_slice; + +/// The guarantee the whole module exists for: a slice always returns `Pending` +/// at least once, so the executor gets a turn. +/// +/// Asserted by driving the future by hand with a no-op waker rather than on a +/// runtime, because "did it yield" is a statement about `poll` and nothing +/// else can observe it directly. +#[test] +fn a_slice_yields_at_least_once() { + use std::future::Future; + use std::task::{Context, Poll, Wake, Waker}; + + struct Noop; + impl Wake for Noop { + fn wake(self: std::sync::Arc) {} + } + + let waker = Waker::from(std::sync::Arc::new(Noop)); + let mut cx = Context::from_waker(&waker); + let future = wait_slice(0); + let mut future = std::pin::pin!(future); + assert_eq!( + future.as_mut().poll(&mut cx), + Poll::Pending, + "a zero-length slice still has to hand the executor a turn" + ); +} + +/// A slice whose timer has *already* fired before the future is first polled — +/// the load-induced case that made a gate's poll count nondeterministic — must +/// still yield rather than completing on its first poll. +#[test] +fn a_slice_yields_even_when_its_timer_already_fired() { + use std::future::Future; + use std::task::{Context, Poll, Wake, Waker}; + + struct Noop; + impl Wake for Noop { + fn wake(self: std::sync::Arc) {} + } + + let waker = Waker::from(std::sync::Arc::new(Noop)); + let mut cx = Context::from_waker(&waker); + let future = wait_slice(1); + let mut future = std::pin::pin!(future); + // Stand in for the thread being descheduled between constructing the + // timer and reaching the await point. + std::thread::sleep(std::time::Duration::from_millis(20)); + assert_eq!( + future.as_mut().poll(&mut cx), + Poll::Pending, + "an already-elapsed slice must still yield, or concurrent work never runs" + ); +} From 4bb2ad0ce77ca469d89b1392993a061fdf1fffbf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:03:10 +0300 Subject: [PATCH 085/138] chore: files changed src/nodes/integration/gate.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/gate.rs | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/src/nodes/integration/gate.rs b/src/nodes/integration/gate.rs index 049faef..c9dbce8 100644 --- a/src/nodes/integration/gate.rs +++ b/src/nodes/integration/gate.rs @@ -238,39 +238,6 @@ impl NodeExecutor for GateNode { let budget_spent = polls >= max_polls; let decision = policy.evaluate(results.len(), expected, budget_spent); - if *crate::TF_DEBUG { - let mut states = Vec::new(); - if let Some(runner) = ctx.caps.tasks.as_ref() { - for item in &awaiting { - if let Some(ticket) = item.ticket.as_ref() { - let state = runner.poll(ticket).await?; - states.push(match state { - TaskState::Pending => "pending", - TaskState::Running => "running", - TaskState::Done(_) => "done", - TaskState::Failed(_) => "failed", - }); - } - } - } - eprintln!( - "GATE {:?} thread={:?} t_us={} step={} polls_in={} expected={} arrived={} \ - decision={:?} states={:?}", - ctx.node.id, - std::thread::current().id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_micros()) - .unwrap_or(0), - ctx.step, - polls, - expected, - results.len(), - decision, - states, - ); - } - let meta = json!({ POLLS_KEY: polls + 1, "arrived": results.len(), From a266bb6cdcc082fc8e8cb59f653b642b1491d190 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:03:16 +0300 Subject: [PATCH 086/138] chore: files changed src/caps/tasks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/caps/tasks.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/caps/tasks.rs b/src/caps/tasks.rs index 6b0d0be..26d25f6 100644 --- a/src/caps/tasks.rs +++ b/src/caps/tasks.rs @@ -176,29 +176,7 @@ impl TaskRunner for TokioTaskRunner { // for, and a host that wants real execution implements `TaskRunner` // against its own stack. Keeping that honest — rather than silently // producing nothing — is why the payload is echoed rather than dropped. - if *crate::TF_DEBUG { - eprintln!( - "SPAWN ticket={ticket} thread={:?} t_us={} rt_id={:?}", - std::thread::current().id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_micros()) - .unwrap_or(0), - tokio::runtime::Handle::current().id(), - ); - } - let debug_ticket = ticket.clone(); let handle = tokio::spawn(async move { - if *crate::TF_DEBUG { - eprintln!( - "TASKRUN ticket={debug_ticket} thread={:?} t_us={}", - std::thread::current().id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_micros()) - .unwrap_or(0), - ); - } *state.lock().expect("task state poisoned") = TaskState::Running; let result = match spec { TaskSpec::Workflow { graph, input } => { From 0e169f15233e161fd301a62c66deec06134c01f7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:03:22 +0300 Subject: [PATCH 087/138] chore: files changed src/lib.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/lib.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a606df5..8a63888 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,9 +36,6 @@ pub mod data; /// empty agent prompts, errors an `on_error` policy swallowed, and nodes a /// branch routed past. pub mod diagnostics; -pub(crate) static TF_DEBUG: std::sync::LazyLock = - std::sync::LazyLock::new(|| std::env::var("TF_GATE_DEBUG").is_ok()); - pub mod engine; pub mod error; /// Bounding what a run hands back — durable records and tool replies alike — From 792dd62213b11596836904bd9509282d26818cc0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:03:36 +0300 Subject: [PATCH 088/138] chore: files changed tests/fuzz_resume.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/fuzz_resume.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/fuzz_resume.rs b/tests/fuzz_resume.rs index 32339ba..d71a47b 100644 --- a/tests/fuzz_resume.rs +++ b/tests/fuzz_resume.rs @@ -199,6 +199,64 @@ fn gated_graphs_are_mostly_runnable_and_actually_suspend() { ); } +/// The shape `resuming_is_deterministic` shrank to when it caught a real race. +/// +/// A gate sitting beside concurrent work, with three spawned tasks either side +/// of a branch — the arrangement in which the number of polls a gate needs was +/// decided by OS scheduling rather than by the graph. +fn regression_shape() -> Shape { + Shape::Gate(Box::new(Shape::Branch( + Box::new(Shape::Spawned { + tasks: 3, + release: "all", + n: 3, + }), + Box::new(Shape::Fanout(vec![ + Shape::Linear(2), + Shape::Linear(0), + Shape::Spawned { + tasks: 3, + release: "all", + n: 3, + }, + ])), + ))) +} + +/// **Regression.** A gate needs the same number of polls every time. +/// +/// `resuming_is_deterministic` found this shape producing runs that agreed on +/// every collected item yet disagreed on the gate's `polls` count and on every +/// downstream `_activation_step`. The cause was not the gate: a `Reenter` +/// backoff whose timer had already fired completed without ever returning +/// `Pending`, so on a single-threaded runtime the spawned tasks the gate was +/// waiting on got no turn to run, and the gate spent an extra poll observing a +/// world that had not moved. Whether that happened depended on whether the +/// thread was descheduled for longer than the poll interval — which is why the +/// property test only ever failed on a loaded CI machine. +/// +/// Run as a fixed loop rather than a property, because the shape is already +/// known and the variable being exercised is repetition, not generation. It +/// reproduced within a few hundred iterations under load before the fix. +#[test] +fn a_gate_takes_the_same_number_of_polls_every_run() { + const RUNS: usize = 150; + + let shape = regression_shape(); + let mut baseline: Option = None; + for run_index in 0..RUNS { + let (_, resumed) = run_both_ways(&shape).expect("the regression shape should compile"); + match &baseline { + None => baseline = Some(resumed), + Some(first) => assert_eq!( + first, &resumed, + "run {run_index} of the same shape differed from the first; a gate's poll count \ + or an activation step is being decided by scheduling rather than by the graph" + ), + } + } +} + proptest! { // Deliberately fewer cases than the other fuzz files: each case runs the // same graph at least twice, once through the checkpointer. From 6929e8187ce1894b0048b32a901f3a05ee285ad6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:04:10 +0300 Subject: [PATCH 089/138] chore: files changed src/engine/build/backoff.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/backoff.rs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/engine/build/backoff.rs b/src/engine/build/backoff.rs index 05bc609..dab9edb 100644 --- a/src/engine/build/backoff.rs +++ b/src/engine/build/backoff.rs @@ -37,20 +37,8 @@ use std::task::Poll; /// The yield is unconditional and comes first, so it happens even when the /// timer has already fired by the time this is awaited (see the module docs). pub(super) async fn wait_slice(ms: u64) { - let delay = futures_timer::Delay::new(std::time::Duration::from_millis(ms)); - let mut yielded = false; - std::future::poll_fn(|cx| { - if yielded { - return Poll::Ready(()); - } - yielded = true; - // Re-queue behind whatever else the executor has ready, rather than - // being polled straight back. - cx.waker().wake_by_ref(); - Poll::Pending - }) - .await; - delay.await; + let _ = Poll::<()>::Pending; + futures_timer::Delay::new(std::time::Duration::from_millis(ms)).await; } #[cfg(test)] From 38c428fd58ee713ef3284a9fdeedbba661b439e9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:05:46 +0300 Subject: [PATCH 090/138] chore: files changed src/engine/build/backoff.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/backoff.rs | 52 +++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/engine/build/backoff.rs b/src/engine/build/backoff.rs index dab9edb..e2a6708 100644 --- a/src/engine/build/backoff.rs +++ b/src/engine/build/backoff.rs @@ -3,28 +3,29 @@ //! Two places in the engine wait without holding the executor: the retry //! backoff between a failed attempt and the next one, and the `Reenter` backoff //! a polling node (a `gate`) asks for between activations. Both chop their wait -//! into short slices so a cancel is seen promptly, and both call this for a -//! slice. +//! into short slices so a cancel is seen promptly, and both take a slice from +//! here. //! //! # Why a wait has to yield, not merely elapse //! -//! `futures_timer::Delay` arms its timer when it is *constructed*, not when it -//! is first polled, and its `poll` returns `Ready` straight away if the timer -//! already fired. So a task descheduled for longer than the slice between -//! constructing the `Delay` and awaiting it finds the wait already over and -//! completes it without ever returning `Pending` — a "wait" during which the -//! executor was never given a turn. +//! `futures_timer::Delay` arms its timer when it is **constructed** — `new` +//! computes the deadline and pushes it to the global timer thread — and its +//! `poll` returns `Ready` immediately if that thread has already fired it. So a +//! task descheduled between constructing the `Delay` and first polling it for +//! longer than the slice finds the wait already over, and completes it without +//! ever returning `Pending`: a "wait" during which the executor was never given +//! a turn. //! //! That is not a cosmetic difference. The engine runs on the caller's executor, //! and a backoff is the only point at which it hands that executor back. On a //! single-threaded runtime the background work a `gate` is waiting on — the //! tasks a `spawn` node started — can *only* progress while the engine is -//! yielded. A backoff that skips the yield therefore returns the gate to a world -//! that has not moved: it observes the same unsettled tickets, spends another +//! yielded. A backoff that skips the yield therefore returns the gate to a +//! world that has not moved: it sees the same unsettled tickets, spends another //! poll against its bounded budget, and the run takes a different number of //! super-steps than an identical run whose backoff did yield. Under enough load -//! a gate could burn its whole poll budget and time out while the tasks it -//! waited on never once ran. +//! a gate could burn its whole poll budget and time out having never once let +//! the tasks it waited on run. //! //! Yielding unconditionally makes the wait mean what it says, and makes the //! number of polls a gate needs a property of the graph rather than of how the @@ -32,13 +33,32 @@ use std::task::Poll; +/// Hands the executor exactly one turn. +/// +/// Returns `Pending` on its first poll — waking itself first, so it is +/// re-queued behind whatever else is already ready — and `Ready` on its second. +/// Unconditional: it does not consult a timer, which is the whole point. +async fn yield_once() { + let mut yielded = false; + std::future::poll_fn(|cx| { + if yielded { + return Poll::Ready(()); + } + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + }) + .await; +} + /// Waits `ms` milliseconds, always giving the executor at least one turn. /// -/// The yield is unconditional and comes first, so it happens even when the -/// timer has already fired by the time this is awaited (see the module docs). +/// The timer is armed before the yield rather than after, so the turn handed +/// over counts toward the wait instead of being added to it. pub(super) async fn wait_slice(ms: u64) { - let _ = Poll::<()>::Pending; - futures_timer::Delay::new(std::time::Duration::from_millis(ms)).await; + let delay = futures_timer::Delay::new(std::time::Duration::from_millis(ms)); + yield_once().await; + delay.await; } #[cfg(test)] From d11ea3d6821f9031b10423ed65b5fbcabe12d2eb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:06:01 +0300 Subject: [PATCH 091/138] chore: files changed src/engine/build/backoff_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/backoff_tests.rs | 97 +++++++++++++++++++------------ 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/src/engine/build/backoff_tests.rs b/src/engine/build/backoff_tests.rs index 9bc7fa6..2765e8f 100644 --- a/src/engine/build/backoff_tests.rs +++ b/src/engine/build/backoff_tests.rs @@ -1,57 +1,82 @@ //! Unit tests for the backoff slice primitive. +//! +//! These pin the contract of [`yield_once`], which is what makes a backoff +//! hand the executor a turn whatever the timer did. The bug that contract +//! exists to prevent is an engine-level one and is covered end to end by +//! `a_gate_takes_the_same_number_of_polls_every_run` in `tests/fuzz_resume.rs`; +//! what is asserted here is the piece that can be checked deterministically. -use super::wait_slice; +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::task::{Context, Poll, Wake, Waker}; -/// The guarantee the whole module exists for: a slice always returns `Pending` -/// at least once, so the executor gets a turn. -/// -/// Asserted by driving the future by hand with a no-op waker rather than on a -/// runtime, because "did it yield" is a statement about `poll` and nothing -/// else can observe it directly. -#[test] -fn a_slice_yields_at_least_once() { - use std::future::Future; - use std::task::{Context, Poll, Wake, Waker}; +use super::{wait_slice, yield_once}; + +/// A waker that counts how many times it was woken. +struct Counting(AtomicUsize); - struct Noop; - impl Wake for Noop { - fn wake(self: std::sync::Arc) {} +impl Wake for Counting { + fn wake(self: Arc) { + self.wake_by_ref(); } - let waker = Waker::from(std::sync::Arc::new(Noop)); + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +/// A yield is `Pending` once, then `Ready` — and it wakes itself, so an +/// executor re-queues it rather than dropping the task. +/// +/// The self-wake is half the contract: a `Pending` that never wakes is a hang, +/// not a yield. +#[test] +fn a_yield_is_pending_once_then_ready() { + let counter = Arc::new(Counting(AtomicUsize::new(0))); + let waker = Waker::from(counter.clone()); let mut cx = Context::from_waker(&waker); - let future = wait_slice(0); - let mut future = std::pin::pin!(future); + let mut future = std::pin::pin!(yield_once()); + assert_eq!( future.as_mut().poll(&mut cx), Poll::Pending, - "a zero-length slice still has to hand the executor a turn" + "the first poll of a yield must hand the executor its turn" + ); + assert_eq!( + counter.0.load(Ordering::SeqCst), + 1, + "a yield must wake itself, or the turn it hands over is never taken back" + ); + assert_eq!( + future.as_mut().poll(&mut cx), + Poll::Ready(()), + "a yield hands over exactly one turn, not a stream of them" ); } -/// A slice whose timer has *already* fired before the future is first polled — -/// the load-induced case that made a gate's poll count nondeterministic — must -/// still yield rather than completing on its first poll. +/// A wait slice never completes on its first poll, however the timer resolved. +/// +/// This is the property the whole module exists for: a slice whose timer had +/// already fired must still yield. Asserted against a zero-length slice, whose +/// deadline is already in the past the moment it is armed — the case most +/// likely to short-circuit. #[test] -fn a_slice_yields_even_when_its_timer_already_fired() { - use std::future::Future; - use std::task::{Context, Poll, Wake, Waker}; - - struct Noop; - impl Wake for Noop { - fn wake(self: std::sync::Arc) {} - } - - let waker = Waker::from(std::sync::Arc::new(Noop)); +fn a_wait_slice_never_completes_on_its_first_poll() { + let counter = Arc::new(Counting(AtomicUsize::new(0))); + let waker = Waker::from(counter); let mut cx = Context::from_waker(&waker); - let future = wait_slice(1); - let mut future = std::pin::pin!(future); - // Stand in for the thread being descheduled between constructing the - // timer and reaching the await point. - std::thread::sleep(std::time::Duration::from_millis(20)); + let mut future = std::pin::pin!(wait_slice(0)); + assert_eq!( future.as_mut().poll(&mut cx), Poll::Pending, "an already-elapsed slice must still yield, or concurrent work never runs" ); } + +/// And it does finish: the yield does not turn a bounded wait into a hang. +#[test] +fn a_wait_slice_still_completes() { + futures::executor::block_on(wait_slice(1)); +} From e3dbdf290a4e9feed1fffba6b2edb57ccd87c1a2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:06:11 +0300 Subject: [PATCH 092/138] chore: files changed src/engine/build/backoff_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/build/backoff_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/engine/build/backoff_tests.rs b/src/engine/build/backoff_tests.rs index 2765e8f..005f078 100644 --- a/src/engine/build/backoff_tests.rs +++ b/src/engine/build/backoff_tests.rs @@ -76,7 +76,7 @@ fn a_wait_slice_never_completes_on_its_first_poll() { } /// And it does finish: the yield does not turn a bounded wait into a hang. -#[test] -fn a_wait_slice_still_completes() { - futures::executor::block_on(wait_slice(1)); +#[tokio::test] +async fn a_wait_slice_still_completes() { + wait_slice(1).await; } From 5e73697a3f285119081640267753e4ee704ad426 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:06:16 +0300 Subject: [PATCH 093/138] chore: files changed tests/zz_repro.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/zz_repro.rs | 146 ---------------------------------------------- 1 file changed, 146 deletions(-) delete mode 100644 tests/zz_repro.rs diff --git a/tests/zz_repro.rs b/tests/zz_repro.rs deleted file mode 100644 index dd22791..0000000 --- a/tests/zz_repro.rs +++ /dev/null @@ -1,146 +0,0 @@ -#![cfg(feature = "mock")] -//! TEMPORARY repro harness for the resume determinism race. Not for commit. - -mod support; - -use std::sync::Arc; -use std::time::Duration; - -use serde_json::{Value, json}; - -use support::graphgen::{Shape, graph_of}; -use tinyflows::caps::mock::mock_capabilities; -use tinyflows::compiler::compile; -use tinyflows::engine::{ - InMemoryCheckpointer, resume_with_checkpointer, run, run_with_checkpointer, -}; - -const GUARD: Duration = Duration::from_secs(20); - -fn runtime() -> tokio::runtime::Runtime { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build runtime") -} - -fn run_both_ways(shape: &Shape) -> Option<(Value, Value)> { - let graph = graph_of(shape); - let compiled = compile(&graph).ok()?; - let gates = shape.gate_ids(); - - runtime().block_on(async { - let caps = mock_capabilities(); - - let straight = tokio::time::timeout( - GUARD, - run(&compiled, json!({ "approvals": gates.clone() }), &caps), - ) - .await - .expect("pre-approved run hung") - .expect("pre-approved run should complete"); - - let checkpointer = Arc::new(InMemoryCheckpointer::default()); - let thread_id = "fuzz-resume"; - let mut outcome = tokio::time::timeout( - GUARD, - run_with_checkpointer(&compiled, json!({}), &caps, checkpointer.clone(), thread_id), - ) - .await - .expect("suspending run hung") - .expect("suspending run should pause rather than fail"); - - let mut rounds = 0; - while !outcome.pending_approvals.is_empty() { - rounds += 1; - assert!(rounds <= gates.len() + 2, "resume did not converge"); - let pending = outcome.pending_approvals.clone(); - outcome = tokio::time::timeout( - GUARD, - resume_with_checkpointer(&compiled, &caps, checkpointer.clone(), thread_id, pending), - ) - .await - .expect("resume hung") - .expect("resume should succeed"); - } - - Some((straight.output, outcome.output)) - }) -} - -fn shape() -> Shape { - Shape::Gate(Box::new(Shape::Branch( - Box::new(Shape::Spawned { - tasks: 3, - release: "all", - n: 3, - }), - Box::new(Shape::Fanout(vec![ - Shape::Linear(2), - Shape::Linear(0), - Shape::Spawned { - tasks: 3, - release: "all", - n: 3, - }, - ])), - ))) -} - -#[test] -fn repro_resume_determinism() { - let threads: usize = std::env::var("REPRO_THREADS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1); - let iters: usize = std::env::var("REPRO_ITERS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(60); - - let handles: Vec<_> = (0..threads) - .map(|t| { - std::thread::spawn(move || { - let shape = shape(); - let mut counts: std::collections::BTreeMap = - std::collections::BTreeMap::new(); - let mut baseline: Option = None; - let mut diverged = 0usize; - for i in 0..iters { - eprintln!("ITER thread={:?} i={i}", std::thread::current().id()); - let (_, resumed) = run_both_ways(&shape).expect("shape should compile"); - let polls = resumed["nodes"]["n21"]["polls"].as_u64().unwrap_or(0); - *counts.entry(polls).or_default() += 1; - match &baseline { - None => baseline = Some(resumed), - Some(first) => { - if first != &resumed { - diverged += 1; - let a = first["nodes"].as_object().cloned().unwrap_or_default(); - let b = resumed["nodes"].as_object().cloned().unwrap_or_default(); - for (id, slot) in &a { - let other = b.get(id); - if other != Some(slot) { - println!( - " node {id}:\n A = {slot}\n B = {}", - other.cloned().unwrap_or(Value::Null) - ); - } - } - for (id, slot) in &b { - if !a.contains_key(id) { - println!(" node {id} only in B: {slot}"); - } - } - } - } - } - } - println!("thread {t}: poll histogram {counts:?}, diverged {diverged}/{iters}"); - diverged - }) - }) - .collect(); - let total: usize = handles.into_iter().map(|h| h.join().unwrap()).sum(); - assert_eq!(total, 0, "{total} runs diverged"); -} From f2c2fd7076e223cb138afd86265c541232061b7c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:08:58 +0300 Subject: [PATCH 094/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 214 ++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/nodes/integration/approval_request.rs diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs new file mode 100644 index 0000000..c29ef5a --- /dev/null +++ b/src/nodes/integration/approval_request.rs @@ -0,0 +1,214 @@ +//! Building an [`ApprovalRequest`] from a node's config, and reading a +//! settled [`ApprovalDecision`] back out of a resume value, the run's +//! approvals list, or a finished review. +//! +//! Split out of `approval.rs` to keep that file under the repository's +//! line-length limit; request-building and decision-reading are one cohesive +//! concern (every function here is pure data shaping, none of it waits or +//! calls a capability), so they belong together rather than split further. + +use serde_json::{Value, json}; + +use crate::caps::{ApprovalDecision, ApprovalRequest, ApprovalSubject}; +use crate::data::Item; +use crate::error::{EngineError, Result}; +use crate::nodes::NodeContext; + +/// The default rendering hint when the graph does not say what the subject is. +const DEFAULT_SUBJECT_KIND: &str = "json"; + +/// The run's host id, when the state carries one under any of the spellings a +/// host might seed (`run.id`, `run.run_id`, `run.trigger.run_id`). +fn run_id(ctx: &NodeContext<'_>) -> Option { + ["id", "run_id"] + .iter() + .find_map(|key| ctx.run.get(*key).and_then(Value::as_str)) + .or_else(|| { + ctx.run + .get("trigger") + .and_then(|t| t.get("run_id")) + .and_then(Value::as_str) + }) + .map(str::to_string) +} + +/// Builds the review request from the node's resolved config. +/// +/// The `request_id` is what makes the provider's create-or-fetch contract +/// work, so it must be **stable across activations**: an interrupt discards the +/// activation's state update, so the node cannot remember an id it generated, +/// and anything derived from the clock or a counter would create a fresh review +/// on every resume. Hence run id + node id, or an explicit `config.request_id` +/// for a host that wants to key reviews its own way. +/// +/// Falling back to the bare node id when *neither* is available would let two +/// different runs of the same graph collide on the same `request_id`: since +/// [`ApprovalProvider::decide`](crate::caps::ApprovalProvider::decide) is +/// create-or-fetch, a later run would silently inherit an earlier run's +/// decision and route an unreviewed subject straight through `approved`. So a +/// node with no `config.request_id` and no run-scoped identity is a +/// configuration error, not a degraded default. +pub(super) fn build_request(ctx: &NodeContext<'_>, config: &Value) -> Result { + let run = run_id(ctx); + let request_id = match config.get("request_id").and_then(Value::as_str) { + Some(explicit) => explicit.to_string(), + None => match &run { + Some(run) => format!("{run}:{}", ctx.node.id), + None => { + return Err(EngineError::Capability(format!( + "approval node {:?}: no `request_id` configured and no run-scoped identity \ + available (expected `run.id`, `run.run_id`, or `run.trigger.run_id`); set \ + `config.request_id` explicitly or seed a run id, otherwise later runs could \ + reuse an earlier run's decision", + ctx.node.id + ))); + } + }, + }; + + // The subject defaults to the item that arrived, which is the common case: + // a node upstream produced the thing, and the human looks at it. + let value = config + .get("subject") + .cloned() + .or_else(|| ctx.input.first().map(|item| item.json.clone())) + .unwrap_or(Value::Null); + + Ok(ApprovalRequest { + request_id, + node_id: ctx.node.id.clone(), + run_id: run, + title: string_field(config, "title"), + prompt: string_field(config, "prompt"), + subject: ApprovalSubject { + kind: config + .get("subject_kind") + .and_then(Value::as_str) + .unwrap_or(DEFAULT_SUBJECT_KIND) + .to_string(), + value, + }, + assignees: config + .get("assignees") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + metadata: config.get("metadata").cloned().unwrap_or(Value::Null), + }) +} + +/// A config field read as a string, ignoring a non-string (an unresolved +/// expression that came back `null`, say). +fn string_field(config: &Value, key: &str) -> Option { + config.get(key).and_then(Value::as_str).map(str::to_string) +} + +/// Whether `list` (an array of strings) names this node or its review. +pub(super) fn names(list: Option<&Value>, request: &ApprovalRequest) -> bool { + list.and_then(Value::as_array).is_some_and(|ids| { + ids.iter() + .filter_map(Value::as_str) + .any(|id| id == request.node_id || id == request.request_id) + }) +} + +/// Reads a decision out of a resume value, if it carries one. +/// +/// Accepts the engine's own `{"rejected": […]}` denial shape (checked +/// first, so a denial always beats an approval delivered in the same value), +/// the mirror `{"approved": […]}`, and a full verdict object — either +/// inline or nested under `decision`. +pub(super) fn decision_from_resume( + resume: &Value, + request: &ApprovalRequest, +) -> Option { + if names(resume.get("rejected"), request) { + return Some(ApprovalDecision::rejected( + resume + .get("comment") + .and_then(Value::as_str) + .map(str::to_string), + )); + } + if names(resume.get("approved"), request) { + return Some(ApprovalDecision::approved()); + } + + let verdict = resume.get("decision").unwrap_or(resume); + let approved = verdict.get("approved").and_then(Value::as_bool)?; + Some(ApprovalDecision { + approved, + decided_by: string_field(verdict, "decided_by"), + comment: string_field(verdict, "comment"), + payload: verdict.get("payload").cloned(), + }) +} + +/// The decision already in hand before the host is asked: a resume value, or +/// this node's id on the run's approvals list. +pub(super) fn delivered(ctx: &NodeContext<'_>, request: &ApprovalRequest) -> Option { + if let Some(decision) = ctx + .resume + .as_ref() + .and_then(|resume| decision_from_resume(resume, request)) + { + return Some(decision); + } + + // The re-execute resume path: `engine::resume` merges newly-approved ids + // into the run input, where they arrive as `run.trigger.approvals`. The + // top-level `run.approvals` is the same list seeded through the explicit + // channel; read both, because which one carries the id depends on how the + // host started the run. + let trigger_approvals = ctx.run.get("trigger").and_then(|t| t.get("approvals")); + if names(trigger_approvals, request) || names(ctx.run.get("approvals"), request) { + return Some(ApprovalDecision::approved()); + } + None +} + +/// The item a settled review emits. +/// +/// `subject` is what the human actually signed off on — their edit when the +/// host's surface allowed one, otherwise exactly what was sent — so a +/// downstream node reads one field regardless. The original input is kept under +/// `input` so nothing is lost when the subject was a projection of it. +pub(super) fn decided_item( + request: &ApprovalRequest, + decision: &ApprovalDecision, + input: Value, +) -> Item { + Item::new(json!({ + "approved": decision.approved, + "subject": decision + .payload + .clone() + .unwrap_or_else(|| request.subject.value.clone()), + "subject_kind": request.subject.kind, + "edited": decision.payload.is_some(), + "decided_by": decision.decided_by, + "comment": decision.comment, + "request_id": request.request_id, + "input": input, + })) +} + +/// The slot state a settled review records, so `=nodes..decision.approved` +/// resolves from anywhere in the graph — including from a branch that did not +/// receive the emitted item (a `drop`ped rejection has no item at all). +pub(super) fn decision_meta(decision: &ApprovalDecision, request: &ApprovalRequest) -> Value { + json!({ + "decision": { + "approved": decision.approved, + "decided_by": decision.decided_by, + "comment": decision.comment, + "request_id": request.request_id, + } + }) +} From 138c757c0e9be2d43fc6c9dc321d2b886dc4b649 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:09:04 +0300 Subject: [PATCH 095/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index 095d09f..7c3b6b3 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -48,11 +48,14 @@ use async_trait::async_trait; use serde_json::{Value, json}; -use crate::caps::{ApprovalDecision, ApprovalOutcome, ApprovalRequest, ApprovalSubject}; +use crate::caps::{ApprovalOutcome, ApprovalRequest}; use crate::data::Item; use crate::error::{EngineError, Result}; use crate::nodes::{NodeContext, NodeExecutor, NodeOutput, resolve_config_traced}; +mod approval_request; +use approval_request::{build_request, decided_item, decision_meta, delivered}; + /// Default gap between polls, in milliseconds. A second, not the `gate`'s 250ms: /// nothing a human does resolves faster than that. const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000; From 9c8bb319139d81c7908f7514f50c37294d7543a8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:09:09 +0300 Subject: [PATCH 096/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index 7c3b6b3..a5ec967 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -53,6 +53,7 @@ use crate::data::Item; use crate::error::{EngineError, Result}; use crate::nodes::{NodeContext, NodeExecutor, NodeOutput, resolve_config_traced}; +#[path = "approval_request.rs"] mod approval_request; use approval_request::{build_request, decided_item, decision_meta, delivered}; From 5d0d218e6205c499d596e960d0a054859f779dc0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:09:33 +0300 Subject: [PATCH 097/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 189 ------------------------------ 1 file changed, 189 deletions(-) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index a5ec967..c2b578c 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -151,195 +151,6 @@ fn positive_u64(config: &Value, key: &str, default: u64) -> u64 { .unwrap_or(default) } -/// The run's host id, when the state carries one under any of the spellings a -/// host might seed (`run.id`, `run.run_id`, `run.trigger.run_id`). -fn run_id(ctx: &NodeContext<'_>) -> Option { - ["id", "run_id"] - .iter() - .find_map(|key| ctx.run.get(*key).and_then(Value::as_str)) - .or_else(|| { - ctx.run - .get("trigger") - .and_then(|t| t.get("run_id")) - .and_then(Value::as_str) - }) - .map(str::to_string) -} - -/// Builds the review request from the node's resolved config. -/// -/// The `request_id` is what makes the provider's create-or-fetch contract -/// work, so it must be **stable across activations**: an interrupt discards the -/// activation's state update, so the node cannot remember an id it generated, -/// and anything derived from the clock or a counter would create a fresh review -/// on every resume. Hence run id + node id, or an explicit `config.request_id` -/// for a host that wants to key reviews its own way. -/// -/// Falling back to the bare node id when *neither* is available would let two -/// different runs of the same graph collide on the same `request_id`: since -/// [`ApprovalProvider::decide`](crate::caps::ApprovalProvider::decide) is -/// create-or-fetch, a later run would silently inherit an earlier run's -/// decision and route an unreviewed subject straight through `approved`. So a -/// node with no `config.request_id` and no run-scoped identity is a -/// configuration error, not a degraded default. -fn build_request(ctx: &NodeContext<'_>, config: &Value) -> Result { - let run = run_id(ctx); - let request_id = match config.get("request_id").and_then(Value::as_str) { - Some(explicit) => explicit.to_string(), - None => match &run { - Some(run) => format!("{run}:{}", ctx.node.id), - None => { - return Err(EngineError::Capability(format!( - "approval node {:?}: no `request_id` configured and no run-scoped identity \ - available (expected `run.id`, `run.run_id`, or `run.trigger.run_id`); set \ - `config.request_id` explicitly or seed a run id, otherwise later runs could \ - reuse an earlier run's decision", - ctx.node.id - ))); - } - }, - }; - - // The subject defaults to the item that arrived, which is the common case: - // a node upstream produced the thing, and the human looks at it. - let value = config - .get("subject") - .cloned() - .or_else(|| ctx.input.first().map(|item| item.json.clone())) - .unwrap_or(Value::Null); - - Ok(ApprovalRequest { - request_id, - node_id: ctx.node.id.clone(), - run_id: run, - title: string_field(config, "title"), - prompt: string_field(config, "prompt"), - subject: ApprovalSubject { - kind: config - .get("subject_kind") - .and_then(Value::as_str) - .unwrap_or(DEFAULT_SUBJECT_KIND) - .to_string(), - value, - }, - assignees: config - .get("assignees") - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(), - metadata: config.get("metadata").cloned().unwrap_or(Value::Null), - }) -} - -/// A config field read as a string, ignoring a non-string (an unresolved -/// expression that came back `null`, say). -fn string_field(config: &Value, key: &str) -> Option { - config.get(key).and_then(Value::as_str).map(str::to_string) -} - -/// Whether `list` (an array of strings) names this node or its review. -fn names(list: Option<&Value>, request: &ApprovalRequest) -> bool { - list.and_then(Value::as_array).is_some_and(|ids| { - ids.iter() - .filter_map(Value::as_str) - .any(|id| id == request.node_id || id == request.request_id) - }) -} - -/// Reads a decision out of a resume value, if it carries one. -/// -/// Accepts the engine's own `{"rejected": […]}` denial shape (checked -/// first, so a denial always beats an approval delivered in the same value), -/// the mirror `{"approved": […]}`, and a full verdict object — either -/// inline or nested under `decision`. -fn decision_from_resume(resume: &Value, request: &ApprovalRequest) -> Option { - if names(resume.get("rejected"), request) { - return Some(ApprovalDecision::rejected( - resume - .get("comment") - .and_then(Value::as_str) - .map(str::to_string), - )); - } - if names(resume.get("approved"), request) { - return Some(ApprovalDecision::approved()); - } - - let verdict = resume.get("decision").unwrap_or(resume); - let approved = verdict.get("approved").and_then(Value::as_bool)?; - Some(ApprovalDecision { - approved, - decided_by: string_field(verdict, "decided_by"), - comment: string_field(verdict, "comment"), - payload: verdict.get("payload").cloned(), - }) -} - -/// The decision already in hand before the host is asked: a resume value, or -/// this node's id on the run's approvals list. -fn delivered(ctx: &NodeContext<'_>, request: &ApprovalRequest) -> Option { - if let Some(decision) = ctx - .resume - .as_ref() - .and_then(|resume| decision_from_resume(resume, request)) - { - return Some(decision); - } - - // The re-execute resume path: `engine::resume` merges newly-approved ids - // into the run input, where they arrive as `run.trigger.approvals`. The - // top-level `run.approvals` is the same list seeded through the explicit - // channel; read both, because which one carries the id depends on how the - // host started the run. - let trigger_approvals = ctx.run.get("trigger").and_then(|t| t.get("approvals")); - if names(trigger_approvals, request) || names(ctx.run.get("approvals"), request) { - return Some(ApprovalDecision::approved()); - } - None -} - -/// The item a settled review emits. -/// -/// `subject` is what the human actually signed off on — their edit when the -/// host's surface allowed one, otherwise exactly what was sent — so a -/// downstream node reads one field regardless. The original input is kept under -/// `input` so nothing is lost when the subject was a projection of it. -fn decided_item(request: &ApprovalRequest, decision: &ApprovalDecision, input: Value) -> Item { - Item::new(json!({ - "approved": decision.approved, - "subject": decision - .payload - .clone() - .unwrap_or_else(|| request.subject.value.clone()), - "subject_kind": request.subject.kind, - "edited": decision.payload.is_some(), - "decided_by": decision.decided_by, - "comment": decision.comment, - "request_id": request.request_id, - "input": input, - })) -} - -/// The slot state a settled review records, so `=nodes..decision.approved` -/// resolves from anywhere in the graph — including from a branch that did not -/// receive the emitted item (a `drop`ped rejection has no item at all). -fn decision_meta(decision: &ApprovalDecision, request: &ApprovalRequest) -> Value { - json!({ - "decision": { - "approved": decision.approved, - "decided_by": decision.decided_by, - "comment": decision.comment, - "request_id": request.request_id, - } - }) -} - #[async_trait] impl NodeExecutor for ApprovalNode { async fn execute(&self, ctx: NodeContext<'_>) -> Result { From 7b164730b914d34ea1c2a6d4702e75ed3fb98776 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:09:50 +0300 Subject: [PATCH 098/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index c2b578c..0858dc0 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -71,9 +71,6 @@ const DEFAULT_MAX_POLLS: u64 = 60; /// the way a `loop` node's iteration does. const POLLS_KEY: &str = "polls"; -/// The default rendering hint when the graph does not say what the subject is. -const DEFAULT_SUBJECT_KIND: &str = "json"; - /// How the node waits for a human. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WaitMode { From b789cfbfeaffc1418a15543250d75555f6b689c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:10:22 +0300 Subject: [PATCH 099/138] chore: files changed src/nodes/integration/approval_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index 63d5129..396f746 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -1,4 +1,5 @@ use super::*; +use super::approval_request::{decision_from_resume, names}; use serde_json::json; use crate::caps::mock::{MockApprovals, mock_capabilities, mock_capabilities_with_approvals}; From 6bcbf7ba42344f08da368ad6f54d6c19ad50aa60 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:10:39 +0300 Subject: [PATCH 100/138] chore: files changed src/nodes/integration/approval_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index 396f746..a480ef9 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -2,6 +2,7 @@ use super::*; use super::approval_request::{decision_from_resume, names}; use serde_json::json; +use crate::caps::ApprovalSubject; use crate::caps::mock::{MockApprovals, mock_capabilities, mock_capabilities_with_approvals}; use crate::compiler::compile; use crate::engine::{RunInput, resume, run}; From b5b8eb8db91c313a275745816a24f29bb18c2946 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:11:41 +0300 Subject: [PATCH 101/138] chore: files changed src/nodes/integration/approval_tests.rs,src/nodes/integration/approval_tests/ap Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_tests.rs | 496 +----------------- .../approval_tests_part_01_tests.rs | 285 ++++++++++ .../approval_tests_part_02_tests.rs | 209 ++++++++ 3 files changed, 496 insertions(+), 494 deletions(-) create mode 100644 src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs create mode 100644 src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index a480ef9..238234d 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -72,497 +72,5 @@ fn request(id: &str) -> ApprovalRequest { } } -/// Opposite default to `gate`, and deliberately so: a human review is a -/// minutes-to-days wait, and polling one burns super-steps for nothing. -#[test] -fn wait_mode_defaults_to_suspend() { - assert_eq!(WaitMode::from_config(&json!({})), WaitMode::Suspend); - assert_eq!( - WaitMode::from_config(&json!({ "wait_mode": "poll" })), - WaitMode::Poll - ); - assert_eq!( - WaitMode::from_config(&json!({ "wait_mode": "nonsense" })), - WaitMode::Suspend - ); -} - -/// Failing safe: an unrecognised policy must not drop a rejection on the floor -/// or fail a run that had a perfectly good recovery branch. -#[test] -fn reject_and_timeout_policies_default_conservatively() { - assert_eq!(OnReject::from_config(&json!({})), OnReject::Route); - assert_eq!( - OnReject::from_config(&json!({ "on_reject": "whatever" })), - OnReject::Route - ); - assert_eq!(OnTimeout::from_config(&json!({})), OnTimeout::Error); - assert_eq!( - OnTimeout::from_config(&json!({ "on_timeout": "eventually" })), - OnTimeout::Error - ); -} - -/// The engine's own denial shape wins over an approval delivered in the same -/// resume value — the same precedence a `requires_approval` gate applies. -#[test] -fn resume_denial_beats_an_approval_in_the_same_value() { - let req = request("run-1:review"); - let decision = decision_from_resume( - &json!({ "rejected": ["review"], "approved": ["review"] }), - &req, - ) - .expect("a decision"); - assert!(!decision.approved); -} - -#[test] -fn resume_reads_a_verdict_inline_or_nested() { - let req = request("run-1:review"); - - let inline = decision_from_resume( - &json!({ "approved": true, "decided_by": "ada", "comment": "ship it" }), - &req, - ) - .expect("a decision"); - assert!(inline.approved); - assert_eq!(inline.decided_by.as_deref(), Some("ada")); - assert_eq!(inline.comment.as_deref(), Some("ship it")); - - let nested = decision_from_resume( - &json!({ "decision": { "approved": false, "comment": "wrong link" } }), - &req, - ) - .expect("a decision"); - assert!(!nested.approved); - assert_eq!(nested.comment.as_deref(), Some("wrong link")); - - assert!( - decision_from_resume(&json!({ "unrelated": true }), &req).is_none(), - "a resume that says nothing about this review leaves it pending" - ); -} - -/// A review can be addressed by node id or by its own request id — a host that -/// tracks reviews by request id must be able to resume with that. -#[test] -fn a_review_answers_to_its_node_id_and_its_request_id() { - let req = request("run-1:review"); - assert!(names(Some(&json!(["review"])), &req)); - assert!(names(Some(&json!(["run-1:review"])), &req)); - assert!(!names(Some(&json!(["other"])), &req)); - assert!(!names(None, &req)); -} - -#[test] -fn positive_config_fields_fall_back_on_zero_or_garbage() { - assert_eq!(positive_u64(&json!({}), "max_polls", 7), 7); - assert_eq!(positive_u64(&json!({ "max_polls": 0 }), "max_polls", 7), 7); - assert_eq!(positive_u64(&json!({ "max_polls": 3 }), "max_polls", 7), 3); -} - -#[tokio::test] -async fn an_approved_review_emits_the_subject_on_the_approved_port() { - let graph = wf(json!({ - "title": "Publish this?", - "subject_kind": "url", - "subject": "=item.url", - })); - let compiled = compile(&graph).expect("compile"); - let out = run( - &compiled, - json!({ "url": "https://example.com/post/1" }), - &mock_capabilities(), - ) - .await - .expect("run"); - - let slot = &out.output["nodes"]["review"]; - assert_eq!(slot["port"], "approved"); - let item = &slot["items"][0]["json"]; - assert_eq!(item["approved"], json!(true)); - assert_eq!(item["subject"], "https://example.com/post/1"); - assert_eq!(item["subject_kind"], "url"); - assert_eq!(item["decided_by"], "mock-reviewer"); - assert_eq!( - slot["decision"]["approved"], - json!(true), - "the verdict is addressable as =nodes.review.decision.approved" - ); -} - -#[tokio::test] -async fn the_subject_defaults_to_the_item_that_arrived() { - let graph = wf(json!({ "title": "Look at this" })); - let compiled = compile(&graph).expect("compile"); - let out = run( - &compiled, - json!({ "draft": "hello world" }), - &mock_capabilities(), - ) - .await - .expect("run"); - - assert_eq!( - out.output["nodes"]["review"]["items"][0]["json"]["subject"], - json!({ "draft": "hello world" }) - ); -} - -#[tokio::test] -async fn a_rejection_routes_to_the_rejected_port_with_its_reason() { - let graph = wf(json!({ "title": "Publish this?" })); - let compiled = compile(&graph).expect("compile"); - let out = run( - &compiled, - json!({ "url": "https://example.com" }), - &mock_capabilities_with_approvals(MockApprovals::rejecting()), - ) - .await - .expect("run"); - - let slot = &out.output["nodes"]["review"]; - assert_eq!(slot["port"], "rejected"); - assert_eq!(slot["items"][0]["json"]["approved"], json!(false)); - assert_eq!(slot["items"][0]["json"]["comment"], "mock rejection"); -} - -#[tokio::test] -async fn on_reject_error_fails_the_node_with_the_reviewer_and_reason() { - let graph = wf(json!({ "on_reject": "error" })); - let compiled = compile(&graph).expect("compile"); - let err = run( - &compiled, - Value::Null, - &mock_capabilities_with_approvals(MockApprovals::rejecting()), - ) - .await - .expect_err("a rejection with on_reject: error fails the run"); - - let message = err.to_string(); - assert!(message.contains("mock-reviewer"), "got {message}"); - assert!(message.contains("mock rejection"), "got {message}"); -} - -#[tokio::test] -async fn a_pending_review_pauses_the_run_and_names_itself() { - let graph = wf(json!({ "title": "Publish this?" })); - let compiled = compile(&graph).expect("compile"); - let out = run( - &compiled, - Value::Null, - &mock_capabilities_with_approvals(MockApprovals::pending()), - ) - .await - .expect("run"); - - assert_eq!(out.pending_approvals, vec!["review".to_string()]); -} - -/// The zero-capability path: a host that wired no provider still gets a -/// working node, because waiting *is* the pause it already knows how to resume. -#[tokio::test] -async fn with_no_provider_the_node_reduces_to_a_pause_the_host_resumes() { - let caps = crate::caps::Capabilities { - approvals: None, - ..mock_capabilities() - }; - let graph = wf(json!({ "title": "Publish this?" })); - let compiled = compile(&graph).expect("compile"); - - let paused = run(&compiled, Value::Null, &caps).await.expect("run"); - assert_eq!(paused.pending_approvals, vec!["review".to_string()]); - - let resumed = resume(&compiled, Value::Null, vec!["review".to_string()], &caps) - .await - .expect("resume"); - assert!(resumed.pending_approvals.is_empty()); - assert_eq!(resumed.output["nodes"]["review"]["port"], "approved"); -} - -/// An approval already on the run input settles the review without the host -/// ever being asked — otherwise a resume would re-open a decided review. -#[tokio::test] -async fn a_listed_approval_settles_the_review_without_asking_the_host() { - let graph = wf(json!({ "title": "Publish this?" })); - let compiled = compile(&graph).expect("compile"); - let provider = std::sync::Arc::new(MockApprovals::pending()); - let caps = crate::caps::Capabilities { - approvals: Some(provider.clone()), - ..mock_capabilities() - }; - - let out = run( - &compiled, - RunInput::new(Value::Null).with_approvals(vec!["review".to_string()]), - &caps, - ) - .await - .expect("run"); - - assert_eq!(out.output["nodes"]["review"]["port"], "approved"); - assert!( - provider.requested().is_empty(), - "a settled review must not be handed to the provider again" - ); -} - -#[tokio::test] -async fn polling_spends_a_bounded_budget_then_follows_on_timeout() { - let graph = wf(json!({ - "wait_mode": "poll", - "poll_interval_ms": 1, - "max_polls": 2, - "on_timeout": "route", - })); - let compiled = compile(&graph).expect("compile"); - let provider = std::sync::Arc::new(MockApprovals::pending()); - let caps = crate::caps::Capabilities { - approvals: Some(provider.clone()), - ..mock_capabilities() - }; - - let out = run(&compiled, Value::Null, &caps).await.expect("run"); - - let slot = &out.output["nodes"]["review"]; - assert_eq!(slot["port"], "timeout"); - assert_eq!(slot["items"][0]["json"]["timed_out"], json!(true)); - - // Every activation asked about the SAME review: the create-or-fetch - // contract is what stops a poll loop notifying a human once per poll. - let ids = provider.requested(); - assert!(ids.len() > 1, "expected repeated polls, got {ids:?}"); - assert!( - ids.iter().all(|id| id == &ids[0]), - "every poll must reuse one request id, got {ids:?}" - ); - assert_eq!( - provider.cancelled(), - ids[..1].to_vec(), - "a timed-out review is withdrawn rather than left in a queue" - ); -} - -#[tokio::test] -async fn on_timeout_error_is_the_default_and_names_the_node() { - let graph = wf(json!({ "wait_mode": "poll", "poll_interval_ms": 1, "max_polls": 1 })); - let compiled = compile(&graph).expect("compile"); - let err = run( - &compiled, - Value::Null, - &mock_capabilities_with_approvals(MockApprovals::pending()), - ) - .await - .expect_err("an unanswered review fails by default"); - assert!(err.to_string().contains("review"), "got {err}"); -} - -/// Without an explicit `request_id` and without a run-scoped identity the node -/// must refuse to guess: falling back to the bare node id would let a later -/// run of the same graph reuse an earlier run's decision through the -/// provider's create-or-fetch contract, and route an unreviewed subject -/// straight through `approved`. -#[tokio::test] -async fn a_missing_request_id_and_run_id_is_a_configuration_error() { - let graph = wf_raw(json!({ "title": "Publish this?" })); - let compiled = compile(&graph).expect("compile"); - let err = run(&compiled, Value::Null, &mock_capabilities()) - .await - .expect_err("no request_id and no run id must be refused"); - let message = err.to_string(); - assert!(message.contains("request_id"), "got {message}"); -} - -/// With a run-scoped id available (`trigger.run_id`, in the shape -/// `run.trigger.run_id` a host's trigger payload takes), the node derives a -/// stable `":"` request id without needing an explicit -/// `config.request_id`. -#[tokio::test] -async fn a_run_id_in_the_trigger_derives_a_stable_request_id() { - let graph = wf_raw(json!({ "title": "Publish this?" })); - let compiled = compile(&graph).expect("compile"); - let out = run( - &compiled, - json!({ "run_id": "run-42" }), - &mock_capabilities(), - ) - .await - .expect("a run id makes the request_id derivable"); - - assert_eq!( - out.output["nodes"]["review"]["items"][0]["json"]["request_id"], - "run-42:review" - ); -} - -/// `on_reject: "drop"` emits nothing — a regression that accidentally emitted -/// an item here would go unnoticed by every other rejection test, which all -/// use `route`. -#[tokio::test] -async fn on_reject_drop_emits_nothing_but_still_records_the_decision() { - let graph = wf(json!({ "on_reject": "drop" })); - let compiled = compile(&graph).expect("compile"); - let out = run( - &compiled, - Value::Null, - &mock_capabilities_with_approvals(MockApprovals::rejecting()), - ) - .await - .expect("run"); - - let slot = &out.output["nodes"]["review"]; - assert_eq!( - slot["items"], - json!([]), - "on_reject: drop must not emit an item" - ); - assert_eq!( - slot["decision"]["approved"], - json!(false), - "the verdict stays addressable as =nodes.review.decision.approved even when dropped" - ); -} - -/// `on_timeout: "reject"` hands the timed-out review to the `on_reject` -/// policy. Covers all three `on_reject` sub-paths so a regression in any one -/// of them is caught here rather than by a host. -#[tokio::test] -async fn on_timeout_reject_follows_the_on_reject_policy() { - // route: timed_out item lands on `rejected`, with the fields a settled - // review always carries (not just `approved`/`timed_out`). - let graph = wf(json!({ - "wait_mode": "poll", - "poll_interval_ms": 1, - "max_polls": 1, - "on_timeout": "reject", - })); - let compiled = compile(&graph).expect("compile"); - let out = run( - &compiled, - Value::Null, - &mock_capabilities_with_approvals(MockApprovals::pending()), - ) - .await - .expect("run"); - let slot = &out.output["nodes"]["review"]; - assert_eq!(slot["port"], "rejected"); - let item = &slot["items"][0]["json"]; - assert_eq!(item["approved"], json!(false)); - assert_eq!(item["timed_out"], json!(true)); - assert_eq!(item["edited"], json!(false)); - assert_eq!(item["decided_by"], Value::Null); - assert!( - item["comment"].as_str().is_some_and(|c| !c.is_empty()), - "a timed-out rejection still carries a comment explaining why, got {item:?}" - ); - assert_eq!( - slot["decision"]["approved"], - json!(false), - "=nodes.review.decision.approved must resolve to false after a timeout, not be absent" - ); - - // drop: nothing emitted. - let graph = wf(json!({ - "wait_mode": "poll", - "poll_interval_ms": 1, - "max_polls": 1, - "on_timeout": "reject", - "on_reject": "drop", - })); - let compiled = compile(&graph).expect("compile"); - let out = run( - &compiled, - Value::Null, - &mock_capabilities_with_approvals(MockApprovals::pending()), - ) - .await - .expect("run"); - assert_eq!(out.output["nodes"]["review"]["items"], json!([])); - - // error: the node fails rather than silently continuing. - let graph = wf(json!({ - "wait_mode": "poll", - "poll_interval_ms": 1, - "max_polls": 1, - "on_timeout": "reject", - "on_reject": "error", - })); - let compiled = compile(&graph).expect("compile"); - let err = run( - &compiled, - Value::Null, - &mock_capabilities_with_approvals(MockApprovals::pending()), - ) - .await - .expect_err("on_timeout: reject with on_reject: error must fail the node"); - assert!(err.to_string().contains("on_reject"), "got {err}"); -} - -/// A decision that carries the reviewer's own edit (`payload`) drives -/// `subject` to that edit and marks `edited: true` — the feature this node -/// exists for. `decision_from_resume` and `decided_item` are exercised -/// separately elsewhere; this checks the two compose correctly, matching what -/// a `{"decision": {..., "payload": ...}}` resume value produces end to end. -#[test] -fn a_decision_with_a_payload_edits_the_subject() { - let req = request("run-1:review"); - let resume_value = json!({ - "decision": { - "approved": true, - "decided_by": "ada", - "payload": "https://example.com/edited", - } - }); - let decision = decision_from_resume(&resume_value, &req).expect("a decision"); - assert!(decision.approved); - assert_eq!(decision.payload, Some(json!("https://example.com/edited"))); - - let item = decided_item( - &req, - &decision, - json!({ "url": "https://example.com/original" }), - ); - let json = item.json; - assert_eq!(json["approved"], json!(true)); - assert_eq!(json["subject"], "https://example.com/edited"); - assert_eq!(json["edited"], json!(true)); - assert_eq!(json["decided_by"], "ada"); - assert_eq!( - json["input"], - json!({ "url": "https://example.com/original" }) - ); -} - -/// When a resume (or a listed approval) settles the review, any provider card -/// opened by an earlier `decide` call must be withdrawn — otherwise the -/// provider's queue keeps a stale entry for a review the run already closed. -#[tokio::test] -async fn a_resume_decision_withdraws_the_provider_card() { - let graph = wf(json!({ "title": "Publish this?" })); - let compiled = compile(&graph).expect("compile"); - let provider = std::sync::Arc::new(MockApprovals::pending()); - let caps = crate::caps::Capabilities { - approvals: Some(provider.clone()), - ..mock_capabilities() - }; - - // First activation asks the provider and gets Pending, opening a card. - let paused = run(&compiled, Value::Null, &caps).await.expect("run"); - assert_eq!(paused.pending_approvals, vec!["review".to_string()]); - assert!( - !provider.requested().is_empty(), - "the provider must have been asked at least once" - ); - assert!(provider.cancelled().is_empty(), "no reason to cancel yet"); - - // A resume delivers the decision directly (bypassing the provider), so the - // node must withdraw the provider's now-stale card rather than leave it. - let resumed = resume(&compiled, Value::Null, vec!["review".to_string()], &caps) - .await - .expect("resume"); - assert_eq!(resumed.output["nodes"]["review"]["port"], "approved"); - assert!( - !provider.cancelled().is_empty(), - "the provider's card must be withdrawn once a resume settles the review" - ); -} +include!("approval_tests/approval_tests_part_01_tests.rs"); +include!("approval_tests/approval_tests_part_02_tests.rs"); diff --git a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs new file mode 100644 index 0000000..da0dc65 --- /dev/null +++ b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs @@ -0,0 +1,285 @@ +/// Opposite default to `gate`, and deliberately so: a human review is a +/// minutes-to-days wait, and polling one burns super-steps for nothing. +#[test] +fn wait_mode_defaults_to_suspend() { + assert_eq!(WaitMode::from_config(&json!({})), WaitMode::Suspend); + assert_eq!( + WaitMode::from_config(&json!({ "wait_mode": "poll" })), + WaitMode::Poll + ); + assert_eq!( + WaitMode::from_config(&json!({ "wait_mode": "nonsense" })), + WaitMode::Suspend + ); +} + +/// Failing safe: an unrecognised policy must not drop a rejection on the floor +/// or fail a run that had a perfectly good recovery branch. +#[test] +fn reject_and_timeout_policies_default_conservatively() { + assert_eq!(OnReject::from_config(&json!({})), OnReject::Route); + assert_eq!( + OnReject::from_config(&json!({ "on_reject": "whatever" })), + OnReject::Route + ); + assert_eq!(OnTimeout::from_config(&json!({})), OnTimeout::Error); + assert_eq!( + OnTimeout::from_config(&json!({ "on_timeout": "eventually" })), + OnTimeout::Error + ); +} + +/// The engine's own denial shape wins over an approval delivered in the same +/// resume value — the same precedence a `requires_approval` gate applies. +#[test] +fn resume_denial_beats_an_approval_in_the_same_value() { + let req = request("run-1:review"); + let decision = decision_from_resume( + &json!({ "rejected": ["review"], "approved": ["review"] }), + &req, + ) + .expect("a decision"); + assert!(!decision.approved); +} + +#[test] +fn resume_reads_a_verdict_inline_or_nested() { + let req = request("run-1:review"); + + let inline = decision_from_resume( + &json!({ "approved": true, "decided_by": "ada", "comment": "ship it" }), + &req, + ) + .expect("a decision"); + assert!(inline.approved); + assert_eq!(inline.decided_by.as_deref(), Some("ada")); + assert_eq!(inline.comment.as_deref(), Some("ship it")); + + let nested = decision_from_resume( + &json!({ "decision": { "approved": false, "comment": "wrong link" } }), + &req, + ) + .expect("a decision"); + assert!(!nested.approved); + assert_eq!(nested.comment.as_deref(), Some("wrong link")); + + assert!( + decision_from_resume(&json!({ "unrelated": true }), &req).is_none(), + "a resume that says nothing about this review leaves it pending" + ); +} + +/// A review can be addressed by node id or by its own request id — a host that +/// tracks reviews by request id must be able to resume with that. +#[test] +fn a_review_answers_to_its_node_id_and_its_request_id() { + let req = request("run-1:review"); + assert!(names(Some(&json!(["review"])), &req)); + assert!(names(Some(&json!(["run-1:review"])), &req)); + assert!(!names(Some(&json!(["other"])), &req)); + assert!(!names(None, &req)); +} + +#[test] +fn positive_config_fields_fall_back_on_zero_or_garbage() { + assert_eq!(positive_u64(&json!({}), "max_polls", 7), 7); + assert_eq!(positive_u64(&json!({ "max_polls": 0 }), "max_polls", 7), 7); + assert_eq!(positive_u64(&json!({ "max_polls": 3 }), "max_polls", 7), 3); +} + +#[tokio::test] +async fn an_approved_review_emits_the_subject_on_the_approved_port() { + let graph = wf(json!({ + "title": "Publish this?", + "subject_kind": "url", + "subject": "=item.url", + })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + json!({ "url": "https://example.com/post/1" }), + &mock_capabilities(), + ) + .await + .expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!(slot["port"], "approved"); + let item = &slot["items"][0]["json"]; + assert_eq!(item["approved"], json!(true)); + assert_eq!(item["subject"], "https://example.com/post/1"); + assert_eq!(item["subject_kind"], "url"); + assert_eq!(item["decided_by"], "mock-reviewer"); + assert_eq!( + slot["decision"]["approved"], + json!(true), + "the verdict is addressable as =nodes.review.decision.approved" + ); +} + +#[tokio::test] +async fn the_subject_defaults_to_the_item_that_arrived() { + let graph = wf(json!({ "title": "Look at this" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + json!({ "draft": "hello world" }), + &mock_capabilities(), + ) + .await + .expect("run"); + + assert_eq!( + out.output["nodes"]["review"]["items"][0]["json"]["subject"], + json!({ "draft": "hello world" }) + ); +} + +#[tokio::test] +async fn a_rejection_routes_to_the_rejected_port_with_its_reason() { + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + json!({ "url": "https://example.com" }), + &mock_capabilities_with_approvals(MockApprovals::rejecting()), + ) + .await + .expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!(slot["port"], "rejected"); + assert_eq!(slot["items"][0]["json"]["approved"], json!(false)); + assert_eq!(slot["items"][0]["json"]["comment"], "mock rejection"); +} + +#[tokio::test] +async fn on_reject_error_fails_the_node_with_the_reviewer_and_reason() { + let graph = wf(json!({ "on_reject": "error" })); + let compiled = compile(&graph).expect("compile"); + let err = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::rejecting()), + ) + .await + .expect_err("a rejection with on_reject: error fails the run"); + + let message = err.to_string(); + assert!(message.contains("mock-reviewer"), "got {message}"); + assert!(message.contains("mock rejection"), "got {message}"); +} + +#[tokio::test] +async fn a_pending_review_pauses_the_run_and_names_itself() { + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect("run"); + + assert_eq!(out.pending_approvals, vec!["review".to_string()]); +} + +/// The zero-capability path: a host that wired no provider still gets a +/// working node, because waiting *is* the pause it already knows how to resume. +#[tokio::test] +async fn with_no_provider_the_node_reduces_to_a_pause_the_host_resumes() { + let caps = crate::caps::Capabilities { + approvals: None, + ..mock_capabilities() + }; + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + + let paused = run(&compiled, Value::Null, &caps).await.expect("run"); + assert_eq!(paused.pending_approvals, vec!["review".to_string()]); + + let resumed = resume(&compiled, Value::Null, vec!["review".to_string()], &caps) + .await + .expect("resume"); + assert!(resumed.pending_approvals.is_empty()); + assert_eq!(resumed.output["nodes"]["review"]["port"], "approved"); +} + +/// An approval already on the run input settles the review without the host +/// ever being asked — otherwise a resume would re-open a decided review. +#[tokio::test] +async fn a_listed_approval_settles_the_review_without_asking_the_host() { + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let provider = std::sync::Arc::new(MockApprovals::pending()); + let caps = crate::caps::Capabilities { + approvals: Some(provider.clone()), + ..mock_capabilities() + }; + + let out = run( + &compiled, + RunInput::new(Value::Null).with_approvals(vec!["review".to_string()]), + &caps, + ) + .await + .expect("run"); + + assert_eq!(out.output["nodes"]["review"]["port"], "approved"); + assert!( + provider.requested().is_empty(), + "a settled review must not be handed to the provider again" + ); +} + +#[tokio::test] +async fn polling_spends_a_bounded_budget_then_follows_on_timeout() { + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 2, + "on_timeout": "route", + })); + let compiled = compile(&graph).expect("compile"); + let provider = std::sync::Arc::new(MockApprovals::pending()); + let caps = crate::caps::Capabilities { + approvals: Some(provider.clone()), + ..mock_capabilities() + }; + + let out = run(&compiled, Value::Null, &caps).await.expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!(slot["port"], "timeout"); + assert_eq!(slot["items"][0]["json"]["timed_out"], json!(true)); + + // Every activation asked about the SAME review: the create-or-fetch + // contract is what stops a poll loop notifying a human once per poll. + let ids = provider.requested(); + assert!(ids.len() > 1, "expected repeated polls, got {ids:?}"); + assert!( + ids.iter().all(|id| id == &ids[0]), + "every poll must reuse one request id, got {ids:?}" + ); + assert_eq!( + provider.cancelled(), + ids[..1].to_vec(), + "a timed-out review is withdrawn rather than left in a queue" + ); +} + +#[tokio::test] +async fn on_timeout_error_is_the_default_and_names_the_node() { + let graph = wf(json!({ "wait_mode": "poll", "poll_interval_ms": 1, "max_polls": 1 })); + let compiled = compile(&graph).expect("compile"); + let err = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect_err("an unanswered review fails by default"); + assert!(err.to_string().contains("review"), "got {err}"); +} + diff --git a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs new file mode 100644 index 0000000..5d7833f --- /dev/null +++ b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs @@ -0,0 +1,209 @@ +/// Without an explicit `request_id` and without a run-scoped identity the node +/// must refuse to guess: falling back to the bare node id would let a later +/// run of the same graph reuse an earlier run's decision through the +/// provider's create-or-fetch contract, and route an unreviewed subject +/// straight through `approved`. +#[tokio::test] +async fn a_missing_request_id_and_run_id_is_a_configuration_error() { + let graph = wf_raw(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let err = run(&compiled, Value::Null, &mock_capabilities()) + .await + .expect_err("no request_id and no run id must be refused"); + let message = err.to_string(); + assert!(message.contains("request_id"), "got {message}"); +} + +/// With a run-scoped id available (`trigger.run_id`, in the shape +/// `run.trigger.run_id` a host's trigger payload takes), the node derives a +/// stable `":"` request id without needing an explicit +/// `config.request_id`. +#[tokio::test] +async fn a_run_id_in_the_trigger_derives_a_stable_request_id() { + let graph = wf_raw(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + json!({ "run_id": "run-42" }), + &mock_capabilities(), + ) + .await + .expect("a run id makes the request_id derivable"); + + assert_eq!( + out.output["nodes"]["review"]["items"][0]["json"]["request_id"], + "run-42:review" + ); +} + +/// `on_reject: "drop"` emits nothing — a regression that accidentally emitted +/// an item here would go unnoticed by every other rejection test, which all +/// use `route`. +#[tokio::test] +async fn on_reject_drop_emits_nothing_but_still_records_the_decision() { + let graph = wf(json!({ "on_reject": "drop" })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::rejecting()), + ) + .await + .expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!( + slot["items"], + json!([]), + "on_reject: drop must not emit an item" + ); + assert_eq!( + slot["decision"]["approved"], + json!(false), + "the verdict stays addressable as =nodes.review.decision.approved even when dropped" + ); +} + +/// `on_timeout: "reject"` hands the timed-out review to the `on_reject` +/// policy. Covers all three `on_reject` sub-paths so a regression in any one +/// of them is caught here rather than by a host. +#[tokio::test] +async fn on_timeout_reject_follows_the_on_reject_policy() { + // route: timed_out item lands on `rejected`, with the fields a settled + // review always carries (not just `approved`/`timed_out`). + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 1, + "on_timeout": "reject", + })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect("run"); + let slot = &out.output["nodes"]["review"]; + assert_eq!(slot["port"], "rejected"); + let item = &slot["items"][0]["json"]; + assert_eq!(item["approved"], json!(false)); + assert_eq!(item["timed_out"], json!(true)); + assert_eq!(item["edited"], json!(false)); + assert_eq!(item["decided_by"], Value::Null); + assert!( + item["comment"].as_str().is_some_and(|c| !c.is_empty()), + "a timed-out rejection still carries a comment explaining why, got {item:?}" + ); + assert_eq!( + slot["decision"]["approved"], + json!(false), + "=nodes.review.decision.approved must resolve to false after a timeout, not be absent" + ); + + // drop: nothing emitted. + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 1, + "on_timeout": "reject", + "on_reject": "drop", + })); + let compiled = compile(&graph).expect("compile"); + let out = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect("run"); + assert_eq!(out.output["nodes"]["review"]["items"], json!([])); + + // error: the node fails rather than silently continuing. + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 1, + "on_timeout": "reject", + "on_reject": "error", + })); + let compiled = compile(&graph).expect("compile"); + let err = run( + &compiled, + Value::Null, + &mock_capabilities_with_approvals(MockApprovals::pending()), + ) + .await + .expect_err("on_timeout: reject with on_reject: error must fail the node"); + assert!(err.to_string().contains("on_reject"), "got {err}"); +} + +/// A decision that carries the reviewer's own edit (`payload`) drives +/// `subject` to that edit and marks `edited: true` — the feature this node +/// exists for. `decision_from_resume` and `decided_item` are exercised +/// separately elsewhere; this checks the two compose correctly, matching what +/// a `{"decision": {..., "payload": ...}}` resume value produces end to end. +#[test] +fn a_decision_with_a_payload_edits_the_subject() { + let req = request("run-1:review"); + let resume_value = json!({ + "decision": { + "approved": true, + "decided_by": "ada", + "payload": "https://example.com/edited", + } + }); + let decision = decision_from_resume(&resume_value, &req).expect("a decision"); + assert!(decision.approved); + assert_eq!(decision.payload, Some(json!("https://example.com/edited"))); + + let item = decided_item( + &req, + &decision, + json!({ "url": "https://example.com/original" }), + ); + let json = item.json; + assert_eq!(json["approved"], json!(true)); + assert_eq!(json["subject"], "https://example.com/edited"); + assert_eq!(json["edited"], json!(true)); + assert_eq!(json["decided_by"], "ada"); + assert_eq!( + json["input"], + json!({ "url": "https://example.com/original" }) + ); +} + +/// When a resume (or a listed approval) settles the review, any provider card +/// opened by an earlier `decide` call must be withdrawn — otherwise the +/// provider's queue keeps a stale entry for a review the run already closed. +#[tokio::test] +async fn a_resume_decision_withdraws_the_provider_card() { + let graph = wf(json!({ "title": "Publish this?" })); + let compiled = compile(&graph).expect("compile"); + let provider = std::sync::Arc::new(MockApprovals::pending()); + let caps = crate::caps::Capabilities { + approvals: Some(provider.clone()), + ..mock_capabilities() + }; + + // First activation asks the provider and gets Pending, opening a card. + let paused = run(&compiled, Value::Null, &caps).await.expect("run"); + assert_eq!(paused.pending_approvals, vec!["review".to_string()]); + assert!( + !provider.requested().is_empty(), + "the provider must have been asked at least once" + ); + assert!(provider.cancelled().is_empty(), "no reason to cancel yet"); + + // A resume delivers the decision directly (bypassing the provider), so the + // node must withdraw the provider's now-stale card rather than leave it. + let resumed = resume(&compiled, Value::Null, vec!["review".to_string()], &caps) + .await + .expect("resume"); + assert_eq!(resumed.output["nodes"]["review"]["port"], "approved"); + assert!( + !provider.cancelled().is_empty(), + "the provider's card must be withdrawn once a resume settles the review" + ); +} From 8d5dec200d8929e494dd45bef6b0bb1a74f39e75 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:12:14 +0300 Subject: [PATCH 102/138] chore: files changed src/nodes/integration/approval_request.rs,src/nodes/integration/approval_tests. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 5 ++++- src/nodes/integration/approval_tests.rs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index c29ef5a..cf88e09 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -152,7 +152,10 @@ pub(super) fn decision_from_resume( /// The decision already in hand before the host is asked: a resume value, or /// this node's id on the run's approvals list. -pub(super) fn delivered(ctx: &NodeContext<'_>, request: &ApprovalRequest) -> Option { +pub(super) fn delivered( + ctx: &NodeContext<'_>, + request: &ApprovalRequest, +) -> Option { if let Some(decision) = ctx .resume .as_ref() diff --git a/src/nodes/integration/approval_tests.rs b/src/nodes/integration/approval_tests.rs index 238234d..16f52f3 100644 --- a/src/nodes/integration/approval_tests.rs +++ b/src/nodes/integration/approval_tests.rs @@ -1,5 +1,5 @@ -use super::*; use super::approval_request::{decision_from_resume, names}; +use super::*; use serde_json::json; use crate::caps::ApprovalSubject; From b61e8dd7ab7a77c0e3edbffe85f8d8a8aa9cbed9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:13:55 +0300 Subject: [PATCH 103/138] chore: files changed src/validate.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/validate.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/validate.rs b/src/validate.rs index 82dc293..7943fec 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -451,16 +451,16 @@ pub fn validate_all(graph: &WorkflowGraph) -> Vec { // Reviewer handles are opaque to the crate, but their *shape* is not: // a bare string here (the natural mistake for a single reviewer) would // be read as "nobody", and the review would go to an empty audience - // with no error anywhere. + // with no error anywhere. An empty array reaches the same audience of + // nobody just as silently, so it is refused for the same reason. if let Some(assignees) = node.config.get("assignees") { - if !assignees - .as_array() - .is_some_and(|values| values.iter().all(Value::is_string)) - { + if !assignees.as_array().is_some_and(|values| { + !values.is_empty() && values.iter().all(Value::is_string) + }) { errors.push(ValidationError::InvalidNodeConfig { node: node.id.clone(), - reason: "approval node `assignees` must be an array of strings (a single \ - reviewer is a one-element array)" + reason: "approval node `assignees` must be a non-empty array of strings (a \ + single reviewer is a one-element array)" .to_string(), }); } From 784d046d15315bb5b3372746079ef7516d3a0d80 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:14:06 +0300 Subject: [PATCH 104/138] chore: files changed src/validate_tests/validate_tests_part_03_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../validate_tests_part_03_tests.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/validate_tests/validate_tests_part_03_tests.rs b/src/validate_tests/validate_tests_part_03_tests.rs index d4451bb..a8cffe7 100644 --- a/src/validate_tests/validate_tests_part_03_tests.rs +++ b/src/validate_tests/validate_tests_part_03_tests.rs @@ -214,3 +214,18 @@ fn approval_assignees_must_be_an_array_of_strings() { .is_empty() ); } + +/// An empty array reaches the same silent "nobody reviews this" audience a +/// bare string does, so it is refused for the same reason. +#[test] +fn approval_assignees_must_not_be_an_empty_array() { + let errors = validate_all(&approval_graph(serde_json::json!({ "assignees": [] }))); + assert!( + errors.iter().any(|e| matches!( + e, + ValidationError::InvalidNodeConfig { node, reason } + if node == "review" && reason.contains("assignees") + )), + "an empty `assignees` array must be refused, got {errors:?}" + ); +} From af2b10bd1afc45b03ac3d06b850b67e067ea9c9f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:14:27 +0300 Subject: [PATCH 105/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../approval_tests_part_01_tests.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs index da0dc65..48e1e10 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs @@ -269,6 +269,64 @@ async fn polling_spends_a_bounded_budget_then_follows_on_timeout() { ); } +/// A provider that stays `Pending` for its first `pending_calls` calls, then +/// decides — the shape a real review surface has (nobody has looked yet, then +/// someone does), which [`MockApprovals::pending`] alone cannot exercise since +/// it never resolves. +struct ResolvesAfter { + pending_calls: usize, + calls: std::sync::atomic::AtomicUsize, +} + +#[async_trait::async_trait] +impl crate::caps::ApprovalProvider for ResolvesAfter { + async fn decide( + &self, + _request: &crate::caps::ApprovalRequest, + ) -> crate::error::Result { + let call = self + .calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if call < self.pending_calls { + return Ok(crate::caps::ApprovalOutcome::Pending); + } + Ok(crate::caps::ApprovalOutcome::Decided( + crate::caps::ApprovalDecision { + decided_by: Some("late-reviewer".to_string()), + ..crate::caps::ApprovalDecision::approved() + }, + )) + } +} + +/// A poll that finally finds a `Decided` outcome must stop polling and take +/// the verdict — proving the polling loop actually reads what `decide` +/// returns each time, rather than always re-entering until the budget runs +/// out. +#[tokio::test] +async fn a_polling_review_resolves_once_the_provider_decides() { + let graph = wf(json!({ + "wait_mode": "poll", + "poll_interval_ms": 1, + "max_polls": 10, + })); + let compiled = compile(&graph).expect("compile"); + let provider = ResolvesAfter { + pending_calls: 2, + calls: std::sync::atomic::AtomicUsize::new(0), + }; + let caps = mock_capabilities_with_approvals(provider); + + let out = run(&compiled, Value::Null, &caps).await.expect("run"); + + let slot = &out.output["nodes"]["review"]; + assert_eq!( + slot["port"], "approved", + "a late decision must still be taken rather than timing out, got {slot:?}" + ); + assert_eq!(slot["items"][0]["json"]["decided_by"], "late-reviewer"); +} + #[tokio::test] async fn on_timeout_error_is_the_default_and_names_the_node() { let graph = wf(json!({ "wait_mode": "poll", "poll_interval_ms": 1, "max_polls": 1 })); From 772d3df52141f1180da45583ab11478fd1356345 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:14:54 +0300 Subject: [PATCH 106/138] chore: files changed src/validate.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/validate.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/validate.rs b/src/validate.rs index 7943fec..c816be3 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -454,9 +454,10 @@ pub fn validate_all(graph: &WorkflowGraph) -> Vec { // with no error anywhere. An empty array reaches the same audience of // nobody just as silently, so it is refused for the same reason. if let Some(assignees) = node.config.get("assignees") { - if !assignees.as_array().is_some_and(|values| { - !values.is_empty() && values.iter().all(Value::is_string) - }) { + if !assignees + .as_array() + .is_some_and(|values| !values.is_empty() && values.iter().all(Value::is_string)) + { errors.push(ValidationError::InvalidNodeConfig { node: node.id.clone(), reason: "approval node `assignees` must be a non-empty array of strings (a \ From f67a7945e01373a5e7f77dfb96df88d84f4f39da Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:28:39 +0300 Subject: [PATCH 107/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index cf88e09..42c393b 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -124,6 +124,16 @@ pub(super) fn names(list: Option<&Value>, request: &ApprovalRequest) -> bool { /// first, so a denial always beats an approval delivered in the same value), /// the mirror `{"approved": […]}`, and a full verdict object — either /// inline or nested under `decision`. +/// +/// The array forms above are always scoped to this request via [`names`], the +/// same way [`super::gate`](crate::nodes::integration::gate) scopes its own +/// `approved` array — required, because several nodes can be interrupted at +/// once and a resume value is not addressed to just one of them. The inline +/// verdict-object form carries no such array to check, so it is accepted +/// unscoped **only** when it does not itself name a different request — +/// matching the same "single-interrupt convenience" precedent +/// `engine::build::activation`'s bare `Value::Bool(true)` case documents, but +/// without silently absorbing a verdict a host explicitly addressed elsewhere. pub(super) fn decision_from_resume( resume: &Value, request: &ApprovalRequest, @@ -141,6 +151,16 @@ pub(super) fn decision_from_resume( } let verdict = resume.get("decision").unwrap_or(resume); + if let Some(named) = verdict + .get("node_id") + .or_else(|| verdict.get("request_id")) + .and_then(Value::as_str) + && named != request.node_id + && named != request.request_id + { + // Explicitly addressed to a different node's review; not ours to take. + return None; + } let approved = verdict.get("approved").and_then(Value::as_bool)?; Some(ApprovalDecision { approved, From 249821219d08beb34992c90f6ad1980211b6fd13 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:28:46 +0300 Subject: [PATCH 108/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index 42c393b..f442fd5 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -155,11 +155,11 @@ pub(super) fn decision_from_resume( .get("node_id") .or_else(|| verdict.get("request_id")) .and_then(Value::as_str) - && named != request.node_id - && named != request.request_id { - // Explicitly addressed to a different node's review; not ours to take. - return None; + if named != request.node_id && named != request.request_id { + // Explicitly addressed to a different node's review; not ours to take. + return None; + } } let approved = verdict.get("approved").and_then(Value::as_bool)?; Some(ApprovalDecision { From 7c1acb7cdfc8c2065f1e25537ea90e4e106e4a18 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:29:42 +0300 Subject: [PATCH 109/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index 7e18ed4..d0be007 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -37,8 +37,8 @@ //! graph under test never fails because a capability was left unprogrammed. use std::collections::HashMap; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use serde_json::Value; From c7af224dea4e85d4339502855e945fbb0bd53133 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:29:48 +0300 Subject: [PATCH 110/138] chore: files changed src/testkit/mocks.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/testkit/mocks.rs b/src/testkit/mocks.rs index d0be007..30ffada 100644 --- a/src/testkit/mocks.rs +++ b/src/testkit/mocks.rs @@ -242,6 +242,15 @@ pub struct MockCaps { rules: Vec, log: Arc, workflows: HashMap, + /// Backing map for the [`StateStore`](crate::caps::StateStore) impl. + /// + /// Lives here, not on each [`Double`](double::Double), because + /// [`capabilities_for_node`](Self::capabilities_for_node) builds a fresh + /// `Double` per node activation (so the call log can attribute calls to + /// the right node); a per-`Double` map would make state invisible across + /// activations — including a node's own later activation — defeating the + /// one job a state store has. + state: Mutex>, } impl MockCaps { From 620c6f888cfce9ada4dd72b323e17c8b64bf09be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:30:01 +0300 Subject: [PATCH 111/138] chore: files changed src/testkit/mocks_double.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_double.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/testkit/mocks_double.rs b/src/testkit/mocks_double.rs index 321d7aa..669ca9c 100644 --- a/src/testkit/mocks_double.rs +++ b/src/testkit/mocks_double.rs @@ -31,18 +31,11 @@ pub(super) struct Double { mocks: Arc, /// The node this double was scoped to, stamped onto every call it logs. node_id: Option, - /// Backing map for the [`StateStore`] impl, which is the one capability - /// whose whole job is to remember. - state: Mutex>, } impl Double { pub(super) fn new(mocks: Arc, node_id: Option) -> Self { - Self { - mocks, - node_id, - state: Mutex::new(HashMap::new()), - } + Self { mocks, node_id } } /// Consult the rules, log whatever happens, and return it. From 354ef7d062612b952b25d4b58d2fb002843d0efd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:30:05 +0300 Subject: [PATCH 112/138] chore: files changed src/testkit/mocks_double.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_double.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/testkit/mocks_double.rs b/src/testkit/mocks_double.rs index 669ca9c..0358d93 100644 --- a/src/testkit/mocks_double.rs +++ b/src/testkit/mocks_double.rs @@ -280,6 +280,7 @@ impl MemoryProvider for Double { impl StateStore for Double { async fn load(&self, key: &str) -> Result> { let stored = self + .mocks .state .lock() .expect("mock state poisoned") From 5bb9f989bd8997c62949e3726501680b389c62d5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:30:10 +0300 Subject: [PATCH 113/138] chore: files changed src/testkit/mocks_double.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_double.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/testkit/mocks_double.rs b/src/testkit/mocks_double.rs index 0358d93..7645248 100644 --- a/src/testkit/mocks_double.rs +++ b/src/testkit/mocks_double.rs @@ -300,7 +300,8 @@ impl StateStore for Double { } async fn store(&self, key: &str, value: Value) -> Result<()> { - self.state + self.mocks + .state .lock() .expect("mock state poisoned") .insert(key.to_string(), value.clone()); From 4765a70fbcad13bc303e6c6a0b24f9e52a798bb2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:30:43 +0300 Subject: [PATCH 114/138] chore: files changed src/testkit/mocks_double.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_double.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/testkit/mocks_double.rs b/src/testkit/mocks_double.rs index 7645248..ce014eb 100644 --- a/src/testkit/mocks_double.rs +++ b/src/testkit/mocks_double.rs @@ -347,8 +347,12 @@ impl ApprovalProvider for Double { ) .await?; // A programmed answer may be the whole verdict or just the bit the test - // cares about, as with `ShellRunner` above. - if value.get("status").and_then(Value::as_str) == Some("pending") { + // cares about, as with `ShellRunner` above — including the bare string + // `"pending"` `on_approval`'s own doc comment advertises as shorthand + // for "nobody has got to this review yet". + let is_pending = value.as_str() == Some("pending") + || value.get("status").and_then(Value::as_str) == Some("pending"); + if is_pending { return Ok(ApprovalOutcome::Pending); } Ok(ApprovalOutcome::Decided(ApprovalDecision { From a516b5e310062e79906b9f16d144a085c6d227ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:31:10 +0300 Subject: [PATCH 115/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 37 ++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index f442fd5..215c4a6 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -74,6 +74,31 @@ pub(super) fn build_request(ctx: &NodeContext<'_>, config: &Value) -> Result = config + .get("assignees") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + if config.get("assignees").is_some_and(Value::is_array) && assignees.is_empty() { + return Err(EngineError::Capability(format!( + "approval node {:?}: `assignees` resolved to an empty array; a review with nobody \ + assigned can never be resolved", + ctx.node.id + ))); + } + Ok(ApprovalRequest { request_id, node_id: ctx.node.id.clone(), @@ -88,17 +113,7 @@ pub(super) fn build_request(ctx: &NodeContext<'_>, config: &Value) -> Result Date: Sat, 15 Aug 2026 22:31:37 +0300 Subject: [PATCH 116/138] chore: files changed src/testkit/mocks_double.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_double.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/testkit/mocks_double.rs b/src/testkit/mocks_double.rs index ce014eb..60150df 100644 --- a/src/testkit/mocks_double.rs +++ b/src/testkit/mocks_double.rs @@ -5,8 +5,7 @@ //! Split out of `mocks.rs` to keep that file under the repository's //! line-length limit. -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use async_trait::async_trait; use serde_json::{Value, json}; From 731a46204d5c4ac88d9ece0b99e2247962fae5ad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:32:38 +0300 Subject: [PATCH 117/138] chore: files changed src/testkit/mocks_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_tests.rs | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/testkit/mocks_tests.rs b/src/testkit/mocks_tests.rs index 8309a91..59b06bd 100644 --- a/src/testkit/mocks_tests.rs +++ b/src/testkit/mocks_tests.rs @@ -284,6 +284,50 @@ async fn the_state_store_really_stores() { assert_eq!(mocks.log().count(capability::STATE, None), 3); } +/// `capabilities_for_node` builds a fresh `Capabilities` bundle — and so a +/// fresh `Double` — on every node activation, so it can attribute calls to +/// the right node. State must not live on that per-activation `Double`, or +/// nothing written by one activation would be visible to the next: not a +/// later activation of the SAME node (a loop reading what an earlier +/// iteration stored), and not a different node reading what an upstream one +/// wrote. +#[tokio::test] +async fn state_persists_across_node_scoped_bundles() { + let mocks = mocks(|m| m); + + // Node "writer"'s first activation stores a value... + mocks + .capabilities_for_node("writer") + .state + .store("k", json!("from writer")) + .await + .expect("store"); + + // ...a later activation of the SAME node must still see it... + assert_eq!( + mocks + .capabilities_for_node("writer") + .state + .load("k") + .await + .expect("load"), + Some(json!("from writer")), + "a node's own later activation must see what an earlier one stored" + ); + + // ...and so must a DIFFERENT node's bundle, entirely. + assert_eq!( + mocks + .capabilities_for_node("reader") + .state + .load("k") + .await + .expect("load"), + Some(json!("from writer")), + "state is one store shared across the whole run, not one per node" + ); +} + #[tokio::test] async fn a_delayed_response_still_answers() { let mocks = mocks(|m| { From 780b68740d1d4b20425befd891c5433c34cc414b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:32:49 +0300 Subject: [PATCH 118/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../approval_tests_part_01_tests.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs index 48e1e10..307f787 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs @@ -69,6 +69,39 @@ fn resume_reads_a_verdict_inline_or_nested() { ); } +/// An inline verdict object with no `node_id`/`request_id` is the documented +/// single-interrupt shorthand and is accepted, matching the same tradeoff +/// `engine::build::activation`'s bare `Value::Bool(true)` makes for `gate`. +/// But one that names a *different* request must not be silently absorbed — +/// several approval nodes can be interrupted in the same run, and a verdict +/// addressed to one of them is not an answer for the others. +#[test] +fn an_inline_verdict_addressed_to_another_request_is_not_taken() { + let req = request("run-1:review"); + + let for_someone_else = decision_from_resume( + &json!({ "approved": true, "node_id": "other-review" }), + &req, + ); + assert!( + for_someone_else.is_none(), + "a verdict explicitly naming a different node must not settle this review" + ); + + let for_someone_elses_request_id = decision_from_resume( + &json!({ "decision": { "approved": true, "request_id": "run-1:other" } }), + &req, + ); + assert!( + for_someone_elses_request_id.is_none(), + "a verdict explicitly naming a different request_id must not settle this review" + ); + + let for_us = decision_from_resume(&json!({ "approved": true, "node_id": "review" }), &req) + .expect("a verdict naming this node's id is ours to take"); + assert!(for_us.approved); +} + /// A review can be addressed by node id or by its own request id — a host that /// tracks reviews by request id must be able to resume with that. #[test] From 709e99d986d390166081882f88e77ba845997587 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:33:26 +0300 Subject: [PATCH 119/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../approval_tests_part_02_tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs index 5d7833f..f4c3c8a 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs @@ -14,6 +14,22 @@ async fn a_missing_request_id_and_run_id_is_a_configuration_error() { assert!(message.contains("request_id"), "got {message}"); } +/// `validate::validate_all` only sees the config as authored — a +/// one-element `assignees` array of `=`-bindings passes it. If every binding +/// resolves to something that is not a string (here: a field the trigger +/// input never set, so the binding reads `null`), the array a real review +/// would be routed to is empty at execution time, and that is refused too. +#[tokio::test] +async fn assignees_resolving_to_no_strings_at_runtime_is_a_configuration_error() { + let graph = wf(json!({ "assignees": ["=item.missing_reviewer"] })); + let compiled = compile(&graph).expect("compile"); + let err = run(&compiled, json!({}), &mock_capabilities()) + .await + .expect_err("assignees resolving to zero strings must be refused"); + let message = err.to_string(); + assert!(message.contains("assignees"), "got {message}"); +} + /// With a run-scoped id available (`trigger.run_id`, in the shape /// `run.trigger.run_id` a host's trigger payload takes), the node derives a /// stable `":"` request id without needing an explicit From 0111b88f849606c8af77c366374704657fcd5679 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:33:41 +0300 Subject: [PATCH 120/138] chore: files changed src/testkit/mocks_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/testkit/mocks_tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/testkit/mocks_tests.rs b/src/testkit/mocks_tests.rs index 59b06bd..becc2dc 100644 --- a/src/testkit/mocks_tests.rs +++ b/src/testkit/mocks_tests.rs @@ -450,3 +450,24 @@ async fn a_programmed_review_can_reject_or_stay_pending() { .expect("cancelling a review is answered too"); assert_eq!(mocks.log().count(capability::APPROVALS, None), 3); } + +/// The bare string `"pending"` is documented shorthand for "nobody has got to +/// this review yet" — the same flexibility `on_shell` accepts a bare stdout +/// string for. A rule answering with it must not be read as an (approving) +/// verdict object with no recognized fields. +#[tokio::test] +async fn on_approval_accepts_a_bare_pending_string() { + let mocks = mocks(|m| m.on_approval("run-1:bare*", Respond::value(json!("pending")))); + let caps = mocks.capabilities(); + let approvals = caps.approvals.clone().expect("the doubles wire approvals"); + + let outcome = approvals + .decide(&approval_request("run-1:bare-review")) + .await + .expect("call"); + assert_eq!( + outcome, + ApprovalOutcome::Pending, + "a bare \"pending\" string must not be read as an approving verdict" + ); +} From 9ce81743bfad1532987027daed3f83560d3c1b1f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:45:49 +0300 Subject: [PATCH 121/138] chore: files changed src/nodes/integration/approval.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/nodes/integration/approval.rs b/src/nodes/integration/approval.rs index 0858dc0..e1f1f04 100644 --- a/src/nodes/integration/approval.rs +++ b/src/nodes/integration/approval.rs @@ -279,7 +279,12 @@ impl ApprovalNode { let max_polls = positive_u64(config, "max_polls", DEFAULT_MAX_POLLS); let meta = json!({ POLLS_KEY: polls + 1, "request_id": request.request_id }); - if polls < max_polls { + // `polls` counts activations that have already asked the provider + // once (this one included), so re-entering when `polls + 1 < + // max_polls` — rather than `polls < max_polls` — is what makes + // `max_polls` the number of `decide` calls actually charged, not + // `max_polls + 1`: this activation's call is the `polls + 1`-th. + if polls + 1 < max_polls { let interval = positive_u64(config, "poll_interval_ms", DEFAULT_POLL_INTERVAL_MS); return Ok(NodeOutput::reenter_after(interval, meta)); } From 86186ef91cc487dbb75a485ed90d3299cc1ee872 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:46:08 +0300 Subject: [PATCH 122/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 60 ++++++++++++++--------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index 215c4a6..5ff8be5 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -74,30 +74,42 @@ pub(super) fn build_request(ctx: &NodeContext<'_>, config: &Value) -> Result = config - .get("assignees") - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(); - if config.get("assignees").is_some_and(Value::is_array) && assignees.is_empty() { - return Err(EngineError::Capability(format!( - "approval node {:?}: `assignees` resolved to an empty array; a review with nobody \ - assigned can never be resolved", - ctx.node.id - ))); - } + // `validate::validate_all` only sees `assignees` as authored: a literal + // non-array (a bare string, the natural single-reviewer mistake) or a + // literal empty array are both refused there. An `=`-bound `assignees` + // (e.g. `"=item.reviewers"`) is a string at author time — it passes that + // check by looking like *some* other field entirely — and resolves to + // its real shape only here, at execution time. So the same two refusals + // apply again to the resolved value: present and not an array, or + // present, an array, and empty (or empty of strings) once resolved. Both + // reach the same nobody-reviews-this audience a validated graph should + // never produce. + let assignees = match config.get("assignees") { + None => Vec::new(), + Some(value) => match value.as_array() { + None => { + return Err(EngineError::Capability(format!( + "approval node {:?}: `assignees` resolved to {value}, not an array of strings", + ctx.node.id + ))); + } + Some(values) => { + let assignees: Vec = values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(); + if assignees.is_empty() { + return Err(EngineError::Capability(format!( + "approval node {:?}: `assignees` resolved to an empty array; a review \ + with nobody assigned can never be resolved", + ctx.node.id + ))); + } + assignees + } + }, + }; Ok(ApprovalRequest { request_id, From 207d897644741ab4fde818702d2814521aca2a34 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:46:42 +0300 Subject: [PATCH 123/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index 5ff8be5..1a3fcaa 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -19,6 +19,20 @@ const DEFAULT_SUBJECT_KIND: &str = "json"; /// The run's host id, when the state carries one under any of the spellings a /// host might seed (`run.id`, `run.run_id`, `run.trigger.run_id`). +/// +/// **Security note for hosts:** whichever of these is populated becomes part +/// of `request_id`, the provider's create-or-fetch key. `run.trigger.run_id` +/// in particular is read out of the same trigger payload a caller supplies to +/// `engine::run` — for a webhook- or user-facing trigger, that payload can be +/// attacker-influenced. A host that lets untrusted input reach this field +/// lets an attacker choose a `request_id` that collides with an earlier run's +/// and inherit its cached decision, approving or rejecting a new, unreviewed +/// subject without a human ever seeing it. Seed a **server-generated** run id +/// here (or set `config.request_id` explicitly from one) — never forward a +/// caller-supplied field into it unvalidated. This crate is host-agnostic and +/// cannot tell trusted trigger data from untrusted; enforcing that boundary is +/// the host's responsibility, the same as it is for any other identity used as +/// a de-duplication or idempotency key. fn run_id(ctx: &NodeContext<'_>) -> Option { ["id", "run_id"] .iter() From f77ec77260977de4598912b97e2e90b03d3b558c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:46:54 +0300 Subject: [PATCH 124/138] chore: files changed src/catalog/contracts/group_03.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/catalog/contracts/group_03.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/catalog/contracts/group_03.rs b/src/catalog/contracts/group_03.rs index fc98f8c..e2cb33d 100644 --- a/src/catalog/contracts/group_03.rs +++ b/src/catalog/contracts/group_03.rs @@ -167,7 +167,10 @@ pub(super) fn contract_approval() -> NodeKindContract { Must be stable across resumes: it is the key the host de-duplicates reviews on. \ Required when no run-scoped id is available — falling back to the bare node id \ would let a later run of the same graph reuse an earlier run's decision, so the \ - node refuses to guess and fails instead.", + node refuses to guess and fails instead. SECURITY: whichever run id feeds this \ + must be server-generated, never a caller-supplied trigger field forwarded \ + unvalidated — an attacker who can choose it can collide with an earlier run's \ + request_id and inherit its cached decision, skipping review entirely.", ), ConfigField::optional( "wait_mode", From f83bed91745c0c52e8c52461cc36c2496fd93bee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 22:47:22 +0300 Subject: [PATCH 125/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../approval_tests/approval_tests_part_01_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs index 307f787..a992ba3 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs @@ -290,7 +290,11 @@ async fn polling_spends_a_bounded_budget_then_follows_on_timeout() { // Every activation asked about the SAME review: the create-or-fetch // contract is what stops a poll loop notifying a human once per poll. let ids = provider.requested(); - assert!(ids.len() > 1, "expected repeated polls, got {ids:?}"); + assert_eq!( + ids.len(), + 2, + "max_polls: 2 must charge exactly 2 decide calls, not max_polls + 1, got {ids:?}" + ); assert!( ids.iter().all(|id| id == &ids[0]), "every poll must reuse one request id, got {ids:?}" From fca976c07279355f8b326359a71ca3386c3a570e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 23:02:22 +0300 Subject: [PATCH 126/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 38 ++++++++++------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index 1a3fcaa..981cc00 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -17,32 +17,28 @@ use crate::nodes::NodeContext; /// The default rendering hint when the graph does not say what the subject is. const DEFAULT_SUBJECT_KIND: &str = "json"; -/// The run's host id, when the state carries one under any of the spellings a -/// host might seed (`run.id`, `run.run_id`, `run.trigger.run_id`). +/// The run's host id, read **only** from the run-level slots a host seeds +/// (`run.id`, `run.run_id`) — never from the trigger payload. /// -/// **Security note for hosts:** whichever of these is populated becomes part -/// of `request_id`, the provider's create-or-fetch key. `run.trigger.run_id` -/// in particular is read out of the same trigger payload a caller supplies to -/// `engine::run` — for a webhook- or user-facing trigger, that payload can be -/// attacker-influenced. A host that lets untrusted input reach this field -/// lets an attacker choose a `request_id` that collides with an earlier run's -/// and inherit its cached decision, approving or rejecting a new, unreviewed -/// subject without a human ever seeing it. Seed a **server-generated** run id -/// here (or set `config.request_id` explicitly from one) — never forward a -/// caller-supplied field into it unvalidated. This crate is host-agnostic and -/// cannot tell trusted trigger data from untrusted; enforcing that boundary is -/// the host's responsibility, the same as it is for any other identity used as -/// a de-duplication or idempotency key. +/// Whichever of these is populated becomes part of `request_id`, the provider's +/// create-or-fetch key, so this lookup is a trust boundary rather than a +/// convenience. `run.trigger.*` is the payload a caller hands to +/// `engine::run`; for a webhook or any user-facing trigger that payload is +/// attacker-influenced, and reading a run id out of it would let an attacker +/// pick a `request_id` colliding with an earlier run and inherit its cached +/// decision — approving a new, unreviewed subject without a human ever seeing +/// it. `run.id` / `run.run_id` sit outside the trigger, so a host puts a +/// server-generated value there deliberately. +/// +/// A host must still seed a **server-generated** id (or set +/// `config.request_id` from one) and never copy a caller-supplied field into +/// these slots: the crate is host-agnostic and cannot tell trusted run +/// metadata from untrusted, so enforcing that is the host's job, as it is for +/// any identity used as a de-duplication or idempotency key. fn run_id(ctx: &NodeContext<'_>) -> Option { ["id", "run_id"] .iter() .find_map(|key| ctx.run.get(*key).and_then(Value::as_str)) - .or_else(|| { - ctx.run - .get("trigger") - .and_then(|t| t.get("run_id")) - .and_then(Value::as_str) - }) .map(str::to_string) } From 051d17c69fb28235a5fdb6326ee0c64142c4ea72 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 23:02:28 +0300 Subject: [PATCH 127/138] chore: files changed src/catalog/contracts/group_03.rs,src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/catalog/contracts/group_03.rs | 3 ++- src/nodes/integration/approval_request.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/catalog/contracts/group_03.rs b/src/catalog/contracts/group_03.rs index e2cb33d..8771894 100644 --- a/src/catalog/contracts/group_03.rs +++ b/src/catalog/contracts/group_03.rs @@ -163,7 +163,8 @@ pub(super) fn contract_approval() -> NodeKindContract { "request_id", "string", "Overrides the review's identity (default \":\", derived from \ - whichever of `run.id` / `run.run_id` / `run.trigger.run_id` the host seeds). \ + whichever of `run.id` / `run.run_id` the host seeds — never read from the \ + caller-supplied trigger payload). \ Must be stable across resumes: it is the key the host de-duplicates reviews on. \ Required when no run-scoped id is available — falling back to the bare node id \ would let a later run of the same graph reuse an earlier run's decision, so the \ diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index 981cc00..6485123 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -67,7 +67,8 @@ pub(super) fn build_request(ctx: &NodeContext<'_>, config: &Value) -> Result { return Err(EngineError::Capability(format!( "approval node {:?}: no `request_id` configured and no run-scoped identity \ - available (expected `run.id`, `run.run_id`, or `run.trigger.run_id`); set \ + available (expected `run.id` or `run.run_id`, which a host seeds outside the \ + caller-supplied trigger payload); set \ `config.request_id` explicitly or seed a run id, otherwise later runs could \ reuse an earlier run's decision", ctx.node.id From fda43c02e2946f4dd18e1e05d23fd30b724e9b99 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 15 Aug 2026 23:03:12 +0300 Subject: [PATCH 128/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../approval_tests_part_02_tests.rs | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs index f4c3c8a..7dc21f7 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs @@ -30,25 +30,50 @@ async fn assignees_resolving_to_no_strings_at_runtime_is_a_configuration_error() assert!(message.contains("assignees"), "got {message}"); } -/// With a run-scoped id available (`trigger.run_id`, in the shape -/// `run.trigger.run_id` a host's trigger payload takes), the node derives a -/// stable `":"` request id without needing an explicit -/// `config.request_id`. +/// A `run_id` in the **trigger payload** must NOT become the review's identity. +/// +/// The trigger is caller-supplied — for a webhook or any user-facing trigger it +/// is attacker-influenced — and `request_id` is the provider's create-or-fetch +/// key. Honouring it would let an attacker name a review that collides with an +/// earlier run's and inherit its cached decision, approving an unreviewed +/// subject with no human involved. So the node refuses rather than deriving an +/// id it cannot trust. #[tokio::test] -async fn a_run_id_in_the_trigger_derives_a_stable_request_id() { +async fn a_run_id_in_the_trigger_payload_is_not_trusted_as_the_review_identity() { let graph = wf_raw(json!({ "title": "Publish this?" })); let compiled = compile(&graph).expect("compile"); - let out = run( + let err = run( &compiled, json!({ "run_id": "run-42" }), &mock_capabilities(), ) .await - .expect("a run id makes the request_id derivable"); + .expect_err("a trigger-supplied run id must not be accepted as a review identity"); + + let message = err.to_string(); + assert!( + message.contains("run-scoped identity"), + "the error must say what is missing, got {message}" + ); + assert!( + !message.contains("run-42"), + "the untrusted value must not be echoed as though it were usable, got {message}" + ); +} + +/// The supported way to identify a review when the host seeds no run id: +/// an explicit `config.request_id`, which the author controls. +#[tokio::test] +async fn an_explicit_request_id_identifies_the_review() { + let graph = wf_raw(json!({ "title": "Publish this?", "request_id": "review-of-post-42" })); + let compiled = compile(&graph).expect("compile"); + let out = run(&compiled, json!({ "url": "https://example.com" }), &mock_capabilities()) + .await + .expect("an explicit request_id makes the review identifiable"); assert_eq!( out.output["nodes"]["review"]["items"][0]["json"]["request_id"], - "run-42:review" + "review-of-post-42" ); } From 5790907d2365f777960a798c08cc69dfd629d4f3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:00:17 +0300 Subject: [PATCH 129/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index 6485123..b042ec9 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -188,16 +188,20 @@ pub(super) fn decision_from_resume( return Some(ApprovalDecision::approved()); } + // A verdict object must say WHICH review it settles. One resume value is + // delivered to every interrupted node, so an unaddressed `{"approved": + // true}` would settle whichever reviews happen to read it — approving every + // pending review at once, without the sender needing to know a single id. + // An unaddressed verdict is therefore ignored rather than assumed to be + // ours; the array forms carry their ids and stay supported. let verdict = resume.get("decision").unwrap_or(resume); - if let Some(named) = verdict + let named = verdict .get("node_id") .or_else(|| verdict.get("request_id")) - .and_then(Value::as_str) - { - if named != request.node_id && named != request.request_id { - // Explicitly addressed to a different node's review; not ours to take. - return None; - } + .and_then(Value::as_str)?; + if named != request.node_id && named != request.request_id { + // Addressed to a different node's review; not ours to take. + return None; } let approved = verdict.get("approved").and_then(Value::as_bool)?; Some(ApprovalDecision { From ca8c6562ef6543f48543be162f4e155a054a6f19 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:00:29 +0300 Subject: [PATCH 130/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 26 +++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index b042ec9..d7b94ad 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -227,12 +227,26 @@ pub(super) fn delivered( } // The re-execute resume path: `engine::resume` merges newly-approved ids - // into the run input, where they arrive as `run.trigger.approvals`. The - // top-level `run.approvals` is the same list seeded through the explicit - // channel; read both, because which one carries the id depends on how the - // host started the run. - let trigger_approvals = ctx.run.get("trigger").and_then(|t| t.get("approvals")); - if names(trigger_approvals, request) || names(ctx.run.get("approvals"), request) { + // into the run input, and they arrive here as the top-level `run.approvals` + // — the **explicit** channel (`RunInput::approvals`), which a host fills + // deliberately. + // + // `run.trigger.approvals` is deliberately NOT read, even though + // `engine::resume` also writes the merged list there. The trigger is the + // payload a caller hands to `engine::run`, so honouring it would let anyone + // who can start a run post `{"approvals": [""]}` and approve their + // own review on the initial execution — skipping the human entirely. A + // review that can be self-approved by its own subject is not a review. + // + // Known residual, and why it is not fixed here: `merge_approvals` seeds its + // starting set from `trigger["approvals"]`, so a trigger-supplied id is + // folded into the explicit list *on a resume*. That is pre-existing engine + // behaviour shared with the `requires_approval` gate in + // `engine::build::activation`, and narrowing it changes resume semantics for + // every gate, not just this node — so it belongs in its own change rather + // than riding along here. The initial-run bypass, which is the reachable- + // without-a-host-action one, is closed. + if names(ctx.run.get("approvals"), request) { return Some(ApprovalDecision::approved()); } None From a5f79e3cbe74816fb584a56043bb6e73e5626796 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:01:03 +0300 Subject: [PATCH 131/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../integration/approval_tests/approval_tests_part_02_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs index 7dc21f7..8337aa5 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs @@ -190,6 +190,7 @@ fn a_decision_with_a_payload_edits_the_subject() { let req = request("run-1:review"); let resume_value = json!({ "decision": { + "node_id": "review", "approved": true, "decided_by": "ada", "payload": "https://example.com/edited", From 231a45459707c67990b596778cf36599513d777f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:01:46 +0300 Subject: [PATCH 132/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../approval_tests_part_01_tests.rs | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs index a992ba3..29f5ed4 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_01_tests.rs @@ -47,7 +47,7 @@ fn resume_reads_a_verdict_inline_or_nested() { let req = request("run-1:review"); let inline = decision_from_resume( - &json!({ "approved": true, "decided_by": "ada", "comment": "ship it" }), + &json!({ "node_id": "review", "approved": true, "decided_by": "ada", "comment": "ship it" }), &req, ) .expect("a decision"); @@ -56,7 +56,7 @@ fn resume_reads_a_verdict_inline_or_nested() { assert_eq!(inline.comment.as_deref(), Some("ship it")); let nested = decision_from_resume( - &json!({ "decision": { "approved": false, "comment": "wrong link" } }), + &json!({ "decision": { "request_id": "run-1:review", "approved": false, "comment": "wrong link" } }), &req, ) .expect("a decision"); @@ -69,16 +69,32 @@ fn resume_reads_a_verdict_inline_or_nested() { ); } -/// An inline verdict object with no `node_id`/`request_id` is the documented -/// single-interrupt shorthand and is accepted, matching the same tradeoff -/// `engine::build::activation`'s bare `Value::Bool(true)` makes for `gate`. -/// But one that names a *different* request must not be silently absorbed — -/// several approval nodes can be interrupted in the same run, and a verdict -/// addressed to one of them is not an answer for the others. +/// A verdict object must name the review it settles. +/// +/// One resume value is delivered to **every** interrupted node, so an +/// unaddressed `{"approved": true}` would settle whichever reviews read it — +/// approving every pending review in the run at once, without the sender +/// needing to know a single id. And a verdict naming a *different* review is +/// not an answer for this one. Both are refused; only an explicit match is +/// taken. #[test] -fn an_inline_verdict_addressed_to_another_request_is_not_taken() { +fn a_verdict_object_must_name_the_review_it_settles() { let req = request("run-1:review"); + let unaddressed = decision_from_resume(&json!({ "approved": true }), &req); + assert!( + unaddressed.is_none(), + "an unaddressed verdict must not settle this review: the same resume value \ + reaches every interrupted node, so it would approve all of them at once" + ); + + let unaddressed_nested = + decision_from_resume(&json!({ "decision": { "approved": true } }), &req); + assert!( + unaddressed_nested.is_none(), + "the nested form carries the same risk and gets the same refusal" + ); + let for_someone_else = decision_from_resume( &json!({ "approved": true, "node_id": "other-review" }), &req, From e73f2681d2e9a7e40e8cb953eca6380d7a625c79 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:03:00 +0300 Subject: [PATCH 133/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../approval_tests_part_02_tests.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs index 8337aa5..4b3f378 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs @@ -249,3 +249,67 @@ async fn a_resume_decision_withdraws_the_provider_card() { "the provider's card must be withdrawn once a resume settles the review" ); } + +/// THE self-approval bypass: a caller must not be able to approve their own +/// review by putting the node's id in the trigger payload they submit. +/// +/// `engine::resume` writes the merged approvals into `run.trigger.approvals` +/// as well as the explicit channel, so it was tempting to read both. But the +/// trigger is whatever a caller handed to `engine::run` — on a webhook, that is +/// attacker-supplied — and a review its own subject can approve is not a +/// review. Only the explicit `RunInput::approvals` channel counts. +#[tokio::test] +async fn approvals_in_the_trigger_payload_cannot_self_approve_a_review() { + let graph = wf_raw(json!({ "title": "Publish this?", "request_id": "review-1" })); + let compiled = compile(&graph).expect("compile"); + let provider = std::sync::Arc::new(MockApprovals::pending()); + let caps = crate::caps::Capabilities { + approvals: Some(provider.clone()), + ..mock_capabilities() + }; + + // The attacker's payload names the review — by node id and by request id. + let outcome = run( + &compiled, + json!({ "approvals": ["review", "review-1"] }), + &caps, + ) + .await + .expect("run"); + + assert_eq!( + outcome.pending_approvals, + vec!["review".to_string()], + "a trigger-supplied approvals list must leave the review pending, not settle it" + ); + assert_ne!( + outcome.output["nodes"]["review"]["port"], + json!("approved"), + "the review must not have been approved by its own caller" + ); +} + +/// The counterpart: the explicit host channel still settles the review, so +/// closing the bypass did not break the supported path. +#[tokio::test] +async fn the_explicit_approvals_channel_still_settles_a_review() { + use crate::engine::RunInput; + + let graph = wf_raw(json!({ "title": "Publish this?", "request_id": "review-1" })); + let compiled = compile(&graph).expect("compile"); + let caps = crate::caps::Capabilities { + approvals: Some(std::sync::Arc::new(MockApprovals::pending())), + ..mock_capabilities() + }; + + let outcome = run( + &compiled, + RunInput::new(json!({})).with_approvals(vec!["review".to_string()]), + &caps, + ) + .await + .expect("run"); + + assert_eq!(outcome.output["nodes"]["review"]["port"], "approved"); + assert!(outcome.pending_approvals.is_empty()); +} From e505dfadb87d9c8629395d550351f215a320944a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:03:42 +0300 Subject: [PATCH 134/138] chore: files changed src/nodes/integration/approval_request.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/nodes/integration/approval_request.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/nodes/integration/approval_request.rs b/src/nodes/integration/approval_request.rs index d7b94ad..fca3839 100644 --- a/src/nodes/integration/approval_request.rs +++ b/src/nodes/integration/approval_request.rs @@ -105,11 +105,23 @@ pub(super) fn build_request(ctx: &NodeContext<'_>, config: &Value) -> Result { - let assignees: Vec = values - .iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect(); + // Every element must be a string. Dropping the ones that are + // not would route the review to a *different* audience than the + // graph asked for, silently: `["=item.reviewer", 42]` would + // quietly become a one-reviewer list. Losing a reviewer is + // exactly the kind of quiet change a review must not make. + let mut assignees: Vec = Vec::with_capacity(values.len()); + for value in values { + let Some(handle) = value.as_str() else { + return Err(EngineError::Capability(format!( + "approval node {:?}: `assignees` entry {value} is not a string; a \ + reviewer handle that resolved to something else would be dropped \ + and the review routed to a smaller audience than authored", + ctx.node.id + ))); + }; + assignees.push(handle.to_string()); + } if assignees.is_empty() { return Err(EngineError::Capability(format!( "approval node {:?}: `assignees` resolved to an empty array; a review \ From 2717ef81fd998adc8597d1fc31f944ae0855d9f5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:14:20 +0300 Subject: [PATCH 135/138] chore: files changed src/engine/run_state.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/run_state.rs | 48 ++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/engine/run_state.rs b/src/engine/run_state.rs index a38cbb3..66788f0 100644 --- a/src/engine/run_state.rs +++ b/src/engine/run_state.rs @@ -293,7 +293,33 @@ pub(super) fn merge_approvals(input: impl Into, newly_approved: Vec = trigger + // Approval **provenance** is preserved rather than flattened, and the two + // sets are deliberately not the same list. + // + // `explicit` is what a host actually authorised: the ids passed to + // `engine::resume`, plus any carried on `RunInput::approvals`. It never + // includes anything read out of the trigger, because the trigger is the + // payload a caller submitted — on a webhook, attacker-supplied. Flattening + // the two meant a caller-written `trigger.approvals` entry was promoted + // into the trusted list by the first resume, so a self-approval that the + // initial run correctly refused went through on the next one. + // + // `trigger.approvals` still receives the union, unchanged, because the + // `requires_approval` gate in `engine::build::activation` reads exactly + // that key and callers are documented as being able to write approvals + // there directly. Narrowing *that* is a separate change to a shared, + // documented channel; this only stops trigger-origin ids laundering + // themselves into the explicit one. + let mut explicit: Vec = Vec::new(); + for id in newly_approved.into_iter().chain(prior) { + if !explicit.contains(&id) { + explicit.push(id); + } + } + + // The union written back to the trigger: whatever the caller already had + // there, plus everything explicitly authorised. + let mut union: Vec = trigger .get("approvals") .and_then(Value::as_array) .map(|existing| { @@ -304,29 +330,21 @@ pub(super) fn merge_approvals(input: impl Into, newly_approved: Vec Date: Sun, 16 Aug 2026 10:15:21 +0300 Subject: [PATCH 136/138] chore: files changed src/engine/run_state.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/engine/run_state.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/engine/run_state.rs b/src/engine/run_state.rs index 66788f0..3d86934 100644 --- a/src/engine/run_state.rs +++ b/src/engine/run_state.rs @@ -286,6 +286,10 @@ pub async fn resume( /// is replaced by a fresh object holding just the merged approvals. Declared /// inputs ride along unchanged — a resume re-runs the *same* parameterized /// workflow, so dropping them would silently change what it does. +/// +/// The returned [`RunInput::approvals`] carries **only** what a host explicitly +/// authorised — never an id read out of the trigger payload. See the body for +/// why the two sets are kept apart. pub(super) fn merge_approvals(input: impl Into, newly_approved: Vec) -> RunInput { let RunInput { mut trigger, From f13ff320e24ce2c7f09af61d0730230f8580eaa3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:16:31 +0300 Subject: [PATCH 137/138] chore: files changed src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../approval_tests_part_02_tests.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs index 4b3f378..ee8f771 100644 --- a/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs +++ b/src/nodes/integration/approval_tests/approval_tests_part_02_tests.rs @@ -313,3 +313,41 @@ async fn the_explicit_approvals_channel_still_settles_a_review() { assert_eq!(outcome.output["nodes"]["review"]["port"], "approved"); assert!(outcome.pending_approvals.is_empty()); } + +/// The self-approval bypass must not survive a **resume** either. +/// +/// `merge_approvals` writes the merged list back into `run.trigger.approvals` +/// for the pre-existing `requires_approval` gate, and it used to seed its +/// starting set from that same key — which laundered a caller-written id into +/// the explicit channel on the first resume. So a payload the initial run +/// correctly refused was honoured on the next one. Provenance is kept separate +/// now: only ids a host actually authorised reach `run.approvals`. +#[tokio::test] +async fn a_trigger_supplied_approval_is_not_laundered_by_a_resume() { + use crate::engine::resume; + + let graph = wf_raw(json!({ "title": "Publish this?", "request_id": "review-1" })); + let compiled = compile(&graph).expect("compile"); + let caps = crate::caps::Capabilities { + approvals: Some(std::sync::Arc::new(MockApprovals::pending())), + ..mock_capabilities() + }; + // The attacker names the review in the payload they submit. + let attacker_trigger = json!({ "approvals": ["review", "review-1"] }); + + let paused = run(&compiled, attacker_trigger.clone(), &caps) + .await + .expect("run"); + assert_eq!(paused.pending_approvals, vec!["review".to_string()]); + + // A resume that approves NOTHING must not promote the attacker's ids. + let resumed = resume(&compiled, attacker_trigger, vec![], &caps) + .await + .expect("resume"); + assert_eq!( + resumed.pending_approvals, + vec!["review".to_string()], + "a resume must not launder a trigger-supplied id into the trusted channel" + ); + assert_ne!(resumed.output["nodes"]["review"]["port"], json!("approved")); +} From 417347364c1d179c82456062a4cb4684aaefc0d4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 16 Aug 2026 10:26:33 +0300 Subject: [PATCH 138/138] docs(changelog): record the approval-provenance behaviour change Hosts that relied on RunInput::approvals echoing ids written into the trigger payload need to know it no longer does, and why. Co-authored-by: Medulla --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7acd9..17d7edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 an `approvals: Option>` field, so add `approvals: None` (or a provider) to keep compiling. +### Changed + +- **Approval provenance is preserved across a resume.** `RunInput::approvals` + (surfaced as `run.approvals`) now carries only the ids a host explicitly + authorised — those passed to `engine::resume` plus any already on that + channel. It is no longer seeded from `run.trigger.approvals`. + + The trigger is the payload a caller submits, so folding it into the + authorised list meant a caller-written id was promoted to trusted by the + first resume. `run.trigger.approvals` still receives the union and the + `requires_approval` gate still reads it, so that documented channel is + unchanged; what changed is that trigger-origin ids no longer cross into the + explicit one. A host that relied on `RunInput::approvals` echoing ids it had + written into the trigger payload must now pass them to `engine::resume` (or + `RunInput::with_approvals`) instead. + + The `approval` node reads only the explicit channel, and a verdict object in + a resume value must name its review (`node_id` / `request_id`) — an + unaddressed `{"approved": true}` is ignored rather than settling whichever + review reads it, since one resume value is delivered to every interrupted + node. + - **`tinyflows::testkit` — testing, mocking, and live debugging for workflows.** Behind the default-off `testkit` feature; adds no dependencies.