mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
2951 lines
132 KiB
Rust
2951 lines
132 KiB
Rust
//! Business logic for the `flows::` domain: validate-on-save CRUD plus the
|
|
//! end-to-end `flows_run` / `flows_resume` path. Delegated to from
|
|
//! `schemas.rs`'s `handle_*` RPC/CLI handlers, mirroring
|
|
//! `src/openhuman/cron/ops.rs`.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use chrono::Utc;
|
|
use serde_json::{json, Value};
|
|
use tinyflows::model::{NodeKind, TriggerKind, WorkflowGraph};
|
|
|
|
use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource};
|
|
use crate::openhuman::config::Config;
|
|
use crate::openhuman::flows::bus;
|
|
use crate::openhuman::flows::run_registry;
|
|
use crate::openhuman::flows::store;
|
|
use crate::openhuman::flows::types::{
|
|
FlowConnection, FlowRunStep, FlowRunTrigger, FlowSuggestion, SuggestionStatus,
|
|
};
|
|
use crate::openhuman::flows::{Flow, FlowRun};
|
|
use crate::rpc::RpcOutcome;
|
|
|
|
/// Overall safety bound on a single `flows_run` / `flows_resume`. Individual
|
|
/// capabilities have their own timeouts (HTTP, sandbox), but a hung LLM/tool
|
|
/// call must never let the RPC block indefinitely — this caps the whole run.
|
|
const FLOW_RUN_TIMEOUT_SECS: u64 = 600;
|
|
|
|
/// How long a run may sit parked at a human-in-the-loop approval gate
|
|
/// (`pending_approval`) before the TTL sweep expires it to a terminal
|
|
/// `"cancelled"` (issue G4). Aligned with the agent tool-call `ApprovalGate`'s
|
|
/// 10-minute fail-closed TTL (`src/openhuman/approval/`), so a flow HITL gate a
|
|
/// human never answers doesn't wedge a run — and its durable checkpoint —
|
|
/// forever. The two are distinct mechanisms (flow runs execute as
|
|
/// `TrustedAutomation { Workflow }`, which the tool-call gate lets through), so
|
|
/// this is a dedicated flows-side TTL, not a reuse of the approval store's.
|
|
const FLOW_PARKED_TTL_SECS: i64 = 600;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Phase 2 — autonomy-tier gating of acting flow nodes
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// A `flows_run` / `flows_resume` executes under a `TrustedAutomation { Workflow }`
|
|
// origin (see `workflow_origin` below), but the *acting power* of a run is still
|
|
// bounded by the user's `[autonomy]` tier — the same `SecurityPolicy`
|
|
// (`src/openhuman/security/`) the agent tool-loop honors, built via
|
|
// `SecurityPolicy::from_config(&config.autonomy, …)` inside
|
|
// `tinyflows::caps::build_capabilities`.
|
|
//
|
|
// Before an acting node dispatches, its capability adapter
|
|
// (`src/openhuman/tinyflows/caps.rs::enforce_node_tier_gate`) maps the node to a
|
|
// `CommandClass` and consults `SecurityPolicy::gate_decision`. `Block` refuses
|
|
// outright (`[policy-blocked]` error, no dispatch); `Prompt`/`Allow` fall through
|
|
// to the process-global `ApprovalGate`, which performs the human round-trip for
|
|
// `Prompt` exactly as the agent tool-loop does. Node → class → per-tier decision:
|
|
//
|
|
// Flow node CommandClass read-only supervised full
|
|
// ──────────── ──────────── ────────── ────────── ──────────
|
|
// http_request Network BLOCK Prompt Prompt
|
|
// code Write BLOCK Prompt Allow
|
|
// tool_call (curation + (curated + Prompt Prompt/Allow¹
|
|
// ApprovalGate) scope gate)
|
|
// agent (llm) — (no acting side effect; not tier-gated, only the
|
|
// inference/privacy chokepoint applies)
|
|
// state (kv) — (host-internal flow KV; not an outbound act)
|
|
//
|
|
// ¹ tool_call routes through the deny-by-default curation/scope gate plus the
|
|
// ApprovalGate rather than `gate_decision`; a Network-class Composio action
|
|
// still prompts under supervised/full and the curation gate is the hard
|
|
// allowlist. See `caps.rs::OpenHumanTools`.
|
|
//
|
|
// `Network` is never `Allow` in any tier (always `Prompt` when not blocked), so
|
|
// even a full-tier http_request node prompts unless a pre-declared trust root /
|
|
// `auto_approve` short-circuits the ApprovalGate — matching `curl`/`shell`.
|
|
// `Write` (code) is `Allow` under full, so trusted automations run sandboxed
|
|
// code unattended; read-only blocks both outright.
|
|
|
|
/// Runs a raw graph JSON value through `tinyflows::migrate::migrate` (upgrade
|
|
/// an older-schema definition to current), deserializes it, and rejects a
|
|
/// structurally invalid graph via `tinyflows::validate::validate` — so a bad
|
|
/// graph is caught at the door, before it's ever persisted.
|
|
///
|
|
/// `pub(crate)` (not private) so `flows::tools::ProposeWorkflowTool` (issue
|
|
/// B4 — agent-first workflow authoring) can run a candidate graph through the
|
|
/// exact same validate/migrate path `flows_create` uses below, without
|
|
/// duplicating it. The tool only calls this — never `flows_create` itself —
|
|
/// which is what keeps the "the agent can never create a flow" invariant
|
|
/// intact: this function validates and returns, it has no persistence effect.
|
|
pub(crate) fn validate_and_migrate_graph(graph_json: Value) -> Result<WorkflowGraph, String> {
|
|
let migrated = tinyflows::migrate::migrate(graph_json).map_err(|e| e.to_string())?;
|
|
let graph: WorkflowGraph = serde_json::from_value(migrated).map_err(|e| e.to_string())?;
|
|
tinyflows::validate::validate(&graph).map_err(|e| e.to_string())?;
|
|
Ok(graph)
|
|
}
|
|
|
|
/// Stable snake_case label for a [`TriggerKind`], matching its serde wire
|
|
/// discriminator — used in loud author-facing warnings (not derived via serde
|
|
/// so the exact human string is unmistakable at the call site).
|
|
fn trigger_kind_label(kind: &TriggerKind) -> &'static str {
|
|
match kind {
|
|
TriggerKind::Manual => "manual",
|
|
TriggerKind::Schedule => "schedule",
|
|
TriggerKind::Webhook => "webhook",
|
|
TriggerKind::AppEvent => "app_event",
|
|
TriggerKind::Form => "form",
|
|
TriggerKind::ExecuteByWorkflow => "execute_by_workflow",
|
|
TriggerKind::ChatMessage => "chat_message",
|
|
TriggerKind::Evaluation => "evaluation",
|
|
TriggerKind::System => "system",
|
|
}
|
|
}
|
|
|
|
/// Whether a flow's trigger kind currently produces *automatic* runs in this
|
|
/// host. Only three kinds fire today:
|
|
/// - `manual` — runnable on demand via `flows_run` (no automatic dispatch, but
|
|
/// that's the whole contract of a manual trigger — never a surprise).
|
|
/// - `schedule` — a `cron` job drives `FlowScheduleTick` (see
|
|
/// [`bind_schedule_trigger`]).
|
|
/// - `app_event` — matched against `ComposioTriggerReceived` at dispatch time
|
|
/// (see `flows::bus::FlowTriggerSubscriber`).
|
|
///
|
|
/// Everything else (`webhook`, `chat_message`, `form`, `execute_by_workflow`,
|
|
/// `evaluation`, `system`) is *accepted and saved* but has no wired dispatch
|
|
/// path yet — enabling such a flow silently produces a flow that never runs
|
|
/// itself. [`graph_trigger_warnings`] turns that silence into a loud warning.
|
|
fn trigger_kind_fires(kind: &TriggerKind) -> bool {
|
|
matches!(
|
|
kind,
|
|
TriggerKind::Manual | TriggerKind::Schedule | TriggerKind::AppEvent
|
|
)
|
|
}
|
|
|
|
/// Produces host-side, **non-fatal** validation warnings for a graph — today
|
|
/// exactly one: "this trigger kind does not fire automatically yet". Returns
|
|
/// an empty vec when the trigger fires (`manual`/`schedule`/`app_event`), when
|
|
/// the graph has no single resolvable trigger node, or when the trigger has no
|
|
/// `trigger_kind` discriminator (a legacy/manual-only graph authored before
|
|
/// B2 simply never self-fires — not a warnable surprise, matching
|
|
/// `bus::extract_trigger_kind`'s "no automatic binding" treatment).
|
|
///
|
|
/// This lives host-side (NOT in `tinyflows::validate`, which is host-agnostic
|
|
/// and only does structural checks) because "which trigger kinds this host has
|
|
/// wired" is an OpenHuman fact, not a property of the portable graph.
|
|
pub(crate) fn graph_trigger_warnings(graph: &WorkflowGraph) -> Vec<String> {
|
|
let Some(trigger) = graph.trigger() else {
|
|
return Vec::new();
|
|
};
|
|
let Some(kind_value) = trigger.config.get("trigger_kind") else {
|
|
return Vec::new();
|
|
};
|
|
let kind: TriggerKind = match serde_json::from_value(kind_value.clone()) {
|
|
Ok(k) => k,
|
|
Err(_) => return Vec::new(),
|
|
};
|
|
if trigger_kind_fires(&kind) {
|
|
return Vec::new();
|
|
}
|
|
let label = trigger_kind_label(&kind);
|
|
vec![format!(
|
|
"Trigger kind '{label}' does not fire automatically yet — this flow will be saved and \
|
|
can be enabled, but nothing will run it on its own until that trigger is wired up. Run \
|
|
it manually with flows_run, or switch to a `schedule` or `app_event` trigger."
|
|
)]
|
|
}
|
|
|
|
/// Author-time wiring warnings for Composio `tool_call` nodes: flags every
|
|
/// **required** arg (per the action's schema, best-effort cached lookup) that
|
|
/// is absent or a literal `null` in `config.args` — the exact mis-wiring that
|
|
/// would later fail the run's required-arg preflight.
|
|
///
|
|
/// Static by design: an arg carrying an `=`-expression counts as wired (only
|
|
/// the runtime preflight can tell whether it resolves), a `=`-derived slug is
|
|
/// skipped (can't know the action), and native `oh:` tools are skipped (no
|
|
/// Composio schema). Best-effort like the runtime preflight — no schema, no
|
|
/// warning, never a block.
|
|
pub(crate) async fn graph_wiring_warnings(config: &Config, graph: &WorkflowGraph) -> Vec<String> {
|
|
use crate::openhuman::tinyflows::caps::{composio_required_args, missing_required_args};
|
|
|
|
let mut warnings = Vec::new();
|
|
for node in &graph.nodes {
|
|
if node.kind != tinyflows::model::NodeKind::ToolCall {
|
|
continue;
|
|
}
|
|
let Some(slug) = node.config.get("slug").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
// `=`-derived slugs are resolved at runtime; native tools have no
|
|
// Composio schema to check against.
|
|
if slug.starts_with('=') || slug.starts_with("oh:") {
|
|
continue;
|
|
}
|
|
let Some(required) = composio_required_args(config, slug).await else {
|
|
tracing::debug!(target: "flows", node = %node.id, %slug, "[flows] wiring check: no schema — skipping node");
|
|
continue;
|
|
};
|
|
let args = node.config.get("args").cloned().unwrap_or(Value::Null);
|
|
for missing in missing_required_args(&required, &args) {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
arg = %missing,
|
|
"[flows] wiring check: required arg not wired"
|
|
);
|
|
warnings.push(format!(
|
|
"Node '{}': required arg `{missing}` of `{slug}` is not wired — set \
|
|
args.{missing}, e.g. \"=nodes.<upstream_id>.item.json.<field>\" (an agent \
|
|
feeding this value needs an output schema — `output_parser.schema` — so its \
|
|
fields are addressable).",
|
|
node.id
|
|
));
|
|
}
|
|
}
|
|
|
|
warnings.extend(graph_output_field_warnings(config, graph).await);
|
|
warnings.extend(graph_split_out_path_warnings(config, graph).await);
|
|
warnings
|
|
}
|
|
|
|
/// Author-time WARN (systemic tool-contract fix, Part 2c): any
|
|
/// `=nodes.<id>.item.json.data.<field>` binding — anywhere in the graph, not
|
|
/// just `tool_call` args — whose `<id>` names a `tool_call` node calling a
|
|
/// REAL Composio action with a KNOWN live output schema, but whose `<field>`
|
|
/// is not one of that action's real `output_fields`. Also warns (a distinct
|
|
/// message) when the binding is missing the `data.` segment entirely — a
|
|
/// Composio `tool_call`'s real runtime output always wraps its payload in
|
|
/// `data` (`ComposioExecuteResponse`; see
|
|
/// [`crate::openhuman::tinyflows::caps::ToolContract::output_fields`]'s doc),
|
|
/// so `=nodes.<id>.item.json.<field>` (no `data.`) is GUARANTEED to resolve
|
|
/// `null` even when `<field>` names a real output field — that used to be
|
|
/// silently accepted here (B1: the exact bug that produces a hollow run).
|
|
/// Advisory, not fatal: a binding to an unknown field could still resolve to
|
|
/// something useful at runtime for an action whose output schema is
|
|
/// incomplete, so this warns rather than rejects — mirroring
|
|
/// `graph_wiring_warnings`'s existing required-arg warnings.
|
|
///
|
|
/// Skipped entirely when the referenced action's output schema is
|
|
/// **unknown** (`ToolContract::output_schema` is `None`) — there is nothing
|
|
/// real to check the field against, so warning would just be noise (or a
|
|
/// false positive for a still-legitimate binding). Also skipped for a
|
|
/// binding that dereferences `.item.<field>` without `.json` on an
|
|
/// enveloping node — that shape is already a HARD reject in
|
|
/// [`validate_binding_resolvability`], not a warning here.
|
|
///
|
|
/// Also skipped for a binding that addresses the whole payload
|
|
/// (`=nodes.<id>.item.json.data`, e.g. as an agent `input_context`) or one
|
|
/// of `ComposioExecuteResponse`'s OTHER top-level envelope fields —
|
|
/// `successful`, `error`, `costUsd`, `markdownFormatted` — which live
|
|
/// alongside `data`, not inside it. `OpenHumanTools::invoke` serializes the
|
|
/// whole `ComposioExecuteResponse` verbatim, so these ARE real
|
|
/// `.item.json.<x>` fields with no `data.` prefix; flagging them as
|
|
/// "missing the `data.` segment" would rewire an already-correct binding to
|
|
/// a nonsense path (e.g. suggesting `.item.json.data.successful`).
|
|
async fn graph_output_field_warnings(config: &Config, graph: &WorkflowGraph) -> Vec<String> {
|
|
use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug;
|
|
use crate::openhuman::tinyflows::caps::fetch_live_toolkit_catalog;
|
|
|
|
let mut warnings = Vec::new();
|
|
for node in &graph.nodes {
|
|
for (location, expr) in collect_expressions(&node.config) {
|
|
let Some((ref_id, has_json, field_path)) = parse_node_binding(&expr) else {
|
|
continue;
|
|
};
|
|
if !has_json {
|
|
continue;
|
|
}
|
|
let Some(ref_node) = graph.node(&ref_id) else {
|
|
continue;
|
|
};
|
|
if ref_node.kind != NodeKind::ToolCall {
|
|
continue;
|
|
}
|
|
let Some(ref_slug) = ref_node.config.get("slug").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
if ref_slug.starts_with('=') || ref_slug.starts_with("oh:") {
|
|
continue;
|
|
}
|
|
let Some(ref_toolkit) = toolkit_from_slug(ref_slug) else {
|
|
continue;
|
|
};
|
|
let Some(catalog) = fetch_live_toolkit_catalog(config, &ref_toolkit).await else {
|
|
continue;
|
|
};
|
|
let Some(contract) = catalog
|
|
.iter()
|
|
.find(|c| c.slug.eq_ignore_ascii_case(ref_slug))
|
|
else {
|
|
continue;
|
|
};
|
|
// Output schema unknown — nothing real to check `field_path` against.
|
|
if contract.output_schema.is_none() {
|
|
continue;
|
|
}
|
|
|
|
// Whole-payload access (`.item.json.data`, e.g. an agent's
|
|
// `input_context`) or one of `ComposioExecuteResponse`'s OTHER
|
|
// top-level envelope fields — these live alongside `data`, not
|
|
// inside it, and are real fields regardless of this action's
|
|
// `output_fields` (see this fn's doc). Not a "missing `data.`"
|
|
// mistake.
|
|
const COMPOSIO_ENVELOPE_METADATA_FIELDS: &[&str] =
|
|
&["successful", "error", "costUsd", "markdownFormatted"];
|
|
if field_path == "data"
|
|
|| COMPOSIO_ENVELOPE_METADATA_FIELDS
|
|
.contains(&field_path.split('.').next().unwrap_or(&field_path))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// A real Composio tool_call's payload is always nested one level
|
|
// under `data` (see this fn's doc) — a binding missing that
|
|
// segment is wrong regardless of whether the rest of the path
|
|
// happens to name a real field.
|
|
let Some(field) = field_path.strip_prefix("data.") else {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%location,
|
|
ref_node = %ref_id,
|
|
ref_slug,
|
|
%field_path,
|
|
"[flows] wiring check: downstream binding is missing the Composio `data.` wrapper segment"
|
|
);
|
|
warnings.push(format!(
|
|
"Node '{}': binding `{location}` (`{expr}`) reads `.item.json.{field_path}` off \
|
|
tool_call `{ref_id}` (`{ref_slug}`), but a Composio tool_call's real output \
|
|
wraps its payload in `data` — this resolves null at runtime. Bind via \
|
|
`=nodes.{ref_id}.item.json.data.{field_path}` instead.",
|
|
node.id
|
|
));
|
|
continue;
|
|
};
|
|
let field = field.split('.').next().unwrap_or(field);
|
|
if !contract.output_fields.iter().any(|f| f == field) {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%location,
|
|
ref_node = %ref_id,
|
|
ref_slug,
|
|
%field,
|
|
output_fields = ?contract.output_fields,
|
|
"[flows] wiring check: downstream binding reads a field not in the tool's real output_fields"
|
|
);
|
|
warnings.push(format!(
|
|
"Node '{}': binding `{location}` (`{expr}`) reads field `{field}` off \
|
|
tool_call `{ref_id}` (`{ref_slug}`), but that is not one of its real \
|
|
output fields ({}) — call get_tool_contract {{ slug: \"{ref_slug}\" }} to \
|
|
see the real output field names.",
|
|
node.id,
|
|
contract.output_fields.join(", "),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
warnings
|
|
}
|
|
|
|
/// Author-time WARN/suggest (systemic tool-contract fix, Part 2d): a
|
|
/// `split_out` node whose direct predecessor is a `tool_call` calling a REAL
|
|
/// Composio action with a KNOWN `primary_array_path` (see
|
|
/// [`crate::openhuman::tinyflows::caps::compute_composio_array_path`] — this
|
|
/// already bakes in the `data.` segment Composio's execute-response wrapper
|
|
/// adds, so `expected` below comes out `"json.data.<…>"` for a real action
|
|
/// with no extra handling needed here), but whose configured `config.path`
|
|
/// doesn't match the `json.<primary_array_path>` convention (dereferencing
|
|
/// the `{json,text,raw}` envelope's own `json` field, then the action's real
|
|
/// array property). Advisory: a mismatched path degrades the fan-out (or
|
|
/// silently produces one item instead of many) rather than crashing, so this
|
|
/// suggests the real path instead of rejecting.
|
|
///
|
|
/// Skipped when the predecessor's action has no known `primary_array_path`
|
|
/// (nothing to suggest), or when `split_out`'s predecessor isn't a
|
|
/// `tool_call` at all (no envelope/array-path convention applies).
|
|
async fn graph_split_out_path_warnings(config: &Config, graph: &WorkflowGraph) -> Vec<String> {
|
|
use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug;
|
|
use crate::openhuman::tinyflows::caps::fetch_live_toolkit_catalog;
|
|
|
|
let mut warnings = Vec::new();
|
|
for node in &graph.nodes {
|
|
if node.kind != NodeKind::SplitOut {
|
|
continue;
|
|
}
|
|
let configured_path = node.config.get("path").and_then(Value::as_str);
|
|
|
|
for edge in graph.edges.iter().filter(|e| e.to_node == node.id) {
|
|
let Some(pred) = graph.node(&edge.from_node) else {
|
|
continue;
|
|
};
|
|
if pred.kind != NodeKind::ToolCall {
|
|
continue;
|
|
}
|
|
let Some(pred_slug) = pred.config.get("slug").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
if pred_slug.starts_with('=') || pred_slug.starts_with("oh:") {
|
|
continue;
|
|
}
|
|
let Some(pred_toolkit) = toolkit_from_slug(pred_slug) else {
|
|
continue;
|
|
};
|
|
let Some(catalog) = fetch_live_toolkit_catalog(config, &pred_toolkit).await else {
|
|
continue;
|
|
};
|
|
let Some(contract) = catalog
|
|
.iter()
|
|
.find(|c| c.slug.eq_ignore_ascii_case(pred_slug))
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(primary) = contract.primary_array_path.as_deref() else {
|
|
continue;
|
|
};
|
|
let expected = format!("json.{primary}");
|
|
if configured_path != Some(expected.as_str()) {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
predecessor = %pred.id,
|
|
pred_slug,
|
|
configured_path,
|
|
%expected,
|
|
"[flows] wiring check: split_out.path does not match the predecessor tool's real array path"
|
|
);
|
|
let configured_display = configured_path
|
|
.map(|p| format!("\"{p}\""))
|
|
.unwrap_or_else(|| "unset".to_string());
|
|
warnings.push(format!(
|
|
"Node '{}': split_out.path is {configured_display} but its predecessor \
|
|
tool_call `{}` (`{pred_slug}`) wraps its real array at `{expected}` — set \
|
|
config.path to \"{expected}\" to fan out over the actual response list.",
|
|
node.id, pred.id,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
warnings
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Enforcing binding-resolvability gate
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// `graph_wiring_warnings` (above) is advisory — it, and `dry_run_workflow`'s
|
|
// null-resolution check (issue #4586), only WARN the author that a binding
|
|
// resolves null. Neither is consulted by the builder before it proposes or
|
|
// saves a graph, so a warned-about-but-ignored binding still ships. The
|
|
// functions below are the HARD counterpart: `validate_binding_resolvability`
|
|
// statically proves a `tool_call` node's `args` bindings are resolvable
|
|
// *before* `propose_workflow`/`revise_workflow`/`save_workflow` accept the
|
|
// graph at all (see their call sites), so the LLM builder is forced to fix
|
|
// the wiring rather than merely being told about it.
|
|
|
|
/// Node kinds whose real capability adapter wraps its structured output in
|
|
/// the stable `{ json, text, raw }` envelope (`src/openhuman/tinyflows/caps.rs`):
|
|
/// a binding into one of these must dereference `.item.json.<field>`, never
|
|
/// `.item.<field>` directly — the latter reads the envelope wrapper itself
|
|
/// (an object with `json`/`text`/`raw` keys), not the field inside it, and
|
|
/// resolves `null` at runtime. Every other node kind (`code`, `transform`,
|
|
/// `split_out`, `merge`, `output_parser`, `sub_workflow`, `trigger`,
|
|
/// `condition`, `switch`) emits its item directly with no envelope, so no
|
|
/// convention applies to a binding that targets one of them.
|
|
const ENVELOPING_KINDS: &[NodeKind] = &[NodeKind::Agent, NodeKind::ToolCall, NodeKind::HttpRequest];
|
|
|
|
/// Recursively collects every `=`-prefixed expression leaf in a config
|
|
/// `Value` tree, paired with its dotted location (array elements as numeric
|
|
/// segments, e.g. `"args.cc.0"`) — the same location convention as
|
|
/// `tinyflows::expr::resolve_traced`. Unlike that function this never
|
|
/// evaluates an expression against a scope; it only locates the leaves so
|
|
/// [`validate_binding_resolvability`] can statically pattern-match them.
|
|
fn collect_expressions(value: &Value) -> Vec<(String, String)> {
|
|
fn walk(value: &Value, location: &str, out: &mut Vec<(String, String)>) {
|
|
match value {
|
|
Value::Object(map) => {
|
|
for (k, v) in map {
|
|
let child = if location.is_empty() {
|
|
k.clone()
|
|
} else {
|
|
format!("{location}.{k}")
|
|
};
|
|
walk(v, &child, out);
|
|
}
|
|
}
|
|
Value::Array(items) => {
|
|
for (i, v) in items.iter().enumerate() {
|
|
let child = if location.is_empty() {
|
|
i.to_string()
|
|
} else {
|
|
format!("{location}.{i}")
|
|
};
|
|
walk(v, &child, out);
|
|
}
|
|
}
|
|
Value::String(s) if tinyflows::expr::is_expression(s) => {
|
|
out.push((location.to_string(), s.clone()));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let mut out = Vec::new();
|
|
walk(value, "", &mut out);
|
|
out
|
|
}
|
|
|
|
/// Matches the dotted-path form of a node-output binding —
|
|
/// `=nodes.<ref_id>.item[.json].<field_path>` — returning `(ref_id, has_json,
|
|
/// field_path)`. `has_json` is `true` when the expression dereferenced the
|
|
/// `{json,text,raw}` envelope wrapper (`.item.json.<field_path>`) rather than
|
|
/// the item directly (`.item.<field_path>`).
|
|
///
|
|
/// `field_path` captures the FULL remaining dotted path, not just its first
|
|
/// segment — e.g. `"data.messages"` for `.item.json.data.messages`. This
|
|
/// matters for a Composio `tool_call` ref, whose real output additionally
|
|
/// wraps the field in `data` (see [`crate::openhuman::tinyflows::caps::ToolContract::output_fields`]'s
|
|
/// doc): callers that need to check field membership against a schema with
|
|
/// no such wrapper (e.g. an `agent` node's `output_parser.schema`) should
|
|
/// compare against just `field_path`'s first segment.
|
|
///
|
|
/// Only the dotted-path form is recognized here — the equivalent jq form
|
|
/// (e.g. `=.nodes["ref"].items[0].field`) is an arbitrary jq program, not a
|
|
/// fixed grammar, so it is not statically pattern-matched; that form is still
|
|
/// covered dynamically by `dry_run_workflow`'s null-resolution check (#4586),
|
|
/// which actually evaluates the expression at run time.
|
|
fn parse_node_binding(expr: &str) -> Option<(String, bool, String)> {
|
|
fn node_binding_regex() -> &'static regex::Regex {
|
|
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
|
RE.get_or_init(|| {
|
|
regex::Regex::new(
|
|
r"^=nodes\.([A-Za-z_][A-Za-z0-9_]*)\.item(?:\.(json))?\.([A-Za-z_][A-Za-z0-9_.]*)",
|
|
)
|
|
.expect("static regex is valid")
|
|
})
|
|
}
|
|
let caps = node_binding_regex().captures(expr)?;
|
|
let ref_id = caps.get(1)?.as_str().to_string();
|
|
let has_json = caps.get(2).is_some();
|
|
let field_path = caps.get(3)?.as_str().trim_end_matches('.').to_string();
|
|
if field_path.is_empty() {
|
|
return None;
|
|
}
|
|
Some((ref_id, has_json, field_path))
|
|
}
|
|
|
|
/// Human-readable label for a [`NodeKind`], for
|
|
/// [`validate_binding_resolvability`]'s envelope-violation message.
|
|
fn node_kind_label(kind: &NodeKind) -> &'static str {
|
|
match kind {
|
|
NodeKind::Agent => "an agent",
|
|
NodeKind::ToolCall => "a tool_call",
|
|
NodeKind::HttpRequest => "an http_request",
|
|
_ => "a node",
|
|
}
|
|
}
|
|
|
|
/// jaq keywords/operators that read as valid jq syntax rather than natural-
|
|
/// language prose; used by [`agent_prompt_looks_like_invalid_jq`]'s bareword
|
|
/// scan so a genuine jq program (`if`/`then`/`else`/`end`, `and`/`or`,
|
|
/// `reduce`/`foreach`, a `def`, …) is never mistaken for prose.
|
|
const JQ_KEYWORDS: &[&str] = &[
|
|
"and", "or", "not", "if", "then", "elif", "else", "end", "as", "def", "reduce", "foreach",
|
|
"try", "catch", "import", "include", "label",
|
|
];
|
|
|
|
/// Best-effort detector for an agent-node `config.prompt` `=`-expression that
|
|
/// is natural-language prose accidentally written in the `=`-binding
|
|
/// convention, rather than a real jq program — the exact failure this check
|
|
/// exists to catch: a builder writes something like `"=You are given an
|
|
/// email: .item. Classify it…"`, which is not a valid jq program (jq's
|
|
/// grammar has no rule for two bare identifiers in a row with nothing but
|
|
/// whitespace between them — an operator or pipe is required), so
|
|
/// `tinyflows::expr::evaluate` silently resolves it to `null` (its contract:
|
|
/// "compile/run errors never panic, they yield `Value::Null`") and the agent
|
|
/// turn then runs with an **empty prompt**.
|
|
///
|
|
/// `tinyflows` doesn't expose a compile-only jq check — `run_jq` is a private
|
|
/// helper in `tinyflows::expr` and the module's evaluation contract is
|
|
/// deliberately "never panics, malformed programs silently yield null" — so
|
|
/// this is a conservative pattern match rather than a real compiler
|
|
/// round-trip: quoted jq string literals are stripped first (so quoted prose
|
|
/// inside a legitimate concatenation like `="Hi " + .item.name` is never
|
|
/// scanned — this includes respecting a `\"` escape inside the string, so a
|
|
/// quoted literal like `="Say \"hi\" to " + .item.name` doesn't desync the
|
|
/// quote-toggle and leak its trailing prose into the bareword scan), then the
|
|
/// remainder is scanned for **two or more consecutive** whitespace-separated
|
|
/// barewords that are neither jq keywords nor path segments (`.foo`,
|
|
/// `.foo.bar`) — a real jq program never juxtaposes two bare identifiers like
|
|
/// that. Deliberately narrow (2+ in a row, not 1): a false negative here just
|
|
/// leaves prose alone (nothing new was broken); a false positive would reject
|
|
/// a legitimate author's graph.
|
|
fn agent_prompt_looks_like_invalid_jq(expr_body: &str) -> bool {
|
|
let mut stripped = String::with_capacity(expr_body.len());
|
|
let mut in_str = false;
|
|
let mut chars = expr_body.chars();
|
|
while let Some(c) = chars.next() {
|
|
// An escaped char inside a jq string literal (`\"`, `\\`, `\n`, …) —
|
|
// consume both the backslash and the escaped char without toggling
|
|
// `in_str`, so an escaped quote never prematurely ends the string.
|
|
if in_str && c == '\\' {
|
|
chars.next();
|
|
continue;
|
|
}
|
|
if c == '"' {
|
|
in_str = !in_str;
|
|
continue;
|
|
}
|
|
if !in_str {
|
|
stripped.push(c);
|
|
}
|
|
}
|
|
|
|
let mut consecutive_bare_words = 0u32;
|
|
for tok in stripped.split_whitespace() {
|
|
let core = tok.trim_matches(|c: char| !c.is_ascii_alphabetic());
|
|
let is_bare_word = !core.is_empty()
|
|
&& core.chars().all(|c| c.is_ascii_alphabetic())
|
|
&& !tok.starts_with('.')
|
|
&& !tok.contains('.')
|
|
&& !JQ_KEYWORDS.contains(&core.to_ascii_lowercase().as_str());
|
|
if is_bare_word {
|
|
consecutive_bare_words += 1;
|
|
if consecutive_bare_words >= 2 {
|
|
return true;
|
|
}
|
|
} else {
|
|
consecutive_bare_words = 0;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Statically proves every `tool_call` node's `config.args` bindings are
|
|
/// resolvable, rejecting the graph (a non-empty `Vec` = reject; empty =
|
|
/// pass) when one is GUARANTEED to resolve `null` (or the wrong value) at
|
|
/// runtime. See the [module section](self) header for why this exists
|
|
/// alongside the advisory `graph_wiring_warnings`/`dry_run_workflow` checks.
|
|
///
|
|
/// Scoped to `tool_call` `args` for the field-addressability checks below —
|
|
/// an `agent` node's free-text prompt has no static output schema to enforce
|
|
/// a `nodes.<ref>.item.<field>` reference against, so a prose string that
|
|
/// merely *mentions* such a path is left alone (degrades output quality, but
|
|
/// doesn't break execution the way a `null` tool argument does). The ONE
|
|
/// `agent`-prompt case this pass DOES reject is narrower and execution-
|
|
/// breaking in its own right: `config.prompt` itself being a `=`-expression
|
|
/// that reads as prose rather than a jq program (see
|
|
/// [`agent_prompt_looks_like_invalid_jq`]) — that doesn't just degrade
|
|
/// output, it guarantees `null`, i.e. an EMPTY prompt, exactly the
|
|
/// `input_context` bug this whole gate was added to prevent (see the
|
|
/// `flows/agents/workflow_builder/prompt.md` convention: `input_context`
|
|
/// carries data, `prompt` stays a plain instruction).
|
|
///
|
|
/// For every `=nodes.<ref>.item[.json].<field>` binding found in a
|
|
/// `tool_call`'s `args` (via [`collect_expressions`] + [`parse_node_binding`]):
|
|
/// - a `<ref>` that doesn't resolve to a node in the graph is skipped — a
|
|
/// dangling reference is already a `tinyflows::validate::validate`
|
|
/// structural error, caught upstream of this pass.
|
|
/// - a `<ref>` that IS an [`ENVELOPING_KINDS`] node and the expression used
|
|
/// `.item.<field>` (no `.json`) is REJECTED: it dereferences the envelope
|
|
/// wrapper, not the field inside it.
|
|
/// - a `<ref>` that is an `agent` node is REJECTED unless it declares
|
|
/// `config.output_parser.schema` with an object `properties` map
|
|
/// containing `<field>` — the exact shape a real run's output-parser
|
|
/// sub-port enforces; without it the agent's structured output has no
|
|
/// addressable `<field>`.
|
|
/// - a `<ref>` that is `tool_call`/`http_request` only gets the envelope
|
|
/// check above — neither has a static output schema to check field
|
|
/// membership against ahead of a real run.
|
|
/// - any other referenced kind (`code`, `transform`, `split_out`, `merge`,
|
|
/// `output_parser`, `sub_workflow`, `trigger`, `condition`, `switch`) has no
|
|
/// schema or envelope convention to enforce and is accepted.
|
|
pub(crate) fn validate_binding_resolvability(graph: &WorkflowGraph) -> Vec<String> {
|
|
let mut errors = Vec::new();
|
|
|
|
// Agent-prompt gate: reject a `prompt` that reads as prose written in the
|
|
// `=`-binding convention (see `agent_prompt_looks_like_invalid_jq`'s doc) —
|
|
// it is GUARANTEED to resolve `null`, handing the agent an empty prompt.
|
|
// A plain (non-`=`) prompt, or a real jq/dotted-path expression, is
|
|
// unaffected.
|
|
for node in &graph.nodes {
|
|
if node.kind != NodeKind::Agent {
|
|
continue;
|
|
}
|
|
// Both runtime paths (`build_completion_messages` and
|
|
// `node_request_to_prompt` in `tinyflows/caps.rs`) fall through to a
|
|
// non-empty `messages` array once `prompt` resolves to `null` — which
|
|
// is exactly what this bad `=`-expression prompt does. So a node that
|
|
// declares real `messages` never actually runs on the null prompt;
|
|
// rejecting the graph for it would be a false positive against a
|
|
// vestigial/unused legacy `prompt` field.
|
|
let messages_supply_the_turn = node
|
|
.config
|
|
.get("messages")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|entries| !entries.is_empty());
|
|
if messages_supply_the_turn {
|
|
continue;
|
|
}
|
|
let Some(prompt) = node.config.get("prompt").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
if !tinyflows::expr::is_expression(prompt) {
|
|
continue;
|
|
}
|
|
let body = prompt[1..].trim();
|
|
if agent_prompt_looks_like_invalid_jq(body) {
|
|
errors.push(format!(
|
|
"Node '{}': `prompt` (`{prompt}`) looks like natural-language text written as \
|
|
a `=`-expression, not a valid jq program — it will resolve to `null` at \
|
|
runtime, handing the agent an EMPTY prompt. Fix: feed upstream data through \
|
|
`config.input_context` (e.g. `\"input_context\": \"=item\"`) and make `prompt` \
|
|
a plain instruction with no leading `=`.",
|
|
node.id
|
|
));
|
|
}
|
|
}
|
|
|
|
for node in &graph.nodes {
|
|
if node.kind != NodeKind::ToolCall {
|
|
continue;
|
|
}
|
|
let Some(args) = node.config.get("args") else {
|
|
continue;
|
|
};
|
|
for (location, expr) in collect_expressions(args) {
|
|
let Some((ref_id, has_json, field_path)) = parse_node_binding(&expr) else {
|
|
continue;
|
|
};
|
|
let Some(ref_node) = graph.node(&ref_id) else {
|
|
continue;
|
|
};
|
|
|
|
if ENVELOPING_KINDS.contains(&ref_node.kind) && !has_json {
|
|
errors.push(format!(
|
|
"Node '{}': arg `{location}` (`{expr}`) uses `.item.{field_path}` on {} node \
|
|
`{ref_id}`, but agent/tool_call/http_request nodes wrap output in {{json, \
|
|
text, raw}} — use `=nodes.{ref_id}.item.json.{field_path}` instead.",
|
|
node.id,
|
|
node_kind_label(&ref_node.kind),
|
|
));
|
|
continue;
|
|
}
|
|
|
|
if ref_node.kind == NodeKind::Agent {
|
|
// Agent output has no Composio `data` wrapper — the schema's
|
|
// top-level properties are checked against just the FIRST
|
|
// segment of the bound path (agents don't publish nested
|
|
// output schemas here).
|
|
let field = field_path.split('.').next().unwrap_or(&field_path);
|
|
let has_field = ref_node
|
|
.config
|
|
.get("output_parser")
|
|
.and_then(|p| p.get("schema"))
|
|
.filter(|s| !s.is_null())
|
|
.and_then(|s| s.get("properties"))
|
|
.and_then(Value::as_object)
|
|
.is_some_and(|props| props.contains_key(field));
|
|
if !has_field {
|
|
errors.push(format!(
|
|
"Node '{}': arg `{location}` (`{expr}`) binds to agent node `{ref_id}`, \
|
|
which has no `output_parser.schema` declaring `{field}` — its \
|
|
structured output has no addressable `{field}`, so this binding \
|
|
resolves null at runtime. Fix: add `{field}` to node `{ref_id}`'s \
|
|
output_parser.schema and bind via `=nodes.{ref_id}.item.json.{field}`.",
|
|
node.id
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
errors
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tool-contract enforcement gate (systemic tool-contract fix, Part 2)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// `validate_binding_resolvability` (above) statically proves a binding's
|
|
// SHAPE is sound (envelope dereference, agent output schema). It has no
|
|
// opinion on whether a `tool_call` node's `slug` is a REAL Composio action,
|
|
// or whether the args it wires cover that action's REAL required set — a
|
|
// builder could pass a hallucinated slug (`SLACK_POST_MESSAGE_TO_CHANNEL`,
|
|
// which 404s at runtime) or omit a genuinely required arg, and
|
|
// `validate_binding_resolvability` would have nothing to say about either.
|
|
// [`validate_tool_contracts`] is that missing HARD gate, grounded in
|
|
// [`crate::openhuman::tinyflows::caps::fetch_live_toolkit_catalog`] — the
|
|
// FULL LIVE Composio catalog, not the static curated subset.
|
|
|
|
/// Statically proves every `tool_call` node's `config.slug` is a REAL action
|
|
/// in the LIVE Composio catalog for its toolkit, and that every one of that
|
|
/// action's REAL required args is present (non-null) in `config.args` —
|
|
/// rejecting the graph (a non-empty `Vec` = reject; empty = pass) when
|
|
/// either check fails. Wired into `propose_workflow` / `revise_workflow` /
|
|
/// `save_workflow` alongside [`validate_binding_resolvability`].
|
|
///
|
|
/// Skipped for a `slug` that is `=`-derived (resolved from upstream/trigger
|
|
/// data at runtime — nothing to check statically) or a native `oh:` tool (no
|
|
/// Composio contract at all).
|
|
///
|
|
/// **Best-effort on catalog availability, not on catalog CONTENT**: when the
|
|
/// live-catalog fetch itself fails (no backend session, network error) the
|
|
/// node is SKIPPED with a debug log — never rejected — because a
|
|
/// hallucinated slug can only be confirmed hallucinated once the real
|
|
/// catalog was actually reachable; `graph_wiring_warnings`'s
|
|
/// `composio_required_args` checks share this exact contract. Once the
|
|
/// catalog IS reachable, though, both checks below are HARD: an unreal slug
|
|
/// or a missing required arg rejects the graph outright, unlike the
|
|
/// advisory output-field/`split_out.path` WARNs in `graph_wiring_warnings`
|
|
/// (Part 2c/2d) — those degrade gracefully because a binding to an unknown
|
|
/// field can't be proven wrong, whereas a nonexistent slug or a missing
|
|
/// required arg are both provably broken.
|
|
pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGraph) -> Vec<String> {
|
|
use crate::openhuman::memory_sync::composio::providers::{
|
|
catalog_for_toolkit, get_provider, toolkit_from_slug,
|
|
};
|
|
use crate::openhuman::tinyflows::caps::{fetch_live_toolkit_catalog, missing_required_args};
|
|
|
|
let mut errors = Vec::new();
|
|
for node in &graph.nodes {
|
|
if node.kind != NodeKind::ToolCall {
|
|
continue;
|
|
}
|
|
let Some(slug) = node.config.get("slug").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
// `=`-derived slugs resolve from upstream/trigger data at runtime —
|
|
// nothing to check statically. Native `oh:` tools have no Composio
|
|
// contract.
|
|
if slug.starts_with('=') || slug.starts_with("oh:") {
|
|
continue;
|
|
}
|
|
let Some(toolkit) = toolkit_from_slug(slug) else {
|
|
continue;
|
|
};
|
|
let Some(catalog) = fetch_live_toolkit_catalog(config, &toolkit).await else {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
%toolkit,
|
|
"[flows] tool-contract check: live catalog fetch failed — skipping (best-effort, never false-rejects)"
|
|
);
|
|
continue;
|
|
};
|
|
|
|
let Some(contract) = catalog.iter().find(|c| c.slug.eq_ignore_ascii_case(slug)) else {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
%toolkit,
|
|
"[flows] tool-contract check: slug is not a real action in the live catalog — rejecting"
|
|
);
|
|
errors.push(format!(
|
|
"Node '{}': `{slug}` is not a real action in the `{toolkit}` toolkit's live \
|
|
Composio catalog — use search_tool_catalog {{ query: ..., toolkit: \"{toolkit}\" \
|
|
}} to find a real action slug.",
|
|
node.id
|
|
));
|
|
continue;
|
|
};
|
|
|
|
// Mirror `flow_tool_allowed`'s Path A: a toolkit OpenHuman ships a
|
|
// static curated catalog for is a hard curated-only allowlist at
|
|
// RUNTIME — `find_curated` rejects any slug that isn't one of the
|
|
// curated actions, regardless of whether it's a real live action.
|
|
// `search_tool_catalog`/`get_tool_contract` deliberately surface
|
|
// real-but-uncurated actions too (ranking signal only, never
|
|
// hidden — see `ToolContract::is_curated`'s doc), so without this
|
|
// check a graph could pass authoring/save with a real-but-uncurated
|
|
// action on a curated toolkit and then fail every run with "tool
|
|
// not permitted". Hold authoring to the same bar the runtime gate
|
|
// enforces instead of loosening the runtime gate.
|
|
let has_static_catalog = get_provider(&toolkit)
|
|
.and_then(|p| p.curated_tools())
|
|
.or_else(|| catalog_for_toolkit(&toolkit))
|
|
.is_some();
|
|
if has_static_catalog && !contract.is_curated {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
%toolkit,
|
|
"[flows] tool-contract check: slug is real but not curated for a statically-catalogued toolkit — rejecting to match the runtime allowlist"
|
|
);
|
|
errors.push(format!(
|
|
"Node '{}': `{slug}` is a real `{toolkit}` action but not one of OpenHuman's \
|
|
curated actions for `{toolkit}` — the runtime tool gate only allows curated \
|
|
actions for toolkits with a curated catalog, so this would be rejected on \
|
|
every run. Use search_tool_catalog {{ query: ..., toolkit: \"{toolkit}\" }} and \
|
|
pick a result with `featured: true`.",
|
|
node.id
|
|
));
|
|
continue;
|
|
}
|
|
|
|
let args = node.config.get("args").cloned().unwrap_or(Value::Null);
|
|
let missing = missing_required_args(&contract.required_args, &args);
|
|
if !missing.is_empty() {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
?missing,
|
|
"[flows] tool-contract check: required arg(s) missing or null — rejecting"
|
|
);
|
|
let list = missing
|
|
.iter()
|
|
.map(|m| format!("`{m}`"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
errors.push(format!(
|
|
"Node '{}': tool_call `{slug}` is missing required arg(s) {list} — wire each \
|
|
from an upstream node's output, e.g. \"{}\": \
|
|
\"=nodes.<node_id>.item.json.<field>\" (call get_tool_contract {{ slug: \
|
|
\"{slug}\" }} for the exact required_args list).",
|
|
node.id, missing[0]
|
|
));
|
|
}
|
|
}
|
|
errors
|
|
}
|
|
|
|
/// Validates a candidate graph without persisting it — the same
|
|
/// migrate/validate path `flows_create` and `ProposeWorkflowTool` use — and
|
|
/// reports structural errors alongside non-fatal trigger warnings
|
|
/// ([`graph_trigger_warnings`]). Backs `openhuman.flows_validate` (PHASE 3c):
|
|
/// an authoring surface can call this to preview validity + warnings before a
|
|
/// save. Pure (no persistence, no config) — `valid == false` is a normal
|
|
/// result, NOT an `Err`; `Err` is reserved for internal serialization faults
|
|
/// (there are none on this path today).
|
|
pub fn flows_validate(graph_json: Value) -> RpcOutcome<crate::openhuman::flows::FlowValidation> {
|
|
use crate::openhuman::flows::FlowValidation;
|
|
tracing::debug!(target: "flows", "[flows] flows_validate: validating candidate graph");
|
|
match validate_and_migrate_graph(graph_json) {
|
|
Ok(graph) => {
|
|
let warnings = graph_trigger_warnings(&graph);
|
|
for warning in &warnings {
|
|
tracing::warn!(target: "flows", warning = %warning, "[flows] flows_validate: non-fatal validation warning");
|
|
}
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node_count = graph.nodes.len(),
|
|
warning_count = warnings.len(),
|
|
"[flows] flows_validate: graph is structurally valid"
|
|
);
|
|
RpcOutcome::single_log(
|
|
FlowValidation {
|
|
valid: true,
|
|
errors: Vec::new(),
|
|
warnings,
|
|
},
|
|
"flow validated",
|
|
)
|
|
}
|
|
Err(error) => {
|
|
tracing::debug!(target: "flows", %error, "[flows] flows_validate: graph is structurally invalid");
|
|
RpcOutcome::single_log(
|
|
FlowValidation {
|
|
valid: false,
|
|
errors: vec![error],
|
|
warnings: Vec::new(),
|
|
},
|
|
"flow validation failed",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Imports a workflow definition WITHOUT persisting it (PHASE 4d), normalizing
|
|
/// it into a migrated + validated [`WorkflowGraph`] the UI opens as an editable
|
|
/// canvas *draft*. Two source formats, selected by `format`:
|
|
///
|
|
/// - `"native"` — a tinyflows `WorkflowGraph` JSON (the same shape
|
|
/// `flows_create` accepts). Run straight through [`validate_and_migrate_graph`].
|
|
/// - `"n8n"` — an n8n workflow export, mapped best-effort by
|
|
/// [`crate::openhuman::flows::n8n_import`] into a `WorkflowGraph` (unmapped
|
|
/// node types become annotated placeholders, expressions translated where
|
|
/// trivial) and THEN run through the same migrate + validate path, so the
|
|
/// host engine is the authority on the result's validity.
|
|
/// - `None`/`"auto"` — auto-detect: n8n exports carry a `connections` object /
|
|
/// `type`-discriminated nodes ([`n8n_import::looks_like_n8n`]); everything
|
|
/// else is treated as native.
|
|
///
|
|
/// Returns `Err` when the (post-mapping) graph is structurally invalid or the
|
|
/// JSON is unparseable — import declines rather than handing the canvas a graph
|
|
/// that can't be saved. On success the `warnings` carry every non-fatal import
|
|
/// approximation (n8n only; native import is warning-free).
|
|
///
|
|
/// Like `flows_validate`, this is pure: NO persistence, NO enablement. The
|
|
/// user's later Save (the existing `flows_create` gate) is the only write.
|
|
pub fn flows_import(
|
|
graph_json: Value,
|
|
format: Option<String>,
|
|
) -> Result<RpcOutcome<crate::openhuman::flows::FlowImport>, String> {
|
|
use crate::openhuman::flows::{n8n_import, FlowImport};
|
|
|
|
let requested = format
|
|
.as_deref()
|
|
.unwrap_or("auto")
|
|
.trim()
|
|
.to_ascii_lowercase();
|
|
let is_n8n = match requested.as_str() {
|
|
"n8n" => true,
|
|
"native" | "tinyflows" => false,
|
|
"auto" | "" => n8n_import::looks_like_n8n(&graph_json),
|
|
other => {
|
|
return Err(format!(
|
|
"unknown import format '{other}' (expected 'native' or 'n8n')"
|
|
))
|
|
}
|
|
};
|
|
tracing::debug!(
|
|
target: "flows",
|
|
requested_format = %requested,
|
|
resolved = if is_n8n { "n8n" } else { "native" },
|
|
"[flows] flows_import: importing workflow definition"
|
|
);
|
|
|
|
let (candidate, mut warnings) = if is_n8n {
|
|
let mapped = n8n_import::map_n8n_workflow(&graph_json)?;
|
|
// Re-serialize the mapped graph so it re-enters the exact same
|
|
// migrate + validate path a native import takes (single source of truth
|
|
// for validity), rather than trusting the mapper's in-memory graph.
|
|
let value = serde_json::to_value(&mapped.graph).map_err(|e| e.to_string())?;
|
|
(value, mapped.warnings)
|
|
} else {
|
|
(graph_json, Vec::new())
|
|
};
|
|
|
|
let graph = validate_and_migrate_graph(candidate)?;
|
|
// Host-side trigger warnings apply to both formats (e.g. an imported
|
|
// webhook trigger that this host does not yet self-fire).
|
|
warnings.extend(graph_trigger_warnings(&graph));
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node_count = graph.nodes.len(),
|
|
warning_count = warnings.len(),
|
|
"[flows] flows_import: import normalized and validated"
|
|
);
|
|
Ok(RpcOutcome::single_log(
|
|
FlowImport { graph, warnings },
|
|
"flow imported",
|
|
))
|
|
}
|
|
|
|
/// Creates a new flow from a name and a raw graph JSON value.
|
|
///
|
|
/// `store::create_flow` defaults new flows to `enabled = true` — this binds
|
|
/// the flow's automatic-dispatch side effect (e.g. registers the
|
|
/// schedule-trigger cron job) immediately, reusing the same [`bind_trigger`]
|
|
/// helper `flows_set_enabled` uses. Without this, a freshly-created enabled
|
|
/// schedule flow would silently never fire until an app restart (boot
|
|
/// reconcile) or a manual disable→enable toggle. Best-effort, same as
|
|
/// `flows_set_enabled`: a binding failure is logged, not fatal to create.
|
|
pub async fn flows_create(
|
|
config: &Config,
|
|
name: String,
|
|
graph_json: Value,
|
|
require_approval: bool,
|
|
) -> Result<RpcOutcome<Flow>, String> {
|
|
let graph = validate_and_migrate_graph(graph_json)?;
|
|
tracing::debug!(target: "flows", %name, node_count = graph.nodes.len(), require_approval, "[flows] flows_create: persisting new flow");
|
|
let flow =
|
|
store::create_flow(config, name, graph, require_approval).map_err(|e| e.to_string())?;
|
|
|
|
if flow.enabled {
|
|
tracing::debug!(target: "flows", flow_id = %flow.id, "[flows] flows_create: flow is enabled — binding automatic-dispatch trigger");
|
|
bind_trigger(config, &flow);
|
|
}
|
|
|
|
Ok(RpcOutcome::single_log(flow, "flow created"))
|
|
}
|
|
|
|
/// Duplicates a saved flow: creates an independent copy of its graph under a
|
|
/// new id/timestamps, with the name suffixed `" (copy)"`. The copy is created
|
|
/// **disabled** (`enabled = false`) and therefore **not** schedule/app_event
|
|
/// trigger-bound — unlike [`flows_create`], which binds a trigger for an
|
|
/// enabled flow, this deliberately calls no [`bind_trigger`], so a duplicate
|
|
/// can never immediately fire. Run history does not carry over. The user
|
|
/// enables it explicitly (via `flows_set_enabled`) once they've reviewed the
|
|
/// copy, at which point its trigger binds like any other flow.
|
|
pub async fn flows_duplicate(config: &Config, id: &str) -> Result<RpcOutcome<Flow>, String> {
|
|
let source = store::get_flow(config, id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("flow '{id}' not found"))?;
|
|
let new_name = format!("{} (copy)", source.name);
|
|
tracing::debug!(target: "flows", source_id = %id, %new_name, "[flows] flows_duplicate: creating disabled, unbound copy");
|
|
let flow =
|
|
store::insert_duplicate_flow(config, &source, new_name).map_err(|e| e.to_string())?;
|
|
// Intentionally NO bind_trigger: a duplicate is disabled and must stay
|
|
// inert (no schedule/trigger dispatch) until the user enables it.
|
|
Ok(RpcOutcome::single_log(
|
|
flow,
|
|
format!("flow duplicated from {id}"),
|
|
))
|
|
}
|
|
|
|
/// Loads one flow by id.
|
|
pub async fn flows_get(config: &Config, id: &str) -> Result<RpcOutcome<Flow>, String> {
|
|
let flow = store::get_flow(config, id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("flow '{id}' not found"))?;
|
|
Ok(RpcOutcome::single_log(flow, format!("flow loaded: {id}")))
|
|
}
|
|
|
|
/// Loads a saved flow's portable [`WorkflowGraph`] by id, for the
|
|
/// `sub_workflow`-by-`workflow_id` resolver capability
|
|
/// (`tinyflows::caps::WorkflowResolver`, implemented in
|
|
/// `src/openhuman/tinyflows/caps.rs`).
|
|
///
|
|
/// Returns `Ok(None)` when no flow with that id exists (the resolver turns that
|
|
/// into a capability error naming the missing id), and `Err` only on a store
|
|
/// failure. Kept sync (the underlying [`store::get_flow`] is sync) so the
|
|
/// resolver can call it directly from its async method without a runtime hop.
|
|
pub fn load_flow_graph(config: &Config, id: &str) -> Result<Option<WorkflowGraph>, String> {
|
|
tracing::debug!(target: "flows", flow_id = %id, "[flows] load_flow_graph: loading saved flow graph for sub_workflow resolver");
|
|
let graph = store::get_flow(config, id)
|
|
.map_err(|e| e.to_string())?
|
|
.map(|flow| flow.graph);
|
|
tracing::debug!(
|
|
target: "flows",
|
|
flow_id = %id,
|
|
found = graph.is_some(),
|
|
"[flows] load_flow_graph: resolver lookup complete"
|
|
);
|
|
Ok(graph)
|
|
}
|
|
|
|
/// Lists every saved flow.
|
|
pub async fn flows_list(config: &Config) -> Result<RpcOutcome<Vec<Flow>>, String> {
|
|
let flows = store::list_flows(config).map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(flows, "flows listed"))
|
|
}
|
|
|
|
/// Lists the connection sources a flow node's `connection_ref` can attach to:
|
|
/// Composio connected accounts (`kind = "composio"`) and stored HTTP
|
|
/// credentials (`kind = "http"`). This is the picker source for the Workflows
|
|
/// UI (and the agent's flow-authoring surface) — it returns ids + display
|
|
/// labels + kind ONLY, never any secret material.
|
|
///
|
|
/// The two sources are aggregated independently and are individually
|
|
/// fault-tolerant: a transient Composio backend/network failure (or an
|
|
/// unconfigured Direct-mode key) yields zero Composio entries but still returns
|
|
/// the HTTP credential half, and vice-versa. A failure in one source never
|
|
/// fails the whole picker.
|
|
pub async fn flows_list_connections(
|
|
config: &Config,
|
|
) -> Result<RpcOutcome<Vec<FlowConnection>>, String> {
|
|
tracing::debug!(
|
|
"[flows] rpc flows_list_connections: aggregating composio + http_cred picker sources"
|
|
);
|
|
let mut logs = Vec::new();
|
|
|
|
// 1. Composio connected accounts. Direct mode without a configured key
|
|
// already short-circuits to an empty list (a valid setup state, not an
|
|
// error); a backend outage returns Err — tolerate it so the picker still
|
|
// surfaces HTTP credentials.
|
|
let composio_conns =
|
|
match crate::openhuman::composio::ops::composio_list_connections(config).await {
|
|
Ok(outcome) => {
|
|
tracing::debug!(
|
|
count = outcome.value.connections.len(),
|
|
"[flows] flows_list_connections: composio source returned connections"
|
|
);
|
|
outcome.value.connections
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"[flows] flows_list_connections: composio source unavailable — \
|
|
returning http_cred entries only"
|
|
);
|
|
logs.push(format!(
|
|
"flows_list_connections: composio source unavailable ({e})"
|
|
));
|
|
Vec::new()
|
|
}
|
|
};
|
|
|
|
// 2. Named HTTP credentials — secret-free summaries (the store never hands
|
|
// out secret material here; injection happens server-side in
|
|
// `tinyflows::caps::OpenHumanHttp`).
|
|
let http_creds =
|
|
match crate::openhuman::credentials::HttpCredentialsStore::from_config(config).list() {
|
|
Ok(list) => {
|
|
tracing::debug!(
|
|
count = list.len(),
|
|
"[flows] flows_list_connections: http_cred store returned summaries"
|
|
);
|
|
list
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"[flows] flows_list_connections: http_cred store read failed — \
|
|
returning composio entries only"
|
|
);
|
|
logs.push(format!(
|
|
"flows_list_connections: http_cred store unavailable ({e})"
|
|
));
|
|
Vec::new()
|
|
}
|
|
};
|
|
|
|
let connections = build_flow_connections(composio_conns, http_creds);
|
|
tracing::debug!(
|
|
total = connections.len(),
|
|
"[flows] flows_list_connections: aggregated picker sources"
|
|
);
|
|
logs.push(format!(
|
|
"flows_list_connections: {} connection(s)",
|
|
connections.len()
|
|
));
|
|
Ok(RpcOutcome::new(connections, logs))
|
|
}
|
|
|
|
/// Fold Composio connected accounts + named HTTP credentials into the flat,
|
|
/// secret-free [`FlowConnection`] picker list. Only ACTIVE Composio connections
|
|
/// are surfaced — a pending/expired OAuth account cannot execute a tool, so it
|
|
/// would be a dead pick. Pure (no I/O) so the aggregation shape is
|
|
/// unit-testable without a live backend.
|
|
fn build_flow_connections(
|
|
composio: Vec<crate::openhuman::composio::ComposioConnection>,
|
|
http: Vec<crate::openhuman::credentials::HttpCredentialSummary>,
|
|
) -> Vec<FlowConnection> {
|
|
let mut out = Vec::with_capacity(composio.len() + http.len());
|
|
for conn in composio {
|
|
if !conn.is_active() {
|
|
tracing::debug!(
|
|
toolkit = %conn.toolkit,
|
|
connection_id = %conn.id,
|
|
status = %conn.status,
|
|
"[flows] flows_list_connections: skipping non-active composio connection"
|
|
);
|
|
continue;
|
|
}
|
|
let toolkit = conn.normalized_toolkit();
|
|
out.push(FlowConnection {
|
|
// Exactly the shape `tinyflows::caps::composio_connection_id` parses.
|
|
connection_ref: format!("composio:{}:{}", toolkit, conn.id),
|
|
kind: "composio".to_string(),
|
|
display: composio_connection_display(&toolkit, &conn),
|
|
toolkit: Some(toolkit),
|
|
scheme: None,
|
|
});
|
|
}
|
|
for cred in http {
|
|
out.push(FlowConnection {
|
|
// Exactly the shape `tinyflows::caps::http_cred_name` parses.
|
|
connection_ref: format!("http_cred:{}", cred.name),
|
|
kind: "http".to_string(),
|
|
display: http_credential_display(&cred),
|
|
toolkit: None,
|
|
scheme: Some(cred.scheme),
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Human-readable picker label for a Composio connected account, e.g.
|
|
/// `"Gmail · user@example.com"`. Prefers email, then workspace/team, then
|
|
/// handle; falls back to the title-cased toolkit alone when no identity is
|
|
/// cached. The identity fields are display metadata (already surfaced by
|
|
/// `composio_list_connections`), never secret material.
|
|
fn composio_connection_display(
|
|
toolkit: &str,
|
|
conn: &crate::openhuman::composio::ComposioConnection,
|
|
) -> String {
|
|
let title = title_case_toolkit(toolkit);
|
|
let identity = conn
|
|
.account_email
|
|
.as_deref()
|
|
.or(conn.workspace.as_deref())
|
|
.or(conn.username.as_deref())
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty());
|
|
match identity {
|
|
Some(id) => format!("{title} · {id}"),
|
|
None => title,
|
|
}
|
|
}
|
|
|
|
/// Human-readable picker label for a named HTTP credential, e.g.
|
|
/// `"stripe (bearer)"`. Only the (non-secret) name + scheme — never the value.
|
|
fn http_credential_display(cred: &crate::openhuman::credentials::HttpCredentialSummary) -> String {
|
|
format!("{} ({})", cred.name, cred.scheme)
|
|
}
|
|
|
|
/// Title-case a toolkit slug for display: `"gmail"` → `"Gmail"`,
|
|
/// `"google_calendar"` → `"Google Calendar"`. Best-effort cosmetic only.
|
|
fn title_case_toolkit(toolkit: &str) -> String {
|
|
let trimmed = toolkit.trim();
|
|
if trimmed.is_empty() {
|
|
return String::new();
|
|
}
|
|
trimmed
|
|
.split(|c| c == '_' || c == '-' || c == ' ')
|
|
.filter(|w| !w.is_empty())
|
|
.map(|word| {
|
|
let mut chars = word.chars();
|
|
match chars.next() {
|
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
None => String::new(),
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
/// Updates a flow's name, graph, and/or `require_approval` toggle.
|
|
/// Re-validates the graph (whether newly supplied or the existing one)
|
|
/// before persisting, same as `flows_create`.
|
|
///
|
|
/// When the caller supplies a new `graph_json` and the flow is (still)
|
|
/// enabled, re-binds the automatic-dispatch trigger if the trigger
|
|
/// kind/config actually changed (e.g. a new schedule cron expression) —
|
|
/// otherwise the stale binding from the old graph would keep firing on the
|
|
/// old cadence, or a newly-added schedule would never get bound at all.
|
|
/// Skipped entirely for a name/`require_approval`-only update (no
|
|
/// `graph_json` supplied), since the trigger definitely didn't change.
|
|
pub async fn flows_update(
|
|
config: &Config,
|
|
id: &str,
|
|
name: Option<String>,
|
|
graph_json: Option<Value>,
|
|
require_approval: Option<bool>,
|
|
) -> Result<RpcOutcome<Flow>, String> {
|
|
let existing = store::get_flow(config, id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("flow '{id}' not found"))?;
|
|
|
|
let new_name = name.unwrap_or_else(|| existing.name.clone());
|
|
let new_require_approval = require_approval.unwrap_or(existing.require_approval);
|
|
let graph_changed = graph_json.is_some();
|
|
let graph = match graph_json {
|
|
Some(raw) => validate_and_migrate_graph(raw)?,
|
|
None => {
|
|
tinyflows::validate::validate(&existing.graph).map_err(|e| e.to_string())?;
|
|
existing.graph.clone()
|
|
}
|
|
};
|
|
|
|
tracing::debug!(target: "flows", flow_id = %id, "[flows] flows_update: persisting changes");
|
|
let updated = store::update_flow_graph(config, id, new_name, graph, new_require_approval)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
if graph_changed && updated.enabled {
|
|
let trigger_unchanged = bus::extract_trigger_kind(&existing)
|
|
== bus::extract_trigger_kind(&updated)
|
|
&& bus::extract_trigger_config(&existing) == bus::extract_trigger_config(&updated);
|
|
if !trigger_unchanged {
|
|
tracing::debug!(target: "flows", flow_id = %id, "[flows] flows_update: trigger changed on an enabled flow — rebinding automatic-dispatch trigger");
|
|
unbind_trigger(config, &existing);
|
|
bind_trigger(config, &updated);
|
|
}
|
|
}
|
|
|
|
Ok(RpcOutcome::single_log(
|
|
updated,
|
|
format!("flow updated: {id}"),
|
|
))
|
|
}
|
|
|
|
/// Deletes a flow by id.
|
|
///
|
|
/// Unbinds the flow's automatic-dispatch trigger (e.g. the schedule-trigger
|
|
/// cron job) *before* removing the flow definition. `flow_runs` cascades on
|
|
/// delete via a same-database `FOREIGN KEY ... ON DELETE CASCADE`, but a
|
|
/// bound cron job lives in the entirely separate `cron.db` — it does NOT
|
|
/// cascade — so skipping this would orphan the cron job, leaving it pointing
|
|
/// at a now-nonexistent `flow_id` forever. Best-effort: a lookup failure
|
|
/// (flow already gone, store error) is logged and does not block the delete
|
|
/// itself — `store::remove_flow` below still errors clearly if `id` doesn't
|
|
/// exist.
|
|
pub async fn flows_delete(config: &Config, id: &str) -> Result<RpcOutcome<Value>, String> {
|
|
match store::get_flow(config, id) {
|
|
Ok(Some(flow)) => unbind_trigger(config, &flow),
|
|
Ok(None) => {}
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", flow_id = %id, error = %e, "[flows] flows_delete: failed to load flow before unbind — proceeding with delete anyway");
|
|
}
|
|
}
|
|
|
|
store::remove_flow(config, id).map_err(|e| e.to_string())?;
|
|
tracing::debug!(target: "flows", flow_id = %id, "[flows] flows_delete: removed");
|
|
Ok(RpcOutcome::new(
|
|
json!({ "id": id, "removed": true }),
|
|
vec![format!("flow removed: {id}")],
|
|
))
|
|
}
|
|
|
|
/// Enables or disables a flow. Enable/disable now (B2) binds/tears down the
|
|
/// flow's automatic trigger:
|
|
/// - `schedule` — registers/removes the backing `cron` job
|
|
/// (`cron::add_flow_schedule_job` / `cron::remove_job`) so
|
|
/// `flows::bus::FlowTriggerSubscriber` gets a `FlowScheduleTick` on the
|
|
/// configured cadence.
|
|
/// - `app_event` — no enable-time side effect needed: the subscriber matches
|
|
/// every `ComposioTriggerReceived` against `store::list_enabled_flows` at
|
|
/// dispatch time, so the `enabled` flag alone gates it.
|
|
/// - `webhook` — **not implemented** in B2 (best-effort deviation, see
|
|
/// `bind_trigger`'s webhook arm below and
|
|
/// `my_docs/ohxtf/b2-triggers-trust/01-triggers-and-trust.md` §1); logged,
|
|
/// not silently skipped.
|
|
/// - `manual` / anything else — no binding needed; `flows_run` always works.
|
|
///
|
|
/// `flows_run` still runs a disabled flow on demand (mirrors
|
|
/// `cron::rpc::cron_run`'s "Run Now always works" behavior) — `enabled` only
|
|
/// gates *automatic* trigger-driven dispatch.
|
|
pub async fn flows_set_enabled(
|
|
config: &Config,
|
|
id: &str,
|
|
enabled: bool,
|
|
) -> Result<RpcOutcome<Flow>, String> {
|
|
let flow = store::set_enabled(config, id, enabled).map_err(|e| e.to_string())?;
|
|
|
|
if enabled {
|
|
bind_trigger(config, &flow);
|
|
} else {
|
|
unbind_trigger(config, &flow);
|
|
}
|
|
|
|
let mut logs = vec![format!("flow {id} enabled={enabled}")];
|
|
// When enabling, loudly surface any unfired-trigger-kind warning in the
|
|
// result (a structured `warning:`-prefixed log), not just a silent tracing
|
|
// line — so an enable of a flow that will never fire itself (webhook,
|
|
// chat_message, form, …) is impossible to miss at the call site.
|
|
if enabled {
|
|
for warning in graph_trigger_warnings(&flow.graph) {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
flow_id = %id,
|
|
warning = %warning,
|
|
"[flows] flows_set_enabled: enabling a flow whose trigger kind does not fire yet"
|
|
);
|
|
logs.push(format!("warning: {warning}"));
|
|
}
|
|
}
|
|
|
|
Ok(RpcOutcome::new(flow, logs))
|
|
}
|
|
|
|
/// Registers the automatic-dispatch side effect for `flow`'s trigger kind, if
|
|
/// any. Best-effort: a binding failure is logged and does not fail the
|
|
/// `flows_set_enabled` call — the flow is still saved as enabled, it just
|
|
/// won't fire automatically until the underlying issue (invalid schedule,
|
|
/// cron store error, …) is fixed.
|
|
fn bind_trigger(config: &Config, flow: &Flow) {
|
|
match bus::extract_trigger_kind(flow) {
|
|
Some(TriggerKind::Schedule) => bind_schedule_trigger(config, flow),
|
|
Some(TriggerKind::Webhook) => log_webhook_trigger_deferred(flow, true),
|
|
_ => {
|
|
// `app_event` needs no enable-time binding (matched at dispatch
|
|
// time against `list_enabled_flows`); `manual`/`form`/others have
|
|
// no automatic-dispatch concept at all.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tears down the automatic-dispatch side effect for `flow`'s trigger kind,
|
|
/// mirroring [`bind_trigger`]. Best-effort, same rationale.
|
|
fn unbind_trigger(config: &Config, flow: &Flow) {
|
|
match bus::extract_trigger_kind(flow) {
|
|
Some(TriggerKind::Schedule) => unbind_schedule_trigger(config, &flow.id),
|
|
Some(TriggerKind::Webhook) => log_webhook_trigger_deferred(flow, false),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Registers (or refreshes) the `cron` job backing a `schedule`-trigger
|
|
/// flow. Idempotent — re-uses an existing binding via
|
|
/// `cron::find_flow_schedule_job` rather than creating a duplicate, so this
|
|
/// is safe to call both from `flows_set_enabled` and from boot
|
|
/// reconciliation ([`reconcile_schedule_triggers_on_boot`]).
|
|
fn bind_schedule_trigger(config: &Config, flow: &Flow) {
|
|
let Some(trigger_config) = bus::extract_trigger_config(flow) else {
|
|
tracing::warn!(target: "flows", flow_id = %flow.id, "[flows] schedule trigger: flow has no single trigger node — cannot bind cron job");
|
|
return;
|
|
};
|
|
let Some(schedule_raw) = trigger_config.get("schedule").cloned() else {
|
|
tracing::warn!(target: "flows", flow_id = %flow.id, "[flows] schedule trigger config is missing `schedule` — cannot bind cron job");
|
|
return;
|
|
};
|
|
let schedule: crate::openhuman::cron::Schedule = match serde_json::from_value(schedule_raw) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", flow_id = %flow.id, error = %e, "[flows] invalid schedule trigger config — cannot bind cron job");
|
|
return;
|
|
}
|
|
};
|
|
|
|
match crate::openhuman::cron::find_flow_schedule_job(config, &flow.id) {
|
|
Ok(Some(existing)) => {
|
|
let patch = crate::openhuman::cron::CronJobPatch {
|
|
enabled: Some(true),
|
|
schedule: Some(schedule),
|
|
..Default::default()
|
|
};
|
|
if let Err(e) = crate::openhuman::cron::update_job(config, &existing.id, patch) {
|
|
tracing::warn!(target: "flows", flow_id = %flow.id, cron_job_id = %existing.id, error = %e, "[flows] failed to refresh existing schedule-trigger cron job");
|
|
} else {
|
|
tracing::debug!(target: "flows", flow_id = %flow.id, cron_job_id = %existing.id, "[flows] refreshed existing schedule-trigger cron job");
|
|
}
|
|
}
|
|
Ok(None) => match crate::openhuman::cron::add_flow_schedule_job(config, &flow.id, schedule)
|
|
{
|
|
Ok(job) => {
|
|
tracing::info!(target: "flows", flow_id = %flow.id, cron_job_id = %job.id, "[flows] registered schedule-trigger cron job")
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", flow_id = %flow.id, error = %e, "[flows] failed to register schedule-trigger cron job")
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", flow_id = %flow.id, error = %e, "[flows] failed to look up existing schedule-trigger cron job");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Removes the `cron` job backing a `schedule`-trigger flow, if one exists.
|
|
fn unbind_schedule_trigger(config: &Config, flow_id: &str) {
|
|
match crate::openhuman::cron::find_flow_schedule_job(config, flow_id) {
|
|
Ok(Some(job)) => {
|
|
if let Err(e) = crate::openhuman::cron::remove_job(config, &job.id) {
|
|
tracing::warn!(target: "flows", %flow_id, cron_job_id = %job.id, error = %e, "[flows] failed to remove schedule-trigger cron job");
|
|
} else {
|
|
tracing::debug!(target: "flows", %flow_id, cron_job_id = %job.id, "[flows] removed schedule-trigger cron job");
|
|
}
|
|
}
|
|
Ok(None) => {}
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", %flow_id, error = %e, "[flows] failed to look up schedule-trigger cron job for teardown");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Webhook trigger binding is a documented B2 stub (best-effort deviation):
|
|
/// registering a real inbound route requires provisioning a backend tunnel
|
|
/// (`webhooks::ops::create_tunnel`, a network call to the signed-in backend
|
|
/// account) plus a UI surface to show the resulting URL to the user — both
|
|
/// are B3 territory. Rather than silently doing nothing, this logs a clear,
|
|
/// actionable warning every time a `webhook`-trigger flow is enabled/disabled
|
|
/// so the gap is diagnosable. `flows::bus::FlowTriggerSubscriber` logs the
|
|
/// matching deferral on the inbound side (`WebhookIncomingRequest`).
|
|
fn log_webhook_trigger_deferred(flow: &Flow, enabled: bool) {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
flow_id = %flow.id,
|
|
enabled,
|
|
"[flows] webhook trigger binding is not implemented in B2 (requires backend tunnel \
|
|
provisioning + a UI surface for the resulting URL) — this flow will not fire \
|
|
automatically from an inbound webhook until that lands"
|
|
);
|
|
}
|
|
|
|
/// Boot-time reconciliation: registers the `cron` job for every enabled,
|
|
/// `schedule`-trigger flow. Idempotent (delegates to [`bind_schedule_trigger`],
|
|
/// which re-uses an existing binding) — mirrors
|
|
/// `cron::seed::seed_proactive_agents_on_boot`'s "ensure jobs exist for
|
|
/// already-onboarded users upgrading from an older build" pattern, so a
|
|
/// flow enabled on a build that predates this cron binding (or whose binding
|
|
/// was lost some other way) gets its schedule re-registered on the next
|
|
/// boot without the user having to toggle it off and on.
|
|
pub async fn reconcile_schedule_triggers_on_boot(config: &Config) -> Result<(), String> {
|
|
let flows = store::list_enabled_flows(config).map_err(|e| e.to_string())?;
|
|
let mut reconciled = 0usize;
|
|
for flow in &flows {
|
|
if matches!(bus::extract_trigger_kind(flow), Some(TriggerKind::Schedule)) {
|
|
bind_schedule_trigger(config, flow);
|
|
reconciled += 1;
|
|
}
|
|
}
|
|
tracing::debug!(target: "flows", scanned = flows.len(), reconciled, "[flows] boot reconciliation of schedule-trigger cron jobs complete");
|
|
Ok(())
|
|
}
|
|
|
|
/// Reads a settled run's durable [`tinyflows::engine::GraphObservation`]
|
|
/// slice back out of the per-run journal (keyed by the tinyagents-minted
|
|
/// `graph_run_id`) and exports it to Langfuse as one trace. Best-effort by
|
|
/// construction: any journal read failure is logged and swallowed, and the
|
|
/// exporter itself never fails the run. Skips the journal read entirely when
|
|
/// `observability.share_usage_data` is off.
|
|
async fn export_run_to_langfuse(
|
|
config: &Config,
|
|
flow_name: &str,
|
|
flow_id: &str,
|
|
thread_id: &str,
|
|
status: &str,
|
|
trigger: FlowRunTrigger,
|
|
journal: &tinyflows::engine::InMemoryGraphEventJournal,
|
|
graph_run_id: &str,
|
|
) {
|
|
if !config.observability.share_usage_data {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
"[flows] langfuse export skipped: observability.share_usage_data is off"
|
|
);
|
|
return;
|
|
}
|
|
use tinyflows::engine::GraphEventJournal as _;
|
|
let observations = match journal.read_from(graph_run_id, 0).await {
|
|
Ok(observations) => observations,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
%thread_id,
|
|
graph_run_id = %graph_run_id,
|
|
error = %e,
|
|
"[flows] langfuse export skipped: could not read run journal"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
tracing::debug!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
%thread_id,
|
|
graph_run_id = %graph_run_id,
|
|
observation_count = observations.len(),
|
|
"[flows] exporting flow run trace to Langfuse"
|
|
);
|
|
crate::openhuman::tinyflows::langfuse_export::export_flow_run_trace(
|
|
config,
|
|
flow_name,
|
|
flow_id,
|
|
thread_id,
|
|
status,
|
|
trigger,
|
|
&observations,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Runs a saved flow end-to-end: compile → build capabilities → durable
|
|
/// checkpointed run → record the outcome onto the flow's summary fields and
|
|
/// into a `flow_runs` history row.
|
|
///
|
|
/// Uses `tinyflows::engine::run_with_checkpointer` (not the simpler `run`) so
|
|
/// a run that pauses at a human-in-the-loop approval gate is durably
|
|
/// checkpointed and can survive a process restart (resumed later via
|
|
/// [`flows_resume`]; see
|
|
/// `my_docs/ohxtf/b1-engine-seam-domain/05-checkpointer-and-state.md`).
|
|
///
|
|
/// The whole run is scoped under `AgentTurnOrigin::TrustedAutomation {
|
|
/// Workflow }` (issue B2) regardless of caller (an interactive RPC "Run" or
|
|
/// an automatic trigger dispatch from `flows::bus::FlowTriggerSubscriber`):
|
|
/// the trust argument is about the *flow* (a saved, validated graph whose
|
|
/// `tool_call`/`http_request` nodes are pre-declared), not about who started
|
|
/// the run — see `TrustedAutomationSource::Workflow`'s doc and
|
|
/// `my_docs/ohxtf/b2-triggers-trust/01-triggers-and-trust.md` §3.
|
|
pub async fn flows_run(
|
|
config: &Config,
|
|
flow_id: &str,
|
|
input: Value,
|
|
trigger: FlowRunTrigger,
|
|
) -> Result<RpcOutcome<Value>, String> {
|
|
let flow = store::get_flow(config, flow_id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("flow '{flow_id}' not found"))?;
|
|
|
|
// `store::get_flow` already ran the stored `graph_json` through
|
|
// `tinyflows::migrate::migrate` before deserializing, so `flow.graph` is
|
|
// always on the current schema here.
|
|
let compiled = tinyflows::compiler::compile(&flow.graph).map_err(|e| e.to_string())?;
|
|
|
|
let config_arc = Arc::new(config.clone());
|
|
// Scope the state store per-flow so two flows never collide on a state key.
|
|
let caps =
|
|
crate::openhuman::tinyflows::build_capabilities(config_arc, format!("flow:{flow_id}"));
|
|
let checkpointer =
|
|
crate::openhuman::tinyflows::open_flow_checkpointer(config).map_err(|e| e.to_string())?;
|
|
let thread_id = format!("flow:{flow_id}:{}", uuid::Uuid::new_v4());
|
|
|
|
tracing::debug!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
thread_id = %thread_id,
|
|
require_approval = flow.require_approval,
|
|
"[flows] flows_run: starting checkpointed run"
|
|
);
|
|
|
|
start_flow_run_row(config, &thread_id, flow_id);
|
|
|
|
// Register this run as in-flight (issue G4) so a concurrent
|
|
// `flows_cancel_run` can signal it to abort. The guard deregisters on any
|
|
// exit from this fn (including the early returns below).
|
|
let (cancel_token, _run_guard) = run_registry::register(&thread_id);
|
|
|
|
// Record a failed attempt so `last_run_at`/`last_status` reflect reality
|
|
// (a stop-policy engine/capability failure or a timeout) rather than
|
|
// leaving the prior success/pending state on the flow. Preserve whatever
|
|
// steps the observer persisted live (don't wipe them back to `[]`).
|
|
let record_failed = |error: &str| {
|
|
if let Err(rec_err) = store::record_run(config, flow_id, "failed") {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
error = %rec_err,
|
|
"[flows] flows_run: failed to record failed run"
|
|
);
|
|
}
|
|
let observed = current_persisted_steps(config, &thread_id);
|
|
finish_flow_run_row(config, &thread_id, "failed", &observed, &[], Some(error));
|
|
};
|
|
|
|
let origin = workflow_origin(flow_id, flow.require_approval);
|
|
// Per-run in-memory journal: tinyflows records every graph event as a
|
|
// durable GraphObservation under the run's tinyagents run id, which the
|
|
// post-run Langfuse export reads back. Process-local and dropped with the
|
|
// run — never persisted.
|
|
let journal = Arc::new(tinyflows::engine::InMemoryGraphEventJournal::new());
|
|
// Live run observer (issue G2): persists each finished step into the
|
|
// `flow_runs` row as it happens and streams a `FlowRunProgress` event to
|
|
// the frontend, so the durable + journaled path also reports live.
|
|
let observer: Arc<dyn tinyflows::observability::RunObserver> = Arc::new(
|
|
crate::openhuman::tinyflows::observability::FlowRunObserver::new(
|
|
Arc::new(config.clone()),
|
|
flow_id,
|
|
thread_id.clone(),
|
|
),
|
|
);
|
|
let run = with_origin(
|
|
origin,
|
|
tinyflows::engine::run_with_checkpointer_journaled_observed(
|
|
&compiled,
|
|
input,
|
|
&caps,
|
|
checkpointer,
|
|
&thread_id,
|
|
journal.clone(),
|
|
&observer,
|
|
),
|
|
);
|
|
let timed = tokio::time::timeout(std::time::Duration::from_secs(FLOW_RUN_TIMEOUT_SECS), run);
|
|
tokio::pin!(timed);
|
|
// Race the run against a cancellation signal (issue G4). `biased` checks the
|
|
// cancel arm first so a `flows_cancel_run` that lands right as the run
|
|
// settles still wins deterministically.
|
|
let journaled = tokio::select! {
|
|
biased;
|
|
_ = cancel_token.cancelled() => {
|
|
tracing::info!(target: "flows", flow_id = %flow_id, thread_id = %thread_id, "[flows] flows_run: cancelled mid-run");
|
|
if let Err(e) = store::record_run(config, flow_id, "cancelled") {
|
|
tracing::warn!(target: "flows", flow_id = %flow_id, error = %e, "[flows] flows_run: failed to record cancelled run");
|
|
}
|
|
let observed = current_persisted_steps(config, &thread_id);
|
|
finish_flow_run_row(config, &thread_id, "cancelled", &observed, &[], Some("run cancelled"));
|
|
drop_checkpoint(config, &thread_id).await;
|
|
return Ok(RpcOutcome::single_log(
|
|
json!({
|
|
"output": Value::Null,
|
|
"pending_approvals": Vec::<String>::new(),
|
|
"thread_id": thread_id,
|
|
"cancelled": true,
|
|
}),
|
|
format!("flow run cancelled: {thread_id}"),
|
|
));
|
|
}
|
|
result = &mut timed => match result {
|
|
Ok(Ok(journaled)) => journaled,
|
|
Ok(Err(e)) => {
|
|
record_failed(&e.to_string());
|
|
tracing::warn!(target: "flows", flow_id = %flow_id, error = %e, "[flows] flows_run: run failed");
|
|
return Err(e.to_string());
|
|
}
|
|
Err(_elapsed) => {
|
|
let msg = format!("flow run timed out after {FLOW_RUN_TIMEOUT_SECS}s");
|
|
record_failed(&msg);
|
|
tracing::warn!(target: "flows", flow_id = %flow_id, timeout_secs = FLOW_RUN_TIMEOUT_SECS, "[flows] flows_run: run timed out");
|
|
return Err(msg);
|
|
}
|
|
},
|
|
};
|
|
let outcome = journaled.outcome;
|
|
|
|
let settled = settle_steps(config, &thread_id, &outcome.output);
|
|
let (status, error) = finalize_terminal_status(&settled, &outcome.pending_approvals);
|
|
store::record_run(config, flow_id, status).map_err(|e| e.to_string())?;
|
|
finish_flow_run_row(
|
|
config,
|
|
&thread_id,
|
|
status,
|
|
&settled,
|
|
&outcome.pending_approvals,
|
|
error.as_deref(),
|
|
);
|
|
export_run_to_langfuse(
|
|
config,
|
|
&flow.name,
|
|
flow_id,
|
|
&thread_id,
|
|
status,
|
|
trigger,
|
|
&journal,
|
|
&journaled.graph_run_ids.run_id,
|
|
)
|
|
.await;
|
|
notify_pending_approval(&flow, &thread_id, &outcome.pending_approvals);
|
|
|
|
tracing::info!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
status,
|
|
pending_approvals = outcome.pending_approvals.len(),
|
|
"[flows] flows_run: finished"
|
|
);
|
|
|
|
Ok(RpcOutcome::single_log(
|
|
json!({
|
|
"output": outcome.output,
|
|
"pending_approvals": outcome.pending_approvals,
|
|
"thread_id": thread_id,
|
|
}),
|
|
format!("flow run {status}"),
|
|
))
|
|
}
|
|
|
|
/// Resumes a `flows_run` that paused at a human-in-the-loop approval gate,
|
|
/// continuing it from the durable checkpoint (`thread_id`) with
|
|
/// `approvals` newly granted. The UI approval card (B3) calls this once the
|
|
/// user decides. See `tinyflows::engine::resume_with_checkpointer`'s doc for
|
|
/// the resume mechanics.
|
|
///
|
|
/// **Host-side approval guard (issue B2 finding #3):** tinyflows 0.2's
|
|
/// `resume_with_checkpointer` treats the resume call itself as approval of
|
|
/// whatever gate paused the run — its `approvals` argument is advisory only,
|
|
/// not enforced inside the crate (`flows_resume(..., approvals: [])` on a
|
|
/// paused run would otherwise still complete it). So before ever calling
|
|
/// into the engine, this loads the persisted `flow_runs` row for
|
|
/// `thread_id` (`flow_runs.id == thread_id`) and requires that `approvals`
|
|
/// names at least one of that row's *actually* pending node ids. A run
|
|
/// that isn't currently `pending_approval` (already completed, failed, or
|
|
/// unknown) is rejected outright — resuming an already-settled thread_id is
|
|
/// no longer treated as a harmless no-op, it's a clear error.
|
|
pub async fn flows_resume(
|
|
config: &Config,
|
|
flow_id: &str,
|
|
thread_id: &str,
|
|
approvals: Vec<String>,
|
|
rejections: Vec<String>,
|
|
) -> Result<RpcOutcome<Value>, String> {
|
|
let flow = store::get_flow(config, flow_id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("flow '{flow_id}' not found"))?;
|
|
|
|
let run_record = store::get_flow_run(config, thread_id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| {
|
|
format!("no paused run to resume: no run recorded for thread '{thread_id}'")
|
|
})?;
|
|
if run_record.flow_id != flow_id {
|
|
return Err(format!(
|
|
"no paused run to resume: run '{thread_id}' belongs to flow '{}', not '{flow_id}'",
|
|
run_record.flow_id
|
|
));
|
|
}
|
|
if run_record.status != "pending_approval" {
|
|
return Err(format!(
|
|
"no paused run to resume: run '{thread_id}' is not pending approval (status: {})",
|
|
run_record.status
|
|
));
|
|
}
|
|
// A gate can't be both approved and denied in the same resume — that's an
|
|
// ambiguous instruction, reject it up front.
|
|
if let Some(dup) = approvals.iter().find(|a| rejections.contains(a)) {
|
|
return Err(format!(
|
|
"gate '{dup}' cannot be both approved and rejected in the same resume"
|
|
));
|
|
}
|
|
// Same host-side guard the approvals path uses (see this fn's doc): the
|
|
// engine trusts whatever the resume delivers, so require that the caller's
|
|
// approvals/rejections actually name a currently-pending gate before ever
|
|
// touching the engine. A denial (issue G4) is enforced the same way — a
|
|
// rejection naming a pending gate is a valid resume just as an approval is.
|
|
let matches_pending = approvals
|
|
.iter()
|
|
.chain(rejections.iter())
|
|
.any(|a| run_record.pending_approvals.contains(a));
|
|
if !matches_pending {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
%thread_id,
|
|
?approvals,
|
|
?rejections,
|
|
pending = ?run_record.pending_approvals,
|
|
"[flows] flows_resume: rejected — caller approvals/rejections name none of the pending gates"
|
|
);
|
|
return Err(format!(
|
|
"no pending approval matches: approvals {approvals:?} / rejections {rejections:?} do \
|
|
not name any of the currently pending gates {:?} for run '{thread_id}'",
|
|
run_record.pending_approvals
|
|
));
|
|
}
|
|
|
|
let compiled = tinyflows::compiler::compile(&flow.graph).map_err(|e| e.to_string())?;
|
|
let config_arc = Arc::new(config.clone());
|
|
let caps =
|
|
crate::openhuman::tinyflows::build_capabilities(config_arc, format!("flow:{flow_id}"));
|
|
let checkpointer =
|
|
crate::openhuman::tinyflows::open_flow_checkpointer(config).map_err(|e| e.to_string())?;
|
|
|
|
tracing::debug!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
%thread_id,
|
|
approval_count = approvals.len(),
|
|
rejection_count = rejections.len(),
|
|
"[flows] flows_resume: resuming checkpointed run"
|
|
);
|
|
|
|
let origin = workflow_origin(flow_id, flow.require_approval);
|
|
// Same per-run journal as `flows_run`: the resumed execution mints a new
|
|
// tinyagents run id, so its observation slice is read under that id.
|
|
let journal = Arc::new(tinyflows::engine::InMemoryGraphEventJournal::new());
|
|
// Live observer (issue G2): the resumed run fires `on_step_finish` for each
|
|
// node that runs after the interrupt boundary, so downstream steps are
|
|
// persisted + streamed live too, keyed by the same `thread_id`/run row.
|
|
let observer: Arc<dyn tinyflows::observability::RunObserver> = Arc::new(
|
|
crate::openhuman::tinyflows::observability::FlowRunObserver::new(
|
|
Arc::new(config.clone()),
|
|
flow_id,
|
|
thread_id.to_string(),
|
|
),
|
|
);
|
|
// `rejections` (issue G4 — deny semantics): a denied gate routes to its
|
|
// `error` port (recovery branch) or, if it has none, fails the run. The
|
|
// empty-rejections case is byte-for-byte the prior approve-only resume.
|
|
let run = with_origin(
|
|
origin,
|
|
tinyflows::engine::resume_with_checkpointer_journaled_observed(
|
|
&compiled,
|
|
&caps,
|
|
checkpointer,
|
|
thread_id,
|
|
approvals,
|
|
rejections,
|
|
journal.clone(),
|
|
&observer,
|
|
),
|
|
);
|
|
|
|
let journaled = match tokio::time::timeout(
|
|
std::time::Duration::from_secs(FLOW_RUN_TIMEOUT_SECS),
|
|
run,
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(journaled)) => journaled,
|
|
Ok(Err(e)) => {
|
|
let _ = store::record_run(config, flow_id, "failed");
|
|
let observed = current_persisted_steps(config, thread_id);
|
|
finish_flow_run_row(
|
|
config,
|
|
thread_id,
|
|
"failed",
|
|
&observed,
|
|
&[],
|
|
Some(&e.to_string()),
|
|
);
|
|
tracing::warn!(target: "flows", flow_id = %flow_id, %thread_id, error = %e, "[flows] flows_resume: run failed");
|
|
return Err(e.to_string());
|
|
}
|
|
Err(_elapsed) => {
|
|
let msg = format!("flow resume timed out after {FLOW_RUN_TIMEOUT_SECS}s");
|
|
let _ = store::record_run(config, flow_id, "failed");
|
|
let observed = current_persisted_steps(config, thread_id);
|
|
finish_flow_run_row(config, thread_id, "failed", &observed, &[], Some(&msg));
|
|
tracing::warn!(target: "flows", flow_id = %flow_id, %thread_id, timeout_secs = FLOW_RUN_TIMEOUT_SECS, "[flows] flows_resume: run timed out");
|
|
return Err(msg);
|
|
}
|
|
};
|
|
let outcome = journaled.outcome;
|
|
|
|
let settled = settle_steps(config, thread_id, &outcome.output);
|
|
let (status, error) = finalize_terminal_status(&settled, &outcome.pending_approvals);
|
|
store::record_run(config, flow_id, status).map_err(|e| e.to_string())?;
|
|
finish_flow_run_row(
|
|
config,
|
|
thread_id,
|
|
status,
|
|
&settled,
|
|
&outcome.pending_approvals,
|
|
error.as_deref(),
|
|
);
|
|
export_run_to_langfuse(
|
|
config,
|
|
&flow.name,
|
|
flow_id,
|
|
thread_id,
|
|
status,
|
|
FlowRunTrigger::Resume,
|
|
&journal,
|
|
&journaled.graph_run_ids.run_id,
|
|
)
|
|
.await;
|
|
notify_pending_approval(&flow, thread_id, &outcome.pending_approvals);
|
|
|
|
tracing::info!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
%thread_id,
|
|
status,
|
|
pending_approvals = outcome.pending_approvals.len(),
|
|
"[flows] flows_resume: finished"
|
|
);
|
|
|
|
Ok(RpcOutcome::single_log(
|
|
json!({
|
|
"output": outcome.output,
|
|
"pending_approvals": outcome.pending_approvals,
|
|
"thread_id": thread_id,
|
|
}),
|
|
format!("flow resume {status}"),
|
|
))
|
|
}
|
|
|
|
/// Lists the most recent runs for a flow (newest first), for the B3
|
|
/// run-history inspector. Runs a lazy parked-run TTL sweep first (see
|
|
/// [`sweep_expired_parked_runs`]) so the listing reflects any run that has now
|
|
/// aged out of `pending_approval`.
|
|
pub async fn flows_list_runs(
|
|
config: &Config,
|
|
flow_id: &str,
|
|
limit: usize,
|
|
) -> Result<RpcOutcome<Vec<FlowRun>>, String> {
|
|
sweep_expired_parked_runs(config).await;
|
|
let runs = store::list_flow_runs(config, flow_id, limit).map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(
|
|
runs,
|
|
format!("flow runs listed: {flow_id}"),
|
|
))
|
|
}
|
|
|
|
/// Manually prunes a flow's run history down to the retention cap
|
|
/// ([`store::MAX_FLOW_RUNS_PER_FLOW`]), deleting only terminal runs outside the
|
|
/// newest-N window. Never removes a `running` or `pending_approval` run — a
|
|
/// parked run must survive for a later `flows_resume`. Pruning also happens
|
|
/// automatically on every new-run insert; this RPC exposes it for an explicit
|
|
/// on-demand sweep (e.g. a maintenance action). Returns the number of runs
|
|
/// pruned.
|
|
pub async fn flows_prune_runs(config: &Config, flow_id: &str) -> Result<RpcOutcome<Value>, String> {
|
|
let keep = store::MAX_FLOW_RUNS_PER_FLOW;
|
|
let pruned = store::prune_flow_runs(config, flow_id, keep).map_err(|e| e.to_string())?;
|
|
tracing::info!(target: "flows", flow_id, pruned, keep, "[flows] flows_prune_runs: manual retention sweep");
|
|
Ok(RpcOutcome::single_log(
|
|
json!({ "flow_id": flow_id, "pruned": pruned, "kept": keep }),
|
|
format!("flow runs pruned: {flow_id} ({pruned} removed)"),
|
|
))
|
|
}
|
|
|
|
/// Loads a single flow run record by id (== `thread_id`). Runs the lazy
|
|
/// parked-run TTL sweep first so a stale parked run is reported as `cancelled`
|
|
/// rather than perpetually `pending_approval`.
|
|
pub async fn flows_get_run(config: &Config, run_id: &str) -> Result<RpcOutcome<FlowRun>, String> {
|
|
sweep_expired_parked_runs(config).await;
|
|
let run = store::get_flow_run(config, run_id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("flow run '{run_id}' not found"))?;
|
|
Ok(RpcOutcome::single_log(
|
|
run,
|
|
format!("flow run loaded: {run_id}"),
|
|
))
|
|
}
|
|
|
|
/// Lazy TTL sweep (issue G4): expires every parked `pending_approval` run older
|
|
/// than [`FLOW_PARKED_TTL_SECS`] to a terminal `"cancelled"`, updates the flow
|
|
/// summary, and drops each expired run's durable checkpoint so it can't be
|
|
/// resumed. Mirrors the `approval` domain's expire-on-read idiom
|
|
/// (`approval::store::expire_stale`): called at the top of the run-read paths
|
|
/// rather than from a dedicated background timer, so it needs no scheduler.
|
|
///
|
|
/// Best-effort by construction — a sweep failure is logged and swallowed, never
|
|
/// failing the read that triggered it. The `flows_resume` status guard already
|
|
/// rejects any non-`pending_approval` run, so a swept run is unresumable the
|
|
/// instant its row flips, independent of the checkpoint drop.
|
|
pub async fn sweep_expired_parked_runs(config: &Config) -> usize {
|
|
let now = Utc::now();
|
|
let cutoff = (now - chrono::Duration::seconds(FLOW_PARKED_TTL_SECS)).to_rfc3339();
|
|
let now_str = now.to_rfc3339();
|
|
let error_msg = format!("parked run expired after {FLOW_PARKED_TTL_SECS}s awaiting approval");
|
|
|
|
let swept = match store::expire_parked_runs(config, &cutoff, &now_str, &error_msg) {
|
|
Ok(swept) => swept,
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", error = %e, "[flows] parked-run TTL sweep failed (read continues)");
|
|
return 0;
|
|
}
|
|
};
|
|
for (run_id, flow_id) in &swept {
|
|
if let Err(e) = store::record_run(config, flow_id, "cancelled") {
|
|
tracing::warn!(target: "flows", run_id, flow_id, error = %e, "[flows] TTL sweep: failed to update flow summary for expired run");
|
|
}
|
|
drop_checkpoint(config, run_id).await;
|
|
}
|
|
if !swept.is_empty() {
|
|
tracing::info!(target: "flows", count = swept.len(), ttl_secs = FLOW_PARKED_TTL_SECS, "[flows] parked-run TTL sweep expired stale runs");
|
|
}
|
|
swept.len()
|
|
}
|
|
|
|
/// Cancels a flow run (issue G4), settling it to a terminal `"cancelled"`
|
|
/// status and dropping its durable checkpoint so the aborted thread can never
|
|
/// be resumed.
|
|
///
|
|
/// Two cases, distinguished by [`run_registry::cancel`]:
|
|
/// - **In-flight** (a `flows_run` / `flows_resume` currently executing its run
|
|
/// future): the token is signalled and that run's own cancellation arm writes
|
|
/// the terminal row + drops the checkpoint as it unwinds — we don't write the
|
|
/// row here, to avoid two writers racing the same `flow_runs` row.
|
|
/// - **Parked / stale** (a `pending_approval` run awaiting a human decision, or
|
|
/// a `running` row whose task is gone): no live task exists to unwind, so
|
|
/// this settles the row terminally itself and drops the checkpoint.
|
|
///
|
|
/// A run that is already terminal (`completed` / `completed_with_warnings` /
|
|
/// `failed` / `cancelled`) is a clear error, not a silent no-op — otherwise a
|
|
/// settled warning run could be overwritten as `"cancelled"`, corrupting the
|
|
/// run-honesty status it already recorded.
|
|
pub async fn flows_cancel_run(config: &Config, run_id: &str) -> Result<RpcOutcome<Value>, String> {
|
|
let run = store::get_flow_run(config, run_id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("flow run '{run_id}' not found"))?;
|
|
|
|
if matches!(
|
|
run.status.as_str(),
|
|
"completed" | "completed_with_warnings" | "failed" | "cancelled"
|
|
) {
|
|
return Err(format!(
|
|
"flow run '{run_id}' is already terminal (status: {}) — nothing to cancel",
|
|
run.status
|
|
));
|
|
}
|
|
|
|
let signalled = run_registry::cancel(run_id);
|
|
tracing::info!(
|
|
target: "flows",
|
|
run_id,
|
|
flow_id = %run.flow_id,
|
|
signalled,
|
|
prior_status = %run.status,
|
|
"[flows] flows_cancel_run: cancelling run"
|
|
);
|
|
|
|
if signalled {
|
|
// The in-flight run's cancellation arm owns the terminal write + the
|
|
// checkpoint drop; we've signalled it and return. Its settle is
|
|
// eventual (the run future unwinds), so report "requested".
|
|
return Ok(RpcOutcome::single_log(
|
|
json!({ "run_id": run_id, "cancelled": true, "was_in_flight": true }),
|
|
format!("flow run {run_id} cancellation requested"),
|
|
));
|
|
}
|
|
|
|
// Not in flight: settle the row terminally and drop the checkpoint here.
|
|
if let Err(e) = store::record_run(config, &run.flow_id, "cancelled") {
|
|
tracing::warn!(target: "flows", run_id, flow_id = %run.flow_id, error = %e, "[flows] flows_cancel_run: failed to record cancelled status on flow summary");
|
|
}
|
|
let observed = current_persisted_steps(config, run_id);
|
|
finish_flow_run_row(
|
|
config,
|
|
run_id,
|
|
"cancelled",
|
|
&observed,
|
|
&[],
|
|
Some("run cancelled"),
|
|
);
|
|
drop_checkpoint(config, run_id).await;
|
|
|
|
Ok(RpcOutcome::single_log(
|
|
json!({ "run_id": run_id, "cancelled": true, "was_in_flight": false }),
|
|
format!("flow run {run_id} cancelled"),
|
|
))
|
|
}
|
|
|
|
/// Best-effort drop of a run's durable tinyagents checkpoint thread, so a
|
|
/// cancelled (or expired) run can never be resumed from its persisted interrupt
|
|
/// boundary. Logged, never fatal — the `flow_runs` row's terminal status is the
|
|
/// authoritative "not resumable" signal (the `flows_resume` guard already
|
|
/// rejects any non-`pending_approval` status); dropping the checkpoint is
|
|
/// belt-and-suspenders that also reclaims the storage.
|
|
async fn drop_checkpoint(config: &Config, thread_id: &str) {
|
|
use tinyflows::engine::Checkpointer as _;
|
|
match crate::openhuman::tinyflows::open_flow_checkpointer(config) {
|
|
Ok(checkpointer) => match checkpointer.delete_thread(thread_id).await {
|
|
Ok(()) => {
|
|
tracing::debug!(target: "flows", thread_id, "[flows] dropped durable checkpoint for cancelled/expired run")
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", thread_id, error = %e, "[flows] failed to drop durable checkpoint")
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", thread_id, error = %e, "[flows] could not open checkpointer to drop checkpoint");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds the `TrustedAutomation { Workflow }` origin scoped around every
|
|
/// `flows_run` / `flows_resume` invocation. See `flows_run`'s doc for why
|
|
/// this applies uniformly regardless of caller.
|
|
fn workflow_origin(flow_id: &str, require_approval: bool) -> AgentTurnOrigin {
|
|
AgentTurnOrigin::TrustedAutomation {
|
|
job_id: flow_id.to_string(),
|
|
source: TrustedAutomationSource::Workflow { require_approval },
|
|
}
|
|
}
|
|
|
|
/// Best-effort insert of the initial `"running"` `flow_runs` row. Logged,
|
|
/// never fails the run — run-history persistence is an observability aid,
|
|
/// not a correctness requirement of the run itself.
|
|
fn start_flow_run_row(config: &Config, thread_id: &str, flow_id: &str) {
|
|
let started_at = Utc::now().to_rfc3339();
|
|
if let Err(e) = store::insert_flow_run(config, thread_id, flow_id, thread_id, &started_at) {
|
|
tracing::warn!(target: "flows", flow_id, thread_id, error = %e, "[flows] failed to persist flow run start");
|
|
}
|
|
}
|
|
|
|
/// Best-effort finalization of a `flow_runs` row. Logged, never fails the
|
|
/// run (see [`start_flow_run_row`]).
|
|
fn finish_flow_run_row(
|
|
config: &Config,
|
|
thread_id: &str,
|
|
status: &str,
|
|
steps: &[FlowRunStep],
|
|
pending_approvals: &[String],
|
|
error: Option<&str>,
|
|
) {
|
|
let finished_at = Utc::now().to_rfc3339();
|
|
if let Err(e) = store::finish_flow_run(
|
|
config,
|
|
thread_id,
|
|
status,
|
|
&finished_at,
|
|
steps,
|
|
pending_approvals,
|
|
error,
|
|
) {
|
|
tracing::warn!(target: "flows", thread_id, status, error = %e, "[flows] failed to persist flow run finish");
|
|
}
|
|
}
|
|
|
|
/// Reconstructs a lean per-node step list from a settled run's
|
|
/// `output["nodes"]` map.
|
|
///
|
|
/// As of issue G2 (live run observation) this is no longer the primary source
|
|
/// of run steps — `flows::observability::FlowRunObserver` persists each step
|
|
/// live as it finishes (with real `status`/`duration_ms`). This reconstruction
|
|
/// is now only a **fallback**, used by [`settle_steps`] to fill in any node the
|
|
/// observer didn't emit an `on_step_finish` for (notably the trigger node),
|
|
/// and as the whole-run source when the observer saw nothing at all.
|
|
fn reconstruct_steps(output: &Value) -> Vec<FlowRunStep> {
|
|
let Some(nodes) = output.get("nodes").and_then(Value::as_object) else {
|
|
return Vec::new();
|
|
};
|
|
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),
|
|
// Reconstructed post-hoc: no live status/timing (see FlowRunStep).
|
|
status: None,
|
|
duration_ms: None,
|
|
diagnostics: Vec::new(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Reads back whatever steps the live [`FlowRunObserver`] has already persisted
|
|
/// onto the run's row. Best-effort: a read failure yields an empty list (the
|
|
/// caller still writes a terminal row), never propagating an error into the
|
|
/// run's settle path.
|
|
///
|
|
/// [`FlowRunObserver`]: crate::openhuman::tinyflows::observability::FlowRunObserver
|
|
fn current_persisted_steps(config: &Config, run_id: &str) -> Vec<FlowRunStep> {
|
|
store::get_flow_run(config, run_id)
|
|
.ok()
|
|
.flatten()
|
|
.map(|run| run.steps)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Assembles the final step list to persist at settle: the live steps the
|
|
/// observer already recorded (carrying real `status`/`duration_ms`), plus any
|
|
/// node present in the post-hoc [`reconstruct_steps`] projection that the
|
|
/// observer never emitted a step for — the trigger node, or (defensively) an
|
|
/// observer that missed a step. If the observer recorded nothing at all
|
|
/// (e.g. a run that paused immediately at a gate before any node finished),
|
|
/// falls back wholesale to the reconstruction.
|
|
fn settle_steps(config: &Config, run_id: &str, output: &Value) -> Vec<FlowRunStep> {
|
|
let reconstructed = reconstruct_steps(output);
|
|
let persisted = current_persisted_steps(config, run_id);
|
|
if persisted.is_empty() {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
run_id,
|
|
reconstructed = reconstructed.len(),
|
|
"[flows] settle_steps: no live-observed steps — using post-hoc reconstruction"
|
|
);
|
|
return reconstructed;
|
|
}
|
|
let mut merged = persisted;
|
|
let mut filled = 0usize;
|
|
for step in reconstructed {
|
|
if !merged.iter().any(|s| s.node_id == step.node_id) {
|
|
merged.push(step);
|
|
filled += 1;
|
|
}
|
|
}
|
|
tracing::debug!(
|
|
target: "flows",
|
|
run_id,
|
|
step_count = merged.len(),
|
|
filled_from_reconstruction = filled,
|
|
"[flows] settle_steps: merged live-observed steps with post-hoc reconstruction"
|
|
);
|
|
merged
|
|
}
|
|
|
|
/// Degrades a would-be `"completed"` status: `"failed"` if any settled step
|
|
/// errored, `"completed_with_warnings"` if any carries null-resolution
|
|
/// diagnostics, else `"completed"`.
|
|
///
|
|
/// Called only once the run has no `pending_approvals` left — precedence
|
|
/// against that case is handled by the caller (`pending_approval` always
|
|
/// wins over any of these).
|
|
fn degrade_completed_status(steps: &[FlowRunStep]) -> &'static str {
|
|
if steps.iter().any(|s| s.status.as_deref() == Some("error")) {
|
|
return "failed";
|
|
}
|
|
if steps.iter().any(|s| !s.diagnostics.is_empty()) {
|
|
"completed_with_warnings"
|
|
} else {
|
|
"completed"
|
|
}
|
|
}
|
|
|
|
/// Names the node(s) whose step settled with `status == "error"` — the
|
|
/// engine's `ExecutionStep` carries no error message of its own for a step
|
|
/// that failed under an `on_error: "continue"`/`"route"` policy (it only
|
|
/// fails the *run* future, and so gets an actual error string, when the
|
|
/// policy is `"stop"`), so this is the best available detail for
|
|
/// [`FlowRun::error`] when [`degrade_completed_status`] degrades to
|
|
/// `"failed"` without an outer run-future `Err`.
|
|
fn failed_step_error_summary(steps: &[FlowRunStep]) -> Option<String> {
|
|
let failed_nodes: Vec<&str> = steps
|
|
.iter()
|
|
.filter(|s| s.status.as_deref() == Some("error"))
|
|
.map(|s| s.node_id.as_str())
|
|
.collect();
|
|
if failed_nodes.is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!(
|
|
"node(s) failed after retries: {}",
|
|
failed_nodes.join(", ")
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Computes a settled run's terminal status and, when that status is
|
|
/// `"failed"`, an accompanying error message — shared by `flows_run` and
|
|
/// `flows_resume` so the two call sites can't drift on the
|
|
/// `pending_approval` > `degrade_completed_status` precedence or forget to
|
|
/// populate [`FlowRun::error`] (its doc contract: "Error message when
|
|
/// `status == \"failed\"`") for a run that degraded via a settled step error
|
|
/// rather than an outer run-future `Err`.
|
|
fn finalize_terminal_status(
|
|
settled: &[FlowRunStep],
|
|
pending_approvals: &[String],
|
|
) -> (&'static str, Option<String>) {
|
|
if !pending_approvals.is_empty() {
|
|
return ("pending_approval", None);
|
|
}
|
|
let status = degrade_completed_status(settled);
|
|
let error = if status == "failed" {
|
|
failed_step_error_summary(settled)
|
|
} else {
|
|
None
|
|
};
|
|
(status, error)
|
|
}
|
|
|
|
/// Milliseconds since the Unix epoch, for `CoreNotificationEvent::timestamp_ms`.
|
|
fn now_ms() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Surfaces a paused run as a `CoreNotification` (category `Agents`) with an
|
|
/// "approve" action carrying `flow_id`/`thread_id`/`node_ids`, mirroring the
|
|
/// pattern `agent_meetings::calendar`'s auto-summarize "Ask" flow uses
|
|
/// (direct `publish_core_notification` call with an action payload, not the
|
|
/// generic `DomainEvent -> event_to_notification` bridge — this is a
|
|
/// flows-specific card with flow-specific action data, not a translation of
|
|
/// an existing broadcast event). No-op when nothing is pending.
|
|
fn notify_pending_approval(flow: &Flow, thread_id: &str, pending_approvals: &[String]) {
|
|
if pending_approvals.is_empty() {
|
|
return;
|
|
}
|
|
|
|
use crate::openhuman::notifications::bus::publish_core_notification;
|
|
use crate::openhuman::notifications::types::{
|
|
CoreNotificationAction, CoreNotificationCategory, CoreNotificationEvent,
|
|
};
|
|
|
|
let action_payload = json!({
|
|
"flow_id": flow.id,
|
|
"thread_id": thread_id,
|
|
"node_ids": pending_approvals,
|
|
});
|
|
|
|
publish_core_notification(CoreNotificationEvent {
|
|
id: format!("flow-pending-approval:{}:{}", flow.id, thread_id),
|
|
category: CoreNotificationCategory::Agents,
|
|
title: "Workflow needs approval".to_string(),
|
|
body: format!(
|
|
"\"{}\" is waiting on {} approval{} before it can continue.",
|
|
flow.name,
|
|
pending_approvals.len(),
|
|
if pending_approvals.len() == 1 {
|
|
""
|
|
} else {
|
|
"s"
|
|
}
|
|
),
|
|
// No dedicated Workflows review route exists yet (B3 ships the UI);
|
|
// leave unset rather than link to a page that can't act on it.
|
|
deep_link: None,
|
|
timestamp_ms: now_ms(),
|
|
actions: Some(vec![CoreNotificationAction {
|
|
action_id: "approve".to_string(),
|
|
label: "Review".to_string(),
|
|
payload: Some(action_payload),
|
|
}]),
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Flow Scout — workflow discovery + suggestion lifecycle
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Overall safety bound on one `flows_discover` run. The `flow_discovery` agent
|
|
/// reasons read-only over the user's data and ends by emitting
|
|
/// `suggest_workflows`; its own `max_iterations` caps the loop, but a hung
|
|
/// LLM/tool call must never let the RPC block indefinitely.
|
|
const FLOW_DISCOVER_TIMEOUT_SECS: u64 = 300;
|
|
|
|
/// The canned brief handed to the `flow_discovery` agent. The agent's own
|
|
/// archetype prompt teaches the read → correlate → ground → emit loop; this is
|
|
/// just the kick-off instruction for the on-demand "Discover" action.
|
|
const FLOW_DISCOVER_PROMPT: &str = "Discover the most useful automations you could set up for me. \
|
|
Read what you can about how I work — my goals, recurring conversations, the people and apps I \
|
|
deal with, and the flows I already have — then propose a few concrete, buildable workflows. \
|
|
Ground each in something you actually observed about me, and end by calling suggest_workflows.";
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Copilot / scout streaming (Phase B) — bridge a builder/scout turn's live
|
|
// AgentProgress onto the web-channel socket, keyed by a chat thread, exactly
|
|
// like an interactive chat turn. Blueprint: `agent/task_dispatcher/executor.rs`.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Where to stream a `flows_build` / `flows_discover` turn. When present, the
|
|
/// agent's progress events (`text_delta` / `thinking_delta` / `tool_call` /
|
|
/// `tool_result` / terminal `chat_done`) are published as `WebChannelEvent`s
|
|
/// tagged with this `thread_id` — the same room the shared chat pane already
|
|
/// subscribes to and decodes — so the copilot/scout UI renders streamed text,
|
|
/// tool cards, and workflow-proposal cards live instead of spinning for the
|
|
/// whole (up to 300s) headless run.
|
|
///
|
|
/// Broadcast client id is always `"system"` (like cron / task-session runs), so
|
|
/// any client viewing the thread receives the events (the frontend keys by
|
|
/// `thread_id`). The blocking `{ proposal, assistant_text }` return is
|
|
/// unchanged — streaming is purely additive, opt-in per call.
|
|
#[derive(Debug, Clone)]
|
|
pub struct FlowStreamTarget {
|
|
/// The chat thread the copilot/scout turn streams into.
|
|
pub thread_id: String,
|
|
/// Per-turn correlation id (matches the frontend `request_id`). Generated
|
|
/// when the caller doesn't supply one.
|
|
pub request_id: String,
|
|
}
|
|
|
|
impl FlowStreamTarget {
|
|
/// Build a streaming target from optional RPC params. Streaming is enabled
|
|
/// only when a non-empty `thread_id` is given; a missing/blank `request_id`
|
|
/// is filled with a fresh uuid so the turn is always correlatable. Returns
|
|
/// `None` (headless run, prior behaviour) when no usable `thread_id`.
|
|
pub fn from_params(thread_id: Option<String>, request_id: Option<String>) -> Option<Self> {
|
|
let thread_id = thread_id
|
|
.map(|t| t.trim().to_string())
|
|
.filter(|t| !t.is_empty())?;
|
|
let request_id = request_id
|
|
.map(|r| r.trim().to_string())
|
|
.filter(|r| !r.is_empty())
|
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
|
Some(Self {
|
|
thread_id,
|
|
request_id,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Attach the web-channel progress bridge to `agent` for a builder/scout turn.
|
|
/// Wires an mpsc channel into the agent's progress sink and spawns the bridge
|
|
/// task that translates each [`AgentProgress`] into a socket event keyed by the
|
|
/// target thread (and mirrors a `TurnStateStore` so the tool timeline replays
|
|
/// on reopen). The bridge task lives until the agent drops its progress sender
|
|
/// (turn end). `source` is a short trace-attribution label (e.g.
|
|
/// `"flows_build"`).
|
|
fn attach_flow_progress_bridge(
|
|
agent: &mut crate::openhuman::agent::Agent,
|
|
target: &FlowStreamTarget,
|
|
source: &str,
|
|
config: &Config,
|
|
) {
|
|
let (progress_tx, progress_rx) = tokio::sync::mpsc::channel(64);
|
|
agent.set_on_progress(Some(progress_tx));
|
|
tracing::info!(
|
|
target: "flows",
|
|
thread_id = %target.thread_id,
|
|
request_id = %target.request_id,
|
|
source = %source,
|
|
"[flows] progress bridge: attaching (streaming copilot/scout turn)"
|
|
);
|
|
crate::openhuman::channels::providers::web::spawn_progress_bridge(
|
|
progress_rx,
|
|
"system".to_string(),
|
|
target.thread_id.clone(),
|
|
target.request_id.clone(),
|
|
crate::openhuman::threads::turn_state::TurnStateStore::new(config.workspace_dir.clone()),
|
|
crate::openhuman::channels::providers::web::ChatRequestMetadata {
|
|
source: Some(source.to_string()),
|
|
..Default::default()
|
|
},
|
|
config.clone(),
|
|
);
|
|
}
|
|
|
|
/// Emit the terminal chat event a streamed builder/scout turn owes its viewers.
|
|
/// The progress bridge only streams intermediate deltas; without this the live
|
|
/// session spins forever. Mirrors how `task_dispatcher/executor.rs` finalizes a
|
|
/// streamed run: a success delivers a `chat_done` (via the shared presentation
|
|
/// path, so segmentation/reaction match a normal turn), a failure publishes a
|
|
/// `chat_error`. Broadcast as `"system"` so any viewer of the thread receives
|
|
/// it (frontend keys by `thread_id`).
|
|
async fn finalize_flow_stream(
|
|
target: &FlowStreamTarget,
|
|
result: &Result<String, String>,
|
|
prompt: &str,
|
|
) {
|
|
match result {
|
|
Ok(text) => {
|
|
crate::openhuman::channels::providers::web::presentation::deliver_response(
|
|
"system",
|
|
&target.thread_id,
|
|
&target.request_id,
|
|
text,
|
|
prompt,
|
|
&[],
|
|
// Builder/scout turns don't surface in the chat footer; their
|
|
// token/cost spend is still captured by the global cost tracker.
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
Err(err) => {
|
|
crate::openhuman::channels::providers::web::publish_web_channel_event(
|
|
crate::core::socketio::WebChannelEvent {
|
|
event: "chat_error".to_string(),
|
|
client_id: "system".to_string(),
|
|
thread_id: target.thread_id.clone(),
|
|
request_id: target.request_id.clone(),
|
|
message: Some(err.clone()),
|
|
error_type: Some("agent_error".to_string()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
}
|
|
}
|
|
tracing::info!(
|
|
target: "flows",
|
|
thread_id = %target.thread_id,
|
|
request_id = %target.request_id,
|
|
ok = result.is_ok(),
|
|
"[flows] progress bridge: detached (terminal chat event emitted)"
|
|
);
|
|
}
|
|
|
|
/// Runs the read-only `flow_discovery` agent ("Flow Scout") on demand: it reads
|
|
/// the user's memory/threads/people/connections/existing flows, grounds a few
|
|
/// automation ideas, and records them via the `suggest_workflows` tool (which
|
|
/// persists to the `flow_suggestions` table). Returns the current set of active
|
|
/// (`New`) suggestions after the run.
|
|
///
|
|
/// The agent is strictly read-only — its only write is `suggest_workflows`
|
|
/// (`PermissionLevel::None`) — so this never persists, enables, or runs a flow.
|
|
/// Turning a suggestion into a real flow is the user's separate "Build this"
|
|
/// action, which routes to `workflow_builder`.
|
|
pub async fn flows_discover(
|
|
config: &Config,
|
|
stream: Option<FlowStreamTarget>,
|
|
) -> Result<RpcOutcome<Vec<FlowSuggestion>>, String> {
|
|
use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin};
|
|
use crate::openhuman::agent::Agent;
|
|
|
|
tracing::info!(
|
|
target: "flows",
|
|
streaming = stream.is_some(),
|
|
"[flows] flows_discover: starting Flow Scout discovery run"
|
|
);
|
|
|
|
// The registry must be initialised before building a named builtin agent
|
|
// (mirrors `agent_registry::ops::available_tools`); it is idempotent, so a
|
|
// second call from an already-booted core is a cheap no-op.
|
|
crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir)
|
|
.map_err(|e| format!("failed to initialise agent registry: {e}"))?;
|
|
|
|
let mut agent = Agent::from_config_for_agent(config, "flow_discovery")
|
|
.map_err(|e| format!("failed to build flow_discovery agent: {e:#}"))?;
|
|
agent.set_agent_definition_name("flow_discovery".to_string());
|
|
|
|
// When a chat thread is attached, stream the scout turn into it exactly like
|
|
// an interactive turn (see `FlowStreamTarget`). Best-effort — with no target
|
|
// the run stays headless, exactly as before.
|
|
if let Some(target) = &stream {
|
|
attach_flow_progress_bridge(&mut agent, target, "flows_discover", config);
|
|
}
|
|
|
|
// Run to completion under a CLI origin (an internal, user-initiated action —
|
|
// the approval gate must not fail-closed on it), bounded by a wall-clock
|
|
// timeout so a hung provider call can't wedge the RPC. When streaming, the
|
|
// run is wrapped in the thread-id scope so descendant turns tag their trace
|
|
// and socket events with this thread.
|
|
let run = with_origin(AgentTurnOrigin::Cli, agent.run_single(FLOW_DISCOVER_PROMPT));
|
|
let run = tokio::time::timeout(
|
|
std::time::Duration::from_secs(FLOW_DISCOVER_TIMEOUT_SECS),
|
|
run,
|
|
);
|
|
let timed = match &stream {
|
|
Some(target) => {
|
|
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
|
target.thread_id.clone(),
|
|
run,
|
|
)
|
|
.await
|
|
}
|
|
None => run.await,
|
|
};
|
|
// Reduce the (timeout, run) result to a single `Result<summary, error>` so
|
|
// the terminal chat event can be emitted uniformly for the streamed case.
|
|
let outcome: Result<String, String> = match timed {
|
|
Ok(Ok(summary)) => {
|
|
tracing::debug!(target: "flows", "[flows] flows_discover: agent run completed");
|
|
Ok(summary)
|
|
}
|
|
Ok(Err(e)) => {
|
|
// The agent errored. Surface it, but still return whatever
|
|
// suggestions may already be persisted (a prior run's active set)
|
|
// rather than hard-failing the UI.
|
|
tracing::warn!(target: "flows", error = %e, "[flows] flows_discover: agent run failed");
|
|
Err(format!("flow_discovery run failed: {e:#}"))
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
timeout_secs = FLOW_DISCOVER_TIMEOUT_SECS,
|
|
"[flows] flows_discover: agent run timed out"
|
|
);
|
|
Err(format!(
|
|
"flow_discovery run timed out after {FLOW_DISCOVER_TIMEOUT_SECS}s"
|
|
))
|
|
}
|
|
};
|
|
|
|
// Emit the terminal chat event so a client viewing the thread finalizes the
|
|
// assistant bubble instead of spinning (the bridge only streams deltas).
|
|
if let Some(target) = &stream {
|
|
finalize_flow_stream(target, &outcome, FLOW_DISCOVER_PROMPT).await;
|
|
}
|
|
|
|
let suggestions = store::list_suggestions(config, Some(SuggestionStatus::New), 50)
|
|
.map_err(|e| e.to_string())?;
|
|
tracing::info!(
|
|
target: "flows",
|
|
count = suggestions.len(),
|
|
"[flows] flows_discover: returning active suggestions"
|
|
);
|
|
Ok(RpcOutcome::single_log(
|
|
suggestions,
|
|
"flow discovery complete",
|
|
))
|
|
}
|
|
|
|
/// Overall safety bound on one `flows_build` run. The `workflow_builder` agent's
|
|
/// own `max_iterations` caps its loop, but a hung LLM/tool call must never let
|
|
/// the RPC block indefinitely.
|
|
const FLOW_BUILD_TIMEOUT_SECS: u64 = 300;
|
|
|
|
/// Tools stripped from the `workflow_builder` belt on the direct `flows_build`
|
|
/// RPC path (issue #4593).
|
|
///
|
|
/// `flows_build` runs the builder under [`AgentTurnOrigin::Cli`] so the approval
|
|
/// gate does not fail-closed in a headless/streamed run — but that same origin
|
|
/// makes [`crate::openhuman::approval::ApprovalGate`] **auto-allow** every
|
|
/// `external_effect` tool. The flows live-runner (`run_flow`,
|
|
/// [`crate::openhuman::flows::tools`]'s `RunFlowTool`) executes a *live* saved
|
|
/// flow (real Slack/Gmail/HTTP/code effects via [`flows_run`]), so a stray call
|
|
/// during an authoring turn would fire it with no HITL confirmation. This path
|
|
/// has no routable approval surface yet (the copilot stream carries only a
|
|
/// broadcast `thread_id`, no per-user `client_id`), so rather than
|
|
/// park-then-TTL-deny we make it **unreachable** here — matching `flows_build`'s
|
|
/// contract that it "never enables or runs a flow". The tool stays available
|
|
/// (and properly gated behind a real `WebChat` approval card) when
|
|
/// `workflow_builder` is invoked as the `build_workflow` chat delegate.
|
|
///
|
|
/// `run_flow` is the live-runner on the belt today. The legacy `run_workflow`
|
|
/// name (now the unrelated harness spawn tool) is listed too as belt-and-braces
|
|
/// against a re-rename or the name ever leaking back onto this belt;
|
|
/// `hide_tools` no-ops on a name that isn't present.
|
|
const FLOWS_BUILD_HIDDEN_TOOLS: &[&str] = &["run_workflow", "run_flow"];
|
|
|
|
/// Strip the live-run tool(s) in [`FLOWS_BUILD_HIDDEN_TOOLS`] from `agent`'s
|
|
/// callable set for the direct `flows_build` RPC path.
|
|
///
|
|
/// Delegates to [`crate::openhuman::agent::Agent::hide_tools`], which removes
|
|
/// the names from the builder's (already narrow) visible belt and rebuilds the
|
|
/// session's `ToolPolicySession` so they resolve to `Deny` at the tool-call
|
|
/// boundary — a hard execution guarantee even if the model requests the tool.
|
|
/// The authoring tools (`propose`/`revise`/`save`/`dry_run`/reads) are all
|
|
/// `external_effect() == false` and untouched, so the turn never fail-closes.
|
|
fn restrict_builder_toolset(agent: &mut crate::openhuman::agent::Agent) {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
hidden = ?FLOWS_BUILD_HIDDEN_TOOLS,
|
|
"[flows] flows_build: hiding live-run tools from builder belt"
|
|
);
|
|
agent.hide_tools(FLOWS_BUILD_HIDDEN_TOOLS);
|
|
}
|
|
|
|
/// Runs the `workflow_builder` agent for one authoring turn and returns its
|
|
/// proposal, invoking it as a first-class backend agent (exactly like the Flow
|
|
/// Scout `flows_discover`) rather than routing a hand-crafted delegate prompt
|
|
/// through the chat orchestrator.
|
|
///
|
|
/// The turn's natural-language brief is rendered **server-side** from the
|
|
/// structured [`BuilderRequest`](crate::openhuman::flows::agents::workflow_builder::builder_prompt::BuilderRequest)
|
|
/// (create / revise / repair / build). The agent ends by calling
|
|
/// `propose_workflow` / `revise_workflow` / `save_workflow`; we capture the
|
|
/// resulting `{ type: "workflow_proposal", … }` payload from the run's tool
|
|
/// history and return it alongside the agent's final assistant text.
|
|
///
|
|
/// Persistence stays with the agent's tools: `propose`/`revise` never persist;
|
|
/// `save_workflow` (only reachable in `build` mode with a real `flow_id`)
|
|
/// writes onto an existing flow. This op never enables or runs a flow.
|
|
pub async fn flows_build(
|
|
config: &Config,
|
|
req: crate::openhuman::flows::agents::workflow_builder::builder_prompt::BuilderRequest,
|
|
stream: Option<FlowStreamTarget>,
|
|
) -> Result<RpcOutcome<Value>, String> {
|
|
use crate::openhuman::agent::Agent;
|
|
use crate::openhuman::flows::agents::workflow_builder::builder_prompt::render_prompt;
|
|
|
|
// Reject invalid turns (e.g. a `build` with no `flow_id`) before we render a
|
|
// brief that would tell the agent to save onto nothing.
|
|
req.validate()?;
|
|
|
|
let prompt = render_prompt(&req);
|
|
tracing::info!(
|
|
target: "flows",
|
|
mode = ?req.mode,
|
|
has_graph = req.graph.is_some(),
|
|
flow_id = req.flow_id.as_deref().unwrap_or("<none>"),
|
|
streaming = stream.is_some(),
|
|
"[flows] flows_build: starting workflow_builder turn"
|
|
);
|
|
|
|
// The registry must be initialised before building a named builtin agent
|
|
// (idempotent — mirrors `flows_discover`).
|
|
crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir)
|
|
.map_err(|e| format!("failed to initialise agent registry: {e}"))?;
|
|
|
|
let mut agent = Agent::from_config_for_agent(config, "workflow_builder")
|
|
.map_err(|e| format!("failed to build workflow_builder agent: {e:#}"))?;
|
|
agent.set_agent_definition_name("workflow_builder".to_string());
|
|
|
|
// Strip the live-run tool(s) from the belt on this direct RPC path: under
|
|
// the `AgentTurnOrigin::Cli` origin below the approval gate auto-allows
|
|
// every external_effect tool, so `run_flow` could execute a live saved flow
|
|
// with no HITL confirmation (issue #4593). Restricting the visible set makes
|
|
// it `Deny` at the tool-call boundary; the authoring tools are untouched so
|
|
// the turn still runs headless without fail-closing.
|
|
restrict_builder_toolset(&mut agent);
|
|
|
|
// When a chat thread is attached (the copilot pane), stream the builder turn
|
|
// into it exactly like an interactive turn — text/tool deltas and the
|
|
// `propose_workflow` tool result the frontend renders as a proposal card.
|
|
// Best-effort — with no target the run stays headless (CLI / tests).
|
|
if let Some(target) = &stream {
|
|
attach_flow_progress_bridge(&mut agent, target, "flows_build", config);
|
|
}
|
|
|
|
// Run to completion under a CLI origin (internal, user-initiated — the
|
|
// approval gate must not fail-closed), bounded by a wall-clock timeout. When
|
|
// streaming, wrap the run in the thread-id scope so descendant turns tag
|
|
// their trace + socket events with this thread.
|
|
let run = with_origin(AgentTurnOrigin::Cli, agent.run_single(&prompt));
|
|
let run = tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run);
|
|
let timed = match &stream {
|
|
Some(target) => {
|
|
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
|
target.thread_id.clone(),
|
|
run,
|
|
)
|
|
.await
|
|
}
|
|
None => run.await,
|
|
};
|
|
let (assistant_text, run_error) = match timed {
|
|
Ok(Ok(text)) => (text, None),
|
|
Ok(Err(e)) => {
|
|
tracing::warn!(target: "flows", error = %e, "[flows] flows_build: agent run failed");
|
|
(
|
|
String::new(),
|
|
Some(format!("workflow_builder run failed: {e:#}")),
|
|
)
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
timeout_secs = FLOW_BUILD_TIMEOUT_SECS,
|
|
"[flows] flows_build: agent run timed out"
|
|
);
|
|
(
|
|
String::new(),
|
|
Some(format!(
|
|
"workflow_builder run timed out after {FLOW_BUILD_TIMEOUT_SECS}s"
|
|
)),
|
|
)
|
|
}
|
|
};
|
|
|
|
// Emit the terminal chat event so a client viewing the copilot thread stops
|
|
// "processing" and finalizes the assistant bubble (the bridge streams only
|
|
// intermediate deltas). Success delivers `chat_done`; a run error delivers
|
|
// `chat_error`. The blocking return below is unchanged.
|
|
if let Some(target) = &stream {
|
|
let terminal: Result<String, String> = match &run_error {
|
|
None => Ok(assistant_text.clone()),
|
|
Some(err) => Err(err.clone()),
|
|
};
|
|
finalize_flow_stream(target, &terminal, &prompt).await;
|
|
}
|
|
|
|
// Capture the proposal from the run's tool history (propose/revise/save all
|
|
// emit the same self-describing `{ type: "workflow_proposal", … }` payload).
|
|
let proposal = extract_workflow_proposal(agent.history());
|
|
|
|
// A run that both errored AND produced no proposal is a hard failure; a run
|
|
// that proposed before erroring still returns the proposal for review.
|
|
if proposal.is_none() {
|
|
if let Some(err) = &run_error {
|
|
return Err(format!("workflow_builder produced no proposal: {err}"));
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
target: "flows",
|
|
has_proposal = proposal.is_some(),
|
|
"[flows] flows_build: workflow_builder turn complete"
|
|
);
|
|
Ok(RpcOutcome::single_log(
|
|
json!({
|
|
"proposal": proposal,
|
|
"assistant_text": assistant_text,
|
|
"error": run_error,
|
|
}),
|
|
"workflow builder turn complete",
|
|
))
|
|
}
|
|
|
|
/// Scans an agent run's conversation history for the workflow proposal a builder
|
|
/// tool emitted. `propose_workflow` / `revise_workflow` / `save_workflow` all
|
|
/// return a self-describing `{ "type": "workflow_proposal", … }` JSON string as
|
|
/// their tool result, so we match on that (the same gate the frontend uses) and
|
|
/// return the LAST one — the most recent proposal in the turn.
|
|
fn extract_workflow_proposal(
|
|
history: &[crate::openhuman::inference::provider::ConversationMessage],
|
|
) -> Option<Value> {
|
|
use crate::openhuman::inference::provider::ConversationMessage;
|
|
let mut latest = None;
|
|
for message in history {
|
|
if let ConversationMessage::ToolResults(results) = message {
|
|
for result in results {
|
|
if let Ok(value) = serde_json::from_str::<Value>(&result.content) {
|
|
if value.get("type").and_then(Value::as_str) == Some("workflow_proposal") {
|
|
latest = Some(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
latest
|
|
}
|
|
|
|
/// Lists persisted workflow suggestions. `status` filters to one lifecycle
|
|
/// state (the UI passes `New` for the active "Suggested for you" cards); `None`
|
|
/// returns every status.
|
|
pub async fn flows_list_suggestions(
|
|
config: &Config,
|
|
status: Option<SuggestionStatus>,
|
|
) -> Result<RpcOutcome<Vec<FlowSuggestion>>, String> {
|
|
let suggestions = store::list_suggestions(config, status, 100).map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(suggestions, "suggestions listed"))
|
|
}
|
|
|
|
/// Marks a suggestion `dismissed` (the user rejected the card). The row is kept
|
|
/// so a later discovery run dedupes against it and won't re-surface the idea.
|
|
pub async fn flows_dismiss_suggestion(
|
|
config: &Config,
|
|
id: &str,
|
|
) -> Result<RpcOutcome<Value>, String> {
|
|
let found = store::set_suggestion_status(config, id, SuggestionStatus::Dismissed)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(
|
|
json!({ "id": id, "dismissed": found }),
|
|
"suggestion dismissed",
|
|
))
|
|
}
|
|
|
|
/// Marks a suggestion `built` — called by the frontend after the user saves a
|
|
/// flow authored from this suggestion, so it drops out of the active cards.
|
|
pub async fn flows_mark_suggestion_built(
|
|
config: &Config,
|
|
id: &str,
|
|
) -> Result<RpcOutcome<Value>, String> {
|
|
let found = store::set_suggestion_status(config, id, SuggestionStatus::Built)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(
|
|
json!({ "id": id, "built": found }),
|
|
"suggestion marked built",
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "ops_tests.rs"]
|
|
mod tests;
|