From 7c5596312706a40fcd5b08a12cab95633e85f9af Mon Sep 17 00:00:00 2001 From: Jithin V G Date: Sat, 14 Nov 2026 23:48:48 +0530 Subject: [PATCH 1/4] pact check and pact discover say where a workspace draws its egress boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving `allow-egress:` out draws no boundary (Q17), which is also what a forgotten line looks like: both printed `OK — loaded cleanly`, and the inventory carried neither an absent line nor an empty list. - `pact check` prints one line after the result of a clean check: no line written (everything may talk outside), `[]` (nothing may), or the parts listed. On stdout, never a diagnostic, so `--deny-warnings` is unaffected. An agent checked on its own is told the boundary of the workspace around it. - `pact discover` carries `egress: {boundary: open|closed|listed, allows}` on each workspace. Co-Authored-By: Claude Fable 5.2 --- crates/pact-cli/src/discover.rs | 13 ++ crates/pact-cli/src/egress.rs | 73 +++++++ crates/pact-cli/src/main.rs | 50 ++++- .../a_check_says_where_the_boundary_is.rs | 191 ++++++++++++++++++ 4 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 crates/pact-cli/tests/a_check_says_where_the_boundary_is.rs diff --git a/crates/pact-cli/src/discover.rs b/crates/pact-cli/src/discover.rs index 5619c17..62ebfe1 100644 --- a/crates/pact-cli/src/discover.rs +++ b/crates/pact-cli/src/discover.rs @@ -226,6 +226,19 @@ pub fn inventory(found: &Found) -> J { // keys in the object that name a place, and a consumer joins them. root_obj.insert("root".into(), J::String(root.to_string())); root_obj.insert("agents".into(), J::Array(list)); + // Where the workspace draws its boundary, in a word and the roles written: + // `open` when no `allow-egress:` line is written (everything may talk + // outside), `closed` for `[]`, `listed` otherwise. A consumer deciding + // whether to run this tree somewhere reads it here; an absent line and an + // empty list are opposite answers and were both simply not in the index. + let boundary = crate::egress::Boundary::read(doc); + let mut egress = Map::new(); + egress.insert("boundary".into(), J::String(boundary.word().into())); + egress.insert( + "allows".into(), + J::Array(boundary.roles().iter().cloned().map(J::String).collect()), + ); + root_obj.insert("egress".into(), J::Object(egress)); root_obj.insert("digest".into(), J::String(pact_doc::digest(doc))); // Each workflow's two digests (02W §7.2): what a waiting run is pinned to, // so an edit elsewhere in the workspace leaves it free to resume. diff --git a/crates/pact-cli/src/egress.rs b/crates/pact-cli/src/egress.rs index 48e93cf..7994fe7 100644 --- a/crates/pact-cli/src/egress.rs +++ b/crates/pact-cli/src/egress.rs @@ -251,6 +251,79 @@ pub fn listed(root: &Node) -> String { format!("[{}]", granted(root).join(", ")) } +/// Where this workspace draws its boundary, for whoever reads the result of a +/// check or an inventory. +/// +/// Leaving `allow-egress:` out draws no boundary, so every part may talk to +/// something outside the box (decision Q17). That is a choice an author may +/// make, and it is also what a forgotten line looks like: the two trees are the +/// same tree, and nothing said which one a reader was looking at. So it is +/// SAID, every time, in one line — and never as a warning, because a warning +/// fails the build of everybody who runs `--deny-warnings`, which would turn +/// "open unless you say otherwise" back into "write the line or fail". +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Boundary { + /// No `allow-egress:` line: no boundary is drawn, and everything may leave. + Open, + /// `allow-egress: []`: nothing may talk to anything outside the box. + Closed, + /// `allow-egress: [llm, tools]`: only the roles written may. + Listed(Vec), +} + +impl Boundary { + /// The boundary a workspace's own document draws. Asked only of a + /// workspace: `allow-egress:` is a workspace's line, and a lone agent file + /// has nowhere to write one. + pub fn read(root: &Node) -> Self { + if root.get("allow-egress").is_none() { + return Self::Open; + } + let roles = granted(root); + if roles.is_empty() { + Self::Closed + } else { + Self::Listed(roles.into_iter().map(str::to_string).collect()) + } + } + + /// The one word an inventory carries: `open`, `closed` or `listed`. + pub fn word(&self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + Self::Listed(_) => "listed", + } + } + + /// The roles that may talk outside, as written. Empty for `open` as well + /// as for `closed`: read [`Boundary::word`] to tell them apart. + pub fn roles(&self) -> &[String] { + match self { + Self::Listed(roles) => roles, + _ => &[], + } + } + + /// The boundary in one sentence, as `pact check` says it. + pub fn in_words(&self) -> String { + match self { + Self::Open => "Egress: no `allow-egress:` line is written, so this workspace draws \ + no boundary and every part of it may talk to something outside this \ + box. Write `allow-egress: []` to let nothing out." + .to_string(), + Self::Closed => "Egress: `allow-egress: []`, so nothing here may talk to anything \ + outside this box." + .to_string(), + Self::Listed(roles) => format!( + "Egress: `allow-egress: [{}]`, so only {} may talk to something outside this box.", + roles.join(", "), + if roles.len() == 1 { "that part" } else { "those parts" } + ), + } + } +} + /// Does this workspace let a binding playing `role` talk outside the box? fn admits(root: &Node, role: &str) -> bool { let listed = granted(root); diff --git a/crates/pact-cli/src/main.rs b/crates/pact-cli/src/main.rs index 38362b2..7e86d83 100644 --- a/crates/pact-cli/src/main.rs +++ b/crates/pact-cli/src/main.rs @@ -1515,13 +1515,16 @@ fn durability_matches_what_this_tree_does( )); } -fn validate( +fn validate_as( path: &Utf8PathBuf, unsafe_spec: bool, ) -> Result<( Option, Diagnostics, Vec, + // What it was validated as: `workspace`, `agent`, or `""` where it never + // got as far as being one or the other. + &'static str, )> { let (mut node, mut diags) = load(path)?; @@ -1592,6 +1595,8 @@ fn validate( pact_loader::interfaces::inherit(root); } + // What the document was validated as, for a caller that asks. + let mut validated_as: &'static str = ""; if let Some(root) = node.as_ref() { let schema = spec .knowing("models", models_known_here(node.as_ref())) @@ -1646,7 +1651,7 @@ fn validate( && holds_self_file(&folder, &AGENT_SELF_FILES) { diags.sort(); - return Ok((node, diags, substituted)); + return Ok((node, diags, substituted, "")); } // A workspace is a workspace; anything else is a single agent. See @@ -1657,6 +1662,7 @@ fn validate( } else { "agent" }; + validated_as = kind; // ...and a folder that is neither is neither, which nothing said. A // directory holding only `agent.yaml` and `instructions.md` — Eve's // documented flat layout — printed "OK — loaded cleanly (3 settings)" @@ -1675,7 +1681,7 @@ fn validate( // reader to add `description:` to a file that does not exist. if nothing_to_validate { diags.sort(); - return Ok((node, diags, substituted)); + return Ok((node, diags, substituted, kind)); } match schema.group(kind) { Some(_) => schema.validate(root, kind, &mut diags), @@ -1995,6 +2001,20 @@ fn validate( pact_loader::settings::check(root, BUILTIN_CATALOGUE, &mut diags); diags.sort(); } + Ok((node, diags, substituted, validated_as)) +} + +/// [`validate_as`], for a caller that does not ask what kind of document it +/// turned out to be. +fn validate( + path: &Utf8PathBuf, + unsafe_spec: bool, +) -> Result<( + Option, + Diagnostics, + Vec, +)> { + let (node, diags, substituted, _) = validate_as(path, unsafe_spec)?; Ok((node, diags, substituted)) } @@ -2472,7 +2492,7 @@ fn check(path: &Utf8PathBuf, quiet: bool, unsafe_spec: bool, deny_warnings: bool if let Some(root) = enclosing_workspace(path) { return check_in_context(path, &root, quiet, unsafe_spec, deny_warnings); } - let (node, diags, _) = validate(path, unsafe_spec)?; + let (node, diags, _, kind) = validate_as(path, unsafe_spec)?; if !diags.is_empty() { out_raw!("{}", diags.render()); @@ -2491,6 +2511,7 @@ fn check(path: &Utf8PathBuf, quiet: bool, unsafe_spec: bool, deny_warnings: bool (e, 0) => out!("{e} problem(s) found in {path}. Nothing was run."), (e, w) => out!("{e} problem(s) and {w} warning(s) found in {path}."), } + say_the_boundary(node.as_ref().filter(|_| kind == "workspace"), errors); } // A warning is a heuristic cross-document judgement, and what makes those @@ -2504,6 +2525,22 @@ fn check(path: &Utf8PathBuf, quiet: bool, unsafe_spec: bool, deny_warnings: bool }) } +/// Say where a workspace draws its egress boundary, after the result of a +/// check. One line on stdout, never a diagnostic: it is a fact about a tree that +/// loaded, not something wrong with it, so it fails no build and is not counted +/// as a warning ([`egress::Boundary`]). +/// +/// Said only of a tree with no problems. A tree that did not load has not said +/// anything a runtime will act on, its `allow-egress:` line included. +fn say_the_boundary(workspace: Option<&pact_doc::Node>, errors: usize) { + if errors > 0 { + return; + } + if let Some(root) = workspace { + out!("{}", egress::Boundary::read(root).in_words()); + } +} + /// The workspace `path` is an agent inside, if it is one. /// /// What makes a folder a workspace is asked of `looks_like_a_workspace_root` and @@ -2542,7 +2579,7 @@ fn check_in_context( deny_warnings: bool, ) -> Result { let here = agent.canonicalize_utf8().unwrap_or_else(|_| agent.clone()); - let (_, whole, _) = validate(root, unsafe_spec)?; + let (around, whole, _) = validate(root, unsafe_spec)?; let mut mine = Diagnostics::new(); for d in whole.items() { let inside = camino::Utf8Path::new(d.span.file.as_str()) @@ -2608,6 +2645,9 @@ fn check_in_context( (e, 0) => out!("{e} problem(s) found in {agent}. Nothing was run."), (e, w) => out!("{e} problem(s) and {w} warning(s) found in {agent}."), } + // The boundary is the workspace's, and it is the one this agent runs + // inside: an agent's own folder has nowhere to write another. + say_the_boundary(around.as_ref(), errors + elsewhere); } // `--deny-warnings` is read HERE too. It was read only on the other branch, // so the one flag whose whole job is *"treat a warning as a refusal, this diff --git a/crates/pact-cli/tests/a_check_says_where_the_boundary_is.rs b/crates/pact-cli/tests/a_check_says_where_the_boundary_is.rs new file mode 100644 index 0000000..9b5e172 --- /dev/null +++ b/crates/pact-cli/tests/a_check_says_where_the_boundary_is.rs @@ -0,0 +1,191 @@ +//! `pact check` and `pact discover` say where a workspace draws its egress +//! boundary, every time, and never as a warning. +//! +//! Leaving `allow-egress:` out draws no boundary: every part of the workspace +//! may talk to something outside the box (decision Q17, held by +//! `a_workspace_that_writes_no_boundary_binds_any_model.rs`). That is a choice +//! an author may make. It is also exactly what a forgotten line looks like, and +//! both printed `OK — loaded cleanly`: the tree that chose to be open and the +//! tree that forgot were the same output, and nobody reading either was told +//! which they had. `pact discover`, which a runtime reads to decide whether to +//! take a tree, did not carry the line at all, so an absent line and an empty +//! list — opposite answers — were both simply missing from the index. +//! +//! So it is said: one line after the result of a check, and an `egress` entry +//! on each workspace `pact discover` lists. It is **not a warning**. A team +//! that runs `--deny-warnings` fails its build on any warning, so a warning +//! here would fail every workspace that chose to be open and make each of them +//! write the line: "open unless you say otherwise" turned back into "write it +//! or fail", by the back door. The control for that is in this file. +//! +//! Each case runs the real binary over files on disk. + +use serde_json::Value; +use std::process::Command; + +fn pact() -> Command { + Command::new(env!("CARGO_BIN_EXE_pact")) +} + +/// A one-agent workspace on a model served on this machine, with the given +/// `allow-egress:` line (or none). +fn desk(name: &str, egress: Option<&str>) -> std::path::PathBuf { + let dst = std::env::temp_dir().join(format!("pact-says-boundary-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dst); + std::fs::create_dir_all(dst.join("agents/desk")).unwrap(); + let line = egress.map(|e| format!("allow-egress: {e}\n")).unwrap_or_default(); + std::fs::write( + dst.join("workspace.yaml"), + format!("name: Probe\ndescription: A probe workspace.\n{line}"), + ) + .unwrap(); + std::fs::write( + dst.join("agents/desk/agent.yaml"), + "name: Desk\ndescription: Answers.\ninstructions: Answer the question.\n", + ) + .unwrap(); + dst +} + +/// Whether it exited 0, what it printed on stdout, and what on stderr. +fn run(args: &[&str]) -> (bool, String, String) { + let out = pact().args(args).output().expect("runs"); + ( + out.status.success(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// The one workspace `pact discover` lists under `root`. +fn inventory(root: &std::path::Path) -> Value { + let (ok, out, err) = run(&["discover", root.to_str().unwrap()]); + assert!(ok, "{err}"); + let all: Value = serde_json::from_str(&out).expect("JSON"); + all.as_array() + .and_then(|a| a.first()) + .cloned() + .unwrap_or_else(|| panic!("nothing listed: {out}{err}")) +} + +#[test] +fn a_workspace_that_writes_no_line_is_told_it_drew_no_boundary() { + let dst = desk("absent", None); + let (ok, out, err) = run(&["check", dst.to_str().unwrap()]); + assert!(ok, "{out}{err}"); + assert!( + out.contains( + "Egress: no `allow-egress:` line is written, so this workspace draws no boundary \ + and every part of it may talk to something outside this box. Write \ + `allow-egress: []` to let nothing out." + ), + "{out}" + ); + let _ = std::fs::remove_dir_all(&dst); +} + +#[test] +fn saying_so_is_not_a_warning_and_fails_no_build() { + // The control. `--deny-warnings` exits 1 on any warning; a workspace that + // chose to be open has done nothing wrong, and is still told. + let dst = desk("deny", None); + let (ok, out, err) = run(&["check", dst.to_str().unwrap(), "--deny-warnings"]); + assert!(ok, "an open workspace failed `--deny-warnings`:\n{out}{err}"); + assert!(out.contains("loaded cleanly"), "{out}"); + assert!(!out.contains("warning"), "{out}"); + assert!(out.contains("Egress: no `allow-egress:` line is written"), "{out}"); + let _ = std::fs::remove_dir_all(&dst); +} + +#[test] +fn a_workspace_that_lets_nothing_out_is_told_so() { + let dst = desk("closed", Some("[]")); + let (ok, out, err) = run(&["check", dst.to_str().unwrap()]); + assert!(ok, "{out}{err}"); + assert!( + out.contains( + "Egress: `allow-egress: []`, so nothing here may talk to anything outside this box." + ), + "{out}" + ); + let _ = std::fs::remove_dir_all(&dst); +} + +#[test] +fn a_workspace_that_lists_parts_is_told_which() { + let one = desk("one", Some("[llm]")); + let (ok, out, err) = run(&["check", one.to_str().unwrap()]); + assert!(ok, "{out}{err}"); + assert!( + out.contains( + "Egress: `allow-egress: [llm]`, so only that part may talk to something outside \ + this box." + ), + "{out}" + ); + let two = desk("two", Some("[llm, tools]")); + let (ok, out, err) = run(&["check", two.to_str().unwrap()]); + assert!(ok, "{out}{err}"); + assert!( + out.contains( + "Egress: `allow-egress: [llm, tools]`, so only those parts may talk to something \ + outside this box." + ), + "{out}" + ); + let _ = std::fs::remove_dir_all(&one); + let _ = std::fs::remove_dir_all(&two); +} + +#[test] +fn an_agent_checked_on_its_own_is_told_the_boundary_of_the_workspace_around_it() { + // The boundary is the workspace's, and it is the one the agent runs inside. + let dst = desk("inside", Some("[]")); + let (ok, out, err) = run(&["check", dst.join("agents/desk").to_str().unwrap()]); + assert!(ok, "{out}{err}"); + assert!(out.contains("checked inside"), "{out}"); + assert!( + out.contains("Egress: `allow-egress: []`, so nothing here may talk to anything outside"), + "{out}" + ); + let _ = std::fs::remove_dir_all(&dst); +} + +#[test] +fn quiet_says_nothing_and_a_tree_with_problems_is_not_described() { + let dst = desk("quiet", None); + let (ok, out, _) = run(&["check", dst.to_str().unwrap(), "--quiet"]); + assert!(ok); + assert!(!out.contains("Egress:"), "{out}"); + // A tree that did not load has said nothing a runtime will act on. + std::fs::write( + dst.join("agents/desk/agent.yaml"), + "name: Desk\ndescription: Answers.\ninstructions: Answer.\nnot-a-setting: 1\n", + ) + .unwrap(); + let (ok, out, err) = run(&["check", dst.to_str().unwrap()]); + assert!(!ok, "{out}{err}"); + assert!(!out.contains("Egress:"), "{out}"); + let _ = std::fs::remove_dir_all(&dst); +} + +#[test] +fn the_inventory_carries_the_boundary_in_a_word_and_the_parts_written() { + for (name, line, word, allows) in [ + ("inv-open", None, "open", vec![]), + ("inv-closed", Some("[]"), "closed", vec![]), + ("inv-listed", Some("[llm, tools]"), "listed", vec!["llm", "tools"]), + ] { + let dst = desk(name, line); + let ws = inventory(&dst); + assert_eq!(ws["egress"]["boundary"], word, "{name}: {ws}"); + let got: Vec<&str> = ws["egress"]["allows"] + .as_array() + .expect("a list") + .iter() + .filter_map(Value::as_str) + .collect(); + assert_eq!(got, allows, "{name}"); + let _ = std::fs::remove_dir_all(&dst); + } +} From 46778a3de105739e0673857377cb3332b51b0a80 Mon Sep 17 00:00:00 2001 From: Jithin V G Date: Sun, 15 Nov 2026 00:01:42 +0530 Subject: [PATCH 2/4] How a run ends: cancelled, why a run stopped, how a called agent ended, and a mixed model list refused - `cancelled`: a run something outside it ended (a port's `if-still-running: stop` or `cancel-previous`, or the host). Between stages; `undo: yes` takes back what it wrote, newest first; `then-run:` starts what the port says. `run_workflow(..., ended=)`, `Callers.start`, `WorkflowResult.cancelled_by`. - `WorkflowResult.why`: the one word for why a `stopped` run stopped, of eight. - The reference ended the block on a no, or silence, with no exit written for it; it now stops the run. A run that calls a workflow that waits was `stopped`; it is `parked`, at its own stage. - `steps..ended` on a stage an agent answered: `ran-out` when the agent reached a limit and answered with what it had (`RanOut`), else `finished`. - `loader/a-rung-with-two-kinds-of-reader` is an error: a classifier and a language model in one `model:` list read as a cascade and are a fallback. Measured on Bud Agent Flow's corpus, 13 of 136 trees write it. - Twelve shared cases (43 workflow cases), a new tree `booked-visits`, and three new keys in every workflow case's `expect`. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 106 +++ .../src/pact_adapters/workflow_runtime.py | 343 ++++++- adapters/python/tests/conformance_kit.py | 58 +- ...un_something_outside_ended_is_cancelled.py | 277 ++++++ .../test_a_stage_says_how_its_agent_ended.py | 139 +++ ...shared_cases_are_what_this_runtime_does.py | 55 ++ ...ng_agent_is_given_what_it_decides_about.rs | 54 +- crates/pact-loader/src/deciding.rs | 52 +- site-docs/guide/programs-in-the-language.md | 87 +- site-docs/reference/cli.md | 23 + spec/schema.yaml | 8 +- tests/conformance/digests.json | 47 +- .../trees/booked-visits/visits.pact | 108 +++ .../trees/triaged-defects/defects.pact | 56 ++ tests/conformance/workflows.json | 861 +++++++++++++++++- 15 files changed, 2176 insertions(+), 98 deletions(-) create mode 100644 adapters/python/tests/test_a_run_something_outside_ended_is_cancelled.py create mode 100644 adapters/python/tests/test_a_stage_says_how_its_agent_ended.py create mode 100644 tests/conformance/trees/booked-visits/visits.pact diff --git a/CHANGELOG.md b/CHANGELOG.md index 92905a4..61f62ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,112 @@ What changed in PACT, newest first. Each entry says what an author can now write, or what now holds that did not; the commits are listed under it for whoever wants the detail. +## 2026-11 — how a run ends, the egress boundary said out loud, and two things that read as one thing and did another (15 November) + +Four rulings, each made with Bud Agent Flow's engine and corpus in front of it. + +### A run something outside it ended is `cancelled` + +A workflow's run ended `final`, `parked` or `stopped`. A run that a port ended (an arrival with +the run's key at a port that says `if-still-running: stop` or `cancel-previous`), or that a person +at the host ended, had no word and was reported `stopped`, with the runs that failed. + +- **`cancelled`**, the fourth ending. `WorkflowResult.cancelled_by` says what ended it + (`{port: }`, or `{by: }` for a host). It hands back no answer. +- **It ends between stages.** A stage that has begun finishes, so what it wrote is on the record; + the next does not begin, is not in the trace, and nobody it would ask is asked. Once ended, no + stage begins anywhere in the run (an item of an `each` still queued does not start). +- **The port's own lines say what else happens.** `undo: yes`, beside `stop` or `cancel-previous`, + takes back what the run wrote, newest first, by each action's `undone-by:`; a write that could + not be taken back is said in `said` and `unenforced`, and the run is cancelled all the same. + `then-run:`, beside `stop`, starts a workflow afterwards and does not wait for it, with the ended + run's own input, as much of it as that workflow `accepts:` (`Callers.start`; with none, said). +- **Never a branch failing.** No `if-it-fails:` plan and no `if-someone-fails: carry-on` takes it. +- `run_workflow(..., ended=)` is how a host says so: asked before each stage begins. + +### `stopped` says why, in one word + +`halted: stopped` covered a person's no, a decision nobody could make, a server that was down and +a ceiling that was reached, told apart only by `said`, which is prose. `WorkflowResult.why` is one +of eight words (`workflow_runtime.STOPPED_BECAUSE`): `said-so`, `declined`, `nobody-answered`, +`nobody-decided`, `a-call-failed`, `reached-a-limit`, `does-not-fit`, `not-runnable`. Empty for +any other ending. + +### What was wrong in the reference + +- **A no, or silence, with no exit written for it ended the block instead of stopping the run.** + `outcome.declined` says "leave it out and the run stops and says so: a no is never quietly + carried past". A workflow that gives nothing finished `final` after a person had said no, and one + that gives something was told its answer "does not fit". It now stops: `why: declined`, or + `nobody-answered`. +- **A run that calls a workflow that waits for a person was `stopped`** ("the workflow 'x' + parked"). It is `parked`: `parked-at` is the caller's own stage, where a host resumes it, and + `question` is the called run's. Who the called run asked is on the caller's record too. + +### A stage says how its agent ended + +An agent that reaches a limit with `when-it-runs-out: answer-with-what-it-has` gives an answer, the +stage takes it and the workflow goes on. Nothing let the workflow tell that answer from a finished +one. + +- **`steps..ended`** is `ran-out` for such an answer and `finished` for any other, on a + `call` of an agent and on a `decide` an agent decided (its label counts either way). The word a + `repeat` has, with the same `ran-out`. A stage that calls a tool or a workflow has none. +- **A word, not an exit under `then:`.** The stage did answer, so `answered` stays the true name of + how it ended; a word can be shown to a person and handed back, which an exit cannot; and no + workflow that exists changes. Branching on it is a `decide` with a written rule. +- A host hands such an answer back as `workflow_runtime.RanOut(answer)`. + +### A classifier and a language model in one `model:` list are refused on a rung + +`loader/a-rung-with-two-kinds-of-reader` was a warning. It is an error. + +- **What it reads as, and what it is.** "The classifier, then the language model for what it is + unsure of." A `model:` list names fallbacks for a call that fails; unsure passes to the next + rung. So the language model would be asked only when the classifier's server was down. +- **Why a warning was not enough.** Measured on Bud Agent Flow's corpus: 13 of 136 trees write it, + each meaning the cascade. A warning loads, and the same file then ran two ways: the framework + under that engine treats an unsure classifier as a failed call and goes on down the list; + PACT's reading does not. +- **Why not give the list that meaning.** One agent would be two readers: `instructions:` and + `not-sure` apply to the language model only, `if-unsure-below:` to the classifier only, and the + record could not say which decided. The ladder already is the cascade: two agents on two rungs, + each with its own threshold and second-opinion line, and `.answered-by` names which decided. +- Two models of one kind in a list are fallbacks as before. + +### `pact check` and `pact discover` say where the boundary is + +Leaving `allow-egress:` out draws no boundary (Q17). That is a choice, and it is also what a +forgotten line looks like: both printed `OK — loaded cleanly`. + +- **`pact check`** prints one line after a check that finds no problems: no line written (every + part may talk outside), `[]` (nothing may), or the parts listed. It is on stdout and is not a + diagnostic: `--deny-warnings` is unaffected, because a warning would fail every workspace that + chose to be open. `--quiet` leaves it out. An agent checked on its own is told the boundary of + the workspace around it. +- **`pact discover`** carries `egress: {boundary: open | closed | listed, allows: [...]}` on each + workspace. + +### What a runtime must match + +- Twelve new shared cases (`tests/conformance/workflows.json`, 43 in all), seven on + `trees/triaged-defects` and five on a new tree, `trees/booked-visits`. Every workflow case's + `expect` has three new keys: `why`, `cancelled-by`, `started`. +- A case may now say `ran-out` (agents that answered on reaching a limit), `ends` (`{before, + port}`: an arrival that ends the run as it is about to begin that stage) and `"nobody"` in place + of a person's answer (the question's time passed). +- `tests/conformance/digests.json`: `triaged-defects` moves (the tree was edited: three workflows + and a question added; every existing `contract-digest` is as it was), and `booked-visits` is new. + +### Still owed + +- A `decide` whose rung's agent cannot be reached: the reference lets the failure out of the run. +- `pact check` does not hold a read of `.ended`, `.rounds` or `.answered-by` to a stage that has + one, so a rule reading `.ended` of a stage that calls a tool decides nothing. +- A cancelled run that was parked, and what a called run's writes are to its caller's undo: ruled + for a host (the wait closes; a later answer is refused), with no case, because the reference + runtime does not resume. + ## 2026-11 — law 8 at the moment of instantiation: a helper, a skill and a snippet are checked as documents (13 November) Law 8 says anything a run creates passes the same checker before it can be asked, and names a diff --git a/adapters/python/src/pact_adapters/workflow_runtime.py b/adapters/python/src/pact_adapters/workflow_runtime.py index d2d3103..ee31946 100644 --- a/adapters/python/src/pact_adapters/workflow_runtime.py +++ b/adapters/python/src/pact_adapters/workflow_runtime.py @@ -29,6 +29,19 @@ `done` ends the block it is in, `stop-and-say-so` ends the run. A stage with no line for its outcome ends its block (inside an `each`, a `repeat` or a `together`, that is the end of one item, round or branch). + +**How a run ends.** In one of four words (`WorkflowResult.halted`): + +* `final` — it reached an `answer`, or the end of its stages. +* `parked` — it waits for a person, here or in a workflow it called and waits + for. +* `stopped` — its OWN stages or rules ended it, and `WorkflowResult.why` says + which of eight things happened (`STOPPED_BECAUSE`), so "a person said no" + is never read as "a call failed". +* `cancelled` — something OUTSIDE it ended it: a port whose arrival has this + run's key and says `if-still-running: stop` or `cancel-previous`, or the + host. It was `stopped`, which made a run a customer withdrew look like one + that broke. """ from __future__ import annotations @@ -53,26 +66,92 @@ CallProgram = Callable[[str, dict[str, Any]], Any] #: `person(question, shown) -> answer`, or raise `Parked` to wait. AskPerson = Callable[[str, dict[str, Any]], Any] +#: `start(workflow, inputs)` — begin a run nobody waits for (a port's +#: `then-run:`). Which run that is, and when it goes, is the host's. +StartRun = Callable[[str, dict[str, Any]], Any] +#: `ended(at) -> None | {"port": }` — asked before each stage begins, +#: with the stage about to run as `trace` writes it: whether something outside +#: the run has ended it. A port is named and its own lines are read from the +#: document (`undo:`, `then-run:`); a host that ends a run itself answers +#: `{"by": , "undo": }`. +EndedBy = Callable[[str], "Mapping[str, Any] | None"] DONE = "done" STOP = "stop-and-say-so" class Parked(Exception): - """A person (or the clock) has not answered yet: the run waits here.""" + """A person (or the clock) has not answered yet: the run waits here. + + `inside` is where the wait is when it is in a workflow this run called and + waits for: `/`. `stage` is always a stage of THIS + run, the one a host resumes it at.""" - def __init__(self, question: str, stage: str) -> None: - super().__init__(f"waiting on {question!r} at {stage!r}") + def __init__(self, question: str, stage: str, inside: str = "") -> None: + super().__init__(question, stage, inside) self.question = question self.stage = stage + self.inside = inside + + def __str__(self) -> str: + said = f"waiting on {self.question!r} at {self.stage!r}" + return f"{said} (asked inside {self.inside!r})" if self.inside else said class NobodyAnswered(Exception): """The question's time ran out with no answer.""" +#: Why a run that `stopped` did, in one word (`WorkflowResult.why`). One word +#: for the ending and words for the reason was all there was: `halted: +#: stopped` covered a person's no, a decision nobody could make and a server +#: that was down, and only `said`, which is prose, told them apart. +STOPPED_BECAUSE = ( + "said-so", # it reached `stop-and-say-so`, an exit its author wrote + "declined", # a person said no, and the stage wrote no `declined:` exit + "nobody-answered", # a wait ran out, and the stage wrote no `nobody-answered:` exit + "nobody-decided", # a `decide` ran out of rungs + "a-call-failed", # a call failed and `if-it-fails:` did not cover it + "reached-a-limit", # a ceiling was reached whose `when-it-runs-out:` stops + "does-not-fit", # what it would hand back is not what it says it gives + "not-runnable", # this runtime cannot carry out what is written +) + + class Stopped(Exception): - """The run stopped and said why (`stop-and-say-so`, or a failure with no plan).""" + """The run's own stages ended it, and said why. `because` is one of + `STOPPED_BECAUSE`.""" + + def __init__(self, said: str, because: str = "not-runnable") -> None: + super().__init__(said) + assert because in STOPPED_BECAUSE, because + self.because = because + + +class Cancelled(Exception): + """Something outside the run ended it (`EndedBy`). Never a failure of the + stage it arrived at: no `if-it-fails:` plan and no `if-someone-fails: + carry-on` takes it.""" + + def __init__(self, by: Mapping[str, Any]) -> None: + super().__init__(f"cancelled by {dict(by)}") + self.by = dict(by) + + +@dataclass(frozen=True) +class RanOut: + """An agent's answer, given because it reached one of its limits and its + `when-it-runs-out:` says `answer-with-what-it-has`. + + It IS an answer: the stage that called the agent takes it and goes on as + it would with any other (`answered`). What changes is the record: + `steps..ended` is `ran-out` where it is otherwise `finished`, so a + later `decide` can send a part-done answer to a person, a question can + show somebody that it is one, and the workflow can hand the fact back. A + host that runs agents (`Callers.agent`) hands back the answer wrapped in + this when the run says a ceiling ended it (`RunResult.stopped_by`).""" + + answer: Mapping[str, Any] @dataclass @@ -92,6 +171,7 @@ class Callers: agent: CallAgent | None = None program: CallProgram | None = None person: AskPerson | None = None + start: StartRun | None = None @dataclass @@ -119,9 +199,19 @@ class Written: class WorkflowResult: """How a workflow run ended, and what it handed back.""" - #: `final` (an `answer` stage, or the end), `stopped` (stop-and-say-so, or a - #: failure nothing planned for), `parked` (a person has not answered). + #: `final` (an `answer` stage, or the end), `parked` (a person has not + #: answered), `stopped` (its own stages ended it: `why` says which of + #: `STOPPED_BECAUSE`), `cancelled` (something outside it did: + #: `cancelled_by` says what). halted: str = "final" + #: Why a `stopped` run stopped, one of `STOPPED_BECAUSE`. Empty otherwise. + why: str = "" + #: What ended a `cancelled` run: `{"port": }`, or `{"by": }` + #: for a host. Empty otherwise. + cancelled_by: dict[str, Any] = field(default_factory=dict) + #: Runs this one started that nobody waits for (a port's `then-run:` after + #: it was cancelled), in order: `{workflow, given}`. + started: list[dict[str, Any]] = field(default_factory=list) answer: dict[str, Any] = field(default_factory=dict) #: Every stage's answer, by name, as `steps.` reads it. steps: dict[str, Any] = field(default_factory=dict) @@ -166,6 +256,11 @@ async def _maybe(value: Any) -> Any: return await value if inspect.isawaitable(value) else value +def _yes(written: Any) -> bool: + """A `yes-no` line, as a document read from YAML or handed in holds it.""" + return written is True or str(written).strip().lower() in ("yes", "true") + + @dataclass class _Scope: """What a binding reads: the run's input, every stage's answer so far, the @@ -232,9 +327,13 @@ def __init__( callers: Callers, document: Mapping[str, Any] | None, result: WorkflowResult, + ended: EndedBy | None = None, ) -> None: self.spec = spec self.callers = callers + self.ended = ended + #: What ended the run, from the first moment the host said so. + self.ended_already: Mapping[str, Any] | None = None self.document = document or {} self.result = result self.agents = set((self.document.get("agents") or {}).keys()) @@ -266,14 +365,24 @@ async def block( last: dict[str, Any] | None = None while at and at != DONE: if at == STOP: - raise Stopped(f"the run reached `stop-and-say-so` in {where or 'the workflow'}") + raise Stopped( + f"the run reached `stop-and-say-so` in {where or 'the workflow'}", "said-so" + ) stage = by_name.get(at) if stage is None: raise Stopped(f"{where or 'the workflow'} has no stage {at!r}") ran += 1 if ran > 10_000: # the checker refuses circles outside a repeat (WF-8) raise Stopped(f"{where} went round more than 10000 stages") - self.result.trace.append(f"{where}/{stage.name}".lstrip("/")) + here = f"{where}/{stage.name}".lstrip("/") + # Something outside may have ended the run. It is asked BETWEEN + # stages: a stage that has begun finishes, so what it wrote is on + # the record and can be undone, and the one about to begin does + # not (it is not in the trace, and nobody it would ask is asked). + by = self.ended_by(here) + if by: + raise Cancelled(by) + self.result.trace.append(here) outcome, answer = await self.stage(stage, scope, where) if stage.does == "answer": if not where: @@ -291,6 +400,24 @@ async def block( nxt = stage.then.get(outcome) if nxt is None and outcome == "too-many-times": nxt = stage.then.get("answered") + # A no, or silence, with no exit written for it stops the run and + # says so (`outcome.declined`, `outcome.nobody-answered`: "a no is + # never quietly carried past"). This ended the block instead, as + # an unwritten `answered:` does: a workflow that gives nothing + # then finished `final` after a person had said no, and one that + # gives something was told its answer "does not fit". + if nxt is None and outcome == "declined": + raise Stopped( + f"{stage.name!r} asked {stage.asks!r} and the answer was no; the stage " + f"writes no `declined:` exit, so the run stops here", + "declined", + ) + if nxt is None and outcome == "nobody-answered": + raise Stopped( + f"{stage.name!r} asked {stage.asks!r} and nobody answered in time; the " + f"stage writes no `nobody-answered:` exit, so the run stops here", + "nobody-answered", + ) if isinstance(nxt, Mapping): # `heard:` — port to stage nxt = next(iter(nxt.values()), None) if stage.does == "decide": @@ -298,6 +425,22 @@ async def block( at = str(nxt).strip() if nxt is not None else "" return last + def ended_by(self, here: str) -> Mapping[str, Any] | None: + """What has ended this run from outside, if anything has, asked as the + stage `here` is about to begin. + + Once the host has said so, it stays so: no stage begins anywhere in + the run after that, whatever the host answers when asked again. Items + of an `each` and branches of a `together` run side by side, and the + ones still queued ask after the one that was told; a host that + answers for a moment and not for a run would otherwise have had one + item stopped and the next one started.""" + if self.ended_already is None and self.ended is not None: + by = self.ended(here) + if by: + self.ended_already = dict(by) + return self.ended_already + # ───────────────────────────────────────────────────────────── stages async def stage(self, stage: StageSpec, scope: _Scope, where: str) -> tuple[str, Any]: @@ -359,8 +502,17 @@ async def invoke(self, name: str, inputs: dict[str, Any]) -> Any: if name in self.workflows: inner = WorkflowSpec.from_workflow(self.document, name) out = await run_workflow(inner, inputs, self.callers, document=self.document) + # Everybody it asked was asked, and this run's record says so. + self.result.asked.extend(out.asked) + self.result.refused.extend(out.refused) + if out.halted == "parked": + # A run that waits for a workflow that waits for a person is + # WAITING. It was `stopped`: "the workflow 'x' parked", read + # by every caller as a failure. `call` names its own stage as + # where this run waits; the question is the called run's. + raise Parked(out.question, "", inside=f"{name}/{out.parked_at}") if out.halted != "final": - raise Stopped(f"the workflow {name!r} {out.halted}: {out.said}") + raise Stopped(f"the workflow {name!r} {out.halted}: {out.said}", "a-call-failed") return out.answer if name in self.programs: if self.callers.program is None: @@ -384,17 +536,47 @@ async def call(self, stage: StageSpec, scope: _Scope, where: str) -> tuple[str, for _ in range(attempts): try: answer = await self.invoke(name, inputs) - except (Stopped, Parked): + except Parked as parked: + if not parked.stage: # the wait is in a workflow this stage called + parked.stage = stage.name + raise + except (Stopped, Cancelled): raise except Exception as err: # a call that failed is data for the plan failure = err continue + answer = self.how_it_ended(stage, name, answer) undo = stage.undone_by or self._action_undo(name) if undo: self.result.written.append(Written(stage.name, undo, inputs, answer)) return "answered", _as_answer(answer) return await self.failed(stage, scope, name, inputs, failure, where) + def is_agent(self, name: str) -> bool: + """Whether `invoke` hands `name` to `Callers.agent`.""" + return ( + "/" not in name + and name not in self.workflows + and name not in self.programs + and (name in self.agents or self.callers.agent is not None) + ) + + def how_it_ended(self, stage: StageSpec, name: str, answer: Any) -> Any: + """A called agent's answer, unwrapped, and `steps..ended` set + for it: `ran-out` when the agent reached a limit and answered with + what it had (`RanOut`), `finished` otherwise. A stage that calls + anything else has no `.ended`: only an agent has limits of its own to + run out of. The same two-valued word a `repeat` has, and the same + `ran-out`.""" + ran_out = isinstance(answer, RanOut) + if ran_out: + answer = answer.answer + if self.is_agent(name): + self.stage_words.setdefault(id(stage), {})["ended"] = ( + "ran-out" if ran_out else "finished" + ) + return answer + def _action_undo(self, name: str) -> str: if "/" not in name: return "" @@ -414,14 +596,17 @@ async def failed( plan = stage.if_it_fails why = f"{stage.name!r} failed calling {name!r}: {failure}" if plan is None or plan.after_that == "stop-and-say-so": - raise Stopped(why) + raise Stopped(why, "a-call-failed") if plan.after_that == "use-a-backup": try: - return "answered", _as_answer(await self.invoke(plan.backup, inputs)) - except (Stopped, Parked): + backup = await self.invoke(plan.backup, inputs) + return "answered", _as_answer(self.how_it_ended(stage, plan.backup, backup)) + except (Stopped, Parked, Cancelled): raise except Exception as err: - raise Stopped(f"{why}; the backup {plan.backup!r} failed too: {err}") from None + raise Stopped( + f"{why}; the backup {plan.backup!r} failed too: {err}", "a-call-failed" + ) from None if plan.after_that == "carry-on": return "answered", {k: (scope.read(v)) for k, v in plan.carry_on_with.items()} if plan.after_that == "ask-a-person": @@ -429,8 +614,13 @@ async def failed( return "answered", answer if plan.after_that == "undo": await self.undo(plan.undo) - raise Stopped(f"{why}; what {plan.undo or 'the run'!r} did was undone, newest first") - raise Stopped(f"{why}; `{plan.after_that}` needs the host (a new version, a person)") + raise Stopped( + f"{why}; what {plan.undo or 'the run'!r} did was undone, newest first", + "a-call-failed", + ) + raise Stopped( + f"{why}; `{plan.after_that}` needs the host (a new version, a person)", "a-call-failed" + ) async def undo(self, stage_name: str = "") -> list[str]: """Take back what was written, newest first: everything, or one stage's.""" @@ -445,6 +635,83 @@ async def undo(self, stage_name: str = "") -> list[str]: ] return undone + async def cancelled(self, by: Mapping[str, Any], inputs: dict[str, Any]) -> None: + """End the run because something outside it did (`EndedBy`). + + **`cancelled`, and never `stopped`.** `stopped` is the run's own + stages ending it. A run a customer withdrew, or that a newer arrival + replaced, did nothing wrong and reached no exit of its own; counted + with the runs that failed, it makes a quiet week look like a bad one. + + **Named by a port, its lines are the port's** (`ports.`): + `undo: yes` takes back what the run wrote, newest first, before it + ends (beside `stop` and beside `cancel-previous`); `then-run:`, a + `stop` port's line, starts a workflow afterwards, not waited for, + with the cancelled run's own input, as much of it as that workflow + `accepts:`. A host that ends a run itself says both in `by`. + + **It hands back nothing.** Whatever its stages answered is in `steps`; + a cancelled run has no answer, as a stopped one has none. + + A write that could not be undone is SAID, in `said` and in + `unenforced`: the run is cancelled all the same, and somebody now has + to take that write back by hand.""" + port = str(by.get("port") or "").strip() + lines: Mapping[str, Any] = by + who = str(by.get("by") or "the host").strip() + if port: + lines = (self.document.get("ports") or {}).get(port) or {} + who = f"the port `{port}`" + self.result.halted = "cancelled" + self.result.cancelled_by = {"port": port} if port else {"by": who} + self.result.answer = {} + said = f"Cancelled by {who}." + if _yes(lines.get("undo")): + undone: list[str] = [] + left: list[str] = [] + for w in reversed(self.result.written): + try: + await self.invoke(w.undo, dict(w.inputs)) + undone.append(w.stage) + except Exception as err: # the run is cancelled all the same + left.append(f"{w.stage} ({w.undo}: {err})") + self.result.written = [] + if undone: + said += f" What it did was undone, newest first: {', '.join(undone)}." + elif not left: + said += " It had done nothing that needed undoing." + if left: + not_undone = f"Could not be undone: {'; '.join(left)}." + said += f" {not_undone}" + self.result.unenforced.append( + f"undo: {who} asked for what this run did to be undone, and some of it " + f"was not. {not_undone} fix: take those back by hand." + ) + then = str(lines.get("then-run") or "").strip() + # `then-run:` is a `stop` port's line (`port.then-run`): what follows a + # `cancel-previous` is the arrival's own run, which the host starts. + if then and port and str(lines.get("if-still-running") or "").strip() != "stop": + self.result.unenforced.append( + f"then-run: the port `{port}` names `{then}` to start after it ends a run, and " + f"that line is read only beside `if-still-running: stop`, so nothing was " + f"started. fix: write `if-still-running: stop`, or take `then-run:` off." + ) + then = "" + if then: + takes = ((self.document.get("workflows") or {}).get(then) or {}).get("accepts") + given = {k: v for k, v in inputs.items() if takes is None or k in takes} + self.result.started.append({"workflow": then, "given": given}) + if self.callers.start is None: + self.result.unenforced.append( + f"then-run: `{then}` was to start after {who} ended this run, and nothing " + f"here can start a run (no `Callers.start`). fix: run it on a host that " + f"starts runs, or start `{then}` yourself with what is under `started`." + ) + else: + await _maybe(self.callers.start(then, dict(given))) + said += f" `{then}` was started after it." + self.result.said = said + async def decide(self, stage: StageSpec, scope: _Scope) -> tuple[str, Any]: """Try the rungs in order and hand back the label that stands at the end, with who chose it (`steps..label`, `.decided-by`, @@ -471,6 +738,8 @@ async def decide(self, stage: StageSpec, scope: _Scope) -> tuple[str, Any]: """ labels = tuple(stage.chooses_between) standing: dict[str, Any] | None = None + #: How the agent whose label stands ended (`.ended`), when one does. + ended = "" for rung in stage.by: if rung.rules: if standing is not None: @@ -488,7 +757,11 @@ async def decide(self, stage: StageSpec, scope: _Scope) -> tuple[str, Any]: if standing is not None and not second: continue given = self.given_to(rung.agent, scope, stage.says, stage.bind) - answer = _as_answer(await self.invoke(rung.agent, given)) + said = await self.invoke(rung.agent, given) + # An answer given on running out is an answer here as it is + # everywhere: its label counts. The record says it ran out. + ran_out = isinstance(said, RanOut) + answer = _as_answer(said.answer if ran_out else said) label = _one_of(answer, labels) if label and rung.if_unsure_below is not None: score = _score(answer) @@ -501,6 +774,7 @@ async def decide(self, stage: StageSpec, scope: _Scope) -> tuple[str, Any]: continue if label: standing = {"label": label, "decided-by": "agent", "answered-by": rung.agent} + ended = "ran-out" if ran_out else "finished" continue if rung.asks: if standing is not None: @@ -516,8 +790,13 @@ async def decide(self, stage: StageSpec, scope: _Scope) -> tuple[str, Any]: if who: standing[ANSWERED_BY] = who if standing is not None: + if standing["decided-by"] == "agent" and ended: + self.stage_words.setdefault(id(stage), {})["ended"] = ended return standing["label"], standing - raise Stopped(f"{stage.name!r} ran out of ways to decide between {', '.join(labels)}") + raise Stopped( + f"{stage.name!r} ran out of ways to decide between {', '.join(labels)}", + "nobody-decided", + ) def given_to( self, @@ -769,7 +1048,8 @@ async def each(self, stage: StageSpec, scope: _Scope, where: str) -> dict[str, A if ceiling is not None and len(items) > ceiling: raise Stopped( f"{stage.name!r} was given {len(items)} items and may take {ceiling} " - f"(`items-at-most:`)" + f"(`items-at-most:`)", + "reached-a-limit", ) if stage.identified_by: seen: set[tuple[Any, ...]] = set() @@ -831,7 +1111,8 @@ def key(it: Any) -> Any: continue if ceiling is not None and len(taken) >= ceiling: raise Stopped( - f"{stage.name!r} has taken {ceiling} items, all its `items-at-most:` allows" + f"{stage.name!r} has taken {ceiling} items, all its `items-at-most:` allows", + "reached-a-limit", ) taken.add(key(it)) running.add(asyncio.ensure_future(one(it))) @@ -866,11 +1147,17 @@ async def branches(self, stage: StageSpec, work: list[Awaitable[dict[str, Any]]] continue err = t.exception() if err is not None: + # The RUN was ended, not this branch: no `carry-on` + # carries on past it. + if isinstance(err, Cancelled): + raise err if isinstance(err, (Stopped, Parked)) and on_failure != "carry-on": raise err got[i] = {"failed": str(err)} if on_failure == "stop-the-others": - raise Stopped(f"a branch of {stage.name!r} failed: {err}") + raise Stopped( + f"a branch of {stage.name!r} failed: {err}", "a-call-failed" + ) continue got[i] = t.result() good += 1 @@ -968,18 +1255,23 @@ async def run_workflow( *, document: Mapping[str, Any] | None = None, remembered: Mapping[str, Any] | None = None, + ended: EndedBy | None = None, ) -> WorkflowResult: """Run `spec` with `inputs`, reaching what it calls through `callers`. `remembered` is what the workspace and the workflow already remember, by name — the host's store, as `harness.run`'s `remembered=` is. A stage reads - it as `remembers.`; a `repeat` adds its own round's memory on top.""" + it as `remembers.`; a `repeat` adds its own round's memory on top. + + `ended` is how the host says something outside the run has ended it + (`EndedBy`): a port's arrival, or a person at a console. The run is then + `cancelled` (`_Run.cancelled`).""" result = WorkflowResult() values = {name: v.value for name, v in spec.values.items()} scope = _Scope( input=dict(inputs), steps={}, remembers=dict(remembered or {}), values=values ) - run = _Run(spec, callers, document, result) + run = _Run(spec, callers, document, result, ended) # The workspace's own `detectors:`, for `looks-like:` (docs/98 WP-12). held = DECLARED.set(declared_in(document or {})) try: @@ -992,8 +1284,12 @@ async def run_workflow( return result except Stopped as stopped: result.halted = "stopped" + result.why = stopped.because result.said = str(stopped) return result + except Cancelled as cancelled: + await run.cancelled(cancelled.by, dict(inputs)) + return result finally: DECLARED.reset(held) # A workflow hands back what an `answer` stage bound, and one that @@ -1007,6 +1303,7 @@ async def run_workflow( wrong = _does_not_fit(spec, result.answer) if wrong: result.halted = "stopped" + result.why = "does-not-fit" result.said = f"the answer does not fit `answers-with:` — {wrong}" # And it is not handed back. A value that fails the workflow's own # contract, returned beside the word `stopped`, is a value a caller can diff --git a/adapters/python/tests/conformance_kit.py b/adapters/python/tests/conformance_kit.py index c4cbf6c..c5e8817 100644 --- a/adapters/python/tests/conformance_kit.py +++ b/adapters/python/tests/conformance_kit.py @@ -41,6 +41,29 @@ `"no"`, given to every question of that park. `"nobody"` means no answer is given and the time the question allows (`answer-within:`) passes: the run is resumed with no answer, at a moment after the wait has run out. + +A workflow's case has three more things it may say: + +* **`people`**, by question: an answer, or a list of answers in the order they + arrive, the last repeating. The text `"nobody"` in place of an answer means + the question's time passed with no answer. `null` for the whole of `people` + means nobody is there to ask, so the run parks at its first question. +* **`ran-out`** — the agents that reached one of their limits in this run and + answered with what they had (`when-it-runs-out: answer-with-what-it-has`). + What each says is still under `agents`; it is the answer of a run that ran + out, and `steps..ended` of the stage that called it is `ran-out`. +* **`ends`** — something outside the run ends it: `{"before": , "port": + }`. The arrival at that port, carrying this run's key, lands when the + run is about to begin that stage (written as `trace` writes it), so every + stage before it has finished and that one never starts. What the arrival + does is the port's own lines, read from the tree: `undo:`, `then-run:`. + +In a workflow's `expect`: **`why`** is the one word for why a `stopped` run +stopped (`workflow_runtime.STOPPED_BECAUSE`), `""` for any other ending; +**`cancelled-by`** is what ended a `cancelled` run, `{}` otherwise; and +**`started`** is every run it started that nobody waits for, in order, with +what each was given. An undo is a call like any other and is in `called`, +in the order it was made. """ from __future__ import annotations @@ -54,7 +77,7 @@ from pact_adapters.ir import AgentSpec, WorkflowSpec from pact_adapters.script import Script, Turn from pact_adapters.transports.mock import ReferenceTransport -from pact_adapters.workflow_runtime import Callers, run_workflow +from pact_adapters.workflow_runtime import Callers, NobodyAnswered, RanOut, run_workflow from trees import shown REPO = Path(__file__).resolve().parents[3] @@ -197,7 +220,9 @@ def agent(name: str, inputs: dict[str, Any]) -> dict[str, Any]: if isinstance(says, list): # one answer for each time it is asked, the last repeating asked = sum(1 for g in given if g["agent"] == name) says = says[min(asked, len(says)) - 1] - return dict(says) + # An agent the case says ran out: its answer is one given on reaching + # a limit, which is how a host that runs agents hands it back. + return RanOut(dict(says)) if name in (case.get("ran-out") or ()) else dict(says) def tool(name: str, action: str, inputs: dict[str, Any]) -> dict[str, Any]: called.append({"tool": f"{name}/{action}", "given": dict(inputs)}) @@ -208,10 +233,32 @@ def person(question: str, shown_: dict[str, Any]) -> dict[str, Any]: says = (case.get("people") or {}).get(question, {}) if isinstance(says, list): # the answers as they arrive, the last repeating says = says[min(handed[question], len(says)) - 1] + if says == "nobody": # the question's time passed with no answer + raise NobodyAnswered(question) return dict(says) - callers = Callers(agent=agent, tool=tool, person=person if case.get("people") is not None else None) - out = asyncio.run(run_workflow(spec, case["input"], callers, document=doc)) + started: list[dict[str, Any]] = [] + + def start(workflow: str, inputs: dict[str, Any]) -> None: + # Starting a run nobody waits for is the host's. A case records that + # it was asked for, and with what. + started.append({"workflow": workflow, "given": dict(inputs)}) + + ends = case.get("ends") or {} + + def ended(at: str) -> dict[str, Any] | None: + return {"port": ends["port"]} if ends and at == ends["before"] else None + + callers = Callers( + agent=agent, + tool=tool, + person=person if case.get("people") is not None else None, + start=start, + ) + out = asyncio.run( + run_workflow(spec, case["input"], callers, document=doc, ended=ended if ends else None) + ) + assert started == [dict(s) for s in out.started], (started, out.started) return { "trace": list(out.trace), "given": given, @@ -220,6 +267,9 @@ def person(question: str, shown_: dict[str, Any]) -> dict[str, Any]: "called": called, "answer": json.loads(json.dumps(out.answer, default=str)), "halted": out.halted, + "why": out.why, + "cancelled-by": dict(out.cancelled_by), + "started": list(out.started), "parked-at": out.parked_at, "question": out.question, "said": out.said, diff --git a/adapters/python/tests/test_a_run_something_outside_ended_is_cancelled.py b/adapters/python/tests/test_a_run_something_outside_ended_is_cancelled.py new file mode 100644 index 0000000..a9b5b5f --- /dev/null +++ b/adapters/python/tests/test_a_run_something_outside_ended_is_cancelled.py @@ -0,0 +1,277 @@ +"""How a workflow's run ends, in a word nobody has to read prose to tell apart. + +A run ended in one of three words: `final`, `parked`, `stopped`. Two things +were missing, and both made a dashboard lie. + +**A run something OUTSIDE it ended had no word.** A port whose arrival has the +run's key and says `if-still-running: stop` or `cancel-previous`, or a person +at the host's console, ends a run that did nothing wrong and reached no exit of +its own. Counted as `stopped`, a week of customers withdrawing their tickets +reads as a week of failures. It is `cancelled`. + +**`stopped` was one word for eight things.** A person's no, a decision nobody +could make, a server that was down and a ceiling that was reached all ended +`stopped`, told apart only by `said`, which is prose. `why` is the one word. + +And the reference runtime got one of them wrong: a no, or silence, with no +exit written for it ENDED THE BLOCK instead of stopping the run +(`outcome.declined`: "a no is never quietly carried past"). A workflow that +gives nothing then finished `final` after a person had said no. + +The shared cases (`tests/conformance/workflows.json`, on +`trees/booked-visits`) hold what another runtime can replay. These hold the +rest: a host ending a run, an undo that fails, an ending inside a loop that +carries on past failures, and each reason. +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import Any + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from pact_adapters.ir import WorkflowSpec # noqa: E402 +from pact_adapters.workflow_runtime import ( # noqa: E402 + STOPPED_BECAUSE, + Callers, + NobodyAnswered, + Stopped, + run_workflow, +) +from trees import shown # noqa: E402 + +REPO = Path(__file__).resolve().parents[3] +VISITS = shown(REPO / "tests/conformance/trees/booked-visits") +DEFECTS = shown(REPO / "tests/conformance/trees/triaged-defects") +VISIT = {"visit": "V-204", "customer": "Ms Okafor"} + + +class Office: + """The host's side of `booked-visits`: every call made, in order.""" + + def __init__(self, broken: str = "") -> None: + self.calls: list[str] = [] + self.started: list[tuple[str, dict[str, Any]]] = [] + self.broken = broken + + def tool(self, tool: str, action: str, inputs: dict[str, Any]) -> dict[str, Any]: + self.calls.append(f"{tool}/{action}") + if f"{tool}/{action}" == self.broken: + raise RuntimeError("the calendar is down") + return {} + + def start(self, workflow: str, inputs: dict[str, Any]) -> None: + self.started.append((workflow, dict(inputs))) + + +def book(office: Office, ended=None, start: bool = True, person=None): + callers = Callers( + tool=office.tool, + person=person or (lambda q, s: {"confirmed": True}), + start=office.start if start else None, + ) + spec = WorkflowSpec.from_workflow(VISITS, "book-visit") + return asyncio.run(run_workflow(spec, VISIT, callers, document=VISITS, ended=ended)) + + +def at(stage: str, by: dict[str, Any]): + return lambda here: dict(by) if here == stage else None + + +# ── cancelled ──────────────────────────────────────────────────────────────── + + +def test_a_run_a_port_ends_is_cancelled_and_never_stopped() -> None: + office = Office() + out = book(office, at("confirm", {"port": "visit-called-off"})) + assert out.halted == "cancelled" and out.why == "" + assert out.cancelled_by == {"port": "visit-called-off"} + # The stage it arrived at never began: nobody was asked. + assert out.trace == ["hold", "file"] and out.asked == [] + # `undo: yes` on the port: newest first. + assert office.calls == [ + "calendar/hold-slot", "files/open-file", "files/close-file", "calendar/release-slot", + ] + # `then-run:`, with the cancelled run's input, as much as that workflow takes. + assert office.started == [("tell-the-clinic", {"visit": "V-204"})] + assert out.answer == {}, "a cancelled run hands back nothing" + assert out.said == ( + "Cancelled by the port `visit-called-off`. What it did was undone, newest first: " + "file, hold. `tell-the-clinic` was started after it." + ) + + +def test_the_same_run_with_no_arrival_is_not_cancelled() -> None: + # The control: `ended` is asked before every stage and says nothing. + asked_at: list[str] = [] + out = book(Office(), lambda here: asked_at.append(here)) + assert out.halted == "final" and out.cancelled_by == {} and out.why == "" + assert asked_at == ["hold", "file", "confirm", "reply"] + + +def test_a_host_that_ends_a_run_itself_says_who_and_whether_to_undo() -> None: + office = Office() + out = book(office, at("confirm", {"by": "an operator", "undo": True})) + assert out.halted == "cancelled" and out.cancelled_by == {"by": "an operator"} + assert office.calls[-2:] == ["files/close-file", "calendar/release-slot"] + assert out.said.startswith("Cancelled by an operator. What it did was undone") + kept = Office() + out = book(kept, at("confirm", {"by": "an operator"})) + assert kept.calls == ["calendar/hold-slot", "files/open-file"], "nothing said undo" + assert out.said == "Cancelled by an operator." + + +def test_an_undo_that_fails_is_said_and_the_run_is_cancelled_all_the_same() -> None: + office = Office(broken="calendar/release-slot") + out = book(office, at("confirm", {"port": "visit-called-off"})) + assert out.halted == "cancelled" + assert "undone, newest first: file." in out.said + assert "Could not be undone: hold (calendar/release-slot: the calendar is down)." in out.said + # And in the channel a host reads for what was written and not carried out. + assert any(u.startswith("undo:") and "take those back by hand" in u for u in out.unenforced) + + +def test_a_follow_up_nothing_here_can_start_is_said_and_not_dropped() -> None: + out = book(Office(), at("confirm", {"port": "visit-called-off"}), start=False) + assert out.started == [{"workflow": "tell-the-clinic", "given": {"visit": "V-204"}}] + assert any(u.startswith("then-run: `tell-the-clinic` was to start") for u in out.unenforced) + assert "was started after it" not in out.said + + +def test_an_ending_inside_a_loop_that_carries_on_past_failures_still_ends_the_run() -> None: + # `if-someone-fails: carry-on` carries on past a branch that FAILED. The + # run being ended is not a branch failing, and nothing carries on past it. + doc = { + "agents": {"reader": {"description": "Reads.", "instructions": "Read."}}, + "workflows": { + "read-all": { + "description": "Read each page.", + "accepts": {"pages": "list of text"}, + "starts-at": "every", + "steps": { + "every": { + "does": "each", + "over": "input.pages", + "limits": {"items-at-most": 5, "at-once-at-most": 1}, + "teamwork": {"if-someone-fails": "carry-on"}, + "starts-at": "read", + "steps": {"read": {"does": "call", "call": "reader", "bind": {"message": "item"}}}, + "then": {"answered": "after"}, + }, + "after": {"does": "call", "call": "reader", "bind": {"message": "input.pages"}}, + }, + } + }, + } + read: list[Any] = [] + seen = {"n": 0} + + def ended(here: str): + if here == "every/read": + seen["n"] += 1 + if seen["n"] == 2: + return {"by": "an operator"} + return None + + spec = WorkflowSpec.from_workflow(doc, "read-all") + callers = Callers(agent=lambda name, inputs: read.append(inputs["message"]) or {"ok": True}) + out = asyncio.run(run_workflow(spec, {"pages": ["a", "b", "c"]}, callers, document=doc, ended=ended)) + assert out.halted == "cancelled", (out.halted, out.said) + assert read == ["a"], "the second page's stage never began, and nothing after it ran" + + +# ── why a stopped run stopped ──────────────────────────────────────────────── + + +def sign_off(person): + spec = WorkflowSpec.from_workflow(DEFECTS, "sign-off-by-the-team") + return asyncio.run( + run_workflow(spec, {"report": "r"}, Callers(person=person), document=DEFECTS) + ) + + +def test_a_no_with_no_exit_written_for_it_stops_the_run() -> None: + # It ended the block, and the run was then told its answer "does not fit". + out = sign_off(lambda q, s: {"close": False, "answered-by": "dana"}) + assert (out.halted, out.why) == ("stopped", "declined") + assert "the answer was no" in out.said and "`declined:` exit" in out.said + assert out.answer == {} + + +def test_silence_with_no_exit_written_for_it_stops_the_run() -> None: + def nobody(question: str, shown_: dict[str, Any]): + raise NobodyAnswered(question) + + out = sign_off(nobody) + assert (out.halted, out.why) == ("stopped", "nobody-answered") + assert "nobody answered in time" in out.said + + +def test_a_no_with_an_exit_written_for_it_goes_there_and_nothing_stops() -> None: + # The control: `sign-off` writes `declined: reply`. + spec = WorkflowSpec.from_workflow(DEFECTS, "sign-off") + out = asyncio.run( + run_workflow( + spec, {"report": "r"}, + Callers(person=lambda q, s: {"close": False, "answered-by": "dana"}), + document=DEFECTS, + ) + ) + assert out.halted == "final" and out.why == "" and out.answer["outcome"] == "declined" + + +def _one_stage(stage: dict[str, Any], **more: Any) -> dict[str, Any]: + return { + "agents": {"helper": {"description": "Helps.", "instructions": "Help."}}, + "workflows": { + "w": {"description": "One stage.", "accepts": {"x": "text"}, "starts-at": "s", + "steps": {"s": stage}, **more} + }, + } + + +def _why(doc: dict[str, Any], callers: Callers, inputs: dict[str, Any] | None = None): + spec = WorkflowSpec.from_workflow(doc, "w") + out = asyncio.run(run_workflow(spec, inputs or {"x": "1"}, callers, document=doc)) + return out.halted, out.why + + +def test_each_reason_a_run_stops_has_its_word() -> None: + def fails(name: str, inputs: dict[str, Any]): + raise RuntimeError("down") + + call = {"does": "call", "call": "helper", "bind": {"message": "input.x"}} + # An exit the author wrote. + said_so = _one_stage({**call, "then": {"answered": "stop-and-say-so"}}) + assert _why(said_so, Callers(agent=lambda n, i: {})) == ("stopped", "said-so") + # A call that failed, with no plan for it. + assert _why(_one_stage(call), Callers(agent=fails)) == ("stopped", "a-call-failed") + # Nobody on the ladder decided. + decide = _one_stage( + {"does": "decide", "chooses-between": {"a": "done", "b": "done"}, "by": [{"agent": "helper"}]} + ) + assert _why(decide, Callers(agent=lambda n, i: {"label": "not-sure"})) == ("stopped", "nobody-decided") + # A ceiling whose reaching stops. + each = _one_stage( + {"does": "each", "over": "input.x", "limits": {"items-at-most": 1}, "starts-at": "i", + "steps": {"i": call}}, + ) + assert _why(each, Callers(agent=lambda n, i: {}), {"x": ["a", "b"]}) == ("stopped", "reached-a-limit") + # What it would hand back is not what it says it gives. + gives = _one_stage({"does": "answer", "bind": {}}, **{"answers-with": {"total": "money"}}) + assert _why(gives, Callers()) == ("stopped", "does-not-fit") + # Nothing here can do what is written. + assert _why(_one_stage(call), Callers()) == ("stopped", "not-runnable") + + +def test_the_words_are_a_closed_set_and_a_stop_cannot_be_given_another() -> None: + assert len(STOPPED_BECAUSE) == len(set(STOPPED_BECAUSE)) == 8 + with pytest.raises(AssertionError): + Stopped("why", "because-i-said") + assert Stopped("why").because == "not-runnable" diff --git a/adapters/python/tests/test_a_stage_says_how_its_agent_ended.py b/adapters/python/tests/test_a_stage_says_how_its_agent_ended.py new file mode 100644 index 0000000..b39281a --- /dev/null +++ b/adapters/python/tests/test_a_stage_says_how_its_agent_ended.py @@ -0,0 +1,139 @@ +"""A stage that an agent answered says how the agent ended. + +An agent may have limits, and may be told to answer with what it has when it +reaches one (`when-it-runs-out: answer-with-what-it-has`). The stage that +called it takes that answer and goes on: it IS an answer. But the workflow +could not tell it from a finished one. An author who wanted a part-done answer +looked at before it went to a customer had nothing to write. + +`steps..ended` is `ran-out` for an agent that reached a limit and +`finished` for one that did not: the word a `repeat` already has, with the same +`ran-out`. It is a word and not a new exit under `then:`, for three reasons. +The stage did answer, and `answered` is the true name of how it ended. A word +can be SHOWN to a person and handed back by the workflow, which an exit cannot. +And nothing that exists changes: a workflow that does not read it runs as it +did. Branching on it is a `decide` with a written rule, which is where every +other "depending on what happened" in a workflow is written. + +A host hands the answer back wrapped (`RanOut`); these hold what the runtime +does with it. The shared cases hold the same on `triaged-defects/draft-once`. +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from pact_adapters.ir import WorkflowSpec # noqa: E402 +from pact_adapters.workflow_runtime import Callers, Parked, RanOut, run_workflow # noqa: E402 +from trees import shown # noqa: E402 + +REPO = Path(__file__).resolve().parents[3] +DOC = shown(REPO / "tests/conformance/trees/triaged-defects") +DRAFT = {"draft": "Gate 4 opens on", "good": False} + + +def draft_once(answer: Any, person=None): + asked: list[dict[str, Any]] = [] + + def ask(question: str, shown_: dict[str, Any]) -> dict[str, Any]: + asked.append(dict(shown_)) + return {"send": True} + + spec = WorkflowSpec.from_workflow(DOC, "draft-once") + callers = Callers(agent=lambda name, inputs: answer, person=person or ask) + out = asyncio.run(run_workflow(spec, {"brief": "A notice."}, callers, document=DOC)) + return out, asked + + +def test_an_answer_given_on_running_out_is_taken_and_the_stage_says_ran_out() -> None: + out, asked = draft_once(RanOut(DRAFT)) + assert out.halted == "final", out.said + # The answer is the agent's, unwrapped: a binding reads its fields as ever. + assert out.steps["write"] == DRAFT + assert out.answer == {"draft": "Gate 4 opens on", "ended": "ran-out", "went": "part-done"} + # A rule read the word and sent the draft to a person, who was shown it. + assert out.trace == ["write", "check", "look", "reply"] + assert asked == [{"steps.write.draft": "Gate 4 opens on", "steps.write.ended": "ran-out"}] + assert out.because["check"] == "The drafter ran out before it finished." + + +def test_an_agent_that_did_not_run_out_is_finished_and_nobody_looks() -> None: + out, asked = draft_once(dict(DRAFT)) + assert out.answer["ended"] == "finished" and out.answer["went"] == "whole" + assert out.trace == ["write", "check", "reply"] and asked == [] + + +def test_a_label_from_an_agent_that_ran_out_stands_and_the_decide_says_ran_out() -> None: + spec = WorkflowSpec.from_workflow(DOC, "assign-only") + given = {"kind": "engine", "summary": "Oil pressure low."} + + def run(answer: Any): + callers = Callers(agent=lambda name, inputs: answer) + return asyncio.run(run_workflow(spec, given, callers, document=DOC)) + + assert run(RanOut({"team": "powerplant"})).answer == {"team": "powerplant", "ended": "ran-out"} + assert run({"team": "powerplant"}).answer == {"team": "powerplant", "ended": "finished"} + + +def test_a_stage_that_calls_a_tool_has_no_such_word() -> None: + # Only an agent has limits of its own to run out of. + doc = { + "tools": {"ledger": {"description": "A ledger.", "actions": {"look": {"description": "Look."}}}}, + "workflows": { + "w": { + "description": "Look once.", "accepts": {"x": "text"}, "starts-at": "look", + "steps": { + "look": {"does": "call", "call": "ledger/look", "then": {"answered": "say"}}, + "say": {"does": "answer", "bind": {"how": "steps.look.ended", "what": "steps.look.outcome"}}, + }, + } + }, + } + spec = WorkflowSpec.from_workflow(doc, "w") + out = asyncio.run( + run_workflow(spec, {"x": "1"}, Callers(tool=lambda t, a, i: {"seen": True}), document=doc) + ) + assert out.answer == {"what": "answered"}, "`.ended` reads nothing there; `.outcome` is on every stage" + + +# ── a called workflow that waits ───────────────────────────────────────────── + + +def fix_and_close(person): + spec = WorkflowSpec.from_workflow(DOC, "fix-and-close") + callers = Callers(agent=lambda name, inputs: {"kind": "cabin", "summary": "A seat."}, person=person) + return asyncio.run(run_workflow(spec, {"report": "Seat 14C."}, callers, document=DOC)) + + +def test_a_run_that_calls_a_workflow_that_waits_is_waiting_too() -> None: + # It was `stopped`: "the workflow 'sign-off-by-the-team' parked", which + # every caller read as a failure. + out = fix_and_close(None) + assert out.halted == "parked" and out.why == "" + # Where THIS run waits, which is where a host resumes it; and whose question. + assert out.parked_at == "close" and out.question == "may-the-team-close" + assert out.said == ( + "waiting on 'may-the-team-close' at 'close' (asked inside 'sign-off-by-the-team/approve')" + ) + + +def test_a_host_that_parks_on_the_called_workflows_question_parks_the_caller_too() -> None: + def later(question: str, shown_: dict[str, Any]): + raise Parked(question, "approve") + + out = fix_and_close(later) + assert out.halted == "parked" and out.parked_at == "close" + # The person WAS asked this time, and the caller's record says so. + assert [a["question"] for a in out.asked] == ["may-the-team-close"] + + +def test_once_the_called_workflow_is_answered_the_caller_goes_on() -> None: + out = fix_and_close(lambda q, s: {"close": True, "answered-by": "dana"}) + assert out.halted == "final" and out.answer == {"close": True, "kind": "cabin"} + assert [a["question"] for a in out.asked] == ["may-the-team-close"] diff --git a/adapters/python/tests/test_the_shared_cases_are_what_this_runtime_does.py b/adapters/python/tests/test_the_shared_cases_are_what_this_runtime_does.py index 33f4ff1..1e9ddda 100644 --- a/adapters/python/tests/test_the_shared_cases_are_what_this_runtime_does.py +++ b/adapters/python/tests/test_the_shared_cases_are_what_this_runtime_does.py @@ -266,3 +266,58 @@ def test_the_cases_say_the_things_they_are_there_to_say() -> None: parked = by_name["nobody-is-there-to-ask-so-the-run-parks-at-the-decision"] assert parked["halted"] == "parked" and parked["parked-at"] == "where" and parked["question"] == "which-desk" + + # ── how a run ends ── + called_off = by_name[ + "a-port-ends-an-open-run-which-is-cancelled-undone-newest-first-and-followed-by-what-the-port-says" + ] + # Something outside ended it: `cancelled`, with no `why` (that word is for + # a run its own stages stopped), and no answer. + assert (called_off["halted"], called_off["why"]) == ("cancelled", "") + assert called_off["cancelled-by"] == {"port": "visit-called-off"} and called_off["answer"] == {} + # The stage it arrived at never began: not in the trace, nobody asked. + assert called_off["trace"] == ["hold", "file"] and called_off["shown"] == [] + # The port says `undo: yes`: newest first, and an undo is a call. + assert [c["tool"] for c in called_off["called"]] == [ + "calendar/hold-slot", "files/open-file", "files/close-file", "calendar/release-slot", + ] + # `then-run:`, given the cancelled run's input, as much as that workflow takes. + assert called_off["started"] == [{"workflow": "tell-the-clinic", "given": {"visit": "V-204"}}] + kept = by_name["a-port-that-does-not-say-undo-ends-the-run-and-leaves-what-it-did"] + assert [c["tool"] for c in kept["called"]] == ["calendar/hold-slot", "files/open-file"] + assert kept["started"] == [] and kept["halted"] == "cancelled" + replaced = by_name["a-newer-arrival-replaces-the-open-run-which-is-cancelled-as-any-other-is"] + assert replaced["cancelled-by"] == {"port": "visit-booked-again"} and replaced["trace"] == ["hold"] + booked = by_name["a-visit-nobody-calls-off-is-booked-and-confirmed"] + assert (booked["halted"], booked["cancelled-by"], booked["started"]) == ("final", {}, []) + + said_no = by_name["a-no-with-no-exit-written-for-it-stops-the-run-and-says-so"] + assert (said_no["halted"], said_no["why"]) == ("stopped", "declined") + silence = by_name["silence-with-no-exit-written-for-it-stops-the-run-and-says-so"] + assert (silence["halted"], silence["why"]) == ("stopped", "nobody-answered") + misfit = by_name["an-answer-that-does-not-fit-what-the-workflow-gives-stops-the-run"] + assert (misfit["halted"], misfit["why"]) == ("stopped", "does-not-fit") + # `why` is for `stopped` and for nothing else. + for name, expect in by_name.items(): + if "why" in expect: + assert bool(expect["why"]) == (expect["halted"] == "stopped"), name + assert bool(expect["cancelled-by"]) == (expect["halted"] == "cancelled"), name + + waiting = by_name["a-run-that-calls-a-workflow-that-waits-is-waiting-too"] + # Waiting, not stopped; at a stage of ITS OWN, with the called run's question. + assert (waiting["halted"], waiting["parked-at"], waiting["question"]) == ( + "parked", "close", "may-the-team-close", + ) + went_on = by_name["a-run-that-calls-a-workflow-goes-on-once-that-one-is-answered"] + assert went_on["halted"] == "final" and went_on["answer"] == {"close": True, "kind": "cabin"} + + # ── how a called agent ended ── + ran_out = by_name["an-agent-that-ran-out-says-so-on-the-stage-that-called-it"] + assert ran_out["halted"] == "final", "an answer given on running out is an answer" + assert ran_out["answer"] == {"draft": "Gate 4 opens on", "ended": "ran-out", "went": "part-done"} + assert ran_out["trace"] == ["write", "check", "look", "reply"] + assert ran_out["shown"][0]["shown"]["steps.write.ended"] == "ran-out" + finished = by_name["an-agent-that-finished-says-so-and-nobody-is-asked"] + assert finished["answer"]["ended"] == "finished" and finished["shown"] == [] + label = by_name["a-label-from-an-agent-that-ran-out-counts-and-the-record-says-it-ran-out"] + assert label["answer"] == {"team": "powerplant", "ended": "ran-out"} diff --git a/crates/pact-cli/tests/a_deciding_agent_is_given_what_it_decides_about.rs b/crates/pact-cli/tests/a_deciding_agent_is_given_what_it_decides_about.rs index 916b28e..653ae89 100644 --- a/crates/pact-cli/tests/a_deciding_agent_is_given_what_it_decides_about.rs +++ b/crates/pact-cli/tests/a_deciding_agent_is_given_what_it_decides_about.rs @@ -302,36 +302,72 @@ fn a_says_is_nothing_to_a_classifier_so_one_given_only_that_is_refused() { } #[test] -fn a_rung_whose_agent_is_a_classifier_and_a_language_model_at_once_is_warned_about() { - let (ok, text) = check( +fn a_rung_whose_agent_is_a_classifier_and_a_language_model_at_once_is_refused() { + // The tree as written is the control: `shelve-twice` asks `scorer` (the + // classifier, with `if-unsure-below:`) and then `reader` (a language + // model), two agents on two rungs, and loads clean + // (`a_decide_that_binds_what_an_earlier_stage_found_loads_clean`). Here + // the two are folded into one agent's `model:` list. + let text = refused( "two-kinds", &[( " model: note-classifier\n takes message: text\n", " model: [note-classifier, qwen2.5-7b-instruct]\n takes message: text\n instructions: Say which shelf.\n", )], + "loader/a-rung-with-two-kinds-of-reader", ); - assert!(!ok, "a warning fails `--deny-warnings`:\n{text}"); + // An error, not a warning: `check` passes `--deny-warnings`, under which a + // warning fails too, so the summary line is what tells the two apart. A + // warning loads, and a tree that loads is run — by two runtimes, two ways. + assert!(text.contains("problem(s) found"), "{text}"); + assert!(!text.contains("loaded with"), "{text}"); assert!( - text.contains("rule: loader/a-rung-with-two-kinds-of-reader"), + text.contains( + "'where' asks 'scorer' to decide, and 'scorer' lists a trained classifier \ + ('note-classifier') and a language model ('qwen2.5-7b-instruct') under one \ + `model:`. That list names fallbacks for a call that fails; it is never tried \ + because an answer was unsure, which passes the decision to the next rung." + ), "{text}" ); assert!( text.contains( - "'where' asks 'scorer' to decide, and 'scorer' runs on a trained classifier \ - ('note-classifier') and on a language model ('qwen2.5-7b-instruct'). A `model:` \ - list is tried in order only when a call fails, never because an answer was unsure" + "'qwen2.5-7b-instruct' would be asked only when the call to 'note-classifier' \ + failed, and never for what the classifier was unsure of." ), "{text}" ); assert!( text.contains( - "fix: Make them two agents on two rungs: one on 'note-classifier' with \ - `if-unsure-below:`, then one on 'qwen2.5-7b-instruct' with `instructions:`." + "fix: Make them two agents on two rungs, the classifier first: one agent on \ + 'note-classifier' with `if-unsure-below:` on its rung, then one on \ + 'qwen2.5-7b-instruct' with `instructions:`, whose `one of` line may add \ + `not-sure`. The record then says which of the two decided." ), "{text}" ); } +#[test] +fn two_classifiers_or_two_language_models_in_one_list_are_fallbacks_and_load() { + // The list still means what it meant: fallbacks of ONE kind, for a call + // that fails. Only the mix is refused. + let (ok, text) = check( + "two-of-a-kind", + &[ + ( + " note-classifier:\n description: A trained classifier that says which shelf a note belongs on.\n kind: decision\n served-by: [{runtime: systemone, endpoint: local}]\n", + " note-classifier:\n description: A trained classifier that says which shelf a note belongs on.\n kind: decision\n served-by: [{runtime: systemone, endpoint: local}]\n note-classifier-spare:\n description: The same classifier on a second server.\n kind: decision\n served-by: [{runtime: systemone, endpoint: local}]\n", + ), + ( + " model: note-classifier\n takes message: text\n", + " model: [note-classifier, note-classifier-spare]\n takes message: text\n", + ), + ], + ); + assert!(ok, "{text}"); +} + #[test] fn it_is_printed_back_as_it_was_written() { let out = Command::new(env!("CARGO_BIN_EXE_pact")) diff --git a/crates/pact-loader/src/deciding.rs b/crates/pact-loader/src/deciding.rs index 88a6904..988a169 100644 --- a/crates/pact-loader/src/deciding.rs +++ b/crates/pact-loader/src/deciding.rs @@ -12,7 +12,7 @@ //! | `loader/a-decide-that-can-run-out-of-rungs` | WF-16 (agent half) | the last rung is an agent that may pass the decision on: its `one of` line has `not-sure`, or it writes `if-unsure-below:` or `if-it-disagrees: go-on` | //! | `loader/sure-only-a-decision-model-can-be` | WF-17 | `if-unsure-below:` on an agent none of whose models has `kind: decision` | //! | `loader/a-decision-asked-of-several` | new | a person rung's question writes `answered-by: everyone` or `enough-of-them`: a decision is one label, from one person, and nothing says which label stands when several people pick different ones | -//! | `loader/a-rung-with-two-kinds-of-reader` | new (a warning) | an agent on a rung names a decision model and a language model in one `model:` list: the list is a fallback for a failed call, not a cascade for an unsure answer, and both are given the same message | +//! | `loader/a-rung-with-two-kinds-of-reader` | new | an agent on a rung names a decision model and a language model in one `model:` list: the list is a fallback for a failed call, not a cascade for an unsure answer, and no one agent can be both kinds of reader. Refused; the cascade is two agents on two rungs | //! //! And one thing the schema cannot know: an agent all of whose models are //! decision models needs no `instructions:` (`model.kind`'s own help), so the @@ -296,11 +296,29 @@ impl<'a> Decide<'a> { } /// An agent on a rung whose `model:` list has a trained classifier and a - /// language model in it. The list is a fallback chain — the next model is - /// asked when a CALL fails, never because an answer was unsure — so the - /// cascade it looks like is not what it does; and both are given one - /// message, with the stage's `says:` in front of it, which a classifier - /// scores as if it were part of the text. + /// language model in it. Refused. + /// + /// It reads as a cascade: the classifier first, the language model for + /// what the classifier is unsure of. That is what the authors who wrote it + /// meant — it was measured on Bud Agent Flow's corpus, where 13 of 136 + /// trees wrote one, each with a comment saying so. It is not what a + /// `model:` list is: the list names fallbacks for a CALL THAT FAILS + /// (`agent.model`'s own help), and an unsure answer passes the decision to + /// the next RUNG. So the language model would be asked only when the + /// classifier's server was down. + /// + /// It was a warning, and a warning loads. A tree that loads is run, and + /// here two runtimes ran it two ways: one framework treats an unsure + /// classifier as a failed call and goes on down the list, another does + /// not, so the same file gave different decisions on each. And no reading + /// makes one agent of the two: a classifier follows no `instructions:` and + /// answers with a score, a language model needs instructions and has no + /// honest score, so `if-unsure-below:`, `not-sure` and the stage's `says:` + /// each apply to half of it. + /// + /// The ladder is the cascade, and says more: each rung has its own + /// threshold and its own `if-it-disagrees:`, and `steps..answered-by` + /// records WHICH of the two decided. fn two_kinds_of_reader(&self, name: &str, agent: &Node, at: &Node, diags: &mut Diagnostics) { let models = models_of(agent); let Some(classifier) = models.iter().find(|m| self.decision.contains(**m)) else { @@ -310,20 +328,24 @@ impl<'a> Decide<'a> { return; }; let stage = self.stage; - diags.push(Diagnostic::warning( + diags.push(Diagnostic::error( "loader/a-rung-with-two-kinds-of-reader", at.span.clone(), format!( - "'{stage}' asks '{name}' to decide, and '{name}' runs on a trained classifier \ - ('{classifier}') and on a language model ('{language}'). A `model:` list is \ - tried in order only when a call fails, never because an answer was unsure — \ - being unsure passes the decision to the next rung — and both are given the \ - same message, the stage's `says:` in front of it, which a classifier scores \ - as text." + "'{stage}' asks '{name}' to decide, and '{name}' lists a trained classifier \ + ('{classifier}') and a language model ('{language}') under one `model:`. That \ + list names fallbacks for a call that fails; it is never tried because an \ + answer was unsure, which passes the decision to the next rung. So \ + '{language}' would be asked only when the call to '{classifier}' failed, \ + and never for what the classifier was unsure of. Nor can one agent be both: \ + a classifier follows no `instructions:` and answers with a score, a language \ + model needs instructions and has no score." ), format!( - "Make them two agents on two rungs: one on '{classifier}' with \ - `if-unsure-below:`, then one on '{language}' with `instructions:`." + "Make them two agents on two rungs, the classifier first: one agent on \ + '{classifier}' with `if-unsure-below:` on its rung, then one on '{language}' \ + with `instructions:`, whose `one of` line may add `not-sure`. The record \ + then says which of the two decided." ), )); } diff --git a/site-docs/guide/programs-in-the-language.md b/site-docs/guide/programs-in-the-language.md index 2f404fb..8082084 100644 --- a/site-docs/guide/programs-in-the-language.md +++ b/site-docs/guide/programs-in-the-language.md @@ -225,14 +225,50 @@ is asked even though a rung above has decided. Passing on is always to the next rung. A `model:` list on the agent names fallbacks for a call that *fails*; it is not tried because an answer was unsure. To have a classifier and then a language model, write two agents on -two rungs. `pact check` warns about one agent that lists both -(`loader/a-rung-with-two-kinds-of-reader`). +two rungs, the classifier first: + +```text + by: + - agent: scorer # a trained classifier + if-unsure-below: 85% + - { agent: reader } # a language model, which may answer not-sure + - { asks: which-shelf } +``` + +One agent that lists both under `model:` is refused +(`loader/a-rung-with-two-kinds-of-reader`). It reads as "the classifier, then +the language model for what it is unsure of", and a `model:` list does not do +that: the language model would be asked only when the classifier's call +failed. Two rungs do, and each has its own threshold, and the record says +which of the two decided. Afterwards the step says who decided: `where.label`, `where.decided-by` (`rules`, `agent` or `person`) and `where.answered-by` (the agent's name, or the person). Every step also has `.outcome`, the word it ended in, and a `repeat` has `.rounds` and `.ended` (`passed` or `ran-out`). +A step that an agent answered has `.ended` too: `finished`, or `ran-out` when +the agent reached one of its limits and answered with what it had +(`when it runs out: answer with what it has`). The step takes that answer and +goes on like any other; `.ended` is how the workflow can tell. To have a +person look at a part-done answer, read it in a rule: + +```text + write = call drafter with brief: input.brief + check = decide + by: + - rules: + - when: + - { value: steps.write.ended, is: ran-out } + choose: part-done + - choose: whole + if part-done: look + if whole: reply +``` + +A question can show it (`shows: [steps.write.ended]`) and an `answer` can hand +it back. A step that calls a tool or another workflow has no `.ended`. + A decision is one person's. A question that needs several answers (`answered-by: everyone`) cannot decide a branch, because nothing says which label stands when people pick different ones; `pact check` refuses it @@ -251,6 +287,53 @@ answered, also when one person did. no at once. A name there can be a person or a team; a team is answered for by one of its members, and who that was is what `.answered-by` keeps. +## How a run ends + +A workflow's run ends in one of four words. + +| It ends | When | +|---|---| +| `final` | it reached an `answer`, or the end of its steps | +| `parked` | it is waiting for a person, in one of its own steps or in a workflow it called and waits for | +| `stopped` | its own steps ended it, and one more word says why | +| `cancelled` | something outside it ended it | + +**`stopped` says why**, in one of eight words, so a person's no is never read +as a broken call: + +| Why | What happened | +|---|---| +| `said-so` | it reached `stop-and-say-so`, an exit its author wrote | +| `declined` | a person said no, and the step writes no `declined:` exit | +| `nobody-answered` | a wait ran out, and the step writes no `nobody-answered:` exit | +| `nobody-decided` | a `decide` ran out of rungs | +| `a-call-failed` | a call failed and `if-it-fails:` did not cover it | +| `reached-a-limit` | a ceiling was reached whose `when-it-runs-out:` stops | +| `does-not-fit` | what it would hand back is not what the workflow says it gives | +| `not-runnable` | the runtime cannot carry out what is written | + +**`cancelled` is a run that did nothing wrong.** A port ends an open run when +an arrival carries the run's key and the port says `if-still-running: stop` +(or `cancel-previous`, which replaces it with the new arrival's run): + +```text +port visit-called-off + description: The customer calls the visit off. + kind: event + answers: book-visit + same-conversation-when: [visit] + if-still-running: stop + undo: yes + then-run: tell-the-clinic +``` + +The run ends between two steps. A step that has begun finishes, so what it +wrote is on the record; the next one does not begin, and nobody it would have +asked is asked. With `undo: yes`, what the run wrote is taken back, newest +first, each by its action's `undone-by:`. With `then-run:`, that workflow is +started afterwards and not waited for, with the ended run's own input, as much +of it as it takes. A cancelled run hands back no answer. + ## Things at once ```text diff --git a/site-docs/reference/cli.md b/site-docs/reference/cli.md index fe490a6..3e91a51 100644 --- a/site-docs/reference/cli.md +++ b/site-docs/reference/cli.md @@ -36,8 +36,25 @@ The one you run constantly. ```console $ pact check examples/refund-desk OK — examples/refund-desk loaded cleanly (498 settings). +Egress: `allow-egress: []`, so nothing here may talk to anything outside this box. ``` +The second line says where the workspace draws its boundary: which parts of it +may talk to something outside the machine. It is printed after every check that +finds no problems, and it has three forms: + +| `workspace.yaml` writes | The line says | +|---|---| +| no `allow-egress:` line | no boundary is drawn, so every part may talk outside | +| `allow-egress: []` | nothing may | +| `allow-egress: [llm, tools]` | only those parts may | + +Leaving the line out is a choice an author may make, and it is also what a +forgotten line looks like, so the tool says which one it read. It is not a +warning: it does not count as one and `--deny-warnings` does not fail on it. +`--quiet` leaves it out. An agent checked on its own (`pact check +agents/desk`) is told the boundary of the workspace around it. + Every problem it reports names the file, the line, what is wrong, and a fix you can type: @@ -84,6 +101,12 @@ pinned to: `answers-with:`, `published-as:`, and each port that answers it with its `same-conversation-when:` and `if-still-running:`. +Each workspace also carries `egress`, the same boundary `pact check` states, +for a runtime deciding whether to take the tree: `boundary` is `open` (no +`allow-egress:` line, so everything may talk outside), `closed` (`[]`) or +`listed`, and `allows` is the parts written. An absent line and an empty list +are opposite answers, and `allows` is empty for both: read `boundary`. + ```console $ pact discover tests/trees/workflows-16-trial-booking [{ …, "digest": "sha256:…", diff --git a/spec/schema.yaml b/spec/schema.yaml index 8bba70b..d185aaa 100644 --- a/spec/schema.yaml +++ b/spec/schema.yaml @@ -5332,13 +5332,17 @@ groups: type: yes-no surface: S-EXEC tier: core - help: with `stop`, undo what the open run did, newest first, before it ends + help: > + with `stop` or `cancel-previous`, undo what the open run did, newest + first, before it ends. Either way the run it ends is `cancelled`. then-run: type: text names: workflows surface: S-EXEC tier: core - help: with `stop`, a workflow to start afterwards with the run's data, not waited for + help: > + with `stop`, a workflow to start afterwards, not waited for. It is + given the ended run's own input, as much of it as it `accepts:`. who-can-reach-it: type: list of text surface: S-EXEC diff --git a/tests/conformance/digests.json b/tests/conformance/digests.json index 03788f0..292839b 100644 --- a/tests/conformance/digests.json +++ b/tests/conformance/digests.json @@ -1,6 +1,19 @@ { - "about": "The digests `pact discover` prints for trees in this repository: the tree's own `digest`, and each workflow's `doc-digest` (what a waiting run is pinned to) and `contract-digest` (what its callers rely on), 02W §7.2. A digest is a function of the tree's files and of nothing else: a new version of PACT prints the same digest for the same files, or says in its changelog which trees move and why. Held by crates/pact-cli/tests/a_digest_moves_only_when_the_tree_does.rs. A runtime that pins PACT holds this file with one `pact discover` per tree, at every pin.", + "about": "The digests `pact discover` prints for trees in this repository: the tree's own `digest`, and each workflow's `doc-digest` (what a waiting run is pinned to) and `contract-digest` (what its callers rely on), 02W \u00a77.2. A digest is a function of the tree's files and of nothing else: a new version of PACT prints the same digest for the same files, or says in its changelog which trees move and why. Held by crates/pact-cli/tests/a_digest_moves_only_when_the_tree_does.rs. A runtime that pins PACT holds this file with one `pact discover` per tree, at every pin.", "trees": { + "tests/conformance/trees/booked-visits": { + "digest": "sha256:682a2d336987da594a761df26694bc74e189b7dabe5649d1ea628985a9acf710", + "workflows": { + "book-visit": { + "doc-digest": "sha256:7d0641dc25062ef8b8113ab3d6aa9fd718f08bea31ddf9f1a69e32cef0666666", + "contract-digest": "sha256:5ce26ba661786d02524ce6be4e4d0b674d348db5923320dccb4d0f508e47678c" + }, + "tell-the-clinic": { + "doc-digest": "sha256:c36aea66390a4c82e7596a3525ec58f7c2a9d519cadf6352c0c0fae7377030df", + "contract-digest": "sha256:aafcab74c564cd299c072d4e37f044ee97a4f362abd1c36e7be7c6efc22feba1" + } + } + }, "tests/conformance/trees/guarded-desk": { "digest": "sha256:96f2462be2e6bcef216efffa0cb93edd5b79beb681a22a66f94605ee3b6ba9bf", "workflows": {} @@ -36,42 +49,54 @@ } }, "tests/conformance/trees/triaged-defects": { - "digest": "sha256:0634a2afd42db93355db2024735806a831e0a4124d6006e8029ddbf04a2ec501", + "digest": "sha256:a4f89a092d366315aec9e4788a095bc1b44af5067167080ad94cf212d3688f68", "workflows": { + "assign-only": { + "doc-digest": "sha256:cd1e74c1b600ff739aa2ff6e6d6034283c096d51549e98c290d5ceb0e9519ff1", + "contract-digest": "sha256:b352dd9a97d85f29d3d51f3f7d3b5996fb29212b1355b252c5f1d2e8060075d4" + }, "classify-only": { - "doc-digest": "sha256:0470fe6f3f7de8eba44d7a9286071e6a6f57c28d8e1446b9f1aa2c854ebabc12", + "doc-digest": "sha256:b59b53436cad9cdf66d25e2f8fda26939bb0d751b82c61070a8c99f24aa5a4ca", "contract-digest": "sha256:373c3db55151ef337d72e5aa2f767e3e19b907188b7c5b7dfdd62c37bcb19dc1" }, + "draft-once": { + "doc-digest": "sha256:535285dc9a140ccd4efe815522d85dcc1f89405214b6171246950e863e710085", + "contract-digest": "sha256:4fdf2b23f9b2e49d8a6028c3a96c73c87532fc895e827d1118434528a377d460" + }, "draft-three-times": { - "doc-digest": "sha256:6c792aa92fed26a03a45ec78c237a50688df98b68745bc12b4430019abeb3b31", + "doc-digest": "sha256:ae6db630c231255c7395ed91bb25d9bfee8995d38b8cf5677fe54feaf974b7ef", "contract-digest": "sha256:cc88efa5b071c1dd1763bdcc020c70abbb84103a922660589f4f2d80e24cc000" }, "draft-until-good": { - "doc-digest": "sha256:e4e31a1ebeb28e07a265775cdf1f6a1ea675f5001c5632b7d277a343326bcf07", + "doc-digest": "sha256:af2c801a16f224fecc273311dd83a366e886164c288e5e8cf61b9933a1beac57", "contract-digest": "sha256:1b9f8469dbe518f30be0660c5e440629cf5e6ef218ce951862b9ba70c4e04e82" }, + "fix-and-close": { + "doc-digest": "sha256:9f5296b93d8c6af188a0dec4e6271ef031770cb52411f9805ac6bf5b9c4692aa", + "contract-digest": "sha256:90ee19112c40de4b76db860f50ffd9a48cb8c661a1bfdce7728e272f309f461f" + }, "fix-item": { - "doc-digest": "sha256:488bdd16a4969bf513c3be226ac99b16e96105ff78ce432f20e9cf06d6be8faa", + "doc-digest": "sha256:e255a7535f3ee414bf854f553a04d7931ceed2b366bbdd5f9fd35a1d01e65d1b", "contract-digest": "sha256:8250dadd43a05efd7fb74f2704e1c072ac6d7de417a8b88e3fb5ebf8fe776936" }, "shelve": { - "doc-digest": "sha256:c31b89eba8be2489efa8ed9b8ec1249cfae6d8cd300ab541b222fecda11735ea", + "doc-digest": "sha256:6c4ee068be6b98f7bd1cde310f57fe44874a9b3d58596c949c46059f80df71de", "contract-digest": "sha256:a3470d96b6e4b7c3ddc1b18e8673d70a81504ecbf800d71ad6adeda492ba84d5" }, "shelve-checked": { - "doc-digest": "sha256:d6a85c179c52f5ca2f84df326b7634bd80da42eba409f745e02899dab78c9fda", + "doc-digest": "sha256:cbceb253fb8f4d131c49bbb5517bbf834345f13d62c96e013a8a0b029f7b269a", "contract-digest": "sha256:d5f0555af2f5a9565c92d6900490948d071d442da170f0e493ef63496422f057" }, "shelve-twice": { - "doc-digest": "sha256:71721c73b13347d2250a23e5aed61ed5de07d0405a6aac85c03429e05f4c9ea5", + "doc-digest": "sha256:ca496ca8356a07902a5070cde0cf3a5075b2128b11bd4980d36006272981d6fe", "contract-digest": "sha256:a3470d96b6e4b7c3ddc1b18e8673d70a81504ecbf800d71ad6adeda492ba84d5" }, "sign-off": { - "doc-digest": "sha256:8743d5a18c7369031478b133a38e8352e6bcfb753a68e148fb72abd22c7fe388", + "doc-digest": "sha256:aa503f6d839bbb0037eb91a24a12d1a419c99d1eea79f1524224e77a2befdbee", "contract-digest": "sha256:43784b26caa17e0a651853fd8bf660ebc94dd00c32355fd5982ff1a3391d9a69" }, "sign-off-by-the-team": { - "doc-digest": "sha256:6856e75f81288d142bb52abcb1179230b0008c6468ed5e8e30394e2b1fddd046", + "doc-digest": "sha256:4f00c0584348cd70174bec5fb9e960416bd772e099d48a655690e3e80ef9ab54", "contract-digest": "sha256:e9594ce43d73dec2b1791b98eb1d6b433c5cf313957e421af97778fbc8225140" } } diff --git a/tests/conformance/trees/booked-visits/visits.pact b/tests/conformance/trees/booked-visits/visits.pact new file mode 100644 index 0000000..ce0e5d2 --- /dev/null +++ b/tests/conformance/trees/booked-visits/visits.pact @@ -0,0 +1,108 @@ +# BOOKED VISITS — a workspace for the cases in tests/conformance/workflows.json +# about a run that something OUTSIDE it ended. +# +# A visit is booked in two systems and then a person confirms it. While that +# run is open the customer may cancel, and the cancellation arrives at a port +# with the same key (the visit). The run is then `cancelled`: never `stopped`, +# which is a run's own stages ending it. +workspace "Booked visits" + description: A visit is booked in two systems, and a cancellation may arrive while it is. + +resource back-office + resource-kind: mcp-server + endpoint: host/back-office + description: Where the calendar and the customer files are reached. + +tool calendar + description: The clinic's calendar. + connect: back-office + actions: + hold-slot: + description: Hold a slot for a visit. + undone-by: calendar/release-slot + takes: + visit: text + release-slot: + description: Give a held slot back. + takes: + visit: text + +tool files + description: The customers' files. + connect: back-office + actions: + open-file: + description: Open a file for a visit. + undone-by: files/close-file + takes: + visit: text + close-file: + description: Close a file that was opened for a visit. + takes: + visit: text + note: + description: Write a line in the clinic's day book. + takes: + visit: text + +question is-the-visit-confirmed + description: Asked of the front desk once a visit is booked in both systems. + says: Is this visit confirmed? + answer: + confirmed: yes or no + shows: [input.visit] + asked-of: [front-desk] + answer-within: 1d + +workflow book-visit + description: Hold a slot, open a file, and have the front desk confirm the visit. + takes visit: text, customer: text + gives confirmed: yes or no + hold = call calendar/hold-slot with visit: input.visit + file = call files/open-file with visit: input.visit + confirm = ask is-the-visit-confirmed + then: + declined: reply + reply = answer with confirmed: confirm.confirmed + +# What a port starts after it has ended a run. It takes the visit and not the +# customer, so it is handed the one and not the other. +workflow tell-the-clinic + description: Write in the day book that a visit was called off. + takes visit: text + write = call files/note with visit: input.visit + +port visit-booked + description: A visit is booked. + kind: event + answers: book-visit + same-conversation-when: [visit] + +# The customer calls the visit off: the open run for that visit ends, what it +# did is taken back, and the clinic is told. +port visit-called-off + description: The customer calls the visit off. + kind: event + answers: book-visit + same-conversation-when: [visit] + if-still-running: stop + undo: yes + then-run: tell-the-clinic + +# The visit is withdrawn by the clinic itself, which keeps the slot and the +# file for whoever is booked in next: the run ends and nothing is undone. +port visit-withdrawn + description: The clinic withdraws the visit and keeps what was set up for it. + kind: event + answers: book-visit + same-conversation-when: [visit] + if-still-running: stop + +# A newer booking for the same visit replaces the open one. +port visit-booked-again + description: The same visit is booked again, in place of the booking still open. + kind: event + answers: book-visit + same-conversation-when: [visit] + if-still-running: cancel-previous + undo: yes diff --git a/tests/conformance/trees/triaged-defects/defects.pact b/tests/conformance/trees/triaged-defects/defects.pact index 7b99e7a..6b85eb7 100644 --- a/tests/conformance/trees/triaged-defects/defects.pact +++ b/tests/conformance/trees/triaged-defects/defects.pact @@ -218,3 +218,59 @@ workflow draft-three-times write = call drafter with brief: input.brief reply = answer with rounds: polish.rounds, outcome: polish.outcome, ended: polish.ended gave-up = answer with rounds: polish.rounds, outcome: polish.outcome + +# An agent that reaches one of its limits may answer with what it has. The +# stage that called it takes that answer and goes on, and says how the agent +# ended: `.ended` is `ran-out`, and `finished` otherwise. Here a person looks +# at a draft only when the drafter ran out before it was done. +question is-this-draft-enough + description: Asked when the drafter ran out before it finished. + says: The drafter ran out before it finished. Is this draft enough to send? + answer: + send: yes or no + shows: [steps.write.draft, steps.write.ended] + asked-of: [editors] + answer-within: 1h + +workflow draft-once + description: Write one draft, and have a person look at it only when the drafter ran out. + takes brief: text + gives draft: text, ended: text, went: text + write = call drafter with brief: input.brief + check = decide + by: + - rules: + - when: + - { value: steps.write.ended, is: ran-out } + choose: part-done + because: The drafter ran out before it finished. + - choose: whole + if part-done: look + if whole: reply + look = ask is-this-draft-enough + then: + declined: reply + reply = answer with draft: write.draft, ended: write.ended, went: check.label + +# The same word on a `decide` an agent decided: its label counts whether or +# not it ran out, and the record says which. +workflow assign-only + description: Pick the team for a defect of a known kind, and say how the agent that picked ended. + takes kind: text, summary: text + gives team: text, ended: text + assign = decide + by: + - { agent: assigner } + if powerplant: reply + if interiors: reply + reply = answer with team: assign.label, ended: assign.ended + +# A workflow that calls one that waits for a person is waiting too, at the +# stage that made the call. +workflow fix-and-close + description: Classify a defect, then have the leads' team close it. + takes report: text + gives close: yes or no, kind: text + triage = call triager with report: input.report + close = call sign-off-by-the-team with report: input.report + reply = answer with close: close.close, kind: triage.kind diff --git a/tests/conformance/workflows.json b/tests/conformance/workflows.json index 154be08..618bb27 100644 --- a/tests/conformance/workflows.json +++ b/tests/conformance/workflows.json @@ -1,5 +1,5 @@ { - "about": "Workflows: stages in the order they run, what each called agent and each person was given, and the answer. Replayed as conformance_kit.replay_workflow does: `agents` is what each agent answers when called (a list is one answer for each time it is asked, the last repeating); `tools` is what each `/` hands back; `people` is what a person answers to each question by name (absent: nobody is there to ask, and the run parks). In `expect`: `trace` is the stages that ran, nested ones as `outer/inner`; `given` is each agent called and its inputs, in order; `shown` is each question put to somebody and what they were shown, once for each entry of the question's `asked-of:` (a question asked of two people is put twice, at once, whoever answers first; with nobody there to ask, nobody was asked); `refused` is each answer handed in that did not count, with who gave it and why (`already-answered`: that person had answered this wait; `several-people`: one answer named more than one answerer), and the wait stays open after one; the same answer from the same person handed in again is a resend, counts once, and is not refused; `called` is each tool call. An agent a stage calls is an agent's run, under its own limits, approvals and reactions; here each is answered by the case, so what is compared is the workflow. A case expects only what the specification fixes: where the order things happen in is the runtime's to choose (an `each` with no `at-once-at-most:`), the tree a case runs on says `at-once-at-most: 1`. In `answer`, a field the run did not set is absent, not null; a run that stopped hands back an empty record, and why is in `said`. A trained classifier's answer carries `score` beside its label, a share of one: how sure it was of the label it picked, which `if-unsure-below:` is read against. A person's answer may carry `answered-by` beside the fields the question asks for: who answered, which the stage keeps as `steps..answered-by` and does not leave in the answer. On a `decide` that is one name; on a stage that waits for people (`ask`) it is a list, always, in the order they answered, so one name is a list of one. `answered-by:` on the question counts the entries of `asked-of:`: with `everyone` each of them is asked and answers separately, one answer has one answerer, a second, different answer from somebody who has answered is refused and changes nothing, and one no is a no at once; the stage's fields are those of the answer that settled the wait, the first no or the answer that made it enough. In `people`, a list for a question is the answers to it as they arrive, in order, the last repeating; each carries who gave it.", + "about": "Workflows: stages in the order they run, what each called agent and each person was given, and the answer. Replayed as conformance_kit.replay_workflow does: `agents` is what each agent answers when called (a list is one answer for each time it is asked, the last repeating); `tools` is what each `/` hands back; `people` is what a person answers to each question by name (absent: nobody is there to ask, and the run parks). The text `nobody` in place of an answer means the question's time passed with none. `ran-out` names the agents that reached one of their limits in this run and answered with what they had: what each says is still under `agents`, and `steps..ended` of the stage that called one is `ran-out` (`finished` for an agent that did not; a stage that calls anything but an agent has no `.ended`). `ends` is something outside the run ending it, `{before: , port: }`: the arrival at that port, with this run's key, lands when the run is about to begin that stage, so every stage before it has finished and that one never starts; what the arrival does is the port's own lines in the tree (`undo:`, `then-run:`). In `expect`: `trace` is the stages that ran, nested ones as `outer/inner`; `given` is each agent called and its inputs, in order; `shown` is each question put to somebody and what they were shown, once for each entry of the question's `asked-of:` (a question asked of two people is put twice, at once, whoever answers first; with nobody there to ask, nobody was asked); `refused` is each answer handed in that did not count, with who gave it and why (`already-answered`: that person had answered this wait; `several-people`: one answer named more than one answerer), and the wait stays open after one; the same answer from the same person handed in again is a resend, counts once, and is not refused; `called` is each tool call, an undo among them, in the order they were made. `halted` is how the run ended, one of four words: `final`, `parked` (it waits for a person, here or in a workflow it called and waits for; `parked-at` is always a stage of this run), `stopped` (its own stages ended it) and `cancelled` (something outside it did). `why` is the one word for why a `stopped` run stopped: `said-so` (it reached `stop-and-say-so`), `declined` (a person said no and the stage writes no `declined:` exit), `nobody-answered` (a wait ran out and the stage writes no `nobody-answered:` exit), `nobody-decided` (a `decide` ran out of rungs), `a-call-failed`, `reached-a-limit`, `does-not-fit` (what it would hand back is not what the workflow says it gives) or `not-runnable`; `\"\"` for any other ending. `cancelled-by` is what ended a `cancelled` run (`{port: }`), `{}` otherwise; a cancelled run hands back no answer. `started` is each run it started that nobody waits for (a port's `then-run:`), with what it was given: the cancelled run's own input, as much of it as that workflow `accepts:`. An agent a stage calls is an agent's run, under its own limits, approvals and reactions; here each is answered by the case, so what is compared is the workflow. A case expects only what the specification fixes: where the order things happen in is the runtime's to choose (an `each` with no `at-once-at-most:`), the tree a case runs on says `at-once-at-most: 1`. In `answer`, a field the run did not set is absent, not null; a run that stopped hands back an empty record, and why is in `said`. A trained classifier's answer carries `score` beside its label, a share of one: how sure it was of the label it picked, which `if-unsure-below:` is read against. A person's answer may carry `answered-by` beside the fields the question asks for: who answered, which the stage keeps as `steps..answered-by` and does not leave in the answer. On a `decide` that is one name; on a stage that waits for people (`ask`) it is a list, always, in the order they answered, so one name is a list of one. `answered-by:` on the question counts the entries of `asked-of:`: with `everyone` each of them is asked and answers separately, one answer has one answerer, a second, different answer from somebody who has answered is refused and changes nothing, and one no is a no at once; the stage's fields are those of the answer that settled the wait, the first no or the answer that made it enough. In `people`, a list for a question is the answers to it as they arrive, in order, the last repeating; each carries who gave it.", "cases": [ { "name": "a-written-rule-decides-and-nobody-is-asked", @@ -30,6 +30,7 @@ "reply": "billing-desk answered" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "billing-desk", @@ -44,12 +45,14 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "where", "bill", "reply-bill" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -81,6 +84,7 @@ "reply": "tech-desk answered" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "sorter", @@ -101,12 +105,14 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "where", "fix", "reply-fix" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -138,6 +144,7 @@ "reply": "tech-desk answered" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "sorter", @@ -165,12 +172,14 @@ } } ], + "started": [], "trace": [ "where", "fix", "reply-fix" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -200,6 +209,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "sorter", @@ -214,11 +224,13 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "where", "decline" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -243,6 +255,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "sorter", @@ -257,10 +270,12 @@ "refused": [], "said": "waiting on 'which-desk' at 'where'", "shown": [], + "started": [], "trace": [ "where" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -310,6 +325,7 @@ ] }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "billing-desk", @@ -336,13 +352,15 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "every", "every/one", "every/one", "done-all" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -368,6 +386,7 @@ "result": "The page is built." }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "researcher", @@ -395,13 +414,15 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "research", "plan", "make", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -460,6 +481,7 @@ ] }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "proposer", @@ -502,6 +524,7 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "rounds", "rounds/propose", @@ -510,7 +533,8 @@ "rounds/score", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -536,6 +560,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "sorter", @@ -556,13 +581,15 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "every", "every/where", "every/where", "answer" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -597,6 +624,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "sorter", @@ -617,13 +645,15 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "every", "every/where", "every/where", "answer" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -656,6 +686,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "proposer", @@ -678,13 +709,15 @@ "refused": [], "said": "the answer does not fit `answers-with:` — ideas: item 1 has 'idea', and idea has no part by that name\nitem 1 leaves out 'text', which should be some text", "shown": [], + "started": [], "trace": [ "rounds", "rounds/propose", "rounds/score", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "does-not-fit" } }, { @@ -712,6 +745,7 @@ "team": "powerplant" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "triager", @@ -734,12 +768,14 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "triage", "assign", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -766,6 +802,7 @@ "team": "interiors" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "triager", @@ -787,12 +824,14 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "triage", "assign", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -819,6 +858,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "shelver", @@ -839,13 +879,15 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "every", "every/where", "every/where", "answer" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -870,6 +912,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "scorer", @@ -890,12 +933,14 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "every", "every/where", "answer" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -920,6 +965,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "scorer", @@ -934,12 +980,14 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "every", "every/where", "answer" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -963,6 +1011,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "scorer", @@ -983,12 +1032,14 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "every", "every/where", "answer" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1016,6 +1067,7 @@ "shelf": "bills" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "checker", @@ -1030,11 +1082,13 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "where", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1063,6 +1117,7 @@ "who": "dana" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "checker", @@ -1084,11 +1139,13 @@ } } ], + "started": [], "trace": [ "where", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1117,6 +1174,7 @@ "who": "dana" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "checker", @@ -1138,11 +1196,13 @@ } } ], + "started": [], "trace": [ "where", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1171,6 +1231,7 @@ "who": "checker" }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "checker", @@ -1185,11 +1246,13 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "where", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1209,6 +1272,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "checker", @@ -1223,10 +1287,12 @@ "refused": [], "said": "waiting on 'which-shelf' at 'where'", "shown": [], + "started": [], "trace": [ "where" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1256,6 +1322,7 @@ "rounds": 2 }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "drafter", @@ -1276,13 +1343,15 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "polish", "polish/write", "polish/write", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1308,6 +1377,7 @@ "rounds": 3 }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "drafter", @@ -1334,6 +1404,7 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "polish", "polish/write", @@ -1341,7 +1412,8 @@ "polish/write", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1361,6 +1433,7 @@ "expect": { "answer": {}, "called": [], + "cancelled-by": {}, "given": [ { "agent": "triager", @@ -1375,10 +1448,12 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "triage" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1401,6 +1476,7 @@ "rounds": 3 }, "called": [], + "cancelled-by": {}, "given": [ { "agent": "drafter", @@ -1427,6 +1503,7 @@ "refused": [], "said": "", "shown": [], + "started": [], "trace": [ "polish", "polish/write", @@ -1434,7 +1511,8 @@ "polish/write", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1459,6 +1537,7 @@ "close": true }, "called": [], + "cancelled-by": {}, "given": [], "halted": "final", "parked-at": "", @@ -1473,11 +1552,13 @@ } } ], + "started": [], "trace": [ "approve", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1513,6 +1594,7 @@ "outcome": "answered" }, "called": [], + "cancelled-by": {}, "given": [], "halted": "final", "parked-at": "", @@ -1533,11 +1615,13 @@ } } ], + "started": [], "trace": [ "approve", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1572,6 +1656,7 @@ "outcome": "declined" }, "called": [], + "cancelled-by": {}, "given": [], "halted": "final", "parked-at": "", @@ -1592,11 +1677,13 @@ } } ], + "started": [], "trace": [ "approve", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1634,6 +1721,7 @@ "outcome": "answered" }, "called": [], + "cancelled-by": {}, "given": [], "halted": "final", "parked-at": "", @@ -1660,11 +1748,13 @@ } } ], + "started": [], "trace": [ "approve", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" } }, { @@ -1701,6 +1791,7 @@ "outcome": "answered" }, "called": [], + "cancelled-by": {}, "given": [], "halted": "final", "parked-at": "", @@ -1721,11 +1812,717 @@ } } ], + "started": [], "trace": [ "approve", "reply" ], - "unenforced": [] + "unenforced": [], + "why": "" + } + }, + { + "name": "an-agent-that-ran-out-says-so-on-the-stage-that-called-it", + "tree": "tests/conformance/trees/triaged-defects", + "workflow": "draft-once", + "input": { + "brief": "A notice about the new gate." + }, + "agents": { + "drafter": { + "draft": "Gate 4 opens on", + "good": false + } + }, + "ran-out": [ + "drafter" + ], + "people": { + "is-this-draft-enough": { + "send": true, + "answered-by": "ed" + } + }, + "expect": { + "answer": { + "draft": "Gate 4 opens on", + "ended": "ran-out", + "went": "part-done" + }, + "called": [], + "cancelled-by": {}, + "given": [ + { + "agent": "drafter", + "given": { + "brief": "A notice about the new gate." + } + } + ], + "halted": "final", + "parked-at": "", + "question": "", + "refused": [], + "said": "", + "shown": [ + { + "question": "is-this-draft-enough", + "shown": { + "steps.write.draft": "Gate 4 opens on", + "steps.write.ended": "ran-out" + } + } + ], + "started": [], + "trace": [ + "write", + "check", + "look", + "reply" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "an-agent-that-finished-says-so-and-nobody-is-asked", + "tree": "tests/conformance/trees/triaged-defects", + "workflow": "draft-once", + "input": { + "brief": "A notice about the new gate." + }, + "agents": { + "drafter": { + "draft": "Gate 4 opens on Monday.", + "good": true + } + }, + "people": { + "is-this-draft-enough": { + "send": true, + "answered-by": "ed" + } + }, + "expect": { + "answer": { + "draft": "Gate 4 opens on Monday.", + "ended": "finished", + "went": "whole" + }, + "called": [], + "cancelled-by": {}, + "given": [ + { + "agent": "drafter", + "given": { + "brief": "A notice about the new gate." + } + } + ], + "halted": "final", + "parked-at": "", + "question": "", + "refused": [], + "said": "", + "shown": [], + "started": [], + "trace": [ + "write", + "check", + "reply" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "a-label-from-an-agent-that-ran-out-counts-and-the-record-says-it-ran-out", + "tree": "tests/conformance/trees/triaged-defects", + "workflow": "assign-only", + "input": { + "kind": "engine", + "summary": "Oil pressure low on engine 2." + }, + "agents": { + "assigner": { + "team": "powerplant" + } + }, + "ran-out": [ + "assigner" + ], + "expect": { + "answer": { + "ended": "ran-out", + "team": "powerplant" + }, + "called": [], + "cancelled-by": {}, + "given": [ + { + "agent": "assigner", + "given": { + "kind": "engine", + "summary": "Oil pressure low on engine 2." + } + } + ], + "halted": "final", + "parked-at": "", + "question": "", + "refused": [], + "said": "", + "shown": [], + "started": [], + "trace": [ + "assign", + "reply" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "a-run-that-calls-a-workflow-that-waits-is-waiting-too", + "tree": "tests/conformance/trees/triaged-defects", + "workflow": "fix-and-close", + "input": { + "report": "Seat 14C will not recline." + }, + "agents": { + "triager": { + "kind": "cabin", + "summary": "A seat is stuck." + } + }, + "expect": { + "answer": {}, + "called": [], + "cancelled-by": {}, + "given": [ + { + "agent": "triager", + "given": { + "report": "Seat 14C will not recline." + } + } + ], + "halted": "parked", + "parked-at": "close", + "question": "may-the-team-close", + "refused": [], + "said": "waiting on 'may-the-team-close' at 'close' (asked inside 'sign-off-by-the-team/approve')", + "shown": [], + "started": [], + "trace": [ + "triage", + "close" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "a-run-that-calls-a-workflow-goes-on-once-that-one-is-answered", + "tree": "tests/conformance/trees/triaged-defects", + "workflow": "fix-and-close", + "input": { + "report": "Seat 14C will not recline." + }, + "agents": { + "triager": { + "kind": "cabin", + "summary": "A seat is stuck." + } + }, + "people": { + "may-the-team-close": { + "close": true, + "answered-by": "dana" + } + }, + "expect": { + "answer": { + "close": true, + "kind": "cabin" + }, + "called": [], + "cancelled-by": {}, + "given": [ + { + "agent": "triager", + "given": { + "report": "Seat 14C will not recline." + } + } + ], + "halted": "final", + "parked-at": "", + "question": "", + "refused": [], + "said": "", + "shown": [ + { + "question": "may-the-team-close", + "shown": { + "input.report": "Seat 14C will not recline." + } + } + ], + "started": [], + "trace": [ + "triage", + "close", + "reply" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "a-no-with-no-exit-written-for-it-stops-the-run-and-says-so", + "tree": "tests/conformance/trees/triaged-defects", + "workflow": "sign-off-by-the-team", + "input": { + "report": "Seat 14C will not recline." + }, + "agents": {}, + "people": { + "may-the-team-close": { + "close": false, + "answered-by": "dana" + } + }, + "expect": { + "answer": {}, + "called": [], + "cancelled-by": {}, + "given": [], + "halted": "stopped", + "parked-at": "", + "question": "", + "refused": [], + "said": "'approve' asked 'may-the-team-close' and the answer was no; the stage writes no `declined:` exit, so the run stops here", + "shown": [ + { + "question": "may-the-team-close", + "shown": { + "input.report": "Seat 14C will not recline." + } + } + ], + "started": [], + "trace": [ + "approve" + ], + "unenforced": [], + "why": "declined" + } + }, + { + "name": "silence-with-no-exit-written-for-it-stops-the-run-and-says-so", + "tree": "tests/conformance/trees/triaged-defects", + "workflow": "sign-off-by-the-team", + "input": { + "report": "Seat 14C will not recline." + }, + "agents": {}, + "people": { + "may-the-team-close": "nobody" + }, + "expect": { + "answer": {}, + "called": [], + "cancelled-by": {}, + "given": [], + "halted": "stopped", + "parked-at": "", + "question": "", + "refused": [], + "said": "'approve' asked 'may-the-team-close' and nobody answered in time; the stage writes no `nobody-answered:` exit, so the run stops here", + "shown": [ + { + "question": "may-the-team-close", + "shown": { + "input.report": "Seat 14C will not recline." + } + } + ], + "started": [], + "trace": [ + "approve" + ], + "unenforced": [], + "why": "nobody-answered" + } + }, + { + "name": "a-visit-nobody-calls-off-is-booked-and-confirmed", + "tree": "tests/conformance/trees/booked-visits", + "workflow": "book-visit", + "input": { + "visit": "V-204", + "customer": "Ms Okafor" + }, + "agents": {}, + "tools": { + "calendar/hold-slot": { + "slot": "Tuesday 10:00" + }, + "files/open-file": { + "file": "F-7" + }, + "calendar/release-slot": { + "released": true + }, + "files/close-file": { + "closed": true + }, + "files/note": { + "noted": true + } + }, + "people": { + "is-the-visit-confirmed": { + "confirmed": true, + "answered-by": "sam" + } + }, + "expect": { + "answer": { + "confirmed": true + }, + "called": [ + { + "given": { + "visit": "V-204" + }, + "tool": "calendar/hold-slot" + }, + { + "given": { + "visit": "V-204" + }, + "tool": "files/open-file" + } + ], + "cancelled-by": {}, + "given": [], + "halted": "final", + "parked-at": "", + "question": "", + "refused": [], + "said": "", + "shown": [ + { + "question": "is-the-visit-confirmed", + "shown": { + "input.visit": "V-204" + } + } + ], + "started": [], + "trace": [ + "hold", + "file", + "confirm", + "reply" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "a-port-ends-an-open-run-which-is-cancelled-undone-newest-first-and-followed-by-what-the-port-says", + "tree": "tests/conformance/trees/booked-visits", + "workflow": "book-visit", + "input": { + "visit": "V-204", + "customer": "Ms Okafor" + }, + "agents": {}, + "tools": { + "calendar/hold-slot": { + "slot": "Tuesday 10:00" + }, + "files/open-file": { + "file": "F-7" + }, + "calendar/release-slot": { + "released": true + }, + "files/close-file": { + "closed": true + }, + "files/note": { + "noted": true + } + }, + "people": { + "is-the-visit-confirmed": { + "confirmed": true, + "answered-by": "sam" + } + }, + "ends": { + "before": "confirm", + "port": "visit-called-off" + }, + "expect": { + "answer": {}, + "called": [ + { + "given": { + "visit": "V-204" + }, + "tool": "calendar/hold-slot" + }, + { + "given": { + "visit": "V-204" + }, + "tool": "files/open-file" + }, + { + "given": { + "visit": "V-204" + }, + "tool": "files/close-file" + }, + { + "given": { + "visit": "V-204" + }, + "tool": "calendar/release-slot" + } + ], + "cancelled-by": { + "port": "visit-called-off" + }, + "given": [], + "halted": "cancelled", + "parked-at": "", + "question": "", + "refused": [], + "said": "Cancelled by the port `visit-called-off`. What it did was undone, newest first: file, hold. `tell-the-clinic` was started after it.", + "shown": [], + "started": [ + { + "given": { + "visit": "V-204" + }, + "workflow": "tell-the-clinic" + } + ], + "trace": [ + "hold", + "file" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "a-port-that-does-not-say-undo-ends-the-run-and-leaves-what-it-did", + "tree": "tests/conformance/trees/booked-visits", + "workflow": "book-visit", + "input": { + "visit": "V-204", + "customer": "Ms Okafor" + }, + "agents": {}, + "tools": { + "calendar/hold-slot": { + "slot": "Tuesday 10:00" + }, + "files/open-file": { + "file": "F-7" + }, + "calendar/release-slot": { + "released": true + }, + "files/close-file": { + "closed": true + }, + "files/note": { + "noted": true + } + }, + "people": { + "is-the-visit-confirmed": { + "confirmed": true, + "answered-by": "sam" + } + }, + "ends": { + "before": "confirm", + "port": "visit-withdrawn" + }, + "expect": { + "answer": {}, + "called": [ + { + "given": { + "visit": "V-204" + }, + "tool": "calendar/hold-slot" + }, + { + "given": { + "visit": "V-204" + }, + "tool": "files/open-file" + } + ], + "cancelled-by": { + "port": "visit-withdrawn" + }, + "given": [], + "halted": "cancelled", + "parked-at": "", + "question": "", + "refused": [], + "said": "Cancelled by the port `visit-withdrawn`.", + "shown": [], + "started": [], + "trace": [ + "hold", + "file" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "a-newer-arrival-replaces-the-open-run-which-is-cancelled-as-any-other-is", + "tree": "tests/conformance/trees/booked-visits", + "workflow": "book-visit", + "input": { + "visit": "V-204", + "customer": "Ms Okafor" + }, + "agents": {}, + "tools": { + "calendar/hold-slot": { + "slot": "Tuesday 10:00" + }, + "files/open-file": { + "file": "F-7" + }, + "calendar/release-slot": { + "released": true + }, + "files/close-file": { + "closed": true + }, + "files/note": { + "noted": true + } + }, + "people": { + "is-the-visit-confirmed": { + "confirmed": true, + "answered-by": "sam" + } + }, + "ends": { + "before": "file", + "port": "visit-booked-again" + }, + "expect": { + "answer": {}, + "called": [ + { + "given": { + "visit": "V-204" + }, + "tool": "calendar/hold-slot" + }, + { + "given": { + "visit": "V-204" + }, + "tool": "calendar/release-slot" + } + ], + "cancelled-by": { + "port": "visit-booked-again" + }, + "given": [], + "halted": "cancelled", + "parked-at": "", + "question": "", + "refused": [], + "said": "Cancelled by the port `visit-booked-again`. What it did was undone, newest first: hold.", + "shown": [], + "started": [], + "trace": [ + "hold" + ], + "unenforced": [], + "why": "" + } + }, + { + "name": "a-run-ended-before-its-first-stage-has-nothing-to-undo-and-is-still-followed", + "tree": "tests/conformance/trees/booked-visits", + "workflow": "book-visit", + "input": { + "visit": "V-204", + "customer": "Ms Okafor" + }, + "agents": {}, + "tools": { + "calendar/hold-slot": { + "slot": "Tuesday 10:00" + }, + "files/open-file": { + "file": "F-7" + }, + "calendar/release-slot": { + "released": true + }, + "files/close-file": { + "closed": true + }, + "files/note": { + "noted": true + } + }, + "people": { + "is-the-visit-confirmed": { + "confirmed": true, + "answered-by": "sam" + } + }, + "ends": { + "before": "hold", + "port": "visit-called-off" + }, + "expect": { + "answer": {}, + "called": [], + "cancelled-by": { + "port": "visit-called-off" + }, + "given": [], + "halted": "cancelled", + "parked-at": "", + "question": "", + "refused": [], + "said": "Cancelled by the port `visit-called-off`. It had done nothing that needed undoing. `tell-the-clinic` was started after it.", + "shown": [], + "started": [ + { + "given": { + "visit": "V-204" + }, + "workflow": "tell-the-clinic" + } + ], + "trace": [], + "unenforced": [], + "why": "" } } ] From 4747a0d4d17c2d1107030d9639c132d288f14cf8 Mon Sep 17 00:00:00 2001 From: Jithin V G Date: Sun, 15 Nov 2026 00:03:54 +0530 Subject: [PATCH 3/4] The decide that says how its agent ended has an agent of its own `assign-only` shared `assigner` with `fix-item`, and the checker's tests that edit `assigner` then broke a second workflow. Co-Authored-By: Claude Fable 5.2 --- tests/conformance/digests.json | 28 +++++++++---------- .../trees/triaged-defects/defects.pact | 8 +++++- tests/conformance/workflows.json | 6 ++-- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/tests/conformance/digests.json b/tests/conformance/digests.json index 292839b..1a38f76 100644 --- a/tests/conformance/digests.json +++ b/tests/conformance/digests.json @@ -1,5 +1,5 @@ { - "about": "The digests `pact discover` prints for trees in this repository: the tree's own `digest`, and each workflow's `doc-digest` (what a waiting run is pinned to) and `contract-digest` (what its callers rely on), 02W \u00a77.2. A digest is a function of the tree's files and of nothing else: a new version of PACT prints the same digest for the same files, or says in its changelog which trees move and why. Held by crates/pact-cli/tests/a_digest_moves_only_when_the_tree_does.rs. A runtime that pins PACT holds this file with one `pact discover` per tree, at every pin.", + "about": "The digests `pact discover` prints for trees in this repository: the tree's own `digest`, and each workflow's `doc-digest` (what a waiting run is pinned to) and `contract-digest` (what its callers rely on), 02W §7.2. A digest is a function of the tree's files and of nothing else: a new version of PACT prints the same digest for the same files, or says in its changelog which trees move and why. Held by crates/pact-cli/tests/a_digest_moves_only_when_the_tree_does.rs. A runtime that pins PACT holds this file with one `pact discover` per tree, at every pin.", "trees": { "tests/conformance/trees/booked-visits": { "digest": "sha256:682a2d336987da594a761df26694bc74e189b7dabe5649d1ea628985a9acf710", @@ -49,54 +49,54 @@ } }, "tests/conformance/trees/triaged-defects": { - "digest": "sha256:a4f89a092d366315aec9e4788a095bc1b44af5067167080ad94cf212d3688f68", + "digest": "sha256:aa9c60b3f21f2ca98fe1f5a496199e83ee5a30cdc94f4b7c5d1426306c1f0915", "workflows": { "assign-only": { - "doc-digest": "sha256:cd1e74c1b600ff739aa2ff6e6d6034283c096d51549e98c290d5ceb0e9519ff1", + "doc-digest": "sha256:d7838340629b087a5f0e6676ec5df73bf94988f22d0905111252b652cda62111", "contract-digest": "sha256:b352dd9a97d85f29d3d51f3f7d3b5996fb29212b1355b252c5f1d2e8060075d4" }, "classify-only": { - "doc-digest": "sha256:b59b53436cad9cdf66d25e2f8fda26939bb0d751b82c61070a8c99f24aa5a4ca", + "doc-digest": "sha256:ec01ee9167876c8270a9673622fbb52c11c5208a8aa0b0ce56064899f448e01c", "contract-digest": "sha256:373c3db55151ef337d72e5aa2f767e3e19b907188b7c5b7dfdd62c37bcb19dc1" }, "draft-once": { - "doc-digest": "sha256:535285dc9a140ccd4efe815522d85dcc1f89405214b6171246950e863e710085", + "doc-digest": "sha256:72abf25ac8c702f7a1aacf6e17012664f44c182970417417ece12186f4603a4b", "contract-digest": "sha256:4fdf2b23f9b2e49d8a6028c3a96c73c87532fc895e827d1118434528a377d460" }, "draft-three-times": { - "doc-digest": "sha256:ae6db630c231255c7395ed91bb25d9bfee8995d38b8cf5677fe54feaf974b7ef", + "doc-digest": "sha256:1080b8567f812f7b60cdeb1b09600466d9aa332a3252b0f690d9171b2ca020c4", "contract-digest": "sha256:cc88efa5b071c1dd1763bdcc020c70abbb84103a922660589f4f2d80e24cc000" }, "draft-until-good": { - "doc-digest": "sha256:af2c801a16f224fecc273311dd83a366e886164c288e5e8cf61b9933a1beac57", + "doc-digest": "sha256:6a184f3c911923d571ddeeea9c5c365adf97a6d45794e05a43527e4c51990c86", "contract-digest": "sha256:1b9f8469dbe518f30be0660c5e440629cf5e6ef218ce951862b9ba70c4e04e82" }, "fix-and-close": { - "doc-digest": "sha256:9f5296b93d8c6af188a0dec4e6271ef031770cb52411f9805ac6bf5b9c4692aa", + "doc-digest": "sha256:ad212498197994dd25b8cf83a0a9ce544d080db90c93135b44b2e40c963c8ae5", "contract-digest": "sha256:90ee19112c40de4b76db860f50ffd9a48cb8c661a1bfdce7728e272f309f461f" }, "fix-item": { - "doc-digest": "sha256:e255a7535f3ee414bf854f553a04d7931ceed2b366bbdd5f9fd35a1d01e65d1b", + "doc-digest": "sha256:b8b5ea9ce02c33422f2e107b9dbc42487a62d330190781a4d7f5b88ec85e4c2e", "contract-digest": "sha256:8250dadd43a05efd7fb74f2704e1c072ac6d7de417a8b88e3fb5ebf8fe776936" }, "shelve": { - "doc-digest": "sha256:6c4ee068be6b98f7bd1cde310f57fe44874a9b3d58596c949c46059f80df71de", + "doc-digest": "sha256:99e016855d510bd32804c1ab9f84f3a40eb5d6710a61b02515b5d50f6fb5f34e", "contract-digest": "sha256:a3470d96b6e4b7c3ddc1b18e8673d70a81504ecbf800d71ad6adeda492ba84d5" }, "shelve-checked": { - "doc-digest": "sha256:cbceb253fb8f4d131c49bbb5517bbf834345f13d62c96e013a8a0b029f7b269a", + "doc-digest": "sha256:6697da38f66738bf2c48528a2b221b0b04fbb78ed57bd909b5db745f0c19e19b", "contract-digest": "sha256:d5f0555af2f5a9565c92d6900490948d071d442da170f0e493ef63496422f057" }, "shelve-twice": { - "doc-digest": "sha256:ca496ca8356a07902a5070cde0cf3a5075b2128b11bd4980d36006272981d6fe", + "doc-digest": "sha256:35a9987ea520bfbfb6ad415da7399d883b83cbfc20561202e71aa7a6c341245f", "contract-digest": "sha256:a3470d96b6e4b7c3ddc1b18e8673d70a81504ecbf800d71ad6adeda492ba84d5" }, "sign-off": { - "doc-digest": "sha256:aa503f6d839bbb0037eb91a24a12d1a419c99d1eea79f1524224e77a2befdbee", + "doc-digest": "sha256:7f40a980f3585315518208a10e329e2497aa0f29aaa1cd1dd807ee0abace989c", "contract-digest": "sha256:43784b26caa17e0a651853fd8bf660ebc94dd00c32355fd5982ff1a3391d9a69" }, "sign-off-by-the-team": { - "doc-digest": "sha256:4f00c0584348cd70174bec5fb9e960416bd772e099d48a655690e3e80ef9ab54", + "doc-digest": "sha256:2c96ea97f5392831a6d1e26960803a013e4b7c993cdc069c209fd619b7ce428a", "contract-digest": "sha256:e9594ce43d73dec2b1791b98eb1d6b433c5cf313957e421af97778fbc8225140" } } diff --git a/tests/conformance/trees/triaged-defects/defects.pact b/tests/conformance/trees/triaged-defects/defects.pact index 6b85eb7..21a13a5 100644 --- a/tests/conformance/trees/triaged-defects/defects.pact +++ b/tests/conformance/trees/triaged-defects/defects.pact @@ -254,13 +254,19 @@ workflow draft-once # The same word on a `decide` an agent decided: its label counts whether or # not it ran out, and the record says which. +agent team-picker + description: Says which team takes a defect, and may run out of steps doing it. + takes kind: text, summary: text + gives team: one of powerplant, interiors + instructions: Pick the team for this kind of defect. + workflow assign-only description: Pick the team for a defect of a known kind, and say how the agent that picked ended. takes kind: text, summary: text gives team: text, ended: text assign = decide by: - - { agent: assigner } + - { agent: team-picker } if powerplant: reply if interiors: reply reply = answer with team: assign.label, ended: assign.ended diff --git a/tests/conformance/workflows.json b/tests/conformance/workflows.json index 618bb27..429d10e 100644 --- a/tests/conformance/workflows.json +++ b/tests/conformance/workflows.json @@ -1944,12 +1944,12 @@ "summary": "Oil pressure low on engine 2." }, "agents": { - "assigner": { + "team-picker": { "team": "powerplant" } }, "ran-out": [ - "assigner" + "team-picker" ], "expect": { "answer": { @@ -1960,7 +1960,7 @@ "cancelled-by": {}, "given": [ { - "agent": "assigner", + "agent": "team-picker", "given": { "kind": "engine", "summary": "Oil pressure low on engine 2." From 50ea6404893e1bd6b9c69cbf2f3607488654b7ba Mon Sep 17 00:00:00 2001 From: Jithin V G Date: Sun, 15 Nov 2026 00:23:21 +0530 Subject: [PATCH 4/4] Counts Co-Authored-By: Claude Fable 5.2 --- README.md | 4 ++-- docs/90-REVIEW.md | 4 ++-- site-docs/index.md | 2 +- site-docs/status/verified.md | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0afc8f7..7d44666 100644 --- a/README.md +++ b/README.md @@ -1000,11 +1000,11 @@ framework is an extra. |---|---| | **Design** | Thesis, 29 binding decisions, FRD (120 requirements), 60-row refusal ledger — complete | | **Research** | 14 source-grounded studies, ~15,750 lines, over 140 repos (~15 GB) + 57 papers | -| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **5043 tests (1679 Rust + 3364 adapter), clippy clean, TypeScript type-checked** | +| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **5081 tests (1687 Rust + 3394 adapter), clippy clean, TypeScript type-checked** | | **Adapters** | **All 7 named targets**, proven against one shared conformance suite | ```bash -./scripts/test-all.sh # 5043 tests, Rust + 7 adapters, fully offline +./scripts/test-all.sh # 5081 tests, Rust + 7 adapters, fully offline ``` **What is still owed** is not hidden — it lives in diff --git a/docs/90-REVIEW.md b/docs/90-REVIEW.md index 075ab5d..b46f7cb 100644 --- a/docs/90-REVIEW.md +++ b/docs/90-REVIEW.md @@ -80,8 +80,8 @@ them and changes nothing. | | | |---|---| | Schema | **43 groups, 262 field entries, 207 distinct field names** (core 123, expert 69, both 15), 40 closed enums, 27 cross-reference constraints, **0 fields missing `tier:` or `surface:`** | -| Rust | 5 crates, **1679 tests**, `unsafe_code = forbid`, clippy-deny, edition 2024 | -| Python | **47 modules** (`find adapters/python/src -name "*.py" \| wc -l`), **3364 tests**, **nine transports** — seven model-bound (`anthropic`, `autogen`, `langchain`, `langgraph`, `ollama`, `openai_agents`, `pydantic_ai`), one bound to a remote *agent* (`a2a`), one deterministic mock; `_metering.py`, `_summarise.py` and `_tool_choice.py` are shared code the transports import, which is what the `_` says — offline by default | +| Rust | 5 crates, **1687 tests**, `unsafe_code = forbid`, clippy-deny, edition 2024 | +| Python | **47 modules** (`find adapters/python/src -name "*.py" \| wc -l`), **3394 tests**, **nine transports** — seven model-bound (`anthropic`, `autogen`, `langchain`, `langgraph`, `ollama`, `openai_agents`, `pydantic_ai`), one bound to a remote *agent* (`a2a`), one deterministic mock; `_metering.py`, `_summarise.py` and `_tool_choice.py` are shared code the transports import, which is what the `_` says — offline by default | | TypeScript | 5 modules, 2,305 lines, a second port of **the stepping, the ceilings and the stage path** (`harness.ts:15-21`: *"AC-5.3's bar is the stage path"*) — **not** of the loop entire: no interceptor chain, no durable suspension record, no ledger (`notDoneHere`; a park returns `halted: "suspended"` and writes nothing down). This row is load-bearing for the Phase 3 pricing below (E4) | | CLI | **6 verbs**: `check`, `show`, `waits`, `discover`, `card`, `help` | | Worked example | 42 files, 1128 lines, **132 distinct authored keys**, `pact check` → OK (498 settings) | diff --git a/site-docs/index.md b/site-docs/index.md index 1ae2e52..b0c9e39 100644 --- a/site-docs/index.md +++ b/site-docs/index.md @@ -47,7 +47,7 @@ so plainly rather than leaving it to be discovered. | | | |---|---| -| Tests | **5,043** — 1679 Rust, 3364 adapter | +| Tests | **5,081** — 1687 Rust, 3394 adapter | | Schema kinds | **69** | | Framework targets | **7**, over 2 runtimes | | Eve capabilities accounted for | **101 of 101** | diff --git a/site-docs/status/verified.md b/site-docs/status/verified.md index b74b453..00f9d70 100644 --- a/site-docs/status/verified.md +++ b/site-docs/status/verified.md @@ -12,9 +12,9 @@ from an earlier document. | Claim | Value | How to check | |---|---|---| -| Rust tests | **1679** | `cargo test --workspace` | -| Adapter tests | **3364** | `cd adapters/python && uv run pytest tests/ -q` | -| Total | **5,043** | both of the above | +| Rust tests | **1687** | `cargo test --workspace` | +| Adapter tests | **3394** | `cd adapters/python && uv run pytest tests/ -q` | +| Total | **5,081** | both of the above | | TypeScript type-checks | clean | `cd adapters/typescript && npx tsc --noEmit` | | Lints | clean | `cargo clippy --all-targets` | | Worked example loads | 498 settings | `cargo run -p pact-cli -- check examples/refund-desk` |