mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
4890 lines
223 KiB
Rust
4890 lines
223 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::approval::{FlowRunContext, APPROVAL_FLOW_RUN_CONTEXT};
|
|
use crate::openhuman::config::Config;
|
|
use crate::openhuman::flows::bus;
|
|
use crate::openhuman::flows::draft_store;
|
|
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 graph = migrate_and_deserialize_graph(graph_json)?;
|
|
tinyflows::validate::validate(&graph).map_err(|e| e.to_string())?;
|
|
Ok(graph)
|
|
}
|
|
|
|
/// Runs a raw graph JSON value through migration + deserialization **without**
|
|
/// the structural `validate` step. Splits the two so a caller that wants
|
|
/// *every* structural error (via `tinyflows::validate::validate_all`) can run
|
|
/// validation itself — a pre-validation failure here (unparseable JSON, an
|
|
/// unmigrateable schema) is genuinely a single error, whereas structural
|
|
/// validation can surface many at once.
|
|
pub(crate) fn migrate_and_deserialize_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())?;
|
|
Ok(graph)
|
|
}
|
|
|
|
/// Maps a portable `tinyflows` [`ValidationError`](tinyflows::error::ValidationError)
|
|
/// into the host's structured [`FlowValidationError`], carrying its stable
|
|
/// `code`, anchoring `node_id`, and human `message`. One place so the mapping
|
|
/// stays consistent across `flows_validate` and the builder gate stack.
|
|
pub(crate) fn to_flow_validation_error(
|
|
err: &tinyflows::error::ValidationError,
|
|
) -> crate::openhuman::flows::FlowValidationError {
|
|
crate::openhuman::flows::FlowValidationError {
|
|
code: err.code().to_string(),
|
|
message: err.to_string(),
|
|
node_id: err.node_id().map(str::to_string),
|
|
field: None,
|
|
}
|
|
}
|
|
|
|
/// The single canonical definition of the builder hard-gate stack: the three
|
|
/// author-time gates that reject (not warn) a graph an agent must not propose
|
|
/// or persist — binding-resolvability, tool-contract, and required-arg
|
|
/// resolvability, in increasing cost order.
|
|
///
|
|
/// Returns an empty `Vec` when the graph passes; otherwise the first failing
|
|
/// gate's node-level error messages (short-circuiting, so an expensive later
|
|
/// gate never runs on a graph already known to be broken). Every plane that
|
|
/// gates an agent-authored graph — `build_builder_proposal` (propose / revise /
|
|
/// edit), `save_workflow`, and the `strict` create/update RPC path — routes
|
|
/// through here, so they cannot drift (audit F3: agent saves and UI saves used
|
|
/// to validate differently).
|
|
///
|
|
/// Assumes `graph` is already structurally valid (run
|
|
/// `validate_and_migrate_graph` / `validate_all` first) — these gates check
|
|
/// resolvability/contracts on a compilable graph.
|
|
pub(crate) async fn run_builder_gates(config: &Config, graph: &WorkflowGraph) -> Vec<String> {
|
|
// Cheap, sync: a binding guaranteed to resolve null / wrong at runtime.
|
|
let binding_errors = validate_binding_resolvability(graph);
|
|
if !binding_errors.is_empty() {
|
|
return binding_errors;
|
|
}
|
|
// Async, live connection list: a tool_call whose `connection_ref` names the
|
|
// wrong toolkit for its slug, or a connection id the user doesn't actually
|
|
// have (WS3 — the transcript bug where a TIKTOK connection id was wired onto
|
|
// Twitter/Gmail nodes and every author-time gate returned ok). Cheap:
|
|
// one connection-list fetch, no per-node catalog round trips.
|
|
let connection_ref_errors = validate_connection_refs(config, graph).await;
|
|
if !connection_ref_errors.is_empty() {
|
|
return connection_ref_errors;
|
|
}
|
|
// Async, live catalog: a tool_call whose slug isn't a real Composio action
|
|
// or whose real required args aren't all wired.
|
|
let contract_errors = validate_tool_contracts(config, graph).await;
|
|
if !contract_errors.is_empty() {
|
|
return contract_errors;
|
|
}
|
|
// Async, sandbox run: a required outbound arg that looks wired but resolves
|
|
// null in a mock execution.
|
|
validate_required_arg_resolvability(graph).await
|
|
}
|
|
|
|
/// Strict-mode gate for the create/update RPC path (audit F3): validates
|
|
/// `graph_json` structurally (surfacing every error at once) and then runs the
|
|
/// same [`run_builder_gates`] the agent tools enforce, returning `Err` with a
|
|
/// combined, model-consumable message if anything fails.
|
|
///
|
|
/// The UI/RPC create/update path stays permissive by default (a human editing
|
|
/// on the canvas may save a work-in-progress graph); passing `strict: true`
|
|
/// opts that call into the *same* gates an agent save must pass, so the two
|
|
/// planes converge on one definition instead of diverging.
|
|
pub(crate) async fn strict_gate(config: &Config, graph_json: &Value) -> Result<(), String> {
|
|
let graph = migrate_and_deserialize_graph(graph_json.clone())?;
|
|
let structural = tinyflows::validate::validate_all(&graph);
|
|
if !structural.is_empty() {
|
|
let messages: Vec<String> = structural.iter().map(ToString::to_string).collect();
|
|
return Err(format!(
|
|
"strict validation failed — the graph is structurally invalid:\n{}",
|
|
messages.join("\n")
|
|
));
|
|
}
|
|
let gate_errors = run_builder_gates(config, &graph).await;
|
|
if !gate_errors.is_empty() {
|
|
return Err(format!(
|
|
"strict validation failed:\n{}",
|
|
gate_errors.join("\n\n")
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Runs the full builder hard-gate stack on an already structurally-valid
|
|
/// `graph` and, if it passes, builds the `workflow_proposal` payload the
|
|
/// propose/revise/edit tools all return.
|
|
///
|
|
/// The single home for the gate sequence (binding-resolvability →
|
|
/// tool-contract → required-arg resolvability) plus summary/warning assembly,
|
|
/// so `revise_workflow` and `edit_workflow` cannot drift. `retry_tool` names
|
|
/// the tool in the "fix … and call `<tool>` again" guidance so each caller's
|
|
/// error text points the agent back at the right tool.
|
|
///
|
|
/// `draft_id` / `flow_id` are OPTIONAL persistence-state context echoed onto
|
|
/// the payload (the draft this proposal's edit lives on, and the saved flow it
|
|
/// derives from / targets). The payload ALWAYS carries `"persisted": false` so
|
|
/// a proposal can never be mistaken for a save confirmation — the exact false
|
|
/// belief the WS2 audit caught (an agent read a proposal as "written onto the
|
|
/// saved flow"). Actual persistence only happens via `save_workflow` /
|
|
/// `create_workflow` / `flows_draft_promote`.
|
|
///
|
|
/// Returns `Ok(payload)` on success, or `Err(message)` with a
|
|
/// model-consumable, fix-and-retry error when a gate rejects the graph. The
|
|
/// caller is responsible for structural validation (`validate_and_migrate_graph`
|
|
/// / `validate_all`) *before* calling this — these gates assume a compilable
|
|
/// graph.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn build_builder_proposal(
|
|
config: &Config,
|
|
retry_tool: &str,
|
|
name: &str,
|
|
graph: &WorkflowGraph,
|
|
require_approval: bool,
|
|
revision: bool,
|
|
instruction: Option<String>,
|
|
draft_id: Option<String>,
|
|
flow_id: Option<String>,
|
|
) -> Result<Value, String> {
|
|
// The full builder hard-gate stack, run through the single canonical
|
|
// runner so every proposal/save/strict-RPC path gates identically (F3).
|
|
let gate_errors = run_builder_gates(config, graph).await;
|
|
if !gate_errors.is_empty() {
|
|
return Err(format!(
|
|
"{}\n\nFix these and call {retry_tool} again.",
|
|
gate_errors.join("\n\n")
|
|
));
|
|
}
|
|
|
|
let summary = crate::openhuman::flows::tools::build_summary(graph);
|
|
let mut warnings = graph_trigger_warnings(graph);
|
|
warnings.extend(graph_wiring_warnings(config, graph).await);
|
|
// Connector onboarding (Phase 5, item 18): tell the proposal card which
|
|
// toolkits this graph needs and whether they're connected, so it can render
|
|
// "Connect <toolkit>" CTAs instead of a bare gate error later.
|
|
let required_connections = compute_required_connections(config, graph).await;
|
|
let graph_value = serde_json::to_value(graph).map_err(|e| e.to_string())?;
|
|
|
|
tracing::info!(
|
|
target: "flows",
|
|
%name,
|
|
node_count = graph.nodes.len(),
|
|
require_approval,
|
|
warning_count = warnings.len(),
|
|
revision,
|
|
"[flows] build_builder_proposal: proposal ready for user review"
|
|
);
|
|
|
|
let mut payload = json!({
|
|
"type": "workflow_proposal",
|
|
"revision": revision,
|
|
// A proposal is NEVER a persisted flow — it is a candidate the user
|
|
// still has to accept/save. Stamp this unconditionally so the payload
|
|
// can't be misread as a save confirmation (WS2 audit).
|
|
"persisted": false,
|
|
"name": name,
|
|
"graph": graph_value,
|
|
"require_approval": require_approval,
|
|
"summary": summary,
|
|
"warnings": warnings,
|
|
"required_connections": required_connections,
|
|
});
|
|
if let Some(instruction) = instruction {
|
|
payload["instruction"] = json!(instruction);
|
|
}
|
|
// Echo the persistence-state handles so the agent can iterate/persist
|
|
// against the right ids (the draft the edit lives on; the flow it targets).
|
|
if let Some(draft_id) = draft_id {
|
|
payload["draft_id"] = json!(draft_id);
|
|
}
|
|
if let Some(flow_id) = flow_id {
|
|
payload["flow_id"] = json!(flow_id);
|
|
}
|
|
Ok(payload)
|
|
}
|
|
|
|
/// 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
|
|
)
|
|
}
|
|
|
|
/// Whether `graph`'s trigger fires **without a human in the loop** — i.e. on
|
|
/// a timer, an inbound webhook, or a connected-app event, as opposed to
|
|
/// `manual` (only ever fired by an explicit `flows_run`). Used by
|
|
/// [`flows_create`] (issue B29 — save/enable safety, Rule 1) to decide
|
|
/// whether a freshly-saved flow may persist `enabled: true` or must persist
|
|
/// `enabled: false` until the user arms it explicitly via
|
|
/// `flows_set_enabled`.
|
|
///
|
|
/// Deliberately broader than [`trigger_kind_fires`]: `webhook` is not yet
|
|
/// wired to auto-dispatch in this host (see that fn's doc), but it WILL fire
|
|
/// unattended the moment it is — so a webhook-trigger flow must not be handed
|
|
/// to the user pre-armed either. Returns `false` for a graph with no single
|
|
/// resolvable trigger node or no `trigger_kind` discriminator (never a
|
|
/// surprise — it never self-fires).
|
|
pub(crate) fn trigger_is_automatic(graph: &WorkflowGraph) -> bool {
|
|
let Some(trigger) = graph.trigger() else {
|
|
return false;
|
|
};
|
|
let Some(kind_value) = trigger.config.get("trigger_kind") else {
|
|
return false;
|
|
};
|
|
let Ok(kind) = serde_json::from_value::<TriggerKind>(kind_value.clone()) else {
|
|
return false;
|
|
};
|
|
matches!(
|
|
kind,
|
|
TriggerKind::Schedule | TriggerKind::AppEvent | TriggerKind::Webhook
|
|
)
|
|
}
|
|
|
|
/// Whether `graph` contains a node that can produce a real outbound side
|
|
/// effect — `tool_call` (a curated integration action), `http_request`, or
|
|
/// `code` (sandboxed but Turing-complete, can reach the network). Used by
|
|
/// [`flows_create`] (issue B29, Rule 2) to force `require_approval: true` on
|
|
/// any graph that can act on the world, regardless of what the caller
|
|
/// passed. A graph built only from `trigger` / `agent` / `transform` /
|
|
/// `condition` / data-flow nodes is read-only and unaffected.
|
|
pub(crate) fn graph_has_outbound_side_effect(graph: &WorkflowGraph) -> bool {
|
|
graph.nodes.iter().any(|n| {
|
|
matches!(
|
|
n.kind,
|
|
NodeKind::ToolCall | NodeKind::HttpRequest | NodeKind::Code
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Whether `graph` has anything for [`flows_run`] to actually *do* — i.e. at
|
|
/// least one non-`trigger` node **reachable from the trigger** by following
|
|
/// directed edges. A graph made of nothing but a bare `trigger` node (or a
|
|
/// `trigger` plus unreachable/disconnected nodes — even ones wired to each
|
|
/// other by their own edges, just not to the trigger) can compile and "run"
|
|
/// cleanly while producing no work whatsoever — the exact live finding this
|
|
/// guards: a trigger-only flow reported `status="completed"
|
|
/// pending_approvals=0` having done nothing, which reads as a successful
|
|
/// automation to anyone not staring at the node count. Used by `flows_run`
|
|
/// to attach a human-readable note to an otherwise-silent "success".
|
|
///
|
|
/// Deliberately a reachability walk rather than "any edge at all exists":
|
|
/// `nodes.len() > 1 && !edges.is_empty()` would count a disconnected
|
|
/// component's internal edges as actionable even though nothing downstream
|
|
/// of the trigger ever runs.
|
|
pub(crate) fn graph_has_actionable_nodes(graph: &WorkflowGraph) -> bool {
|
|
let Some(trigger) = graph.trigger() else {
|
|
// No single resolvable trigger to walk from — fall back to the
|
|
// coarse "any non-trigger node wired up by an edge" check so a
|
|
// malformed/ambiguous-trigger graph doesn't spuriously suppress the
|
|
// empty-flow note.
|
|
return graph.nodes.iter().any(|n| n.kind != NodeKind::Trigger) && !graph.edges.is_empty();
|
|
};
|
|
|
|
let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
|
let mut stack = vec![trigger.id.as_str()];
|
|
while let Some(current) = stack.pop() {
|
|
if !visited.insert(current) {
|
|
continue;
|
|
}
|
|
for next in graph.successors(current) {
|
|
if !visited.contains(next) {
|
|
stack.push(next);
|
|
}
|
|
}
|
|
}
|
|
|
|
visited
|
|
.into_iter()
|
|
.filter_map(|id| graph.node(id))
|
|
.any(|n| n.kind != NodeKind::Trigger)
|
|
}
|
|
|
|
/// 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;
|
|
};
|
|
// B12: a real-output probe (`get_tool_output_sample`) for this
|
|
// exact slug overrides the schema-derived `output_fields` — most
|
|
// relevant for an action whose live listing publishes no output
|
|
// schema at all (e.g. every GitHub action, verified live).
|
|
let contract =
|
|
crate::openhuman::tinyflows::caps::apply_probe_override(contract.clone());
|
|
// Nothing real to check `field_path` against — schema unknown AND
|
|
// no probed output fields either.
|
|
if contract.output_schema.is_none() && contract.output_fields.is_empty() {
|
|
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
|
|
}
|
|
|
|
/// Given a Composio action's payload-only `output_schema` (see
|
|
/// [`crate::openhuman::tinyflows::caps::ToolContract::output_fields`]'s doc —
|
|
/// NEVER includes the runtime `data` envelope) and a `split_out.path`
|
|
/// addressed relative to the ENVELOPE (`json.<envelope_field…>`, e.g.
|
|
/// `"json.data"` or `"json.data.issues"`), resolves whether the path lands on
|
|
/// something that is DEFINITELY not an array.
|
|
///
|
|
/// `Some(true)` — non-array (an object or scalar): a `split_out` over this
|
|
/// path fans out over exactly ONE item, the classic "wrong array path"
|
|
/// signal [`graph_split_out_path_warnings`]'s generic enforcement flags.
|
|
/// `Some(false)` — array: the path is fine. `None` — the path can't be
|
|
/// resolved against the schema at all (an unpublished/unknown nested field,
|
|
/// or a path missing the `data.` segment entirely) — stay silent rather than
|
|
/// guess; that's a distinct failure mode from "resolves to a non-array".
|
|
fn schema_says_path_is_non_array(output_schema: &Value, configured_path: &str) -> Option<bool> {
|
|
let relative = configured_path
|
|
.strip_prefix("json.")
|
|
.unwrap_or(configured_path);
|
|
if relative == "data" {
|
|
// Whole-payload access (`json.data`) — non-array unless the payload's
|
|
// own root schema type is literally "array" (a bare-array response,
|
|
// e.g. a REST endpoint that returns `[...]` directly), in which case
|
|
// `json.data` legitimately IS the real list.
|
|
let ty = output_schema.get("type").and_then(Value::as_str)?;
|
|
return Some(ty != "array");
|
|
}
|
|
let rest = relative.strip_prefix("data.").filter(|r| !r.is_empty())?;
|
|
let mut node = output_schema;
|
|
for seg in rest.split('.') {
|
|
node = node.get("properties")?.get(seg)?;
|
|
}
|
|
let ty = node.get("type").and_then(Value::as_str)?;
|
|
Some(ty != "array")
|
|
}
|
|
|
|
/// Author-time WARN/suggest (systemic tool-contract fix, Part 2d, extended by
|
|
/// B12): a `split_out` node whose direct predecessor is a `tool_call` calling
|
|
/// a REAL Composio action, checked two ways:
|
|
///
|
|
/// 1. **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.<…>"` with no
|
|
/// extra handling needed here — and, via
|
|
/// [`crate::openhuman::tinyflows::caps::apply_probe_override`], a real
|
|
/// `get_tool_output_sample` probe for this slug overrides a schema that
|
|
/// never named an array at all): if the configured `config.path` doesn't match the
|
|
/// `json.<primary_array_path>` convention, suggest the real path.
|
|
/// 2. **UNKNOWN `primary_array_path`, but a KNOWN `output_schema`/probe that
|
|
/// proves the configured path is definitely NOT an array** (B12
|
|
/// enforcement, "regardless" of whether a correct path can be suggested —
|
|
/// catches the class at build time even when nothing to suggest is
|
|
/// derivable): warn generically. This is exactly the live bug this fix
|
|
/// closes — `GITHUB_LIST_REPOSITORY_ISSUES` publishes no output schema at
|
|
/// all, so a builder without a probe guessed the whole-payload
|
|
/// `"json.data"`, silently fanning out over ONE item (the `{issues:
|
|
/// [...]}` container) instead of the real per-issue list.
|
|
///
|
|
/// Both are advisory: a mismatched/non-array path degrades the fan-out (or
|
|
/// silently produces one item instead of many) rather than crashing.
|
|
///
|
|
/// Skipped entirely when `split_out`'s predecessor isn't a `tool_call` at all
|
|
/// (no envelope/array-path convention applies), or when NEITHER a
|
|
/// `primary_array_path` NOR an `output_schema` is known (truly nothing to
|
|
/// check against).
|
|
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::{apply_probe_override, 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;
|
|
};
|
|
// B12: a real-output probe overrides the schema-derived
|
|
// `primary_array_path` for this exact slug when one is cached.
|
|
let contract = apply_probe_override(contract.clone());
|
|
|
|
match contract.primary_array_path.as_deref() {
|
|
Some(primary) => {
|
|
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,
|
|
));
|
|
}
|
|
}
|
|
// No known array anywhere in this action's real output — the
|
|
// generic non-array enforcement is the only thing left that
|
|
// can catch a wrong path here (nothing to suggest, but a
|
|
// known-non-array hit is still a strong signal).
|
|
None => {
|
|
let Some(cp) = configured_path else { continue };
|
|
let Some(schema) = contract.output_schema.as_ref() else {
|
|
continue;
|
|
};
|
|
if schema_says_path_is_non_array(schema, cp) == Some(true) {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
predecessor = %pred.id,
|
|
pred_slug,
|
|
configured_path = cp,
|
|
"[flows] wiring check: split_out.path resolves to a non-array — likely the wrong array path"
|
|
);
|
|
warnings.push(format!(
|
|
"Node '{}': split_out.path is \"{cp}\" but tool_call `{}` (`{pred_slug}`)'s \
|
|
known real output does not name an array at that path (or names no array \
|
|
property at all) — this fans out over a single object instead of a real \
|
|
list. If the action's real output nests the list under a named field (e.g. \
|
|
`data.issues`), call get_tool_output_sample {{ slug: \"{pred_slug}\" }} to \
|
|
sample the real response, then re-check with get_tool_contract.",
|
|
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.
|
|
/// Whether OpenHuman ships a STATIC curated catalog for `toolkit`. This is the
|
|
/// exact condition both [`validate_tool_contracts`]'s curation gate and
|
|
/// `tinyflows::caps::flow_tool_allowed`'s runtime Path A use to decide a toolkit
|
|
/// is a hard curated-only allowlist: for such a toolkit a real-but-uncurated
|
|
/// action is rejected on EVERY real run, so the author-time gate and the early
|
|
/// builder-tool warnings (`get_tool_contract` / `search_tool_catalog`) must all
|
|
/// agree on it — one home for the check so they cannot drift.
|
|
pub(crate) fn toolkit_has_curated_catalog(toolkit: &str) -> bool {
|
|
use crate::openhuman::memory_sync::composio::providers::{catalog_for_toolkit, get_provider};
|
|
get_provider(toolkit)
|
|
.and_then(|p| p.curated_tools())
|
|
.or_else(|| catalog_for_toolkit(toolkit))
|
|
.is_some()
|
|
}
|
|
|
|
pub(crate) async fn validate_tool_contracts(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, missing_required_args, unsupported_arg_names,
|
|
};
|
|
|
|
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 = toolkit_has_curated_catalog(&toolkit);
|
|
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]
|
|
));
|
|
}
|
|
|
|
// [B13] Arg-NAME validity: `missing_required_args` only proves a
|
|
// required arg is PRESENT — it says nothing about whether every arg
|
|
// the builder wired is actually a property this action's schema
|
|
// recognizes. A misnamed/unsupported field (the live bug: wiring
|
|
// `SLACK_SEND_MESSAGE` with `text` when the action wants
|
|
// `markdown_text`) sails through the check above unrejected — a
|
|
// value IS present, just under the wrong key — and only surfaces as
|
|
// a runtime 400 from the real provider. `unsupported_arg_names`
|
|
// returns `None` when the schema can't be used to validate names
|
|
// (unknown schema, or `additionalProperties: true`) — that case is
|
|
// deliberately never rejected here (best-effort, same posture as the
|
|
// rest of this gate).
|
|
if let Some(unsupported) = unsupported_arg_names(contract.input_schema.as_ref(), &args) {
|
|
if !unsupported.is_empty() {
|
|
let valid_names: Vec<String> = contract
|
|
.input_schema
|
|
.as_ref()
|
|
.and_then(|s| s.get("properties"))
|
|
.and_then(Value::as_object)
|
|
.map(|props| {
|
|
let mut names: Vec<String> = props.keys().cloned().collect();
|
|
names.sort();
|
|
names
|
|
})
|
|
.unwrap_or_default();
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
?unsupported,
|
|
?valid_names,
|
|
"[flows] tool-contract check: arg name(s) not declared by the action's \
|
|
input schema — rejecting"
|
|
);
|
|
let bad_list = unsupported
|
|
.iter()
|
|
.map(|m| format!("`{m}`"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let valid_suffix = if valid_names.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
" — valid arg names for `{slug}` are: {}",
|
|
valid_names.join(", ")
|
|
)
|
|
};
|
|
errors.push(format!(
|
|
"Node '{}': tool_call `{slug}` has unsupported arg name(s) {bad_list} — not \
|
|
a property of this action's input schema{valid_suffix}. Call \
|
|
get_tool_contract {{ slug: \"{slug}\" }} and use the exact property names \
|
|
from `input_schema` (never guess an arg name).",
|
|
node.id
|
|
));
|
|
}
|
|
}
|
|
}
|
|
errors
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Connection-ref gate (WS3): a Composio tool_call's `connection_ref` must name
|
|
// a real connected account of the RIGHT toolkit
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Transcript audit: the user's connections were `twitter →
|
|
// composio:twitter:ca_JX6QU88UfSk4`, `gmail → composio:gmail:ca_vX_WA8FsqNmE`,
|
|
// `tiktok → composio:tiktok:ca_LPCp3WQpaDma`. The agent wired
|
|
// `composio:twitter:ca_LPCp3WQpaDma` and `composio:gmail:ca_LPCp3WQpaDma` (the
|
|
// TIKTOK id) onto the Twitter and Gmail tool_call nodes. dry_run / validate /
|
|
// propose all returned ok:true — nothing cross-checked the id against the user's
|
|
// real connections, nor the ref's toolkit segment against the slug — and it
|
|
// would fail on the first real run. This gate closes that gap: it parses the
|
|
// ref, enforces the toolkit segment matches the slug (needs no I/O), and — when
|
|
// the live connection list is reachable — that the id names a real connected
|
|
// account of that toolkit, naming the correct ref when it can.
|
|
|
|
/// Parses a `composio:<toolkit>:<id>` connection_ref into its `(toolkit, id)`
|
|
/// segments. Mirrors [`crate::openhuman::tinyflows::caps::composio_connection_id`]'s
|
|
/// rsplit for the id (everything after the LAST `:`), taking everything between
|
|
/// the `composio:` prefix and that last `:` as the toolkit. Returns `None` for
|
|
/// anything that isn't this shape (missing `composio:` prefix, no `:` after it,
|
|
/// or an empty toolkit/id segment).
|
|
fn parse_composio_connection_ref(conn_ref: &str) -> Option<(&str, &str)> {
|
|
let rest = conn_ref.strip_prefix("composio:")?;
|
|
let (toolkit, id) = rest.rsplit_once(':')?;
|
|
if toolkit.trim().is_empty() || id.trim().is_empty() {
|
|
return None;
|
|
}
|
|
Some((toolkit.trim(), id.trim()))
|
|
}
|
|
|
|
/// First connected account `connection_ref` for `toolkit` (case-insensitive)
|
|
/// from `conns`, used to name the correct ref in a rejection's "did you mean"
|
|
/// hint. `None` when the toolkit has no connection at all.
|
|
fn first_connection_ref_for_toolkit(conns: &[FlowConnection], toolkit: &str) -> Option<String> {
|
|
conns
|
|
.iter()
|
|
.find(|c| {
|
|
c.toolkit
|
|
.as_deref()
|
|
.is_some_and(|t| t.eq_ignore_ascii_case(toolkit))
|
|
})
|
|
.map(|c| c.connection_ref.clone())
|
|
}
|
|
|
|
/// Hard gate: for every Composio `tool_call` node carrying a `connection_ref`,
|
|
/// prove the ref names a real connected account of the SAME toolkit as the
|
|
/// slug. Fetches the live connection list once (same source
|
|
/// [`flows_list_connections`] reads) and delegates the pure matching to
|
|
/// [`validate_connection_refs_against`].
|
|
///
|
|
/// Fail-open on I/O: if the Composio connection list is unreachable (backend
|
|
/// outage), the id-existence check is SKIPPED (a `tracing::debug!` records it)
|
|
/// so a real connection is never false-rejected during an outage — but the
|
|
/// toolkit-mismatch check, which needs no I/O, still runs.
|
|
pub(crate) async fn validate_connection_refs(
|
|
config: &Config,
|
|
graph: &WorkflowGraph,
|
|
) -> Vec<String> {
|
|
let connections: Option<Vec<FlowConnection>> =
|
|
match crate::openhuman::composio::ops::composio_list_connections(config).await {
|
|
Ok(outcome) => Some(build_flow_connections(
|
|
outcome.value.connections,
|
|
Vec::new(),
|
|
// Identity isn't needed for this existence/toolkit-mismatch
|
|
// check — only `connection_ref` and `toolkit` are read.
|
|
&[],
|
|
)),
|
|
Err(e) => {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
error = %e,
|
|
"[flows] connection-ref check: composio connection list unavailable — \
|
|
skipping id-existence check (fail-open); toolkit-mismatch check still runs"
|
|
);
|
|
None
|
|
}
|
|
};
|
|
validate_connection_refs_against(graph, connections.as_deref())
|
|
}
|
|
|
|
/// Pure connection-ref validator (no I/O) so the gate's decision logic is
|
|
/// unit-testable without a live Composio backend. `connections` is `Some(list)`
|
|
/// when the live connection list was fetched (possibly empty — a genuine "no
|
|
/// connections" state), or `None` when it was unavailable (fail-open: the
|
|
/// id-existence check is skipped, only the toolkit-mismatch check runs).
|
|
fn validate_connection_refs_against(
|
|
graph: &WorkflowGraph,
|
|
connections: Option<&[FlowConnection]>,
|
|
) -> Vec<String> {
|
|
use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug;
|
|
|
|
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 at runtime; native `oh:` tools have no
|
|
// Composio connection to name.
|
|
if slug.starts_with('=') || slug.starts_with("oh:") {
|
|
continue;
|
|
}
|
|
// A MISSING `connection_ref` stays allowed (unchanged): a Composio
|
|
// tool_call with no ref runs against the ambient signed-in account and
|
|
// the flow prompts for a connection at first run.
|
|
let Some(conn_ref) = node.config.get("connection_ref").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
if conn_ref.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let Some(slug_toolkit) = toolkit_from_slug(slug) else {
|
|
continue;
|
|
};
|
|
|
|
let Some((ref_toolkit, ref_id)) = parse_composio_connection_ref(conn_ref) else {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
toolkit = %slug_toolkit,
|
|
%conn_ref,
|
|
matched = false,
|
|
"[flows] connection-ref check: malformed ref — rejecting"
|
|
);
|
|
errors.push(format!(
|
|
"Node '{}': `connection_ref` `{conn_ref}` is malformed — a Composio account ref \
|
|
must look like `composio:<toolkit>:<connection_id>` (e.g. \
|
|
`composio:{slug_toolkit}:<id>`). Call list_flow_connections and copy a \
|
|
`connection_ref` value verbatim.",
|
|
node.id
|
|
));
|
|
continue;
|
|
};
|
|
|
|
// Toolkit segment vs the slug's toolkit — needs no I/O.
|
|
if !ref_toolkit.eq_ignore_ascii_case(&slug_toolkit) {
|
|
let suggestion = connections
|
|
.and_then(|conns| first_connection_ref_for_toolkit(conns, &slug_toolkit));
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
toolkit = %slug_toolkit,
|
|
%ref_toolkit,
|
|
%ref_id,
|
|
matched = false,
|
|
"[flows] connection-ref check: toolkit segment does not match the slug's toolkit — rejecting"
|
|
);
|
|
let hint = match suggestion {
|
|
Some(r) => format!(" — did you mean `{r}`?"),
|
|
None => format!(
|
|
" — no `{slug_toolkit}` account is connected; connect one with \
|
|
composio_connect (or ask the user to), then use its `connection_ref`"
|
|
),
|
|
};
|
|
errors.push(format!(
|
|
"Node '{}': `connection_ref` `{conn_ref}` names the `{ref_toolkit}` toolkit but the \
|
|
tool_call slug `{slug}` is a `{slug_toolkit}` action{hint}.",
|
|
node.id
|
|
));
|
|
continue;
|
|
}
|
|
|
|
// Existence check: the id must name a real connected account of this
|
|
// toolkit. Skipped (fail-open) when the connection list is unavailable.
|
|
let Some(conns) = connections else {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
toolkit = %slug_toolkit,
|
|
%ref_id,
|
|
"[flows] connection-ref check: toolkit matches; id-existence check skipped (connections unavailable)"
|
|
);
|
|
continue;
|
|
};
|
|
// The id must belong to a connection OF THIS TOOLKIT — not merely
|
|
// exist somewhere. The transcript bug was a real TIKTOK connection id
|
|
// stamped onto a `composio:twitter:` ref: the id exists globally, but
|
|
// it is not a Twitter account, so it must still be rejected.
|
|
let id_exists = conns.iter().any(|c| {
|
|
c.toolkit
|
|
.as_deref()
|
|
.is_some_and(|t| t.eq_ignore_ascii_case(&slug_toolkit))
|
|
&& parse_composio_connection_ref(&c.connection_ref)
|
|
.is_some_and(|(_, cid)| cid.eq_ignore_ascii_case(ref_id))
|
|
});
|
|
if id_exists {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
toolkit = %slug_toolkit,
|
|
%ref_id,
|
|
matched = true,
|
|
"[flows] connection-ref check: ref resolves to a real connected account — ok"
|
|
);
|
|
continue;
|
|
}
|
|
// Unknown id. Name the right ref for this toolkit if one exists.
|
|
match first_connection_ref_for_toolkit(conns, &slug_toolkit) {
|
|
Some(r) => {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
toolkit = %slug_toolkit,
|
|
%ref_id,
|
|
matched = false,
|
|
"[flows] connection-ref check: unknown id; toolkit has a different connected account — rejecting"
|
|
);
|
|
errors.push(format!(
|
|
"Node '{}': `connection_ref` `{conn_ref}` does not match any connected \
|
|
`{slug_toolkit}` account — did you mean `{r}`? Call list_flow_connections and \
|
|
copy a `connection_ref` value verbatim.",
|
|
node.id
|
|
));
|
|
}
|
|
None => {
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %node.id,
|
|
%slug,
|
|
toolkit = %slug_toolkit,
|
|
%ref_id,
|
|
matched = false,
|
|
"[flows] connection-ref check: no connected account for this toolkit — rejecting"
|
|
);
|
|
errors.push(format!(
|
|
"Node '{}': `connection_ref` `{conn_ref}` names a `{slug_toolkit}` account, but \
|
|
no `{slug_toolkit}` account is connected — connect one with composio_connect \
|
|
(or ask the user to), then use its `connection_ref`.",
|
|
node.id
|
|
));
|
|
}
|
|
}
|
|
}
|
|
errors
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Required-arg resolvability gate (issue B18)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// `validate_tool_contracts` (above) proves a required arg is PRESENT
|
|
// (`missing_required_args`: absent or literal `null`) — it has no opinion on
|
|
// whether an arg wired to a real-looking `=`-expression actually RESOLVES to
|
|
// something at runtime, and it says nothing at all about an arg the live
|
|
// schema doesn't individually mark `required` even though the PROVIDER
|
|
// enforces it as a business rule — e.g. `GMAIL_SEND_EMAIL.subject`/`.body`
|
|
// are each individually optional in the schema, but Gmail rejects a send
|
|
// where BOTH are empty ("At least one of 'subject' or 'body' must be
|
|
// provided with non-empty content"). A builder can wire either to an
|
|
// upstream path that looks fully wired but resolves `null`, and neither
|
|
// static check above has anything to say about it.
|
|
//
|
|
// `crate::openhuman::flows::builder_tools::DryRunWorkflowTool` already
|
|
// detects exactly this class of null resolution (`null_resolutions`) by
|
|
// running the graph through the same MOCK sandbox — but only as information
|
|
// the agent is *instructed* (by prompt, not enforced in code) to act on
|
|
// before calling `propose_workflow`/`save_workflow`. Nothing previously
|
|
// stopped those tools from persisting the graph anyway.
|
|
// [`validate_required_arg_resolvability`] closes that gap: it re-runs the
|
|
// identical sandbox check and escalates ANY arg of a real (non-`=`-derived,
|
|
// non-native) `tool_call` node that resolved `null` to a hard reject, wired
|
|
// into `propose_workflow` / `revise_workflow` / `save_workflow` alongside
|
|
// [`validate_binding_resolvability`] and [`validate_tool_contracts`].
|
|
|
|
/// Wall-clock bound on the sandbox run this gate performs. Mirrors
|
|
/// `builder_tools::DRY_RUN_TIMEOUT_SECS`'s purpose but kept short: unlike the
|
|
/// opt-in `dry_run_workflow` tool, this check runs on EVERY
|
|
/// propose/revise/save call, so a slow or pathological draft must not stall
|
|
/// authoring.
|
|
const REQUIRED_ARG_NULL_CHECK_TIMEOUT_SECS: u64 = 15;
|
|
|
|
/// Sandbox-executes `graph` against `tinyflows`' deterministic MOCK
|
|
/// capabilities (the same shape `DryRunWorkflowTool` uses — see this
|
|
/// section's module doc) and returns one human-readable error per arg of a
|
|
/// real (non-`=`-derived, non-native) `tool_call` node whose `=`-expression
|
|
/// resolved to `null` during that run **and** whose expression is wired to a
|
|
/// specific upstream node's output (directly, via the implicit
|
|
/// `item`/`items` scope, or explicitly via `nodes.<id>...`) rather than to
|
|
/// the trigger.
|
|
///
|
|
/// This run always sandboxes against `json!({})` as the trigger payload (see
|
|
/// below), so any arg wired to trigger-scoped data — `=item.<field>` /
|
|
/// `=items...` fed directly from the trigger node, or `=run.<field>` (the
|
|
/// trigger metadata itself) — legitimately resolves `null` here even though a
|
|
/// real webhook/app-event/manual trigger WILL populate it at runtime. Hard
|
|
/// gate that on an empty mock run would reject every ordinary trigger-bound
|
|
/// workflow (Codex feedback on PR #4826). Only a `null` resolved from a
|
|
/// genuine upstream **node** reference is escalated — that's the real B18
|
|
/// bug this gate exists to catch: an arg wired to a node output path that can
|
|
/// never resolve (e.g. `GMAIL_SEND_EMAIL.subject =
|
|
/// "=nodes.build_body.item.subject"` where `build_body` never produces
|
|
/// `subject`), which stays broken no matter what the trigger payload is.
|
|
///
|
|
/// Deliberately does **not** wrap the mock `ToolInvoker` in
|
|
/// [`crate::openhuman::tinyflows::caps::PreflightToolInvoker`] the way
|
|
/// `DryRunWorkflowTool` does: that wrapper aborts the WHOLE sandbox run the
|
|
/// instant a node with a `stop` `on_error` policy (the default) hits a
|
|
/// schema-required null arg, which would lose the per-field diagnostic this
|
|
/// gate exists to report for every OTHER node — and this check cares about
|
|
/// EVERY arg, not just ones the schema happens to mark `required`. The plain
|
|
/// mock tool invoker always "succeeds" (a deterministic echo), so the run
|
|
/// settles and every node's config-resolution diagnostics get captured
|
|
/// regardless of on_error policy or schema required-ness.
|
|
///
|
|
/// Best-effort, same posture as [`validate_tool_contracts`]: a compile
|
|
/// failure (structural errors are already caught by
|
|
/// [`validate_and_migrate_graph`] before this gate ever runs) or a sandbox
|
|
/// error/timeout is SKIPPED — never turned into a false rejection. This
|
|
/// check only ever adds a diagnostic the sandbox actually observed.
|
|
pub(crate) async fn validate_required_arg_resolvability(graph: &WorkflowGraph) -> Vec<String> {
|
|
use crate::openhuman::flows::builder_tools::CapturingObserver;
|
|
use crate::openhuman::tinyflows::caps::{SchemaAwareMockAgentRunner, SchemaAwareMockLlm};
|
|
|
|
let Ok(compiled) = tinyflows::compiler::compile(graph) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut caps = tinyflows::caps::mock::mock_capabilities_with_agent(SchemaAwareMockAgentRunner);
|
|
// Same fix as `DryRunWorkflowTool`: a plain agent node (no `agent_ref`)
|
|
// routes to the `llm` slot, not the runner above, so the vendored `MockLlm`
|
|
// echo would fail its `output_parser.schema` sub-port and make this gate
|
|
// reject a correct graph (which is why `propose_workflow` was rejecting
|
|
// valid graphs). The schema-aware mock LLM honors the schema instead.
|
|
caps.llm = Arc::new(SchemaAwareMockLlm);
|
|
|
|
let observer = Arc::new(CapturingObserver::default());
|
|
let observer_dyn: Arc<dyn tinyflows::observability::RunObserver> = observer.clone();
|
|
let run = tinyflows::engine::run_with_observer(&compiled, json!({}), &caps, &observer_dyn);
|
|
if tokio::time::timeout(
|
|
std::time::Duration::from_secs(REQUIRED_ARG_NULL_CHECK_TIMEOUT_SECS),
|
|
run,
|
|
)
|
|
.await
|
|
.is_err()
|
|
{
|
|
// Timed out — a different class of problem than this gate exists to
|
|
// catch; never block authoring on it here.
|
|
return Vec::new();
|
|
}
|
|
// A sandbox `Err` outcome here is a compile/capability issue unrelated
|
|
// to null args (the plain mock invoker never itself fails) — surfaced by
|
|
// the other gates / `dry_run_workflow` instead; this gate only adds
|
|
// diagnostics from a run that actually settled, so an error is silently
|
|
// skipped rather than turned into a (misleading) empty-errors success.
|
|
|
|
let tool_call_slugs: std::collections::HashMap<&str, &str> = graph
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == NodeKind::ToolCall)
|
|
.filter_map(|n| {
|
|
let slug = n.config.get("slug").and_then(Value::as_str)?;
|
|
Some((n.id.as_str(), slug))
|
|
})
|
|
.collect();
|
|
|
|
// The trigger node's id, if any — used below to tell a trigger-scoped
|
|
// `item`/`items` reference (the direct predecessor IS the trigger) apart
|
|
// from a real upstream-node reference. Graphs are expected to have
|
|
// exactly one trigger; `flows_validate` rejects zero/multiple before this
|
|
// gate ever runs, so `first()` here doesn't hide ambiguity.
|
|
let trigger_id: Option<&str> = graph
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.kind == NodeKind::Trigger)
|
|
.map(|n| n.id.as_str());
|
|
|
|
let mut errors = Vec::new();
|
|
for step in observer.steps() {
|
|
let Some(&slug) = tool_call_slugs.get(step.node_id.as_str()) else {
|
|
continue;
|
|
};
|
|
// `=`-derived slugs resolve from upstream/trigger data at runtime;
|
|
// native `oh:` tools have no external-provider rejection mode.
|
|
if slug.starts_with('=') || slug.starts_with("oh:") {
|
|
continue;
|
|
}
|
|
for diag in &step.diagnostics {
|
|
let Some(field) = diag.location.strip_prefix("args.") else {
|
|
continue;
|
|
};
|
|
if is_trigger_scoped_expression(&diag.expression, graph, &step.node_id, trigger_id) {
|
|
// Legitimately empty in this gate's `{}` mock run — the real
|
|
// trigger (webhook/app-event/manual) will populate it. Not
|
|
// the B18 broken-wiring case this gate exists to catch.
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %step.node_id,
|
|
%slug,
|
|
%field,
|
|
expression = %diag.expression,
|
|
"[flows] required-arg resolvability check: trigger-scoped null in empty \
|
|
mock run — not rejecting"
|
|
);
|
|
continue;
|
|
}
|
|
// A null bound to the OUTPUT of an upstream Composio `tool_call`
|
|
// node is UNVERIFIABLE in this echo sandbox — the mock renders a
|
|
// Composio `tool_call` as `{tool, args, connection}` and can NEVER
|
|
// produce its real output fields (`.item.json.data.<field>`), so a
|
|
// downstream binding to one resolves `null` here even when the
|
|
// wiring is perfectly correct. Hard-rejecting it (WS6) would block
|
|
// a possibly-correct graph from ever being proposed — the exact
|
|
// false-negative the transcript audit caught. Downgrade to a
|
|
// debug-logged skip; `dry_run_workflow` remains the surface that
|
|
// reports it (as an `unverifiable` diagnostic the agent can act on
|
|
// via get_tool_contract / get_tool_output_sample).
|
|
if let Some(upstream) =
|
|
composio_tool_call_upstream_ref(&diag.expression, graph, &step.node_id)
|
|
{
|
|
tracing::debug!(
|
|
target: "flows",
|
|
node = %step.node_id,
|
|
%slug,
|
|
%field,
|
|
upstream = %upstream,
|
|
expression = %diag.expression,
|
|
"[flows] required-arg resolvability check: arg binds to a Composio \
|
|
tool_call's output — UNVERIFIABLE in the echo sandbox (the mock cannot \
|
|
produce real tool output fields), not rejecting; dry_run_workflow \
|
|
reports it instead"
|
|
);
|
|
continue;
|
|
}
|
|
tracing::warn!(
|
|
target: "flows",
|
|
node = %step.node_id,
|
|
%slug,
|
|
%field,
|
|
expression = %diag.expression,
|
|
"[flows] required-arg resolvability check: arg resolved null in sandbox — \
|
|
rejecting"
|
|
);
|
|
errors.push(format!(
|
|
"Node '{}': arg `{field}` of `{slug}` (`{}`) resolved to `null` during a \
|
|
sandboxed test run — an empty/missing `{field}` can be rejected by the real \
|
|
provider at runtime (e.g. Gmail rejects a send with no subject or body). \
|
|
Rewire it from an upstream node's output that actually has a value — call \
|
|
dry_run_workflow to see exactly which upstream field is null — or drop the \
|
|
field from args if it isn't really needed.",
|
|
step.node_id, diag.expression
|
|
));
|
|
}
|
|
}
|
|
errors
|
|
}
|
|
|
|
/// Returns the node id an explicit `nodes.<id>...` expression addresses —
|
|
/// either the legacy dotted shorthand (`=nodes.build_body.item.subject`) or
|
|
/// the jq bracket form (`=.nodes["build_body"].item.subject`) — or `None` if
|
|
/// the expression's root isn't the `nodes` scope key at all. The expression
|
|
/// scope's shape (`item` / `items` / `run` / `nodes`) is documented on
|
|
/// `tinyflows`'s `expr` module and `nodes::expr_scope`.
|
|
fn explicit_nodes_ref(expr: &str) -> Option<&str> {
|
|
let body = expr.strip_prefix('=')?.trim();
|
|
let body = body.strip_prefix('.').unwrap_or(body);
|
|
let rest = body.strip_prefix("nodes")?;
|
|
if let Some(after_dot) = rest.strip_prefix('.') {
|
|
// Dotted shorthand: `nodes.<id>.item.<field>` — the id ends at the
|
|
// next `.` or `[`.
|
|
let id = after_dot.split(['.', '[']).next()?;
|
|
(!id.is_empty()).then_some(id)
|
|
} else if let Some(after_bracket) = rest.strip_prefix('[') {
|
|
// jq bracket form: `nodes["<id>"]` / `nodes['<id>']`.
|
|
let after_bracket = after_bracket.trim_start();
|
|
let after_bracket = after_bracket
|
|
.strip_prefix('"')
|
|
.or_else(|| after_bracket.strip_prefix('\''))
|
|
.unwrap_or(after_bracket);
|
|
let id = after_bracket.split(['"', '\'', ']']).next()?;
|
|
(!id.is_empty()).then_some(id)
|
|
} else {
|
|
// `rest` is empty (bare `nodes`) or continues some other identifier
|
|
// (e.g. a hypothetical `nodesomething` — not this scope key at all).
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Whether a null-resolved config expression on `node_id` is scoped to the
|
|
/// TRIGGER's data rather than a specific upstream node's output — and
|
|
/// therefore legitimately empty in [`validate_required_arg_resolvability`]'s
|
|
/// `{}` mock run rather than evidence of broken wiring (see that function's
|
|
/// doc comment and the Codex feedback it links).
|
|
///
|
|
/// - `=run...` always addresses the trigger payload/metadata directly
|
|
/// (`crate::openhuman::tinyflows`'s `expr_scope` docs) — always
|
|
/// trigger-scoped.
|
|
/// - `=nodes.<id>...` / `=.nodes["<id>"]...` explicitly names an upstream
|
|
/// node. Trigger-scoped only if `<id>` IS the trigger node; naming any
|
|
/// other node is exactly the B18 broken-wiring case this gate exists to
|
|
/// catch, so it is never treated as trigger-scoped.
|
|
/// - `=item...` / `=items...` implicitly addresses `node_id`'s direct
|
|
/// predecessor(s) output. Trigger-scoped only when EVERY incoming edge to
|
|
/// `node_id` comes from the trigger node — a fan-in that mixes the trigger
|
|
/// with a real upstream node, or an `item`/`items` reference fed entirely
|
|
/// by real upstream nodes, keeps the existing (reject) behavior, since a
|
|
/// node that already ran in the sandbox is expected to have produced its
|
|
/// real, deterministic output.
|
|
/// - Anything else (a jq expression not rooted at one of the above, or a
|
|
/// malformed one) is conservatively treated as NOT trigger-scoped, matching
|
|
/// this gate's pre-existing behavior.
|
|
fn is_trigger_scoped_expression(
|
|
expr: &str,
|
|
graph: &WorkflowGraph,
|
|
node_id: &str,
|
|
trigger_id: Option<&str>,
|
|
) -> bool {
|
|
let body = expr.strip_prefix('=').unwrap_or(expr).trim();
|
|
let body = body.strip_prefix('.').unwrap_or(body);
|
|
|
|
if body == "run" || body.starts_with("run.") || body.starts_with("run[") {
|
|
return true;
|
|
}
|
|
|
|
if let Some(referenced_id) = explicit_nodes_ref(expr) {
|
|
return trigger_id == Some(referenced_id);
|
|
}
|
|
|
|
let is_item_scoped = body == "item"
|
|
|| body.starts_with("item.")
|
|
|| body.starts_with("item[")
|
|
|| body == "items"
|
|
|| body.starts_with("items.")
|
|
|| body.starts_with("items[");
|
|
if !is_item_scoped {
|
|
return false;
|
|
}
|
|
|
|
let Some(trigger_id) = trigger_id else {
|
|
return false;
|
|
};
|
|
let mut predecessors = graph
|
|
.edges
|
|
.iter()
|
|
.filter(|e| e.to_node == node_id)
|
|
.peekable();
|
|
predecessors.peek().is_some() && predecessors.all(|e| e.from_node == trigger_id)
|
|
}
|
|
|
|
/// If a null-resolved config expression on `node_id` is bound to the OUTPUT of
|
|
/// an upstream **Composio `tool_call`** node (a `tool_call` whose `slug` is a
|
|
/// real Composio action — not `=`-derived, not native `oh:`), returns that
|
|
/// upstream node's id; otherwise `None`.
|
|
///
|
|
/// The dry-run / gate sandbox renders a Composio `tool_call` as a deterministic
|
|
/// echo (`{tool, args, connection}`) and can NEVER produce its real output
|
|
/// fields, so a downstream binding to `.item.json.data.<field>` off such a node
|
|
/// resolves `null` in the sandbox **even when the wiring is correct** — the
|
|
/// binding is UNVERIFIABLE here, not necessarily broken. Callers use this to
|
|
/// tell that honest-uncertainty case apart from a genuinely broken binding
|
|
/// (one wired to an `agent` / `transform` / `code` / trigger upstream, whose
|
|
/// real output the sandbox DOES produce, so a null there IS a real bug).
|
|
///
|
|
/// Handles both addressing forms the engine can trace:
|
|
/// - explicit `=nodes.<id>...` / `=.nodes["<id>"]...` (parsed via
|
|
/// [`explicit_nodes_ref`]), and
|
|
/// - implicit `=item...` / `=items...`, resolved against `node_id`'s direct
|
|
/// predecessor — but only when there is exactly ONE incoming edge, so an
|
|
/// ambiguous fan-in is never mis-attributed to a single upstream node.
|
|
///
|
|
/// Anything else (a `=run...` trigger reference, a jq expression not rooted at
|
|
/// one of the above, or a reference to a non-`tool_call` / native / dynamic
|
|
/// node) returns `None`.
|
|
pub(crate) fn composio_tool_call_upstream_ref<'a>(
|
|
expr: &str,
|
|
graph: &'a WorkflowGraph,
|
|
node_id: &str,
|
|
) -> Option<&'a str> {
|
|
let referenced_id: String = if let Some(id) = explicit_nodes_ref(expr) {
|
|
id.to_string()
|
|
} else {
|
|
let body = expr.strip_prefix('=').unwrap_or(expr).trim();
|
|
let body = body.strip_prefix('.').unwrap_or(body);
|
|
let is_item_scoped = body == "item"
|
|
|| body.starts_with("item.")
|
|
|| body.starts_with("item[")
|
|
|| body == "items"
|
|
|| body.starts_with("items.")
|
|
|| body.starts_with("items[");
|
|
if !is_item_scoped {
|
|
return None;
|
|
}
|
|
let mut preds = graph
|
|
.edges
|
|
.iter()
|
|
.filter(|e| e.to_node == node_id)
|
|
.map(|e| e.from_node.as_str());
|
|
let first = preds.next()?;
|
|
if preds.next().is_some() {
|
|
// Ambiguous fan-in — cannot attribute the null to one upstream node.
|
|
return None;
|
|
}
|
|
first.to_string()
|
|
};
|
|
let node = graph.nodes.iter().find(|n| n.id == referenced_id)?;
|
|
if node.kind != NodeKind::ToolCall {
|
|
return None;
|
|
}
|
|
let slug = node.config.get("slug").and_then(Value::as_str)?;
|
|
if slug.starts_with('=') || slug.starts_with("oh:") {
|
|
return None;
|
|
}
|
|
Some(node.id.as_str())
|
|
}
|
|
|
|
/// 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");
|
|
// Split migrate/deserialize (a genuinely single failure) from structural
|
|
// validation (which can surface many problems at once). A pre-validation
|
|
// failure short-circuits with one error; a deserializable graph is then run
|
|
// through `validate_all` so the author sees every structural problem in one
|
|
// pass instead of one round-trip per error.
|
|
let graph = match migrate_and_deserialize_graph(graph_json) {
|
|
Ok(graph) => graph,
|
|
Err(error) => {
|
|
tracing::debug!(target: "flows", %error, "[flows] flows_validate: graph could not be migrated/parsed");
|
|
return RpcOutcome::single_log(
|
|
FlowValidation {
|
|
valid: false,
|
|
errors: vec![error.clone()],
|
|
error_details: vec![crate::openhuman::flows::FlowValidationError {
|
|
code: "unparseable_graph".to_string(),
|
|
message: error,
|
|
node_id: None,
|
|
field: None,
|
|
}],
|
|
warnings: Vec::new(),
|
|
},
|
|
"flow validation failed",
|
|
);
|
|
}
|
|
};
|
|
|
|
let structural = tinyflows::validate::validate_all(&graph);
|
|
if !structural.is_empty() {
|
|
let error_details: Vec<_> = structural.iter().map(to_flow_validation_error).collect();
|
|
let errors: Vec<String> = error_details.iter().map(|e| e.message.clone()).collect();
|
|
tracing::debug!(
|
|
target: "flows",
|
|
error_count = errors.len(),
|
|
"[flows] flows_validate: graph is structurally invalid"
|
|
);
|
|
return RpcOutcome::single_log(
|
|
FlowValidation {
|
|
valid: false,
|
|
errors,
|
|
error_details,
|
|
warnings: Vec::new(),
|
|
},
|
|
"flow validation failed",
|
|
);
|
|
}
|
|
|
|
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(),
|
|
error_details: Vec::new(),
|
|
warnings,
|
|
},
|
|
"flow validated",
|
|
)
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Issue B29 (save/enable safety) — two server-side rules apply here,
|
|
/// authoritative regardless of what the caller passed, so no creation path
|
|
/// (prompt bar, scratch/template modal, proposal "save & enable", copilot
|
|
/// `save_workflow`, …) can silently hand the user an armed, unattended
|
|
/// automation:
|
|
///
|
|
/// - **Rule 1** ([`trigger_is_automatic`]): a graph whose trigger fires
|
|
/// without a human in the loop (`schedule` / `app_event` / `webhook`)
|
|
/// persists **disabled**. The user arms it explicitly via
|
|
/// `flows_set_enabled` — the same toggle already used everywhere else. A
|
|
/// `manual` trigger (or no trigger-kind discriminator at all) still
|
|
/// persists enabled: it only ever runs via an explicit `flows_run`, so
|
|
/// there is no surprise, and gating it would just add friction.
|
|
///
|
|
/// This means a caller that represents an explicit user-arming action
|
|
/// (e.g. `WorkflowProposalCard`'s "Save & enable" click,
|
|
/// `app/src/components/chat/WorkflowProposalCard.tsx`) must check the
|
|
/// returned [`Flow`]'s `enabled` field and follow up with
|
|
/// `flows_set_enabled(id, true)` when it comes back `false` — otherwise
|
|
/// the button's own label lies to the user. That follow-up call is a
|
|
/// legitimate, explicit enable, not the silent copilot auto-arm this rule
|
|
/// exists to prevent (the copilot's `save_workflow` path has no such
|
|
/// follow-up and stays disabled).
|
|
/// - **Rule 2** ([`graph_has_outbound_side_effect`]): a graph containing any
|
|
/// `tool_call` / `http_request` / `code` node — the three kinds that can
|
|
/// produce a real outbound effect — forces `require_approval: true`,
|
|
/// overriding whatever the caller passed. A read-only graph (only
|
|
/// `trigger` / `agent` / `transform` / `condition` / data-flow nodes) is
|
|
/// unaffected.
|
|
///
|
|
/// An enabled flow still has its automatic-dispatch side effect bound
|
|
/// immediately (e.g. the schedule-trigger cron job registered), reusing the
|
|
/// same [`bind_trigger`] helper `flows_set_enabled` uses — but per Rule 1
|
|
/// that now only happens for a `manual`-triggered (or trigger-kind-less)
|
|
/// flow. 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)?;
|
|
|
|
// Rule 1: automatic triggers create DISABLED — the user must arm them
|
|
// explicitly.
|
|
let enabled = !trigger_is_automatic(&graph);
|
|
|
|
// Rule 2: any outbound side-effect node forces require_approval, no
|
|
// matter what the caller asked for.
|
|
let has_side_effect = graph_has_outbound_side_effect(&graph);
|
|
let effective_require_approval = require_approval || has_side_effect;
|
|
if has_side_effect && !require_approval {
|
|
tracing::info!(
|
|
target: "flows",
|
|
%name,
|
|
"[flows] flows_create: forcing require_approval=true — graph contains outbound \
|
|
side-effect node(s) (tool_call / http_request / code)"
|
|
);
|
|
}
|
|
|
|
tracing::debug!(
|
|
target: "flows",
|
|
%name,
|
|
node_count = graph.nodes.len(),
|
|
enabled,
|
|
require_approval = effective_require_approval,
|
|
"[flows] flows_create: persisting new flow"
|
|
);
|
|
let flow = store::create_flow(config, name, graph, effective_require_approval, enabled)
|
|
.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);
|
|
}
|
|
|
|
let mut logs = vec!["flow created".to_string()];
|
|
if !enabled {
|
|
let trigger_label = flow
|
|
.graph
|
|
.trigger()
|
|
.and_then(|t| t.config.get("trigger_kind"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("automatic");
|
|
logs.push(format!(
|
|
"Flow created DISABLED because it has an automatic trigger ({trigger_label}). \
|
|
Enable it explicitly (flows_set_enabled) when you are ready for it to fire."
|
|
));
|
|
}
|
|
if effective_require_approval && !require_approval {
|
|
logs.push(
|
|
"require_approval forced to true because the graph contains outbound side-effect \
|
|
nodes (tool_call / http_request / code)."
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
publish_flow_changed(&flow.id, "created", "system");
|
|
Ok(RpcOutcome::new(flow, logs))
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
};
|
|
|
|
// Connected-account identities (email/handle/platform user id), synced
|
|
// via each toolkit's whoami-style call (e.g. Slack `SLACK_TEST_AUTH`) on
|
|
// connection sync. Loaded once here so `build_flow_connections` can stay
|
|
// a pure, unit-testable matcher.
|
|
let identities = crate::openhuman::composio::providers::profile::load_connected_identities();
|
|
tracing::debug!(
|
|
count = identities.len(),
|
|
"[flows] flows_list_connections: identity-cache load"
|
|
);
|
|
let connections = build_flow_connections(composio_conns, http_creds, &identities);
|
|
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; `identities` is loaded once by the
|
|
/// caller and matched in here.
|
|
///
|
|
/// Each Composio connection is also matched against `identities` (keyed by
|
|
/// `(toolkit, connection_id)`, both normalized the same way
|
|
/// `enrich_connections_with_identity` in `composio::ops::connections` does)
|
|
/// to attach `platform_user_id` — the connected account's own member id
|
|
/// (e.g. Slack `U123ABC`). This is what lets the workflow builder wire a
|
|
/// self-targeted action ("DM me") to the user's own account instead of
|
|
/// guessing a public channel.
|
|
fn build_flow_connections(
|
|
composio: Vec<crate::openhuman::composio::ComposioConnection>,
|
|
http: Vec<crate::openhuman::credentials::HttpCredentialSummary>,
|
|
identities: &[crate::openhuman::composio::providers::profile::ConnectedIdentity],
|
|
) -> Vec<FlowConnection> {
|
|
use crate::openhuman::composio::providers::profile::normalize_connection_identifier;
|
|
|
|
let identity_lookup: std::collections::HashMap<(String, String), &_> = identities
|
|
.iter()
|
|
.map(|id| {
|
|
(
|
|
(
|
|
normalize_connection_identifier(&id.source),
|
|
normalize_connection_identifier(&id.identifier),
|
|
),
|
|
id,
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
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();
|
|
let lookup_key = (
|
|
normalize_connection_identifier(&toolkit),
|
|
normalize_connection_identifier(&conn.id),
|
|
);
|
|
let platform_user_id = identity_lookup
|
|
.get(&lookup_key)
|
|
.and_then(|identity| identity.user_id.clone());
|
|
tracing::debug!(
|
|
toolkit = %toolkit,
|
|
connection_id = %conn.id,
|
|
has_platform_user_id = platform_user_id.is_some(),
|
|
"[flows] flows_list_connections: resolved platform_user_id for composio connection"
|
|
);
|
|
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,
|
|
platform_user_id,
|
|
});
|
|
}
|
|
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),
|
|
platform_user_id: None,
|
|
});
|
|
}
|
|
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(['_', '-', ' '])
|
|
.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(" ")
|
|
}
|
|
|
|
/// Publishes a [`DomainEvent::FlowChanged`](crate::core::event_bus::DomainEvent::FlowChanged)
|
|
/// so an open Workflows list/canvas refetches (bridged to a `flow:changed`
|
|
/// socket event) — the observability half of audit F6. Best-effort broadcast;
|
|
/// `actor` is a coarse hint (`"system"` for RPC-driven changes today).
|
|
fn publish_flow_changed(flow_id: &str, kind: &str, actor: &str) {
|
|
tracing::debug!(target: "flows", %flow_id, kind, actor, "[flows] publishing FlowChanged");
|
|
crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::FlowChanged {
|
|
flow_id: flow_id.to_string(),
|
|
kind: kind.to_string(),
|
|
actor: actor.to_string(),
|
|
});
|
|
}
|
|
|
|
/// Maps a store-level [`FlowUpdateError`](store::FlowUpdateError) to the RPC
|
|
/// error string. A concurrency conflict is encoded as a JSON object the UI can
|
|
/// parse (`{ code: "version_conflict", message, current }`) so it can offer a
|
|
/// reload/diff instead of silently clobbering; other variants are plain text.
|
|
fn map_flow_update_error(e: store::FlowUpdateError) -> String {
|
|
match e {
|
|
store::FlowUpdateError::NotFound => "flow not found".to_string(),
|
|
store::FlowUpdateError::Conflict(current) => serde_json::to_string(&json!({
|
|
"code": "version_conflict",
|
|
"message": "This flow changed since you loaded it. Reload to see the latest \
|
|
version, then reapply your change.",
|
|
"current": *current,
|
|
}))
|
|
.unwrap_or_else(|_| "version_conflict".to_string()),
|
|
store::FlowUpdateError::Store(err) => err.to_string(),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// **B29 Rule 1 analogue for saves** (save/enable safety — same issue
|
|
/// `flows_create` guards at creation time, see its doc): `flows_create`
|
|
/// refuses to persist an automatic-trigger graph (`schedule` / `app_event` /
|
|
/// `webhook`, see [`trigger_is_automatic`]) as `enabled`, but that guard only
|
|
/// runs once, at creation. Without an equivalent here, a flow created
|
|
/// `enabled: true` with a manual/no-op trigger could later have an
|
|
/// automatic-trigger graph saved onto it — via the `save_workflow` agent
|
|
/// tool, the canvas Save button, a proposal apply, or any other
|
|
/// `flows_update` caller — and go LIVE immediately with no user review
|
|
/// (confirmed live: a flow started firing on an unreviewed 8am schedule).
|
|
/// So: when the *new* graph's trigger is automatic and the *previous*
|
|
/// graph's trigger was NOT automatic (a manual/none → automatic
|
|
/// transition), this forces the persisted `enabled` back to `false` in the
|
|
/// same store write — the user must explicitly re-arm via
|
|
/// `flows_set_enabled` after reviewing the new trigger. An automatic →
|
|
/// automatic re-edit (e.g. tweaking a cron expression) is left alone — the
|
|
/// user already opted in once, and re-disarming on every edit would just be
|
|
/// friction.
|
|
///
|
|
/// The override is applied **unconditionally** on a manual/none → automatic
|
|
/// transition — it does *not* gate on whether the flow *looked* enabled in
|
|
/// the `existing` read above. That read is a snapshot taken before
|
|
/// `store::update_flow_graph`'s own guarded UPDATE re-reads the row; a
|
|
/// concurrent `flows_set_enabled(id, true)` landing in the gap would leave
|
|
/// this snapshot stale while the row is actually enabled by the time the
|
|
/// guarded UPDATE runs — and since `set_enabled` bumps `updated_at` too,
|
|
/// such a race wouldn't even trip the optimistic-concurrency conflict, it
|
|
/// would just silently persist the automatic graph as enabled (the exact
|
|
/// bug this rule exists to close). Gating on the stale `existing.enabled`
|
|
/// re-opens that race; forcing the override on every transition, enabled-or-
|
|
/// not, is exactly as safe as Rule 1's at-create version — a transition on
|
|
/// an already-disabled flow is just a no-op write of `enabled=false` over
|
|
/// `enabled=false`.
|
|
pub async fn flows_update(
|
|
config: &Config,
|
|
id: &str,
|
|
name: Option<String>,
|
|
graph_json: Option<Value>,
|
|
require_approval: Option<bool>,
|
|
expected_version: Option<String>,
|
|
) -> 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()
|
|
}
|
|
};
|
|
|
|
// B29 Rule 1 analogue: disarm every manual/none → automatic trigger
|
|
// transition, unconditionally — see the doc comment above for why this
|
|
// must NOT gate on the (possibly stale) `existing.enabled` read.
|
|
let was_auto = trigger_is_automatic(&existing.graph);
|
|
let now_auto = trigger_is_automatic(&graph);
|
|
let is_manual_to_auto_transition = now_auto && !was_auto;
|
|
let enabled_override = is_manual_to_auto_transition.then_some(false);
|
|
// Best-effort flag for the info log / result message below: whether the
|
|
// flow *appeared* live going into this update. Not used for the
|
|
// override decision itself (that's unconditional, see above) — only to
|
|
// avoid telling the user "flow was auto-disabled" when it was already
|
|
// disabled going in.
|
|
let should_disarm = is_manual_to_auto_transition && existing.enabled;
|
|
tracing::debug!(
|
|
target: "flows",
|
|
flow_id = %id,
|
|
was_auto,
|
|
now_auto,
|
|
currently_enabled = existing.enabled,
|
|
is_manual_to_auto_transition,
|
|
should_disarm,
|
|
"[flows] flows_update: auto-trigger disarm decision inputs"
|
|
);
|
|
|
|
tracing::debug!(target: "flows", flow_id = %id, has_expected = expected_version.is_some(), "[flows] flows_update: persisting changes");
|
|
// `enabled_override` is threaded into the same guarded UPDATE as the
|
|
// graph/name/require_approval write (see `store::update_flow_graph`)
|
|
// rather than a follow-up `flows_set_enabled` call, so the disarm can
|
|
// never race a concurrent read/write of `enabled`.
|
|
let updated = store::update_flow_graph(
|
|
config,
|
|
id,
|
|
new_name,
|
|
graph,
|
|
new_require_approval,
|
|
enabled_override,
|
|
expected_version.as_deref(),
|
|
)
|
|
.map_err(map_flow_update_error)?;
|
|
|
|
if should_disarm {
|
|
tracing::info!(
|
|
target: "flows",
|
|
flow_id = %id,
|
|
"[flows] flows_update: auto-disabled — graph changed manual→automatic trigger on an enabled flow"
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
publish_flow_changed(id, "updated", "system");
|
|
let mut logs = vec![format!("flow updated: {id}")];
|
|
if should_disarm {
|
|
logs.push(
|
|
"Flow was auto-disabled because its trigger changed from manual to automatic \
|
|
(schedule / app_event / webhook). Enable it explicitly (flows_set_enabled) once \
|
|
you've reviewed the new trigger."
|
|
.to_string(),
|
|
);
|
|
}
|
|
Ok(RpcOutcome::new(updated, logs))
|
|
}
|
|
|
|
/// Lists a flow's revision history (prior graph snapshots), newest first,
|
|
/// capped at `limit` (audit F6). The safety rail that makes rollback possible.
|
|
pub fn flows_get_history(
|
|
config: &Config,
|
|
id: &str,
|
|
limit: usize,
|
|
) -> Result<RpcOutcome<Vec<crate::openhuman::flows::FlowRevision>>, String> {
|
|
let revisions = store::list_revisions(config, id, limit).map_err(|e| e.to_string())?;
|
|
let count = revisions.len();
|
|
Ok(RpcOutcome::single_log(
|
|
revisions,
|
|
format!("flow history: {id} ({count} revisions)"),
|
|
))
|
|
}
|
|
|
|
/// Rolls a flow back to a prior revision by restoring that revision's graph
|
|
/// through the normal update path — which itself snapshots the current graph as
|
|
/// a new revision, so a rollback is itself undoable. Honours optimistic
|
|
/// concurrency via `expected_version`.
|
|
pub async fn flows_rollback(
|
|
config: &Config,
|
|
id: &str,
|
|
revision_id: &str,
|
|
expected_version: Option<String>,
|
|
) -> Result<RpcOutcome<Flow>, String> {
|
|
let rev = store::revision_by_id(config, id, revision_id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("revision '{revision_id}' not found for flow '{id}'"))?;
|
|
|
|
tracing::debug!(target: "flows", flow_id = %id, %revision_id, "[flows] flows_rollback: restoring prior revision");
|
|
flows_update(
|
|
config,
|
|
id,
|
|
Some(rev.name),
|
|
Some(rev.graph),
|
|
Some(rev.require_approval),
|
|
expected_version,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// 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");
|
|
publish_flow_changed(id, "deleted", "system");
|
|
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}"));
|
|
}
|
|
}
|
|
|
|
publish_flow_changed(id, "enabled_changed", "system");
|
|
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"))?;
|
|
|
|
// Live finding: a graph with no actionable nodes (only a `trigger`, or a
|
|
// `trigger` plus nodes with no edges wiring them up) compiles and "runs"
|
|
// cleanly but does nothing — and previously reported
|
|
// `status="completed" pending_approvals=0` indistinguishably from a real
|
|
// run, reading as "triggered but nothing happened" was actually a
|
|
// success. Surface it loudly instead of letting it pass silently: warn
|
|
// now (independent of how the run below turns out), and attach a
|
|
// human-readable note to the returned outcome so the UI can show
|
|
// "nothing to run" rather than a bare "completed".
|
|
let no_actionable_nodes = !graph_has_actionable_nodes(&flow.graph);
|
|
if no_actionable_nodes {
|
|
tracing::warn!(
|
|
target: "flows",
|
|
flow_id = %flow_id,
|
|
"[flows] flows_run: flow has no actionable nodes — nothing to execute"
|
|
);
|
|
}
|
|
|
|
// `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(),
|
|
),
|
|
);
|
|
// Scope the flow/run correlation (issue flow-approval-surface, PR2)
|
|
// alongside the `Workflow` origin so a tool call the engine dispatches
|
|
// can, if it parks in the `ApprovalGate`, stamp its `PendingApproval` with
|
|
// `source_context = Flow { flow_id, run_id }` — the origin alone only
|
|
// carries `flow_id`. See `approval::gate::APPROVAL_FLOW_RUN_CONTEXT`.
|
|
let run = APPROVAL_FLOW_RUN_CONTEXT.scope(
|
|
FlowRunContext {
|
|
flow_id: flow_id.to_string(),
|
|
run_id: thread_id.clone(),
|
|
},
|
|
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(),
|
|
no_actionable_nodes,
|
|
"[flows] flows_run: finished"
|
|
);
|
|
|
|
const NO_ACTIONABLE_NODES_NOTE: &str = "This flow's graph has no actionable nodes beyond \
|
|
its trigger (no downstream action nodes, or no edges connecting them) — the run \
|
|
completed without doing anything. Add and wire up at least one action node.";
|
|
|
|
let mut result = json!({
|
|
"output": outcome.output,
|
|
"pending_approvals": outcome.pending_approvals,
|
|
"thread_id": thread_id,
|
|
});
|
|
let mut logs = vec![format!("flow run {status}")];
|
|
if no_actionable_nodes {
|
|
result["note"] = json!(NO_ACTIONABLE_NODES_NOTE);
|
|
logs.push(NO_ACTIONABLE_NODES_NOTE.to_string());
|
|
}
|
|
|
|
Ok(RpcOutcome::new(result, logs))
|
|
}
|
|
|
|
/// 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.
|
|
//
|
|
// Same flow/run correlation scope as `flows_run` (see its comment) — a
|
|
// resumed run can dispatch further tool calls that park, and those parks
|
|
// need `source_context` too.
|
|
let run = APPROVAL_FLOW_RUN_CONTEXT.scope(
|
|
FlowRunContext {
|
|
flow_id: flow_id.to_string(),
|
|
run_id: thread_id.to_string(),
|
|
},
|
|
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}"),
|
|
))
|
|
}
|
|
|
|
/// List the most recent runs across ALL flows, newest first — backs the
|
|
/// aggregate "All runs" page. Each returned run carries its `flow_id` so the UI
|
|
/// can group/label by workflow.
|
|
pub async fn flows_list_all_runs(
|
|
config: &Config,
|
|
limit: usize,
|
|
) -> Result<RpcOutcome<Vec<FlowRun>>, String> {
|
|
sweep_expired_parked_runs(config).await;
|
|
let runs = store::list_all_flow_runs(config, limit).map_err(|e| e.to_string())?;
|
|
let count = runs.len();
|
|
Ok(RpcOutcome::single_log(
|
|
runs,
|
|
format!("all flow runs listed: {count} run(s)"),
|
|
))
|
|
}
|
|
|
|
/// 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) {
|
|
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.
|
|
///
|
|
/// Matches [`FLOW_BUILD_TIMEOUT_SECS`] (600s): the session builder applies the
|
|
/// `flow_discovery` definition's `effective_max_iterations()` (50, not the
|
|
/// global default of 10) to this path (issue #4868), so a worst-case run at
|
|
/// ~10s/iteration can take up to ~500s — the old 300s bound could clip a
|
|
/// legitimate long discovery run before the iteration cap ever got a chance
|
|
/// to (post-merge Codex P2 finding).
|
|
const FLOW_DISCOVER_TIMEOUT_SECS: u64 = 600;
|
|
|
|
/// 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.
|
|
///
|
|
/// Matches [`FLOW_RUN_TIMEOUT_SECS`] (600s): the session builder applies the
|
|
/// `workflow_builder` definition's `effective_max_iterations()` (50, not the
|
|
/// global default of 10) to this path (issue #4868), so a worst-case run at
|
|
/// ~10s/iteration can take up to ~500s — the old 300s bound would have
|
|
/// clipped a legitimate long build before the iteration cap ever got a
|
|
/// chance to.
|
|
const FLOW_BUILD_TIMEOUT_SECS: u64 = 600;
|
|
|
|
/// Tools stripped from the `workflow_builder` belt on the direct `flows_build`
|
|
/// RPC path (issue #4593; widened for `resume_flow_run`/`cancel_flow_run`
|
|
/// alongside issue #4881, which added both to the belt without extending
|
|
/// this list).
|
|
///
|
|
/// `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.
|
|
///
|
|
/// `resume_flow_run` ([`builder_tools::ResumeFlowRunTool`]) is the exact same
|
|
/// concern as `run_flow`, one hop later: it is `external_effect() == true`
|
|
/// (its own description says "This ADVANCES A REAL RUN — approved outbound
|
|
/// nodes will fire") and would be auto-allowed by the same `Cli`-origin gate
|
|
/// bypass, letting an authoring turn (or a confused/prompt-injected model)
|
|
/// approve a live run's parked Slack/Gmail/HTTP node with zero human
|
|
/// confirmation — the exact HITL hole #4593 closed, reopened by #4881
|
|
/// widening the belt.
|
|
///
|
|
/// `cancel_flow_run` fires no new outbound effect
|
|
/// (`external_effect() == false`), so it isn't a gate-bypass concern the same
|
|
/// way — but an authoring turn still has no business tearing down a run the
|
|
/// *user* started, so it is hidden alongside the two above out of caution.
|
|
///
|
|
/// `create_workflow` / `duplicate_flow` are deliberately **left visible**:
|
|
/// both are hard-forced **born disabled** (see [`builder_tools::CreateWorkflowTool`]
|
|
/// / [`builder_tools::DuplicateFlowTool`]), so even an unattended call can't
|
|
/// leave anything live — lower risk than the run/resume/cancel trio above.
|
|
const FLOWS_BUILD_HIDDEN_TOOLS: &[&str] = &[
|
|
"run_workflow",
|
|
"run_flow",
|
|
"resume_flow_run",
|
|
"cancel_flow_run",
|
|
];
|
|
|
|
/// Strip the live-run / resume / cancel 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/`create_workflow`/
|
|
/// `duplicate_flow`) stay visible 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/resume/cancel 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}"))?;
|
|
|
|
// Issue #4868 — the session builder (`build_session_agent_inner`) now
|
|
// resolves the per-agent iteration cap from the `workflow_builder`
|
|
// `AgentDefinition` itself (`iteration_policy = "extended"` ->
|
|
// `effective_max_iterations()` = 50), so no override is needed here.
|
|
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"
|
|
)),
|
|
)
|
|
}
|
|
};
|
|
|
|
// Capture the proposal from the run's tool history (propose/revise/save all
|
|
// emit the same self-describing `{ type: "workflow_proposal", … }` payload).
|
|
// Extracted BEFORE the stream is finalized below (issue: builder
|
|
// convergence): the trail-off backstop needs `proposal`/`capped` to decide
|
|
// whether to override `assistant_text`, and the streamed copilot-pane chat
|
|
// bubble must render the SAME (possibly-overridden) text as the RPC
|
|
// response — the frontend renders from the stream, not the return value,
|
|
// so patching only the latter would still leave an interactive user
|
|
// staring at the original silent/status-only text.
|
|
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 {
|
|
if let Some(target) = &stream {
|
|
let terminal: Result<String, String> = Err(err.clone());
|
|
finalize_flow_stream(target, &terminal, &prompt).await;
|
|
}
|
|
return Err(format!("workflow_builder produced no proposal: {err}"));
|
|
}
|
|
}
|
|
|
|
// (B34) Whether this turn paused because it hit `max_tool_iterations`
|
|
// rather than finishing naturally (asking a question, or proposing). A
|
|
// capped turn with no proposal renders a raw checkpoint ("Done so far /
|
|
// Next steps") that's indistinguishable, in the response shape alone,
|
|
// from the agent voluntarily asking a clarifying question — `capped`
|
|
// gives the frontend the explicit signal to render a "Continue building"
|
|
// card instead. Scoped to `proposal.is_none()`: a turn that hit the cap
|
|
// but still squeezed out a proposal (the checkpoint fires before the
|
|
// final `propose_workflow` call in that ordering) has nothing left to
|
|
// continue.
|
|
let hit_cap = agent.last_turn_hit_cap();
|
|
let capped = hit_cap && proposal.is_none();
|
|
|
|
// Terminal-state guarantee (builder convergence fix): a turn can end
|
|
// "naturally" (no more tool calls, not capped, no run error) yet still
|
|
// produce neither a proposal nor a real question — the model ran out of
|
|
// steam mid-build and left a status dump ("Done so far: checked
|
|
// connections…") as its final reply. `prompt.md` tells the model to
|
|
// always end a building turn in a proposal or a question, but a prompt
|
|
// rule can be silently ignored; this is the fail-closed backend backstop
|
|
// that makes it a hard invariant regardless of model behavior — the user
|
|
// is NEVER left with silence or an unanswerable status note.
|
|
let trail_off = !capped && proposal.is_none() && run_error.is_none();
|
|
let assistant_text = if trail_off && !text_looks_like_question(&assistant_text) {
|
|
let fallback = build_trail_off_fallback(agent.history());
|
|
tracing::warn!(
|
|
target: "flows",
|
|
flow_id = req.flow_id.as_deref().unwrap_or("<none>"),
|
|
original_len = assistant_text.len(),
|
|
fallback_len = fallback.len(),
|
|
"[flows] flows_build: trail-off detected (no proposal, no cap, no question) — \
|
|
guaranteeing a fallback question instead of silence"
|
|
);
|
|
fallback
|
|
} else {
|
|
assistant_text
|
|
};
|
|
|
|
// 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. Uses the
|
|
// (possibly trail-off-overridden) `assistant_text` above.
|
|
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;
|
|
}
|
|
|
|
tracing::info!(
|
|
target: "flows",
|
|
flow_id = req.flow_id.as_deref().unwrap_or("<none>"),
|
|
has_proposal = proposal.is_some(),
|
|
hit_cap,
|
|
capped,
|
|
trail_off,
|
|
"[flows] flows_build: workflow_builder turn complete"
|
|
);
|
|
Ok(RpcOutcome::single_log(
|
|
json!({
|
|
"proposal": proposal,
|
|
"assistant_text": assistant_text,
|
|
"error": run_error,
|
|
"capped": capped,
|
|
"trail_off": trail_off,
|
|
}),
|
|
"workflow builder turn complete",
|
|
))
|
|
}
|
|
|
|
/// Heuristic: does `text` already end with a clear, answerable question?
|
|
/// Conservative by design (issue: builder convergence) — a false negative (an
|
|
/// actual question this misses) just wraps it in the trail-off fallback,
|
|
/// which still includes the blocker context, so the safe failure mode is
|
|
/// "over-wrap", never "under-detect and stay silent".
|
|
fn text_looks_like_question(text: &str) -> bool {
|
|
let trimmed = text
|
|
.trim()
|
|
.trim_end_matches(['"', '\'', ')', ']', '*', '_', '`', '.'])
|
|
.trim_end();
|
|
if trimmed.is_empty() {
|
|
return false;
|
|
}
|
|
if trimmed.ends_with('?') {
|
|
return true;
|
|
}
|
|
// The question may not be the literal last character (trailing markdown
|
|
// like a closing code fence or list marker on its own line) — fall back
|
|
// to the last non-blank line. This does NOT catch a question followed by
|
|
// a further trailing sentence ("...channel?\n\nLet me know!") — that's
|
|
// an accepted false negative: the turn still ends in a real (if
|
|
// over-eagerly replaced) question, never in silence, which is the
|
|
// invariant this function exists to protect.
|
|
trimmed
|
|
.lines()
|
|
.rfind(|line| !line.trim().is_empty())
|
|
.is_some_and(|last_line| last_line.trim_end().ends_with('?'))
|
|
}
|
|
|
|
/// Builder-authoring tools whose result body can explain a trail-off — the
|
|
/// authoring belt `dry_run_workflow`/`validate_workflow`/`propose_workflow`/
|
|
/// `revise_workflow`/`edit_workflow`/`save_workflow` all report either a hard
|
|
/// gate rejection (`ToolResult::error`) or a self-reported broken-graph
|
|
/// result (`"ok": false` in a successful body), so a plain-text read-only
|
|
/// tool's output is never misattributed as the blocker.
|
|
const TRAIL_OFF_BLOCKER_TOOLS: &[&str] = &[
|
|
"dry_run_workflow",
|
|
"validate_workflow",
|
|
"propose_workflow",
|
|
"revise_workflow",
|
|
"edit_workflow",
|
|
"save_workflow",
|
|
];
|
|
|
|
/// Synthesizes a guaranteed, user-facing fallback for a trail-off turn (no
|
|
/// proposal, not capped, no run error, and the model's own text isn't a
|
|
/// question). Scans the run's tool history for the last builder-tool result
|
|
/// that looks like a blocker (a hard-gate rejection, or a `dry_run_workflow`/
|
|
/// `validate_workflow` report with `"ok": false`) and asks the user about it;
|
|
/// falls back to a generic "what should I focus on" question when no such
|
|
/// blocker is found (the model may have simply stopped with nothing to point
|
|
/// to).
|
|
fn build_trail_off_fallback(
|
|
history: &[crate::openhuman::inference::provider::ConversationMessage],
|
|
) -> String {
|
|
match last_builder_tool_blocker(history) {
|
|
Some(blocker) => format!(
|
|
"I wasn't able to finish building this workflow. Here's where I got stuck:\n\n{blocker}\n\n\
|
|
Could you tell me how you'd like me to resolve that, or share more detail about what's needed here?"
|
|
),
|
|
None => "I wasn't able to finish building this workflow in this turn. Could you describe \
|
|
what you'd like in more detail, or tell me which part to focus on?"
|
|
.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Scans `history` in reverse for the last result from a
|
|
/// [`TRAIL_OFF_BLOCKER_TOOLS`] call that reads as a failure — a plain-text
|
|
/// error message (gate rejection), or a JSON body with `"ok": false` — and
|
|
/// returns a truncated, human-readable description of it. Tool names are
|
|
/// resolved by correlating each `ToolResults` entry's `tool_call_id` back to
|
|
/// the `AssistantToolCalls` message that issued it, so this never
|
|
/// misattributes an unrelated read-only tool's plain-text output as a
|
|
/// blocker.
|
|
fn last_builder_tool_blocker(
|
|
history: &[crate::openhuman::inference::provider::ConversationMessage],
|
|
) -> Option<String> {
|
|
use crate::openhuman::inference::provider::ConversationMessage;
|
|
|
|
let mut call_names: std::collections::HashMap<String, String> =
|
|
std::collections::HashMap::new();
|
|
for message in history {
|
|
if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = message {
|
|
for call in tool_calls {
|
|
call_names.insert(call.id.clone(), call.name.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
for message in history.iter().rev() {
|
|
let ConversationMessage::ToolResults(results) = message else {
|
|
continue;
|
|
};
|
|
for result in results.iter().rev() {
|
|
let Some(name) = call_names.get(&result.tool_call_id) else {
|
|
continue;
|
|
};
|
|
if !TRAIL_OFF_BLOCKER_TOOLS.contains(&name.as_str()) {
|
|
continue;
|
|
}
|
|
// This is the MOST RECENT authoring-belt tool result in the
|
|
// turn (results are scanned newest-first). Whatever it reads as
|
|
// is authoritative: a success/progress result here means any
|
|
// earlier failure from the same tool was already resolved
|
|
// within this turn, so we must stop at this result rather than
|
|
// keep walking backward and surfacing a stale, already-fixed
|
|
// blocker (see review discussion on this PR).
|
|
return describe_tool_result_blocker(&result.content)
|
|
.map(|desc| crate::openhuman::util::truncate_with_ellipsis(&desc, 500));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Reads one builder tool result's content as a failure description, or
|
|
/// `None` when it reads as success/progress (a `workflow_proposal` payload,
|
|
/// or an `"ok": true` report). The whole body is the description, never one
|
|
/// hardcoded field, so this stays correct regardless of which fields a given
|
|
/// tool uses to explain its failure.
|
|
fn describe_tool_result_blocker(content: &str) -> Option<String> {
|
|
let trimmed = content.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
|
|
if value.get("type").and_then(Value::as_str) == Some("workflow_proposal") {
|
|
return None; // Success: a proposal was emitted.
|
|
}
|
|
if let Some(ok) = value.get("ok").and_then(Value::as_bool) {
|
|
return if ok { None } else { Some(value.to_string()) };
|
|
}
|
|
// Some other structured payload with no `ok`/`type` marker this
|
|
// function recognises — not confidently a blocker, skip it.
|
|
return None;
|
|
}
|
|
// Non-JSON content: a hard-gate rejection (`ToolResult::error`) puts the
|
|
// plain error message straight into the content — since every builder
|
|
// tool's SUCCESS shape is JSON (a proposal or a `{ ok, ... }` report), a
|
|
// bare string here is, by elimination, an error message.
|
|
Some(trimmed.to_string())
|
|
}
|
|
|
|
/// 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",
|
|
))
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Connector onboarding (Phase 5, item 18) — which toolkits a graph needs
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// The set of Composio toolkits currently connected (lowercased), derived from
|
|
/// the same picker source the node-config credential dropdown uses.
|
|
pub(crate) async fn connected_toolkits(config: &Config) -> std::collections::HashSet<String> {
|
|
match flows_list_connections(config).await {
|
|
Ok(outcome) => outcome
|
|
.value
|
|
.iter()
|
|
.filter_map(|c| c.toolkit.as_deref())
|
|
.map(|t| t.to_ascii_lowercase())
|
|
.collect(),
|
|
Err(e) => {
|
|
tracing::warn!(target: "flows", error = %e, "[flows] connected_toolkits: could not list connections — treating all as unconnected");
|
|
std::collections::HashSet::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The Composio toolkits a graph needs (from its `tool_call` slugs and any
|
|
/// `app_event` trigger), each tagged connected/missing — the data behind the
|
|
/// canvas/proposal "Connect <toolkit>" CTAs (audit Phase 5, item 18). Native
|
|
/// `oh:` tools and `http_request` nodes need no Composio connection and are
|
|
/// skipped.
|
|
pub async fn compute_required_connections(config: &Config, graph: &WorkflowGraph) -> Vec<Value> {
|
|
use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug;
|
|
|
|
// Collect required toolkits (deduped, order-preserving).
|
|
let mut required: Vec<String> = Vec::new();
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut push = |tk: String| {
|
|
let tk = tk.to_ascii_lowercase();
|
|
if !tk.is_empty() && seen.insert(tk.clone()) {
|
|
required.push(tk);
|
|
}
|
|
};
|
|
|
|
for node in &graph.nodes {
|
|
if node.kind == NodeKind::ToolCall {
|
|
if let Some(slug) = node.config.get("slug").and_then(Value::as_str) {
|
|
// Native OpenHuman tools (`oh:<name>`) need no connection.
|
|
if slug.starts_with("oh:") {
|
|
continue;
|
|
}
|
|
if let Some(tk) = toolkit_from_slug(slug) {
|
|
push(tk.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// An app_event trigger names its toolkit directly.
|
|
if let Some(trigger) = graph.trigger() {
|
|
if let Some(tk) = trigger.config.get("toolkit").and_then(Value::as_str) {
|
|
push(tk.to_string());
|
|
}
|
|
}
|
|
|
|
if required.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let connected = connected_toolkits(config).await;
|
|
required
|
|
.into_iter()
|
|
.map(|toolkit| {
|
|
let status = if connected.contains(&toolkit) {
|
|
"connected"
|
|
} else {
|
|
"missing"
|
|
};
|
|
json!({ "toolkit": toolkit, "status": status })
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// RPC: compute the toolkits a candidate graph needs and their connected
|
|
/// status, so the canvas/proposal can render "Connect <toolkit>" CTAs.
|
|
pub async fn flows_required_connections(
|
|
config: &Config,
|
|
graph_json: Value,
|
|
) -> Result<RpcOutcome<Value>, String> {
|
|
let graph = migrate_and_deserialize_graph(graph_json)?;
|
|
let required = compute_required_connections(config, &graph).await;
|
|
Ok(RpcOutcome::single_log(
|
|
json!({ "required_connections": required }),
|
|
"required connections computed",
|
|
))
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Catalog RPCs for the UI (Phase 5, item 16) — one implementation, two consumers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Searches the live Composio tool catalog (secret-free) — the RPC the in-canvas
|
|
/// tool browser calls, reusing the exact same core as the agent's
|
|
/// `search_tool_catalog` tool so the two can't drift.
|
|
pub async fn flows_search_tool_catalog(
|
|
config: &Config,
|
|
query: &str,
|
|
toolkit: Option<&str>,
|
|
limit: usize,
|
|
) -> Result<RpcOutcome<Value>, String> {
|
|
tracing::debug!(target: "flows", %query, toolkit = toolkit.unwrap_or("<all>"), "[flows] flows_search_tool_catalog: searching live catalog");
|
|
let tools =
|
|
crate::openhuman::flows::builder_tools::search_live_catalog(config, query, toolkit, limit)
|
|
.await;
|
|
Ok(RpcOutcome::single_log(
|
|
json!({ "tools": tools }),
|
|
"tool catalog searched",
|
|
))
|
|
}
|
|
|
|
/// Fetches one Composio action's full contract (secret-free) — the RPC the
|
|
/// canvas tool browser calls to fill in an action's arg schema, reusing the same
|
|
/// core as the agent's `get_tool_contract` tool.
|
|
pub async fn flows_get_tool_contract(
|
|
config: &Config,
|
|
slug: &str,
|
|
) -> Result<RpcOutcome<Value>, String> {
|
|
let slug = slug.trim();
|
|
let Some(toolkit) = crate::openhuman::memory_sync::composio::providers::toolkit_from_slug(slug)
|
|
else {
|
|
return Err(format!(
|
|
"Could not extract a toolkit from slug '{slug}' — it must look like \
|
|
'<TOOLKIT>_<ACTION>' (e.g. 'GMAIL_SEND_EMAIL')."
|
|
));
|
|
};
|
|
tracing::debug!(target: "flows", %slug, %toolkit, "[flows] flows_get_tool_contract: fetching contract");
|
|
let Some(catalog) =
|
|
crate::openhuman::tinyflows::caps::fetch_live_toolkit_catalog(config, &toolkit).await
|
|
else {
|
|
return Err(format!(
|
|
"Could not fetch the live Composio catalog for toolkit '{toolkit}'."
|
|
));
|
|
};
|
|
match catalog.iter().find(|c| c.slug.eq_ignore_ascii_case(slug)) {
|
|
Some(contract) => {
|
|
let contract =
|
|
crate::openhuman::tinyflows::caps::apply_probe_override(contract.clone());
|
|
let value = serde_json::to_value(&contract).map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(
|
|
json!({ "contract": value }),
|
|
"tool contract fetched",
|
|
))
|
|
}
|
|
None => Err(format!(
|
|
"'{slug}' is not a real action in the '{toolkit}' toolkit's live catalog."
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Core-managed local drafts (F5) — the shared agent/canvas working copy
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Creates a new draft (a durable, non-live working copy) from a graph.
|
|
pub fn flows_draft_create(
|
|
config: &Config,
|
|
flow_id: Option<String>,
|
|
name: String,
|
|
graph: Value,
|
|
origin: crate::openhuman::flows::DraftOrigin,
|
|
) -> Result<RpcOutcome<crate::openhuman::flows::FlowDraft>, String> {
|
|
let draft = draft_store::create_draft(config, flow_id, name, graph, origin)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(draft, "draft created"))
|
|
}
|
|
|
|
/// Reads a draft by id (errors if it does not exist).
|
|
pub fn flows_draft_get(
|
|
config: &Config,
|
|
id: &str,
|
|
) -> Result<RpcOutcome<crate::openhuman::flows::FlowDraft>, String> {
|
|
let draft = draft_store::get_draft(config, id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("draft '{id}' not found"))?;
|
|
Ok(RpcOutcome::single_log(draft, format!("draft loaded: {id}")))
|
|
}
|
|
|
|
/// Patches a draft's `name`/`graph`/`flow_id` (any `Some` applied) and bumps
|
|
/// `updated_at`.
|
|
pub fn flows_draft_update(
|
|
config: &Config,
|
|
id: &str,
|
|
name: Option<String>,
|
|
graph: Option<Value>,
|
|
flow_id: Option<Option<String>>,
|
|
) -> Result<RpcOutcome<crate::openhuman::flows::FlowDraft>, String> {
|
|
let draft =
|
|
draft_store::update_draft(config, id, name, graph, flow_id).map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(draft, "draft updated"))
|
|
}
|
|
|
|
/// Lists all drafts, newest-updated first.
|
|
pub fn flows_draft_list(
|
|
config: &Config,
|
|
) -> Result<RpcOutcome<Vec<crate::openhuman::flows::FlowDraft>>, String> {
|
|
let drafts = draft_store::list_drafts(config).map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(drafts, "drafts listed"))
|
|
}
|
|
|
|
/// Deletes a draft by id (idempotent — reports whether a file was removed).
|
|
pub fn flows_draft_delete(config: &Config, id: &str) -> Result<RpcOutcome<Value>, String> {
|
|
let deleted = draft_store::delete_draft(config, id).map_err(|e| e.to_string())?;
|
|
Ok(RpcOutcome::single_log(
|
|
json!({ "id": id, "deleted": deleted }),
|
|
"draft deleted",
|
|
))
|
|
}
|
|
|
|
/// Promotes a draft into a saved flow, then removes the draft file.
|
|
///
|
|
/// Runs the SAME create/update gates as a normal save (structural validation,
|
|
/// the forced `require_approval` floor for side-effect graphs, born-disabled
|
|
/// for automatic triggers) — a draft is never a back-door around them. A draft
|
|
/// with a `flow_id` updates that flow; otherwise it creates a new one. The
|
|
/// draft file is deleted only on a successful promote.
|
|
pub async fn flows_draft_promote(
|
|
config: &Config,
|
|
id: &str,
|
|
require_approval: Option<bool>,
|
|
) -> Result<RpcOutcome<Flow>, String> {
|
|
let draft = draft_store::get_draft(config, id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("draft '{id}' not found"))?;
|
|
|
|
tracing::debug!(
|
|
target: "flows",
|
|
draft_id = %id,
|
|
promotes_to = draft.flow_id.as_deref().unwrap_or("<new flow>"),
|
|
"[flows] flows_draft_promote: promoting draft through the create/update gates"
|
|
);
|
|
|
|
let outcome = match &draft.flow_id {
|
|
Some(flow_id) => {
|
|
flows_update(
|
|
config,
|
|
flow_id,
|
|
Some(draft.name.clone()),
|
|
Some(draft.graph.clone()),
|
|
require_approval,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
None => {
|
|
flows_create(
|
|
config,
|
|
draft.name.clone(),
|
|
draft.graph.clone(),
|
|
require_approval.unwrap_or(false),
|
|
)
|
|
.await?
|
|
}
|
|
};
|
|
|
|
// Only remove the draft once the flow write succeeded.
|
|
if let Err(e) = draft_store::delete_draft(config, id) {
|
|
tracing::warn!(target: "flows", draft_id = %id, error = %e, "[flows] flows_draft_promote: flow saved but draft file could not be removed");
|
|
}
|
|
Ok(outcome)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "ops_tests.rs"]
|
|
mod tests;
|