diff --git a/CLAUDE.md b/CLAUDE.md index 9e294960..d95f53bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,21 @@ model::WorkflowGraph → validate → compiler::compile → engine::run - `validate.rs` — structural validation, run before compile. - `caps/` — host-injected capability traits (`caps/mod.rs`); `caps/mock.rs` has in-memory mock impls, gated behind the `mock` feature (always on inside tests). + `caps/host/` has *real* implementations a host may opt into — out-of-process + script/shell running, a file-backed `StateStore`, an allowlisted HTTP client — + behind the `host-caps` feature. These are offered, never assumed: nothing in + the engine reaches into them, and a host with a sandbox implements its own. +- `store/` — the durable model *around* a graph (versioned documents, run + records, notes, proposals), a JSON file-backed store for it, and `authoring` + (patch-based editing: apply → validate → gate → save), behind the `store` + feature. Not part of the engine: `engine::run` neither reads nor writes any of + it. `store::HostPolicy` is where a host injects the judgements only it can + make — which harnesses exist, which slugs resolve. +- `bindings.rs` — reading the `={{ ... }}` bindings a graph declares: which node + an expression reads from, and whether it reads as prose rather than jq. +- `gates/` — authoring gates: what is *guaranteed* wrong with a graph, refused + before a write rather than surfacing as a silent null at run time. Only the + host-agnostic ones; a host adds its own via `store::HostPolicy::check_graph`. - `nodes/` — `NodeExecutor` trait + dispatch; `control_flow.rs` (if/switch/merge/ split_out/…) and `integration.rs` (agent/tool_call/http_request/code/…). - `compiler.rs` — compiles a validated graph into runnable form. @@ -44,6 +59,9 @@ model::WorkflowGraph → validate → compiler::compile → engine::run - **Host-agnostic rule:** never hard-code an LLM/tool/HTTP/persistence vendor in the crate. New outside-world effects go through a `caps` trait, not a direct dependency. This is the core design constraint — do not violate it. + `caps/host/` and `store/` do not weaken it: they are *optional* implementations + behind default-off features, and the engine never depends on them. A host name + (`medulla`, `openhuman`) must not appear in either. - **Declarative model:** no arbitrary embedded scripting in the workflow model; code execution is a sandboxed capability, not model logic. - **License:** GPL-3.0-or-later. Keep new files compatible. @@ -52,8 +70,10 @@ model::WorkflowGraph → validate → compiler::compile → engine::run ```bash cargo check # fast type/borrow check -cargo test # unit + compiler tests (mocks auto-available) +cargo test # unit + compiler tests (all optional modules compile in tests) cargo test --features mock # exercise the mock capabilities explicitly +cargo check --features host-caps # the opt-in host capability implementations +cargo check --features store # the file-backed workflow/run store cargo clippy --all-targets # lint cargo fmt # format (run before committing) cargo build --release diff --git a/Cargo.lock b/Cargo.lock index 1dc2f8f6..3db72b53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.32" @@ -1495,6 +1505,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1643,7 +1664,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "thiserror", "tokio", "tracing", @@ -1655,6 +1676,7 @@ version = "0.6.1" dependencies = [ "async-trait", "axum", + "fs2", "futures-timer", "futures-util", "getrandom 0.4.3", @@ -1665,6 +1687,8 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde_json", + "sha2 0.10.9", + "tempfile", "thiserror", "tinyagents", "tokio", @@ -2048,6 +2072,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 23502c36..9fe0968c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,11 +30,30 @@ thiserror = "2" futures-timer = "3" futures-util = "0.3" getrandom = "0.4" -tinyagents = { version = "2.1", path = "vendor/tinyagents" } +# A registry coordinate, redirected to the vendored copy by the +# `[patch.crates-io]` table at the bottom of this file — NOT a `path` +# dependency, though the vendored tree is exactly what both resolve to when this +# crate is the workspace root. +# +# The difference only shows when something *embeds* this crate. A path +# dependency names a directory and cannot be redirected: `[patch.crates-io]` has +# no effect on it. So an embedding host that vendors its own `tinyagents` — and +# both known hosts do — ends up with two `tinyagents v2.1.0` packages at two +# paths, which Cargo refuses outright ("package collision in the lockfile") +# rather than resolving. Declared this way, a host's own patch table redirects +# this dependency along with its own and the graph holds one copy. +tinyagents = "2.1" tracing = "0.1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } axum = { version = "0.8", features = ["ws"] } reqwest = { version = "0.13", default-features = false, features = ["json"] } +# `caps::host` only: hashing an author-supplied state key (and namespace) into +# one safe path component, and staging a script in a temporary directory. +sha2 = { version = "0.10", optional = true } +tempfile = { version = "3", optional = true } +# `store` only: the advisory file lock that keeps two processes from deciding +# the same proposal at once. +fs2 = { version = "0.4", optional = true } jaq-core = "3.1.0" jaq-std = "3.0.1" jaq-json = { version = "2.0.1", features = ["serde"] } @@ -44,7 +63,32 @@ jaq-json = { version = "2.0.1", features = ["serde"] } # and examples. Mocks are always available inside this crate's own tests. default = [] mock = [] +# Off by default; enables `caps::host` — ready-made capability implementations +# for a host that runs scripts as child processes of itself and keeps state on +# its own disk. Off by default because a host with a sandbox wants none of it, +# and because it is the only thing in the crate that spawns a process or writes +# a file. Always available inside this crate's own tests. +host-caps = ["dep:sha2", "dep:tempfile", "tokio/fs", "tokio/process", "tokio/io-util"] +# Off by default; enables `store` — the durable model around a graph (versioned +# documents, run records, notes, proposals) and a JSON file-backed store for it. +# A host with its own catalog or database wants none of it, so it is not part of +# the engine. Always available inside this crate's own tests. +store = ["dep:sha2", "dep:fs2"] [dev-dependencies] proptest = "1" async-trait = "0.1" +# `caps::host` compiles inside this crate's own tests whether or not the +# `host-caps` feature is on (the same rule `caps::mock` follows), so what that +# feature would switch on has to be present for a test build too. An optional +# dependency is not activated by `cfg(test)`, hence these. +sha2 = "0.10" +tempfile = "3" +fs2 = "0.4" +tokio = { version = "1", features = ["fs", "process", "io-util"] } + +# Applies only when this crate is the workspace root — an embedding host's own +# table wins, which is the point: standalone builds and tests use the vendored +# submodule, and a host redirects `tinyagents` to whichever copy it links. +[patch.crates-io] +tinyagents = { path = "vendor/tinyagents" } diff --git a/src/bindings.rs b/src/bindings.rs new file mode 100644 index 00000000..84c72162 --- /dev/null +++ b/src/bindings.rs @@ -0,0 +1,224 @@ +//! Reading the `=`-expressions out of a graph, and deciding which of them are +//! already known to be wrong. +//! +//! Everything here is a pure function of the graph. That is what makes it a +//! *gate* rather than a diagnostic: it runs before a write, costs nothing, and +//! can refuse an edit outright. + +use crate::model::{NodeKind, WorkflowGraph}; +use serde_json::Value; + +/// Node kinds whose output is wrapped in the engine's `{json, text, raw}` +/// envelope. +/// +/// The distinction the envelope creates is the single most common way a graph +/// compiles, validates, dry-runs green, and then does nothing: `=nodes.x.item.f` +/// reads a field that lives at `=nodes.x.item.json.f`, so it resolves to null +/// and the step runs with an empty value. +const ENVELOPING_KINDS: [NodeKind; 3] = + [NodeKind::Agent, NodeKind::ToolCall, NodeKind::HttpRequest]; + +/// jq keywords that read as syntax rather than as prose. +/// +/// Used by [`reads_as_prose`] so a genuine jq program — `if`/`then`/`else`, +/// `reduce`, a `def` — is never mistaken for natural language. +const JQ_KEYWORDS: &[&str] = &[ + "and", "or", "not", "if", "then", "elif", "else", "end", "as", "def", "reduce", "foreach", + "try", "catch", "import", "include", "label", +]; + +/// Every `=`-expression in `value`, paired with its dotted location. +/// +/// Walks objects and arrays, so a binding nested inside a tool call's `args` +/// is found and can be named precisely in an error an author has to act on. +pub fn collect_expressions(value: &Value) -> Vec<(String, String)> { + fn walk(value: &Value, location: &str, out: &mut Vec<(String, String)>) { + match value { + Value::Object(map) => { + for (key, child) in map { + let path = if location.is_empty() { + key.clone() + } else { + format!("{location}.{key}") + }; + walk(child, &path, out); + } + } + Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + let path = if location.is_empty() { + index.to_string() + } else { + format!("{location}.{index}") + }; + walk(child, &path, out); + } + } + Value::String(text) if crate::expr::is_expression(text) => { + out.push((location.to_string(), text.clone())); + } + _ => {} + } + } + let mut out = Vec::new(); + walk(value, "", &mut out); + out +} + +/// One node-output binding, taken apart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeBinding { + /// The node whose output is being read. + pub node_id: String, + /// Whether the expression went through the `{json, text, raw}` envelope. + pub through_envelope: bool, + /// The remaining dotted path, whole — `data.messages`, not `data`. + pub field_path: String, +} + +/// Parse `=nodes..item[.json].`, if the expression is one. +/// +/// Deliberately narrow. An expression this does not match is not reported on at +/// all, because a gate that guessed at arbitrary jq would refuse graphs that are +/// fine — and a false refusal costs an author their edit. +/// +/// Hand-written rather than a regular expression +/// (`^=nodes\.([A-Za-z_]\w*)\.item(?:\.(json))?\.([A-Za-z_][\w.]*)`), because a +/// regex engine is a large dependency for one anchored pattern in a crate that +/// otherwise needs none. +pub fn parse_node_binding(expr: &str) -> Option { + let rest = expr.strip_prefix("=nodes.")?; + let (node_id, rest) = take_identifier(rest)?; + let rest = rest.strip_prefix(".item")?; + + // `.json` is optional, and only counts when it is a whole path segment: a + // node field actually named `jsonish` must not be mistaken for the envelope. + let (through_envelope, rest) = match rest.strip_prefix(".json") { + Some(after) if after.starts_with('.') => (true, after), + _ => (false, rest), + }; + + let rest = rest.strip_prefix('.')?; + let (field_path, _) = take_field_path(rest)?; + Some(NodeBinding { + node_id: node_id.to_string(), + through_envelope, + field_path, + }) +} + +/// The leading `[A-Za-z_][A-Za-z0-9_]*`, and what follows it. +fn take_identifier(input: &str) -> Option<(&str, &str)> { + let mut end = 0; + for (index, ch) in input.char_indices() { + let ok = if index == 0 { + ch.is_ascii_alphabetic() || ch == '_' + } else { + ch.is_ascii_alphanumeric() || ch == '_' + }; + if !ok { + break; + } + end = index + ch.len_utf8(); + } + (end > 0).then(|| input.split_at(end)) +} + +/// The leading `[A-Za-z_][A-Za-z0-9_.]*`, with trailing dots trimmed. +/// +/// Trailing dots are trimmed rather than refused because the pattern this +/// replaces did the same: `=nodes.a.item.field.` names `field`, and the dot is +/// the author's typo, not a different binding. +fn take_field_path(input: &str) -> Option<(String, &str)> { + let mut end = 0; + for (index, ch) in input.char_indices() { + let ok = if index == 0 { + ch.is_ascii_alphabetic() || ch == '_' + } else { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' + }; + if !ok { + break; + } + end = index + ch.len_utf8(); + } + if end == 0 { + return None; + } + let (matched, rest) = input.split_at(end); + let trimmed = matched.trim_end_matches('.'); + (!trimmed.is_empty()).then(|| (trimmed.to_string(), rest)) +} + +/// Whether an expression body reads as prose rather than as a jq program. +/// +/// The failure this catches is specific and common: an author writes +/// `"=You are given an issue: .item. Summarise it"` in a node's `prompt`, +/// believing `=` interpolates. It does not — jq has no rule for two bare words +/// in a row — so the whole expression resolves to null and the step runs with an +/// empty prompt. Nothing else notices: it parses, it validates, and it produces +/// a plausible-looking run. +/// +/// Best-effort by construction, and biased toward *not* firing: two consecutive +/// bare alphabetic words outside a string literal, none of them jq keywords. +pub fn reads_as_prose(expr_body: &str) -> bool { + // String literals are stripped first: `"two words"` inside a real jq + // program is data, not prose, and would otherwise trip the scan. + let mut stripped = String::with_capacity(expr_body.len()); + let mut in_string = false; + let mut chars = expr_body.chars(); + while let Some(c) = chars.next() { + // An escaped character inside a literal — consume both, so `\"` does + // not read as the end of the string. + if in_string && c == '\\' { + chars.next(); + continue; + } + if c == '"' { + in_string = !in_string; + continue; + } + if !in_string { + stripped.push(c); + } + } + + let mut consecutive = 0u32; + for token in stripped.split_whitespace() { + let core = token.trim_matches(|c: char| !c.is_ascii_alphabetic()); + let bare = !core.is_empty() + && core.chars().all(|c| c.is_ascii_alphabetic()) + && !token.starts_with('.') + && !token.contains('.') + && !JQ_KEYWORDS.contains(&core.to_ascii_lowercase().as_str()); + if bare { + consecutive += 1; + if consecutive >= 2 { + return true; + } + } else { + consecutive = 0; + } + } + false +} + +/// Whether `kind` wraps its output in the `{json, text, raw}` envelope. +pub fn wraps_output(kind: &NodeKind) -> bool { + ENVELOPING_KINDS.contains(kind) +} + +/// A node kind named the way an error message should name it. +pub fn kind_article(kind: &NodeKind) -> &'static str { + match kind { + NodeKind::Agent => "an agent", + NodeKind::ToolCall => "a tool_call", + NodeKind::HttpRequest => "an http_request", + _ => "a node", + } +} + +/// The node with `id`, if the graph has one. +pub fn node_of<'a>(graph: &'a WorkflowGraph, id: &str) -> Option<&'a crate::model::Node> { + graph.nodes.iter().find(|node| node.id == id) +} diff --git a/src/caps/host/code.rs b/src/caps/host/code.rs new file mode 100644 index 00000000..2c4696c9 --- /dev/null +++ b/src/caps/host/code.rs @@ -0,0 +1,80 @@ +//! The `code` node's runner — refusing by default, executing when opted in. +//! +//! A host with a sandbox runs `code` nodes inside it, with its own approval +//! policy deciding whether a call needs consent. A host *without* one — a daemon +//! holding the privileges of the user who started it — executes a workflow +//! author's script with those privileges and no boundary at all. +//! +//! Rather than pretend otherwise, this pair makes the choice explicit. +//! [`DeniedCodeRunner`] refuses and says why; [`ProcessCodeRunner`] runs the +//! script out of process for a host that has opted in. What neither does is +//! refuse and leave the author with nothing: a workflow whose real work is a +//! fifty-line script should be able to say so, and the alternative — an `agent` +//! node whose prompt asks a harness to run it — costs a whole model session for +//! work that takes milliseconds. +//! +//! The execution itself lives in [`super::script`], shared with the `shell` +//! node so an author learns one calling convention. + +use crate::caps::{CodeLanguage, CodeRunner}; +use crate::error::{EngineError, Result}; +use async_trait::async_trait; +use serde_json::Value; + +use super::script::{ScriptLanguage, ScriptRequest, run_script}; + +/// A [`CodeRunner`] that refuses every request, explaining the missing sandbox. +pub struct DeniedCodeRunner; + +#[async_trait] +impl CodeRunner for DeniedCodeRunner { + async fn run(&self, _language: CodeLanguage, _source: &str, _input: Value) -> Result { + Err(EngineError::Capability( + "code nodes are disabled: this host has no sandbox, so workflow code would run with \ + the daemon's full privileges. Enable `workflows.allowCode` only where that is \ + acceptable, or use a `transform` node's expressions instead." + .to_string(), + )) + } +} + +/// A [`CodeRunner`] that runs code out-of-process, for hosts that opted in. +/// +/// Still not a sandbox — it is the operator's explicit decision to trust the +/// workflow author — but bounded: a temporary working directory and a +/// wall-clock limit. +/// +/// The script is given its input on **stdin as JSON**, and also as a file whose +/// path is `argv[1]` and `$TINYFLOWS_INPUT`. It returns its result on **stdout**; +/// stdout that parses as JSON becomes structured output, anything else becomes a +/// string. See [`super::script`] for the whole contract. +pub struct ProcessCodeRunner { + /// How long one execution may take. + timeout: std::time::Duration, +} + +impl ProcessCodeRunner { + /// A runner with the given per-execution timeout. + pub fn new(timeout: std::time::Duration) -> Self { + Self { timeout } + } +} + +#[async_trait] +impl CodeRunner for ProcessCodeRunner { + async fn run(&self, language: CodeLanguage, source: &str, input: Value) -> Result { + // A `code` node runs in a temporary directory rather than the operator's + // project: the engine's own contract for the kind is a computation over + // its input, and a step that means to touch the repo is a + // `shell` node, which says so in the graph. + run_script(ScriptRequest::plain( + ScriptLanguage::from(language), + source, + &input, + self.timeout, + &std::collections::BTreeMap::new(), + )) + .await + .map(|output| output.value) + } +} diff --git a/src/caps/host/http.rs b/src/caps/host/http.rs new file mode 100644 index 00000000..69986b5a --- /dev/null +++ b/src/caps/host/http.rs @@ -0,0 +1,471 @@ +//! Outbound HTTP for `http_request` nodes, behind a host allowlist. +//! +//! Two guards stand between a workflow author and the network, and they are +//! deliberately separate: +//! +//! 1. **The allowlist**, which is policy: a host an operator has agreed this +//! workflow may reach. Empty by default, so a freshly installed workflow +//! cannot become an exfiltration path. +//! 2. **The loopback and private-range refusal**, which is not policy: reaching +//! `127.0.0.1` or `10.x` from a workflow means reaching services that trusted +//! the network boundary, so it is refused whatever the allowlist says. +//! +//! Credentials never appear in the graph. A node names one with an opaque +//! `connection_ref` of the form `http_cred:`, resolved here against the +//! host's store and injected into the request *after* any summary of the call +//! has been taken — so a secret cannot reach a log, an approval prompt, or a +//! node's recorded output. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::caps::HttpClient; +use crate::error::{EngineError, Result}; + +/// The `connection_ref` prefix naming an HTTP credential. +pub const HTTP_CRED_PREFIX: &str = "http_cred:"; + +/// How long a workflow HTTP request waits for a TCP/TLS connection. +/// +/// A black-holed peer must fail the node, not hang it forever. +const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +/// How long a workflow HTTP request tolerates a silent socket mid-response. +/// +/// An idle timeout, not a whole-request one: a peer that keeps sending bytes +/// (a streamed response) must not be cut off schedule. +const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// How long `permit` waits for the DNS lookup inside [`vet_resolution`]. +/// +/// Separate from [`CONNECT_TIMEOUT`], which bounds only the TCP/TLS handshake +/// that follows a successful resolution — a resolver that never answers is +/// not covered by it at all. +const DNS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// A credential the host injects into an outbound request. +#[derive(Debug, Clone)] +pub struct HttpCredential { + /// The header to set, e.g. `Authorization`. + pub header: String, + /// The header's value, e.g. `Bearer …`. Never logged. + pub value: String, +} + +/// The credential name inside a `connection_ref`, if it names one. +/// +/// Fails closed: a `connection_ref` that is present but not an HTTP credential +/// reference is an error rather than a silently unauthenticated request, because +/// silently dropping the credential would send the call anyway. +pub fn http_cred_name(conn: Option<&str>) -> Result> { + let Some(conn) = conn.map(str::trim).filter(|c| !c.is_empty()) else { + return Ok(None); + }; + conn.strip_prefix(HTTP_CRED_PREFIX) + .map(Some) + .filter(|name| name.is_some_and(|n| !n.is_empty())) + .ok_or_else(|| { + EngineError::Capability(format!( + "http_request: unrecognised connection_ref '{conn}'; expected \ + '{HTTP_CRED_PREFIX}'" + )) + }) +} + +/// Merge `cred` into `request`'s headers, returning the request to send. +/// +/// Called last, after the request has been described for logs or approval, so +/// the secret exists only in the value handed to the transport. +pub fn inject_credential(mut request: Value, cred: &HttpCredential) -> Value { + if let Some(object) = request.as_object_mut() { + let headers = object + .entry("headers") + .or_insert_with(|| Value::Object(Default::default())); + if let Some(headers) = headers.as_object_mut() { + headers.insert(cred.header.clone(), Value::String(cred.value.clone())); + } + } + request +} + +/// A description of a request safe to log or show for approval: method and URL +/// only, never headers or body. +pub fn redacted_summary(request: &Value) -> String { + let method = request + .get("method") + .and_then(Value::as_str) + .unwrap_or("GET") + .to_ascii_uppercase(); + let url = request.get("url").and_then(Value::as_str).unwrap_or(""); + format!("{method} {url}") +} + +/// The hosts an `http_request` node may reach. +/// +/// Policy, and therefore the host's to state — but the *shape* of the policy is +/// the same wherever this client runs, so the matching rule lives here rather +/// than being re-derived by each embedding application. Empty denies everything, +/// which is what makes a freshly installed workflow unable to become an +/// exfiltration path. +/// +/// An entry matches its own name and any subdomain of it: `example.com` permits +/// `example.com` and `api.example.com`, but not `notexample.com`. +#[derive(Debug, Clone, Default)] +pub struct HostAllowlist { + /// The permitted host suffixes, as the operator wrote them. + entries: Vec, +} + +impl HostAllowlist { + /// An allowlist over `entries`. + pub fn new(entries: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + entries: entries.into_iter().map(Into::into).collect(), + } + } + + /// Whether `host` is permitted, by exact match or as a subdomain. + /// + /// Case- and whitespace-insensitive on both sides, because an operator's + /// configuration and a URL's authority are written by different people. + #[must_use] + pub fn allows(&self, host: &str) -> bool { + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() { + return false; + } + self.entries.iter().any(|allowed| { + let allowed = allowed.trim().to_ascii_lowercase(); + if allowed.is_empty() { + return false; + } + host == allowed || host.ends_with(&format!(".{allowed}")) + }) + } + + /// Whether the list permits nothing at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.iter().all(|entry| entry.trim().is_empty()) + } +} + +/// An [`HttpClient`] over `reqwest`, gated by the host's allowlist. +pub struct AllowlistHttpClient { + allowlist: HostAllowlist, + credentials: HashMap, + client: reqwest::Client, +} + +impl AllowlistHttpClient { + /// A client permitting only what `allowlist` allows, resolving + /// `connection_ref`s against `credentials`. + /// + /// # Panics + /// Panics if the underlying `reqwest` client cannot be built. A silent + /// fallback to `reqwest::Client::default()` would drop both the redirect + /// refusal and the timeouts configured below, producing a client that + /// looks the same but no longer enforces either guard — worse than + /// failing loudly at construction, which happens once at startup. + pub fn new(allowlist: HostAllowlist, credentials: HashMap) -> Self { + Self { + allowlist, + credentials, + // Redirects are refused rather than followed. A permitted host that + // 302s to `169.254.169.254` or to an unlisted domain would + // otherwise walk straight past both guards, since only the first + // URL is ever checked. A workflow that genuinely needs to follow one + // can make the second request itself, where it is checked again. + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_TIMEOUT) + .build() + .expect("reqwest client with static config must build"), + } + } + + /// Check the URL against both guards, returning the parsed URL and the + /// vetted addresses the request may connect to. + /// + /// The address list is returned rather than discarded because vetting a + /// name and then letting the transport resolve it a second time is a + /// rebinding window: a short-TTL answer can be private by the time the + /// connection is made. The caller pins the transport to exactly these. + async fn permit(&self, request: &Value) -> Result<(reqwest::Url, Vec)> { + let raw = request + .get("url") + .and_then(Value::as_str) + .ok_or_else(|| EngineError::Capability("http_request: no url".to_string()))?; + let url = reqwest::Url::parse(raw) + .map_err(|err| EngineError::Capability(format!("http_request: invalid url: {err}")))?; + + if !matches!(url.scheme(), "http" | "https") { + return Err(EngineError::Capability(format!( + "http_request: refusing scheme '{}'", + url.scheme() + ))); + } + let host = url + .host_str() + .ok_or_else(|| EngineError::Capability("http_request: url has no host".to_string()))?; + if is_private_host(host) { + return Err(EngineError::Capability(format!( + "http_request: refusing '{host}': loopback and private addresses are not \ + reachable from a workflow" + ))); + } + if !self.allowlist.allows(host) { + return Err(EngineError::Capability(format!( + "http_request: '{host}' is not in the configured http allowlist" + ))); + } + // Last, because it is the only check that touches the network: an + // allowlisted name must not resolve into a range the guard above + // refuses by literal. + let port = url + .port_or_known_default() + .unwrap_or(if url.scheme() == "https" { 443 } else { 80 }); + let vetted = resolve_with_timeout(host.to_string(), port).await?; + Ok((url, vetted)) + } + + /// A client that can only connect to `addrs` when it resolves `host`. + /// + /// Built per request rather than shared, because the override is a builder + /// option and the host is not known until one arrives. That costs a client + /// construction per call and gives up connection pooling; the alternative + /// is letting the transport perform its own lookup, which is precisely the + /// second resolution this exists to remove. + /// + /// An IP-literal host needs no override — there is no name to resolve, and + /// the literal has already been judged by `is_private_host`. + fn pinned(&self, host: &str, addrs: &[std::net::SocketAddr]) -> Result { + let bare = host.trim_matches(['[', ']']); + if addrs.is_empty() || bare.parse::().is_ok() { + return Ok(self.client.clone()); + } + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .resolve_to_addrs(bare, addrs) + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_TIMEOUT) + .build() + .map_err(|err| { + EngineError::Capability(format!("http_request: cannot build client: {err}")) + }) + } +} + +/// Whether an address is one a workflow must never reach. +/// +/// Loopback, link-local (which includes the cloud metadata endpoint at +/// `169.254.169.254`), and the RFC 1918 ranges. Reaching any of them from a +/// workflow means reaching services that trusted the network boundary. +pub fn is_private_addr(addr: &std::net::IpAddr) -> bool { + match addr { + std::net::IpAddr::V4(v4) => is_private_v4(v4), + std::net::IpAddr::V6(v6) => { + // An IPv4-mapped address is an IPv4 address wearing a hat: + // `::ffff:127.0.0.1` reaches loopback just as `127.0.0.1` does, so + // it must be judged by the same rules rather than falling through + // the v6 checks below. + if let Some(mapped) = v6.to_ipv4_mapped() { + return is_private_v4(&mapped); + } + v6.is_loopback() + || v6.is_unspecified() + // link-local fe80::/10 + || v6.segments()[0] & 0xffc0 == 0xfe80 + // unique-local fc00::/7 — the v6 answer to RFC 1918 + || v6.segments()[0] & 0xfe00 == 0xfc00 + } + } +} + +/// The IPv4 ranges a workflow must never reach. +fn is_private_v4(addr: &std::net::Ipv4Addr) -> bool { + // `is_shared` (`100.64.0.0/10`, RFC 6598 carrier-grade NAT) is not yet a + // stable `std` method, so the range is checked by hand. It fronts internal + // infrastructure on many cloud and carrier networks the same way RFC 1918 + // does, and an allowlisted name resolving into it must be refused for the + // same reason. + let shared = addr.octets()[0] == 100 && (addr.octets()[1] & 0xC0) == 0x40; + addr.is_loopback() + || addr.is_private() + || addr.is_link_local() + || addr.is_unspecified() + || shared +} + +/// Runs [`vet_resolution`] on a blocking-pool thread with a bounded wait. +/// +/// `permit` runs on the request path, and the synchronous +/// `ToSocketAddrs::to_socket_addrs` inside `vet_resolution` performs a real DNS +/// lookup — potentially a slow one, against a resolver this process does not +/// control. Run it off the async worker so a slow resolver cannot stall other +/// tasks sharing that thread; `CONNECT_TIMEOUT` only bounds the TCP handshake +/// that follows a successful resolution, not the lookup before it, so this +/// needs its own timeout. +/// +/// Timing out the join handle does not cancel an already-running blocking +/// call: `to_socket_addrs` keeps running to completion on the blocking pool +/// and its result is simply discarded once this returns. Tokio's blocking +/// pool has its own bound on concurrent threads, which caps how many stalled +/// lookups can accumulate; a resolver that hangs on every call still degrades +/// throughput, just not into an unbounded thread leak. +/// +/// `vet_resolution` itself stays synchronous — moving only the call site keeps +/// the existing unit tests, which call it directly, working unchanged. +async fn resolve_with_timeout(host: String, port: u16) -> Result> { + let lookup = tokio::task::spawn_blocking(move || vet_resolution(&host, port)); + match tokio::time::timeout(DNS_TIMEOUT, lookup).await { + Ok(Ok(resolved)) => resolved, + Ok(Err(_join_error)) => Err(EngineError::Capability( + "http_request: dns lookup task panicked".to_string(), + )), + Err(_elapsed) => Err(EngineError::Capability( + "http_request: dns lookup timed out".to_string(), + )), + } +} + +/// Every address `host` resolves to, refused if any is private. +/// +/// The textual check alone is not enough: an allowlisted name whose DNS answer +/// is `127.0.0.1` would otherwise pass both guards. Resolving makes the guard +/// depend on the network it is guarding, which is the trade — but the failure +/// mode of not resolving is an authored workflow reaching internal services, +/// and that is worse than a lookup. +/// +/// The vetted addresses are *returned*, not just judged: the caller pins the +/// transport to them, so the answer checked here is the answer connected to. A +/// second, independent lookup by the transport would reopen the rebinding gap +/// this closes — a name whose record flips to `169.254.169.254` between the two +/// resolutions passes the guard and reaches metadata anyway. +/// +/// A name that cannot be resolved at all is refused rather than allowed: the +/// request would fail anyway, and failing here says why. So is one that +/// resolves to nothing, which would otherwise pin the transport to an empty +/// set and let it fall back to its own lookup. +pub fn vet_resolution(host: &str, port: u16) -> Result> { + use std::net::ToSocketAddrs; + + let resolved: Vec = (host, port) + .to_socket_addrs() + .map_err(|err| { + EngineError::Capability(format!("http_request: cannot resolve '{host}': {err}")) + })? + .collect(); + + if let Some(private) = resolved.iter().find(|addr| is_private_addr(&addr.ip())) { + return Err(EngineError::Capability(format!( + "http_request: refusing '{host}': it resolves to {}, which is loopback or private", + private.ip() + ))); + } + if resolved.is_empty() { + return Err(EngineError::Capability(format!( + "http_request: cannot resolve '{host}': it has no addresses" + ))); + } + Ok(resolved) +} + +/// Whether a host *names* loopback, a link-local address, or an RFC 1918 range. +/// +/// The cheap textual guard, applied before any lookup. The authoritative check +/// is `vet_resolution`, which catches the names this cannot. +pub fn is_private_host(host: &str) -> bool { + let host = host.trim_matches(['[', ']']).to_ascii_lowercase(); + if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".internal") { + return true; + } + if let Ok(addr) = host.parse::() { + return is_private_addr(&addr); + } + false +} + +#[async_trait] +impl HttpClient for AllowlistHttpClient { + async fn request(&self, request: Value, conn: Option<&str>) -> Result { + let (url, vetted) = self.permit(&request).await?; + let summary = redacted_summary(&request); + // Pinned to the addresses just vetted, so the connection cannot go + // anywhere a second DNS answer might point. + let host = url.host_str().unwrap_or_default().to_string(); + let client = self.pinned(&host, &vetted)?; + + // Resolve the credential before building the request, so an unknown name + // fails before anything leaves the process. + let credential = match http_cred_name(conn)? { + Some(name) => Some(self.credentials.get(name).cloned().ok_or_else(|| { + EngineError::Capability(format!("http_request: unknown credential '{name}'")) + })?), + None => None, + }; + // `permit` allows both `http` and `https` — plain `http` is a + // legitimate destination on its own. It stops being one the moment a + // credential is going to ride along: `http` is cleartext, so a + // resolved credential would be sent unencrypted. Refuse before + // `inject_credential` rather than after, so the secret never touches + // the outgoing request. + if credential.is_some() && url.scheme() == "http" { + return Err(EngineError::Capability( + "http_request: refusing to send a credential over plain http".to_string(), + )); + } + let request = match &credential { + Some(cred) => inject_credential(request, cred), + None => request, + }; + + let method = request + .get("method") + .and_then(Value::as_str) + .unwrap_or("GET") + .to_ascii_uppercase(); + let method = reqwest::Method::from_bytes(method.as_bytes()) + .map_err(|err| EngineError::Capability(format!("http_request: {err}")))?; + + let mut builder = client.request(method, url); + if let Some(headers) = request.get("headers").and_then(Value::as_object) { + for (name, value) in headers { + if let Some(value) = value.as_str() { + builder = builder.header(name, value); + } + } + } + if let Some(body) = request.get("body") { + builder = builder.json(body); + } + + let response = builder + .send() + .await + .map_err(|err| EngineError::Capability(format!("http_request: {summary}: {err}")))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|err| EngineError::Capability(format!("http_request: {summary}: {err}")))?; + let json: Option = serde_json::from_str(&text).ok(); + + Ok(serde_json::json!({ + "status": status, + "text": text, + "json": json, + })) + } +} + +#[cfg(test)] +#[path = "http_tests.rs"] +mod tests; diff --git a/src/caps/host/http_tests.rs b/src/caps/host/http_tests.rs new file mode 100644 index 00000000..1d6b378a --- /dev/null +++ b/src/caps/host/http_tests.rs @@ -0,0 +1,184 @@ +//! Tests for the allowlisted HTTP client: the two guards, and credentials. +//! +//! The guards are the reason this implementation is shared rather than rewritten +//! per host. Each case here is one way an `http_request` node could otherwise +//! reach something it must not: a private literal, a public name whose DNS +//! answer is private, an IPv4 address wearing an IPv6 hat, a `connection_ref` +//! that names nothing. + +use std::collections::HashMap; + +use serde_json::json; + +use super::*; +use crate::caps::HttpClient; + +/// A client permitting `hosts` and holding no credentials. +fn client(hosts: &[&str]) -> AllowlistHttpClient { + AllowlistHttpClient::new(HostAllowlist::new(hosts.to_vec()), HashMap::new()) +} + +#[tokio::test] +async fn http_refuses_loopback_and_anything_off_the_allowlist() { + let client = client(&["example.com"]); + + // Loopback is refused even though the test could otherwise serve it — a + // workflow reaching localhost is reaching services that trusted the network + // boundary. + let loopback = client + .request(json!({ "url": "http://127.0.0.1:8080/x" }), None) + .await + .expect_err("loopback"); + assert!(loopback.to_string().contains("private"), "got {loopback}"); + + let off_list = client + .request(json!({ "url": "https://elsewhere.test/x" }), None) + .await + .expect_err("not allowlisted"); + assert!(off_list.to_string().contains("allowlist"), "got {off_list}"); +} + +#[test] +fn an_empty_allowlist_permits_nothing() { + // The default, and the reason a freshly installed workflow cannot become an + // exfiltration path without an operator saying so first. + let list = HostAllowlist::default(); + + assert!(list.is_empty()); + assert!(!list.allows("example.com")); +} + +#[test] +fn an_allowlist_entry_covers_its_subdomains_but_not_its_lookalikes() { + let list = HostAllowlist::new(["Example.com "]); + + assert!(list.allows("example.com")); + assert!(list.allows("api.example.com")); + assert!(!list.allows("notexample.com")); + assert!(!list.allows("")); +} + +#[test] +fn private_host_detection_covers_loopback_names_and_ranges_but_not_lookalikes() { + for private in [ + "localhost", + "127.0.0.1", + "10.1.2.3", + "192.168.0.1", + "169.254.1.1", + "::1", + "db.internal", + ] { + assert!(is_private_host(private), "{private} should be refused"); + } + for public in ["example.com", "notlocalhost.com", "8.8.8.8"] { + assert!(!is_private_host(public), "{public} should be reachable"); + } +} + +#[test] +fn an_unrecognised_connection_ref_fails_closed_rather_than_sending_unauthenticated() { + assert_eq!(http_cred_name(None).unwrap(), None); + assert_eq!(http_cred_name(Some("http_cred:ci")).unwrap(), Some("ci")); + + // Silently dropping it would send the request anyway, without the + // credential the author asked for. + assert!(http_cred_name(Some("composio:abc")).is_err()); + assert!(http_cred_name(Some("http_cred:")).is_err()); +} + +#[test] +fn a_credential_is_injected_after_the_summary_is_taken() { + let request = json!({ "method": "post", "url": "https://example.com/x" }); + let summary = redacted_summary(&request); + let sent = inject_credential( + request, + &HttpCredential { + header: "Authorization".into(), + value: "Bearer super-secret".into(), + }, + ); + + assert_eq!(summary, "POST https://example.com/x"); + assert!( + !summary.contains("super-secret"), + "a secret must never reach a log or an approval prompt" + ); + assert_eq!(sent["headers"]["Authorization"], "Bearer super-secret"); +} + +#[test] +fn private_address_detection_covers_the_cloud_metadata_endpoint() { + // The one an SSRF is usually aiming for, and the reason link-local is + // refused rather than only loopback. + let metadata: std::net::IpAddr = "169.254.169.254".parse().unwrap(); + assert!(is_private_addr(&metadata)); + + for private in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1", "::1"] { + let addr: std::net::IpAddr = private.parse().unwrap(); + assert!(is_private_addr(&addr), "{private}"); + } + for public in ["8.8.8.8", "1.1.1.1"] { + let addr: std::net::IpAddr = public.parse().unwrap(); + assert!(!is_private_addr(&addr), "{public}"); + } +} + +#[tokio::test] +async fn an_allowlisted_name_that_resolves_to_loopback_is_still_refused() { + // The textual guard cannot catch this: `localtest.me` and friends are + // ordinary names whose DNS answer is 127.0.0.1. Resolving is what closes + // the rebinding gap. + let result = client(&["localtest.me"]) + .request(json!({ "url": "http://localtest.me/x" }), None) + .await; + + // Either it resolved to loopback and was refused for that, or this machine + // has no DNS for the name and it was refused for that — never sent. + let err = result.expect_err("must not be sent"); + let message = err.to_string(); + assert!( + message.contains("loopback or private") || message.contains("cannot resolve"), + "got {message}" + ); +} + +#[test] +fn vetting_returns_the_very_addresses_the_request_will_be_pinned_to() { + // The vetted list is the point: it is handed to the transport as a DNS + // override, so the answer checked here is the answer connected to and a + // second lookup cannot rebind the name to something private in between. + let refused = vet_resolution("localhost", 80).expect_err("loopback must not be vetted"); + assert!( + refused.to_string().contains("loopback or private"), + "{refused}" + ); + + // An IP literal resolves to itself, so a public one vets to exactly one + // address and pins the transport to it. + let vetted = vet_resolution("93.184.216.34", 443).expect("a public literal"); + assert_eq!( + vetted, + vec!["93.184.216.34:443".parse::().unwrap()] + ); +} + +#[test] +fn an_ipv4_mapped_ipv6_loopback_is_recognised_as_private() { + // `::ffff:127.0.0.1` reaches loopback exactly as `127.0.0.1` does, so + // judging it by the v6 rules alone would let it through. + for mapped in [ + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "::ffff:169.254.169.254", + ] { + let addr: std::net::IpAddr = mapped.parse().unwrap(); + assert!(is_private_addr(&addr), "{mapped}"); + } + // Unique-local fc00::/7 — the v6 answer to RFC 1918. + let ula: std::net::IpAddr = "fd00::1".parse().unwrap(); + assert!(is_private_addr(&ula)); + + let public: std::net::IpAddr = "::ffff:8.8.8.8".parse().unwrap(); + assert!(!is_private_addr(&public)); +} diff --git a/src/caps/host/mocks.rs b/src/caps/host/mocks.rs new file mode 100644 index 00000000..39dda29d --- /dev/null +++ b/src/caps/host/mocks.rs @@ -0,0 +1,120 @@ +//! Capability stand-ins for dry runs. +//! +//! The engine ships mocks that echo their request back. That is enough to prove +//! a graph *executes*, but not that it is correct: a node declaring an +//! `output_parser.schema` will have its echoed response fail validation, so a +//! perfectly good graph fails a simulation for a reason that has nothing to do +//! with the graph. A sibling host implementation hit exactly that and answered +//! it with schema-aware mocks; these are the same idea. +//! +//! A dry run therefore means: every expression resolved, every node's declared +//! output shape was satisfiable, and nothing left the process. + +use crate::caps::{AgentRunner, LlmProvider}; +use crate::error::Result; +use async_trait::async_trait; +use serde_json::{Value, json}; + +/// Synthesize a value satisfying a JSON Schema well enough to pass validation. +/// +/// Deliberately shallow — it honours `type`, `properties`, `required`, and +/// `enum`, which is what node schemas in practice use. Anything it does not +/// understand becomes null, and a schema strict enough to reject that is a +/// schema whose graph deserves a real run before being trusted. +pub fn sample_for_schema(schema: &Value) -> Value { + let Some(object) = schema.as_object() else { + return Value::Null; + }; + if let Some(first) = object + .get("enum") + .and_then(Value::as_array) + .and_then(|v| v.first()) + { + return first.clone(); + } + match object.get("type").and_then(Value::as_str) { + Some("object") => { + let mut out = serde_json::Map::new(); + if let Some(properties) = object.get("properties").and_then(Value::as_object) { + // Every declared property, not only the required ones: a graph + // binding `=item.json.optional_field` should still resolve. + for (name, property) in properties { + out.insert(name.clone(), sample_for_schema(property)); + } + } + Value::Object(out) + } + Some("array") => match object.get("items") { + // One element, so a downstream `per_item` node has something to map + // over and a `[0]` expression resolves. + Some(items) => json!([sample_for_schema(items)]), + None => json!([]), + }, + Some("string") => json!("sample"), + Some("integer") | Some("number") => json!(0), + Some("boolean") => json!(false), + _ => Value::Null, + } +} + +/// The `output_parser.schema` a request declares, if any. +fn declared_schema(request: &Value) -> Option<&Value> { + request.get("output_parser")?.get("schema") +} + +/// The response a schema-aware mock returns for `request`. +fn mock_response(request: &Value, source: &str) -> Value { + match declared_schema(request) { + Some(schema) => { + let sample = sample_for_schema(schema); + json!({ + "text": serde_json::to_string(&sample).unwrap_or_default(), + "json": sample, + "mock": source, + }) + } + None => json!({ + "text": format!("[{source} dry run]"), + "json": Value::Null, + "mock": source, + }), + } +} + +/// An [`LlmProvider`] whose response satisfies the node's declared schema. +pub struct SchemaAwareMockLlm; + +#[async_trait] +impl LlmProvider for SchemaAwareMockLlm { + async fn complete(&self, request: Value, _conn: Option<&str>) -> Result { + Ok(mock_response(&request, "llm")) + } +} + +/// An [`AgentRunner`] whose response satisfies the node's declared schema. +/// +/// Dispatches nothing: the whole point of a dry run is that no harness session +/// is started and no repository is touched. +pub struct SchemaAwareMockAgentRunner; + +#[async_trait] +impl AgentRunner for SchemaAwareMockAgentRunner { + async fn run_agent( + &self, + agent_ref: &str, + request: Value, + _conn: Option<&str>, + ) -> Result { + let mut response = mock_response(&request, "agent"); + if let Some(object) = response.as_object_mut() { + // Recorded so a dry run's output shows *which* worker each node + // would have gone to — the thing an author most often gets wrong. + object.insert("agent_ref".into(), Value::String(agent_ref.to_string())); + } + Ok(response) + } +} + +#[cfg(test)] +#[path = "mocks_tests.rs"] +mod tests; diff --git a/src/caps/host/mocks_tests.rs b/src/caps/host/mocks_tests.rs new file mode 100644 index 00000000..a287b0cb --- /dev/null +++ b/src/caps/host/mocks_tests.rs @@ -0,0 +1,24 @@ +//! Tests for the schema-aware dry-run stand-ins. + +use serde_json::json; + +use super::*; + +#[test] +fn a_dry_run_sample_satisfies_the_shape_a_node_declared() { + let sample = sample_for_schema(&json!({ + "type": "object", + "properties": { + "title": { "type": "string" }, + "count": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "state": { "enum": ["open", "closed"] } + } + })); + + assert!(sample["title"].is_string()); + assert!(sample["count"].is_number()); + // One element, so a downstream per-item node has something to map over. + assert_eq!(sample["tags"].as_array().unwrap().len(), 1); + assert_eq!(sample["state"], "open"); +} diff --git a/src/caps/host/mod.rs b/src/caps/host/mod.rs new file mode 100644 index 00000000..b9258247 --- /dev/null +++ b/src/caps/host/mod.rs @@ -0,0 +1,57 @@ +//! Ready-made capability implementations for a host that runs on an ordinary +//! machine. +//! +//! [`crate::caps`] states what the engine needs and deliberately implements +//! none of it, so the crate never hard-codes a vendor. That rule is about +//! *policy* — which model, which tools, which network, which sandbox — and it +//! stands. But several capabilities have no vendor in them at all: writing a +//! script to a temporary file and reading its stdout, keying JSON documents onto +//! disk, refusing a URL that resolves into a private range. Every host that runs +//! outside a sandbox needs those, and each one that wrote them itself wrote the +//! same subtle parts again: the stdin/stdout deadlock, the DNS-rebinding window +//! between vetting a name and connecting to it, the traversal check that has to +//! canonicalize because a symlink inside the workspace can still point out of +//! it. +//! +//! So they live here, behind the `host-caps` feature, as *implementations a host +//! may choose* rather than behaviour the engine assumes. Nothing in the engine +//! reaches into this module; a host wires what it wants into +//! [`Capabilities`](crate::caps::Capabilities) and supplies its own for the +//! rest. A host with a real sandbox should implement [`CodeRunner`] and +//! [`ShellRunner`] over that instead — these run a script with the privileges of +//! the process that started it, and say so. +//! +//! # What is here +//! +//! - [`script`] — the out-of-process script runner and its calling convention. +//! - [`script_policy`] — which files a script step may read and run in. +//! - [`code`] — [`CodeRunner`] for `code` nodes: refusing, or executing. +//! - [`shell`] — [`ShellRunner`] for `shell` nodes, over the same runner. +//! - [`state`] — [`StateStore`](crate::caps::StateStore) over files. +//! - [`http`] — [`HttpClient`](crate::caps::HttpClient) behind a host allowlist. +//! - [`mocks`] — schema-aware stand-ins for validating a graph by simulation. +//! +//! [`CodeRunner`]: crate::caps::CodeRunner +//! [`ShellRunner`]: crate::caps::ShellRunner + +pub mod code; +pub mod http; +pub mod mocks; +pub mod script; +pub mod script_policy; +pub mod shell; +pub mod state; + +pub use self::code::{DeniedCodeRunner, ProcessCodeRunner}; +pub use self::http::{ + AllowlistHttpClient, HTTP_CRED_PREFIX, HostAllowlist, HttpCredential, http_cred_name, + inject_credential, is_private_addr, is_private_host, redacted_summary, vet_resolution, +}; +pub use self::mocks::{SchemaAwareMockAgentRunner, SchemaAwareMockLlm, sample_for_schema}; +pub use self::script::{ + DEFAULT_SHELL, INPUT_ENV, Interpreter, ScriptCompletion, ScriptLanguage, ScriptOutput, + ScriptRequest, ScriptSource, USER_SHELL, run_script, run_script_capture, +}; +pub use self::script_policy::{ScriptPolicy, is_valid_env_name, read_env}; +pub use self::shell::ProcessShellRunner; +pub use self::state::FileStateStore; diff --git a/src/caps/host/script.rs b/src/caps/host/script.rs new file mode 100644 index 00000000..ff93528f --- /dev/null +++ b/src/caps/host/script.rs @@ -0,0 +1,529 @@ +//! Running a script out-of-process, for `code` and `shell` nodes. +//! +//! One executor behind both, so a workflow author learns one calling convention +//! rather than two. What it does is deliberately small: write the source to a +//! temporary file, run it, read stdout. +//! +//! # The calling convention +//! +//! A script gets its input **on stdin, as JSON**, and returns its result **on +//! stdout**. Stdout that parses as JSON becomes structured output; anything else +//! becomes a string, so a script that prints one line is still usable. +//! +//! Stdin rather than an argument because it is the one channel every language +//! reads the same way — `JSON.parse(require('fs').readFileSync(0,'utf8'))`, +//! `json.load(sys.stdin)`, `cat`. The input is *also* written to a file whose +//! path is `argv[1]` and [`INPUT_ENV`], because a large payload through a pipe +//! is awkward in shell and a path is not. +//! +//! # What this is not +//! +//! Not a sandbox. The child inherits this process's environment and privileges, +//! and the only boundary is a temporary working directory, which is not one. +//! What *is* checked, at the boundary above this one, is where a script may come +//! from and where it may run: see [`super::script_policy`]. A host decides +//! whether script steps are permitted at all; everything here is about making a +//! trusted script *work correctly*, not about containing an untrusted one. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::Value; + +use crate::error::{EngineError, Result}; + +/// The environment variable naming the file the script's JSON input was written +/// to. Always set, alongside the same path as `argv[1]`. +pub const INPUT_ENV: &str = "TINYFLOWS_INPUT"; + +/// A language this host can execute. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScriptLanguage { + /// Node.js. + JavaScript, + /// CPython 3. + Python, + /// POSIX shell, run with `bash` unless an [`Interpreter`] says otherwise. + Shell, +} + +impl ScriptLanguage { + /// The interpreter and the extension its file wants. + fn program(self) -> (&'static str, &'static str) { + match self { + Self::JavaScript => ("node", "js"), + Self::Python => ("python3", "py"), + Self::Shell => (DEFAULT_SHELL, "sh"), + } + } + + /// The name an author writes in a node's config. + pub fn as_str(self) -> &'static str { + match self { + Self::JavaScript => "javascript", + Self::Python => "python", + Self::Shell => "shell", + } + } + + /// Parse the name an author wrote, accepting the obvious spellings. + /// + /// Forgiving on purpose: an author who writes `bash`, `sh`, `js`, or `py` + /// meant something unambiguous, and refusing it teaches nothing. + pub fn parse(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "javascript" | "js" | "node" | "nodejs" => Some(Self::JavaScript), + "python" | "python3" | "py" => Some(Self::Python), + "shell" | "sh" | "bash" => Some(Self::Shell), + _ => None, + } + } + + /// Every spelling an author may write, for an error that teaches. + pub const NAMES: [&'static str; 3] = ["javascript", "python", "shell"]; + + /// Whether `name` picks a specific interpreter rather than naming the + /// shell family generically. + /// + /// `"bash"` and `"sh"` are an author saying *which* shell, and they said it + /// before `workflows.shell` existed — a step spelled that way keeps + /// [`DEFAULT_SHELL`] even on a host that configured another shell, so + /// enabling `shell = "zsh"` cannot silently re-run bash-specific scripts + /// somewhere else. Only the generic `"shell"` follows the host. + #[must_use] + pub fn pins_interpreter(name: &str) -> bool { + matches!(name.trim().to_ascii_lowercase().as_str(), "bash" | "sh") + } +} + +/// The shell a `shell` script runs under when nothing chooses another. +/// +/// Not the operator's login shell: an existing workflow's script was written +/// against *this*, and quietly re-running it under `fish` or `dash` because that +/// is what `$SHELL` happens to say would break it in ways that look like the +/// script's fault. Tracking the login shell is available, but it is opted into — +/// see [`Interpreter::resolve`]. +pub const DEFAULT_SHELL: &str = "bash"; + +/// The configured value that means "whatever the operator's login shell is". +pub const USER_SHELL: &str = "user"; + +/// The program a script runs under, and the arguments that precede its path. +/// +/// Exists so the shell is a decision rather than a constant. The reason an +/// operator reaches for it is almost always the same one: their own functions, +/// aliases, and `PATH` live in `~/.zshrc`, and a script run as +/// `bash ` — non-login, non-interactive — sees none of it. Naming `zsh` +/// with `args: ["-l"]` is what puts those back in scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Interpreter { + /// The program to spawn: a bare name resolved on `PATH`, or an absolute + /// path. + pub program: String, + /// Arguments passed before the script path — `["-l"]` for a login shell, + /// `["-l", "-i"]` to also get aliases, which are not exported. + pub args: Vec, +} + +impl Interpreter { + /// The default shell, with no leading arguments. + #[must_use] + pub fn default_shell() -> Self { + Self { + program: DEFAULT_SHELL.to_string(), + args: Vec::new(), + } + } + + /// Choose the interpreter an operator's configuration asks for. + /// + /// `configured` is `workflows.shell` (or a node's own `args.shell`): + /// + /// - empty — [`DEFAULT_SHELL`], so an unconfigured host is unchanged. + /// - [`USER_SHELL`] — the login shell `login_shell` reports, falling back to + /// [`DEFAULT_SHELL`] when the environment names none. This is the opt-in + /// that makes scripts run under whatever the operator actually uses. + /// - anything else — that program. + /// + /// `login_shell` is passed in rather than read from the environment here so + /// the choice is a pure function of its inputs, testable without mutating + /// process-global state. + /// + /// # Errors + /// + /// Refuses a program that is not a bare name or an absolute path, and any + /// program or argument carrying an interior NUL. A relative path is refused + /// rather than resolved because what it would resolve *against* is the + /// script's working directory, which the workflow author chose — so + /// `workflows.shell = "./sh"` would let a graph decide which binary the + /// operator's own configuration named. + pub fn resolve(configured: &str, args: &[String], login_shell: Option<&str>) -> Result { + let configured = configured.trim(); + let program = match configured { + "" => DEFAULT_SHELL, + USER_SHELL => login_shell + .map(str::trim) + .filter(|shell| !shell.is_empty()) + .unwrap_or(DEFAULT_SHELL), + other => other, + }; + Self::validated(program, args) + } + + /// An interpreter from an already-chosen program name, checked. + /// + /// # Errors + /// + /// As [`resolve`](Self::resolve). + pub fn validated(program: &str, args: &[String]) -> Result { + let program = program.trim(); + if program.is_empty() { + return Err(refused("the interpreter must not be empty")); + } + if program.contains('\0') { + return Err(refused(format!( + "the interpreter {program:?} contains a NUL byte, which cannot be passed to a \ + process" + ))); + } + // `is_separator` rather than `MAIN_SEPARATOR`: Windows accepts `/` as + // well as `\`, so matching only the platform's *preferred* separator + // would wave `./sh` straight through on the one platform where two + // spellings exist. It stays exact on unix, where `\` is an ordinary + // filename character. + if program.chars().any(std::path::is_separator) && !Path::new(program).is_absolute() { + return Err(refused(format!( + "the interpreter {program:?} is a relative path; name a program on PATH (\"zsh\") \ + or give an absolute path (\"/bin/zsh\")" + ))); + } + for arg in args { + if arg.contains('\0') { + return Err(refused(format!( + "the interpreter argument {arg:?} contains a NUL byte, which cannot be passed \ + to a process" + ))); + } + } + Ok(Self { + program: program.to_string(), + args: args.to_vec(), + }) + } + + /// The operator's login shell, as the environment reports it. + /// + /// `$SHELL` only — no `/etc/passwd` lookup, because the environment is what + /// a daemon started from a login session actually carries, and a passwd + /// entry would disagree with it exactly when a user has changed shells + /// without re-logging in. + #[must_use] + pub fn login_shell() -> Option { + std::env::var("SHELL").ok() + } +} + +/// A refusal about the interpreter, prefixed so a run record says what refused. +fn refused(message: impl AsRef) -> EngineError { + EngineError::Capability(format!("script: {}", message.as_ref())) +} + +impl From for ScriptLanguage { + fn from(language: crate::caps::CodeLanguage) -> Self { + match language { + crate::caps::CodeLanguage::JavaScript => Self::JavaScript, + crate::caps::CodeLanguage::Python => Self::Python, + } + } +} + +/// What a script run produced. +#[derive(Debug)] +pub struct ScriptOutput { + /// Stdout, parsed as JSON when it is JSON. + pub value: Value, + /// Stderr, kept whether or not the script succeeded. + /// + /// A script that works and warns is the normal case, and discarding what it + /// said would hide the one thing its author wrote for a reader. + pub stderr: String, +} + +/// What to run: source this host stages, or a file that already exists. +#[derive(Debug, Clone, Copy)] +pub enum ScriptSource<'a> { + /// Source text, written to a temporary file before it is run. + Inline(&'a str), + /// An existing script file. + /// + /// Whether a workflow may reach this path is decided *before* it gets here, + /// by [`super::script_policy`]; nothing below re-checks it. + File(&'a Path), +} + +/// Everything one script run needs. +/// +/// A struct rather than six positional arguments, two of which are paths and +/// three of which are optional — an order a caller would eventually get wrong +/// without the compiler noticing. +#[derive(Debug, Clone, Copy)] +pub struct ScriptRequest<'a> { + /// The language the script is written in. Decides the interpreter, unless + /// `interpreter` names one, and always decides the staged file's extension. + pub language: ScriptLanguage, + /// The interpreter to run under, overriding the one `language` implies. + /// + /// `None` keeps the language's own program, which is what a `code` node + /// wants: its `javascript` and `python` are the contract, not a preference. + pub interpreter: Option<&'a Interpreter>, + /// The script itself. + pub source: ScriptSource<'a>, + /// The JSON handed to the script on stdin, and written to `argv[1]`. + pub input: &'a Value, + /// How long the script may run before it is abandoned. + pub timeout: Duration, + /// The directory to run in. `None` uses the temporary directory holding the + /// staged script — right for a pure computation, wrong for anything that + /// means to touch the operator's project. + pub cwd: Option<&'a Path>, + /// Environment variables layered over the inherited environment. + pub env: &'a BTreeMap, +} + +impl<'a> ScriptRequest<'a> { + /// A request with no working directory and no extra environment — the shape + /// a pure computation over `input` wants. + pub fn plain( + language: ScriptLanguage, + source: &'a str, + input: &'a Value, + timeout: Duration, + env: &'a BTreeMap, + ) -> Self { + Self { + language, + interpreter: None, + source: ScriptSource::Inline(source), + input, + timeout, + cwd: None, + env, + } + } + + /// The program this request will spawn — the interpreter it names, or the + /// one its language implies. + /// + /// Public because an error message naming the program is the difference + /// between "the script failed" and "`node` is not installed". + #[must_use] + pub fn program(&self) -> &str { + match self.interpreter { + Some(chosen) => chosen.program.as_str(), + None => self.language.program().0, + } + } +} + +/// Everything a finished script run produced, including a failing exit status. +/// +/// Separate from [`ScriptOutput`] because the two callers disagree about what a +/// non-zero exit *is*. A `code` node's contract is a value, so a script that +/// exits non-zero produced none and the step failed. A `shell` node's contract +/// is a process, so its exit code is part of the answer and the node — not this +/// runner — decides what a failure means. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScriptCompletion { + /// The process exit status, or `-1` when a signal terminated it. + pub exit_code: i32, + /// Everything the script wrote to standard output, lossily decoded. + pub stdout: String, + /// Everything the script wrote to standard error, lossily decoded. + pub stderr: String, +} + +/// Run the script `request` describes, requiring it to succeed. +/// +/// # Errors +/// +/// Fails when the interpreter is missing, the script exits non-zero, or it +/// outlives `request.timeout`. The message carries the interpreter's own stderr, +/// which is the only thing that says what actually went wrong. +pub async fn run_script(request: ScriptRequest<'_>) -> Result { + /// Bytes of `stderr` folded into the error message. This message becomes + /// `RunRecord::error` once the engine surfaces it, which — unlike step + /// `input`/`output` — is not passed through `bounded_within`; a script + /// that dumps a large stack trace must not be able to grow that field + /// without limit. + const MAX_STDERR_BYTES: usize = 4 * 1024; + + let program = request.program().to_string(); + let completion = run_script_capture(request).await?; + if completion.exit_code != 0 { + let stderr = if completion.stderr.len() > MAX_STDERR_BYTES { + let end = completion + .stderr + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= MAX_STDERR_BYTES) + .last() + .unwrap_or(0); + format!("{} …[truncated]", &completion.stderr[..end]) + } else { + completion.stderr.clone() + }; + return Err(EngineError::Capability(format!( + "script: {program} exited with {}{}", + completion.exit_code, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ))); + } + Ok(ScriptOutput { + // Structured when the script printed JSON, the raw text otherwise — a + // script that just prints a line should still be usable downstream. + value: serde_json::from_str(&completion.stdout) + .unwrap_or(Value::String(completion.stdout.clone())), + stderr: completion.stderr, + }) +} + +/// Run the script `request` describes, reporting its exit status rather than +/// treating a non-zero one as a failure. +/// +/// # Errors +/// +/// Fails when the interpreter cannot be spawned, the request cannot be staged +/// on disk, or the script outlives `request.timeout` — that is, when there is no +/// exit status to report at all. +pub async fn run_script_capture(request: ScriptRequest<'_>) -> Result { + use tokio::io::AsyncWriteExt; + + let ScriptRequest { + language, + interpreter, + source, + input, + timeout, + cwd, + env, + } = request; + + // Refused rather than emulated. `argv[1]` and `TINYFLOWS_INPUT` are real + // filesystem paths this host wrote (`C:\...` on Windows), and Git Bash — the + // only `bash` a Windows host is likely to have — cannot open a Windows path + // without translating it, which is exactly the kind of per-platform + // reinterpretation that would make a workflow look portable while quietly + // behaving differently by host. `javascript` and `python` need no such + // translation and stay available everywhere their interpreter is. + #[cfg(windows)] + if language == ScriptLanguage::Shell { + return Err(EngineError::Capability( + "script: shell scripts are not supported on Windows (no portable POSIX shell to \ + run them in); use language: \"javascript\" or \"python\" instead" + .to_string(), + )); + } + + // The extension always comes from the language; only the program is + // negotiable. A `.sh` staged for `zsh` is still a shell script. + let (default_program, extension) = language.program(); + let (program, leading_args) = match interpreter { + Some(chosen) => (chosen.program.as_str(), chosen.args.as_slice()), + None => (default_program, &[][..]), + }; + let dir = + tempfile::tempdir().map_err(|err| EngineError::Capability(format!("script: {err}")))?; + + let script: PathBuf = match source { + ScriptSource::Inline(source) => { + let staged = dir.path().join(format!("script.{extension}")); + std::fs::write(&staged, source) + .map_err(|err| EngineError::Capability(format!("script: {err}")))?; + staged + } + ScriptSource::File(path) => path.to_path_buf(), + }; + + // The input reaches the script two ways because the languages want + // different ones: a pipe reads naturally in node and python, a path reads + // naturally in shell. Writing both costs one small file. + let input_path = dir.path().join("input.json"); + let body = serde_json::to_vec(input) + .map_err(|err| EngineError::Capability(format!("script: {err}")))?; + std::fs::write(&input_path, &body) + .map_err(|err| EngineError::Capability(format!("script: {err}")))?; + + let mut command = tokio::process::Command::new(program); + command + .args(leading_args) + .arg(&script) + .arg(&input_path) + .env(INPUT_ENV, &input_path) + // Layered after `INPUT_ENV` so a workflow's own declaration wins over + // the inherited value of the same name — that is what declaring one is + // for. A host that wants the path under a second name of its own (an + // older, product-specific spelling its authored workflows already use) + // puts it here. + .envs(env) + .current_dir(cwd.unwrap_or_else(|| dir.path())) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + // Tokio does not kill a child when its future is dropped, so a timeout + // would otherwise leave an infinite script running forever with nothing + // holding a handle to it. + .kill_on_drop(true); + + let mut child = command.spawn().map_err(|err| { + EngineError::Capability(format!( + "script: cannot run `{program}` ({err}). Is it installed and on PATH?" + )) + })?; + + // Writing stdin and draining stdout/stderr happen concurrently, not one + // after the other: a script that prints before it finishes reading stdin + // fills its stdout pipe while this side is still blocked in `write_all` on + // stdin, and neither side would ever unblock the other — a real deadlock, + // not just a slow path, for any input near the OS pipe buffer size. The + // writer runs on its own task so `wait_with_output` starts reading + // immediately; if the child exits without reading all of stdin, the pipe + // simply closes underneath the writer, which surfaces as a write error we + // ignore (the exit status and stderr are the story in that case, not this). + let mut stdin = child.stdin.take(); + let writer = tokio::spawn(async move { + if let Some(mut stdin) = stdin.take() { + let _ = stdin.write_all(&body).await; + let _ = stdin.shutdown().await; + } + }); + let output = tokio::time::timeout(timeout, async { + let output = child.wait_with_output().await; + // Joined so a slow writer is still bounded by `timeout` above, not left + // running past the point this function returns. + let _ = writer.await; + output + }) + .await + .map_err(|_| { + EngineError::Capability(format!("script: timed out after {}s", timeout.as_secs())) + })? + .map_err(|err| EngineError::Capability(format!("script: {program}: {err}")))?; + + Ok(ScriptCompletion { + // `-1` for a signal: `ExitStatus::code()` is `None` when a process was + // terminated rather than exiting, and a caller comparing against zero + // must not read that as success. + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) +} + +#[cfg(test)] +#[path = "script_tests.rs"] +mod tests; diff --git a/src/caps/host/script_policy.rs b/src/caps/host/script_policy.rs new file mode 100644 index 00000000..3e0e1ecf --- /dev/null +++ b/src/caps/host/script_policy.rs @@ -0,0 +1,210 @@ +//! Validating the untrusted parts of a script step's arguments. +//! +//! A workflow arrives as a file — possibly written by an agent, possibly copied +//! from somewhere. Its `script_path`, `cwd`, and `env` are author-supplied +//! strings that reach the operating system, so they are treated like every other +//! untrusted input in this crate: checked at the boundary, against the +//! operator's own configuration, before anything is spawned. +//! +//! The boundary is the configured workspace. [`super::script`] is deliberately +//! not a sandbox and does not pretend to be one; what this module bounds is +//! narrower and worth having anyway — *which file* a step may execute and +//! *which directory* it may run in. A workflow naming `../../../.ssh/id_rsa` +//! or `/etc/cron.d/x` as its script is answered rather than obeyed. +//! +//! Nothing here restricts what a script does once it runs. It cannot: this host +//! has no sandbox, which is exactly what `workflows.allowCode` says. + +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; + +use crate::error::{EngineError, Result}; + +/// Where a script step may read a script from and run. +#[derive(Debug, Clone, Default)] +pub struct ScriptPolicy { + /// The operator's workspace, and the only directory a `script_path` or a + /// `cwd` may resolve inside. + /// + /// `None` when no workspace is configured, which refuses both: a step can + /// still run an inline script, in a scratch directory, which needs no + /// filesystem policy at all. + workspace: Option, +} + +impl ScriptPolicy { + /// A policy rooted at `workspace`, or refusing paths entirely when it is + /// empty or is not a directory. + pub fn new(workspace: &str) -> Self { + let candidate = Path::new(workspace); + Self { + workspace: (!workspace.trim().is_empty() && candidate.is_dir()) + .then(|| candidate.to_path_buf()), + } + } + + /// The workspace itself, when one is configured. + /// + /// This is the directory a script runs in unless the step named another. + pub fn workspace(&self) -> Option<&Path> { + self.workspace.as_deref() + } + + /// Resolves an author-supplied script path to a readable file in the + /// workspace. + /// + /// # Errors + /// Refuses when no workspace is configured, when `raw` is absolute or + /// traverses upwards, when the resolved path escapes the workspace + /// (following symlinks), or when it is not an existing regular file. + pub fn resolve_script(&self, raw: &str) -> Result { + let resolved = self.resolve(raw, "script_path")?; + if !resolved.is_file() { + return Err(refused(format!( + "`args.script_path` ('{raw}') is not a file in the workspace" + ))); + } + Ok(resolved) + } + + /// Resolves an author-supplied working directory in the workspace. + /// + /// # Errors + /// As [`Self::resolve_script`], except the result must be a directory. + pub fn resolve_cwd(&self, raw: &str) -> Result { + let resolved = self.resolve(raw, "cwd")?; + if !resolved.is_dir() { + return Err(refused(format!( + "`args.cwd` ('{raw}') is not a directory in the workspace" + ))); + } + Ok(resolved) + } + + /// The shared resolution: reject the shape, then reject the destination. + /// + /// Both halves are load-bearing. The syntactic check answers the obvious + /// `../../etc/passwd` without touching the disk; canonicalizing and + /// re-checking afterwards is what catches a symlink *inside* the workspace + /// pointing out of it, which no amount of string inspection would have seen. + fn resolve(&self, raw: &str, field: &str) -> Result { + let Some(workspace) = &self.workspace else { + return Err(refused(format!( + "`args.{field}` needs a configured workspace to resolve against, and this host \ + has none; pass `args.script` instead" + ))); + }; + + let candidate = Path::new(raw); + if candidate.is_absolute() { + return Err(refused(format!( + "`args.{field}` ('{raw}') must be relative to the workspace, not absolute" + ))); + } + if candidate.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(refused(format!( + "`args.{field}` ('{raw}') must not traverse outside the workspace" + ))); + } + + let workspace = workspace.canonicalize().map_err(|err| { + refused(format!( + "the configured workspace ({}) is unreadable: {err}", + workspace.display() + )) + })?; + let resolved = workspace.join(candidate).canonicalize().map_err(|err| { + refused(format!( + "`args.{field}` ('{raw}') does not resolve inside the workspace: {err}" + )) + })?; + if !resolved.starts_with(&workspace) { + return Err(refused(format!( + "`args.{field}` ('{raw}') resolves outside the workspace" + ))); + } + Ok(resolved) + } +} + +/// Whether `name` is a usable environment-variable name. +/// +/// Stricter than the kernel on purpose: a name carrying `=` or a NUL would be +/// rejected by `execve` anyway, and one carrying a space or a newline is far +/// more likely to be a mistake in the workflow than a deliberate choice. +pub fn is_valid_env_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first.is_ascii_alphabetic() || first == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Reads an author-supplied `args.env` object into a string map. +/// +/// Only strings are accepted: coercing a number or a boolean would make the +/// value a script actually sees depend on JSON formatting rather than on what +/// the author wrote. +/// +/// # Errors +/// Refuses a non-object, a non-string value, a malformed variable name, or a +/// value containing an interior NUL. +pub fn read_env(value: Option<&serde_json::Value>) -> Result> { + let Some(value) = value else { + return Ok(BTreeMap::new()); + }; + let object = value.as_object().ok_or_else(|| { + refused("`args.env` must be an object mapping variable names to string values") + })?; + + object + .iter() + .map(|(name, value)| { + if !is_valid_env_name(name) { + return Err(refused(format!( + "`args.env` has an invalid variable name '{name}'; expected letters, digits, \ + and underscores, not starting with a digit" + ))); + } + let value = value.as_str().ok_or_else(|| { + refused(format!( + "`args.env.{name}` must be a string, not {}", + kind_of(value) + )) + })?; + if value.contains('\0') { + return Err(refused(format!( + "`args.env.{name}` contains a NUL byte, which cannot be passed to a process" + ))); + } + Ok((name.clone(), value.to_string())) + }) + .collect() +} + +/// A short name for a JSON value's type, for an error that teaches. +fn kind_of(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "a boolean", + serde_json::Value::Number(_) => "a number", + serde_json::Value::String(_) => "a string", + serde_json::Value::Array(_) => "an array", + serde_json::Value::Object(_) => "an object", + } +} + +/// A refusal, prefixed so a run record says which step surface refused. +fn refused(message: impl AsRef) -> EngineError { + EngineError::Capability(format!("shell: {}", message.as_ref())) +} + +#[cfg(test)] +#[path = "script_policy_tests.rs"] +mod tests; diff --git a/src/caps/host/script_policy_tests.rs b/src/caps/host/script_policy_tests.rs new file mode 100644 index 00000000..801bb061 --- /dev/null +++ b/src/caps/host/script_policy_tests.rs @@ -0,0 +1,194 @@ +//! Tests for the script-step boundary: which files a step may run, which +//! directories it may run in, and which environment it may declare. +//! +//! Filesystem-only and offline — nothing here spawns a process. + +use serde_json::json; + +use super::{ScriptPolicy, is_valid_env_name, read_env}; + +/// A workspace with `scripts/build.sh` and a `project/` directory in it. +fn workspace() -> tempfile::TempDir { + let root = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(root.path().join("scripts")).expect("mkdir"); + std::fs::create_dir(root.path().join("project")).expect("mkdir"); + std::fs::write(root.path().join("scripts/build.sh"), "printf built\n").expect("write"); + root +} + +fn policy_at(root: &tempfile::TempDir) -> ScriptPolicy { + ScriptPolicy::new(&root.path().to_string_lossy()) +} + +/// A path this platform actually considers absolute. +/// +/// `/etc/passwd` is *not* absolute on Windows — it has no drive prefix — so a +/// test hard-coding it would take the traversal branch there and assert against +/// the wrong message. +const fn absolute_path() -> &'static str { + #[cfg(windows)] + { + r"C:\Windows\System32\drivers\etc\hosts" + } + #[cfg(not(windows))] + { + "/etc/passwd" + } +} + +#[test] +fn a_script_in_the_workspace_resolves() { + let root = workspace(); + let resolved = policy_at(&root) + .resolve_script("scripts/build.sh") + .expect("a file in the workspace resolves"); + + assert!(resolved.ends_with("scripts/build.sh")); + assert!(resolved.is_absolute(), "the caller gets a usable path"); +} + +#[test] +fn a_directory_in_the_workspace_resolves_as_a_working_directory() { + let root = workspace(); + let resolved = policy_at(&root) + .resolve_cwd("project") + .expect("a directory in the workspace resolves"); + + assert!(resolved.ends_with("project")); +} + +#[test] +fn the_workspace_is_the_default_directory_a_step_runs_in() { + let root = workspace(); + assert_eq!(policy_at(&root).workspace(), Some(root.path())); +} + +#[test] +fn absolute_and_traversing_paths_are_refused() { + let root = workspace(); + let policy = policy_at(&root); + + for (raw, needle) in [ + (absolute_path(), "must be relative to the workspace"), + ("../../etc/passwd", "must not traverse outside"), + ("scripts/../../escape.sh", "must not traverse outside"), + ] { + let error = policy + .resolve_script(raw) + .expect_err("a path outside the workspace must be refused"); + assert!(error.to_string().contains(needle), "{raw}: {error}"); + } + + let error = policy + .resolve_cwd("../elsewhere") + .expect_err("a cwd outside the workspace must be refused"); + assert!(error.to_string().contains("must not traverse outside")); +} + +#[cfg(unix)] +#[test] +fn a_symlink_out_of_the_workspace_is_refused() { + // The syntactic check cannot see this one: the path has no `..` in it, and + // only canonicalizing reveals where it lands. + let root = workspace(); + let outside = tempfile::tempdir().expect("tempdir"); + std::fs::write(outside.path().join("secret.sh"), "printf leaked").expect("write"); + std::os::unix::fs::symlink( + outside.path().join("secret.sh"), + root.path().join("link.sh"), + ) + .expect("symlink"); + + let error = policy_at(&root) + .resolve_script("link.sh") + .expect_err("a symlink out of the workspace must be refused"); + assert!( + error.to_string().contains("resolves outside the workspace"), + "unexpected error: {error}" + ); +} + +#[test] +fn a_missing_script_is_refused_rather_than_run_empty() { + let root = workspace(); + let error = policy_at(&root) + .resolve_script("scripts/absent.sh") + .expect_err("a missing script must be refused"); + assert!(error.to_string().contains("does not resolve inside")); +} + +#[test] +fn a_directory_is_not_a_script_and_a_file_is_not_a_directory() { + let root = workspace(); + let policy = policy_at(&root); + + let error = policy + .resolve_script("scripts") + .expect_err("a directory is not a script"); + assert!(error.to_string().contains("is not a file")); + + let error = policy + .resolve_cwd("scripts/build.sh") + .expect_err("a file is not a working directory"); + assert!(error.to_string().contains("is not a directory")); +} + +#[test] +fn a_host_without_a_workspace_refuses_paths_and_says_what_to_use_instead() { + let policy = ScriptPolicy::new(""); + assert_eq!(policy.workspace(), None); + + let error = policy + .resolve_script("scripts/build.sh") + .expect_err("no workspace means no path may resolve"); + assert!( + error.to_string().contains("pass `args.script` instead"), + "unexpected error: {error}" + ); +} + +#[test] +fn a_workspace_that_is_not_a_directory_is_treated_as_absent() { + let root = tempfile::tempdir().expect("tempdir"); + let file = root.path().join("not-a-dir"); + std::fs::write(&file, "x").expect("write"); + + assert_eq!(ScriptPolicy::new(&file.to_string_lossy()).workspace(), None); +} + +#[test] +fn a_declared_environment_reads_back_as_a_string_map() { + let env = read_env(Some(&json!({ "PROFILE": "release", "TARGET": "wasm" }))) + .expect("a string map is valid"); + + assert_eq!(env.get("PROFILE").map(String::as_str), Some("release")); + assert_eq!(env.get("TARGET").map(String::as_str), Some("wasm")); +} + +#[test] +fn no_environment_at_all_is_an_empty_map() { + assert!(read_env(None).expect("absent is valid").is_empty()); +} + +#[test] +fn a_malformed_environment_is_refused_before_anything_spawns() { + for (value, needle) in [ + (json!([]), "must be an object"), + (json!({ "not a name": "x" }), "invalid variable name"), + (json!({ "COUNT": 3 }), "must be a string, not a number"), + (json!({ "TOKEN": "a\0b" }), "NUL byte"), + ] { + let error = read_env(Some(&value)).expect_err("malformed env must be refused"); + assert!(error.to_string().contains(needle), "{value}: {error}"); + } +} + +#[test] +fn environment_names_follow_the_usual_shape() { + for name in ["PATH", "_private", "A1", "a_b_1"] { + assert!(is_valid_env_name(name), "{name} should be valid"); + } + for name in ["", "1ST", "with space", "with-dash", "with=equals", "a\0b"] { + assert!(!is_valid_env_name(name), "{name} should be invalid"); + } +} diff --git a/src/caps/host/script_tests.rs b/src/caps/host/script_tests.rs new file mode 100644 index 00000000..26cbadb4 --- /dev/null +++ b/src/caps/host/script_tests.rs @@ -0,0 +1,529 @@ +//! Tests for the out-of-process script runner. +//! +//! These spawn real interpreters, which is the point: the bug this module was +//! written to fix was a calling convention that was documented one way and +//! implemented another, and only actually running something catches that. +//! +//! `bash` is assumed present on unix, and the `ScriptLanguage::Shell` cases are +//! `#[cfg(unix)]` because `run_script` itself refuses that language on +//! Windows (see its doc comment): there is no portable POSIX shell there to +//! run them in, and emulating one is exactly the per-platform behavior this +//! module exists to avoid. `node` and `python3` are cross-platform but not +//! guaranteed installed, so those cases skip rather than fail on a machine +//! without them. + +use super::*; +use serde_json::json; + +/// The plain shape most cases here want: an inline script, no declared +/// environment. Cases that exercise `cwd`, a script file, or `env` build a +/// [`ScriptRequest`] themselves. +async fn run( + language: ScriptLanguage, + source: &str, + input: &Value, + timeout: Duration, + cwd: Option<&Path>, +) -> Result { + let env = BTreeMap::new(); + run_script(ScriptRequest { + language, + interpreter: None, + source: ScriptSource::Inline(source), + input, + timeout, + cwd, + env: &env, + }) + .await +} + +const TIMEOUT: Duration = Duration::from_secs(30); + +/// Whether an interpreter is on `PATH`, so a test can skip rather than fail. +fn available(program: &str) -> bool { + std::process::Command::new(program) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn a_shell_script_reads_its_input_on_stdin_and_returns_stdout() { + let output = run( + ScriptLanguage::Shell, + "cat", + &json!({ "name": "sweep" }), + TIMEOUT, + None, + ) + .await + .expect("runs"); + + // Round-tripped through stdin and back out, and parsed as JSON on the way + // back because it is JSON. + assert_eq!(output.value, json!({ "name": "sweep" })); +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn a_shell_script_can_read_its_input_from_the_path_instead() { + // Shell reads a path more naturally than a pipe, so both are offered. + let output = run( + ScriptLanguage::Shell, + "cat \"$TINYFLOWS_INPUT\"", + &json!({ "n": 1 }), + TIMEOUT, + None, + ) + .await + .expect("runs"); + + assert_eq!(output.value, json!({ "n": 1 })); +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn the_input_path_is_also_the_first_argument() { + let output = run( + ScriptLanguage::Shell, + "cat \"$1\"", + &json!(42), + TIMEOUT, + None, + ) + .await + .expect("runs"); + + assert_eq!(output.value, json!(42)); +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn output_that_is_not_json_comes_back_as_a_string() { + let output = run( + ScriptLanguage::Shell, + "echo hello there", + &json!(null), + TIMEOUT, + None, + ) + .await + .expect("runs"); + + // A script that just prints a line should still be usable downstream. + assert_eq!(output.value, json!("hello there")); +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn stderr_survives_a_script_that_succeeded() { + let output = run( + ScriptLanguage::Shell, + "echo warning: skipped one >&2; echo done", + &json!(null), + TIMEOUT, + None, + ) + .await + .expect("runs"); + + assert_eq!(output.value, json!("done")); + // A script that works and warns wrote that warning for a reader. + assert_eq!(output.stderr, "warning: skipped one"); +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn a_failing_script_reports_its_stderr_rather_than_only_its_code() { + let err = run( + ScriptLanguage::Shell, + "echo could not reach the host >&2; exit 3", + &json!(null), + TIMEOUT, + None, + ) + .await + .expect_err("fails"); + + // The exit code alone says nothing an author can act on. + assert!( + err.to_string().contains("could not reach the host"), + "{err}" + ); +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn a_script_that_never_ends_is_stopped_and_says_so() { + let err = run( + ScriptLanguage::Shell, + "sleep 30", + &json!(null), + Duration::from_millis(300), + None, + ) + .await + .expect_err("times out"); + + assert!(err.to_string().contains("timed out"), "{err}"); +} + +#[tokio::test] +async fn a_missing_interpreter_says_what_is_missing_rather_than_failing_opaquely() { + let err = run( + ScriptLanguage::Python, + "print(1)", + &json!(null), + TIMEOUT, + None, + ) + .await; + + // Only meaningful when python3 is genuinely absent; where it exists this + // case cannot arise and the assertion is skipped. + if let Err(err) = err { + if err.to_string().contains("cannot run") { + assert!(err.to_string().contains("PATH"), "{err}"); + } + } +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn a_script_runs_where_it_was_told_to() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("marker.txt"), "found me").expect("write"); + + let output = run( + ScriptLanguage::Shell, + "cat marker.txt", + &json!(null), + TIMEOUT, + Some(dir.path()), + ) + .await + .expect("runs"); + + // This is what makes a `shell` node useful: a step that means to touch the + // operator's project has to actually be in it. + assert_eq!(output.value, json!("found me")); +} + +// Unix-only: these run `ScriptLanguage::Shell`, which `run_script` refuses +// on Windows because there is no portable POSIX shell to run it in (see +// the `#[cfg(windows)]` guard in `run_script`) rather than emulating one. +#[cfg(unix)] +#[tokio::test] +async fn a_script_with_no_directory_given_runs_somewhere_disposable() { + let output = run(ScriptLanguage::Shell, "pwd", &json!(null), TIMEOUT, None) + .await + .expect("runs"); + + // A `code` node is a computation over its input, so it gets a scratch + // directory rather than the repository. + let cwd = output.value.as_str().expect("a path"); + assert_ne!( + cwd, + std::env::current_dir().unwrap().to_string_lossy(), + "a code node must not default to the process's own directory" + ); +} + +#[tokio::test] +async fn javascript_reads_the_same_input_the_same_way() { + if !available("node") { + return; + } + + let output = run( + ScriptLanguage::JavaScript, + "const fs = require('fs');\n\ + const input = JSON.parse(fs.readFileSync(0, 'utf8'));\n\ + console.log(JSON.stringify({ doubled: input.n * 2 }));", + &json!({ "n": 21 }), + TIMEOUT, + None, + ) + .await + .expect("runs"); + + assert_eq!(output.value, json!({ "doubled": 42 })); +} + +#[tokio::test] +async fn python_reads_the_same_input_the_same_way() { + if !available("python3") { + return; + } + + let output = run( + ScriptLanguage::Python, + "import json, sys\nprint(json.dumps({'doubled': json.load(sys.stdin)['n'] * 2}))", + &json!({ "n": 21 }), + TIMEOUT, + None, + ) + .await + .expect("runs"); + + assert_eq!(output.value, json!({ "doubled": 42 })); +} + +#[test] +fn a_language_name_is_read_the_way_an_author_would_write_it() { + for name in ["shell", "sh", "bash", "SHELL", " bash "] { + assert_eq!( + ScriptLanguage::parse(name), + Some(ScriptLanguage::Shell), + "{name}" + ); + } + for name in ["javascript", "js", "node", "nodejs"] { + assert_eq!( + ScriptLanguage::parse(name), + Some(ScriptLanguage::JavaScript), + "{name}" + ); + } + for name in ["python", "python3", "py"] { + assert_eq!( + ScriptLanguage::parse(name), + Some(ScriptLanguage::Python), + "{name}" + ); + } + // Refused rather than guessed: running the wrong interpreter on someone's + // script is worse than telling them the name was not recognised. + assert_eq!(ScriptLanguage::parse("ruby"), None); +} + +#[cfg(windows)] +#[tokio::test] +async fn shell_is_refused_on_windows_rather_than_emulated() { + // The one Windows-specific behavior this module has: refuse plainly, + // pointing at the languages that do work everywhere, instead of + // path-translating into Git Bash or swapping in `cmd`/PowerShell — either + // of which would make a workflow look portable while quietly behaving + // differently by host. + let err = run( + ScriptLanguage::Shell, + "echo hi", + &json!(null), + TIMEOUT, + None, + ) + .await + .expect_err("shell must be refused on Windows"); + + let message = err.to_string(); + assert!(message.contains("Windows"), "{message}"); + assert!(message.contains("javascript"), "{message}"); + assert!(message.contains("python"), "{message}"); +} + +/// Runs `source` under an explicitly chosen interpreter. +async fn run_under(interpreter: &Interpreter, source: &str) -> Result { + let env = BTreeMap::new(); + let input = json!(null); + run_script(ScriptRequest { + language: ScriptLanguage::Shell, + interpreter: Some(interpreter), + source: ScriptSource::Inline(source), + input: &input, + timeout: TIMEOUT, + cwd: None, + env: &env, + }) + .await +} + +#[test] +fn an_unconfigured_host_keeps_the_default_shell() { + // The whole point of the empty default: a workflow whose scripts were + // written against `bash` must not change interpreter because this field + // was added. + let chosen = Interpreter::resolve("", &[], Some("/usr/bin/fish")).expect("valid"); + + assert_eq!(chosen.program, DEFAULT_SHELL); + assert!(chosen.args.is_empty()); +} + +#[test] +fn the_user_sentinel_follows_the_login_shell() { + // The fixture has to be absolute *on this platform*: a `$SHELL` of + // `/bin/zsh` is rooted but drive-less on Windows, which is exactly the + // relative-path shape `validated` refuses. + let login = absolute_interpreter(); + + let chosen = Interpreter::resolve(USER_SHELL, &["-l".to_string()], Some(login)).expect("valid"); + + assert_eq!(chosen.program, login); + assert_eq!(chosen.args, vec!["-l".to_string()]); +} + +#[test] +fn a_login_shell_the_platform_cannot_use_is_refused_rather_than_spawned() { + // The other half of the case above: `$SHELL` is ordinary environment data, + // so a value this platform would treat as relative has to be refused with + // the rest, not waved through because it came from the environment. + #[cfg(windows)] + { + let err = Interpreter::resolve(USER_SHELL, &[], Some("/bin/zsh")).expect_err("drive-less"); + assert!(err.to_string().contains("relative path"), "{err}"); + } + #[cfg(not(windows))] + { + let err = Interpreter::resolve(USER_SHELL, &[], Some("bin/zsh")).expect_err("relative"); + assert!(err.to_string().contains("relative path"), "{err}"); + } +} + +#[test] +fn the_user_sentinel_falls_back_when_the_environment_names_no_shell() { + // A daemon started by systemd has no `$SHELL`, and an empty one is the + // same absence spelled differently. Neither may leave the program empty. + for absent in [None, Some(""), Some(" ")] { + let chosen = Interpreter::resolve(USER_SHELL, &[], absent).expect("valid"); + assert_eq!(chosen.program, DEFAULT_SHELL, "for {absent:?}"); + } +} + +#[test] +fn a_named_interpreter_wins_over_the_login_shell() { + let chosen = Interpreter::resolve("zsh", &[], Some("/usr/bin/fish")).expect("valid"); + + assert_eq!(chosen.program, "zsh"); +} + +#[test] +fn a_relative_interpreter_path_is_refused() { + // The path would resolve against the script's working directory, which the + // *workflow author* chooses — so accepting it would let a graph decide + // which binary the operator's own configuration named. + let err = Interpreter::resolve("./sh", &[], None).expect_err("relative path"); + + let message = err.to_string(); + assert!(message.contains("relative path"), "{message}"); + assert!( + message.contains("/bin/zsh"), + "the error must teach the fix: {message}" + ); +} + +#[test] +fn an_absolute_interpreter_path_is_accepted() { + let chosen = Interpreter::resolve(absolute_interpreter(), &[], None).expect("valid"); + + assert_eq!(chosen.program, absolute_interpreter()); +} + +/// An absolute path this platform actually considers absolute. +fn absolute_interpreter() -> &'static str { + #[cfg(windows)] + { + r"C:\Windows\System32\cmd.exe" + } + #[cfg(not(windows))] + { + "/bin/zsh" + } +} + +#[test] +fn a_blank_configured_shell_reads_as_unconfigured() { + // Whitespace is how a half-edited config file spells "I did not set this", + // and reading it as an empty program name would break every script. + let chosen = Interpreter::resolve(" ", &[], None).expect("valid"); + + assert_eq!(chosen.program, DEFAULT_SHELL); +} + +#[test] +fn an_empty_interpreter_is_refused() { + // `resolve` maps blank to the default; `validated` is the direct path, and + // there an empty program is a caller's mistake rather than an absence. + let err = Interpreter::validated(" ", &[]).expect_err("blank"); + + assert!(err.to_string().contains("must not be empty"), "{err}"); +} + +#[test] +fn a_nul_byte_is_refused_in_the_program_and_in_an_argument() { + // Neither can be passed to a process; refusing here beats a spawn error + // that names nothing an author wrote. + let program = Interpreter::resolve("z\0sh", &[], None).expect_err("NUL program"); + assert!(program.to_string().contains("NUL"), "{program}"); + + let argument = + Interpreter::resolve("zsh", &["-l\0".to_string()], None).expect_err("NUL argument"); + assert!(argument.to_string().contains("NUL"), "{argument}"); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_chosen_interpreter_actually_runs_the_script() { + // `sh` rather than `zsh`: every unix host has it, so this pins that the + // override reaches the spawn rather than that a particular shell exists. + let chosen = Interpreter::validated("sh", &[]).expect("valid"); + + let output = run_under(&chosen, "echo chosen").await.expect("runs"); + + assert_eq!(output.value, json!("chosen")); +} + +#[cfg(unix)] +#[tokio::test] +async fn interpreter_arguments_reach_the_command_line() { + // `-x` traces to stderr, which is the observable proof the argument landed + // *before* the script path rather than being dropped. + let chosen = Interpreter::validated("sh", &["-x".to_string()]).expect("valid"); + + let output = run_under(&chosen, "echo traced").await.expect("runs"); + + assert_eq!(output.value, json!("traced")); + assert!( + output.stderr.contains("echo traced"), + "expected an -x trace, got {:?}", + output.stderr + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_missing_interpreter_names_itself() { + let chosen = Interpreter::validated("tinyflows-no-such-shell", &[]).expect("valid"); + + let err = run_under(&chosen, "echo hi").await.expect_err("missing"); + + let message = err.to_string(); + assert!(message.contains("tinyflows-no-such-shell"), "{message}"); + assert!(message.contains("PATH"), "{message}"); +} diff --git a/src/caps/host/shell.rs b/src/caps/host/shell.rs new file mode 100644 index 00000000..c1272c9e --- /dev/null +++ b/src/caps/host/shell.rs @@ -0,0 +1,125 @@ +//! A [`ShellRunner`] over the out-of-process script runner. +//! +//! [`crate::caps::shell`] states the contract a `shell` node needs and stops +//! there, because the engine must not decide which paths are reachable or which +//! environment a script inherits. This module is the other half: the ordinary +//! answer for a host that runs scripts as child processes of itself, so such a +//! host wires a field rather than reimplementing process plumbing, path +//! validation, and the stdin/stdout convention. +//! +//! Two decisions are the host's and stay parameters here: +//! +//! - **Which files a step may reach**, via the [`ScriptPolicy`] this is built +//! with. An author's `script` path and `cwd` are untrusted strings; they are +//! resolved inside the configured workspace or refused. +//! - **How long a script may run**, via the timeout. Unbounded is not an option +//! — a `shell` node that never returns holds its run open forever. +//! +//! What stays *not* a decision is containment: this is not a sandbox, and a +//! script it runs holds the privileges of the process that started it. A host +//! that needs isolation implements [`ShellRunner`] over its own sandbox instead. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use async_trait::async_trait; + +use crate::caps::shell::{ShellInterpreter, ShellOutcome, ShellRequest, ShellRunner, ShellScript}; +use crate::error::Result; + +use super::script::{Interpreter, ScriptLanguage, ScriptRequest, ScriptSource, run_script_capture}; +use super::script_policy::ScriptPolicy; + +/// A [`ShellRunner`] that spawns each script as a child process. +pub struct ProcessShellRunner { + /// Where a script may be read from, and where it may run. + policy: ScriptPolicy, + /// How long one script may run before it is abandoned. + timeout: Duration, +} + +impl ProcessShellRunner { + /// A runner bounded by `policy` and `timeout`. + #[must_use] + pub fn new(policy: ScriptPolicy, timeout: Duration) -> Self { + Self { policy, timeout } + } + + /// The working directory to run in: the author's, resolved in the + /// workspace, or the workspace itself when they named none. + /// + /// Falling back to the workspace rather than to a temporary directory is + /// deliberate. A `shell` node exists to touch the operator's project; a step + /// that omitted `cwd` meant "the usual place", and a scratch directory would + /// satisfy that request by running the script somewhere nothing it cares + /// about exists. + fn working_dir(&self, requested: Option<&str>) -> Result> { + match requested.map(str::trim).filter(|cwd| !cwd.is_empty()) { + Some(cwd) => self.policy.resolve_cwd(cwd).map(Some), + None => Ok(self.policy.workspace().map(Path::to_path_buf)), + } + } +} + +#[async_trait] +impl ShellRunner for ProcessShellRunner { + async fn run(&self, request: ShellRequest) -> Result { + let ShellRequest { + interpreter, + script, + cwd, + env, + input, + } = request; + + // The node's chosen shell is honoured exactly: `sh` and `bash` are the + // two the engine advertises, and an author who wrote one of them said + // which, so neither follows a host-configured default. + let interpreter = Interpreter::validated( + match interpreter { + ShellInterpreter::Sh => "sh", + ShellInterpreter::Bash => "bash", + }, + &[], + )?; + + // Resolved before anything is spawned, so a refused path fails the node + // without a process ever existing. + let staged = match &script { + ShellScript::Path(raw) => Some(self.policy.resolve_script(raw)?), + ShellScript::Inline(_) => None, + }; + let working_dir = self.working_dir(cwd.as_deref())?; + + let source = match (&script, &staged) { + (ShellScript::Inline(source), _) => ScriptSource::Inline(source.as_str()), + (ShellScript::Path(_), Some(path)) => ScriptSource::File(path.as_path()), + // Unreachable: `staged` is `Some` for exactly the `Path` case above. + (ShellScript::Path(raw), None) => ScriptSource::Inline(raw.as_str()), + }; + + let completion = run_script_capture(ScriptRequest { + language: ScriptLanguage::Shell, + interpreter: Some(&interpreter), + source, + input: &input, + timeout: self.timeout, + cwd: working_dir.as_deref(), + env: &env, + }) + .await?; + + // A non-zero exit is reported, not raised: the `shell` node is what + // turns a failing script into a failing step, and collapsing the two + // here would make a host error and a script error indistinguishable. + Ok(ShellOutcome { + exit_code: completion.exit_code, + stdout: completion.stdout, + stderr: completion.stderr, + }) + } +} + +#[cfg(test)] +#[path = "shell_tests.rs"] +mod tests; diff --git a/src/caps/host/shell_tests.rs b/src/caps/host/shell_tests.rs new file mode 100644 index 00000000..e8ea6e74 --- /dev/null +++ b/src/caps/host/shell_tests.rs @@ -0,0 +1,166 @@ +//! Tests for the process-backed `shell` capability. +//! +//! These spawn a real `sh`, which is the point: the contract this implements is +//! about exit codes, working directories, and refused paths, and none of those +//! can be checked against a stand-in. Unix-only, because [`run_script_capture`] +//! refuses shell scripts on Windows outright. +//! +//! [`run_script_capture`]: super::super::script::run_script_capture + +#![cfg(unix)] + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::*; + +/// The timeout every case here runs under. Generous enough that a loaded +/// machine does not fail a case, short enough that a hung script does not hang +/// the suite. +const TIMEOUT: Duration = Duration::from_secs(30); + +/// A runner rooted at `workspace`. +fn runner(workspace: &Path) -> ProcessShellRunner { + ProcessShellRunner::new(ScriptPolicy::new(&workspace.to_string_lossy()), TIMEOUT) +} + +/// The plain request shape: an inline script, no declared environment, no +/// working directory of its own. +fn inline(source: &str) -> ShellRequest { + ShellRequest { + interpreter: ShellInterpreter::Sh, + script: ShellScript::Inline(source.to_string()), + cwd: None, + env: BTreeMap::new(), + input: json!({}), + } +} + +#[tokio::test] +async fn an_inline_script_reports_its_output() { + let workspace = tempfile::tempdir().expect("workspace"); + + let outcome = runner(workspace.path()) + .run(inline("echo hello")) + .await + .expect("runs"); + + assert_eq!(outcome.exit_code, 0); + assert_eq!(outcome.stdout, "hello"); + assert!(outcome.is_success()); +} + +#[tokio::test] +async fn a_failing_script_reports_its_code_rather_than_erroring() { + let workspace = tempfile::tempdir().expect("workspace"); + + // The whole reason `run_script_capture` exists: a non-zero exit is the + // node's answer, not this layer's failure. + let outcome = runner(workspace.path()) + .run(inline("echo trouble >&2; exit 3")) + .await + .expect("runs"); + + assert_eq!(outcome.exit_code, 3); + assert_eq!(outcome.stderr, "trouble"); + assert!(!outcome.is_success()); +} + +#[tokio::test] +async fn the_input_reaches_the_script_by_path() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut request = inline("cat \"$TINYFLOWS_INPUT\""); + request.input = json!({"answer": 42}); + + let outcome = runner(workspace.path()).run(request).await.expect("runs"); + + assert_eq!( + serde_json::from_str::(&outcome.stdout).expect("json"), + json!({"answer": 42}) + ); +} + +#[tokio::test] +async fn a_declared_variable_reaches_the_script() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut request = inline("printf %s \"$GREETING\""); + request.env = BTreeMap::from([("GREETING".to_string(), "ahoy".to_string())]); + + let outcome = runner(workspace.path()).run(request).await.expect("runs"); + + assert_eq!(outcome.stdout, "ahoy"); +} + +#[tokio::test] +async fn a_script_without_a_cwd_runs_in_the_workspace() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write(workspace.path().join("marker.txt"), "found me").expect("marker"); + + // Not a temporary directory: a `shell` node that named no `cwd` still means + // the operator's project, and a scratch directory would hide that. + let outcome = runner(workspace.path()) + .run(inline("cat marker.txt")) + .await + .expect("runs"); + + assert_eq!(outcome.stdout, "found me"); +} + +#[tokio::test] +async fn a_script_file_in_the_workspace_runs() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write(workspace.path().join("run.sh"), "echo from a file").expect("script"); + + let mut request = inline(""); + request.script = ShellScript::Path("run.sh".to_string()); + + let outcome = runner(workspace.path()).run(request).await.expect("runs"); + + assert_eq!(outcome.stdout, "from a file"); +} + +#[tokio::test] +async fn a_script_path_outside_the_workspace_is_refused() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut request = inline(""); + request.script = ShellScript::Path("../escape.sh".to_string()); + + let err = request_error(workspace.path(), request).await; + + assert!(err.contains("traverse outside the workspace"), "{err}"); +} + +#[tokio::test] +async fn a_cwd_outside_the_workspace_is_refused() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut request = inline("pwd"); + request.cwd = Some("/etc".to_string()); + + let err = request_error(workspace.path(), request).await; + + assert!(err.contains("must be relative to the workspace"), "{err}"); +} + +#[tokio::test] +async fn a_refused_path_never_spawns_anything() { + let workspace = tempfile::tempdir().expect("workspace"); + // The script would create the file if it ran; a refusal must happen before + // that, so its absence is the assertion. + let witness = workspace.path().join("ran.txt"); + let mut request = inline(&format!("touch {}", witness.display())); + request.cwd = Some("../elsewhere".to_string()); + + let _ = request_error(workspace.path(), request).await; + + assert!(!witness.exists(), "the script ran despite a refused cwd"); +} + +/// Run `request` expecting a refusal, returning the message. +async fn request_error(workspace: &Path, request: ShellRequest) -> String { + runner(workspace) + .run(request) + .await + .expect_err("refused") + .to_string() +} diff --git a/src/caps/host/state.rs b/src/caps/host/state.rs new file mode 100644 index 00000000..ef343d45 --- /dev/null +++ b/src/caps/host/state.rs @@ -0,0 +1,107 @@ +//! Durable key/value state for stateful workflows. +//! +//! One JSON file per key, under a per-workflow namespace directory, so two +//! workflows can use the same key name without colliding. Keys are hashed into +//! their filename rather than used verbatim: a key is author-supplied and may +//! contain path separators, and a `StateStore` must never be a way to write +//! outside its own directory. +//! +//! Writes are staged and renamed, never made in place, so a key can never be +//! left holding a half-written document that no later read can recover from. +//! +//! That guarantee is about *process* crashes, not power loss: `store` neither +//! `sync_all`s the staged file before the rename nor syncs the namespace +//! directory afterward, so a kill -9 mid-write always leaves either the whole +//! previous value or the whole new one, but a host that also needs the rename +//! itself to survive an unclean *shutdown* (a crash, not just a killed +//! process) needs to add that fsync discipline on top of this. + +use std::path::{Path, PathBuf}; + +use crate::caps::StateStore; +use crate::error::{EngineError, Result}; +use async_trait::async_trait; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// A [`StateStore`] over files beneath a namespace directory. +pub struct FileStateStore { + /// The namespace's directory: `/`. + dir: PathBuf, +} + +impl FileStateStore { + /// A store for `namespace` (conventionally `workflow:`) under `root`. + pub fn new(root: &Path, namespace: &str) -> Self { + Self { + dir: root.join(digest(namespace)), + } + } + + /// The file a key is stored in. + fn path(&self, key: &str) -> PathBuf { + self.dir.join(format!("{}.json", digest(key))) + } +} + +/// A hex SHA-256 digest, used to turn an arbitrary author-supplied string into +/// one safe path component. +fn digest(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +#[async_trait] +impl StateStore for FileStateStore { + async fn load(&self, key: &str) -> Result> { + let path = self.path(key); + // `tokio::fs` rather than `std::fs`: these run on the runtime's worker + // threads alongside every other node in the graph, and a blocking read + // here stalls whatever else is scheduled there. + match tokio::fs::read(&path).await { + Ok(body) => serde_json::from_slice(&body) + .map(Some) + .map_err(|err| EngineError::Capability(format!("state: {key}: {err}"))), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(EngineError::Capability(format!("state: {key}: {err}"))), + } + } + + async fn store(&self, key: &str, value: Value) -> Result<()> { + tokio::fs::create_dir_all(&self.dir) + .await + .map_err(|err| EngineError::Capability(format!("state: {key}: {err}")))?; + let body = serde_json::to_vec(&value) + .map_err(|err| EngineError::Capability(format!("state: {key}: {err}")))?; + let path = self.path(key); + // Staged and renamed rather than written in place. A plain write + // truncates and then fills, so a kill in that window — or a second + // writer for the same key — leaves a prefix of JSON on disk, and `load` + // has no way to read a prefix: the key would be wedged for good. A + // rename publishes either the whole previous value or the whole new + // one. The temp name carries a unique token so two writers racing on + // one key cannot scribble over each other's scratch file, and it sits + // beside the target so the rename stays within one filesystem. + let tmp = self.dir.join(format!("{}.tmp", crate::ids::token())); + // A failed write must not leave the scratch file behind either: like a + // failed rename, it would otherwise accumulate under the namespace + // directory, and because every attempt names a fresh token, retries + // under a full disk would pile up partial `.tmp` files. + if let Err(err) = tokio::fs::write(&tmp, body).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(EngineError::Capability(format!("state: {key}: {err}"))); + } + if let Err(err) = tokio::fs::rename(&tmp, &path).await { + // A failed rename must not leave scratch files accumulating in the + // namespace directory. + let _ = tokio::fs::remove_file(&tmp).await; + return Err(EngineError::Capability(format!("state: {key}: {err}"))); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "state_tests.rs"] +mod tests; diff --git a/src/caps/host/state_tests.rs b/src/caps/host/state_tests.rs new file mode 100644 index 00000000..2788a1f5 --- /dev/null +++ b/src/caps/host/state_tests.rs @@ -0,0 +1,89 @@ +//! State-store behaviour: per-namespace scoping, path containment, and the +//! atomic-write guarantee. +//! +//! The last two are why this store is worth sharing rather than rewriting: a +//! key is author-supplied and may contain path separators, and a plain write +//! can leave a key holding a prefix of JSON that no later read recovers from. + +use std::sync::Arc; + +use serde_json::json; + +use super::*; +use crate::caps::StateStore; + +#[tokio::test] +async fn state_is_scoped_per_namespace_so_two_workflows_cannot_collide() { + let root = tempfile::tempdir().unwrap(); + let alpha = FileStateStore::new(root.path(), "workflow:alpha"); + let beta = FileStateStore::new(root.path(), "workflow:beta"); + + alpha.store("cursor", json!(1)).await.unwrap(); + beta.store("cursor", json!(2)).await.unwrap(); + + assert_eq!(alpha.load("cursor").await.unwrap(), Some(json!(1))); + assert_eq!(beta.load("cursor").await.unwrap(), Some(json!(2))); + assert_eq!(alpha.load("missing").await.unwrap(), None); +} + +#[tokio::test] +async fn a_state_key_containing_path_separators_cannot_escape_its_directory() { + let root = tempfile::tempdir().unwrap(); + let store = FileStateStore::new(&root.path().join("state"), "workflow:alpha"); + + store.store("../../escaped", json!("x")).await.unwrap(); + + // Everything written must live under the namespace directory. + let escaped = root.path().join("escaped.json"); + assert!(!escaped.exists(), "a key must not choose its own path"); + assert_eq!( + store.load("../../escaped").await.unwrap(), + Some(json!("x")), + "and it must still round-trip" + ); +} + +#[tokio::test] +async fn concurrent_writers_of_one_key_never_leave_a_torn_document() { + // A plain `fs::write` truncates and then fills, so two writers of the same + // key — or a kill mid-write — can leave a prefix of JSON that no later read + // can recover from, wedging the key for good. Staging and renaming makes + // every observable state a whole document. + let root = tempfile::tempdir().unwrap(); + let store = Arc::new(FileStateStore::new(root.path(), "workflow:alpha")); + + // Bodies large enough that a truncating write would be observable in parts. + let writes: Vec<_> = (0..16) + .map(|n| { + let store = store.clone(); + tokio::spawn(async move { + let body = json!({ "n": n, "pad": "x".repeat(64 * 1024) }); + store.store("cursor", body).await + }) + }) + .collect(); + for write in writes { + write.await.unwrap().expect("stores"); + } + + let loaded = store + .load("cursor") + .await + .expect("parses") + .expect("present"); + assert_eq!(loaded["pad"].as_str().map(str::len), Some(64 * 1024)); + + // And no scratch file is left behind: the namespace holds the one document. + let namespace = std::fs::read_dir(root.path()) + .unwrap() + .next() + .expect("the namespace directory") + .unwrap() + .path(); + let leftovers: Vec<_> = std::fs::read_dir(namespace) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!(leftovers.len(), 1, "{leftovers:?}"); + assert!(leftovers[0].ends_with(".json"), "{leftovers:?}"); +} diff --git a/src/caps/mod.rs b/src/caps/mod.rs index eacd9832..055d8b56 100644 --- a/src/caps/mod.rs +++ b/src/caps/mod.rs @@ -6,6 +6,8 @@ //! curated Composio tools, `HttpRequestTool`, and sandboxed code runtimes. pub mod agent; +#[cfg(any(test, feature = "host-caps"))] +pub mod host; #[cfg(any(test, feature = "mock"))] pub mod mock; pub mod shell; diff --git a/src/gates/mod.rs b/src/gates/mod.rs new file mode 100644 index 00000000..e941e7b4 --- /dev/null +++ b/src/gates/mod.rs @@ -0,0 +1,163 @@ +//! Checks that run before an authoring write lands. +//! +//! [`validate`](crate::validate) answers "would this compile" — no trigger, an +//! edge to a node that is not there. That is a real bar and it is not the one +//! authors keep failing. The graphs that cost people an afternoon *do* compile: +//! they have a binding that resolves to null at run time, so a step executes +//! with an empty value and the run reports success having done nothing. +//! +//! Nothing downstream catches that. A null is a legal value, so the engine has +//! no complaint; the run record shows every node green. The only place it can be +//! caught is here, before the write, while there is still an author on the other +//! end to tell. +//! +//! Two rules the gates hold themselves to: +//! +//! - **Refuse only what is *guaranteed* wrong.** A gate that fires on a graph +//! that would have worked costs an author their edit and teaches them to +//! distrust the tool. Everything merely suspicious belongs in a dry run's +//! diagnostics, which advise rather than refuse. +//! - **Say what to do.** Every message names the node, the binding, and the +//! correction. The reader is often an agent with one round trip to spend. +//! +//! # What is *not* here +//! +//! Anything that depends on a host's own vocabulary. Which harnesses exist, +//! which tool slugs resolve, which integrations are installed — a gate over any +//! of those would have to hard-code a host, which this crate does not do. A host +//! adds its own by implementing +//! [`HostPolicy::check_graph`](crate::store::HostPolicy::check_graph), whose +//! default is exactly [`failures`] below. + +use crate::bindings::{self, collect_expressions, parse_node_binding, reads_as_prose}; +use crate::model::{NodeKind, WorkflowGraph}; + +/// Every gate failure in `graph`, collected rather than short-circuited. +/// +/// One round trip then tells an author everything wrong with what they wrote, +/// which matters most when the author is an agent editing over a tool call. +/// +/// An empty result is a pass. +#[must_use] +pub fn failures(graph: &WorkflowGraph) -> Vec { + let mut failures = agent_prompt_failures(graph); + failures.extend(binding_failures(graph)); + failures.extend(code_language_failures(graph)); + failures +} + +/// `code` nodes whose language the engine will not read the way it was written. +/// +/// The engine matches the literal string `"python"` and treats *everything else* +/// as JavaScript — silently. So `"language": "python3"` runs a Python program +/// through node, and `"language": "shell"` runs a shell script through node. +/// Both fail with a syntax error from an interpreter the author never named, +/// which is among the least helpful failures a run can produce. +/// +/// Refused here rather than documented, because documentation does not stop a +/// plausible spelling. +fn code_language_failures(graph: &WorkflowGraph) -> Vec { + /// The two the engine actually distinguishes. + const ACCEPTED: [&str; 2] = ["javascript", "python"]; + /// Spellings that mean "a shell", which a `code` node cannot run at all. + const SHELL_SPELLINGS: [&str; 3] = ["shell", "sh", "bash"]; + + let mut failures = Vec::new(); + for node in &graph.nodes { + if node.kind != NodeKind::Code { + continue; + } + let Some(language) = node.config.get("language").and_then(|v| v.as_str()) else { + // Absent is legal and means JavaScript, which the engine's own + // default already says. + continue; + }; + if ACCEPTED.contains(&language) { + continue; + } + // Worth its own sentence: an author reaching for a shell has a node kind + // that does exactly that, and the generic message would send them + // looking for a spelling of `language` that does not exist. + let hint = if SHELL_SPELLINGS.contains(&language.trim().to_ascii_lowercase().as_str()) { + " A `code` node cannot run shell: use a `shell` node, which takes an interpreter, a \ + working directory, and an environment." + } else { + "" + }; + failures.push(format!( + "node '{}': `language` is `{language}`, which this engine does not recognise — it \ + matches only the exact strings `javascript` and `python`, and silently treats \ + anything else as JavaScript. Your program would be run through node and fail with a \ + syntax error naming an interpreter you did not choose.{hint}", + node.id + )); + } + failures +} + +/// Agent nodes whose `prompt` is prose written as an expression. +/// +/// The node would run with an empty instruction — on a host that dispatches a +/// whole agent session per node, that is a session started with nothing to do. +fn agent_prompt_failures(graph: &WorkflowGraph) -> Vec { + let mut failures = Vec::new(); + for node in &graph.nodes { + if node.kind != NodeKind::Agent { + continue; + } + // `instruction` is the alias hosts commonly use for the same field, and + // the engine accepts it, so it has to be checked too. + for key in ["prompt", "instruction"] { + let Some(text) = node.config.get(key).and_then(|v| v.as_str()) else { + continue; + }; + if !crate::expr::is_expression(text) { + continue; + } + if reads_as_prose(text[1..].trim()) { + failures.push(format!( + "node '{}': `{key}` (`{text}`) reads as an instruction written as a \ + `=`-expression, not as a jq program. `=` does not interpolate — the whole \ + thing resolves to null and the node runs with an empty prompt. Fix: drop the \ + leading `=` and write the instruction plainly, referring to upstream data \ + with a separate `=` binding.", + node.id + )); + } + } + } + failures +} + +/// Bindings that read a node's output through the wrong shape. +fn binding_failures(graph: &WorkflowGraph) -> Vec { + let mut failures = Vec::new(); + for node in &graph.nodes { + for (location, expr) in collect_expressions(&node.config) { + let Some(binding) = parse_node_binding(&expr) else { + continue; + }; + // A binding to a node that does not exist is the engine's to + // report, and it already does. + let Some(target) = bindings::node_of(graph, &binding.node_id) else { + continue; + }; + if bindings::wraps_output(&target.kind) && !binding.through_envelope { + failures.push(format!( + "node '{}': `{location}` (`{expr}`) reads `.item.{path}` from {article} node \ + `{target_id}`, whose output is wrapped as {{json, text, raw}} — so this \ + resolves to null at run time and the step gets nothing. Fix: \ + `=nodes.{target_id}.item.json.{path}`.", + node.id, + path = binding.field_path, + article = bindings::kind_article(&target.kind), + target_id = binding.node_id, + )); + } + } + } + failures +} + +#[cfg(test)] +mod tests; diff --git a/src/gates/tests.rs b/src/gates/tests.rs new file mode 100644 index 00000000..7ef82133 --- /dev/null +++ b/src/gates/tests.rs @@ -0,0 +1,264 @@ +//! Tests for the authoring gates. +//! +//! Two obligations, and the second is the one that keeps a gate trustworthy: +//! it fires on the graph that is guaranteed broken, and it stays silent on +//! everything else. A gate with false positives costs authors their edits and +//! teaches them to route around it. + +use serde_json::json; + +use super::*; +use crate::model::WorkflowGraph; + +/// A graph from a node list, with no edges — the gates read configs, not +/// topology, and a trigger would only be noise here. +fn graph(nodes: serde_json::Value) -> WorkflowGraph { + serde_json::from_value(json!({ "name": "test", "nodes": nodes, "edges": [] })) + .expect("graph parses") +} + +// ---- prompts written as expressions ---- + +#[test] +fn an_instruction_written_as_an_expression_is_refused() { + let graph = graph(json!([ + { "id": "work", "kind": "agent", "name": "Work", + "config": { "prompt": "=You are given an issue: .item. Summarise it" } }, + ])); + + let failures = failures(&graph); + + // `=` does not interpolate. The whole expression resolves to null and the + // node dispatches a harness session with nothing to do. + assert_eq!(failures.len(), 1, "{failures:?}"); + assert!(failures[0].contains("does not interpolate"), "{failures:?}"); + assert!(failures[0].contains("work"), "the node has to be named"); +} + +#[test] +fn the_instruction_alias_is_checked_too() { + // `instruction` is what other hosts call the same field, and the + // engine accepts it — so a gate that only read `prompt` would miss half of + // what authors actually write. + let graph = graph(json!([ + { "id": "work", "kind": "agent", "name": "Work", + "config": { "instruction": "=Look at the diff and fix it" } }, + ])); + + assert_eq!(failures(&graph).len(), 1, "{:?}", failures(&graph)); +} + +#[test] +fn a_plain_instruction_is_left_alone() { + let graph = graph(json!([ + { "id": "work", "kind": "agent", "name": "Work", + "config": { "prompt": "You are given an issue. Summarise it." } }, + ])); + + assert!(failures(&graph).is_empty()); +} + +#[test] +fn a_real_expression_is_not_mistaken_for_prose() { + for expr in [ + "=.item.text", + "=nodes.fetch.item.json.title", + "=if .item.ok then .item.text else \"none\" end", + "=.item.issues | map(.title) | join(\", \")", + "=\"Summarise this issue for me\"", + ] { + let graph = graph(json!([ + { "id": "work", "kind": "agent", "name": "Work", + "config": { "prompt": expr } }, + ])); + + assert!( + failures(&graph).is_empty(), + "{expr} is valid jq and must not be refused: {:?}", + failures(&graph) + ); + } +} + +// ---- the output envelope ---- + +#[test] +fn reading_an_agents_output_without_the_envelope_is_refused() { + let graph = graph(json!([ + { "id": "fetch", "kind": "agent", "name": "Fetch", "config": { "prompt": "get it" } }, + { "id": "notify", "kind": "tool_call", "name": "Notify", + "config": { "slug": "demo:echo", "args": { "text": "=nodes.fetch.item.title" } } }, + ])); + + let failures = failures(&graph); + + assert_eq!(failures.len(), 1, "{failures:?}"); + assert!(failures[0].contains("args.text"), "{failures:?}"); + // The message has to carry the correction, not just the complaint. + assert!( + failures[0].contains("=nodes.fetch.item.json.title"), + "{failures:?}" + ); +} + +#[test] +fn reading_through_the_envelope_is_accepted() { + let graph = graph(json!([ + { "id": "fetch", "kind": "agent", "name": "Fetch", "config": { "prompt": "get it" } }, + { "id": "notify", "kind": "tool_call", "name": "Notify", + "config": { "slug": "demo:echo", + "args": { "text": "=nodes.fetch.item.json.title" } } }, + ])); + + assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); +} + +#[test] +fn a_node_kind_that_does_not_wrap_its_output_is_read_directly() { + // A transform's output is the item itself, so `.item.` is correct + // there and refusing it would be a false positive. + let graph = graph(json!([ + { "id": "shape", "kind": "transform", "name": "Shape", + "config": { "set": { "title": "=.item.name" } } }, + { "id": "notify", "kind": "tool_call", "name": "Notify", + "config": { "slug": "demo:echo", "args": { "text": "=nodes.shape.item.title" } } }, + ])); + + assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); +} + +#[test] +fn a_binding_nested_deep_inside_args_is_still_found_and_named() { + let graph = graph(json!([ + { "id": "fetch", "kind": "agent", "name": "Fetch", "config": { "prompt": "get it" } }, + { "id": "notify", "kind": "tool_call", "name": "Notify", + "config": { "slug": "demo:echo", "args": { + "blocks": [{ "fields": { "value": "=nodes.fetch.item.title" } }] } } }, + ])); + + let failures = failures(&graph); + + assert_eq!(failures.len(), 1, "{failures:?}"); + // Named precisely enough for an author to find it in a large config. + assert!( + failures[0].contains("args.blocks.0.fields.value"), + "{failures:?}" + ); +} + +#[test] +fn a_binding_to_a_node_that_does_not_exist_is_left_to_the_engine() { + let graph = graph(json!([ + { "id": "notify", "kind": "tool_call", "name": "Notify", + "config": { "slug": "demo:echo", "args": { "text": "=nodes.ghost.item.title" } } }, + ])); + + // The engine already reports a reference to a node that is not there; + // saying it twice in different words helps nobody. + assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); +} + +#[test] +fn an_expression_that_is_not_a_node_binding_is_not_second_guessed() { + let graph = graph(json!([ + { "id": "notify", "kind": "tool_call", "name": "Notify", + "config": { "slug": "demo:echo", + "args": { "text": "=.item.text | ascii_downcase", + "count": "=run.trigger.n" } } }, + ])); + + // A gate that guessed at arbitrary jq would refuse graphs that work. + assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); +} + +// ---- the error surface ---- + +#[test] +fn every_failure_is_reported_at_once_rather_than_the_first() { + let graph = graph(json!([ + { "id": "fetch", "kind": "agent", "name": "Fetch", + "config": { "prompt": "=Go and fetch the issues" } }, + { "id": "notify", "kind": "tool_call", "name": "Notify", + "config": { "slug": "demo:echo", "args": { "text": "=nodes.fetch.item.title" } } }, + ])); + + let messages = failures(&graph); + + // One round trip has to tell an agent everything, or it spends a turn per + // mistake. + assert_eq!(messages.len(), 2, "{messages:?}"); +} + +#[test] +fn a_clean_graph_passes() { + let graph = graph(json!([ + { "id": "t", "kind": "trigger", "name": "Start", + "config": { "trigger_kind": "manual" } }, + { "id": "work", "kind": "agent", "name": "Work", + "config": { "prompt": "summarise the open issues" } }, + ])); + + assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); +} + +// ---- code node languages ---- + +#[test] +fn a_code_node_asking_for_shell_is_refused_and_pointed_at_the_shell_node() { + let graph = graph(json!([ + { "id": "compute", "kind": "code", "name": "Compute", + "config": { "language": "shell", "source": "echo hi" } }, + ])); + + let failures = failures(&graph); + + // The engine treats anything but the literal "python" as JavaScript, so + // this would run a shell script through node and fail with a syntax error + // naming an interpreter the author never chose. + assert_eq!(failures.len(), 1, "{failures:?}"); + assert!(failures[0].contains("use a `shell` node"), "{failures:?}"); +} + +#[test] +fn a_near_miss_language_spelling_is_refused_rather_than_silently_becoming_javascript() { + for spelling in ["python3", "py", "js", "node"] { + let graph = graph(json!([ + { "id": "compute", "kind": "code", "name": "Compute", + "config": { "language": spelling, "source": "print(1)" } }, + ])); + + assert_eq!( + failures(&graph).len(), + 1, + "{spelling} must not silently become javascript" + ); + } +} + +#[test] +fn the_two_spellings_the_engine_actually_reads_are_accepted() { + for spelling in ["javascript", "python"] { + let graph = graph(json!([ + { "id": "compute", "kind": "code", "name": "Compute", + "config": { "language": spelling, "source": "x" } }, + ])); + + assert!( + failures(&graph).is_empty(), + "{spelling} is exactly what the engine matches: {:?}", + failures(&graph) + ); + } +} + +#[test] +fn a_code_node_that_names_no_language_is_left_alone() { + let graph = graph(json!([ + { "id": "compute", "kind": "code", "name": "Compute", + "config": { "source": "console.log(1)" } }, + ])); + + // Absent is legal and means JavaScript, which the engine's own default + // already says — refusing it would be a false positive. + assert!(failures(&graph).is_empty(), "{:?}", failures(&graph)); +} diff --git a/src/ids.rs b/src/ids.rs new file mode 100644 index 00000000..0eb773e1 --- /dev/null +++ b/src/ids.rs @@ -0,0 +1,72 @@ +//! Random tokens, for the places a name has to be unique rather than meaningful. +//! +//! Scratch filenames, minted note and proposal ids, the suffix on a quarantined +//! document. None of these are read for their content; what they have to +//! guarantee is that two writers — in this process or in another one sharing the +//! same directory — never choose the same one. +//! +//! Random rather than sequential for exactly that reason: a counter separates +//! writers inside one process and collides immediately across two. A dedicated +//! UUID dependency would do the same job, but this crate already carries +//! `getrandom` for the engine, and an opaque token needs no version, variant, or +//! canonical formatting. + +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Bytes of randomness behind one token — the same 128 bits a v4 UUID carries. +const TOKEN_BYTES: usize = 16; + +/// Fallback sequence, used only when the OS refuses randomness. +static SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// A unique, opaque token: 32 lowercase hex characters. +/// +/// Safe as a path component by construction — hex has no separators, no +/// `..`, and no case-folding surprises. +/// +/// Falls back to `-` if the OS refuses randomness at all, a case +/// that should not happen and where a within-process guarantee still beats a +/// fixed name shared by every writer. +#[must_use] +pub(crate) fn token() -> String { + let mut bytes = [0u8; TOKEN_BYTES]; + if getrandom::fill(&mut bytes).is_ok() { + let mut out = String::with_capacity(TOKEN_BYTES * 2); + for byte in bytes { + out.push_str(&format!("{byte:02x}")); + } + return out; + } + format!( + "{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_token_is_thirty_two_hex_characters() { + let token = token(); + + assert_eq!(token.len(), 32, "{token}"); + assert!(token.bytes().all(|b| b.is_ascii_hexdigit()), "{token}"); + } + + #[test] + fn two_tokens_differ() { + // The whole contract. A repeat here means two writers can name the same + // scratch file and scribble over each other. + assert_ne!(token(), token()); + } + + #[test] + fn a_token_is_one_path_component() { + let token = token(); + + assert_eq!(std::path::Path::new(&token).components().count(), 1); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2a5976ca..78e0f5f5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,9 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +/// Reading the expression bindings a graph declares — which node an `=` +/// expression reads from, and whether it reads as prose. +pub mod bindings; /// Browser automation protocol, action validation, and tool routing. pub mod browser; pub mod caps; @@ -30,11 +33,22 @@ pub mod data; pub mod engine; pub mod error; pub mod expr; +/// Authoring gates: what is *guaranteed* wrong with a graph, caught before a +/// write lands rather than as a silent null at run time. +pub mod gates; pub mod graph_ops; +// Only the file-backed store and the process-backed capabilities need unique +// scratch names, and both are optional. +#[cfg(any(test, feature = "store", feature = "host-caps"))] +mod ids; pub mod migrate; pub mod model; pub mod nodes; pub mod observability; +/// Stored workflows and their run history: the durable model around a graph, +/// and a file-backed store for it. Behind the `store` feature. +#[cfg(any(test, feature = "store"))] +pub mod store; pub mod validate; /// The crate name published to crates.io. diff --git a/src/store/authoring.rs b/src/store/authoring.rs new file mode 100644 index 00000000..b9b96346 --- /dev/null +++ b/src/store/authoring.rs @@ -0,0 +1,208 @@ +//! Editing a workflow as a series of patches. +//! +//! Workflows here are written by agents as often as by people, and an agent +//! editing a graph by rewriting the whole JSON document loses information every +//! time it misremembers a field. The [`GraphOp`] patch language exists for +//! exactly this: small, named, checkable edits — add a node, merge-patch a +//! config, rewire an edge — that fail loudly rather than silently dropping what +//! they did not mention. +//! +//! Every edit here is apply → validate → gate → save, in that order, and a graph +//! that fails any of the three is never written. An author's mistake costs them +//! an error message, not their saved workflow. +//! +//! The gate is the store's own [`HostPolicy`](super::HostPolicy), reached +//! through [`WorkflowStore::policy`] rather than passed in, so an edit is always +//! judged by the rules of the store it is about to land in. + +use std::sync::Arc; + +use crate::graph_ops::{GraphOp, apply_ops}; +use crate::model::WorkflowGraph; + +use super::types::{WorkflowError, WorkflowRecord, record_fingerprint}; +use super::{WorkflowStore, parse_workflow_with, require, validate_graph}; + +/// Apply `ops` to the workflow `id` and save the result. +/// +/// Returns the saved record. The workflow is left untouched if any op fails to +/// apply or the result fails validation — the ops are applied to a copy, and +/// only a graph that would compile is written back. +pub fn apply_workflow_ops( + store: &Arc, + id: &str, + ops: &[GraphOp], +) -> Result { + // A copilot, CLI, and another MCP process may all edit the same file through + // independent store instances. Rebase the patch when another writer wins + // between read and save instead of reporting two successes while silently + // discarding the earlier edit. + apply_workflow_ops_observed(store, id, ops, |_| {}).map(|(record, _)| record) +} + +/// Apply graph operations while observing each freshly read save attempt. +/// +/// The observer exists so a concurrency test can synchronize two writers after +/// their first read without relying on scheduler timing — including a host's +/// own test, pairing this against a writer of its own. Production callers use +/// [`apply_workflow_ops`], whose observer is a no-op. +pub fn apply_workflow_ops_observed( + store: &Arc, + id: &str, + ops: &[GraphOp], + observer: impl FnMut(usize), +) -> Result<(WorkflowRecord, usize), WorkflowError> { + mutate_workflow_record( + store, + id, + |record| { + record.graph = apply_ops(&record.graph, ops) + .map_err(|err| WorkflowError::Engine(format!("workflow '{id}': {err}")))?; + validate_graph(id, &record.graph)?; + store.policy().check_graph(id, &record.graph) + }, + observer, + "kept changing while the edit was being saved; retry the edit", + ) +} + +/// Mutate and atomically save a workflow record, rebasing after CAS conflicts. +pub fn mutate_workflow_record( + store: &Arc, + id: &str, + mut mutate: impl FnMut(&mut WorkflowRecord) -> Result<(), WorkflowError>, + mut observer: impl FnMut(usize), + failure: &str, +) -> Result<(WorkflowRecord, usize), WorkflowError> { + const MAX_RETRIES: usize = 16; + for attempt in 1..=MAX_RETRIES { + let mut record = require(store.as_ref(), id)?; + let expected = record_fingerprint(&record); + mutate(&mut record)?; + observer(attempt); + if store.save_if_record_fingerprint(&record, &expected)? { + return Ok((record, attempt)); + } + } + Err(WorkflowError::Engine(format!("workflow '{id}' {failure}"))) +} + +/// Apply `ops` only if the graph still matches `expected_fingerprint`. +/// +/// Returns `None` when the expected fingerprint is stale, including when the +/// graph changes between the initial read and persistence. Returns `Some` only +/// after applying the ops, validating the graph and host semantic gates, and +/// durably saving the result. +/// +/// # Errors +/// +/// Returns an error when the workflow is missing, an op cannot be applied, the +/// resulting graph fails validation or semantic checks, or persistence fails. +pub fn apply_workflow_ops_if_unchanged( + store: &Arc, + id: &str, + ops: &[GraphOp], + expected_fingerprint: &str, +) -> Result, WorkflowError> { + let mut record = require(store.as_ref(), id)?; + if super::types::fingerprint(&record.graph) != expected_fingerprint { + return Ok(None); + } + // The caller only ever observed the *graph* fingerprint, so that stays the + // freshness check above. The save itself guards the whole record: captured + // right after this read and before the mutation below, so a concurrent + // writer that changed only metadata (defaults, description — not the + // graph) between our read and our save is still detected, instead of this + // write silently overwriting that change with the metadata as it stood + // when we read it. + let observed = record_fingerprint(&record); + record.graph = apply_ops(&record.graph, ops) + .map_err(|err| WorkflowError::Engine(format!("workflow '{id}': {err}")))?; + validate_graph(id, &record.graph)?; + store.policy().check_graph(id, &record.graph)?; + if !store.save_if_record_fingerprint(&record, &observed)? { + return Ok(None); + } + Ok(Some(record)) +} + +/// Preview `ops` against the workflow `id` without saving. +/// +/// The same checks as [`apply_workflow_ops`], minus the write. What an author +/// calls to see whether an edit is sound before committing to it. +pub fn preview_workflow_ops( + store: &Arc, + id: &str, + ops: &[GraphOp], +) -> Result { + let record = require(store.as_ref(), id)?; + let graph = apply_ops(&record.graph, ops) + .map_err(|err| WorkflowError::Engine(format!("workflow '{id}': {err}")))?; + validate_graph(id, &graph)?; + store.policy().check_graph(id, &graph)?; + Ok(graph) +} + +/// Create a workflow from a whole graph document, replacing any existing one of +/// the same id. +/// +/// Parses, then validates, then saves — the same order [`apply_workflow_ops`] +/// uses, and for the same reason. A document that parses is not necessarily a +/// graph the engine would compile, and a create path that skipped validation +/// would be the one way to get an unrunnable workflow into a store whose +/// listings are otherwise trustworthy. +pub fn create_workflow( + store: &Arc, + document: &str, + id_fallback: &str, +) -> Result { + let record = parse_workflow_with(document, id_fallback, store.policy()) + .map_err(WorkflowError::Malformed)?; + validate_graph(&record.id, &record.graph)?; + store.policy().check_graph(&record.id, &record.graph)?; + store.save(&record)?; + Ok(record) +} + +/// A graph an author handed in, resolved from one of the ways they can name it. +/// +/// Two ways to say "the graph I mean" — a saved id, or an inline document — so +/// validate, preview, and dry-run all take the same argument whether the author +/// is editing something saved or checking something they have not saved yet. +pub enum GraphHandle<'a> { + /// A workflow already in the store. + Saved(&'a str), + /// A graph document supplied inline. + Inline(&'a str), +} + +impl GraphHandle<'_> { + /// Resolve to a record, without saving anything. + pub fn resolve(&self, store: &Arc) -> Result { + match self { + GraphHandle::Saved(id) => require(store.as_ref(), id), + GraphHandle::Inline(document) => { + parse_workflow_with(document, "inline", store.policy()) + .map_err(WorkflowError::Malformed) + } + } + } +} + +/// Validate a graph the author has not necessarily saved. +/// +/// Reports every failure, not the first, so one round-trip tells an author +/// everything wrong with what they wrote. +pub fn validate_handle( + store: &Arc, + handle: &GraphHandle<'_>, +) -> Result { + let record = handle.resolve(store)?; + validate_graph(&record.id, &record.graph)?; + store.policy().check_graph(&record.id, &record.graph)?; + Ok(record) +} + +#[cfg(test)] +#[path = "authoring_tests.rs"] +mod tests; diff --git a/src/store/authoring_tests.rs b/src/store/authoring_tests.rs new file mode 100644 index 00000000..ad26e587 --- /dev/null +++ b/src/store/authoring_tests.rs @@ -0,0 +1,333 @@ +//! Tests for patch-based workflow editing. +//! +//! The theme is that a bad edit costs an error message, never the saved +//! workflow. + +use std::sync::Arc; + +use crate::graph_ops::GraphOp; +use serde_json::json; + +use super::{ + GraphHandle, apply_workflow_ops, apply_workflow_ops_observed, create_workflow, + preview_workflow_ops, validate_handle, +}; +use crate::store::{FileWorkflowStore, WorkflowError, WorkflowStore}; + +fn document(id: &str) -> String { + json!({ + "id": id, + "name": "Sweep", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "start", + "config": { "trigger_kind": "manual" } }, + { "id": "work", "kind": "agent", "name": "Work", + "config": { "prompt": "do it", "agent_ref": "builder" } } + ], + "edges": [{ "from_node": "t", "to_node": "work" }] + }) + .to_string() +} + +fn store() -> (tempfile::TempDir, Arc) { + let root = tempfile::tempdir().unwrap(); + let store: Arc = Arc::new(FileWorkflowStore::new( + vec![root.path().join("workflows")], + root.path().join("runs"), + )); + (root, store) +} + +#[test] +fn concurrent_store_instances_rebase_graph_ops_instead_of_losing_one() { + let root = tempfile::tempdir().unwrap(); + let definitions = root.path().join("workflows"); + let runs = root.path().join("runs"); + let first: Arc = Arc::new(FileWorkflowStore::new( + vec![definitions.clone()], + runs.clone(), + )); + let second: Arc = + Arc::new(FileWorkflowStore::new(vec![definitions.clone()], runs)); + create_workflow(&first, &document("sweep"), "sweep").unwrap(); + + // Both first attempts pause after reading the same record, immediately + // before their CAS. One must then lose and retry; no scheduler sleep or + // knowledge of the store's private lock path is involved. + let barrier = Arc::new(std::sync::Barrier::new(2)); + + let name_barrier = barrier.clone(); + let name_edit = std::thread::spawn(move || { + apply_workflow_ops_observed( + &first, + "sweep", + &[GraphOp::SetNodeName { + id: "work".into(), + name: "Renamed".into(), + }], + |attempt| { + if attempt == 1 { + name_barrier.wait(); + } + }, + ) + }); + let config_barrier = barrier.clone(); + let config_edit = std::thread::spawn(move || { + apply_workflow_ops_observed( + &second, + "sweep", + &[GraphOp::UpdateNodeConfig { + id: "work".into(), + config: json!({ "prompt": "carefully" }), + }], + |attempt| { + if attempt == 1 { + config_barrier.wait(); + } + }, + ) + }); + let (_, name_attempts) = name_edit.join().unwrap().unwrap(); + let (_, config_attempts) = config_edit.join().unwrap().unwrap(); + assert!( + name_attempts > 1 || config_attempts > 1, + "one stale CAS must rebase" + ); + let check: Arc = Arc::new(FileWorkflowStore::new( + vec![definitions], + root.path().join("check-runs"), + )); + let record = check.get("sweep").unwrap().unwrap(); + let node = record.graph.node("work").unwrap(); + assert_eq!(node.name, "Renamed"); + assert_eq!(node.config["prompt"], "carefully"); +} + +#[test] +fn a_config_patch_merges_rather_than_replacing_the_whole_config() { + let (_root, store) = store(); + create_workflow(&store, &document("sweep"), "sweep").unwrap(); + + let record = apply_workflow_ops( + &store, + "sweep", + &[GraphOp::UpdateNodeConfig { + id: "work".into(), + config: json!({ "prompt": "do it carefully" }), + }], + ) + .expect("applies"); + + let node = record.graph.node("work").unwrap(); + assert_eq!(node.config["prompt"], "do it carefully"); + assert_eq!( + node.config["agent_ref"], "builder", + "a merge patch must not drop the fields it did not mention" + ); +} + +#[test] +fn a_null_leaf_in_a_patch_deletes_that_key() { + let (_root, store) = store(); + create_workflow(&store, &document("sweep"), "sweep").unwrap(); + + let record = apply_workflow_ops( + &store, + "sweep", + &[GraphOp::UpdateNodeConfig { + id: "work".into(), + config: json!({ "agent_ref": null }), + }], + ) + .expect("applies"); + + assert!( + record + .graph + .node("work") + .unwrap() + .config + .get("agent_ref") + .is_none(), + "the node should fall back to the default worker" + ); +} + +#[test] +fn an_op_naming_a_node_that_does_not_exist_leaves_the_workflow_untouched() { + let (_root, store) = store(); + create_workflow(&store, &document("sweep"), "sweep").unwrap(); + + let err = apply_workflow_ops( + &store, + "sweep", + &[GraphOp::SetNodeName { + id: "ghost".into(), + name: "nope".into(), + }], + ) + .expect_err("no such node"); + + assert!(err.to_string().contains("ghost"), "name the node: {err}"); + assert_eq!( + store + .get("sweep") + .unwrap() + .unwrap() + .graph + .node("work") + .unwrap() + .name, + "Work", + "a failed edit must not have been half-applied" + ); +} + +#[test] +fn an_edit_that_would_break_the_graph_is_refused_before_it_is_saved() { + let (_root, store) = store(); + create_workflow(&store, &document("sweep"), "sweep").unwrap(); + + // Removing the trigger leaves a graph the engine will not compile. + let err = apply_workflow_ops(&store, "sweep", &[GraphOp::RemoveNode { id: "t".into() }]) + .expect_err("must be refused"); + + assert!(matches!(err, WorkflowError::Invalid { .. }), "got {err:?}"); + assert!( + store + .get("sweep") + .unwrap() + .unwrap() + .graph + .node("t") + .is_some(), + "the saved workflow must still have its trigger" + ); +} + +#[test] +fn a_batch_of_ops_reports_which_one_failed() { + let (_root, store) = store(); + create_workflow(&store, &document("sweep"), "sweep").unwrap(); + + let err = apply_workflow_ops( + &store, + "sweep", + &[ + GraphOp::SetNodeName { + id: "work".into(), + name: "Renamed".into(), + }, + GraphOp::SetNodeName { + id: "ghost".into(), + name: "nope".into(), + }, + ], + ) + .expect_err("the second op fails"); + + assert!( + err.to_string().contains("ghost"), + "the message should identify the failing op: {err}" + ); + assert_eq!( + store + .get("sweep") + .unwrap() + .unwrap() + .graph + .node("work") + .unwrap() + .name, + "Work", + "the first op must not survive the batch failing" + ); +} + +#[test] +fn a_preview_checks_the_edit_without_writing_it() { + let (_root, store) = store(); + create_workflow(&store, &document("sweep"), "sweep").unwrap(); + + let previewed = preview_workflow_ops( + &store, + "sweep", + &[GraphOp::SetNodeName { + id: "work".into(), + name: "Renamed".into(), + }], + ) + .expect("previews"); + + assert_eq!(previewed.node("work").unwrap().name, "Renamed"); + assert_eq!( + store + .get("sweep") + .unwrap() + .unwrap() + .graph + .node("work") + .unwrap() + .name, + "Work", + "a preview must not save" + ); +} + +#[test] +fn an_inline_graph_can_be_validated_without_saving_it_first() { + let (_root, store) = store(); + + let record = + validate_handle(&store, &GraphHandle::Inline(&document("draft"))).expect("a valid draft"); + + assert_eq!(record.id, "draft"); + assert!( + store.list().unwrap().is_empty(), + "validating a draft must not install it" + ); +} + +#[test] +fn validating_an_inline_graph_reports_every_problem_at_once() { + let (_root, store) = store(); + let broken = json!({ + "id": "broken", + "nodes": [{ "id": "a", "kind": "transform", "name": "a" }], + "edges": [{ "from_node": "a", "to_node": "ghost" }] + }) + .to_string(); + + let err = validate_handle(&store, &GraphHandle::Inline(&broken)).expect_err("invalid"); + + let WorkflowError::Invalid { messages, .. } = err else { + panic!("expected Invalid"); + }; + assert!( + messages.len() >= 2, + "one round-trip should tell an author everything: {messages:?}" + ); +} + +#[test] +fn a_saved_handle_and_an_inline_handle_resolve_the_same_way() { + let (_root, store) = store(); + create_workflow(&store, &document("sweep"), "sweep").unwrap(); + + let saved = GraphHandle::Saved("sweep").resolve(&store).unwrap(); + let inline = GraphHandle::Inline(&document("sweep")) + .resolve(&store) + .unwrap(); + + assert_eq!(saved.graph, inline.graph); +} + +#[test] +fn editing_a_workflow_that_does_not_exist_says_so() { + let (_root, store) = store(); + + let err = apply_workflow_ops(&store, "ghost", &[]).expect_err("no such workflow"); + + assert!(matches!(err, WorkflowError::NotFound(_)), "got {err:?}"); +} diff --git a/src/store/concurrency_tests.rs b/src/store/concurrency_tests.rs new file mode 100644 index 00000000..801560a8 --- /dev/null +++ b/src/store/concurrency_tests.rs @@ -0,0 +1,424 @@ +//! Regression coverage for concurrent `save`/`delete` on one store instance. +//! +//! Split out of the sibling `tests` module (already at the repository's +//! 500-line file ceiling) rather than grown into it. What is proven here is +//! narrow but load-bearing: two threads sharing a cloned [`FileWorkflowStore`] +//! — the shape a copilot autosave racing a manual TUI edit takes, both +//! holding `Arc` clones of the same store — must not +//! interleave a save's read-modify-write and lose a revision or a write. + +use std::path::Path; +use std::sync::{Arc, Barrier}; + +use fs2::FileExt; +use serde_json::json; + +use super::WorkflowStore; +use super::file::{FileWorkflowStore, definition_state_dir}; +use crate::store::types::WorkflowRecord; + +/// A store rooted in a temporary directory. Mirrors `tests::store_in`, kept +/// local so this file has no dependency on that module's internals. +fn store_in(root: &Path) -> FileWorkflowStore { + FileWorkflowStore::new(vec![root.join("workflows")], root.join("runs")) +} + +/// A minimal valid document, distinguished only by its `name` so two writers +/// racing on the same id can be told apart afterwards. +fn document(id: &str, name: &str) -> WorkflowRecord { + let graph = serde_json::from_value(json!({ + "id": id, + "name": name, + "nodes": [ + { "id": "t", "kind": "trigger", "name": "start", + "config": { "trigger_kind": "manual" } }, + ], + "edges": [], + })) + .expect("graph parses"); + WorkflowRecord { + id: id.to_string(), + name: name.to_string(), + description: String::new(), + enabled: true, + defaults: Default::default(), + graph, + source_path: None, + } +} + +#[test] +fn two_threads_saving_the_same_id_at_once_never_lose_a_revision() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store_in(root.path()); + store.save(&document("race", "v0")).expect("seed save"); + + // Released together so both threads' read-modify-write genuinely overlaps + // rather than happening to run one after the other by scheduling luck — + // the failure mode under test is a race, so the test has to race. + let barrier = Arc::new(Barrier::new(2)); + let handles: Vec<_> = ["v1", "v2"] + .into_iter() + .map(|name| { + let store = store.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + store.save(&document("race", name)).expect("save") + }) + }) + .collect(); + for handle in handles { + handle.join().expect("thread panicked"); + } + + // Three versions existed in total: the seed, and one per racing save. The + // lock does not decide which save wins the final file — that is still a + // last-write-wins race, same as any single-writer save — only that each + // save's own read-then-snapshot-then-write cannot be torn by the other's. + // Without it, a save could snapshot what the *other* save had already + // half-written, or overwrite the other's snapshot before it captured + // anything — either way losing one of the three versions below. + let revisions = store.list_revisions("race").expect("list revisions"); + assert_eq!( + revisions.len(), + 2, + "both saves must have captured the version they superseded: {revisions:?}" + ); + let current = store.get("race").expect("get").expect("still exists"); + assert!( + current.name == "v1" || current.name == "v2", + "the surviving write must be one of the two racing saves, not a torn mix: {}", + current.name + ); +} + +#[test] +fn a_save_racing_a_delete_leaves_the_deletion_recoverable() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store_in(root.path()); + store.save(&document("race", "v0")).expect("seed save"); + + let barrier = Arc::new(Barrier::new(2)); + let save_store = store.clone(); + let save_barrier = barrier.clone(); + let saver = std::thread::spawn(move || { + save_barrier.wait(); + // Either order is fine — this is a race — but it must not panic or + // corrupt the store either way. + let _ = save_store.save(&document("race", "v1")); + }); + let delete_store = store.clone(); + let deleter = std::thread::spawn(move || { + barrier.wait(); + let _ = delete_store.delete("race"); + }); + saver.join().expect("save thread panicked"); + deleter.join().expect("delete thread panicked"); + + // Whatever order the two actually ran in, `list_revisions` must still + // reflect every version that existed before whichever write settled last + // — the same "nothing gets torn" property, just across the two different + // operations that share the lock. + let revisions = store.list_revisions("race").expect("list revisions"); + assert!( + !revisions.is_empty(), + "at least the seed version must have been captured: {revisions:?}" + ); +} + +#[test] +fn failed_definition_publish_does_not_leave_a_revision() { + let root = tempfile::tempdir().expect("tempdir"); + let lower = root.path().join("defaults"); + let upper = root.path().join("workflows"); + std::fs::create_dir_all(&lower).expect("lower definitions"); + let seed = document("blocked", "v0"); + let seed_store = FileWorkflowStore::new(vec![lower.clone()], root.path().join("seed-runs")); + seed_store.save(&seed).expect("seed lower definition"); + + std::fs::create_dir_all(upper.join("blocked.json")).expect("blocking destination directory"); + let store = FileWorkflowStore::new(vec![lower, upper], root.path().join("runs")); + assert!(store.save(&document("blocked", "v1")).is_err()); + assert!( + store + .list_revisions("blocked") + .expect("list revisions") + .is_empty(), + "a source version that was never superseded must not enter history" + ); +} + +#[test] +fn separate_store_instances_use_the_same_definition_lock() { + let root = tempfile::tempdir().expect("tempdir"); + let first = store_in(root.path()); + let second = store_in(root.path()); + first.save(&document("race", "v0")).expect("seed save"); + + let lock_path = definition_state_dir( + &root.path().join("state/workflows"), + &[root.path().join("workflows")], + ) + .join("locks/.race.lock"); + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(lock_path) + .expect("definition lock exists"); + lock.lock_exclusive().expect("claim definition lock"); + + let (sent, received) = std::sync::mpsc::channel(); + let writer = std::thread::spawn(move || { + sent.send(second.save(&document("race", "v1"))) + .expect("report save"); + }); + assert!( + received + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "a separate store must wait for the filesystem lock" + ); + + FileExt::unlock(&lock).expect("release definition lock"); + received + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("save completes after unlock") + .expect("save succeeds"); + writer.join().expect("writer thread"); +} + +#[test] +fn workspace_scoped_stores_share_the_global_definition_lock() { + let root = tempfile::tempdir().expect("tempdir"); + let definitions = vec![root.path().join("workflows")]; + let state = root.path().join("state"); + let first = FileWorkflowStore::with_workspace_state( + definitions.clone(), + &state, + &root.path().join("workspace-a"), + ); + let second = FileWorkflowStore::with_workspace_state( + definitions, + &state, + &root.path().join("workspace-b"), + ); + first.save(&document("race", "v0")).expect("seed save"); + + let lock_path = + definition_state_dir(&state, &[root.path().join("workflows")]).join("locks/.race.lock"); + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(lock_path) + .expect("global definition lock exists"); + lock.lock_exclusive().expect("claim definition lock"); + + let (sent, received) = std::sync::mpsc::channel(); + let writer = std::thread::spawn(move || { + sent.send(second.save(&document("race", "v1"))) + .expect("report save"); + }); + assert!( + received + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "a store for another workspace must wait on the shared definition lock" + ); + + FileExt::unlock(&lock).expect("release definition lock"); + received + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("save completes after unlock") + .expect("save succeeds"); + writer.join().expect("writer thread"); + + let history = first.list_revisions("race").expect("shared history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].record.name, "v0"); +} + +#[test] +fn explicit_stores_derive_locks_from_the_shared_definition_destination() { + let root = tempfile::tempdir().expect("tempdir"); + let definitions = vec![root.path().join("workflows")]; + let first = FileWorkflowStore::new(definitions.clone(), root.path().join("a/runs")); + let second = FileWorkflowStore::new(definitions, root.path().join("b/runs")); + first.save(&document("race", "v0")).expect("seed save"); + + let lock_path = definition_state_dir( + &root.path().join("state/workflows"), + &[root.path().join("workflows")], + ) + .join("locks/.race.lock"); + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(lock_path) + .expect("definition-derived lock exists"); + lock.lock_exclusive().expect("claim definition lock"); + + let (sent, received) = std::sync::mpsc::channel(); + let writer = std::thread::spawn(move || { + sent.send(second.save(&document("race", "v1"))) + .expect("report save"); + }); + assert!( + received + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "different run roots must not split the shared definition lock" + ); + FileExt::unlock(&lock).expect("release definition lock"); + received + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("save completes after unlock") + .expect("save succeeds"); + writer.join().expect("writer thread"); +} + +#[test] +fn lexical_catalog_aliases_derive_the_same_definition_state() { + let root = tempfile::tempdir().expect("tempdir"); + let direct = root.path().join("catalog"); + let aliased = root.path().join("missing/../catalog"); + + assert_eq!( + super::file::definition_state_dir(root.path(), &[direct]), + super::file::definition_state_dir(root.path(), &[aliased]) + ); +} + +#[cfg(unix)] +#[test] +fn symlinked_catalog_aliases_derive_the_same_definition_state() { + let root = tempfile::tempdir().expect("tempdir"); + let direct = root.path().join("catalog"); + std::fs::create_dir(&direct).expect("catalog"); + let alias = root.path().join("catalog-link"); + std::os::unix::fs::symlink(&direct, &alias).expect("symlink"); + + assert_eq!( + super::file::definition_state_dir(root.path(), &[direct]), + super::file::definition_state_dir(root.path(), &[alias]) + ); +} + +#[test] +fn sibling_definition_catalogs_do_not_share_revision_history() { + let root = tempfile::tempdir().expect("tempdir"); + let first = FileWorkflowStore::new( + vec![root.path().join("catalog-a")], + root.path().join("runs-a"), + ); + let second = FileWorkflowStore::new( + vec![root.path().join("catalog-b")], + root.path().join("runs-b"), + ); + first.save(&document("same", "a0")).expect("seed first"); + first.save(&document("same", "a1")).expect("edit first"); + second.save(&document("same", "b0")).expect("seed second"); + + assert_eq!( + first.list_revisions("same").expect("first history").len(), + 1 + ); + assert!( + second + .list_revisions("same") + .expect("second history") + .is_empty() + ); +} + +#[cfg(unix)] +#[test] +fn symlinked_catalog_paths_share_definition_state() { + let root = tempfile::tempdir().expect("tempdir"); + let real = root.path().join("real/workflows"); + std::fs::create_dir_all(&real).expect("real catalog"); + let alias = root.path().join("alias"); + std::os::unix::fs::symlink(root.path().join("real"), &alias).expect("catalog alias"); + let first = FileWorkflowStore::new(vec![real], root.path().join("runs-a")); + let second = FileWorkflowStore::new(vec![alias.join("workflows")], root.path().join("runs-b")); + + first + .save(&document("same", "v0")) + .expect("seed definition"); + second + .save(&document("same", "v1")) + .expect("edit through alias"); + assert_eq!( + first.list_revisions("same").expect("shared history").len(), + 1 + ); +} + +#[cfg(unix)] +#[test] +fn symlinked_catalog_destination_shares_inferred_state_root() { + let root = tempfile::tempdir().expect("tempdir"); + let real = root.path().join("real/catalog"); + let alias_parent = root.path().join("alias"); + std::fs::create_dir_all(&real).expect("real catalog"); + std::fs::create_dir(&alias_parent).expect("alias parent"); + let alias = alias_parent.join("catalog-link"); + std::os::unix::fs::symlink(&real, &alias).expect("catalog symlink"); + let first = FileWorkflowStore::new(vec![real], root.path().join("runs-a")); + let second = FileWorkflowStore::new(vec![alias], root.path().join("runs-b")); + first + .save(&document("same", "v0")) + .expect("seed definition"); + second + .save(&document("same", "v1")) + .expect("edit through alias"); + assert_eq!( + first.list_revisions("same").expect("shared history").len(), + 1 + ); +} + +#[cfg(unix)] +#[test] +fn parent_after_symlink_uses_filesystem_catalog_identity() { + let root = tempfile::tempdir().expect("tempdir"); + let other = root.path().join("other"); + std::fs::create_dir_all(other.join("inner")).expect("symlink target"); + std::fs::create_dir(other.join("catalog")).expect("real catalog"); + std::os::unix::fs::symlink(other.join("inner"), root.path().join("link")) + .expect("directory symlink"); + let aliased = root.path().join("link/../catalog"); + assert_eq!( + definition_state_dir(root.path(), &[aliased]), + definition_state_dir(root.path(), &[other.join("catalog")]) + ); +} + +#[test] +fn separate_store_instances_serialize_proposal_decisions() { + let root = tempfile::tempdir().expect("tempdir"); + let first = store_in(root.path()); + let second = store_in(root.path()); + let first_claim = first + .lock_proposal_decision("race") + .expect("claim proposal decisions"); + + let (sent, received) = std::sync::mpsc::channel(); + let contender = std::thread::spawn(move || { + let claim = second.lock_proposal_decision("race"); + sent.send(claim.map(drop)).expect("report decision claim"); + }); + assert!( + received + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "another store must wait before deciding the same workflow" + ); + + drop(first_claim); + received + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("claim completes after unlock") + .expect("claim succeeds"); + contender.join().expect("contender thread"); +} diff --git a/src/store/file/dirs.rs b/src/store/file/dirs.rs new file mode 100644 index 00000000..fe2b614f --- /dev/null +++ b/src/store/file/dirs.rs @@ -0,0 +1,78 @@ +//! Which directories hold workflows. + +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// The workflow directories for a host laying its catalog out the conventional +/// way, lowest precedence first: project-local `//workflows`, +/// then user-global `/workflows`. +/// +/// Project definitions remain readable as repository-provided defaults, while +/// authored and edited definitions are written to the final, user-global layer +/// beside the rest of the host's persistent data. +/// +/// The two must be distinct directories. A host whose `home` resolves *inside* +/// `/` would read the same directory twice and make every +/// workflow shadow itself; that is the host's constraint to honour when it +/// chooses a home, not something this function can check. +pub fn workflow_dirs(home: &Path, cwd: &Path, project_dir: &str) -> Vec { + vec![ + cwd.join(project_dir).join("workflows"), + home.join("workflows"), + ] +} + +/// State shared by stores writing the same catalog, beneath the caller's root. +pub(crate) fn definition_state_dir(state_root: &Path, dirs: &[PathBuf]) -> PathBuf { + let write_dir = catalog_identity(dirs); + let scope = format!( + "{:x}", + Sha256::digest(write_dir.as_os_str().as_encoded_bytes()) + ); + state_root.join("definitions").join(scope) +} + +/// Canonical identity of the catalog's write destination. +pub(crate) fn catalog_identity(dirs: &[PathBuf]) -> PathBuf { + let raw = dirs.last().map_or_else( + || PathBuf::from("."), + |dir| { + if dir.is_absolute() { + dir.clone() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(dir) + } + }, + ); + canonical_path_identity(&raw) +} + +/// Resolve existing symlinks while retaining lexical semantics for missing parts. +fn canonical_path_identity(path: &Path) -> PathBuf { + let mut resolved = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if resolved.exists() { + resolved = std::fs::canonicalize(&resolved).unwrap_or(resolved); + } + resolved.pop(); + } + other => { + resolved.push(other.as_os_str()); + if resolved.exists() { + resolved = std::fs::canonicalize(&resolved).unwrap_or(resolved); + } + } + } + } + resolved +} + +#[cfg(test)] +#[path = "dirs_tests.rs"] +mod tests; diff --git a/src/store/file/dirs_tests.rs b/src/store/file/dirs_tests.rs new file mode 100644 index 00000000..6e3358ea --- /dev/null +++ b/src/store/file/dirs_tests.rs @@ -0,0 +1,78 @@ +//! Unit tests for workflow directory layering: which directories hold +//! workflows, in what precedence, and which one a save lands in. + +use std::path::{Path, PathBuf}; + +use super::super::{FileWorkflowStore, parse_workflow}; +use super::workflow_dirs; +use crate::store::WorkflowStore; + +/// The smallest document that validates: one trigger, one transform, one edge. +fn valid_document(id: &str) -> String { + serde_json::json!({ + "id": id, + "name": "Greet", + "description": "says hello", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "start", + "config": { "trigger_kind": "manual" } }, + { "id": "greet", "kind": "transform", "name": "greet", + "config": { "set": { "greeting": "=.item.name" } } } + ], + "edges": [ + { "from_node": "t", "from_port": "main", "to_node": "greet", "to_port": "main" } + ] + }) + .to_string() +} + +#[test] +fn the_directories_are_project_then_home_with_home_as_the_write_layer() { + let dirs = workflow_dirs(Path::new("/somewhere/home"), Path::new("/repo"), ".myapp"); + + assert_eq!( + dirs, + vec![ + PathBuf::from("/repo/.myapp/workflows"), + PathBuf::from("/somewhere/home/workflows"), + ] + ); +} + +#[test] +fn a_discovered_store_saves_new_definitions_under_the_home() { + // The precedence rule made observable: a project directory is a place to + // *read* repository-provided defaults from, never a place an edit lands. + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = root.path().join("project"); + let store = FileWorkflowStore::discover(&home, &project, ".myapp"); + let record = parse_workflow(&valid_document("home-save"), "home-save").unwrap(); + + store.save(&record).unwrap(); + + assert!(home.join("workflows/home-save.json").is_file()); + assert!(!project.join(".myapp/workflows/home-save.json").exists()); +} + +#[test] +fn a_home_inside_the_project_directory_collapses_both_layers() { + // The constraint `workflow_dirs` documents but cannot enforce: a host whose + // home resolves inside `/` reads one directory twice and + // makes every workflow shadow itself. + let dirs = workflow_dirs(Path::new("/repo/.myapp"), Path::new("/repo"), ".myapp"); + + assert_eq!(dirs.len(), 2); + assert_eq!( + dirs[0], dirs[1], + "a home inside the project directory collapses both layers onto one path" + ); +} + +#[test] +fn a_home_outside_the_project_keeps_the_two_layers_distinct() { + let dirs = workflow_dirs(Path::new("/somewhere/home"), Path::new("."), ".myapp"); + + assert_eq!(dirs.len(), 2); + assert_ne!(dirs[0], dirs[1]); +} diff --git a/src/store/file/document.rs b/src/store/file/document.rs new file mode 100644 index 00000000..569f6701 --- /dev/null +++ b/src/store/file/document.rs @@ -0,0 +1,250 @@ +//! The on-disk workflow document: reading it, writing it, checking it. +//! +//! A document is the engine's `WorkflowGraph` JSON with this host's own fields +//! (`id`, `name`, `description`, `enabled`, `defaults`) merged in beside it, +//! rather than nested under a wrapper. That shape is deliberate: a file an operator opens +//! reads as a graph, and a graph exported from anywhere else loads here without +//! being re-wrapped. + +use std::path::Path; + +use crate::model::WorkflowGraph; +use serde_json::Value; + +use crate::store::types::{RunRecord, RunStatus, WorkflowDefaults, WorkflowError, WorkflowRecord}; + +/// Read and parse one workflow document, naming errors by path. +pub fn read_workflow(path: &Path) -> Result { + read_workflow_with(path, &EnginePolicy) +} + +/// [`read_workflow`], judging the `defaults` block by a host's policy. +pub fn read_workflow_with(path: &Path, policy: &dyn HostPolicy) -> Result { + let text = std::fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?; + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let mut record = parse_workflow_with(&text, stem, policy) + .map_err(|err| format!("{}: {err}", path.display()))?; + // A document can deserialize cleanly and still be a graph the engine will + // not compile — no trigger, an edge to a node that is not there. Catching + // it here means a listing only ever shows workflows that would actually + // run, and the operator hears about the broken file by name. + validate_graph(&record.id, &record.graph) + .map_err(|err| format!("{}: {err}", path.display()))?; + record.source_path = Some(path.to_path_buf()); + Ok(record) +} + +/// Parse one workflow document. +/// +/// The document is the engine's `WorkflowGraph` JSON with optional host fields +/// (`description`, `enabled`, `defaults`) alongside it. `id` defaults to +/// `id_fallback` — the filename, for a file — and `name` to the id, so the +/// smallest useful document is a set of nodes and edges. +/// +/// The pipeline is the engine's documented one: migrate the persisted JSON to +/// the current schema *before* deserializing, so a definition saved by an older +/// build keeps loading. +pub fn parse_workflow(text: &str, id_fallback: &str) -> Result { + parse_workflow_with(text, id_fallback, &EnginePolicy) +} + +/// The judgements about a workflow that only its host can make. +/// +/// Two of them, and they have the same shape of reason. A `defaults` block's +/// `harness` and `model` are opaque strings to this crate — which harnesses +/// exist, and what names them, is the embedding application's vocabulary. So is +/// which tool slugs resolve, or which integrations are installed, which is the +/// kind of thing a host's own authoring gate refuses. +/// +/// Both are checked *at the boundary*: a document naming a harness the host +/// does not have must fail loudly at load, not quietly at dispatch, and an edit +/// that a host gate would refuse must never reach the disk. So the rules are the +/// host's to supply, and the store's to run. +/// +/// The default implementations are the honest answer for a host with no such +/// vocabulary: accept any `defaults`, and apply the engine's own gates +/// ([`crate::gates`]) and nothing more. +pub trait HostPolicy: std::fmt::Debug + Send + Sync { + /// Accept `defaults`, or say in one sentence what is wrong with it. + /// + /// # Errors + /// Returns the sentence shown to whoever tried to load or save the + /// document. + fn check_defaults(&self, defaults: &WorkflowDefaults) -> Result<(), String> { + let _ = defaults; + Ok(()) + } + + /// Accept `graph` as an authoring write, or list everything wrong with it. + /// + /// A host overriding this should run [`crate::gates::failures`] as well as + /// its own — the engine's gates catch the mistakes that are wrong on any + /// host, and dropping them would be a silent loss. + /// + /// # Errors + /// Returns [`WorkflowError::Invalid`] listing every failure, so one round + /// trip tells an author everything rather than one thing at a time. + fn check_graph(&self, id: &str, graph: &WorkflowGraph) -> Result<(), WorkflowError> { + gate_failures_into_error(id, crate::gates::failures(graph)) + } +} + +/// Turn a gate failure list into the error every authoring path reports. +/// +/// Public because a host overriding [`HostPolicy::check_graph`] has to build the +/// same error from its own combined list, and rebuilding it by hand is how the +/// two drift. +/// +/// # Errors +/// Returns [`WorkflowError::Invalid`] when `failures` is non-empty. +pub fn gate_failures_into_error(id: &str, failures: Vec) -> Result<(), WorkflowError> { + if failures.is_empty() { + return Ok(()); + } + Err(WorkflowError::Invalid { + id: id.to_string(), + messages: failures, + }) +} + +/// The policy for a host with no vocabulary of its own. +#[derive(Debug, Clone, Copy, Default)] +pub struct EnginePolicy; + +impl HostPolicy for EnginePolicy {} + +/// [`parse_workflow`], judging the `defaults` block by a host's policy. +pub fn parse_workflow_with( + text: &str, + id_fallback: &str, + policy: &dyn HostPolicy, +) -> Result { + let raw: Value = serde_json::from_str(text).map_err(|err| format!("invalid JSON: {err}"))?; + let migrated = crate::migrate::migrate(raw).map_err(|err| err.to_string())?; + + let object = migrated + .as_object() + .ok_or_else(|| "workflow document must be a JSON object".to_string())?; + + let id = object + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .unwrap_or(id_fallback) + .to_string(); + let description = object + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let enabled = object + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or(true); + + // Parsed before the graph, because parsing consumes `migrated`. An + // unreadable `defaults` block is a hard error rather than an ignored one: a + // workflow that meant to run on Codex and silently ran on the host default + // is exactly the kind of quiet wrongness this store exists to refuse. + let defaults: WorkflowDefaults = match object.get("defaults") { + Some(Value::Null) | None => WorkflowDefaults::default(), + Some(value) => serde_json::from_value(value.clone()) + .map_err(|err| format!("invalid `defaults`: {err}"))?, + }; + policy + .check_defaults(&defaults) + .map_err(|err| format!("invalid `defaults`: {err}"))?; + + let graph: WorkflowGraph = + serde_json::from_value(migrated).map_err(|err| format!("invalid workflow: {err}"))?; + let name = if graph.name.is_empty() { + id.clone() + } else { + graph.name.clone() + }; + + Ok(WorkflowRecord { + id, + name, + description, + enabled, + defaults, + graph, + source_path: None, + }) +} + +/// Run the engine's validation, collecting every failure rather than the first. +/// +/// One round-trip then tells an author everything wrong with their graph, which +/// matters most when the author is an agent editing over a tool call. +pub fn validate_graph(id: &str, graph: &WorkflowGraph) -> Result<(), WorkflowError> { + let errors = crate::validate::validate_all(graph); + if errors.is_empty() { + return Ok(()); + } + Err(WorkflowError::Invalid { + id: id.to_string(), + messages: errors + .iter() + .map(|err| match err.node_id() { + Some(node) => format!("[{}] {node}: {err}", err.code()), + None => format!("[{}] {err}", err.code()), + }) + .collect(), + }) +} + +/// Serialize a record into the on-disk document shape: the graph, with the host +/// fields merged in beside it. +pub fn to_document(record: &WorkflowRecord) -> Result, WorkflowError> { + let mut value = serde_json::to_value(&record.graph) + .map_err(|err| WorkflowError::Malformed(err.to_string()))?; + if let Some(object) = value.as_object_mut() { + object.insert("id".into(), Value::String(record.id.clone())); + object.insert("name".into(), Value::String(record.name.clone())); + object.insert( + "description".into(), + Value::String(record.description.clone()), + ); + object.insert("enabled".into(), Value::Bool(record.enabled)); + // Omitted entirely when the workflow states no preference, so an + // untouched document does not grow a block of nulls. + if !record.defaults.is_empty() { + let defaults = serde_json::to_value(&record.defaults) + .map_err(|err| WorkflowError::Malformed(err.to_string()))?; + object.insert("defaults".into(), defaults); + } else { + object.remove("defaults"); + } + } + serde_json::to_vec_pretty(&value).map_err(|err| WorkflowError::Malformed(err.to_string())) +} + +/// A run record for a run that has just started. +pub fn new_run_record(id: &str, workflow_id: &str, started_at: u64) -> RunRecord { + RunRecord { + id: id.to_string(), + workflow_id: workflow_id.to_string(), + status: RunStatus::Running, + started_at, + finished_at: None, + steps: Vec::new(), + pending_approvals: Vec::new(), + error: None, + // Supplied by the caller through `RunRecord::with_inputs` and + // `with_origin`, which every real door does. A record built without + // them is still honest — it simply says nothing about what it was + // started with, which is what an older record says too. + inputs: serde_json::Map::new(), + trigger: None, + origin: None, + // Both are evidence about a run that has ended, so a run that has only + // just started has neither. They are filled in when it settles. + summary: None, + diagnosis: None, + } +} diff --git a/src/store/file/journal/mod.rs b/src/store/file/journal/mod.rs new file mode 100644 index 00000000..bcbd94ac --- /dev/null +++ b/src/store/file/journal/mod.rs @@ -0,0 +1,48 @@ +//! One workflow's notes on disk. +//! +//! Stored under the state directory beside run records rather than beside the +//! definitions, because a journal is *host* knowledge: it is what this machine +//! observed while running the workflow, not part of the document an operator +//! edits and commits. +//! +//! One file per workflow, not one per note. Notes are only ever read as a whole +//! set — a brief wants all of them or none — and a directory per workflow would +//! reproduce the unindexed scan that already makes run history expensive. + +mod persistence; +mod prune; + +pub use persistence::{append, list, supersede}; + +use crate::store::types::NoteId; + +/// How many notes one workflow keeps. +/// +/// Generous, because a note is a sentence rather than a graph, and a workflow +/// that has failed a hundred times has a hundred things worth remembering. The +/// cap exists so an automated pass writing on every failure cannot grow a file +/// without bound. +pub const MAX_NOTES: usize = 100; + +/// Tie-breaker for notes written inside the same millisecond. +/// +/// Process-wide for the same reason revisions use one: it only has to increase, +/// and a per-file count read off disk could be raced into reuse. +static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Mint a note id that sorts chronologically. +/// +/// Same three-part scheme as a revision id: a zero-padded stamp so a lexical +/// sort is a chronological one, a monotonic counter because a pass writes +/// several notes inside one millisecond, and a random token because two +/// processes can pick the same counter. +pub fn mint_id(recorded_at: u64) -> NoteId { + format!( + "{recorded_at:013}-{:012}-{}", + SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + crate::ids::token() + ) +} + +#[cfg(test)] +mod tests; diff --git a/src/store/file/journal/persistence.rs b/src/store/file/journal/persistence.rs new file mode 100644 index 00000000..106ad6f4 --- /dev/null +++ b/src/store/file/journal/persistence.rs @@ -0,0 +1,151 @@ +//! Locks, recovers, reads, and atomically writes one workflow's journal. + +use std::path::{Path, PathBuf}; + +use fs2::FileExt; + +use crate::store::types::{WorkflowError, WorkflowNote}; + +use super::super::paths::{safe_component, write_atomic}; +use super::prune::prune; + +/// Where one workflow's journal lives. +fn path_for(journal_dir: &Path, workflow_id: &str) -> Result { + Ok(journal_dir.join(format!("{}.json", safe_component(workflow_id)?))) +} + +/// Every note for `workflow_id`, newest first, superseded ones included. +/// +/// A journal this host cannot parse yields an empty list with a warning rather +/// than an error. The alternative is that one bad file makes a workflow +/// unreadable everywhere its notes are shown, which is a worse failure than +/// forgetting what it learned — and run history already behaves this way. +pub fn list(journal_dir: &Path, workflow_id: &str) -> Result, WorkflowError> { + with_write_lock(journal_dir, workflow_id, || { + let mut notes = read_all(journal_dir, workflow_id)?; + notes.sort_by(|a, b| b.id.cmp(&a.id)); + Ok(notes) + }) +} + +/// Append `note`, then prune to [`super::MAX_NOTES`]. +/// +/// # Errors +/// +/// Fails when the workflow id is not a usable filename, or when the file cannot +/// be written. A note that could not be recorded is a real failure: the callers +/// that append are the ones claiming the host now knows something. +pub fn append(journal_dir: &Path, note: &WorkflowNote) -> Result<(), WorkflowError> { + with_write_lock(journal_dir, ¬e.workflow_id, || { + let mut notes = read_all(journal_dir, ¬e.workflow_id)?; + notes.push(note.clone()); + prune(&mut notes); + write(journal_dir, ¬e.workflow_id, ¬es) + }) +} + +/// Mark `id` as replaced by `by`, returning whether a current note changed. +/// +/// Silently does nothing when the note is not there. Supersession is a tidying +/// action taken after the fact, and a caller naming a note that has already +/// been pruned away has nothing left to fix. +pub fn supersede( + journal_dir: &Path, + workflow_id: &str, + id: &str, + by: &str, +) -> Result { + with_write_lock(journal_dir, workflow_id, || { + let mut notes = read_all(journal_dir, workflow_id)?; + let mut changed = false; + for note in notes.iter_mut() { + if note.id == id && note.superseded_by.is_none() { + note.superseded_by = Some(by.to_string()); + changed = true; + } + } + if !changed { + return Ok(false); + } + write(journal_dir, workflow_id, ¬es)?; + Ok(true) + }) +} + +/// Serialize journal reads and writes across stores and processes. +/// +/// Reads participate because recovering a corrupt file renames it. Without the +/// same lock a reader could quarantine the valid replacement a writer had just +/// installed after the reader captured the old corrupt bytes. +fn with_write_lock( + journal_dir: &Path, + workflow_id: &str, + write_operation: impl FnOnce() -> Result, +) -> Result { + std::fs::create_dir_all(journal_dir).map_err(|source| WorkflowError::Io { + path: journal_dir.to_path_buf(), + source, + })?; + let lock_path = journal_dir.join(format!("{}.lock", safe_component(workflow_id)?)); + let lock = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .map_err(|source| WorkflowError::Io { + path: lock_path.clone(), + source, + })?; + lock.lock_exclusive().map_err(|source| WorkflowError::Io { + path: lock_path.clone(), + source, + })?; + let result = write_operation(); + // Qualified rather than `lock.unlock()`: `std::fs::File` grew an inherent + // `unlock` in 1.89, which would win the method lookup and take this crate + // past its 1.85 MSRV without any error saying so. + if let Err(source) = FileExt::unlock(&lock) { + tracing::warn!(path = %lock_path.display(), "failed to release journal lock: {source}"); + } + result +} + +/// Read the file, treating absence and corruption alike as "nothing learned". +fn read_all(journal_dir: &Path, workflow_id: &str) -> Result, WorkflowError> { + let path = path_for(journal_dir, workflow_id)?; + let body = match std::fs::read(&path) { + Ok(body) => body, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => return Err(WorkflowError::Io { path, source }), + }; + match serde_json::from_slice::>(&body) { + Ok(notes) => Ok(notes), + Err(err) => { + // Kept, not overwritten. Reading past a corrupt journal is a + // deliberate kindness; appending on top of it would destroy + // whatever an operator might still have recovered by hand, which + // is a different and much less forgivable thing to do. + let quarantine = path.with_extension(format!("json.corrupt.{}", crate::ids::token())); + let _ = std::fs::rename(&path, &quarantine); + tracing::warn!( + workflow = %workflow_id, + path = %path.display(), + kept = %quarantine.display(), + "workflow journal is unreadable; moved aside and starting a new one: {err}" + ); + Ok(Vec::new()) + } + } +} + +/// Write the whole journal back. +fn write( + journal_dir: &Path, + workflow_id: &str, + notes: &[WorkflowNote], +) -> Result<(), WorkflowError> { + let body = serde_json::to_vec_pretty(notes) + .map_err(|err| WorkflowError::Malformed(err.to_string()))?; + write_atomic(&path_for(journal_dir, workflow_id)?, &body) +} diff --git a/src/store/file/journal/prune.rs b/src/store/file/journal/prune.rs new file mode 100644 index 00000000..846eaa75 --- /dev/null +++ b/src/store/file/journal/prune.rs @@ -0,0 +1,83 @@ +//! Prunes old notes without breaking supersession chains. + +use crate::store::types::WorkflowNote; + +use super::MAX_NOTES; + +/// Drop the oldest notes past [`MAX_NOTES`]. +/// +/// Pinned notes are protected. Supersession chains are pruned as whole groups: +/// a recent replacement and its predecessor survive together, while an old +/// chain can eventually leave together without a dangling `superseded_by`. +pub(super) fn prune(notes: &mut Vec) { + if notes.len() <= MAX_NOTES { + return; + } + + let by_id: std::collections::HashMap<&str, usize> = notes + .iter() + .enumerate() + .map(|(index, note)| (note.id.as_str(), index)) + .collect(); + let mut parents: Vec = (0..notes.len()).collect(); + for (index, note) in notes.iter().enumerate() { + if let Some(replacement) = note.superseded_by.as_deref().and_then(|id| by_id.get(id)) { + join_groups(&mut parents, index, *replacement); + } + } + + let mut groups: std::collections::HashMap> = std::collections::HashMap::new(); + for index in 0..notes.len() { + let root = group_root(&mut parents, index); + groups.entry(root).or_default().push(index); + } + let mut droppable: Vec> = groups + .into_values() + .filter(|group| group.iter().all(|index| !notes[*index].pinned)) + .collect(); + droppable.sort_by(|a, b| { + let oldest = |group: &[usize]| { + group + .iter() + .map(|index| notes[*index].id.as_str()) + .min() + .unwrap_or_default() + }; + oldest(a).cmp(oldest(b)) + }); + + let needed = notes.len() - MAX_NOTES; + let mut removed = 0; + let mut doomed = std::collections::HashSet::new(); + for group in droppable { + if removed >= needed { + break; + } + removed += group.len(); + doomed.extend(group); + } + let mut index = 0; + notes.retain(|_| { + let keep = !doomed.contains(&index); + index += 1; + keep + }); +} + +/// Find a supersession group's root while compressing the traversed path. +fn group_root(parents: &mut [usize], index: usize) -> usize { + if parents[index] != index { + let parent = parents[index]; + parents[index] = group_root(parents, parent); + } + parents[index] +} + +/// Join two notes into one indivisible supersession group. +fn join_groups(parents: &mut [usize], left: usize, right: usize) { + let left = group_root(parents, left); + let right = group_root(parents, right); + if left != right { + parents[right] = left; + } +} diff --git a/src/store/file/journal/tests.rs b/src/store/file/journal/tests.rs new file mode 100644 index 00000000..1a635fa3 --- /dev/null +++ b/src/store/file/journal/tests.rs @@ -0,0 +1,302 @@ +//! Tests for the on-disk journal. +//! +//! The behaviour worth pinning here is what happens when things go wrong: a +//! journal that cannot be parsed, an id that is not a filename, a cap reached +//! by automation. The happy path is a JSON array; the failure paths are where +//! a workflow either keeps working or stops. + +use std::path::Path; + +use super::*; +use crate::store::types::{NoteKind, NoteSource, WorkflowNote}; + +fn note(workflow_id: &str, recorded_at: u64, text: &str) -> WorkflowNote { + WorkflowNote { + id: mint_id(recorded_at), + workflow_id: workflow_id.to_string(), + kind: NoteKind::Observation, + text: text.to_string(), + recorded_at, + source: NoteSource::System, + run_ids: Vec::new(), + superseded_by: None, + pinned: false, + } +} + +fn dir() -> tempfile::TempDir { + tempfile::tempdir().expect("a temp dir") +} + +#[test] +fn notes_come_back_newest_first() { + let home = dir(); + for (at, text) in [(1, "first"), (2, "second"), (3, "third")] { + append(home.path(), ¬e("sweep", at, text)).expect("append"); + } + + let listed = list(home.path(), "sweep").expect("list"); + + let texts: Vec<&str> = listed.iter().map(|n| n.text.as_str()).collect(); + assert_eq!(texts, ["third", "second", "first"]); +} + +#[test] +fn a_workflow_with_no_journal_has_no_notes_rather_than_an_error() { + let home = dir(); + assert!( + list(home.path(), "never-written") + .expect("a missing journal is the normal state") + .is_empty() + ); +} + +#[test] +fn journals_are_kept_apart_by_workflow() { + let home = dir(); + append(home.path(), ¬e("sweep", 1, "about sweep")).expect("append"); + append(home.path(), ¬e("deploy", 1, "about deploy")).expect("append"); + + assert_eq!(list(home.path(), "sweep").expect("list").len(), 1); + assert_eq!( + list(home.path(), "deploy").expect("list")[0].text, + "about deploy" + ); +} + +#[test] +fn superseding_marks_the_note_without_removing_it() { + let home = dir(); + let first = note("sweep", 1, "the timeout is too short"); + append(home.path(), &first).expect("append"); + let second = note("sweep", 2, "the timeout was never the problem"); + append(home.path(), &second).expect("append"); + + supersede(home.path(), "sweep", &first.id, &second.id).expect("supersede"); + + let listed = list(home.path(), "sweep").expect("list"); + assert_eq!(listed.len(), 2, "history keeps the superseded note"); + let superseded = listed + .iter() + .find(|n| n.id == first.id) + .expect("the superseded note is still listed"); + assert_eq!( + superseded.superseded_by.as_deref(), + Some(second.id.as_str()) + ); + assert!( + !superseded.is_current(), + "a superseded note must stay out of briefs" + ); +} + +#[test] +fn superseding_a_note_that_is_not_there_is_not_a_failure() { + let home = dir(); + append(home.path(), ¬e("sweep", 1, "something")).expect("append"); + + // A caller naming a note that has already been pruned has nothing left to + // fix, and failing here would turn tidying into an error path. + assert!( + !supersede(home.path(), "sweep", "no-such-note", "whatever") + .expect("supersede is forgiving"), + "a missing predecessor was not superseded" + ); +} + +#[test] +fn a_workflow_id_that_is_not_a_filename_is_refused() { + let home = dir(); + let escaping = note("../../etc/passwd", 1, "nope"); + + assert!( + append(home.path(), &escaping).is_err(), + "an id that escapes the journal directory must not be written" + ); + assert!(list(home.path(), "../../etc/passwd").is_err()); +} + +#[test] +fn an_unreadable_journal_reads_as_empty_rather_than_failing() { + let home = dir(); + append(home.path(), ¬e("sweep", 1, "something")).expect("append"); + std::fs::write(home.path().join("sweep.json"), b"{ this is not json").expect("corrupt it"); + + // One bad file must not make the workflow unreadable everywhere its notes + // are shown — the same bargain run history already makes. + assert!( + list(home.path(), "sweep") + .expect("a corrupt journal is not an error") + .is_empty() + ); +} + +#[test] +fn repeated_corruption_preserves_each_quarantined_journal() { + let home = dir(); + for body in [b"{ first corruption".as_slice(), b"{ second corruption"] { + std::fs::write(home.path().join("sweep.json"), body).expect("corrupt it"); + assert!( + list(home.path(), "sweep") + .expect("a corrupt journal is not an error") + .is_empty() + ); + } + + let quarantined: Vec<_> = std::fs::read_dir(home.path()) + .expect("journal directory") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("sweep.json.corrupt.") + }) + .collect(); + assert_eq!(quarantined.len(), 2); +} + +#[test] +fn the_journal_is_capped_and_drops_the_oldest_first() { + let home = dir(); + for at in 0..(MAX_NOTES as u64 + 5) { + append(home.path(), ¬e("sweep", at, &format!("note {at}"))).expect("append"); + } + + let listed = list(home.path(), "sweep").expect("list"); + + assert_eq!(listed.len(), MAX_NOTES); + assert_eq!( + listed.last().expect("a note").text, + "note 5", + "the five oldest went, not the five newest" + ); +} + +#[test] +fn pinned_notes_survive_the_cap() { + let home = dir(); + let mut pinned = note("sweep", 0, "what the operator said"); + pinned.pinned = true; + append(home.path(), &pinned).expect("append"); + for at in 1..(MAX_NOTES as u64 + 20) { + append(home.path(), ¬e("sweep", at, &format!("note {at}"))).expect("append"); + } + + let listed = list(home.path(), "sweep").expect("list"); + + assert_eq!(listed.len(), MAX_NOTES); + assert!( + listed.iter().any(|n| n.id == pinned.id), + "automation writing a hundred observations must not evict a person's note" + ); +} + +#[test] +fn an_old_supersession_chain_is_evicted_together_at_the_cap() { + let home = dir(); + let first = note("sweep", 0, "obsolete"); + let replacement = note("sweep", 1, "current"); + append(home.path(), &first).expect("append predecessor"); + append(home.path(), &replacement).expect("append replacement"); + supersede(home.path(), "sweep", &first.id, &replacement.id).expect("supersede"); + for at in 2..(MAX_NOTES as u64 + 20) { + append(home.path(), ¬e("sweep", at, &format!("note {at}"))).expect("append"); + } + + let listed = list(home.path(), "sweep").expect("list"); + assert_eq!(listed.len(), MAX_NOTES); + assert!(!listed.iter().any(|note| note.id == first.id)); + assert!(!listed.iter().any(|note| note.id == replacement.id)); + assert!(listed.iter().all(|note| { + note.superseded_by + .as_ref() + .is_none_or(|id| listed.iter().any(|replacement| &replacement.id == id)) + })); +} + +#[test] +fn a_recent_replacement_survives_with_its_superseded_predecessor() { + let home = dir(); + for at in 0..MAX_NOTES as u64 { + append(home.path(), ¬e("sweep", at, &format!("note {at}"))).expect("append"); + } + let first = note("sweep", MAX_NOTES as u64, "obsolete"); + let replacement = note("sweep", MAX_NOTES as u64 + 1, "current"); + append(home.path(), &first).expect("append predecessor"); + append(home.path(), &replacement).expect("append replacement"); + supersede(home.path(), "sweep", &first.id, &replacement.id).expect("supersede"); + + let listed = list(home.path(), "sweep").expect("list"); + assert_eq!(listed.len(), MAX_NOTES); + assert!(listed.iter().any(|note| note.id == first.id)); + assert!(listed.iter().any(|note| note.id == replacement.id)); +} + +#[test] +fn concurrent_appenders_do_not_overwrite_each_other() { + let home = dir(); + let journal_dir = std::sync::Arc::new(home.path().to_path_buf()); + let gate = std::sync::Arc::new(std::sync::Barrier::new(16)); + let writers: Vec<_> = (0..16) + .map(|at| { + let journal_dir = journal_dir.clone(); + let gate = gate.clone(); + std::thread::spawn(move || { + gate.wait(); + append(&journal_dir, ¬e("sweep", at, &format!("note {at}"))).expect("append"); + }) + }) + .collect(); + for writer in writers { + writer.join().expect("writer completed"); + } + + assert_eq!(list(&journal_dir, "sweep").expect("list").len(), 16); +} + +#[test] +fn ids_minted_in_the_same_millisecond_still_sort_in_order() { + // A pass writes several notes at once; without the counter their order + // would fall through to a random token and the listing would be arbitrary. + let ids: Vec = (0..8).map(|_| mint_id(1_700_000_000_000)).collect(); + let mut sorted = ids.clone(); + sorted.sort(); + + assert_eq!(ids, sorted); +} + +#[test] +fn a_note_round_trips_every_field_through_disk() { + let home = dir(); + let written = WorkflowNote { + id: mint_id(7), + workflow_id: "sweep".into(), + kind: NoteKind::Constraint, + text: "the deploy step must never run before tests".into(), + recorded_at: 7, + source: NoteSource::Agent { + model: Some("claude-opus-5".into()), + }, + run_ids: vec!["run-1".into(), "run-2".into()], + superseded_by: None, + pinned: true, + }; + append(home.path(), &written).expect("append"); + + let read_back = list(home.path(), "sweep").expect("list").remove(0); + + assert_eq!(read_back, written); +} + +/// The journal directory is created on demand, like every other store path. +#[test] +fn appending_creates_the_directory() { + let home = dir(); + let nested = home.path().join("state").join("workflows").join("journal"); + assert!(!Path::new(&nested).exists()); + + append(&nested, ¬e("sweep", 1, "first")).expect("append"); + + assert_eq!(list(&nested, "sweep").expect("list").len(), 1); +} diff --git a/src/store/file/mod.rs b/src/store/file/mod.rs new file mode 100644 index 00000000..ba6c8302 --- /dev/null +++ b/src/store/file/mod.rs @@ -0,0 +1,732 @@ +//! JSON workflow documents under the host's data directory, one graph per file. +//! +//! The directory layering, the forgiving read, and the atomic write all match +//! how agent templates are already kept ([`crate::agents`]) — an operator who +//! has learned one has learned the other. The format is JSON rather than the +//! TOML used for templates because a node's `config` is free-form JSON that the +//! engine hands to jq expressions; round-tripping it through TOML would change +//! what the author wrote. +//! +//! Reading never fails as a whole. A missing directory is the normal state, and +//! a malformed document costs only itself — an operator hand-editing a catalog +//! should lose the file they broke, not the nine that are fine. What went wrong +//! travels back in [`LoadReport::errors`]. +//! +//! The work is split by responsibility: [`dirs`] decides where to look, +//! [`document`] turns bytes into a record and back, [`paths`] guards the +//! identifier-to-filename boundary, and [`revisions`] keeps the superseded +//! copies that make an edit undoable. This module is the store itself. + +mod dirs; +mod document; +mod journal; +mod paths; +mod proposals; +mod revisions; + +pub use dirs::workflow_dirs; +pub use document::{ + EnginePolicy, HostPolicy, gate_failures_into_error, new_run_record, parse_workflow, + parse_workflow_with, read_workflow, read_workflow_with, validate_graph, +}; +pub use journal::{MAX_NOTES, mint_id as mint_note_id}; +pub use proposals::mint_id as mint_proposal_id; +pub use revisions::MAX_REVISIONS; + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use fs2::FileExt; +use sha2::{Digest, Sha256}; + +use crate::store::types::{ + RunRecord, WorkflowError, WorkflowNote, WorkflowProposal, WorkflowRecord, WorkflowRevision, + WorkflowSummary, +}; + +use dirs::catalog_identity; +pub(super) use dirs::definition_state_dir; +use document::to_document; +pub use paths::write_atomic; +use paths::{is_json, stage_atomic}; +// Re-exported within the crate rather than merely imported: the identifier +// guard is the one piece of this module worth asserting on from outside it. +pub use paths::safe_component; + +use super::{ProposalDecisionGuard, WorkflowStore}; + +/// The host-state directory this environment and working directory resolve to. +/// +/// Everything a host records *about* workflows rather than as part of them — +/// runs, journal notes, proposals, host transcripts — hangs off this one path, +/// scoped to the workspace so two checkouts of the same repository do not read +/// each other's history. Exposed rather than left inside +/// [`FileWorkflowStore::discover`] because a host keeping its own per-workflow +/// state (a transcript store, say) has to land it in the same place, and a +/// second copy of this derivation is a second thing to keep in step. +/// +/// `home` is the host's own persistent-data root, passed in rather than derived +/// here: a host that resolved it once at startup — honouring a `--home` flag, or +/// a test fixture's scratch directory — must not have it re-derived from the +/// process environment behind its back. +pub fn workspace_state_dir_under(home: &Path, cwd: &Path) -> PathBuf { + scoped_state_dir(&home.join("state").join("workflows"), cwd) +} + +/// `state_dir` narrowed to one workspace, by a digest of its canonical path. +fn scoped_state_dir(state_dir: &Path, workspace: &Path) -> PathBuf { + state_dir.join("scopes").join(workspace_scope(workspace)) +} + +/// The directory-name digest that identifies one workspace. +/// +/// Canonical rather than literal so `.` and a symlinked checkout resolve to the +/// same scope; truncated to sixteen hex characters because this is a directory +/// name a person occasionally has to read, and collision here would need a +/// deliberate preimage attack on a path nobody else chooses. +/// +/// Shared in-crate because the generated skills are scoped the same way and by +/// the same rule — a second copy of this derivation is a second thing that can +/// drift. +pub fn workspace_scope(workspace: &Path) -> String { + let identity = std::fs::canonicalize(workspace).unwrap_or_else(|_| absolute_path(workspace)); + let digest = Sha256::digest(identity.to_string_lossy().as_bytes()); + format!("{digest:x}")[..16].to_string() +} + +/// A file-backed proposal decision claim released when dropped. +struct FileProposalDecisionGuard { + file: std::fs::File, + path: PathBuf, +} + +impl ProposalDecisionGuard for FileProposalDecisionGuard {} + +impl Drop for FileProposalDecisionGuard { + fn drop(&mut self) { + if let Err(source) = FileExt::unlock(&self.file) { + tracing::warn!(path = %self.path.display(), "failed to release proposal decision lock: {source}"); + } + } +} + +/// What one read of the workflow directories found. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LoadReport { + /// Workflows in load order, later directories having replaced earlier ones + /// of the same id. + pub workflows: Vec, + /// Directories that existed and were read, in precedence order. + pub dirs: Vec, + /// One message per document that could not be read, parsed, or validated. + pub errors: Vec, +} + +/// A workflow store backed by JSON files in the layered workflow directories. +#[derive(Debug, Clone)] +pub struct FileWorkflowStore { + /// Definition directories, lowest precedence first. + dirs: Vec, + /// Where run records are written. Runs are host state, not an authored + /// artifact, so they live under the state directory rather than beside the + /// definitions an operator edits. + runs_dir: PathBuf, + /// Where per-workflow notes are written. + /// + /// Beside the runs rather than beside the definitions for the same reason: + /// a journal is what this host observed while running the workflow, not + /// part of the document an operator edits and commits. + journal_dir: PathBuf, + /// Where proposed graph changes are written, awaiting an operator. + proposals_dir: PathBuf, + /// Where superseded workflow definitions are kept for undo. + revisions_dir: PathBuf, + /// Where cross-process definition locks live. + /// + /// Locks are runtime coordination, so they must not appear among authored + /// files an operator may sync between machines. + definition_locks_dir: PathBuf, + /// Stable identity for in-process decisions and evolution claims. + /// + /// Derived from the persistent proposal directory rather than this + /// object's address because daemon tasks construct independent store + /// instances over the same on-disk state. + decision_scope: String, + /// Serializes `save`/`delete` against each other on *this store instance*. + /// + /// Both are read-modify-write: read what a save would supersede or what a + /// delete would remove, capture that as a revision, then write. Two + /// concurrent writers for the same id — a copilot autosave racing a manual + /// TUI edit, both holding a `clone()` of this store — could otherwise + /// interleave those steps and either lose one edit's revision snapshot or + /// have one silently overwrite the other's write with a stale read. `Arc` + /// so every clone of this store shares the one lock rather than each + /// getting its own and serializing nothing. + /// + /// Separate store instances and processes additionally synchronize through + /// the per-workflow file lock acquired by every definition writer. + write_lock: Arc>, + /// The host rules this store judges documents and edits by. + /// + /// The engine's own unless a host sets one with + /// [`FileWorkflowStore::with_policy`]: the engine cannot judge a harness + /// name it has never heard of, but the host that owns the vocabulary can, + /// and a bad one must fail at load rather than at dispatch. + policy: Arc, +} + +impl FileWorkflowStore { + /// A store over explicit directories. Mostly for tests; production callers + /// want [`FileWorkflowStore::discover`]. + pub fn new(dirs: Vec, runs_dir: PathBuf) -> Self { + // The journal is derived from the runs directory rather than taken as a + // parameter, so every existing caller of this constructor keeps working + // and still gets a working journal. `with_state` is the explicit form. + let journal_dir = runs_dir + .parent() + .map(|state| state.join("journal")) + .unwrap_or_else(|| PathBuf::from("journal")); + let proposals_dir = runs_dir + .parent() + .map(|state| state.join("proposals")) + .unwrap_or_else(|| PathBuf::from("proposals")); + let definition_root = catalog_identity(&dirs) + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("state/workflows"); + let definition_state = definition_state_dir(&definition_root, &dirs); + let decision_scope = file_store_scope(&proposals_dir); + Self { + dirs, + runs_dir, + journal_dir, + proposals_dir, + revisions_dir: definition_state.join("revisions"), + definition_locks_dir: definition_state.join("locks"), + decision_scope, + write_lock: Arc::new(Mutex::new(())), + policy: Arc::new(EnginePolicy), + } + } + + /// A store whose host state lives under one directory. + /// + /// The explicit form of [`FileWorkflowStore::new`], for callers that know + /// where state belongs rather than only where runs go. + pub fn with_state(dirs: Vec, state_dir: &Path) -> Self { + Self::with_state_roots(dirs, state_dir, state_dir) + } + + /// Build with independently selected run and shared definition state roots. + fn with_state_roots(dirs: Vec, run_state: &Path, definition_state: &Path) -> Self { + let proposals_dir = run_state.join("proposals"); + let definition_state = definition_state_dir(definition_state, &dirs); + Self { + dirs, + runs_dir: run_state.join("runs"), + journal_dir: run_state.join("journal"), + revisions_dir: definition_state.join("revisions"), + definition_locks_dir: definition_state.join("locks"), + decision_scope: file_store_scope(&proposals_dir), + proposals_dir, + write_lock: Arc::new(Mutex::new(())), + policy: Arc::new(EnginePolicy), + } + } + + /// The same store, judging every document and every edit by `policy`. + /// + /// Applied to reads as well as writes, so a document that became invalid + /// because the host's harness vocabulary changed under it is reported the + /// next time it is read rather than silently running somewhere else. + #[must_use] + pub fn with_policy(mut self, policy: Arc) -> Self { + self.policy = policy; + self + } + + /// A store whose host state is isolated to one workspace. + pub fn with_workspace_state(dirs: Vec, state_dir: &Path, workspace: &Path) -> Self { + Self::with_state_roots(dirs, &scoped_state_dir(state_dir, workspace), state_dir) + } + + /// A store over the conventional locations beneath `home`, for the working + /// directory `cwd`. + /// + /// `project_dir` is the per-checkout directory a host keeps its own data in + /// (e.g. `.myapp`), whose `workflows/` subdirectory supplies repository- + /// provided defaults. + pub fn discover(home: &Path, cwd: &Path, project_dir: &str) -> Self { + let state_dir = home.join("state").join("workflows"); + Self::with_workspace_state(workflow_dirs(home, cwd, project_dir), &state_dir, cwd) + } + + /// The definition directories, lowest precedence first. + pub fn dirs(&self) -> &[PathBuf] { + &self.dirs + } + + /// The directory new definitions are written to: the highest-precedence one, + /// which production discovery resolves to `/workflows`. + /// + /// Project-local workflows remain a readable lower-precedence layer, but + /// generated user data belongs beside the host's own config and state + /// rather than appearing as an untracked repository artifact. + pub fn write_dir(&self) -> &Path { + self.dirs + .last() + .map(PathBuf::as_path) + .unwrap_or_else(|| Path::new(".")) + } + + /// Read every `*.json` in every directory, later directories overriding + /// earlier ones by workflow id. + /// + /// Files within one directory are read in sorted order so the catalog is + /// stable across platforms. Never fails: a missing directory yields nothing + /// and a bad document yields an entry in [`LoadReport::errors`]. + pub fn load(&self) -> LoadReport { + let mut report = LoadReport::default(); + for dir in &self.dirs { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + // Not existing is the normal state, not a failure worth reporting. + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(err) => { + report.errors.push(format!("{}: {err}", dir.display())); + continue; + } + }; + report.dirs.push(dir.clone()); + + let mut paths: Vec = entries + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| is_json(path)) + .collect(); + paths.sort(); + + for path in paths { + match read_workflow_with(&path, self.policy.as_ref()) { + Ok(record) => upsert(&mut report.workflows, record), + Err(err) => report.errors.push(err), + } + } + } + report + } + + /// The path a workflow with `id` is written to. + fn definition_path(&self, id: &str) -> Result { + Ok(self + .write_dir() + .join(format!("{}.json", safe_component(id)?))) + } + + /// The version a save to `path` is about to supersede, if there is one. + /// + /// Two cases, and the cheap one is the common one. When the write directory + /// already holds this workflow, that file *is* what a reader resolves to — + /// the write directory is the highest-precedence one — so parsing it alone + /// is exactly right and costs one read. + /// + /// Only when it does not is a full load needed: the workflow is coming from + /// a lower-precedence directory and this save will shadow it. Snapshotting + /// the shadowed version is what lets an operator undo a project-local edit + /// back to what their home directory had. + fn superseded_by( + &self, + path: &Path, + id: &str, + ) -> Result, WorkflowError> { + if path.exists() { + // A file that no longer parses is not a version worth keeping, and + // refusing the save over it would strand the operator with a broken + // definition they cannot overwrite. + return Ok(read_workflow_with(path, self.policy.as_ref()).ok()); + } + self.get(id) + } + + /// The path a run record is written to. + fn run_path(&self, run_id: &str) -> Result { + Ok(self + .runs_dir + .join(format!("{}.json", safe_component(run_id)?))) + } + + /// Run one workflow definition mutation while holding its filesystem lock. + fn with_definition_lock( + &self, + workflow_id: &str, + operation: impl FnOnce() -> Result, + ) -> Result { + std::fs::create_dir_all(&self.definition_locks_dir).map_err(|source| { + WorkflowError::Io { + path: self.definition_locks_dir.clone(), + source, + } + })?; + let lock_path = self + .definition_locks_dir + .join(format!(".{}.lock", safe_component(workflow_id)?)); + let file_lock = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .map_err(|source| WorkflowError::Io { + path: lock_path.clone(), + source, + })?; + file_lock + .lock_exclusive() + .map_err(|source| WorkflowError::Io { + path: lock_path.clone(), + source, + })?; + let result = operation(); + if let Err(source) = FileExt::unlock(&file_lock) { + tracing::warn!(path = %lock_path.display(), "failed to release workflow lock: {source}"); + } + result + } + + /// Atomically save a workflow when the selected part of its current record + /// still matches the caller's observation. + fn save_if_current_matches( + &self, + record: &WorkflowRecord, + expected_fingerprint: &str, + fingerprint: impl FnOnce(&WorkflowRecord) -> String, + ) -> Result { + let _guard = self + .write_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + self.with_definition_lock(&record.id, || { + let Some(current) = self.get(&record.id)? else { + return Ok(false); + }; + if fingerprint(¤t) != expected_fingerprint { + return Ok(false); + } + let path = self.definition_path(&record.id)?; + validate_graph(&record.id, &record.graph)?; + let document = to_document(record)?; + let staged = stage_atomic(&path, &document)?; + let revision = revisions::capture(&self.revisions_dir, ¤t)?; + if let Err(error) = staged.commit() { + revisions::rollback_capture(&revision); + return Err(error); + } + revisions::commit_capture(&revision)?; + Ok(true) + }) + } +} + +/// Make a stable best-effort absolute identity when a path does not yet exist. +fn absolute_path(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) +} + +/// A stable process-local key for every store instance over `proposals_dir`. +fn file_store_scope(proposals_dir: &Path) -> String { + format!("file:{}", absolute_path(proposals_dir).to_string_lossy()) +} + +impl WorkflowStore for FileWorkflowStore { + fn policy(&self) -> &dyn HostPolicy { + self.policy.as_ref() + } + + fn proposal_decision_scope(&self) -> String { + self.decision_scope.clone() + } + + fn list(&self) -> Result, WorkflowError> { + Ok(self + .load() + .workflows + .iter() + .map(WorkflowRecord::summary) + .collect()) + } + + fn get(&self, id: &str) -> Result, WorkflowError> { + Ok(self.load().workflows.into_iter().find(|w| w.id == id)) + } + + fn save(&self, record: &WorkflowRecord) -> Result<(), WorkflowError> { + // Held across the whole read-modify-write below — see `write_lock`'s + // doc comment for what a concurrent `save`/`delete` on this store + // would otherwise interleave. + let _guard = self.write_lock.lock().unwrap_or_else(|poison| { + // A prior panic mid-write is exactly the case a lock exists to + // survive: the on-disk state is whatever it was left in, but that + // is what a torn write already risks and `write_atomic`'s rename + // makes recoverable — poisoning must not turn one bad write into + // every future save failing too. + poison.into_inner() + }); + self.with_definition_lock(&record.id, || { + // The id decides a filename, so it is checked before anything else: + // a document's own `id` overrides what the caller asked for, and a + // document may have been written by an agent. + let path = self.definition_path(&record.id)?; + // Validate before writing so a listing can be trusted to be runnable. + validate_graph(&record.id, &record.graph)?; + let document = to_document(record)?; + // Snapshot what is about to be replaced, before replacing it. Doing it + // here rather than at each call site is what makes every authoring + // surface undoable without any of them having to opt in. + if let Some(superseded) = self.superseded_by(&path, &record.id)? { + let staged = stage_atomic(&path, &document)?; + let revision = revisions::capture(&self.revisions_dir, &superseded)?; + if let Err(error) = staged.commit() { + revisions::rollback_capture(&revision); + return Err(error); + } + revisions::commit_capture(&revision)?; + return Ok(()); + } + write_atomic(&path, &document) + }) + } + + fn save_if_fingerprint( + &self, + record: &WorkflowRecord, + expected_fingerprint: &str, + ) -> Result { + self.save_if_current_matches(record, expected_fingerprint, |current| { + crate::store::types::fingerprint(¤t.graph) + }) + } + + fn save_if_record_fingerprint( + &self, + record: &WorkflowRecord, + expected_fingerprint: &str, + ) -> Result { + self.save_if_current_matches( + record, + expected_fingerprint, + crate::store::types::record_fingerprint, + ) + } + + fn delete(&self, id: &str) -> Result<(), WorkflowError> { + // See `save`'s matching guard and `write_lock`'s doc comment: this is + // the same read (`load`/`get`), snapshot, write shape. + let _guard = self + .write_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + self.with_definition_lock(id, || { + let existing = self + .load() + .workflows + .into_iter() + .find(|w| w.id == id) + .ok_or_else(|| WorkflowError::NotFound(id.to_string()))?; + let default_path = self.definition_path(id)?; + let path = existing.source_path.clone().unwrap_or(default_path); + if path.parent() != Some(self.write_dir()) { + return Err(WorkflowError::ReadOnlyDefinition { + id: id.to_string(), + path, + }); + } + // Snapshot before removing. A delete is the one edit that leaves + // nothing to diff against afterwards, so without this it is the one + // edit that cannot be undone. + let revision = revisions::capture(&self.revisions_dir, &existing)?; + if let Err(source) = std::fs::remove_file(&path) { + revisions::rollback_capture(&revision); + return Err(WorkflowError::Io { path, source }); + } + revisions::commit_capture(&revision) + }) + } + + fn record_run(&self, run: &RunRecord) -> Result<(), WorkflowError> { + let path = self.run_path(&run.id)?; + let body = serde_json::to_vec_pretty(run) + .map_err(|err| WorkflowError::Malformed(err.to_string()))?; + write_atomic(&path, &body) + } + + fn get_run(&self, run_id: &str) -> Result, WorkflowError> { + let path = self.run_path(run_id)?; + let body = match std::fs::read(&path) { + Ok(body) => body, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(WorkflowError::Io { path, source }), + }; + serde_json::from_slice(&body) + .map(Some) + .map_err(|err| WorkflowError::Malformed(format!("{}: {err}", path.display()))) + } + + fn list_runs(&self, workflow_id: &str) -> Result, WorkflowError> { + let entries = match std::fs::read_dir(&self.runs_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => { + return Err(WorkflowError::Io { + path: self.runs_dir.clone(), + source, + }); + } + }; + + let mut runs: Vec = entries + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| is_json(path)) + // A run record this host cannot parse is skipped rather than + // failing the listing: history is diagnostic, and one corrupt file + // should not hide the rest of it. + .filter_map(|path| std::fs::read(&path).ok()) + .filter_map(|body| serde_json::from_slice::(&body).ok()) + .filter(|run| run.workflow_id == workflow_id) + .collect(); + runs.sort_by_key(|run| std::cmp::Reverse(run.started_at)); + Ok(runs) + } + + fn list_revisions(&self, workflow_id: &str) -> Result, WorkflowError> { + // Releases before the source/state split kept undo snapshots beside + // definitions. Merge that history with new workspace-scoped snapshots + // so the first post-upgrade edit does not hide the older entries. + revisions::list_merged( + &self.revisions_dir, + &self.write_dir().join(".revisions"), + workflow_id, + ) + } + + fn revision( + &self, + workflow_id: &str, + revision_id: &str, + ) -> Result, WorkflowError> { + match revisions::read(&self.revisions_dir, workflow_id, revision_id)? { + some @ Some(_) => Ok(some), + None => revisions::read( + &self.write_dir().join(".revisions"), + workflow_id, + revision_id, + ), + } + } + + fn list_notes(&self, workflow_id: &str) -> Result, WorkflowError> { + journal::list(&self.journal_dir, workflow_id) + } + + fn append_note(&self, note: &WorkflowNote) -> Result<(), WorkflowError> { + // Under the same lock as `save`/`delete`: appending is a + // read-modify-write of one file, so two passes writing at once would + // otherwise lose whichever note lost the race. + // + // Poison-tolerant for the same reason `save` is, and it matters more + // here: this runs on the failure path, where the caller has documented + // it as best effort. Panicking on a poisoned lock would unwind out of a + // run that already completed. + let _guard = self.write_lock.lock().unwrap_or_else(|p| p.into_inner()); + journal::append(&self.journal_dir, note) + } + + fn supersede_note( + &self, + workflow_id: &str, + note_id: &str, + by: &str, + ) -> Result { + let _guard = self.write_lock.lock().unwrap_or_else(|p| p.into_inner()); + journal::supersede(&self.journal_dir, workflow_id, note_id, by) + } + + fn save_proposal(&self, proposal: &WorkflowProposal) -> Result<(), WorkflowError> { + // Every proposal transition (verification, rejection, acceptance, and + // supersession) funnels through this method. Serialize those writes on + // the shared store lock so clones cannot concurrently replace the same + // proposal document. + let _guard = self.write_lock.lock().unwrap_or_else(|p| p.into_inner()); + proposals::save(&self.proposals_dir, proposal) + } + + fn save_proposal_if_fingerprint( + &self, + proposal: &WorkflowProposal, + expected_fingerprint: &str, + ) -> Result { + let _guard = self + .write_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + self.with_definition_lock(&proposal.workflow_id, || { + let Some(current) = self.get(&proposal.workflow_id)? else { + return Ok(false); + }; + if crate::store::types::fingerprint(¤t.graph) != expected_fingerprint { + return Ok(false); + } + proposals::save(&self.proposals_dir, proposal)?; + Ok(true) + }) + } + + fn get_proposal(&self, id: &str) -> Result, WorkflowError> { + proposals::read(&self.proposals_dir, id) + } + + fn list_proposals(&self, workflow_id: &str) -> Result, WorkflowError> { + proposals::list_for(&self.proposals_dir, workflow_id) + } + + fn lock_proposal_decision( + &self, + workflow_id: &str, + ) -> Result, WorkflowError> { + std::fs::create_dir_all(&self.proposals_dir).map_err(|source| WorkflowError::Io { + path: self.proposals_dir.clone(), + source, + })?; + let path = self.proposals_dir.join(format!( + ".workflow-{}.decision.lock", + safe_component(workflow_id)? + )); + let file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .map_err(|source| WorkflowError::Io { + path: path.clone(), + source, + })?; + file.lock_exclusive().map_err(|source| WorkflowError::Io { + path: path.clone(), + source, + })?; + Ok(Box::new(FileProposalDecisionGuard { file, path })) + } +} + +/// Add `record` to `workflows`, replacing any entry with the same id in place so +/// a project-local override keeps the position of what it overrides. +fn upsert(workflows: &mut Vec, record: WorkflowRecord) { + match workflows.iter_mut().find(|w| w.id == record.id) { + Some(existing) => *existing = record, + None => workflows.push(record), + } +} diff --git a/src/store/file/paths.rs b/src/store/file/paths.rs new file mode 100644 index 00000000..ea54e2c9 --- /dev/null +++ b/src/store/file/paths.rs @@ -0,0 +1,131 @@ +//! Turning identifiers into filenames, and writing files without tearing. +//! +//! Everything here guards the boundary between a name this host was *given* and +//! a path it will *act on*. Workflow ids, run ids, and revision ids all arrive +//! from somewhere less trusted than this process — a document an agent wrote, a +//! task frame from a peer — and all three become filenames. + +use std::path::{Path, PathBuf}; + +use crate::store::types::WorkflowError; + +/// Suffix appended while writing, then renamed over the target. Matches the +/// idiom already used for trust state, so a half-written file is never +/// observable — and never mistaken for a definition, since the resulting +/// extension is not `json`. +const TMP_SUFFIX: &str = ".flows-tmp"; + +/// An identifier's use as a single filename component, or an error. +/// +/// Workflow ids and run ids both become filenames, and both are attacker-shaped +/// input: a workflow document's `id` overrides whatever the caller asked for, a +/// document may be written by an agent, and a run id can arrive on a task frame +/// from a peer. Without this, an id of `../../authorized_keys` would let a save +/// write outside the workflow directory with the daemon's privileges. +/// +/// The rule is deliberately strict rather than sanitizing: an id that is not +/// already a safe component is rejected, not silently rewritten into a +/// different one. Rewriting would let two distinct ids collapse onto one file. +pub fn safe_component(id: &str) -> Result<&str, WorkflowError> { + if id.trim().is_empty() { + return Err(WorkflowError::Malformed( + "identifier must not be empty".to_string(), + )); + } + // Rejected rather than rewritten: silently trimming would let "sweep " and + // "sweep" collapse onto the same `sweep.json`, and the stored record would + // still carry the untrimmed id — no longer matching the filename it + // resolves to. + if id.trim() != id { + return Err(WorkflowError::Malformed(format!( + "identifier '{id}' must not have leading or trailing whitespace" + ))); + } + let trimmed = id; + if trimmed == "." || trimmed == ".." { + return Err(WorkflowError::Malformed(format!( + "identifier '{trimmed}' is not a usable filename" + ))); + } + // Both separators, on every platform: a document written on one machine is + // read on another, and `a\..\b` must not become traversal on Windows just + // because it was authored on unix. + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') { + return Err(WorkflowError::Malformed(format!( + "identifier '{trimmed}' must not contain a path separator" + ))); + } + // Catches drive-relative and other platform spellings the checks above miss + // by asking the platform itself whether this is one plain component. + let path = Path::new(trimmed); + let mut components = path.components(); + match (components.next(), components.next()) { + (Some(std::path::Component::Normal(_)), None) => Ok(trimmed), + _ => Err(WorkflowError::Malformed(format!( + "identifier '{trimmed}' must be a single path component" + ))), + } +} + +/// Whether a path is a file this store reads. +pub fn is_json(path: &Path) -> bool { + path.is_file() + && path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("json")) + .unwrap_or(false) +} + +/// Write `body` to `path` through a temporary file in the same directory, so a +/// reader never observes a half-written document. +pub fn write_atomic(path: &Path, body: &[u8]) -> Result<(), WorkflowError> { + stage_atomic(path, body)?.commit() +} + +/// A complete temporary write waiting to be renamed over its destination. +pub struct StagedWrite { + tmp: PathBuf, + path: PathBuf, +} + +impl StagedWrite { + /// Publish the staged bytes atomically. + pub fn commit(self) -> Result<(), WorkflowError> { + std::fs::rename(&self.tmp, &self.path).map_err(|source| WorkflowError::Io { + path: self.path.clone(), + source, + }) + } +} + +impl Drop for StagedWrite { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.tmp); + } +} + +/// Write all bytes into the target directory without replacing the target yet. +pub fn stage_atomic(path: &Path, body: &[u8]) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|source| WorkflowError::Io { + path: parent.to_path_buf(), + source, + })?; + } + // Appended rather than substituted for the extension, so an id containing a + // dot cannot collide with a different workflow's temporary file — and + // carrying a unique token, so two writers racing on the *same* id cannot + // scribble over each other's scratch file before either rename lands. + let mut tmp_name = path.as_os_str().to_os_string(); + tmp_name.push(format!("{TMP_SUFFIX}.{}", crate::ids::token())); + let tmp = PathBuf::from(tmp_name); + std::fs::write(&tmp, body).map_err(|source| WorkflowError::Io { + path: tmp.clone(), + source, + })?; + Ok(StagedWrite { + tmp, + path: path.to_path_buf(), + }) +} diff --git a/src/store/file/proposals/mod.rs b/src/store/file/proposals/mod.rs new file mode 100644 index 00000000..2134c381 --- /dev/null +++ b/src/store/file/proposals/mod.rs @@ -0,0 +1,103 @@ +//! Pending graph changes on disk. +//! +//! One file per proposal, under the state directory beside runs and the +//! journal. Unlike notes, a proposal is read individually as often as in a set +//! — an operator accepts *this* one — so a file each keeps a decision from +//! rewriting every other proposal's record. +//! +//! Listing scans the directory, the same shape run history has. That is +//! acceptable here in a way it is not there: an evolution pass supersedes its +//! own undecided proposal rather than adding to a pile, so the directory stays +//! small by construction. + +use std::path::{Path, PathBuf}; + +use crate::store::types::{ProposalId, WorkflowError, WorkflowProposal}; + +use super::paths::{is_json, safe_component, write_atomic}; + +/// Tie-breaker for proposals minted inside the same millisecond. +static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Mint a proposal id that sorts chronologically. +pub fn mint_id(created_at: u64) -> ProposalId { + format!( + "{created_at:013}-{:012}-{}", + SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + crate::ids::token() + ) +} + +/// Where one proposal lives. +fn path_for(proposals_dir: &Path, id: &str) -> Result { + Ok(proposals_dir.join(format!("{}.json", safe_component(id)?))) +} + +/// Write a proposal, replacing any earlier state for the same id. +/// +/// Every state change — verified, accepted, rejected, made stale — goes through +/// here, so a proposal's file is always its current state rather than a log to +/// replay. +pub fn save(proposals_dir: &Path, proposal: &WorkflowProposal) -> Result<(), WorkflowError> { + let body = serde_json::to_vec_pretty(proposal) + .map_err(|err| WorkflowError::Malformed(err.to_string()))?; + write_atomic(&path_for(proposals_dir, &proposal.id)?, &body) +} + +/// One proposal by id, or `None` when there is no such file. +pub fn read(proposals_dir: &Path, id: &str) -> Result, WorkflowError> { + let path = path_for(proposals_dir, id)?; + let body = match std::fs::read(&path) { + Ok(body) => body, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(WorkflowError::Io { path, source }), + }; + serde_json::from_slice(&body) + .map(Some) + .map_err(|err| WorkflowError::Malformed(format!("{}: {err}", path.display()))) +} + +/// Every proposal for one workflow, newest first. +/// +/// A file this host cannot parse is skipped rather than failing the listing, so +/// one bad proposal does not hide the rest — the same bargain run history and +/// the journal already make. +pub fn list_for( + proposals_dir: &Path, + workflow_id: &str, +) -> Result, WorkflowError> { + let entries = match std::fs::read_dir(proposals_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => { + return Err(WorkflowError::Io { + path: proposals_dir.to_path_buf(), + source, + }); + } + }; + let mut proposals: Vec = entries + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| is_json(path)) + .filter_map(|path| match std::fs::read(&path) { + Ok(body) => match serde_json::from_slice::(&body) { + Ok(proposal) => Some(proposal), + Err(err) => { + tracing::warn!(path = %path.display(), "skipping unreadable proposal: {err}"); + None + } + }, + Err(err) => { + tracing::warn!(path = %path.display(), "skipping unreadable proposal: {err}"); + None + } + }) + .filter(|proposal| proposal.workflow_id == workflow_id) + .collect(); + // Ids lead with a zero-padded stamp, so this is chronological. + proposals.sort_by(|a, b| b.id.cmp(&a.id)); + Ok(proposals) +} + +#[cfg(test)] +mod tests; diff --git a/src/store/file/proposals/tests.rs b/src/store/file/proposals/tests.rs new file mode 100644 index 00000000..8b4c17aa --- /dev/null +++ b/src/store/file/proposals/tests.rs @@ -0,0 +1,198 @@ +//! Tests for proposals on disk. +//! +//! A proposal is the one artifact in this feature that can change a saved +//! graph, so what is asserted here is mostly about *not* doing that by +//! accident: a proposal that is not applicable, a listing that cannot leak +//! another workflow's proposals, a file that survives a decision. + +use super::*; +use crate::store::types::{ProposalStatus, ProposalVerification}; +use serde_json::json; + +fn proposal(workflow_id: &str, created_at: u64) -> WorkflowProposal { + WorkflowProposal { + id: mint_id(created_at), + workflow_id: workflow_id.to_string(), + created_at, + rationale: "the timeout is too short for a cold cache".into(), + ops: json!([{ "op": "update_node_config", "id": "build", "config": { "timeout": 600 } }]), + evidence_runs: vec!["run-1".into()], + note_ids: Vec::new(), + base_fingerprint: "abc123".into(), + verification: None, + status: ProposalStatus::Pending, + decided_at: None, + decision_reason: None, + } +} + +fn dir() -> tempfile::TempDir { + tempfile::tempdir().expect("a temp dir") +} + +#[test] +fn a_proposal_round_trips_through_disk() { + let home = dir(); + let written = proposal("sweep", 1); + save(home.path(), &written).expect("save"); + + let read_back = read(home.path(), &written.id) + .expect("read") + .expect("the proposal is there"); + + assert_eq!(read_back, written); +} + +#[test] +fn an_unknown_proposal_is_none_rather_than_an_error() { + let home = dir(); + assert!( + read(home.path(), "no-such-proposal") + .expect("read") + .is_none() + ); +} + +#[test] +fn listing_is_scoped_to_one_workflow() { + let home = dir(); + save(home.path(), &proposal("sweep", 1)).expect("save"); + save(home.path(), &proposal("sweep", 2)).expect("save"); + save(home.path(), &proposal("deploy", 3)).expect("save"); + + assert_eq!(list_for(home.path(), "sweep").expect("list").len(), 2); + assert_eq!(list_for(home.path(), "deploy").expect("list").len(), 1); + assert!(list_for(home.path(), "unrelated").expect("list").is_empty()); +} + +#[test] +fn proposals_come_back_newest_first() { + let home = dir(); + let first = proposal("sweep", 1); + let second = proposal("sweep", 2); + save(home.path(), &first).expect("save"); + save(home.path(), &second).expect("save"); + + let listed = list_for(home.path(), "sweep").expect("list"); + + assert_eq!(listed[0].id, second.id); + assert_eq!(listed[1].id, first.id); +} + +#[test] +fn a_decision_replaces_the_file_rather_than_adding_one() { + let home = dir(); + let mut written = proposal("sweep", 1); + save(home.path(), &written).expect("save"); + + written.status = ProposalStatus::Rejected; + written.decided_at = Some(9); + written.decision_reason = Some("the cache is the real problem".into()); + save(home.path(), &written).expect("save the decision"); + + let listed = list_for(home.path(), "sweep").expect("list"); + assert_eq!( + listed.len(), + 1, + "a decision is a state change, not a new row" + ); + assert_eq!(listed[0].status, ProposalStatus::Rejected); + assert_eq!( + listed[0].decision_reason.as_deref(), + Some("the cache is the real problem") + ); +} + +#[test] +fn only_a_verified_pending_proposal_is_applicable() { + let mut subject = proposal("sweep", 1); + assert!( + !subject.is_applicable(), + "an unverified proposal must not be offered" + ); + + subject.verification = Some(ProposalVerification { + ok: false, + verified_at: 2, + messages: vec!["node 'build' does not exist".into()], + diagnosis: None, + }); + assert!( + !subject.is_applicable(), + "a proposal that failed its check is evidence, not an offer" + ); + + subject.verification = Some(ProposalVerification { + ok: true, + verified_at: 3, + messages: Vec::new(), + diagnosis: None, + }); + assert!(subject.is_applicable()); + + subject.status = ProposalStatus::Accepted; + assert!( + !subject.is_applicable(), + "a proposal cannot be applied twice" + ); +} + +#[test] +fn an_unreadable_proposal_is_skipped_rather_than_failing_the_listing() { + let home = dir(); + save(home.path(), &proposal("sweep", 1)).expect("save"); + std::fs::write(home.path().join("broken.json"), b"not json at all").expect("write junk"); + + let listed = list_for(home.path(), "sweep").expect("one bad file is not a failure"); + + assert_eq!(listed.len(), 1); +} + +#[test] +fn a_proposal_id_that_is_not_a_filename_is_refused() { + let home = dir(); + let mut escaping = proposal("sweep", 1); + escaping.id = "../../escape".into(); + + assert!(save(home.path(), &escaping).is_err()); + assert!(read(home.path(), "../../escape").is_err()); +} + +#[test] +fn ops_survive_as_the_json_they_arrived_as() { + // The reason `ops` is a raw `Value`: a stored proposal has to stay readable + // even if the engine's op enum changes shape under it. + let home = dir(); + let mut written = proposal("sweep", 1); + written.ops = json!([{ "op": "some_future_op", "wholly": { "unknown": ["shape"] } }]); + save(home.path(), &written).expect("save"); + + let read_back = read(home.path(), &written.id) + .expect("read") + .expect("the proposal is there"); + + assert_eq!(read_back.ops, written.ops); +} + +#[test] +fn fingerprints_distinguish_graphs_and_agree_with_themselves() { + use crate::store::types::fingerprint; + + let graph: crate::model::WorkflowGraph = serde_json::from_value(json!({ + "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], + "edges": [] + })) + .expect("the fixture graph should parse"); + let changed: crate::model::WorkflowGraph = serde_json::from_value(json!({ + "nodes": [{ "id": "t", "kind": "trigger", "name": "start again" }], + "edges": [] + })) + .expect("the fixture graph should parse"); + + assert_eq!(fingerprint(&graph), fingerprint(&graph)); + assert_ne!( + fingerprint(&graph), + fingerprint(&changed), + "a graph that moved must not look unchanged to an accept" + ); +} diff --git a/src/store/file/revisions.rs b/src/store/file/revisions.rs new file mode 100644 index 00000000..52773e6a --- /dev/null +++ b/src/store/file/revisions.rs @@ -0,0 +1,237 @@ +//! Superseded copies of a workflow, so an edit can be taken back. +//! +//! The copilot writes to the store directly — that is the design, and it is why +//! every authoring surface shares one path — but it left an operator with no way +//! to disagree with an edit after the fact. A harness turn that misread the +//! instruction rewrote the graph and the previous one was gone. +//! +//! So [`capture`] runs inside [`super::FileWorkflowStore::save`] and +//! [`super::FileWorkflowStore::delete`], snapshotting what is about to be +//! replaced. Being in the store rather than at the call sites is the point: +//! the copilot, the host's `workflow` subcommand, and the MCP tools all become +//! undoable without any of them knowing revisions exist. +//! +//! Snapshots live in the workflow state directory. They are host history rather +//! than authored source, so syncing the definitions never pulls along undo +//! state. They are capped at [`MAX_REVISIONS`] per workflow: history is for +//! taking back a mistake that was just made, not an archive. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::store::types::{WorkflowError, WorkflowRecord, WorkflowRevision}; + +use super::paths::{is_json, safe_component, write_atomic}; + +/// How many superseded copies of one workflow are kept. +/// +/// Matches a sibling host's cap. Past this, an operator is not +/// undoing an edit they just watched happen — they want the version control the +/// project is already in. +pub const MAX_REVISIONS: usize = 20; + +/// Tie-breaker for snapshots taken inside the same millisecond. +/// +/// Process-wide rather than per-workflow: it only has to be increasing, and one +/// counter cannot be raced into reuse the way a per-directory count read off the +/// filesystem could. +static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// A snapshot as it is stored: the whole record, plus when it stopped being +/// current. +/// +/// The record is embedded rather than flattened so a revision keeps loading if +/// the record shape gains a field. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredRevision { + /// Epoch-millisecond stamp of when this copy was *superseded*. + superseded_at: u64, + /// The workflow as it was. + record: WorkflowRecord, +} + +/// Where one workflow's snapshots live. +fn dir_for(revisions_dir: &Path, workflow_id: &str) -> Result { + Ok(revisions_dir.join(safe_component(workflow_id)?)) +} + +/// Snapshot `record` as a superseded version, then prune to [`MAX_REVISIONS`]. +/// +/// # Errors +/// +/// Fails when the id is not a usable filename, or when the snapshot cannot be +/// written — the caller is about to overwrite the only copy, so a history it +/// could not record is a failure rather than something to log past. +pub fn capture(write_dir: &Path, record: &WorkflowRecord) -> Result { + let dir = dir_for(write_dir, &record.id)?; + let superseded_at = now_ms(); + // Three parts, and each earns its place. The zero-padded stamp leads so a + // lexical sort is a chronological one. A monotonic counter follows it + // because several saves land inside one millisecond — a burst of copilot + // edits does — and without it the sort would fall through to the random + // token, making both the listing order and *which* revision the cap drops + // arbitrary. The random token is last, and only for uniqueness: two + // processes can pick the same counter. + let revision_id = format!( + "{superseded_at:013}-{:012}-{}", + SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + crate::ids::token() + ); + let stored = StoredRevision { + superseded_at, + record: WorkflowRecord { + // The path a record was read from is where it lived, not part of + // what it was; carrying it into a snapshot would make a rollback + // claim to have come from a file that holds something else. + source_path: None, + ..record.clone() + }, + }; + let body = serde_json::to_vec_pretty(&stored) + .map_err(|err| WorkflowError::Malformed(err.to_string()))?; + let path = dir.join(format!("{revision_id}.json")); + write_atomic(&path, &body)?; + Ok(path) +} + +/// Commit a captured snapshot after its matching source mutation succeeds. +/// +/// Pruning is housekeeping, not part of the commit: the definition write or +/// delete this follows has already landed, so a pruning failure (for example +/// `read_dir` failing on the revisions directory) must not turn an already- +/// successful save into a reported failure — a caller that saw `Err` here +/// would retry the whole mutation and record a second, spurious revision. +pub fn commit_capture(path: &Path) -> Result<(), WorkflowError> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + if let Err(error) = prune(dir) { + tracing::warn!(path = %dir.display(), %error, "failed to prune workflow revisions"); + } + Ok(()) +} + +/// Remove a snapshot whose matching source mutation failed. +pub fn rollback_capture(path: &Path) { + let _ = std::fs::remove_file(path); +} + +/// Every snapshot of `workflow_id`, newest first. +/// +/// A snapshot this host cannot parse is skipped rather than failing the listing, +/// matching how run history already behaves: one corrupt file should not hide +/// the rest of it. +pub fn list(write_dir: &Path, workflow_id: &str) -> Result, WorkflowError> { + let dir = dir_for(write_dir, workflow_id)?; + let mut revisions: Vec = snapshot_paths(&dir)? + .into_iter() + .filter_map(|path| load(&path).ok().flatten()) + .collect(); + revisions.sort_by(|a, b| b.id.cmp(&a.id)); + Ok(revisions) +} + +/// Merge snapshots from the current state directory and a legacy directory. +/// +/// Revision identifiers are globally unique in normal operation. Deduplicating +/// them also makes a partially migrated history harmless. +pub(super) fn list_merged( + current_dir: &Path, + legacy_dir: &Path, + workflow_id: &str, +) -> Result, WorkflowError> { + let mut revisions = list(current_dir, workflow_id)?; + revisions.extend(list(legacy_dir, workflow_id)?); + revisions.sort_by(|a, b| b.id.cmp(&a.id)); + revisions.dedup_by(|a, b| a.id == b.id); + revisions.truncate(MAX_REVISIONS); + Ok(revisions) +} + +/// One snapshot by id, scoped to its workflow. +/// +/// Scoped deliberately: a revision id is enough to name a file, and letting one +/// workflow's rollback reach another's history would be a way to write a graph +/// an operator never had. +pub fn read( + write_dir: &Path, + workflow_id: &str, + revision_id: &str, +) -> Result, WorkflowError> { + let path = + dir_for(write_dir, workflow_id)?.join(format!("{}.json", safe_component(revision_id)?)); + load(&path) +} + +/// Read one snapshot file, treating absence as `None`. +fn load(path: &Path) -> Result, WorkflowError> { + let body = match std::fs::read(path) { + Ok(body) => body, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(WorkflowError::Io { + path: path.to_path_buf(), + source, + }); + } + }; + let stored: StoredRevision = serde_json::from_slice(&body) + .map_err(|err| WorkflowError::Malformed(format!("{}: {err}", path.display())))?; + let id = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default() + .to_string(); + Ok(Some(WorkflowRevision { + id, + superseded_at: stored.superseded_at, + record: stored.record, + })) +} + +/// Every snapshot file in `dir`, sorted oldest first. A missing directory is the +/// normal state for a workflow that has never been edited. +fn snapshot_paths(dir: &Path) -> Result, WorkflowError> { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => { + return Err(WorkflowError::Io { + path: dir.to_path_buf(), + source, + }); + } + }; + let mut paths: Vec = entries + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| is_json(path)) + .collect(); + // Filenames lead with a zero-padded stamp, so this is chronological. + paths.sort(); + Ok(paths) +} + +/// Drop the oldest snapshots past [`MAX_REVISIONS`]. +/// +/// A snapshot that cannot be removed is not an error: the cap is housekeeping, +/// and failing the *save* that triggered it would be a worse outcome than one +/// extra file on disk. +fn prune(dir: &Path) -> Result<(), WorkflowError> { + let paths = snapshot_paths(dir)?; + let excess = paths.len().saturating_sub(MAX_REVISIONS); + for path in paths.into_iter().take(excess) { + let _ = std::fs::remove_file(path); + } + Ok(()) +} + +/// Epoch milliseconds, saturating at zero if the clock is before the epoch. +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default() +} + +#[cfg(test)] +#[path = "revisions_tests.rs"] +mod tests; diff --git a/src/store/file/revisions_tests.rs b/src/store/file/revisions_tests.rs new file mode 100644 index 00000000..f75b60c4 --- /dev/null +++ b/src/store/file/revisions_tests.rs @@ -0,0 +1,156 @@ +//! Tests for snapshot capture, ordering, pruning, and scoping. + +use serde_json::json; + +use super::*; +use crate::store::types::WorkflowRecord; + +/// A record whose graph validates, named so successive versions are tellable +/// apart by their description. +fn record(id: &str, description: &str) -> WorkflowRecord { + WorkflowRecord { + id: id.to_string(), + name: "Greet".into(), + description: description.to_string(), + enabled: true, + defaults: Default::default(), + graph: serde_json::from_value(json!({ + "name": "Greet", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "start", + "config": { "trigger_kind": "manual" } }, + ], + "edges": [], + })) + .expect("graph parses"), + source_path: None, + } +} + +#[test] +fn a_captured_snapshot_can_be_listed_and_read_back() { + let root = tempfile::tempdir().expect("tempdir"); + + capture(root.path(), &record("greet", "first")).expect("capture"); + + let listed = list(root.path(), "greet").expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].record.description, "first"); + assert!(listed[0].superseded_at > 0); + + let one = read(root.path(), "greet", &listed[0].id) + .expect("read") + .expect("present"); + assert_eq!(one.record.description, "first"); +} + +#[test] +fn snapshots_are_listed_newest_first() { + let root = tempfile::tempdir().expect("tempdir"); + + for description in ["first", "second", "third"] { + capture(root.path(), &record("greet", description)).expect("capture"); + } + + let listed = list(root.path(), "greet").expect("list"); + let descriptions: Vec<&str> = listed + .iter() + .map(|r| r.record.description.as_str()) + .collect(); + assert_eq!(descriptions, vec!["third", "second", "first"]); +} + +#[test] +fn two_snapshots_taken_in_the_same_millisecond_are_both_kept() { + let root = tempfile::tempdir().expect("tempdir"); + + // No sleep between them on purpose: the stamp alone is not unique enough to + // name a file, so a second save inside one millisecond used to overwrite the + // first rather than adding to the history. + capture(root.path(), &record("greet", "a")).expect("capture"); + capture(root.path(), &record("greet", "b")).expect("capture"); + + assert_eq!(list(root.path(), "greet").expect("list").len(), 2); +} + +#[test] +fn history_is_capped_and_the_oldest_go_first() { + let root = tempfile::tempdir().expect("tempdir"); + + for n in 0..MAX_REVISIONS + 5 { + let captured = capture(root.path(), &record("greet", &format!("v{n}"))).expect("capture"); + commit_capture(&captured).expect("commit capture"); + } + + let listed = list(root.path(), "greet").expect("list"); + assert_eq!(listed.len(), MAX_REVISIONS); + assert_eq!( + listed[0].record.description, + format!("v{}", MAX_REVISIONS + 4) + ); + // The five oldest were dropped, so the tail starts at v5 rather than v0. + assert_eq!(listed[MAX_REVISIONS - 1].record.description, "v5"); +} + +#[test] +fn one_workflow_cannot_read_another_workflows_history() { + let root = tempfile::tempdir().expect("tempdir"); + capture(root.path(), &record("greet", "secret")).expect("capture"); + let listed = list(root.path(), "greet").expect("list"); + + // The id names a real file — just not one that belongs to this workflow. + // Letting it through would be a way to write a graph the operator never had. + let cross = read(root.path(), "other", &listed[0].id).expect("read"); + + assert!(cross.is_none()); +} + +#[test] +fn a_workflow_with_no_history_lists_nothing_rather_than_failing() { + let root = tempfile::tempdir().expect("tempdir"); + + assert!(list(root.path(), "never-edited").expect("list").is_empty()); + assert!( + read(root.path(), "never-edited", "whatever") + .expect("read") + .is_none() + ); +} + +#[test] +fn a_snapshot_forgets_where_the_record_was_read_from() { + let root = tempfile::tempdir().expect("tempdir"); + let mut original = record("greet", "first"); + original.source_path = Some("/somewhere/greet.json".into()); + + capture(root.path(), &original).expect("capture"); + + // Carrying the path would make a rollback claim to have come from a file + // that by then holds something else. + let listed = list(root.path(), "greet").expect("list"); + assert_eq!(listed[0].record.source_path, None); +} + +#[test] +fn current_and_legacy_histories_are_merged_newest_first() { + let root = tempfile::tempdir().expect("tempdir"); + let current = root.path().join("current"); + let legacy = root.path().join("legacy"); + capture(&legacy, &record("greet", "legacy")).expect("legacy capture"); + capture(¤t, &record("greet", "current")).expect("current capture"); + + let listed = list_merged(¤t, &legacy, "greet").expect("merged history"); + + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].record.description, "current"); + assert_eq!(listed[1].record.description, "legacy"); +} + +#[test] +fn an_id_that_would_escape_the_history_directory_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + + assert!(capture(root.path(), &record("../escape", "x")).is_err()); + assert!(list(root.path(), "../escape").is_err()); + assert!(read(root.path(), "greet", "../../escape").is_err()); +} diff --git a/src/store/mod.rs b/src/store/mod.rs new file mode 100644 index 00000000..5bfd9d5f --- /dev/null +++ b/src/store/mod.rs @@ -0,0 +1,361 @@ +//! Where workflows and their run records live. +//! +//! The engine has exactly one seam for "where does a graph come from" — +//! [`crate::caps::WorkflowResolver`] — and it only covers resolving a +//! `sub_workflow` node's id. Everything else a host needs (listing, saving, +//! deleting, recording runs) has no contract upstream, so this module defines +//! one: [`WorkflowStore`]. +//! +//! The trait exists so the backing store is a decision, not a fact of the +//! codebase. [`FileWorkflowStore`] — JSON documents under the host's data +//! directory, +//! optionally layered over repository-provided defaults — is the only +//! implementation today, but a remote catalog or a database is a new impl +//! rather than a refactor. + +pub mod authoring; +mod file; +pub mod types; + +#[cfg(test)] +mod concurrency_tests; +#[cfg(test)] +mod tests; + +pub use authoring::{ + GraphHandle, apply_workflow_ops, apply_workflow_ops_if_unchanged, apply_workflow_ops_observed, + create_workflow, mutate_workflow_record, preview_workflow_ops, validate_handle, +}; +pub use file::{ + EnginePolicy, FileWorkflowStore, HostPolicy, LoadReport, MAX_NOTES, MAX_REVISIONS, + gate_failures_into_error, mint_note_id, mint_proposal_id, new_run_record, parse_workflow, + parse_workflow_with, read_workflow, read_workflow_with, validate_graph, workflow_dirs, + workspace_state_dir_under, +}; +// The filename guard and the atomic write are how *every* file-backed piece of +// workflow state lands on disk. A host keeping its own per-workflow state +// alongside this store's needs the same two, and a second copy could disagree +// about what a safe name is — so they are public rather than internal. +pub use file::{safe_component, workspace_scope, write_atomic}; + +pub use self::types::{ + Diagnosis, LEGACY_TRUNCATED_KEY, NoteId, NoteKind, NoteSource, ProposalId, ProposalStatus, + ProposalVerification, RunId, RunOrigin, RunRecord, RunStatus, RunStep, TRUNCATED_KEY, + TranscriptEntry, WorkflowDefaults, WorkflowError, WorkflowId, WorkflowNote, WorkflowProposal, + WorkflowRecord, WorkflowRevision, WorkflowSummary, is_truncated, +}; + +/// An exclusive claim over proposal decisions for one workflow. +/// +/// The value has no operations: holding it is the operation, and dropping it +/// releases the backing store's claim. +pub trait ProposalDecisionGuard: Send {} + +/// Default guard for stores that need no external transaction primitive. +struct NoopProposalDecisionGuard; + +impl ProposalDecisionGuard for NoopProposalDecisionGuard {} + +/// Persistence for workflow definitions and their run history. +/// +/// Implementations are shared across threads and may be called from async +/// contexts, so they must be `Send + Sync`; the methods are synchronous because +/// every backing store in view is either local files or an in-process database, +/// and a blocking read there is cheaper than the machinery to avoid it. Callers +/// on an async runtime should wrap these in `spawn_blocking`, as the TUI already +/// does for its task repository. +pub trait WorkflowStore: Send + Sync { + /// The host rules this store judges documents and edits by. + /// + /// On the trait rather than passed to each authoring call because every + /// authoring path already takes the store, and threading a second argument + /// through all of them is how one call site ends up applying a different + /// policy than the store it writes to. + /// + /// The default is the engine's own: any `defaults` block, and + /// [`crate::gates`] for the graph. + fn policy(&self) -> &dyn HostPolicy { + const ENGINE: EnginePolicy = EnginePolicy; + &ENGINE + } + + /// Identity used to scope in-process proposal decision guards. + /// + /// Workflow and proposal ids are only unique within a store. Including the + /// store identity prevents two independent catalogs with the same ids from + /// blocking each other. + fn proposal_decision_scope(&self) -> String { + format!("{self:p}") + } + + /// Every known workflow, in a stable display order. + fn list(&self) -> Result, WorkflowError>; + + /// One workflow by id, or `None` when the store has no such record. + fn get(&self, id: &str) -> Result, WorkflowError>; + + /// Write `record`, replacing any existing workflow with the same id. + /// + /// Implementations validate before writing: a store never persists a graph + /// the engine would refuse to compile, so a listing can be trusted to be + /// runnable. + fn save(&self, record: &WorkflowRecord) -> Result<(), WorkflowError>; + + /// Save only when the current graph still has `expected_fingerprint`. + /// + /// File-backed stores override this atomically. The default preserves the + /// contract for lightweight test stores that do not expose transactions. + fn save_if_fingerprint( + &self, + record: &WorkflowRecord, + expected_fingerprint: &str, + ) -> Result { + save_if_current_matches(self, record, expected_fingerprint, |current| { + crate::store::types::fingerprint(¤t.graph) + }) + } + + /// Save only when the entire current record still has `expected_fingerprint`. + /// + /// Definition edits use this stronger comparison so a graph write cannot + /// silently restore stale defaults or metadata. File-backed stores override + /// it atomically; the default supports lightweight test stores. + fn save_if_record_fingerprint( + &self, + record: &WorkflowRecord, + expected_fingerprint: &str, + ) -> Result { + save_if_current_matches( + self, + record, + expected_fingerprint, + crate::store::types::record_fingerprint, + ) + } + + /// Remove a workflow. Removing one that does not exist is an error, so a + /// caller cannot mistake a typo for a successful delete. + fn delete(&self, id: &str) -> Result<(), WorkflowError>; + + /// Write a run record, replacing any earlier state for the same run id. + fn record_run(&self, run: &RunRecord) -> Result<(), WorkflowError>; + + /// One run by id. + fn get_run(&self, run_id: &str) -> Result, WorkflowError>; + + /// Every recorded run for a workflow, newest first. + fn list_runs(&self, workflow_id: &str) -> Result, WorkflowError>; + + /// Every superseded copy of a workflow, newest first. + /// + /// A workflow that has never been written over has no revisions, which is + /// an empty listing rather than an error. + fn list_revisions(&self, workflow_id: &str) -> Result, WorkflowError>; + + /// One superseded copy, by id, scoped to the workflow it belongs to. + /// + /// Scoped rather than global so a rollback cannot reach another workflow's + /// history — that would be a way to write a graph the operator never had. + fn revision( + &self, + workflow_id: &str, + revision_id: &str, + ) -> Result, WorkflowError>; + + /// Every note recorded about a workflow, newest first, including notes a + /// later one superseded. + /// + /// Defaulted so a store that keeps no journal — a read-only catalogue, a + /// test stand-in — is not obliged to invent one. The asymmetry with + /// [`WorkflowStore::append_note`] is deliberate: reporting "nothing + /// learned" is honest, whereas silently discarding something the host + /// claims to have learned is not. + fn list_notes(&self, workflow_id: &str) -> Result, WorkflowError> { + let _ = workflow_id; + Ok(Vec::new()) + } + + /// Record a note. + fn append_note(&self, note: &WorkflowNote) -> Result<(), WorkflowError> { + let _ = note; + Err(WorkflowError::Engine( + "this workflow store does not keep notes".to_string(), + )) + } + + /// Mark a note as replaced by a later one, returning whether it changed. + /// + /// The superseded note stays listed; it simply stops being briefed. + fn supersede_note( + &self, + workflow_id: &str, + note_id: &str, + by: &str, + ) -> Result { + let _ = (workflow_id, note_id, by); + Err(WorkflowError::Engine( + "this workflow store does not keep notes".to_string(), + )) + } + + /// Write a proposal, replacing any earlier state for the same id. + /// + /// Every transition goes through here — verified, accepted, rejected, made + /// stale — so a proposal's stored form is its current state rather than a + /// log to replay. + fn save_proposal(&self, proposal: &WorkflowProposal) -> Result<(), WorkflowError> { + let _ = proposal; + Err(WorkflowError::Engine( + "this workflow store does not keep proposals".to_string(), + )) + } + + /// Save a proposal only while its workflow still has `expected_fingerprint`. + /// + /// File-backed stores override this atomically with definition writes. The + /// default preserves the contract for lightweight test stores that do not + /// expose transactions. + fn save_proposal_if_fingerprint( + &self, + proposal: &WorkflowProposal, + expected_fingerprint: &str, + ) -> Result { + let Some(current) = self.get(&proposal.workflow_id)? else { + return Ok(false); + }; + if crate::store::types::fingerprint(¤t.graph) != expected_fingerprint { + return Ok(false); + } + self.save_proposal(proposal)?; + Ok(true) + } + + /// One proposal by id. + fn get_proposal(&self, id: &str) -> Result, WorkflowError> { + let _ = id; + Ok(None) + } + + /// Every proposal for a workflow, newest first, decided ones included. + fn list_proposals(&self, workflow_id: &str) -> Result, WorkflowError> { + let _ = workflow_id; + Ok(Vec::new()) + } + + /// Claim exclusive decision access for every proposal on one workflow. + /// + /// File stores override this with a cross-process lock held across reading + /// the proposal, applying or rejecting it, and persisting the outcome. + fn lock_proposal_decision( + &self, + workflow_id: &str, + ) -> Result, WorkflowError> { + let _ = workflow_id; + Ok(Box::new(NoopProposalDecisionGuard)) + } +} + +/// Implement the non-transactional compare-and-save fallback with a chosen +/// fingerprint scope. +fn save_if_current_matches( + store: &S, + record: &WorkflowRecord, + expected_fingerprint: &str, + fingerprint: F, +) -> Result +where + S: WorkflowStore + ?Sized, + F: FnOnce(&WorkflowRecord) -> String, +{ + let Some(current) = store.get(&record.id)? else { + return Ok(false); + }; + if fingerprint(¤t) != expected_fingerprint { + return Ok(false); + } + store.save(record)?; + Ok(true) +} + +/// Fetch a proposal by id, turning absence into an error. +pub fn require_proposal( + store: &dyn WorkflowStore, + id: &str, +) -> Result { + store + .get_proposal(id)? + .ok_or_else(|| WorkflowError::Malformed(format!("no proposal with id '{id}'"))) +} + +/// A workflow's current notes — what a brief should be built from. +/// +/// Superseded notes are deliberately excluded: they are history worth showing +/// an operator, but asking a model to reason from a claim already known to be +/// wrong is worse than telling it nothing. +pub fn current_notes( + store: &dyn WorkflowStore, + workflow_id: &str, +) -> Result, WorkflowError> { + Ok(store + .list_notes(workflow_id)? + .into_iter() + .filter(WorkflowNote::is_current) + .collect()) +} + +/// Restore `workflow_id` to the state held by `revision_id`. +/// +/// Goes through [`WorkflowStore::save`], so the graph being replaced is itself +/// snapshotted first: a rollback is undoable by the same key that performed it. +/// +/// # Errors +/// +/// Fails when the workflow or the revision is unknown, or when the restored +/// graph no longer validates — which can happen if a sub-workflow it referenced +/// has since been deleted. +pub fn rollback( + store: &dyn WorkflowStore, + workflow_id: &str, + revision_id: &str, +) -> Result { + let revision = store.revision(workflow_id, revision_id)?.ok_or_else(|| { + WorkflowError::Malformed(format!( + "workflow '{workflow_id}' has no revision '{revision_id}'" + )) + })?; + store.save(&revision.record)?; + Ok(revision.record) +} + +/// Restore `workflow_id` to the state before its most recent edit. +/// +/// What the operator's undo key calls. Returns `None` when there is no history +/// to go back to, which the caller reports rather than treating as a failure — +/// a workflow that has never been edited is a normal thing to press undo on. +pub fn undo_last( + store: &dyn WorkflowStore, + workflow_id: &str, +) -> Result, WorkflowError> { + let Some(newest) = store.list_revisions(workflow_id)?.into_iter().next() else { + return Ok(None); + }; + let restored = rollback(store, workflow_id, &newest.id)?; + Ok(Some((newest, restored))) +} + +/// Fetch a workflow by id, turning "no such workflow" into an error. +/// +/// The common case at a command boundary, where absence is a failure to report +/// rather than a state to branch on. +pub fn require(store: &dyn WorkflowStore, id: &str) -> Result { + store + .get(id)? + .ok_or_else(|| WorkflowError::NotFound(WorkflowId::from(id))) +} + +/// Fetch a run by id, turning absence into an error. +pub fn require_run(store: &dyn WorkflowStore, run_id: &str) -> Result { + store + .get_run(run_id)? + .ok_or_else(|| WorkflowError::RunNotFound(RunId::from(run_id))) +} diff --git a/src/store/tests/discovery.rs b/src/store/tests/discovery.rs new file mode 100644 index 00000000..6c51786d --- /dev/null +++ b/src/store/tests/discovery.rs @@ -0,0 +1,62 @@ +//! Layered directory loading: a home definition overrides a project default in +//! place, one malformed document costs only itself, and a missing directory is +//! not an error. + +use super::*; + +#[test] +fn a_home_workflow_overrides_a_project_default_of_the_same_id_in_place() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = root.path().join("project"); + + write(&project.join("first.json"), &valid_document("first")); + write(&project.join("shared.json"), &valid_document("shared")); + let overridden = valid_document("shared").replace("\"Greet\"", "\"Personal greet\""); + write(&home.join("shared.json"), &overridden); + + let store = FileWorkflowStore::new(vec![project, home], root.path().join("runs")); + let report = store.load(); + + assert!(report.errors.is_empty(), "unexpected: {:?}", report.errors); + let ids: Vec<&str> = report.workflows.iter().map(|w| w.id.as_str()).collect(); + assert_eq!( + ids, + vec!["first", "shared"], + "an override should keep the position of what it overrides" + ); + assert_eq!(report.workflows[1].name, "Personal greet"); +} + +#[test] +fn one_malformed_document_costs_only_itself() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("workflows"); + write(&dir.join("good.json"), &valid_document("good")); + write(&dir.join("broken.json"), "{ not json"); + + let store = store_in(root.path()); + let report = store.load(); + + assert_eq!( + report.workflows.len(), + 1, + "the good document should survive" + ); + assert_eq!(report.errors.len(), 1); + assert!( + report.errors[0].contains("broken.json"), + "the error should name the file: {:?}", + report.errors + ); +} + +#[test] +fn a_missing_directory_is_not_an_error() { + let root = tempfile::tempdir().unwrap(); + let report = store_in(root.path()).load(); + + assert!(report.workflows.is_empty()); + assert!(report.errors.is_empty(), "unexpected: {:?}", report.errors); + assert!(report.dirs.is_empty(), "nothing was read"); +} diff --git a/src/store/tests/history.rs b/src/store/tests/history.rs new file mode 100644 index 00000000..0a13487c --- /dev/null +++ b/src/store/tests/history.rs @@ -0,0 +1,252 @@ +//! Revision snapshotting, undo, and rollback: saving over a workflow +//! snapshots what it replaced, undo restores (and is itself undoable), a named +//! rollback restores exactly that revision, and history never leaks into the +//! ordinary workflow listing. + +use super::*; + +#[test] +fn a_workflow_saved_for_the_first_time_has_no_history_to_go_back_to() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + + store + .save(&parse_workflow(&valid_document("greet"), "greet").unwrap()) + .unwrap(); + + // Nothing was replaced, so nothing was superseded. + assert!(store.list_revisions("greet").unwrap().is_empty()); + assert!(undo_last(&store, "greet").unwrap().is_none()); +} + +#[test] +fn saving_over_a_workflow_snapshots_the_version_it_replaced() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); + store.save(&record).unwrap(); + + record.description = "rewritten by the copilot".into(); + store.save(&record).unwrap(); + + let history = store.list_revisions("greet").unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].record.description, "says hello"); +} + +#[test] +fn legacy_and_new_revisions_are_listed_together_after_an_edit() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); + record.description = "legacy version".into(); + store.save(&record).unwrap(); + record.description = "current at upgrade".into(); + store.save(&record).unwrap(); + + let legacy_dir = root.path().join("workflows/.revisions/greet"); + std::fs::create_dir_all(legacy_dir.parent().unwrap()).unwrap(); + std::fs::rename( + super::super::file::definition_state_dir( + &root.path().join("state/workflows"), + &[root.path().join("workflows")], + ) + .join("revisions/greet"), + &legacy_dir, + ) + .unwrap(); + + record.description = "post-upgrade edit".into(); + store.save(&record).unwrap(); + + let history = store.list_revisions("greet").unwrap(); + let descriptions: Vec<_> = history + .iter() + .map(|revision| revision.record.description.as_str()) + .collect(); + assert_eq!(descriptions, ["current at upgrade", "legacy version"]); + + let legacy = history + .iter() + .find(|revision| revision.record.description == "legacy version") + .expect("legacy revision remains addressable"); + let restored = rollback(&store, "greet", &legacy.id).expect("legacy rollback"); + assert_eq!(restored.description, "legacy version"); + assert_eq!( + store.get("greet").unwrap().unwrap().description, + "legacy version" + ); +} + +#[test] +fn workspace_scoped_stores_share_definition_revision_history() { + let root = tempfile::tempdir().unwrap(); + let definitions = vec![root.path().join("workflows")]; + let state = root.path().join("state/workflows"); + let first = FileWorkflowStore::with_workspace_state( + definitions.clone(), + &state, + &root.path().join("workspace-a"), + ); + let second = FileWorkflowStore::with_workspace_state( + definitions, + &state, + &root.path().join("workspace-b"), + ); + let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); + first.save(&record).unwrap(); + record.description = "edited from workspace a".into(); + first.save(&record).unwrap(); + + let history = second.list_revisions("greet").unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].record.description, "says hello"); +} + +#[test] +fn sibling_definition_catalogs_do_not_share_revision_history() { + let root = tempfile::tempdir().unwrap(); + let first = FileWorkflowStore::new( + vec![root.path().join("catalog-a")], + root.path().join("runs-a"), + ); + let second = FileWorkflowStore::new( + vec![root.path().join("catalog-b")], + root.path().join("runs-b"), + ); + + let mut first_record = parse_workflow(&valid_document("greet"), "greet").unwrap(); + first.save(&first_record).unwrap(); + first_record.description = "catalog a edit".into(); + first.save(&first_record).unwrap(); + + let mut second_record = parse_workflow(&valid_document("greet"), "greet").unwrap(); + second_record.description = "catalog b original".into(); + second.save(&second_record).unwrap(); + second_record.description = "catalog b edit".into(); + second.save(&second_record).unwrap(); + + let first_history = first.list_revisions("greet").unwrap(); + let second_history = second.list_revisions("greet").unwrap(); + assert_eq!(first_history.len(), 1); + assert_eq!(first_history[0].record.description, "says hello"); + assert_eq!(second_history.len(), 1); + assert_eq!(second_history[0].record.description, "catalog b original"); +} + +#[test] +fn undo_restores_the_previous_version_and_is_itself_undoable() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); + store.save(&record).unwrap(); + record.description = "rewritten by the copilot".into(); + store.save(&record).unwrap(); + + let (revision, restored) = undo_last(&store, "greet").unwrap().expect("history"); + + assert_eq!(restored.description, "says hello"); + assert_eq!( + store.get("greet").unwrap().unwrap().description, + "says hello" + ); + // The rollback went through `save`, so the version it replaced was + // snapshotted too: pressing undo twice returns to where you started. + let history = store.list_revisions("greet").unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].record.description, "rewritten by the copilot"); + assert_ne!(history[0].id, revision.id); +} + +#[test] +fn rolling_back_to_a_named_revision_restores_exactly_that_one() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); + for description in ["first", "second", "third"] { + record.description = description.into(); + store.save(&record).unwrap(); + } + + // Three saves, but the first replaced nothing — so history holds the two + // versions that were superseded, newest first, and the last entry is the + // oldest of those. + let history = store.list_revisions("greet").unwrap(); + assert_eq!(history.len(), 2); + let oldest = history.last().expect("history"); + let restored = rollback(&store, "greet", &oldest.id).unwrap(); + + assert_eq!(restored.description, "first"); +} + +#[test] +fn rolling_back_to_a_revision_that_does_not_exist_is_an_error() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + store + .save(&parse_workflow(&valid_document("greet"), "greet").unwrap()) + .unwrap(); + + let err = rollback(&store, "greet", "no-such-revision").expect_err("must refuse"); + + assert!(matches!(err, WorkflowError::Malformed(_)), "got {err:?}"); +} + +#[test] +fn deleting_a_workflow_leaves_it_recoverable() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + store + .save(&parse_workflow(&valid_document("greet"), "greet").unwrap()) + .unwrap(); + + store.delete("greet").unwrap(); + + // A delete has nothing left to diff against, which is exactly why it is + // snapshotted: without this it is the one edit that cannot be taken back. + assert!(store.get("greet").unwrap().is_none()); + let history = store.list_revisions("greet").unwrap(); + assert_eq!(history.len(), 1); + rollback(&store, "greet", &history[0].id).unwrap(); + assert!(store.get("greet").unwrap().is_some()); +} + +#[test] +fn history_does_not_show_up_in_the_workflow_listing() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("greet"), "greet").unwrap(); + store.save(&record).unwrap(); + record.description = "again".into(); + store.save(&record).unwrap(); + + // Snapshots sit outside the definition directory, so a load must never + // mistake a past version for a current workflow. + assert_eq!(store.list().unwrap().len(), 1); + assert!(store.load().errors.is_empty()); +} + +#[test] +fn shadowing_a_project_default_with_a_home_workflow_snapshots_what_it_shadowed() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = root.path().join("project"); + write(&project.join("greet.json"), &valid_document("greet")); + let store = FileWorkflowStore::new(vec![project, home], root.path().join("runs")); + + // The first home-level save writes a file that did not exist, so nothing + // in the write directory is overwritten — but the operator *does* see the + // graph change, because the home copy now shadows the project default. + let mut record = require(&store, "greet").unwrap(); + record.description = "edited in this project".into(); + store.save(&record).unwrap(); + + let history = store.list_revisions("greet").unwrap(); + assert_eq!(history.len(), 1, "the shadowed version must be recoverable"); + assert_eq!(history[0].record.description, "says hello"); + rollback(&store, "greet", &history[0].id).unwrap(); + assert_eq!( + store.get("greet").unwrap().unwrap().description, + "says hello" + ); +} diff --git a/src/store/tests/mod.rs b/src/store/tests/mod.rs new file mode 100644 index 00000000..2cfb5902 --- /dev/null +++ b/src/store/tests/mod.rs @@ -0,0 +1,64 @@ +//! Unit tests for workflow directory layering, document parsing, and the +//! file-backed store's read/write/delete, run-history, and undo behaviour. +//! +//! The mechanics of snapshot files — ordering, the cap, scoping — are tested +//! next to them in `file/revisions_tests.rs`. What is tested here is the part +//! that matters to a caller: that saving captures history at all, and that +//! rolling back lands where the operator expects. +//! +//! Split by theme rather than kept as one file, to stay under this +//! repository's 500-line-per-file ceiling: [`parsing`] (document parsing and the +//! `defaults` block), [`discovery`] (layered directory loading), +//! [`persistence`] (save/delete round-tripping), [`runs`] (run-record +//! listing), [`path_guards`] (escaping-id refusal), and [`history`] +//! (revision snapshotting, undo, rollback). Shared fixtures live here and +//! reach every submodule through `super::*`. + +mod discovery; +mod history; +mod parsing; +mod path_guards; +mod persistence; +mod runs; + +use std::path::Path; + +use serde_json::json; + +pub(super) use super::file::{ + HostPolicy, new_run_record, parse_workflow, parse_workflow_with, validate_graph, +}; +pub(super) use super::{ + FileWorkflowStore, WorkflowStore, require, require_run, rollback, undo_last, +}; +pub(super) use crate::store::types::{RunStatus, WorkflowDefaults, WorkflowError, WorkflowRecord}; + +/// A store rooted in a temporary directory, with definitions and runs kept +/// apart the way the discovered layout keeps them. +pub(super) fn store_in(root: &Path) -> FileWorkflowStore { + FileWorkflowStore::new(vec![root.join("workflows")], root.join("runs")) +} + +/// The smallest document that validates: one trigger, one transform, one edge. +pub(super) fn valid_document(id: &str) -> String { + json!({ + "id": id, + "name": "Greet", + "description": "says hello", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "start", + "config": { "trigger_kind": "manual" } }, + { "id": "greet", "kind": "transform", "name": "greet", + "config": { "set": { "greeting": "=.item.name" } } } + ], + "edges": [ + { "from_node": "t", "from_port": "main", "to_node": "greet", "to_port": "main" } + ] + }) + .to_string() +} + +pub(super) fn write(path: &Path, body: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); +} diff --git a/src/store/tests/parsing.rs b/src/store/tests/parsing.rs new file mode 100644 index 00000000..60cc532d --- /dev/null +++ b/src/store/tests/parsing.rs @@ -0,0 +1,145 @@ +//! Document parsing: id fallback, schema migration, the `defaults` block, and +//! structural validation reporting every failure rather than only the first. + +use serde_json::json; + +use super::*; + +#[test] +fn parsing_names_a_workflow_by_its_filename_when_the_document_omits_an_id() { + let document = json!({ + "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], + "edges": [] + }) + .to_string(); + + let record = parse_workflow(&document, "nightly-sweep").expect("parses"); + + assert_eq!(record.id, "nightly-sweep"); + // Name falls back to the id so a listing is never blank. + assert_eq!(record.name, "nightly-sweep"); + assert!(record.enabled, "workflows are enabled unless opted out"); +} + +#[test] +fn parsing_migrates_a_document_saved_without_a_schema_version() { + // A document predating the field must keep loading; the engine's migration + // runs before deserialization, not after. + let document = json!({ + "id": "old", + "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], + "edges": [] + }) + .to_string(); + + let record = parse_workflow(&document, "old").expect("parses"); + + assert_eq!( + record.graph.schema_version, + crate::model::CURRENT_SCHEMA_VERSION + ); +} + +#[test] +fn parsing_reads_the_defaults_block() { + let document = json!({ + "id": "nightly", + "defaults": { "harness": "codex", "model": "gpt-5-codex" }, + "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], + "edges": [] + }) + .to_string(); + + let record = parse_workflow(&document, "nightly").expect("parses"); + + assert_eq!(record.defaults.harness.as_deref(), Some("codex")); + assert_eq!(record.defaults.model.as_deref(), Some("gpt-5-codex")); +} + +#[test] +fn a_host_policy_can_refuse_a_defaults_block_the_engine_cannot_judge() { + // Which harnesses exist is the host's vocabulary, not the engine's, so the + // rule is injected. What matters here is *when* it runs: on the way in + // rather than at dispatch, because a workflow that meant to change where its + // work runs and quietly ran it on the host default is the failure this + // exists to prevent. + #[derive(Debug)] + struct OnlyKnownHarnesses; + + impl HostPolicy for OnlyKnownHarnesses { + fn check_defaults(&self, defaults: &WorkflowDefaults) -> Result<(), String> { + match defaults.harness.as_deref() { + Some(name) if name != "codex" => Err(format!("no harness named '{name}'")), + _ => Ok(()), + } + } + } + + let document = json!({ + "id": "nightly", + "defaults": { "harness": "claude code" }, + "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], + "edges": [] + }) + .to_string(); + + let err = parse_workflow_with(&document, "nightly", &OnlyKnownHarnesses).expect_err("refused"); + + assert!(err.contains("defaults"), "{err}"); + assert!(err.contains("claude code"), "{err}"); +} + +#[test] +fn with_no_host_policy_an_unknown_harness_name_is_carried_through() { + // The engine has no opinion about the string, so the default is to keep it + // rather than to guess. A host that cares supplies a policy. + let document = json!({ + "id": "nightly", + "defaults": { "harness": "something-else" }, + "nodes": [{ "id": "t", "kind": "trigger", "name": "start" }], + "edges": [] + }) + .to_string(); + + let record = parse_workflow(&document, "nightly").expect("parses"); + + assert_eq!(record.defaults.harness.as_deref(), Some("something-else")); +} + +#[test] +fn a_document_without_defaults_stays_without_them() { + let record = parse_workflow(&valid_document("plain"), "plain").expect("parses"); + assert!(record.defaults.is_empty()); +} + +#[test] +fn parsing_rejects_a_document_that_is_not_an_object() { + let err = parse_workflow("[]", "list").expect_err("an array is not a workflow"); + assert!(err.contains("object"), "unhelpful message: {err}"); +} + +#[test] +fn validation_reports_every_failure_not_only_the_first() { + // A graph with no trigger *and* an edge to a node that does not exist. An + // author — often an agent editing over a tool call — should learn both in + // one round-trip. + let graph = serde_json::from_value(json!({ + "nodes": [{ "id": "a", "kind": "transform", "name": "a" }], + "edges": [{ "from_node": "a", "to_node": "ghost" }] + })) + .unwrap(); + + let err = validate_graph("broken", &graph).expect_err("invalid"); + let WorkflowError::Invalid { messages, .. } = err else { + panic!("expected Invalid, got {err:?}"); + }; + + assert!( + messages.len() >= 2, + "expected every failure, got {messages:?}" + ); + assert!( + messages.iter().any(|m| m.contains("missing_trigger")), + "missing trigger not reported: {messages:?}" + ); +} diff --git a/src/store/tests/path_guards.rs b/src/store/tests/path_guards.rs new file mode 100644 index 00000000..81d41323 --- /dev/null +++ b/src/store/tests/path_guards.rs @@ -0,0 +1,65 @@ +//! An id that would escape the workflow or run directory is refused, whether +//! it names a workflow, a save target, or a run record — none of these ids are +//! any more trusted than input from a peer. + +use super::*; + +#[test] +fn an_id_that_would_escape_the_workflow_directory_is_refused() { + // A document's own `id` overrides whatever the caller asked for, and a + // document may have been written by an agent — so this is the guard that + // stops a save from writing outside the store with the daemon's rights. + for hostile in [ + "../escape", + "../../etc/authorized_keys", + "sub/dir", + "back\\slash", + "..", + ".", + " ", + "/absolute", + ] { + assert!( + super::super::file::safe_component(hostile).is_err(), + "{hostile:?} should be refused" + ); + } + // Ordinary ids, including ones with dots, still work. + for ordinary in ["sweep", "nightly-sweep", "a.b", "review_and_fix"] { + assert!( + super::super::file::safe_component(ordinary).is_ok(), + "{ordinary:?} should be allowed" + ); + } +} + +#[test] +fn saving_a_workflow_whose_id_escapes_writes_nothing() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("ok"), "ok").unwrap(); + record.id = "../escaped".into(); + + let err = store.save(&record).expect_err("must refuse"); + + assert!(matches!(err, WorkflowError::Malformed(_)), "got {err:?}"); + assert!( + !root.path().join("escaped.json").exists(), + "nothing may be written outside the workflow directory" + ); +} + +#[test] +fn a_run_id_that_escapes_is_refused_too() { + // Run ids arrive on task frames from peers, so they are no more trusted + // than a workflow id. + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + + let err = store + .record_run(&new_run_record("../escaped", "alpha", 1)) + .expect_err("must refuse"); + + assert!(matches!(err, WorkflowError::Malformed(_)), "got {err:?}"); + assert!(!root.path().join("escaped.json").exists()); +} diff --git a/src/store/tests/persistence.rs b/src/store/tests/persistence.rs new file mode 100644 index 00000000..ee8eacf4 --- /dev/null +++ b/src/store/tests/persistence.rs @@ -0,0 +1,199 @@ +//! Save/delete round-tripping: the graph and host fields survive a save, the +//! `defaults` block round-trips (or is omitted entirely when empty), an +//! uncompilable graph is refused, and deletes remove the file actually read. + +use serde_json::json; + +use super::*; + +#[test] +fn saving_then_loading_round_trips_the_host_fields_and_the_graph() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("round"), "round").unwrap(); + record.description = "edited".into(); + record.enabled = false; + + store.save(&record).expect("saves"); + let loaded = require(&store, "round").expect("found"); + + assert_eq!(loaded.description, "edited"); + assert!(!loaded.enabled, "enabled must survive the round trip"); + assert_eq!(loaded.graph, record.graph); + assert_eq!(loaded.trigger_kind().as_deref(), Some("manual")); +} + +#[test] +fn authored_directory_contains_only_workflow_sources_after_edits() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("clean"), "clean").unwrap(); + + store.save(&record).expect("initial save"); + record.description = "second version".into(); + store.save(&record).expect("edit"); + + let source_entries: Vec<_> = std::fs::read_dir(root.path().join("workflows")) + .expect("source directory") + .map(|entry| entry.expect("entry").file_name()) + .collect(); + assert_eq!(source_entries, vec![std::ffi::OsString::from("clean.json")]); + let definition_state = super::super::file::definition_state_dir( + &root.path().join("state/workflows"), + &[root.path().join("workflows")], + ); + assert!(definition_state.join("revisions/clean").is_dir()); + assert!(definition_state.join("locks/.clean.lock").is_file()); +} + +#[test] +fn explicit_state_root_owns_definition_history_and_locks() { + let root = tempfile::tempdir().unwrap(); + let catalog_parent = tempfile::tempdir().unwrap(); + let definitions = catalog_parent.path().join("authored"); + let state = root.path().join("host-state"); + let store = FileWorkflowStore::with_state(vec![definitions.clone()], &state); + let mut record = parse_workflow(&valid_document("placed"), "placed").unwrap(); + store.save(&record).unwrap(); + record.description = "edited".into(); + store.save(&record).unwrap(); + + let definition_state = + super::super::file::definition_state_dir(&state, std::slice::from_ref(&definitions)); + assert!(definition_state.join("revisions/placed").is_dir()); + assert!(definition_state.join("locks/.placed.lock").is_file()); + assert!(!catalog_parent.path().join("state").exists()); +} + +#[test] +fn saving_round_trips_the_defaults_block() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut record = parse_workflow(&valid_document("pinned"), "pinned").unwrap(); + record.defaults.harness = Some("codex".into()); + record.defaults.model = Some("gpt-5-codex".into()); + + store.save(&record).expect("saves"); + let loaded = require(&store, "pinned").expect("found"); + + assert_eq!(loaded.defaults, record.defaults); +} + +#[test] +fn a_workflow_stating_no_preference_writes_no_defaults_block() { + // A document an operator opens should not grow a block of nulls to say + // nothing. + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let record = parse_workflow(&valid_document("plain"), "plain").unwrap(); + + store.save(&record).expect("saves"); + let path = require(&store, "plain") + .unwrap() + .source_path + .expect("on disk"); + let written: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + + assert!(written.get("defaults").is_none(), "{written}"); +} + +#[test] +fn saving_refuses_a_graph_the_engine_would_not_compile() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let record = WorkflowRecord { + id: "no-trigger".into(), + name: "no trigger".into(), + description: String::new(), + enabled: true, + defaults: Default::default(), + graph: serde_json::from_value(json!({ "nodes": [], "edges": [] })).unwrap(), + source_path: None, + }; + + let err = store.save(&record).expect_err("must not persist"); + + assert!(matches!(err, WorkflowError::Invalid { .. }), "got {err:?}"); + assert!( + store.list().unwrap().is_empty(), + "an invalid graph must not reach the catalog" + ); +} + +#[test] +fn deleting_removes_the_file_the_workflow_was_actually_read_from() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let record = parse_workflow(&valid_document("gone"), "gone").unwrap(); + store.save(&record).unwrap(); + + store.delete("gone").expect("deletes"); + + assert!(store.get("gone").unwrap().is_none()); + let err = store + .delete("gone") + .expect_err("deleting twice is an error"); + assert!(matches!(err, WorkflowError::NotFound(_)), "got {err:?}"); +} + +#[test] +fn deleting_a_repository_default_never_modifies_the_checkout() { + let root = tempfile::tempdir().unwrap(); + let repository_dir = root.path().join("repo/.flows/workflows"); + let home_dir = root.path().join("home/workflows"); + let repository_file = repository_dir.join("shared.json"); + write(&repository_file, &valid_document("shared")); + let store = FileWorkflowStore::new( + vec![repository_dir, home_dir], + root.path().join("state/runs"), + ); + + let err = store + .delete("shared") + .expect_err("repository defaults are read-only"); + + assert!( + matches!(err, WorkflowError::ReadOnlyDefinition { .. }), + "got {err:?}" + ); + assert!( + repository_file.exists(), + "the checkout must remain untouched" + ); + assert!(store.get("shared").unwrap().is_some()); +} + +#[test] +fn deleting_a_home_definition_uses_its_actual_filename() { + let root = tempfile::tempdir().unwrap(); + let home_dir = root.path().join("home/workflows"); + let alias_file = home_dir.join("alias.json"); + write(&alias_file, &valid_document("shared")); + let store = FileWorkflowStore::new(vec![home_dir], root.path().join("state/runs")); + + store + .delete("shared") + .expect("home definitions are writable"); + + assert!(!alias_file.exists()); + assert!(store.get("shared").unwrap().is_none()); +} + +#[test] +fn an_id_containing_a_dot_does_not_collide_on_its_temporary_file() { + // The temp name is appended, not substituted for the extension, so + // `a.b.json` and `a.json` cannot fight over one scratch path. + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + store + .save(&parse_workflow(&valid_document("a.b"), "a.b").unwrap()) + .unwrap(); + store + .save(&parse_workflow(&valid_document("a"), "a").unwrap()) + .unwrap(); + + // Load order is the sorted filename order, so `a.b.json` precedes `a.json`. + let ids: Vec = store.list().unwrap().into_iter().map(|s| s.id).collect(); + assert_eq!(ids, vec!["a.b", "a"], "both should survive"); +} diff --git a/src/store/tests/runs.rs b/src/store/tests/runs.rs new file mode 100644 index 00000000..cc914fe8 --- /dev/null +++ b/src/store/tests/runs.rs @@ -0,0 +1,51 @@ +//! Run-record listing: newest first, scoped to their workflow, and a run that +//! was never recorded is a distinct error rather than a silent `None`. + +use super::*; + +#[test] +fn runs_are_listed_newest_first_and_scoped_to_their_workflow() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + + store + .record_run(&new_run_record("r1", "alpha", 100)) + .unwrap(); + store + .record_run(&new_run_record("r2", "alpha", 300)) + .unwrap(); + store + .record_run(&new_run_record("r3", "beta", 200)) + .unwrap(); + + let alpha = store.list_runs("alpha").unwrap(); + let ids: Vec<&str> = alpha.iter().map(|r| r.id.as_str()).collect(); + + assert_eq!(ids, vec!["r2", "r1"], "newest first"); + assert_eq!(store.list_runs("beta").unwrap().len(), 1); + assert_eq!(store.list_runs("unknown").unwrap().len(), 0); +} + +#[test] +fn a_run_record_survives_being_rewritten_as_it_settles() { + let root = tempfile::tempdir().unwrap(); + let store = store_in(root.path()); + let mut run = new_run_record("r1", "alpha", 100); + store.record_run(&run).unwrap(); + + run.status = RunStatus::PendingApproval; + run.pending_approvals = vec!["review".into()]; + store.record_run(&run).unwrap(); + + let loaded = require_run(&store, "r1").expect("found"); + assert_eq!(loaded.status, RunStatus::PendingApproval); + assert_eq!(loaded.pending_approvals, vec!["review".to_string()]); + assert!(!loaded.status.is_settled(), "an approval gate is resumable"); +} + +#[test] +fn asking_for_a_run_that_was_never_recorded_is_an_error_not_a_silent_none() { + let root = tempfile::tempdir().unwrap(); + let err = require_run(&store_in(root.path()), "ghost").expect_err("no such run"); + assert!(matches!(err, WorkflowError::RunNotFound(_)), "got {err:?}"); +} diff --git a/src/store/types/diagnosis.rs b/src/store/types/diagnosis.rs new file mode 100644 index 00000000..de50fa33 --- /dev/null +++ b/src/store/types/diagnosis.rs @@ -0,0 +1,304 @@ +//! Reading a simulation's steps for the failures a green run hides. +//! +//! A dry run that "passes" proves less than it looks like it does. Every node +//! ran, every node returned, the outcome is a JSON object — and a step whose +//! only input expression resolved to `null` looks exactly the same as one that +//! got what it needed. Null is a legal value: the engine has no complaint, the +//! run record is all green, and the workflow does nothing when it runs for real. +//! +//! So the point of a dry run at authoring time is not the outcome. It is the +//! *steps*, and specifically four things in them that the outcome cannot say: +//! +//! - a binding that resolved to null, +//! - an `agent` node that would dispatch a harness session with an empty prompt, +//! - a node that errored but whose `on_error` policy swallowed it, +//! - a node that never ran at all because a condition routed the sample past it. +//! +//! The last two are the ones a naive reading misses. An error hidden by +//! `on_error: continue` leaves a step marked failed with *empty* diagnostics, so +//! a check that only looked at diagnostics sees nothing; and a node that never +//! executed produces no step at all, so a check that only walked the steps it +//! got would report a clean run on a graph where half the work was skipped. +//! +//! # What this cannot see +//! +//! The engine traces null-resolved expressions for `agent`, `tool_call`, and +//! `http_request` nodes only — the kinds that hand a resolved config to +//! something outside itself. A `transform` or `condition` whose expression +//! resolves to null emits no diagnostic, so a transform that quietly sets a +//! field to null passes here. That is a real gap and the reason the *gates* +//! ([`crate::gates`]) exist alongside this: they read the graph statically and +//! catch the shapes that are wrong before anything runs. +//! +//! Adapted from a sibling host's dry-run diagnostics. + +use std::collections::{HashSet, VecDeque}; +use std::sync::{Arc, Mutex}; + +use crate::model::{NodeKind, WorkflowGraph}; +use crate::observability::{ExecutionStep, Run, RunObserver, StepStatus}; +use serde::{Deserialize, Serialize}; + +/// Collects every step a run reports, for reading once it settles. +/// +/// The whole of the observer a simulation needs: no events, no progress, no +/// plan — just the record. +#[derive(Default)] +pub struct CapturingObserver { + steps: Mutex>, +} + +impl CapturingObserver { + /// The steps captured so far, in completion order. + pub fn steps(&self) -> Vec { + self.steps.lock().expect("steps lock").clone() + } +} + +impl RunObserver for CapturingObserver { + fn on_step_finish(&self, step: &ExecutionStep) { + self.steps.lock().expect("steps lock").push(step.clone()); + } + + fn on_run_finish(&self, _run: &Run) {} +} + +/// One expression that resolved to null during the simulation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NullBinding { + /// The node whose config held it. + pub node_id: String, + /// The dotted config location — `args.to`, `args.cc.0`. + pub location: String, + /// The expression as written. + pub expression: String, + /// Whether a dry run can actually settle this. + /// + /// The honest half of the diagnostic. When a binding reads from an upstream + /// node the sandbox cannot faithfully stand in for — an `agent` node, whose + /// real output is whatever a harness replies — a null here is what the + /// *mock* produced, not proof the wiring is wrong. Saying so is what stops + /// an agent rewiring a correct graph over and over against a check that was + /// never going to go green. + pub unverifiable: bool, + /// The upstream node the binding reads from, when it reads from one. + #[serde(skip_serializing_if = "Option::is_none")] + pub reads_from: Option, + /// What to do about it. + pub suggestion: String, +} + +/// A node that errored where the graph's own error policy hid it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HiddenError { + /// The node that failed. + pub node_id: String, + /// What it reported, if anything readable came back. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// A node the simulation never reached. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NeverRan { + /// The node that did not execute. + pub node_id: String, + /// The condition upstream of it that routed the run elsewhere, if one was + /// found. + #[serde(skip_serializing_if = "Option::is_none")] + pub routed_by: Option, +} + +/// What a simulation says about a graph, beyond whether it completed. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Diagnosis { + /// Bindings that resolved to null. + pub null_bindings: Vec, + /// Agent nodes that would run with an empty instruction. + pub empty_prompts: Vec, + /// Failures the graph's error policy swallowed. + pub hidden_errors: Vec, + /// Nodes the sample never reached. A warning, not a failure. + pub never_ran: Vec, +} + +impl Diagnosis { + /// Whether anything here should stop an author calling the graph done. + /// + /// [`never_ran`](Self::never_ran) is deliberately excluded: a condition + /// routing one sample down one branch is what a condition is *for*, and + /// failing on it would make every branching graph unbuildable. + pub fn is_clean(&self) -> bool { + self.null_bindings.iter().all(|b| b.unverifiable) + && self.empty_prompts.is_empty() + && self.hidden_errors.is_empty() + } +} + +/// Read `steps` against `graph` for what the outcome cannot say. +pub fn diagnose(graph: &WorkflowGraph, steps: &[ExecutionStep]) -> Diagnosis { + let mut diagnosis = Diagnosis::default(); + + for step in steps { + let node = graph.nodes.iter().find(|n| n.id == step.node_id); + + for null in &step.diagnostics { + // An agent node whose *instruction* resolved to null is its own + // class: the node still runs, and dispatches a whole harness + // session with nothing to do. + let is_prompt = matches!(node.map(|n| &n.kind), Some(NodeKind::Agent)) + && matches!(null.location.as_str(), "prompt" | "instruction"); + if is_prompt { + diagnosis.empty_prompts.push(step.node_id.clone()); + continue; + } + diagnosis + .null_bindings + .push(null_binding(graph, &step.node_id, null)); + } + + // An error hidden by `on_error: continue|route` carries no diagnostics + // at all, so it has to be read off the step's status and its output — + // which is the only place the message survives. + if matches!(step.status, StepStatus::Error) { + diagnosis.hidden_errors.push(HiddenError { + node_id: step.node_id.clone(), + message: error_message(&step.output), + }); + } + } + + let ran: HashSet<&str> = steps.iter().map(|s| s.node_id.as_str()).collect(); + for node in &graph.nodes { + // Only the kinds that do outside work are worth reporting. A transform + // that was routed past is not a surprise worth a warning. + if !matches!( + node.kind, + NodeKind::Agent | NodeKind::ToolCall | NodeKind::HttpRequest + ) { + continue; + } + if ran.contains(node.id.as_str()) { + continue; + } + diagnosis.never_ran.push(NeverRan { + node_id: node.id.clone(), + routed_by: upstream_condition(graph, &node.id), + }); + } + + diagnosis +} + +/// Build one null-binding entry, deciding whether a dry run could settle it. +fn null_binding( + graph: &WorkflowGraph, + node_id: &str, + null: &crate::expr::NullResolution, +) -> NullBinding { + let reads_from = crate::bindings::parse_node_binding(&null.expression) + .map(|binding| binding.node_id) + .filter(|id| graph.nodes.iter().any(|n| &n.id == id)); + + // A binding onto an `agent` node cannot be checked here. The sandbox stands + // in for a harness with a canned reply, so a null says the *mock* had no + // such field — not that a real session would not produce one. + let unverifiable = reads_from + .as_deref() + .and_then(|id| graph.nodes.iter().find(|n| n.id == id)) + .is_some_and(|node| node.kind == NodeKind::Agent); + + let suggestion = if unverifiable { + format!( + "reads from agent node `{}`, whose real output is whatever the harness replies — a \ + dry run cannot confirm this field exists. Check it against what you asked that node \ + to produce rather than re-wiring against the sandbox.", + reads_from.clone().unwrap_or_default() + ) + } else { + "resolved to null, so this step ran with an empty value. Check the path against the \ + upstream node's actual output shape; `workflow_catalog` describes each kind's." + .to_string() + }; + + NullBinding { + node_id: node_id.to_string(), + location: null.location.clone(), + expression: null.expression.clone(), + unverifiable, + reads_from, + suggestion, + } +} + +/// The nearest `condition` upstream of `node_id`, walking edges backwards. +/// +/// Named so the warning can say *why* a node was skipped. "`notify` never ran" +/// sends an author looking at `notify`; "`notify` never ran — `check` routed +/// past it" sends them to the node that actually decided. +fn upstream_condition(graph: &WorkflowGraph, node_id: &str) -> Option { + let mut seen: HashSet<&str> = HashSet::from([node_id]); + let mut queue: VecDeque<&str> = VecDeque::from([node_id]); + + while let Some(current) = queue.pop_front() { + for edge in &graph.edges { + if edge.to_node != current { + continue; + } + let from = edge.from_node.as_str(); + if !seen.insert(from) { + continue; + } + if graph + .nodes + .iter() + .any(|n| n.id == from && n.kind == NodeKind::Condition) + { + return Some(from.to_string()); + } + queue.push_back(from); + } + } + None +} + +/// The message an errored step left in its output, if it left a readable one. +fn error_message(output: &serde_json::Value) -> Option { + output + .get("error") + .and_then(|e| { + e.as_str() + .map(str::to_string) + .or_else(|| Some(e.to_string())) + }) + .filter(|message| !message.trim().is_empty()) +} + +/// A [`CapturingObserver`] as the engine's observer handle. +pub fn capturing() -> (Arc, Arc) { + let observer = Arc::new(CapturingObserver::default()); + (observer.clone(), observer as Arc) +} + +/// What one simulation produced. +/// +/// The output and the diagnosis together, because either alone misleads: the +/// output of a graph that did nothing looks like the output of one that worked, +/// and a diagnosis with nothing to show is only meaningful next to a run that +/// actually completed. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DryRun { + /// The final run state: every node's output, keyed by node id. + pub output: serde_json::Value, + /// What the steps said on the way. + pub diagnosis: Diagnosis, +} + +#[cfg(test)] +#[path = "diagnosis_tests.rs"] +mod tests; diff --git a/src/store/types/diagnosis_tests.rs b/src/store/types/diagnosis_tests.rs new file mode 100644 index 00000000..04876a91 --- /dev/null +++ b/src/store/types/diagnosis_tests.rs @@ -0,0 +1,311 @@ +//! Tests for reading a simulation's steps. +//! +//! The steps are synthesised rather than produced by a real run: what is under +//! test is the *reading*, and building a graph that makes the engine emit a +//! particular diagnostic would be testing the engine instead. The one case that +//! needs a real run — that a green outcome can still hide a null — is covered +//! end-to-end in `ops_tests`. + +use super::*; +use serde_json::json; + +fn graph(nodes: serde_json::Value, edges: serde_json::Value) -> WorkflowGraph { + serde_json::from_value(json!({ "name": "test", "nodes": nodes, "edges": edges })) + .expect("graph parses") +} + +fn step(node_id: &str, nulls: &[(&str, &str)]) -> ExecutionStep { + ExecutionStep { + node_id: node_id.to_string(), + status: StepStatus::Success, + output: json!({}), + duration_ms: 1, + diagnostics: nulls + .iter() + .map(|(location, expression)| crate::expr::NullResolution { + location: location.to_string(), + expression: expression.to_string(), + }) + .collect(), + } +} + +fn failed(node_id: &str, output: serde_json::Value) -> ExecutionStep { + ExecutionStep { + node_id: node_id.to_string(), + status: StepStatus::Error, + output, + duration_ms: 1, + // The point of this case: an error hidden by an `on_error` policy + // carries no diagnostics at all. + diagnostics: Vec::new(), + } +} + +// ---- null bindings ---- + +#[test] +fn a_binding_that_resolved_to_null_is_reported_with_where_it_was() { + let graph = graph( + json!([ + { "id": "shape", "kind": "transform", "name": "Shape", "config": {} }, + { "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }, + ]), + json!([]), + ); + + let diagnosis = diagnose( + &graph, + &[step("notify", &[("args.text", "=nodes.shape.item.title")])], + ); + + assert_eq!(diagnosis.null_bindings.len(), 1); + assert_eq!(diagnosis.null_bindings[0].node_id, "notify"); + assert_eq!(diagnosis.null_bindings[0].location, "args.text"); + assert_eq!( + diagnosis.null_bindings[0].reads_from.as_deref(), + Some("shape") + ); + assert!(!diagnosis.is_clean(), "a null binding is not a clean run"); +} + +#[test] +fn a_null_reading_from_an_agent_is_marked_unverifiable_and_does_not_fail_the_run() { + let graph = graph( + json!([ + { "id": "fetch", "kind": "agent", "name": "Fetch", "config": { "prompt": "go" } }, + { "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }, + ]), + json!([]), + ); + + let diagnosis = diagnose( + &graph, + &[step( + "notify", + &[("args.text", "=nodes.fetch.item.json.title")], + )], + ); + + // The sandbox stands in for a harness with a canned reply, so a null here + // says the mock had no such field — not that a real session would not + // produce one. Failing on it makes an agent re-wire a correct graph. + assert!(diagnosis.null_bindings[0].unverifiable); + assert!( + diagnosis.null_bindings[0] + .suggestion + .contains("cannot confirm") + ); + assert!(diagnosis.is_clean(), "an unverifiable null must not block"); +} + +#[test] +fn a_null_that_reads_from_nothing_is_still_reported_as_checkable() { + let graph = graph( + json!([{ "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }]), + json!([]), + ); + + let diagnosis = diagnose( + &graph, + &[step("notify", &[("args.text", "=run.trigger.x")])], + ); + + assert!(!diagnosis.null_bindings[0].unverifiable); + assert_eq!(diagnosis.null_bindings[0].reads_from, None); + assert!(!diagnosis.is_clean()); +} + +// ---- empty prompts ---- + +#[test] +fn an_agent_whose_instruction_resolved_to_null_is_its_own_class() { + let graph = graph( + json!([{ "id": "work", "kind": "agent", "name": "Work", "config": {} }]), + json!([]), + ); + + let diagnosis = diagnose( + &graph, + &[step("work", &[("prompt", "=nodes.missing.item.x")])], + ); + + // Not a generic null: this node still runs, and dispatches a whole harness + // session with nothing to do. + assert_eq!(diagnosis.empty_prompts, vec!["work".to_string()]); + assert!(diagnosis.null_bindings.is_empty()); + assert!(!diagnosis.is_clean()); +} + +#[test] +fn the_instruction_alias_counts_as_a_prompt_too() { + let graph = graph( + json!([{ "id": "work", "kind": "agent", "name": "Work", "config": {} }]), + json!([]), + ); + + let diagnosis = diagnose(&graph, &[step("work", &[("instruction", "=.item.text")])]); + + assert_eq!(diagnosis.empty_prompts, vec!["work".to_string()]); +} + +#[test] +fn a_null_prompt_on_a_node_that_is_not_an_agent_is_an_ordinary_null() { + let graph = graph( + json!([{ "id": "shape", "kind": "transform", "name": "Shape", "config": {} }]), + json!([]), + ); + + let diagnosis = diagnose(&graph, &[step("shape", &[("prompt", "=.item.x")])]); + + assert!(diagnosis.empty_prompts.is_empty()); + assert_eq!(diagnosis.null_bindings.len(), 1); +} + +// ---- hidden errors ---- + +#[test] +fn a_failure_the_error_policy_swallowed_is_surfaced_with_its_message() { + let graph = graph( + json!([{ "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }]), + json!([]), + ); + + let diagnosis = diagnose( + &graph, + &[failed("notify", json!({ "error": "slug not allowlisted" }))], + ); + + // The step carries no diagnostics, so a check that only read those would + // report this run clean — with a node that failed in it. + assert_eq!(diagnosis.hidden_errors.len(), 1); + assert_eq!(diagnosis.hidden_errors[0].node_id, "notify"); + assert_eq!( + diagnosis.hidden_errors[0].message.as_deref(), + Some("slug not allowlisted") + ); + assert!(!diagnosis.is_clean()); +} + +#[test] +fn a_failure_with_no_readable_message_is_still_reported() { + let graph = graph( + json!([{ "id": "notify", "kind": "tool_call", "name": "Notify", "config": {} }]), + json!([]), + ); + + let diagnosis = diagnose(&graph, &[failed("notify", json!(null))]); + + assert_eq!(diagnosis.hidden_errors.len(), 1); + assert_eq!(diagnosis.hidden_errors[0].message, None); + assert!( + !diagnosis.is_clean(), + "a failure without a message is still a failure" + ); +} + +// ---- nodes that never ran ---- + +#[test] +fn a_node_a_condition_routed_past_names_the_condition_that_decided() { + let graph = graph( + json!([ + { "id": "t", "kind": "trigger", "name": "Start", + "config": { "trigger_kind": "manual" } }, + { "id": "check", "kind": "condition", "name": "Check", + "config": { "expression": "=.item.ok" } }, + { "id": "yes", "kind": "agent", "name": "Yes", "config": { "prompt": "go" } }, + { "id": "no", "kind": "agent", "name": "No", "config": { "prompt": "stop" } }, + ]), + json!([ + { "from_node": "t", "to_node": "check" }, + { "from_node": "check", "from_port": "true", "to_node": "yes" }, + { "from_node": "check", "from_port": "false", "to_node": "no" }, + ]), + ); + + let diagnosis = diagnose( + &graph, + &[step("t", &[]), step("check", &[]), step("yes", &[])], + ); + + // "`no` never ran" sends an author to look at `no`. Naming the condition + // sends them to the node that actually decided. + assert_eq!(diagnosis.never_ran.len(), 1); + assert_eq!(diagnosis.never_ran[0].node_id, "no"); + assert_eq!(diagnosis.never_ran[0].routed_by.as_deref(), Some("check")); + // A condition sending one sample down one branch is what a condition is + // for, so this warns without failing. + assert!(diagnosis.is_clean()); +} + +#[test] +fn only_nodes_that_do_outside_work_are_reported_as_skipped() { + let graph = graph( + json!([ + { "id": "check", "kind": "condition", "name": "Check", + "config": { "expression": "=.item.ok" } }, + { "id": "shape", "kind": "transform", "name": "Shape", "config": {} }, + ]), + json!([{ "from_node": "check", "to_node": "shape" }]), + ); + + let diagnosis = diagnose(&graph, &[step("check", &[])]); + + // A transform that was routed past is not a surprise worth a warning. + assert!(diagnosis.never_ran.is_empty(), "{:?}", diagnosis.never_ran); +} + +#[test] +fn a_skipped_node_with_no_condition_above_it_reports_no_culprit_rather_than_guessing() { + let graph = graph( + json!([ + { "id": "t", "kind": "trigger", "name": "Start", + "config": { "trigger_kind": "manual" } }, + { "id": "work", "kind": "agent", "name": "Work", "config": { "prompt": "go" } }, + ]), + json!([{ "from_node": "t", "to_node": "work" }]), + ); + + let diagnosis = diagnose(&graph, &[step("t", &[])]); + + assert_eq!(diagnosis.never_ran.len(), 1); + assert_eq!(diagnosis.never_ran[0].routed_by, None); +} + +#[test] +fn the_search_for_a_routing_condition_terminates_on_a_cycle() { + // Not a graph the engine would run, but the walk must not hang on one: + // this function is reached from an authoring tool, on whatever was written. + let graph = graph( + json!([ + { "id": "a", "kind": "transform", "name": "A", "config": {} }, + { "id": "b", "kind": "transform", "name": "B", "config": {} }, + { "id": "work", "kind": "agent", "name": "Work", "config": { "prompt": "go" } }, + ]), + json!([ + { "from_node": "a", "to_node": "b" }, + { "from_node": "b", "to_node": "a" }, + { "from_node": "b", "to_node": "work" }, + ]), + ); + + let diagnosis = diagnose(&graph, &[]); + + assert_eq!(diagnosis.never_ran[0].routed_by, None); +} + +// ---- the whole picture ---- + +#[test] +fn a_run_with_nothing_to_report_is_clean() { + let graph = graph( + json!([{ "id": "work", "kind": "agent", "name": "Work", "config": { "prompt": "go" } }]), + json!([]), + ); + + let diagnosis = diagnose(&graph, &[step("work", &[])]); + + assert!(diagnosis.is_clean()); + assert_eq!(diagnosis, Diagnosis::default()); +} diff --git a/src/store/types/error.rs b/src/store/types/error.rs new file mode 100644 index 00000000..2cd64587 --- /dev/null +++ b/src/store/types/error.rs @@ -0,0 +1,82 @@ +//! The failure vocabulary every workflow surface reports through. +//! +//! Kept deliberately wide rather than collapsed into one string: the CLI, the +//! MCP server, and the TUI all branch on these, and an operator's next step +//! differs for each. + +use std::path::PathBuf; + +use super::run::RunId; +use super::workflow::WorkflowId; + +/// What can go wrong reading, writing, or running a workflow. +#[derive(Debug, thiserror::Error)] +pub enum WorkflowError { + /// No workflow with that id is known to the store. + #[error("no workflow with id '{0}'")] + NotFound(WorkflowId), + + /// No run with that id is known to the store. + #[error("no run with id '{0}'")] + RunNotFound(RunId), + + /// The graph did not pass the engine's validation. Carries every failure, + /// not just the first, so one round-trip tells an author everything. + #[error("workflow '{id}' is invalid: {}", .messages.join("; "))] + Invalid { + /// The workflow that failed validation. + id: WorkflowId, + /// One message per validation failure. + messages: Vec, + }, + + /// A document could not be read or parsed. + #[error("{0}")] + Malformed(String), + + /// A visible definition belongs to a read-only, lower-precedence layer. + #[error( + "workflow '{id}' comes from repository default {path}; save it to a writable layer before deleting it" + )] + ReadOnlyDefinition { + /// The workflow the operator tried to delete. + id: WorkflowId, + /// The lower-precedence definition that remains untouched. + path: PathBuf, + }, + + /// The filesystem refused an operation. + #[error("{path}: {source}")] + Io { + /// The path being operated on. + path: PathBuf, + /// The underlying failure. + #[source] + source: std::io::Error, + }, + + /// The engine refused to compile or run the graph. + #[error("{0}")] + Engine(String), + + /// A dispatch to a harness ran out of time before it replied. + /// + /// Kept apart from the three below because the operator's next step differs + /// for each: a timeout is worth retrying, an abort was deliberate, a harness + /// error wants reading, and an unreachable harness wants configuring. + #[error("the harness did not respond in time")] + DispatchTimeout, + + /// A dispatch was aborted before it replied. + #[error("the turn was aborted")] + DispatchAborted, + + /// The harness ran and reported a failure of its own. + #[error("harness: {0}")] + Harness(String), + + /// The dispatch never reached a harness — no transport, no worker, or the + /// waiter went away. + #[error("could not reach a harness: {0}")] + Unreachable(String), +} diff --git a/src/store/types/mod.rs b/src/store/types/mod.rs new file mode 100644 index 00000000..a54cff34 --- /dev/null +++ b/src/store/types/mod.rs @@ -0,0 +1,51 @@ +//! The data model for stored workflows, their runs, and what a host learns +//! about them. +//! +//! A *workflow* is a [`crate::model::WorkflowGraph`] — the engine's own +//! portable JSON shape — plus the bookkeeping a host needs to find it, list +//! it, and say where it came from. The graph itself is deliberately not +//! re-modelled here: it is the contract shared with the engine and with the +//! sibling hosts that embed it, and a parallel host-side copy would only +//! drift. +//! +//! Runs are recorded rather than merely streamed, so a workflow that paused for +//! approval or died with the process can be found again by id. +//! +//! The submodules split the model by lifetime rather than by shape, because +//! that is what decides where each type is stored: +//! +//! - [`workflow`] — the versioned document an operator edits. +//! - [`run`] — one execution's durable record, written once and never revised. +//! - [`note`] — what the host has learned about a workflow across runs. +//! - [`proposal`] — a graph change suggested but not yet made. +//! - [`error`] — the failure vocabulary every surface reports through. +//! - [`diagnosis`] — why a failed run failed, in terms an author can act on. +//! - [`transcript`] — one line of what an agent did inside a step. + +pub mod diagnosis; +mod error; +mod note; +mod proposal; +mod run; +mod transcript; +mod workflow; + +#[cfg(test)] +mod tests; + +pub use diagnosis::Diagnosis; +pub use error::WorkflowError; +pub use note::{NoteId, NoteKind, NoteSource, WorkflowNote}; +pub use proposal::{ + ProposalId, ProposalStatus, ProposalVerification, WorkflowProposal, fingerprint, +}; + +pub use run::{ + LEGACY_TRUNCATED_KEY, RunId, RunOrigin, RunRecord, RunStatus, RunStep, TRUNCATED_KEY, + bounded_evidence, bounded_within, is_truncated, +}; +pub use transcript::TranscriptEntry; +pub use workflow::{ + WorkflowDefaults, WorkflowId, WorkflowRecord, WorkflowRevision, WorkflowSummary, + record_fingerprint, +}; diff --git a/src/store/types/note.rs b/src/store/types/note.rs new file mode 100644 index 00000000..85798d37 --- /dev/null +++ b/src/store/types/note.rs @@ -0,0 +1,116 @@ +//! What a host has learned about a workflow. +//! +//! A workflow could previously say what it *is* and what one run *did*, but +//! nothing carried across runs. Every diagnosis started from zero, so the same +//! cause was re-derived every time it recurred, and a conclusion an operator +//! reached last week was gone by the time it mattered again. +//! +//! A note is one durable claim about a workflow, and the journal is the set of +//! them. Notes are deliberately *not* part of the workflow document: they churn +//! on every failure, and the document is versioned through a twenty-entry +//! revision ring that an operator's real edit history has to fit into. + +use serde::{Deserialize, Serialize}; + +use super::run::RunId; +use super::workflow::WorkflowId; + +/// A note's identifier, unique within its workflow's journal. +pub type NoteId = String; + +/// What a note claims. +/// +/// Separated because they age differently, and the brief that reads them +/// weights them differently: an observation is evidence about one moment, a +/// constraint is a rule the next proposal has to obey. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NoteKind { + /// Something that happened, stated without explanation. The safest kind to + /// write from automation, because it makes no claim about cause. + Observation, + /// A proposed cause, not yet confirmed. Worth recording precisely because a + /// later run either supports it or does not. + Hypothesis, + /// A rule about this workflow that any future change must respect. + Constraint, + /// A change that was made and what it was meant to fix. + Fix, + /// A change that was considered and turned down, with the reason. + /// + /// The kind that makes the loop converge rather than merely terminate: + /// without it, an idea an operator has already rejected is proposed again + /// the next time the same evidence turns up. + Rejection, +} + +/// Who wrote a note. +/// +/// An agent's claim and an operator's instruction are not the same kind of +/// thing, and a brief that presented them identically would let a model's own +/// guess outweigh what a human actually said. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum NoteSource { + /// Written by a model during an evolution pass. + Agent { + /// The model that wrote it, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + }, + /// Written by a person. + Operator, + /// Written by the host itself, from a run record — no model involved. + /// + /// The kind that is always safe to write: it needs no dispatch, so it + /// survives a missing harness, a timed-out turn, and a reply that was pure + /// prose. + System, +} + +/// One durable claim about a workflow. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowNote { + /// This note's id. Leads with a zero-padded timestamp, so a lexical sort is + /// a chronological one — the same scheme workflow revisions use. + pub id: NoteId, + /// The workflow this note is about. + pub workflow_id: WorkflowId, + /// What the note claims. + pub kind: NoteKind, + /// The claim itself, in whoever's words wrote it. + pub text: String, + /// Epoch-millisecond stamp of when it was written. + pub recorded_at: u64, + /// Who wrote it. + pub source: NoteSource, + /// The runs this note is evidence from. + /// + /// Provenance rather than decoration: a note whose evidence is one flaky + /// run should not be read the same way as one drawn from five. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub run_ids: Vec, + /// The note that replaced this one, when a later note did. + /// + /// Superseded notes stay listed — an operator reading history wants to see + /// what was believed and when — but are kept out of briefs, so a model is + /// not asked to reason from a claim already known to be wrong. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub superseded_by: Option, + /// Whether this note is exempt from pruning. + /// + /// An operator's own words are pinned by default: automation writing a + /// hundred observations must not be able to evict what a person said. + #[serde(default)] + pub pinned: bool, +} + +impl WorkflowNote { + /// Whether this note should appear in a brief. + /// + /// Superseded notes are history, not context. + pub fn is_current(&self) -> bool { + self.superseded_by.is_none() + } +} diff --git a/src/store/types/proposal.rs b/src/store/types/proposal.rs new file mode 100644 index 00000000..2c4da9a8 --- /dev/null +++ b/src/store/types/proposal.rs @@ -0,0 +1,138 @@ +//! A graph change an agent suggests but does not make. +//! +//! The whole point of the type. An evolution pass reads a workflow's history +//! and often concludes something should change — but a model that edits a saved +//! graph on its own reasoning is a model that can quietly break a workflow +//! nobody was watching. So a pass produces a *proposal*: a checked, dry-run +//! patch that sits on disk until an operator accepts it. +//! +//! Proposals are host state like runs and notes, not part of the versioned +//! document, because most of them never become one. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::note::NoteId; +use super::run::RunId; +use super::workflow::WorkflowId; +use crate::store::types::diagnosis::Diagnosis; + +/// A proposal's identifier. +pub type ProposalId = String; + +/// Where a proposal stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalStatus { + /// Waiting on an operator. + Pending, + /// Applied to the saved graph. + Accepted, + /// Turned down. The reason becomes a note, so a later pass does not propose + /// it again. + Rejected, + /// The graph moved on before anyone decided. + /// + /// Kept as its own state rather than folded into "rejected": nobody + /// disagreed with this proposal, it simply cannot be applied to a graph it + /// was not computed against. + Stale, +} + +/// What checking a proposal found. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProposalVerification { + /// Whether the proposal applies cleanly and simulates without new problems. + pub ok: bool, + /// Epoch-millisecond stamp of the check. + pub verified_at: u64, + /// Why it did not pass: op errors, engine validation, or gate failures. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub messages: Vec, + /// What a dry run of the patched graph reported. Absent when the patch + /// could not be applied far enough to simulate. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diagnosis: Option, +} + +/// A change to a workflow, argued for and checked but not made. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowProposal { + /// This proposal's id. + pub id: ProposalId, + /// The workflow it would change. + pub workflow_id: WorkflowId, + /// Epoch-millisecond stamp of when it was made. + pub created_at: u64, + /// The argument for it, in the proposer's own words. What an operator reads + /// before deciding. + pub rationale: String, + /// The engine's patch language, as raw JSON. + /// + /// Deliberately not a typed `Vec`: the ops surface only ever + /// *deserializes* that type, and keeping the proposal as the JSON it + /// arrived as means a stored proposal stays readable across engine + /// versions rather than becoming unloadable when the op enum changes. + pub ops: Value, + /// The runs that motivated it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence_runs: Vec, + /// The notes it was reasoned from. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub note_ids: Vec, + /// A fingerprint of the graph these ops were computed against. + /// + /// Checked again at accept time. Ops are positional edits to a specific + /// graph, so applying them to one that has since changed is not a merge — + /// it is a silent, arbitrary rewrite. A mismatch makes the proposal + /// [`ProposalStatus::Stale`] rather than applying it anyway. + pub base_fingerprint: String, + /// What checking it found. `None` before it has been checked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verification: Option, + /// Where it stands. + pub status: ProposalStatus, + /// Epoch-millisecond stamp of the decision, when one was made. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decided_at: Option, + /// Why it was turned down, when it was. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_reason: Option, +} + +impl WorkflowProposal { + /// Whether this proposal is still awaiting a decision. + pub fn is_pending(&self) -> bool { + self.status == ProposalStatus::Pending + } + + /// Whether an operator could apply this proposal as it stands. + /// + /// Both halves matter: a proposal that failed verification is kept on disk + /// as evidence for the next pass, but it is not something to offer. + pub fn is_applicable(&self) -> bool { + self.is_pending() && self.verification.as_ref().is_some_and(|check| check.ok) + } +} + +/// Fingerprint a graph, for detecting that it moved under a proposal. +/// +/// SHA-256 of the graph's canonical JSON. Serialization is stable for a given +/// engine version, which is all this needs: it is a same-process, same-build +/// equality check, not a durable content address. +pub fn fingerprint(graph: &crate::model::WorkflowGraph) -> String { + use sha2::{Digest, Sha256}; + match serde_json::to_vec(graph) { + Ok(canonical) => format!("{:x}", Sha256::digest(&canonical)), + // A graph that fails to serialize (a non-finite `Position`, for + // instance) must not fingerprint the same as every other graph that + // also fails to serialize. Hashing empty bytes would do exactly that, + // letting two genuinely different broken graphs compare equal and a + // stale proposal pass a freshness check it should fail. A fresh random + // token can never equal a caller-supplied `expected_fingerprint`, so + // the comparison this backs always reports "changed" instead. + Err(_) => format!("unfingerprintable:{}", crate::ids::token()), + } +} diff --git a/src/store/types/run.rs b/src/store/types/run.rs new file mode 100644 index 00000000..30299f93 --- /dev/null +++ b/src/store/types/run.rs @@ -0,0 +1,371 @@ +//! One execution's durable record. +//! +//! Unlike a [`super::WorkflowRecord`], a run is written once and never revised, +//! so it needs no snapshot ring. It is the only durable evidence of what the +//! engine actually did, which is why every field here is additive: readers of +//! run files written by an older build must keep working. + +use serde::{Deserialize, Serialize}; + +use super::workflow::WorkflowId; +use crate::store::types::diagnosis::Diagnosis; + +/// Maximum serialized bytes retained for one step input or output. +pub(crate) const MAX_EVIDENCE_BYTES: usize = 64 * 1024; + +/// The key marking a value that was bounded rather than stored whole. +/// +/// Part of the on-disk format, so it is a named constant rather than a literal: +/// a reader that looks for the wrong string does not fail loudly, it silently +/// renders a truncation wrapper as if it were the value. +pub const TRUNCATED_KEY: &str = "_flowsTruncated"; + +/// The key Medulla wrote before this bounding moved into the engine crate. +/// +/// Run records are written once and never revised, so files carrying it exist +/// and will keep existing. Recognising it costs one comparison; not recognising +/// it would make every one of those records read as an untruncated object whose +/// only fields are `originalBytes` and `preview`. +pub const LEGACY_TRUNCATED_KEY: &str = "_medullaTruncated"; + +/// Whether `value` is a truncation wrapper rather than a stored value. +/// +/// Accepts both [`TRUNCATED_KEY`] and [`LEGACY_TRUNCATED_KEY`], so a host reads +/// its own history back regardless of which build wrote it. +#[must_use] +pub fn is_truncated(value: &serde_json::Value) -> bool { + [TRUNCATED_KEY, LEGACY_TRUNCATED_KEY] + .iter() + .any(|key| value.get(key).and_then(serde_json::Value::as_bool) == Some(true)) +} + +/// Keep small evidence intact and summarize values that would bloat history. +/// +/// Execution and diagnosis retain the engine's full in-memory value. Only the +/// durable inspection copy is bounded, so one response cannot make every +/// future history listing read an arbitrarily large file. +pub fn bounded_evidence(value: &serde_json::Value) -> serde_json::Value { + bounded_within(value, MAX_EVIDENCE_BYTES) +} + +/// Keep small values intact and summarize ones larger than `max_bytes`. +/// +/// The same bounding as [`bounded_evidence`] against a caller-chosen budget. +/// The durable record uses a generous one because it is written once; a reply +/// projected for a model uses a much smaller one, because a hundred of them +/// land in the same context window. +/// +/// The wrapper shape is deliberately identical at every budget, so a reader +/// that knows how to unpack a truncated run file already knows how to unpack a +/// truncated reply. +pub fn bounded_within(value: &serde_json::Value, max_bytes: usize) -> serde_json::Value { + let serialized = serde_json::to_string(value).unwrap_or_else(|_| value.to_string()); + if serialized.len() <= max_bytes { + return value.clone(); + } + // The preview is itself embedded in JSON, so reserve half the budget for + // escaping plus the wrapper metadata. Quotes and backslashes can nearly + // double when serialized a second time. + let preview_budget = (max_bytes / 2).saturating_sub(256); + let end = serialized + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= preview_budget) + .last() + .unwrap_or(0); + let bounded = serde_json::json!({ + TRUNCATED_KEY: true, + "originalBytes": serialized.len(), + "preview": &serialized[..end], + }); + debug_assert!( + serde_json::to_vec(&bounded) + .map(|body| body.len() <= max_bytes.max(512)) + .unwrap_or(false) + ); + bounded +} + +/// One run's identifier. Doubles as the engine checkpointer's `thread_id`, which +/// is what makes a paused run resumable across process restarts. +pub type RunId = String; + +/// Bytes of one declared input value kept on the durable record. +/// +/// Much smaller than [`MAX_EVIDENCE_BYTES`]: an input is a knob a caller turned, +/// and every surface that shows a run shows all of them at once. A repository +/// name, a branch, a PR number, a paragraph of instruction all fit; a pasted +/// transcript is summarized rather than carried into every future listing. +pub(crate) const MAX_INPUT_BYTES: usize = 4 * 1024; + +/// Who asked for a run, and from where. +/// +/// Recorded because a run record on its own cannot say why it exists. The +/// question an operator asks in front of a rail full of runs is "which of these +/// did the session I am sitting in start", and answering it needs the session's +/// own correlation key on the record — nothing about the workflow, the graph, or +/// the steps can supply it after the fact. +/// +/// Every field is optional except `kind`, because the callers differ in how much +/// they know about themselves: a host's `workflow run` on a terminal knows it +/// is a CLI and nothing else, while a harness session knows the key its tool +/// grant was minted under. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunOrigin { + /// What kind of caller started the run — see [`RunOrigin::SESSION`] and its + /// siblings. + /// + /// A free string rather than an enum: a build that learns a new door must + /// still be readable by one that does not, and an unknown kind displayed + /// verbatim is strictly better than a record that fails to parse. + pub kind: String, + /// The harness session this run was started from, when one was. + /// + /// The MCP grant key the session's tool server was launched under + /// (`pty-`), which is the only identifier shared by the harness + /// process and the host that spawned it. This is what nests a run under its + /// session in the Agents rail. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session: Option, + /// What to call the caller on screen, when it has a name worth showing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + /// The directory the run was started in. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, +} + +impl RunOrigin { + /// A run started by a harness session through the workflow MCP tools. + pub const SESSION: &'static str = "session"; + /// A run started from a terminal by a host's `workflow run`. + pub const CLI: &'static str = "cli"; + /// A run started from the operator's own Workflows pane. + pub const OPERATOR: &'static str = "operator"; + + /// An origin naming the harness session `session` started the run. + pub fn session(session: impl Into) -> Self { + Self { + kind: Self::SESSION.to_string(), + session: Some(session.into()), + ..Self::default() + } + } + + /// An origin of `kind` with nothing else known about it. + pub fn of_kind(kind: impl Into) -> Self { + Self { + kind: kind.into(), + ..Self::default() + } + } + + /// Record the directory the run started in. + pub fn in_workspace(mut self, workspace: impl Into) -> Self { + let workspace = workspace.into(); + self.workspace = (!workspace.trim().is_empty()).then_some(workspace); + self + } + + /// Record a display name for the caller. + pub fn labelled(mut self, label: impl Into) -> Self { + let label = label.into(); + self.label = (!label.trim().is_empty()).then_some(label); + self + } +} + +/// Where a run got to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + /// Started and not yet settled. + Running, + /// Parked on one or more approval gates; resumable. + PendingApproval, + /// Finished successfully. + Succeeded, + /// Finished with an error. + Failed, + /// Cancelled by an operator or an abort frame. + Cancelled, + /// The process went away mid-run. Reconciled from `Running` on drop, so a + /// crashed run is never left claiming to be live. + Interrupted, +} + +impl RunStatus { + /// Whether this status is terminal — no resume or cancel applies. + pub fn is_settled(&self) -> bool { + !matches!(self, Self::Running | Self::PendingApproval) + } +} + +/// One node's execution within a run, recorded as the engine reports it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunStep { + /// The node this step ran. + pub node_id: String, + /// The engine's step status, lowercased. + pub status: String, + /// Wall-clock duration in milliseconds. + pub duration_ms: u128, + /// The resolved input this activation received. + /// + /// Currently recorded for agent nodes as their full prompt. Absent on + /// other node kinds and on records written before input evidence existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// The items emitted by this activation, retained for run inspection. + /// + /// Absent on records written before step results were persisted and null + /// when the engine failed before producing an output. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + /// Expressions that resolved to null, which are usually a wiring mistake + /// rather than an intended value. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, + /// What the harness said while it ran this step, in order. + /// + /// Recorded for `agent` nodes only, and only for steps run through a + /// dispatch that collects one. Absent on every other node kind and on + /// records written before transcripts existed — additive, like every other + /// field here, so an older build still reads these files. + /// + /// This is the answer to the question [`input`](Self::input) and + /// [`output`](Self::output) cannot reach: those say what the step was asked + /// and what it returned, while a step that returned something surprising is + /// explained by what happened in between. See + /// [`TranscriptEntry::bounded`](crate::store::types::TranscriptEntry::bounded) + /// for the per-entry bound a host's folding code is expected to apply. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transcript: Vec, +} + +/// A durable record of one workflow run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunRecord { + /// This run's id, and the checkpointer thread id that can resume it. + pub id: RunId, + /// The workflow that ran. + pub workflow_id: WorkflowId, + /// Where the run got to. + pub status: RunStatus, + /// Epoch-millisecond start stamp. + pub started_at: u64, + /// Epoch-millisecond settle stamp, absent while running. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + /// The values supplied for the workflow's *declared* inputs, by name. + /// + /// The single most useful thing about a run that the graph cannot supply: + /// two runs of one workflow differ only in what was passed to them, so a + /// history that omitted this listed the same sentence over and over. Bounded + /// per value ([`MAX_INPUT_BYTES`]) rather than whole, because every surface + /// that lists runs shows all of a run's inputs at once. + /// + /// Empty on a workflow that declares no inputs, and on records written + /// before this field existed — the two are indistinguishable, which is + /// acceptable: neither has anything to show. + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub inputs: serde_json::Map, + /// The free-form trigger payload the run was started with. + /// + /// Separate from [`inputs`](Self::inputs) because they are separate + /// arguments: `inputs` names the workflow's declared parameters, while this + /// is whatever the trigger handed the graph. Absent when it was empty, and + /// on records written before this field existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger: Option, + /// Who started this run, and from where. + /// + /// Absent on records written before this field existed, and on a run whose + /// caller could not say anything about itself. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, + /// Steps in completion order. + #[serde(default)] + pub steps: Vec, + /// Node ids currently awaiting approval. Non-empty exactly when the status + /// is [`RunStatus::PendingApproval`], and the set a resume must name. + #[serde(default)] + pub pending_approvals: Vec, + /// Failure message, when the run failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// One line saying what this run did, written when it settled. + /// + /// The observer builds this to narrate the run live; keeping it means a + /// reader after the fact — an operator scanning history, an agent reviewing + /// what a workflow has been doing — gets the same sentence rather than + /// re-deriving a worse one from the steps. + /// + /// Absent on records written before this field existed, and on runs that + /// never settled through the engine. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// What was wrong with the run beyond whether it failed. + /// + /// Null bindings, errors an `on_error` policy swallowed, and nodes that + /// never executed. Previously produced only for *dry* runs, which meant the + /// runs that actually mattered were the ones with no diagnosis at all. + /// + /// Absent on records written before this field existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diagnosis: Option, +} + +impl RunRecord { + /// Record what this run was started with. + /// + /// Values are bounded individually rather than as one blob, so a single + /// oversized argument does not summarize away the six small ones beside it — + /// which are usually the ones that identify the run. + pub fn with_inputs( + mut self, + inputs: &serde_json::Map, + trigger: &serde_json::Value, + ) -> Self { + self.inputs = inputs + .iter() + .map(|(name, value)| (name.clone(), bounded_within(value, MAX_INPUT_BYTES))) + .collect(); + // An empty trigger is what almost every caller passes, and recording + // `{}` on every run would put a meaningless row on every run view. + self.trigger = match trigger { + serde_json::Value::Null => None, + serde_json::Value::Object(map) if map.is_empty() => None, + value => Some(bounded_within(value, MAX_INPUT_BYTES)), + }; + self + } + + /// Record who asked for this run. + pub fn with_origin(mut self, origin: Option) -> Self { + self.origin = origin; + self + } + + /// How long the run took, once it has settled. + pub fn duration_ms(&self) -> Option { + self.finished_at + .map(|finished| finished.saturating_sub(self.started_at)) + } + + /// How many recorded steps ended in an engine-reported failure. + pub fn failed_steps(&self) -> usize { + self.steps + .iter() + .filter(|step| { + matches!( + step.status.trim().to_ascii_lowercase().as_str(), + "failed" | "error" + ) + }) + .count() + } +} diff --git a/src/store/types/tests.rs b/src/store/types/tests.rs new file mode 100644 index 00000000..87d4b921 --- /dev/null +++ b/src/store/types/tests.rs @@ -0,0 +1,267 @@ +//! Unit tests for the workflow data model. +//! +//! These lean on literal JSON rather than round-tripping Rust values wherever a +//! wire shape is the actual contract: run records and workflow documents are +//! read back from disk by builds other than the one that wrote them, so the +//! spelling of a field is the thing worth asserting. + +use serde_json::json; + +use super::*; + +/// A minimal single-node graph, enough to build a record around. +fn graph() -> crate::model::WorkflowGraph { + serde_json::from_value(json!({ + "nodes": [{ + "id": "start", + "kind": "trigger", + "name": "start", + "config": { "trigger_kind": "manual" } + }], + "edges": [] + })) + .expect("the fixture graph should parse") +} + +fn record() -> WorkflowRecord { + WorkflowRecord { + id: "demo".into(), + name: "Demo".into(), + description: "A demo workflow".into(), + enabled: true, + defaults: Default::default(), + graph: graph(), + source_path: None, + } +} + +#[test] +fn summary_counts_nodes_and_reads_the_trigger_kind() { + let summary = record().summary(); + + assert_eq!(summary.id, "demo"); + assert_eq!(summary.node_count, 1); + assert_eq!(summary.trigger_kind.as_deref(), Some("manual")); +} + +#[test] +fn a_document_without_enabled_defaults_to_enabled() { + let parsed: WorkflowRecord = serde_json::from_value(json!({ + "id": "demo", + "name": "Demo", + "graph": graph(), + })) + .expect("a document may omit `enabled` and `description`"); + + assert!(parsed.enabled); + assert_eq!(parsed.description, ""); +} + +#[test] +fn run_status_settles_everything_but_running_and_pending() { + assert!(!RunStatus::Running.is_settled()); + assert!(!RunStatus::PendingApproval.is_settled()); + for status in [ + RunStatus::Succeeded, + RunStatus::Failed, + RunStatus::Cancelled, + RunStatus::Interrupted, + ] { + assert!(status.is_settled(), "{status:?} should be settled"); + } +} + +#[test] +fn run_records_use_camel_case_on_the_wire() { + let wire = serde_json::to_value(RunRecord { + id: "run-1".into(), + workflow_id: "demo".into(), + status: RunStatus::Succeeded, + started_at: 1, + finished_at: Some(2), + steps: vec![RunStep { + node_id: "start".into(), + status: "ok".into(), + duration_ms: 3, + input: Some(json!("inspect this")), + output: None, + diagnostics: Vec::new(), + transcript: Vec::new(), + }], + pending_approvals: Vec::new(), + error: None, + inputs: Default::default(), + trigger: None, + origin: None, + summary: None, + diagnosis: None, + }) + .expect("a run record should serialize"); + + assert!(wire.get("workflowId").is_some()); + assert!(wire.get("startedAt").is_some()); + assert_eq!(wire["status"], json!("succeeded")); + assert!( + wire.get("error").is_none(), + "an absent error should not be written" + ); + assert!(wire["steps"][0].get("nodeId").is_some()); + assert_eq!(wire["steps"][0]["input"], json!("inspect this")); +} + +#[test] +fn a_run_file_written_before_evidence_existed_still_parses() { + // A literal, not a round trip: run records are read back by builds other + // than the one that wrote them, and every one of these files is already on + // operators' disks. If `summary` or `diagnosis` ever stops defaulting, this + // is the test that says so rather than a support ticket. + let parsed: RunRecord = serde_json::from_value(json!({ + "id": "run-old", + "workflowId": "demo", + "status": "failed", + "startedAt": 1, + "finishedAt": 2, + "steps": [{ "nodeId": "start", "status": "error", "durationMs": 3 }], + "pendingApprovals": [], + "error": "boom" + })) + .expect("a run record from before the evidence fields must still load"); + + assert_eq!(parsed.status, RunStatus::Failed); + assert!(parsed.steps[0].input.is_none()); + assert!(parsed.summary.is_none()); + assert!(parsed.diagnosis.is_none()); +} + +#[test] +fn durable_step_evidence_is_bounded_without_changing_small_values() { + let small = json!({ "answer": "still structured" }); + assert_eq!(bounded_evidence(&small), small); + + let large = json!({ "body": "x".repeat(run::MAX_EVIDENCE_BYTES * 2) }); + let bounded = bounded_evidence(&large); + assert_eq!(bounded[TRUNCATED_KEY], true); + assert!(is_truncated(&bounded)); + assert!(bounded["originalBytes"].as_u64().unwrap() > run::MAX_EVIDENCE_BYTES as u64); + assert!( + serde_json::to_vec(&bounded).unwrap().len() <= run::MAX_EVIDENCE_BYTES, + "the persisted summary itself must remain bounded" + ); + + let escaping = json!({ "body": "\\\"".repeat(run::MAX_EVIDENCE_BYTES) }); + assert!( + serde_json::to_vec(&bounded_evidence(&escaping)) + .unwrap() + .len() + <= run::MAX_EVIDENCE_BYTES + ); +} + +#[test] +fn run_evidence_is_omitted_from_the_wire_when_absent() { + // The other half of the compatibility bargain: a record with no evidence + // must not start writing null keys into files an older build reads. + let wire = serde_json::to_value(RunRecord { + id: "run-1".into(), + workflow_id: "demo".into(), + status: RunStatus::Running, + started_at: 1, + finished_at: None, + steps: Vec::new(), + pending_approvals: Vec::new(), + error: None, + inputs: Default::default(), + trigger: None, + origin: None, + summary: None, + diagnosis: None, + }) + .expect("a run record should serialize"); + + assert!(wire.get("summary").is_none()); + assert!(wire.get("diagnosis").is_none()); + assert!(wire.get("inputs").is_none()); + assert!(wire.get("trigger").is_none()); + assert!(wire.get("origin").is_none()); +} + +#[test] +fn what_a_run_was_started_with_survives_the_wire() { + let record = RunRecord { + id: "run-1".into(), + workflow_id: "demo".into(), + status: RunStatus::Running, + started_at: 1, + finished_at: None, + steps: Vec::new(), + pending_approvals: Vec::new(), + error: None, + inputs: Default::default(), + trigger: None, + origin: None, + summary: None, + diagnosis: None, + } + .with_inputs( + &json!({ "repo": "acme/api" }).as_object().cloned().unwrap(), + &json!({ "event": "push" }), + ) + .with_origin(Some(RunOrigin::session("pty-1").in_workspace("/tmp/work"))); + + let wire = serde_json::to_value(&record).expect("a run record should serialize"); + assert_eq!(wire["inputs"]["repo"], json!("acme/api")); + assert_eq!(wire["trigger"]["event"], json!("push")); + assert_eq!(wire["origin"]["kind"], json!("session")); + assert_eq!(wire["origin"]["session"], json!("pty-1")); + assert_eq!(wire["origin"]["workspace"], json!("/tmp/work")); + + let back: RunRecord = serde_json::from_value(wire).expect("and parse back"); + assert_eq!(back, record); +} + +#[test] +fn an_oversized_input_is_summarized_rather_than_carried_whole() { + let record = crate::store::new_run_record("run-1", "demo", 1).with_inputs( + &json!({ "body": "x".repeat(run::MAX_INPUT_BYTES * 3) }) + .as_object() + .cloned() + .unwrap(), + &json!({}), + ); + assert_eq!(record.inputs["body"]["_flowsTruncated"], json!(true)); + assert!( + serde_json::to_vec(&record.inputs["body"]).unwrap().len() <= run::MAX_INPUT_BYTES, + "one oversized input must not bloat every listing that shows it" + ); +} + +#[test] +fn an_empty_trigger_is_not_recorded_as_a_value() { + let record = crate::store::new_run_record("run-1", "demo", 1) + .with_inputs(&Default::default(), &json!({})); + assert!(record.trigger.is_none()); +} + +#[test] +fn a_record_written_before_the_marker_was_renamed_still_reads_as_truncated() { + // Run records are written once and never revised, so files carrying the old + // key exist. A reader that only knew the new one would render the wrapper + // as if it were the value — an object whose fields are `originalBytes` and + // `preview` — rather than as an elision. + let legacy = serde_json::json!({ + LEGACY_TRUNCATED_KEY: true, + "originalBytes": 200_000, + "preview": "{\"items\":[", + }); + + assert!(is_truncated(&legacy)); +} + +#[test] +fn an_ordinary_value_is_not_mistaken_for_a_truncation_wrapper() { + // Both directions matter: a stored value that merely *has* the key set to + // something other than `true` is a value, not a wrapper. + assert!(!is_truncated(&serde_json::json!({ "items": [1, 2, 3] }))); + assert!(!is_truncated(&serde_json::json!({ TRUNCATED_KEY: false }))); + assert!(!is_truncated(&serde_json::json!("a string"))); +} diff --git a/src/store/types/transcript.rs b/src/store/types/transcript.rs new file mode 100644 index 00000000..043b766e --- /dev/null +++ b/src/store/types/transcript.rs @@ -0,0 +1,74 @@ +//! One line of what an agent did, as a run record keeps it. +//! +//! Part of the stored model rather than of the engine: nothing in +//! [`crate::engine`] produces these, and nothing in it reads them. A host that +//! runs `agent` nodes against something with an event stream folds that stream +//! into these entries and hangs them off a [`RunStep`](super::RunStep), so a run +//! read back tomorrow still says what happened inside a step and not only +//! whether it passed. +//! +//! Deliberately flat and stringly-typed. Mirroring a host's own event +//! vocabulary into the record would make every event kind it adds later a +//! breaking change to a file format that must stay readable by older builds. A +//! reader meeting an unfamiliar `kind` still has a timestamp and a line of text +//! to render. + +use serde::{Deserialize, Serialize}; + +/// Bytes of one entry's `text` kept on the durable record. +/// +/// [`RunRecord`](super::RunRecord) bounds step `input`, `output`, and its own +/// `inputs` through `bounded_within` so no single value can grow a run record +/// without limit; a transcript entry is the same kind of host-produced text +/// (a tool result, a model message) and needs the same ceiling. Small on +/// purpose — a transcript is many short lines, not one large payload, and a +/// step with hundreds of entries must not turn one long one into the whole +/// record's size budget. +pub const MAX_ENTRY_TEXT_BYTES: usize = 4 * 1024; + +/// One thing an agent did, in the order it did it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptEntry { + /// Epoch milliseconds, as the host stamped the event. + pub at_ms: i64, + /// The host event kind this was folded from — `agent_message`, `tool_call`, + /// `tool_result`, `agent_thinking`, `error`, and so on. + /// + /// Carried verbatim rather than mapped to a closed set, so a kind added to + /// a host's wire vocabulary later shows up here without a change to this + /// file. + pub kind: String, + /// The renderable line: the message text, the tool's one-line summary, the + /// error message. + pub text: String, +} + +impl TranscriptEntry { + /// Build an entry with `text` capped at [`MAX_ENTRY_TEXT_BYTES`]. + /// + /// Nothing in this crate folds a host's event stream into these — that + /// happens entirely on the host side, as the module doc says — so this is + /// the bound a host's folding code is expected to apply per entry, the way + /// [`bounded_within`](super::bounded_within) bounds the record's other + /// host-produced text. + #[must_use] + pub fn bounded(at_ms: i64, kind: impl Into, text: impl Into) -> Self { + let mut text = text.into(); + if text.len() > MAX_ENTRY_TEXT_BYTES { + let end = text + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= MAX_ENTRY_TEXT_BYTES) + .last() + .unwrap_or(0); + text.truncate(end); + text.push_str(" …[truncated]"); + } + Self { + at_ms, + kind: kind.into(), + text, + } + } +} diff --git a/src/store/types/workflow.rs b/src/store/types/workflow.rs new file mode 100644 index 00000000..8e67733e --- /dev/null +++ b/src/store/types/workflow.rs @@ -0,0 +1,188 @@ +//! The stored workflow document and its listing and history views. +//! +//! These are the versioned half of the model: every write to a +//! [`WorkflowRecord`] snapshots the superseded copy as a [`WorkflowRevision`], +//! which is what makes an edit an operator disagrees with reversible. + +use std::path::PathBuf; + +use crate::model::{WorkflowGraph, WorkflowInput}; +use serde::{Deserialize, Serialize}; + +/// A workflow's stable identifier: the `id` in its document, defaulting to the +/// filename stem when the document omits one. +pub type WorkflowId = String; + +/// A stored workflow: the engine graph plus where this host found it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WorkflowRecord { + /// The workflow's stable id. + pub id: WorkflowId, + /// Display name; falls back to the id when the document omits one. + pub name: String, + /// Operator-facing description of what the workflow does. + #[serde(default)] + pub description: String, + /// Whether the workflow may be run. A disabled workflow still lists and + /// validates, so an operator can repair one without it firing. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// What every `agent` node in this workflow runs on unless it says + /// otherwise. + #[serde(default, skip_serializing_if = "WorkflowDefaults::is_empty")] + pub defaults: WorkflowDefaults, + /// The engine graph. + pub graph: WorkflowGraph, + /// The file this record was read from, when it came from disk. `None` for a + /// graph built in memory (an agent's draft, an import not yet saved). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub source_path: Option, +} + +/// Workflows are enabled unless a document says otherwise. +fn default_enabled() -> bool { + true +} + +/// A workflow's standing choice of harness and model. +/// +/// The middle layer between an `agent` node's own `config` and the host's +/// `workflows` config, and the one an author reaches for most: "this whole plan +/// runs on Codex" is a property of the plan, not of every node in it and not of +/// the machine that happens to run it. +/// +/// Stored as free-form strings rather than parsed types because a workflow may +/// legitimately name a custom harness preset only some hosts expose. The +/// meaning of these strings — and the refusal of one that cannot be a harness — +/// lives in [`crate::flow_engine::harness_choice`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowDefaults { + /// The harness every `agent` node runs on unless it names its own: a + /// built-in CLI (`claude`, `codex`, `opencode`) or a custom preset id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, + /// The model hint sent with every dispatch this workflow makes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +impl WorkflowDefaults { + /// Whether this workflow states no preference at all. + /// + /// Kept so an unset block is omitted from the document entirely: a file an + /// operator opens should not grow two null fields per workflow to say + /// nothing. + pub fn is_empty(&self) -> bool { + self.harness.is_none() && self.model.is_none() + } +} + +impl WorkflowRecord { + /// The listing view of this record. + pub fn summary(&self) -> WorkflowSummary { + WorkflowSummary { + id: self.id.clone(), + name: self.name.clone(), + description: self.description.clone(), + enabled: self.enabled, + node_count: self.graph.nodes.len(), + trigger_kind: self.trigger_kind(), + inputs: self.inputs().to_vec(), + } + } + + /// The workflow's declared inputs — what a caller must supply to run it. + /// + /// Lives on the engine graph, so this is a shorthand rather than a second + /// copy. Empty for a workflow that takes none. + pub fn inputs(&self) -> &[WorkflowInput] { + &self.graph.inputs + } + + /// The graph's trigger kind, as a lowercase string. + /// + /// Read out of the trigger node's free-form config rather than a typed + /// field, because that is where the engine keeps it. `None` when the graph + /// has no single trigger — which validation will also report, so this stays + /// quiet rather than duplicating the error. + pub fn trigger_kind(&self) -> Option { + let trigger = self.graph.trigger()?; + trigger + .config + .get("trigger_kind") + .and_then(|value| value.as_str()) + .map(str::to_string) + } +} + +/// Fingerprint every persisted field in a workflow record. +/// +/// The source path is a property of the store read, not part of the workflow +/// document, so it is deliberately excluded. Definition compare-and-swap +/// writes use this fingerprint because a graph-only comparison would miss +/// concurrent changes to defaults or other workflow metadata. +pub fn record_fingerprint(record: &WorkflowRecord) -> String { + use sha2::{Digest, Sha256}; + + let mut persisted = record.clone(); + persisted.source_path = None; + match serde_json::to_vec(&persisted) { + Ok(canonical) => format!("{:x}", Sha256::digest(&canonical)), + // Same reasoning as `proposal::fingerprint`: hashing empty bytes on a + // serialization failure would let a compare-and-swap write accept a + // stale record whenever both the expected and current record happen + // to fail to serialize the same way. A fresh token can never match a + // caller's `expected_fingerprint`, so the write is refused instead. + Err(_) => format!("unfingerprintable:{}", crate::ids::token()), + } +} + +/// A workflow reduced to what a list needs — the shape advertised to the +/// orchestrator and rendered in the TUI, so neither has to hold whole graphs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowSummary { + /// The workflow's stable id. + pub id: WorkflowId, + /// Display name. + pub name: String, + /// Operator-facing description. + pub description: String, + /// Whether the workflow may be run. + pub enabled: bool, + /// How many nodes the graph has. + pub node_count: usize, + /// The trigger kind, when the graph declares exactly one trigger. + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger_kind: Option, + /// The workflow's declared inputs — what a caller must supply to run it. + /// + /// Carried on the *listing* view deliberately: the TUI has to know whether + /// to prompt before it runs the selected workflow, and the orchestrator has + /// to know what to collect before it asks. Both would otherwise need a + /// second fetch of the whole graph just to answer "does this take + /// arguments?". Omitted from the wire for a workflow that takes none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub inputs: Vec, +} + +/// A copy of a workflow from before it was last written over. +/// +/// Kept so an operator can disagree with an edit after the fact. That matters +/// most for the copilot, which writes to the store directly and would otherwise +/// leave a misread instruction as the only surviving version of a graph. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowRevision { + /// This snapshot's id, unique within its workflow. Sorts chronologically. + pub id: String, + /// Epoch-millisecond stamp of when this copy stopped being current. + /// + /// When it was *superseded*, not when it was authored — a revision is + /// named by the edit that replaced it, which is what an operator scanning + /// history is looking for. + pub superseded_at: u64, + /// The workflow as it was. + pub record: WorkflowRecord, +}