diff --git a/crates/tinyflows-catalog/src/lib.rs b/crates/tinyflows-catalog/src/lib.rs index fef6ca75..85cc3d8d 100644 --- a/crates/tinyflows-catalog/src/lib.rs +++ b/crates/tinyflows-catalog/src/lib.rs @@ -32,6 +32,11 @@ pub mod build_registry; pub mod graph_policy; pub mod import; pub mod run_registry; +pub mod run_summary; + +#[cfg(test)] +#[path = "run_summary_tests.rs"] +mod run_summary_tests; pub mod types; pub use types::{ diff --git a/crates/tinyflows-catalog/src/run_summary.rs b/crates/tinyflows-catalog/src/run_summary.rs new file mode 100644 index 00000000..0710e6c0 --- /dev/null +++ b/crates/tinyflows-catalog/src/run_summary.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Portable settlement rules for saved workflow run history. + +use serde_json::Value; + +use crate::FlowRunStep; + +/// Reconstructs lightweight steps from an engine run output. +pub fn reconstruct_steps(output: &Value) -> Vec { + output + .get("nodes") + .and_then(Value::as_object) + .map(|nodes| { + nodes + .iter() + .map(|(node_id, slot)| FlowRunStep { + node_id: node_id.clone(), + output: slot.get("items").cloned().unwrap_or(Value::Null), + port: slot.get("port").and_then(Value::as_str).map(str::to_string), + ..Default::default() + }) + .collect() + }) + .unwrap_or_default() +} + +/// Merges observed steps with reconstructed output, retaining richer observed +/// timing, status, and diagnostic fields for matching nodes. +pub fn settle_steps(observed: Vec, output: &Value) -> Vec { + let reconstructed = reconstruct_steps(output); + if observed.is_empty() { + return reconstructed; + } + let mut settled = observed; + for step in reconstructed { + if let Some(existing) = settled + .iter_mut() + .find(|existing| existing.node_id == step.node_id) + { + // The live observer has richer timing/status data, but only the + // post-hoc output knows which branch a routing node selected. + if existing.port.is_none() { + existing.port = step.port; + } + } else { + settled.push(step); + } + } + settled +} + +/// A terminal run classification suitable for persisting in [`crate::FlowRun`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalRunStatus { + /// Persisted status string. + pub status: &'static str, + /// Error text when a continued/routed step failed. + pub error: Option, +} + +/// Classifies settled steps, with a pending approval taking precedence. +pub fn terminal_status(steps: &[FlowRunStep], pending_approvals: &[String]) -> TerminalRunStatus { + if !pending_approvals.is_empty() { + return TerminalRunStatus { + status: "pending_approval", + error: None, + }; + } + let failed: Vec<&str> = steps + .iter() + .filter(|step| step.status.as_deref() == Some("error")) + .map(|step| step.node_id.as_str()) + .collect(); + if !failed.is_empty() { + return TerminalRunStatus { + status: "failed", + error: Some(format!( + "node(s) failed after retries: {}", + failed.join(", ") + )), + }; + } + if steps.iter().any(|step| !step.diagnostics.is_empty()) { + TerminalRunStatus { + status: "completed_with_warnings", + error: None, + } + } else { + TerminalRunStatus { + status: "completed", + error: None, + } + } +} diff --git a/crates/tinyflows-catalog/src/run_summary_tests.rs b/crates/tinyflows-catalog/src/run_summary_tests.rs new file mode 100644 index 00000000..5bab992f --- /dev/null +++ b/crates/tinyflows-catalog/src/run_summary_tests.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use crate::FlowRunStep; +use crate::run_summary::*; +use serde_json::json; + +fn step(node_id: &str) -> FlowRunStep { + FlowRunStep { + node_id: node_id.to_string(), + output: json!([]), + ..Default::default() + } +} + +#[test] +fn reconstruct_steps_reads_items_and_port() { + let steps = reconstruct_steps(&json!({ + "nodes": {"switch": {"items": [{"json": {"ok": true}}], "port": "true"}} + })); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].node_id, "switch"); + assert_eq!(steps[0].port.as_deref(), Some("true")); + assert_eq!(steps[0].output, json!([{"json": {"ok": true}}])); +} + +#[test] +fn settle_steps_keeps_observed_data_and_fills_routing_port() { + let mut observed = step("switch"); + observed.status = Some("success".to_string()); + let settled = settle_steps( + vec![observed], + &json!({"nodes": {"switch": {"items": [], "port": "false"}, "done": {"items": []}}}), + ); + assert_eq!(settled.len(), 2); + assert_eq!(settled[0].status.as_deref(), Some("success")); + assert_eq!(settled[0].port.as_deref(), Some("false")); + assert_eq!(settled[1].node_id, "done"); +} + +#[test] +fn terminal_status_prioritizes_approval_then_failure_then_warnings() { + assert_eq!( + terminal_status(&[], &["approval".to_string()]).status, + "pending_approval" + ); + let mut failed = step("broken"); + failed.status = Some("error".to_string()); + assert_eq!(terminal_status(&[failed], &[]).status, "failed"); + let mut warning = step("warn"); + warning.diagnostics.push(json!({"location": "args.x"})); + assert_eq!( + terminal_status(&[warning], &[]).status, + "completed_with_warnings" + ); + assert_eq!(terminal_status(&[], &[]).status, "completed"); +} diff --git a/crates/tinyflows-copilot/src/lib.rs b/crates/tinyflows-copilot/src/lib.rs index 1fa6c78f..b3b22fc2 100644 --- a/crates/tinyflows-copilot/src/lib.rs +++ b/crates/tinyflows-copilot/src/lib.rs @@ -26,5 +26,6 @@ pub mod builder; pub mod prompts; +pub mod resources; pub use builder::{BuildMode, BuilderRequest, render_prompt}; diff --git a/crates/tinyflows-copilot/src/resources.rs b/crates/tinyflows-copilot/src/resources.rs new file mode 100644 index 00000000..e453b25d --- /dev/null +++ b/crates/tinyflows-copilot/src/resources.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Reference material for authors of tinyflows graphs. +//! +//! Hosts decide how these bytes are exposed to an agent. The content itself +//! is portable: it documents the graph language and deliberately avoids a +//! dependency on a particular harness or skill runtime. + +/// One file in a portable authoring resource bundle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResourceFile { + /// Slash-separated path relative to the bundle root. + pub path: &'static str, + /// UTF-8 resource contents. + pub contents: &'static str, +} + +/// The flow-authoring manual's manifest page. +pub const FLOW_AUTHORING_WORKFLOW: &str = include_str!("resources/flow-authoring/WORKFLOW.md"); +/// Expression and jq reference page. +pub const FLOW_AUTHORING_EXPRESSIONS: &str = + include_str!("resources/flow-authoring/references/expressions.md"); +/// Node-configuration reference page. +pub const FLOW_AUTHORING_NODE_CONFIG: &str = + include_str!("resources/flow-authoring/references/node-config.md"); +/// Dry-run interpretation reference page. +pub const FLOW_AUTHORING_DRY_RUN: &str = + include_str!("resources/flow-authoring/references/dry-run.md"); + +/// Every file in the portable `flow-authoring` manual. +pub const FLOW_AUTHORING_FILES: &[ResourceFile] = &[ + ResourceFile { + path: "WORKFLOW.md", + contents: FLOW_AUTHORING_WORKFLOW, + }, + ResourceFile { + path: "references/expressions.md", + contents: FLOW_AUTHORING_EXPRESSIONS, + }, + ResourceFile { + path: "references/node-config.md", + contents: FLOW_AUTHORING_NODE_CONFIG, + }, + ResourceFile { + path: "references/dry-run.md", + contents: FLOW_AUTHORING_DRY_RUN, + }, +]; + +#[cfg(test)] +#[path = "resources_tests.rs"] +mod tests; diff --git a/crates/tinyflows-copilot/src/resources/flow-authoring/WORKFLOW.md b/crates/tinyflows-copilot/src/resources/flow-authoring/WORKFLOW.md new file mode 100644 index 00000000..8282c4b4 --- /dev/null +++ b/crates/tinyflows-copilot/src/resources/flow-authoring/WORKFLOW.md @@ -0,0 +1,54 @@ + +--- +name: flow-authoring +description: The tinyflows authoring reference — expression and jq syntax, node configuration for memory/dedup/trigger nodes, per-node error handling, and how to read a dry run honestly. Read a page before configuring the thing it covers. +metadata: + version: "1.0.0" + author: tinyflows + tags: + - flows + - workflows + - authoring + - reference +allowed-tools: + - read_workflow_resource + - get_node_kind_contract + - list_node_kinds +--- + +# Authoring a tinyflows workflow + +This is a **reference manual, not a procedure**. It holds the exact rules that +are too long to keep in a system prompt and too precise to reconstruct from +memory: an expression convention you half-remember produces a graph that +validates and then does the wrong thing at run time. + +Read the one page that covers what you are about to configure. + +| page | read it before you | +| --- | --- | +| `references/expressions.md` | write any `=` expression or jq filter, or attach a produced file to an outbound action | +| `references/node-config.md` | configure a `memory`, `dedup` or `trigger` node, or set per-node error handling | +| `references/dry-run.md` | report what a dry run did and did not prove | + +Fetch one with: + +``` +read_workflow_resource { skill_id: "flow-authoring", relative_path: "references/expressions.md" } +``` + +## What is deliberately not here + +**Per-kind configuration.** `get_node_kind_contract { kind }` returns a node +kind's config fields, ports, a worked example and its gotchas, and it is +generated from the same catalog the validator enforces — so it cannot go stale +the way this text can. Where the two disagree, the contract tool is right. +These pages cover the rules that span kinds, which is why they have nowhere +generated to live. + +**The rules you must not break.** Propose rather than persist, ask before a +real run, ground every slug, prefer the minimal viable graph — those stay in +the system prompt, because a rule that only binds once someone chooses to read +it is not a rule. Graph sizing was moved here during an earlier pass and moved +back for exactly that reason: it shapes every graph, including the ones built +without opening a manual. diff --git a/crates/tinyflows-copilot/src/resources/flow-authoring/references/dry-run.md b/crates/tinyflows-copilot/src/resources/flow-authoring/references/dry-run.md new file mode 100644 index 00000000..3e7dbd27 --- /dev/null +++ b/crates/tinyflows-copilot/src/resources/flow-authoring/references/dry-run.md @@ -0,0 +1,22 @@ + +# Reading a dry run + +A dry run evaluates the graph without committing the real side effects. It is +useful for checking node wiring, expression results, branches, and the actions +the graph would request. It is not evidence that external services accepted a +request, that credentials are valid, or that a real action completed. + +## Report results precisely + +Say which input was used, which branches executed, and which proposed actions +were observed. Separate values that were evaluated from actions that would +have happened. Never describe a dry run as sending a message, writing memory, +or modifying an integration. + +## Limits + +Use non-secret representative data. Test alternate branches and absent +optional values when they affect a decision. A dry run cannot prove behavior +behind a live provider, approval prompt, network failure, rate limit, or +permission boundary; name those remaining checks before asking to run the +workflow for real. diff --git a/crates/tinyflows-copilot/src/resources/flow-authoring/references/expressions.md b/crates/tinyflows-copilot/src/resources/flow-authoring/references/expressions.md new file mode 100644 index 00000000..4b90240d --- /dev/null +++ b/crates/tinyflows-copilot/src/resources/flow-authoring/references/expressions.md @@ -0,0 +1,31 @@ + +# Expressions + +Use a literal value unless a field must be derived at run time. A value that +starts with `=` is evaluated as an expression; all other values are passed to +the node unchanged. + +## Inputs and results + +Build expressions from the values exposed by the node contract. Start by +reading `get_node_kind_contract` for the node being configured, then use the +documented input and output names exactly. Do not guess a path from a label: +a graph can validate while a guessed path evaluates to `null` at run time. + +Use jq only for a transformation that cannot be represented by selecting a +field. Keep filters small, preserve the value type expected by the destination +port, and account for absent optional fields with `?` or an explicit default. + +## Files and outbound actions + +An attachment must come from an output that is documented as a file or binary +artifact. Do not turn arbitrary text into a path, and do not put a local path +in a remote-action configuration. Inspect the producing node's output +contract before wiring it to an outbound action. + +## Check before proposing + +Before presenting a graph, use the dry-run tool on representative, non-secret +input. A successful expression evaluation proves only the exercised shape of +data; it does not prove that optional fields will exist in every production +run. diff --git a/crates/tinyflows-copilot/src/resources/flow-authoring/references/node-config.md b/crates/tinyflows-copilot/src/resources/flow-authoring/references/node-config.md new file mode 100644 index 00000000..d4369830 --- /dev/null +++ b/crates/tinyflows-copilot/src/resources/flow-authoring/references/node-config.md @@ -0,0 +1,31 @@ + +# Node configuration + +`get_node_kind_contract` is the source of truth for a node's fields, ports, +examples, and validation rules. Read it before configuring any node; this +page records only conventions shared by several node kinds. + +## Memory and deduplication + +Give memory operations a stable source scope. Scope identifies where a fact +comes from; an item ID is only a deduplication key and must not be used as the +collection scope. Choose a deterministic deduplication key from data that is +available on every run. Do not use a timestamp or generated UUID when the +intent is to suppress repeated work. + +## Triggers + +Make a trigger narrow enough that its input shape and authorization boundary +are clear. A trigger begins a run; it does not grant an action permission. +Any side effect still needs the normal approval and policy path. + +## Error handling + +Set per-node error handling deliberately. Continue only when later nodes can +produce a correct result without this node's output. Prefer a visible failure +for required inputs and side effects; swallowing an error may make a completed +run misleading. + +After configuring a node, inspect its port types and connect only compatible +outputs. A graph proposal should state the purpose of each non-default error +policy so the user can review its consequence. diff --git a/crates/tinyflows-copilot/src/resources_tests.rs b/crates/tinyflows-copilot/src/resources_tests.rs new file mode 100644 index 00000000..3fc5ab0f --- /dev/null +++ b/crates/tinyflows-copilot/src/resources_tests.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use super::*; + +fn resource_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/resources/flow-authoring") +} + +#[test] +fn flow_authoring_manifest_matches_files_on_disk() { + let root = resource_dir(); + let mut on_disk = Vec::new(); + let mut pending = vec![root.clone()]; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(dir) + .expect("read resource directory") + .flatten() + { + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else { + on_disk.push( + path.strip_prefix(&root) + .expect("resource under root") + .to_string_lossy() + .replace('\\', "/"), + ); + } + } + } + on_disk.sort(); + + let mut listed: Vec<_> = FLOW_AUTHORING_FILES.iter().map(|file| file.path).collect(); + listed.sort(); + assert_eq!(listed, on_disk); +} + +#[test] +fn flow_authoring_manifest_links_every_resource() { + for file in FLOW_AUTHORING_FILES { + if file.path == "WORKFLOW.md" { + continue; + } + assert!( + FLOW_AUTHORING_WORKFLOW.contains(file.path), + "manifest does not link {}", + file.path + ); + } +} diff --git a/crates/tinyflows/src/diagnostics.rs b/crates/tinyflows/src/diagnostics.rs index c63fd887..7c8cfa73 100644 --- a/crates/tinyflows/src/diagnostics.rs +++ b/crates/tinyflows/src/diagnostics.rs @@ -187,7 +187,7 @@ pub fn diagnose(graph: &WorkflowGraph, steps: &[ExecutionStep]) -> Diagnosis { } diagnosis.never_ran.push(NeverRan { node_id: node.id.clone(), - routed_by: upstream_condition(graph, &node.id), + routed_by: nearest_upstream_condition(graph, &node.id), }); } @@ -240,7 +240,7 @@ fn null_binding( /// Named so the warning can say *why* a node was skipped. "`notify` never ran" /// sends an author looking at `notify`; "`notify` never ran — `check` routed /// past it" sends them to the node that actually decided. -fn upstream_condition(graph: &WorkflowGraph, node_id: &str) -> Option { +pub fn nearest_upstream_condition(graph: &WorkflowGraph, node_id: &str) -> Option { let mut seen: HashSet<&str> = HashSet::from([node_id]); let mut queue: VecDeque<&str> = VecDeque::from([node_id]); @@ -267,7 +267,7 @@ fn upstream_condition(graph: &WorkflowGraph, node_id: &str) -> Option { } /// The message an errored step left in its output, if it left a readable one. -fn error_message(output: &serde_json::Value) -> Option { +pub fn error_message(output: &serde_json::Value) -> Option { output .get("error") .and_then(|e| { @@ -278,6 +278,18 @@ fn error_message(output: &serde_json::Value) -> Option { .filter(|message| !message.trim().is_empty()) } +/// Returns an error message from a node's emitted items in an engine run +/// output (`output["nodes"][node_id]["items"]`). +pub fn node_error_message(output: &serde_json::Value, node_id: &str) -> Option { + output + .get("nodes")? + .get(node_id)? + .get("items")? + .as_array()? + .iter() + .find_map(|item| item.get("json").and_then(error_message)) +} + /// A [`CapturingObserver`] as the engine's observer handle. pub fn capturing() -> (Arc, Arc) { let observer = Arc::new(CapturingObserver::default()); diff --git a/crates/tinyflows/src/diagnostics_tests.rs b/crates/tinyflows/src/diagnostics_tests.rs index d7c5578e..12cdd19f 100644 --- a/crates/tinyflows/src/diagnostics_tests.rs +++ b/crates/tinyflows/src/diagnostics_tests.rs @@ -206,6 +206,22 @@ fn a_failure_with_no_readable_message_is_still_reported() { ); } +#[test] +fn node_error_message_reads_error_from_emitted_item_json() { + let output = json!({ + "nodes": { + "notify": { + "items": [{"json": {"error": "slug not allowlisted"}}] + } + } + }); + + assert_eq!( + node_error_message(&output, "notify").as_deref(), + Some("slug not allowlisted") + ); +} + // ---- nodes that never ran ---- #[test]