From 8d42ed6ed1dfc581e190159b5bf55f0599d93494 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 10:39:29 -0700 Subject: [PATCH 1/5] cooperation: guard mode is live, dissolve counts real failures, dispatch splits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects a documentation pass recorded but did not fix. 1. Guard mode was captured at deploy. `toggle_formation_guard` wrote the `guard:{formation_id}` config row and nothing else, while enforcement in `tick_steps/handle_command.rs::guarded` reads the live formation's `constraints.guard_mode` — which `spawn_formation` set from `FormationConstraints::default()` (always false) and never refreshed. Engaging guard therefore protected nothing, redeploy included. The toggle now posts `FormationCommand::SetGuard` on the same channel the other operator verbs ride, spawn seeds the live flag from the config row, and every reader of that row goes through the new `config::formation_guard_engaged`, so row and live constraint cannot disagree. The `Recruit` arm's inline guard check now goes through `guarded` like the rest. 2. The dissolve outcome reported `failure_count: 0` on both the gossip `FormationOutcome` and the durable `OutcomeNote`, which read as "nothing ever failed here" and skewed the retrieval scorer's `success / (success + failure)` ratio. The real lifetime count is the momentum FSM's `interference_total` (`record_interference` bumps it and never resets it, unlike the per-run `interference_count` and `consecutive_successes`), now reported via `dissolve_failure_count`. 3. The autonomy pip colours in `DetailPanel` still had five entries after `AUTONOMY_LABELS` was cut to four; the fifth outlived the SELF-DIRECT level. Extracted to `AUTONOMY_COLORS` in `colony/types.ts` with one entry per level. 4. Split the 1265-line `dispatch.rs` into `dispatch/` — `mod.rs` (entry point + re-exports), `entry.rs`, `step.rs`, `connector.rs`, `chain.rs`, `extract.rs`. A move, not a rewrite: `dispatch::dispatch_action` and `dispatch::dispatch_actions` stay importable at the same paths. `docs/guide/formations.md` no longer describes defects 1 and 2 as known. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/cooperation/lifecycle.rs | 15 +- .../src/runtime/tick_steps/handle_command.rs | 103 +- crates/springtale-cooperation/src/command.rs | 11 + crates/springtale-runtime/src/dispatch.rs | 1265 ----------------- .../springtale-runtime/src/dispatch/chain.rs | 58 + .../src/dispatch/connector.rs | 235 +++ .../springtale-runtime/src/dispatch/entry.rs | 477 +++++++ .../src/dispatch/extract.rs | 51 + crates/springtale-runtime/src/dispatch/mod.rs | 53 + .../springtale-runtime/src/dispatch/step.rs | 515 +++++++ .../src/operations/config.rs | 52 +- .../src/operations/formations.rs | 33 +- docs/guide/formations.md | 22 +- .../ui/src/colony/panel/DetailPanel.tsx | 13 +- tauri/packages/ui/src/colony/types.ts | 8 + 15 files changed, 1602 insertions(+), 1309 deletions(-) delete mode 100644 crates/springtale-runtime/src/dispatch.rs create mode 100644 crates/springtale-runtime/src/dispatch/chain.rs create mode 100644 crates/springtale-runtime/src/dispatch/connector.rs create mode 100644 crates/springtale-runtime/src/dispatch/entry.rs create mode 100644 crates/springtale-runtime/src/dispatch/extract.rs create mode 100644 crates/springtale-runtime/src/dispatch/mod.rs create mode 100644 crates/springtale-runtime/src/dispatch/step.rs diff --git a/crates/springtale-bot/src/cooperation/lifecycle.rs b/crates/springtale-bot/src/cooperation/lifecycle.rs index 74d95273..0dd7e259 100644 --- a/crates/springtale-bot/src/cooperation/lifecycle.rs +++ b/crates/springtale-bot/src/cooperation/lifecycle.rs @@ -112,6 +112,19 @@ pub async fn spawn_formation( // Parse intent from stored string let intent = springtale_cooperation::command::parse_intent(&row.intent); + // Guard mode is durable in the `guard:{formation_id}` config row; the live + // formation carries it in `constraints.guard_mode`. Seed it here so a + // formation that was guarded before a restart comes back guarded — the + // toggle keeps the two in step afterward via `FormationCommand::SetGuard`. + let constraints = FormationConstraints { + guard_mode: springtale_runtime::operations::config::formation_guard_engaged( + &**store, + formation_id, + ) + .await, + ..FormationConstraints::default() + }; + let deps = FormationDeps { cadence: cadence.clone(), store: store.clone(), @@ -120,7 +133,7 @@ pub async fn spawn_formation( formation_gossip: formation_gossip.cloned(), }; let (mut formation, proto_dispatch, ack_dispatch) = - Formation::new(members, intent, FormationConstraints::default(), deps); + Formation::new(members, intent, constraints, deps); // Override the auto-generated ID with the stored one if let Ok(uuid) = uuid::Uuid::parse_str(&row.id) { diff --git a/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs b/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs index eed7ae24..cecfaf98 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs @@ -33,6 +33,34 @@ fn guarded(formation: &Formation, verb: &str) -> bool { } } +/// Engage or disengage guard mode on a live formation and republish the +/// formation context so members see the new constraint on their next read. +/// +/// The `guard:{formation_id}` config row is the durable copy (read back into +/// `constraints.guard_mode` at deploy); this is the live copy that +/// [`guarded`] enforces. `operations::config::toggle_formation_guard` writes +/// the row and posts `FormationCommand::SetGuard` together, so engaging guard +/// protects the formation immediately rather than at the next redeploy. +fn set_guard(formation: &mut Formation, engaged: bool) { + formation.constraints.guard_mode = engaged; + formation.broadcast_context(); +} + +/// Failures a formation recorded over its whole life, for the dissolve +/// outcome that gossip and the knowledge store publish. +/// +/// The momentum FSM is the only place that counts a formation's failures: +/// `record_interference` bumps `interference_total` on every interference and +/// never resets it, while `interference_count` and `consecutive_successes` are +/// per-clean-run and reset on every break (Patapon combo). A dissolve is a +/// lifetime summary, so `interference_total` is the count that matches +/// `success_count`'s question — "how did this formation do" — and a hardcoded +/// zero read as "nothing ever failed here", which skewed the retrieval +/// scorer's success/total ratio in `cooperation::lifecycle`. +fn dissolve_failure_count(momentum: &springtale_cooperation::momentum::MomentumState) -> u32 { + momentum.interference_total +} + pub async fn handle_formation_command(bot: &mut Bot, cmd: FormationCommand) { match cmd { FormationCommand::Deploy { formation_id } => { @@ -85,7 +113,7 @@ pub async fn handle_formation_command(bot: &mut Bot, cmd: FormationCommand) { formation_id: f.id, final_intent: f.intent.clone(), success_count: f.momentum.consecutive_successes, - failure_count: 0, + failure_count: dissolve_failure_count(&f.momentum), dissolve_reason: reason.clone(), at: chrono::Utc::now(), }; @@ -110,7 +138,7 @@ pub async fn handle_formation_command(bot: &mut Bot, cmd: FormationCommand) { peak_tier: f.momentum.tier, connectors, success_count: f.momentum.consecutive_successes, - failure_count: 0, + failure_count: dissolve_failure_count(&f.momentum), dissolve_reason: reason.clone(), at: chrono::Utc::now(), }; @@ -136,6 +164,18 @@ pub async fn handle_formation_command(bot: &mut Bot, cmd: FormationCommand) { tracing::warn!(id = %formation_id, "formation not found for pause"); } } + FormationCommand::SetGuard { + formation_id, + engaged, + } => { + let mut formations = bot.formations.write().await; + if let Some(formation) = formations.iter_mut().find(|f| f.id == formation_id) { + set_guard(formation, engaged); + tracing::info!(id = %formation_id, engaged, "formation guard mode set"); + } else { + tracing::warn!(id = %formation_id, "formation not found for SetGuard"); + } + } FormationCommand::Resume { formation_id } => { let mut formations = bot.formations.write().await; if let Some(formation) = formations.iter_mut().find(|f| f.id == formation_id) { @@ -271,8 +311,8 @@ pub async fn handle_formation_command(bot: &mut Bot, cmd: FormationCommand) { tier = ?formation.momentum.tier, "recruit denied — formation has not earned Fever tier" ); - } else if formation.constraints.guard_mode { - tracing::info!(id = %formation_id, "recruit denied — guard mode engaged"); + } else if guarded(formation, "recruit") { + // `guarded` already logged the denial. } else { let member = crate::cooperation::formation::FormationMember::from_strings( AgentId::new(), @@ -418,6 +458,61 @@ mod tests { crate::cooperation::formation::FormationMember::new(id, vec!["connector-telegram".into()]) } + /// Toggling guard on a *live* formation blocks a guarded verb straight + /// away. The guard flag used to be read only at deploy, so engaging it + /// left the running formation unprotected until a redeploy; + /// `FormationCommand::SetGuard` (posted by `toggle_formation_guard`) lands + /// here instead. + #[test] + fn test_set_guard_blocks_guarded_verbs_without_redeploy() { + use springtale_cooperation::cadence::IntentPattern; + use springtale_cooperation::types::FormationConstraints; + + // Deployed with guard off — the default every spawn used to get. + let mut formation = crate::cooperation::formation::Formation::new_disconnected( + vec![member(AgentId::new())], + IntentPattern::Stabilize { + reason: "test".into(), + }, + FormationConstraints::default(), + ); + assert!(!formation.constraints.guard_mode); + for verb in ["dissolve", "intent", "remove_member", "rally", "recruit"] { + assert!(!guarded(&formation, verb), "{verb} blocked before toggle"); + } + + set_guard(&mut formation, true); + + assert!(formation.constraints.guard_mode); + for verb in ["dissolve", "intent", "remove_member", "rally", "recruit"] { + assert!(guarded(&formation, verb), "{verb} not blocked under guard"); + } + + set_guard(&mut formation, false); + assert!(!guarded(&formation, "dissolve")); + } + + /// The dissolve outcome reports the failures the formation actually + /// recorded, not a hardcoded zero. + #[test] + fn test_dissolve_failure_count_matches_recorded_interferences() { + use springtale_cooperation::momentum::MomentumState; + + let mut momentum = MomentumState::default(); + assert_eq!(dissolve_failure_count(&momentum), 0); + + momentum.record_interference(); + momentum.record_success(); + momentum.record_interference(); + momentum.record_interference(); + + // Three interferences over the formation's life; the per-run counter + // reset behind each one, which is why it cannot be the source. + assert_eq!(momentum.interference_total, 3); + assert_eq!(momentum.interference_count, 0); + assert_eq!(dissolve_failure_count(&momentum), 3); + } + /// A rally relieves the member carrying the most attention. Picking the /// least-loaded member, as this path once did, releases load that was /// never there. diff --git a/crates/springtale-cooperation/src/command.rs b/crates/springtale-cooperation/src/command.rs index abcbd81e..a2cf0d15 100644 --- a/crates/springtale-cooperation/src/command.rs +++ b/crates/springtale-cooperation/src/command.rs @@ -15,6 +15,17 @@ pub enum FormationCommand { Pause { formation_id: FormationId }, /// Resume a paused formation. Resume { formation_id: FormationId }, + /// Engage or disengage guard mode on a live formation. + /// + /// Guard mode lives in two places: the `guard:{formation_id}` config row + /// (durable, read back at deploy into `constraints.guard_mode`) and the + /// live `Formation` the bot ticks. `operations::config::toggle_formation_guard` + /// writes the row and posts this command in the same call, so the two can + /// never disagree and the toggle takes effect without a redeploy. + SetGuard { + formation_id: FormationId, + engaged: bool, + }, /// Dissolve a formation and remove from memory. Dissolve { formation_id: FormationId, diff --git a/crates/springtale-runtime/src/dispatch.rs b/crates/springtale-runtime/src/dispatch.rs deleted file mode 100644 index b53f6b38..00000000 --- a/crates/springtale-runtime/src/dispatch.rs +++ /dev/null @@ -1,1265 +0,0 @@ -//! Shared rule-action dispatcher — executes matched rule actions. -//! -//! Both springtaled (daemon) and springtale-bot route action dispatch -//! through this module. Per ARCHITECTURE.md §6.10, this is the single -//! enforcement point that calls `sentinel.evaluate()` before every -//! action. -//! -//! ## Phase 0 rework (chain context + real I/O) -//! -//! Pre-Phase-0 this module returned `Result` and -//! discarded step outputs between iterations. `Action::AiComplete` -//! was a stub returning `"ai: noop"`. `Action::RunConnector` captured -//! only the connector's plain-text `message` and dropped its -//! structured `output: Value`. The net effect: ~12 shipped builtin -//! recipes referenced `${last_ai_output}` / `${last_connector_output}` -//! in their TOML, but the dispatcher had no path that resolved those -//! placeholders — users received literal `${last_ai_output}` strings -//! in their messaging channels. -//! -//! The new shape closes all four gaps: -//! -//! 1. Return type is `Result` — -//! `ChainContext` carries every step's typed `output`, the -//! `last_*_output` aliases, and the trigger payload. Callers read -//! it to surface results or persist to the executions log. -//! 2. Before each step runs, action parameters are template-resolved -//! against the chain via [`resolve_chain_value`] — `${trigger.x}`, -//! `${last_ai_output}`, `${stepN.field}`, `${step.NAME.field}` all -//! bind to live values. -//! 3. `RunConnector` captures the connector's `ActionResult.output` -//! (the structured JSON) into `StepOutput.output`, not just the -//! human message. -//! 4. `AiComplete` calls the real adapter via -//! [`CapabilityBridge::ai_adapter_for`] — falls back to -//! `NoopAdapter` (clean error, not silent stubbing) when no -//! adapter is wired. -//! -//! Cooperation alignment: every dispatch carries an -//! [`ExecutionContext`] from `springtale-cooperation::execution`, so -//! the runtime knows which agent in which formation at which momentum -//! tier is firing. The bridge consults the tier for capability -//! routing (per-tier WASM `InstancePre` selection, §16). The sentinel -//! consults the tier for rate-budget scaling: Cold = 1/30s, Warming = -//! 12/min, Hot = 60/min, Fever = 600/min — the Phase 0.5 mapping in -//! [`crate::cooperation::momentum_to_throttle_tier`]. - -use std::sync::Arc; - -use springtale_ai::{AiOptions, AiRequest}; -use springtale_cooperation::execution::ExecutionContext; -use springtale_core::rule::action::Action; -use springtale_core::rule::template_resolve::{resolve_chain_template, resolve_chain_value}; -use springtale_core::rule::{ChainContext, ChainError, StepOutput}; -use springtale_sentinel::impact::ActionHints; -use springtale_sentinel::sentinel::EvaluateRequest; -use springtale_sentinel::{Sentinel, Verdict}; - -use crate::cooperation::{CapabilityBridge, momentum_to_throttle_tier, momentum_to_wasm_tier}; - -/// Maximum size for WriteFile action content (10 MiB). -const MAX_WRITE_FILE_BYTES: usize = 10 * 1024 * 1024; - -/// Dispatch one top-level rule action with full chain-context -/// threading. Returns the final [`ChainContext`] containing every -/// recorded [`StepOutput`]. -/// -/// `trigger_payload` is the JSON the trigger fired with — referenced -/// by recipe templates as `${trigger.path}`. Cron triggers pass -/// `Value::Null`. Webhook / connector-event triggers pass the -/// inbound payload. -pub fn dispatch_action<'a>( - action: &'a Action, - bridge: &'a CapabilityBridge, - sentinel: &'a Arc, - execution: ExecutionContext, - trigger_payload: serde_json::Value, -) -> std::pin::Pin< - Box> + Send + 'a>, -> { - dispatch_actions( - std::slice::from_ref(action), - bridge, - sentinel, - execution, - trigger_payload, - ) -} - -/// Dispatch a sequence of top-level actions as a single chain fire. -/// Used by `trigger_dispatch` when a [`RuleMatch::actions`] holds -/// `Vec` — each action becomes a step in the shared -/// `ChainContext`, so `${last_*_output}` and `${stepN.*}` resolve -/// across the whole rule. -pub fn dispatch_actions<'a>( - actions: &'a [Action], - bridge: &'a CapabilityBridge, - sentinel: &'a Arc, - execution: ExecutionContext, - trigger_payload: serde_json::Value, -) -> std::pin::Pin< - Box> + Send + 'a>, -> { - Box::pin(async move { - let recorder = bridge.recorder(); - let trigger_summary = summarize_trigger(&trigger_payload, execution.mode); - // Best-effort recorder.begin — failures fall through to - // dispatch so the chain still runs. The privacy invariant - // is about NOT writing content; missing rows are fine. - if let Err(e) = recorder.begin(&execution, &trigger_summary, None).await { - tracing::warn!(error = %e, "executions recorder.begin failed"); - } - - let execution_id = execution.execution_id.to_string(); - let mut chain = ChainContext::new(trigger_payload); - let mut steps_emitted = 0usize; - let mut final_status = springtale_store::schema::executions::ExecutionStatus::Succeeded; - let mut error_kind: Option<&'static str> = None; - let mut chain_outcome: Result<(), ChainError> = Ok(()); - - for action in actions { - match run_step(action, bridge, sentinel, &execution, &mut chain, 0).await { - Ok(()) => { - // Record any new steps the action appended to - // chain.steps. Chain action expands into - // multiple sub-steps so we drain everything past - // `steps_emitted`. - while steps_emitted < chain.steps.len() { - let step = &chain.steps[steps_emitted]; - if let Err(e) = recorder.record_step(&execution_id, step).await { - tracing::warn!(error = %e, "executions recorder.record_step failed"); - } - steps_emitted += 1; - } - } - Err(ChainError::Suppressed) => { - // Flush any steps that did run (dedupe step itself). - while steps_emitted < chain.steps.len() { - let step = &chain.steps[steps_emitted]; - if let Err(e) = recorder.record_step(&execution_id, step).await { - tracing::warn!(error = %e, "executions recorder.record_step failed"); - } - steps_emitted += 1; - } - final_status = springtale_store::schema::executions::ExecutionStatus::Empty; - chain_outcome = Ok(()); - break; - } - Err(e) => { - while steps_emitted < chain.steps.len() { - let step = &chain.steps[steps_emitted]; - if let Err(rec_err) = recorder.record_step(&execution_id, step).await { - tracing::warn!(error = %rec_err, "executions recorder.record_step failed"); - } - steps_emitted += 1; - } - final_status = springtale_store::schema::executions::ExecutionStatus::Failed; - error_kind = Some(classify_chain_error(&e)); - chain_outcome = Err(e); - break; - } - } - } - - if let Err(e) = recorder - .finish(&execution_id, final_status, error_kind) - .await - { - tracing::warn!(error = %e, "executions recorder.finish failed"); - } - - match chain_outcome { - Ok(()) => Ok(chain), - Err(e) => Err(e), - } - }) -} - -/// Build a short summary string the executions log records for the -/// firing trigger. Sized for a status line — no payload, just the -/// kind + the obvious discriminator. -fn summarize_trigger( - trigger: &serde_json::Value, - mode: springtale_cooperation::execution::ExecutionMode, -) -> String { - use springtale_cooperation::execution::ExecutionMode as M; - match mode { - M::Cron => trigger - .get("expression") - .and_then(|v| v.as_str()) - .map(|e| format!("Cron {e}")) - .unwrap_or_else(|| "Cron".to_owned()), - M::Webhook => "Webhook".to_owned(), - M::ConnectorEvent => trigger - .get("trigger_name") - .and_then(|v| v.as_str()) - .map(|t| format!("Event {t}")) - .unwrap_or_else(|| "ConnectorEvent".to_owned()), - M::FileWatch => "FileWatch".to_owned(), - M::Manual => "Manual".to_owned(), - M::Cooperation => "Cooperation".to_owned(), - M::Retry => "Retry".to_owned(), - M::DryRun => "DryRun".to_owned(), - } -} - -/// Map a chain error to the same enum-tag set the recorder writes -/// for step errors. Keeps the audit trail consistent — privacy -/// invariant says no full messages reach the DB. -fn classify_chain_error(err: &ChainError) -> &'static str { - match err { - ChainError::Suppressed => "suppressed", - ChainError::StepNotYetRun(_) => "template_step_unresolved", - ChainError::StepNameNotFound(_) => "template_name_unresolved", - ChainError::DuplicateStepName(_) => "template_duplicate_name", - ChainError::DepthExceeded { .. } => "chain_depth_exceeded", - ChainError::StepFailed { .. } => "step_failed", - ChainError::Template(_) => "template_invalid", - } -} - -/// Run one action against the chain. Recursive — `Action::Chain` -/// expands into multiple sub-steps that all share the chain context. -fn run_step<'a>( - action: &'a Action, - bridge: &'a CapabilityBridge, - sentinel: &'a Arc, - execution: &'a ExecutionContext, - chain: &'a mut ChainContext, - depth: u32, -) -> std::pin::Pin> + Send + 'a>> { - Box::pin(run_step_inner( - action, bridge, sentinel, execution, chain, depth, - )) -} - -async fn run_step_inner( - action: &Action, - bridge: &CapabilityBridge, - sentinel: &Arc, - execution: &ExecutionContext, - chain: &mut ChainContext, - depth: u32, -) -> Result<(), ChainError> { - let run_id = execution.execution_id.to_string(); - - // ── Sentinel ───────────────────────────────────────────────── - // Throw the (unresolved) action at sentinel for connector-name - // routing only — the resolver below produces the executable - // version. Sentinel doesn't read action parameters; it reads the - // connector name, the manifest's advisory hints for the named - // action, and the envelope's policy / autonomy. - let connector_name = match action { - Action::RunConnector { connector, .. } => connector.as_str(), - _ => "system", - }; - let throttle_tier = momentum_to_throttle_tier(execution.momentum); - let hints = if let Action::RunConnector { - connector, - action: name, - .. - } = action - { - let reg = bridge.registry().read().await; - reg.get(connector) - .and_then(|e| e.host.actions().iter().find(|d| d.name == *name).cloned()) - .map(|d| ActionHints { - read_only: d.read_only, - destructive: d.destructive, - }) - } else { - None - }; - let action_name = if let Action::RunConnector { action: name, .. } = action { - Some(name.as_str()) - } else { - None - }; - let verdict = sentinel - .evaluate(EvaluateRequest { - action, - connector_name, - tier: throttle_tier, - hints, - action_name, - policy: execution.policy, - autonomy: execution.autonomy, - origin: execution.origin.as_ref(), - }) - .await; - match verdict { - Verdict::Go => {} - Verdict::Throttle(duration) => { - tracing::info!( - connector = connector_name, - delay_ms = duration.as_millis() as u64, - "sentinel: throttling action" - ); - chain.throttles += 1; - tokio::time::sleep(duration).await; - } - Verdict::Pause(reason) => { - return Err(ChainError::StepFailed { - index: chain.next_step_index(), - kind: action_kind(action).into(), - message: format!("sentinel paused: {reason}"), - }); - } - Verdict::Quarantine(reason) => { - return Err(ChainError::StepFailed { - index: chain.next_step_index(), - kind: action_kind(action).into(), - message: format!("sentinel quarantined: {reason}"), - }); - } - } - - let kind = action_kind(action); - let started = std::time::Instant::now(); - let dry_run = matches!( - execution.mode, - springtale_cooperation::execution::ExecutionMode::DryRun - ); - - // ── Action arm dispatch ───────────────────────────────────── - let outcome: Result = match action { - Action::RunConnector { - connector, - action: action_name, - params, - } => { - // Resolve `${trigger.*}` / `${last_*_output.*}` / - // `${stepN.*}` in every param string before handing to - // the connector. - let raw = serde_json::Value::Object(params.clone()); - let resolved = resolve_chain_value(&raw, chain, Some(&run_id)); - let input = resolved; - - // Dry-run stubs side-effecting connector actions but - // lets read-only actions (HTTP get, browser navigate, - // extract_text, etc.) run for real — that's the whole - // point of "Test This Step": fetch real upstream data - // to validate downstream rendering without spamming - // the destination channel. - if dry_run && is_side_effecting_action(action_name) { - tracing::info!( - connector = %connector, - action = %action_name, - "DRY RUN — side-effecting connector action stubbed" - ); - let step = StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ - "success": true, - "message": format!( - "dry-run: would call {connector}.{action_name}" - ), - "output": { - "connector": connector, - "action": action_name, - "params": input, - }, - "dry_run": true, - }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }; - chain.record_step(step); - sentinel.report_success(connector_name); - return Ok(()); - } - - let effective_tier = momentum_to_wasm_tier(execution.momentum); - let exec = bridge - .execute_with_origin( - connector, - action_name, - input, - effective_tier, - execution.origin.clone(), - ) - .await; - match exec { - Ok(result) => { - tracing::info!( - connector = %connector, - action = %action_name, - success = result.success, - "connector action executed" - ); - let index = chain.next_step_index(); - // Capture both the structured output AND the - // human message so downstream templates can read - // either. `output` keys exposed in the chain - // alias: `last_connector_output.output.*` is the - // structured data, `last_connector_output.message` - // is the plain-text result. - let payload = serde_json::json!({ - "success": result.success, - "message": result.message, - "output": result.output, - }); - Ok(StepOutput { - index, - kind: kind.into(), - name: None, - output: payload, - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - Err(e) => { - tracing::warn!( - connector = %connector, - action = %action_name, - error = %e, - "connector action failed" - ); - Err(ChainError::StepFailed { - index: chain.next_step_index(), - kind: kind.into(), - message: e.to_string(), - }) - } - } - } - - Action::Notify { title, body } => { - let resolved_title = resolve_chain_template(title, chain, Some(&run_id)); - let resolved_body = resolve_chain_template(body, chain, Some(&run_id)); - if dry_run { - tracing::info!( - title = %resolved_title, - "DRY RUN — Notify stubbed" - ); - } else { - tracing::info!(title = %resolved_title, body = %resolved_body, "NOTIFICATION"); - } - Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ - "title": resolved_title, - "body": resolved_body, - "dry_run": dry_run, - }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - - Action::SendMessage { text } => { - let resolved = resolve_chain_template(text, chain, Some(&run_id)); - if dry_run { - tracing::info!(text_len = resolved.len(), "DRY RUN — SendMessage stubbed"); - } else { - tracing::info!(text = %resolved, "SendMessage (no destination context)"); - } - Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ - "text": resolved, - "dry_run": dry_run, - }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - - Action::WriteFile { - destination, - content, - delete_source: _, - } => { - let resolved_destination = resolve_chain_template(destination, chain, Some(&run_id)); - let resolved_content = resolve_chain_template(content, chain, Some(&run_id)); - - if resolved_content.len() > MAX_WRITE_FILE_BYTES { - return Err(ChainError::StepFailed { - index: chain.next_step_index(), - kind: kind.into(), - message: format!( - "file content size ({} bytes) exceeds maximum ({MAX_WRITE_FILE_BYTES} bytes)", - resolved_content.len() - ), - }); - } - let path = std::path::Path::new(&resolved_destination); - if !path.is_absolute() { - return Err(ChainError::StepFailed { - index: chain.next_step_index(), - kind: kind.into(), - message: "WriteFile requires absolute path".to_string(), - }); - } - if path - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { - return Err(ChainError::StepFailed { - index: chain.next_step_index(), - kind: kind.into(), - message: "WriteFile path must not contain '..'".to_string(), - }); - } - if dry_run { - tracing::info!( - path = %resolved_destination, - bytes = resolved_content.len(), - "DRY RUN — WriteFile stubbed" - ); - } else { - tokio::fs::write(&resolved_destination, &resolved_content) - .await - .map_err(|e| ChainError::StepFailed { - index: chain.next_step_index(), - kind: kind.into(), - message: format!("failed to write file {resolved_destination}: {e}"), - })?; - tracing::info!(path = %resolved_destination, "file written"); - } - Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ - "path": resolved_destination, - "bytes": resolved_content.len(), - "dry_run": dry_run, - }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - - Action::RunShell { command } => { - let resolved = resolve_chain_template(command, chain, Some(&run_id)); - // ShellExec requires capability approval flow — actual - // execution is gated outside the dispatcher. The - // dispatcher records the request so the capability layer - // and audit trail see it. - tracing::info!( - command = %resolved, - "SHELL (not executed — requires ShellExec approval)" - ); - Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ - "command": resolved, - "executed": false, - "reason": "ShellExec capability gate", - }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - - Action::Delay { seconds } => { - if dry_run { - tracing::info!(seconds = seconds, "DRY RUN — Delay stubbed"); - } else { - tokio::time::sleep(std::time::Duration::from_secs(*seconds)).await; - tracing::debug!(seconds = seconds, "delay completed"); - } - Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ "seconds": seconds }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - - Action::Chain { steps } => { - let new_depth = depth + 1; - if new_depth > springtale_core::rule::action::MAX_CHAIN_DEPTH { - return Err(ChainError::DepthExceeded { - depth: new_depth, - max: springtale_core::rule::action::MAX_CHAIN_DEPTH, - }); - } - // Chain expands transparently — each sub-step is recorded - // as its own StepOutput in the shared ChainContext. The - // Chain action itself doesn't produce a wrapper step. - for (i, step) in steps.iter().enumerate() { - match run_step(step, bridge, sentinel, execution, chain, new_depth).await { - Ok(()) => {} - Err(ChainError::Suppressed) => { - // A nested dedupe step suppressed the chain — - // propagate cleanly so the outer caller can - // mark execution status `empty`. - return Err(ChainError::Suppressed); - } - Err(e) => { - tracing::warn!(step = i, error = %e, "chain step failed"); - return Err(e); - } - } - } - // Chain returns without recording its own StepOutput — - // sub-steps are already in chain.steps. - // - // Skip the post-step alias refresh path below: we already - // returned the sub-steps individually. - sentinel.report_success(connector_name); - return Ok(()); - } - - Action::Transform { operation, params } => { - // Transform is a placeholder today — operation-specific - // implementations land in Phase A (the extraction ladder - // replaces most Transform use cases). For now we record - // the transform request so the chain context surfaces it. - tracing::debug!(operation = %operation, "transform pass-through"); - Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ - "operation": operation, - "params": params, - }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - - Action::AiComplete { prompt, .. } => { - // Resolve `${...}` placeholders in the prompt before the - // model sees it. Critical: this is how `${last_connector_output}` - // ends up in the prompt for "summarize this fetched - // body" recipes. - // - // OWASP LLM01:2025 indirect-injection guard: every - // substituted value is wrapped in `` tags - // by the AI-specific resolver, and the rule explaining the - // tags is prepended to the system prompt. The model - // therefore sees both (a) explicit instructions that the - // tagged content is untrusted data, and (b) the tagged - // values themselves. - let resolved_user_prompt = - springtale_core::rule::template_resolve::resolve_chain_template_for_ai( - prompt, - chain, - Some(&run_id), - ); - let resolved_prompt = format!( - "{rule}\n\n{prompt}", - rule = springtale_core::rule::template_resolve::AI_EXTERNAL_CONTEXT_RULE, - prompt = resolved_user_prompt, - ); - - // Route through the bridge — falls back to NoopAdapter - // when no adapter is wired. NoopAdapter returns - // `AiError::Disabled`, which we surface as a step error - // (not a silent stub). - let adapter_arc = bridge.ai_adapter_for(execution).await; - let request = AiRequest::Complete { - prompt: resolved_prompt.clone(), - }; - let options = AiOptions::default(); - let response = adapter_arc.complete(request, options).await; - match response { - Ok(ai_response) => { - tracing::debug!( - prompt_len = resolved_prompt.len(), - content_len = ai_response.content.len(), - finish_reason = ?ai_response.finish_reason, - "AI complete" - ); - Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ - "text": ai_response.content, - "finish_reason": ai_response.finish_reason, - }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - Err(e) => { - tracing::warn!(error = %e, "AI complete failed"); - Err(ChainError::StepFailed { - index: chain.next_step_index(), - kind: kind.into(), - message: e.to_string(), - }) - } - } - } - - Action::Extract { - source, - kind: extract_kind, - } => { - // Resolve `source` as a path against the chain — e.g. - // `"last_connector_output.body"` → the HTTP body string, - // or `"trigger.payload"` → the trigger event JSON. - let resolved_source = resolve_chain_value( - &serde_json::Value::String(format!("${{{source}}}")), - chain, - Some(&run_id), - ); - - // The AI adapter for LlmSchema extraction. We pass it - // through opt-in — Phase A only fires non-LLM tiers; - // Phase B activates LlmSchema and the adapter is read. - let adapter_arc = bridge.ai_adapter_for(execution).await; - let ai_ref: Option<&dyn springtale_ai::AiAdapter> = Some(&*adapter_arc); - - let extracted = - crate::extraction::extract(&resolved_source, extract_kind, ai_ref).await; - match extracted { - Ok(value) => Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: value, - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }), - Err(e) => Err(ChainError::StepFailed { - index: chain.next_step_index(), - kind: kind.into(), - message: e.to_string(), - }), - } - } - - Action::Dedupe { - key, - bucket, - history, - } => { - // Resolve key + bucket templates against the chain. - let resolved_key = resolve_chain_template(key, chain, Some(&run_id)); - let resolved_bucket = resolve_chain_template(bucket, chain, Some(&run_id)); - - // Bridge holds the store handle. Test builds without a - // store wired fall through to "fresh" (the default impl - // on the StorageBackend trait) so dispatcher tests don't - // need a real DB just to exercise non-dedupe arms. - let formation_id = execution.formation_id.map(|f| f.0.to_string()); - let rule_id = execution.rule_id.0.to_string(); - - // Dry-run: never write to the dedupe table. We want - // Test This Step to render the downstream steps as - // if the data were fresh — without polluting the - // real dedupe state for the next production fire. - let outcome = if dry_run { - springtale_store::schema::dedupe::DedupeOutcome::Fresh - } else { - match bridge.store() { - Some(store) => crate::dedupe::check_and_record( - store, - formation_id.as_deref(), - &rule_id, - &resolved_bucket, - &resolved_key, - *history, - ) - .await - .map_err(|e| ChainError::StepFailed { - index: chain.next_step_index(), - kind: kind.into(), - message: e.to_string(), - })?, - None => springtale_store::schema::dedupe::DedupeOutcome::Fresh, - } - }; - - // SeenBefore short-circuits the chain. The Chain runner - // arm above catches `ChainError::Suppressed` and ends - // the execution cleanly with status `empty`. - if matches!( - outcome, - springtale_store::schema::dedupe::DedupeOutcome::SeenBefore - ) { - tracing::info!( - rule = %rule_id, - bucket = %resolved_bucket, - "dedupe: key seen before — chain suppressed" - ); - return Err(ChainError::Suppressed); - } - - Ok(StepOutput { - index: chain.next_step_index(), - kind: kind.into(), - name: None, - output: serde_json::json!({ - "outcome": "fresh", - "bucket": resolved_bucket, - "dry_run": dry_run, - }), - duration_ms: started.elapsed().as_millis() as u64, - error: None, - }) - } - }; - - // ── Record outcome + sentinel report ──────────────────────── - match outcome { - Ok(step) => { - chain.record_step(step); - sentinel.report_success(connector_name); - Ok(()) - } - Err(e) => { - sentinel.report_failure(connector_name); - Err(e) - } - } -} - -/// Stable kind tag the dispatcher writes into [`StepOutput::kind`]. -/// Mirrors the [`Action`] variant discriminant so chain-context -/// readers can filter by kind without re-matching the original -/// variant. -fn action_kind(action: &Action) -> &'static str { - match action { - Action::RunConnector { .. } => "run_connector", - Action::SendMessage { .. } => "send_message", - Action::WriteFile { .. } => "write_file", - Action::RunShell { .. } => "run_shell", - Action::Notify { .. } => "notify", - Action::Chain { .. } => "chain", - Action::Transform { .. } => "transform", - Action::Delay { .. } => "delay", - Action::AiComplete { .. } => "ai_complete", - Action::Extract { .. } => "extract", - Action::Dedupe { .. } => "dedupe", - } -} - -/// Classify a connector action name as side-effecting. Used by the -/// DryRun dispatcher path: side-effecting actions are stubbed -/// (return a "would have done X" StepOutput); read-only actions -/// run for real so Test This Step shows realistic upstream data. -/// -/// The heuristic is verb-prefix based — connector authors who add -/// new write actions just need to use a recognizable prefix. -/// First-party connectors today: `send_message`, `post_*`, -/// `write_*`, `create_*`, `delete_*`, `update_*`, `publish_*`, -/// `commit_*`, `push_*`, `react`, `dispatch`, `react_to_message`, -/// `set_*` (config writes). Read-side actions use `get_*`, `list_*`, -/// `read_*`, `fetch_*`, `search_*`, `query_*`, `wait_*`, plus the -/// browser primitives `navigate`, `evaluate`, `screenshot`, -/// `extract_text`, `get_html`, `query_all`, `fill_form`, `click`. -/// -/// `fill_form` + `click` are ambiguous — they mutate page state but -/// don't reach external systems. We classify them as read-only -/// (false) so chained recipes like "navigate → fill_form → click → -/// extract_text" produce useful Test This Step output. Connectors -/// that ship truly destructive actions under those names should -/// rename them. -fn is_side_effecting_action(name: &str) -> bool { - const WRITE_PREFIXES: &[&str] = &[ - "send_", - "post_", - "write_", - "create_", - "delete_", - "remove_", - "update_", - "publish_", - "commit_", - "push_", - "dispatch_", - "set_", - "ban_", - "kick_", - "mute_", - "broadcast_", - "react_", - "reply_", - "subscribe_", - "unsubscribe_", - "approve_", - "deny_", - ]; - const WRITE_EXACT: &[&str] = &[ - "send", - "post", - "write", - "publish", - "commit", - "react", - "react_to_message", - "dispatch", - "ban", - "kick", - "mute", - ]; - if WRITE_EXACT.contains(&name) { - return true; - } - WRITE_PREFIXES.iter().any(|p| name.starts_with(p)) -} - -#[cfg(test)] -mod side_effect_tests { - use super::is_side_effecting_action; - - #[test] - fn send_message_is_side_effecting() { - assert!(is_side_effecting_action("send_message")); - } - - #[test] - fn get_is_read_only() { - assert!(!is_side_effecting_action("get")); - assert!(!is_side_effecting_action("get_html")); - assert!(!is_side_effecting_action("list_repos")); - } - - #[test] - fn browser_navigation_is_read_only() { - assert!(!is_side_effecting_action("navigate")); - assert!(!is_side_effecting_action("evaluate")); - assert!(!is_side_effecting_action("screenshot")); - assert!(!is_side_effecting_action("query_all")); - assert!(!is_side_effecting_action("wait_for_selector")); - assert!(!is_side_effecting_action("extract_text")); - } - - #[test] - fn write_prefixes_are_side_effecting() { - for name in [ - "post_status", - "write_file", - "create_issue", - "delete_message", - "update_repo", - "publish_release", - "commit_change", - "push_branch", - "set_config", - "ban_user", - "kick_member", - "mute_user", - ] { - assert!( - is_side_effecting_action(name), - "expected {name} to be side-effecting" - ); - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - use springtale_cooperation::execution::{ - ExecutionContext as CoopExecutionContext, ExecutionMode as CoopExecutionMode, - }; - use springtale_store::SqliteBackend; - use springtale_store::backend::StorageBackend; - use springtale_store::schema::executions::ExecutionFilter; - use std::sync::Arc; - - /// Build a bridge wired against an in-memory SqliteBackend with - /// a real StoreRecorder — used by tests that assert on - /// executions-log rows after a chain runs. - fn bridge_with_recorded_store() -> (CapabilityBridge, Arc) { - let store: Arc = Arc::new(SqliteBackend::open_in_memory().unwrap()); - let recorder: Arc = Arc::new( - crate::operations::executions::StoreRecorder::new(store.clone()), - ); - let registry = Arc::new(tokio::sync::RwLock::new( - springtale_connector::registry::store::ConnectorRegistry::default(), - )); - let bridge = CapabilityBridge::new(registry) - .with_store(store.clone()) - .with_recorder(recorder); - (bridge, store) - } - - fn manual_execution_ctx() -> CoopExecutionContext { - CoopExecutionContext::for_global( - springtale_core::rule::types::RuleId::new(), - CoopExecutionMode::Manual, - ) - } - - #[tokio::test] - async fn dispatch_records_execution_and_step_rows() { - let (bridge, store) = bridge_with_recorded_store(); - let sentinel = Arc::new(springtale_sentinel::Sentinel::new( - springtale_sentinel::SentinelConfig::default(), - store.clone(), - )); - let execution = manual_execution_ctx(); - let exec_id = execution.execution_id.to_string(); - - // SendMessage: simplest non-network action — produces one step. - let action = Action::SendMessage { - text: "hello".into(), - }; - let chain = dispatch_action( - &action, - &bridge, - &sentinel, - execution, - serde_json::Value::Null, - ) - .await - .unwrap(); - assert_eq!(chain.steps.len(), 1); - - // executions row recorded. - let list = store - .list_executions(ExecutionFilter::default()) - .await - .unwrap(); - assert_eq!(list.len(), 1); - assert_eq!(list[0].id, exec_id); - assert_eq!( - list[0].status, - springtale_store::schema::executions::ExecutionStatus::Succeeded - ); - assert_eq!( - list[0].mode, - springtale_store::schema::executions::ExecutionMode::Manual - ); - - // execution_steps row recorded with sizes only. - let steps = store.get_execution_steps(&exec_id).await.unwrap(); - assert_eq!(steps.len(), 1); - assert_eq!(steps[0].step_kind, "send_message"); - assert!(steps[0].output_bytes > 0, "output_bytes captured size"); - assert!( - steps[0].input_blob_ref.is_none() && steps[0].output_blob_ref.is_none(), - "privacy default: no content retained" - ); - } - - #[tokio::test] - async fn dispatch_dry_run_stubs_sendmessage_and_returns_dry_run_flag() { - let (bridge, store) = bridge_with_recorded_store(); - let sentinel = Arc::new(springtale_sentinel::Sentinel::new( - springtale_sentinel::SentinelConfig::default(), - store.clone(), - )); - let execution = CoopExecutionContext::for_global( - springtale_core::rule::types::RuleId::new(), - CoopExecutionMode::DryRun, - ); - - let action = Action::SendMessage { - text: "would have sent this".into(), - }; - let chain = dispatch_action( - &action, - &bridge, - &sentinel, - execution, - serde_json::Value::Null, - ) - .await - .unwrap(); - - assert_eq!(chain.steps.len(), 1); - let step = &chain.steps[0]; - assert_eq!(step.kind, "send_message"); - assert_eq!( - step.output.get("dry_run").and_then(|v| v.as_bool()), - Some(true) - ); - - // Executions log captured the run in DryRun mode. - let runs = store - .list_executions(ExecutionFilter::default()) - .await - .unwrap(); - assert_eq!(runs.len(), 1); - assert_eq!( - runs[0].mode, - springtale_store::schema::executions::ExecutionMode::DryRun - ); - } - - #[tokio::test] - async fn dispatch_records_failure_status_on_step_failure() { - // WriteFile with a relative path is rejected at the - // dispatcher's pre-flight — yields a StepFailed chain error. - let (bridge, store) = bridge_with_recorded_store(); - let sentinel = Arc::new(springtale_sentinel::Sentinel::new( - springtale_sentinel::SentinelConfig::default(), - store.clone(), - )); - let execution = manual_execution_ctx(); - let exec_id = execution.execution_id.to_string(); - - let action = Action::WriteFile { - destination: "relative.txt".into(), - content: "data".into(), - delete_source: false, - }; - let result = dispatch_action( - &action, - &bridge, - &sentinel, - execution, - serde_json::Value::Null, - ) - .await; - assert!(result.is_err()); - - let list = store - .list_executions(ExecutionFilter::default()) - .await - .unwrap(); - assert_eq!(list.len(), 1); - assert_eq!( - list[0].status, - springtale_store::schema::executions::ExecutionStatus::Failed, - "WriteFile rejected → executions row marked failed" - ); - assert_eq!(list[0].error_kind.as_deref(), Some("step_failed")); - let _ = exec_id; // unused but kept for parallel-with-success test - } - - /// Minimal native connector whose single action declares no - /// `destructive` hint — the "unknown hint" case the sentinel must - /// treat as destructive (MCP `destructiveHint` default `true`). - struct HintlessConnector { - manifest: springtale_connector::manifest::types::ConnectorManifest, - } - - impl HintlessConnector { - fn new(name: &str) -> Self { - use springtale_connector::manifest::SignatureAlgorithm; - use springtale_connector::manifest::types::{ - ActionDecl, Capability, ConnectorManifest, TriggerDecl, - }; - Self { - manifest: ConnectorManifest { - name: name.to_owned(), - version: "0.1.0".into(), - author: "test".into(), - description: "hintless".into(), - capabilities: vec![Capability::NetworkOutbound { - host: "api.example.com".into(), - }], - triggers: vec![TriggerDecl { - name: "test_event".into(), - description: "test".into(), - schema: None, - }], - actions: vec![ActionDecl { - read_only: false, - destructive: None, - poll_interval_secs: None, - name: "echo".into(), - description: "echo".into(), - input_schema: None, - output_schema: None, - }], - data_disclosure: vec![], - roles: vec![], - wasm_hash: None, - signature_alg: SignatureAlgorithm::default(), - signature: None, - }, - } - } - } - - #[async_trait::async_trait] - impl springtale_connector::connector::trait_::Connector for HintlessConnector { - fn triggers(&self) -> &[springtale_connector::manifest::types::TriggerDecl] { - &self.manifest.triggers - } - fn actions(&self) -> &[springtale_connector::manifest::types::ActionDecl] { - &self.manifest.actions - } - async fn execute( - &self, - action: &str, - input: serde_json::Value, - ) -> Result< - springtale_connector::connector::trait_::ActionResult, - springtale_connector::ConnectorError, - > { - Ok(springtale_connector::connector::trait_::ActionResult { - success: true, - output: serde_json::json!({"echoed": input, "action": action}), - message: "ok".into(), - }) - } - async fn on_event( - &self, - trigger: &str, - _handler: springtale_connector::connector::trait_::EventHandler, - ) -> Result< - springtale_connector::connector::subscription::Subscription, - springtale_connector::ConnectorError, - > { - Ok( - springtale_connector::connector::subscription::Subscription { - id: springtale_connector::connector::subscription::SubscriptionId(0), - trigger: trigger.to_owned(), - }, - ) - } - async fn remove_event( - &self, - _sub: &springtale_connector::connector::subscription::Subscription, - ) -> Result<(), springtale_connector::ConnectorError> { - Ok(()) - } - fn manifest(&self) -> &springtale_connector::manifest::types::ConnectorManifest { - &self.manifest - } - } - - #[tokio::test] - async fn dispatch_quarantines_hintless_connector_action_under_default_deny() { - let store: Arc = Arc::new(SqliteBackend::open_in_memory().unwrap()); - let mut registry = springtale_connector::registry::store::ConnectorRegistry::new( - springtale_connector::capability::grant::CapabilityPolicy::AllowAll, - ); - registry - .install_native(Box::new(HintlessConnector::new("hintless"))) - .unwrap(); - let bridge = CapabilityBridge::new(Arc::new(tokio::sync::RwLock::new(registry))) - .with_store(store.clone()); - // `Sentinel::new` wires `DefaultDenyApprovalGate`. - let sentinel = Arc::new(springtale_sentinel::Sentinel::new( - springtale_sentinel::SentinelConfig::default(), - store.clone(), - )); - let execution = manual_execution_ctx(); - - let action = Action::RunConnector { - connector: "hintless".into(), - action: "echo".into(), - params: serde_json::Map::new(), - }; - let err = dispatch_action( - &action, - &bridge, - &sentinel, - execution, - serde_json::Value::Null, - ) - .await - .unwrap_err(); - assert!( - matches!(&err, ChainError::StepFailed { message, .. } if message.contains("quarantined")), - "expected sentinel quarantine, got {err:?}" - ); - } -} diff --git a/crates/springtale-runtime/src/dispatch/chain.rs b/crates/springtale-runtime/src/dispatch/chain.rs new file mode 100644 index 00000000..e000a933 --- /dev/null +++ b/crates/springtale-runtime/src/dispatch/chain.rs @@ -0,0 +1,58 @@ +//! The `Action::Chain` arm — sub-step expansion inside one shared +//! [`ChainContext`]. + +use std::sync::Arc; + +use springtale_cooperation::execution::ExecutionContext; +use springtale_core::rule::action::Action; +use springtale_core::rule::{ChainContext, ChainError}; +use springtale_sentinel::Sentinel; + +use super::step::run_step; +use crate::cooperation::CapabilityBridge; + +/// Run every sub-step of a chain action against the shared context. +/// +/// Records no wrapper step of its own: each sub-step lands in +/// `chain.steps` as it runs. `ChainError::Suppressed` from a nested dedupe +/// propagates unchanged so the outer caller can end the execution `empty`. +pub(super) async fn run_chain_steps( + steps: &[Action], + bridge: &CapabilityBridge, + sentinel: &Arc, + execution: &ExecutionContext, + chain: &mut ChainContext, + depth: u32, +) -> Result<(), ChainError> { + let new_depth = depth + 1; + if new_depth > springtale_core::rule::action::MAX_CHAIN_DEPTH { + return Err(ChainError::DepthExceeded { + depth: new_depth, + max: springtale_core::rule::action::MAX_CHAIN_DEPTH, + }); + } + // Chain expands transparently — each sub-step is recorded + // as its own StepOutput in the shared ChainContext. The + // Chain action itself doesn't produce a wrapper step. + for (i, step) in steps.iter().enumerate() { + match run_step(step, bridge, sentinel, execution, chain, new_depth).await { + Ok(()) => {} + Err(ChainError::Suppressed) => { + // A nested dedupe step suppressed the chain — + // propagate cleanly so the outer caller can + // mark execution status `empty`. + return Err(ChainError::Suppressed); + } + Err(e) => { + tracing::warn!(step = i, error = %e, "chain step failed"); + return Err(e); + } + } + } + // Chain returns without recording its own StepOutput — + // sub-steps are already in chain.steps. + // + // Skip the post-step alias refresh path below: we already + // returned the sub-steps individually. + Ok(()) +} diff --git a/crates/springtale-runtime/src/dispatch/connector.rs b/crates/springtale-runtime/src/dispatch/connector.rs new file mode 100644 index 00000000..785a06bb --- /dev/null +++ b/crates/springtale-runtime/src/dispatch/connector.rs @@ -0,0 +1,235 @@ +//! The `Action::RunConnector` arm — the only step that reaches a +//! connector, plus the read-only/side-effecting split that decides what a +//! dry run is allowed to actually do. + +use springtale_cooperation::execution::ExecutionContext; +use springtale_core::rule::template_resolve::resolve_chain_value; +use springtale_core::rule::{ChainContext, ChainError, StepOutput}; + +use crate::cooperation::{CapabilityBridge, momentum_to_wasm_tier}; +/// Classify a connector action name as side-effecting. Used by the +/// DryRun dispatcher path: side-effecting actions are stubbed +/// (return a "would have done X" StepOutput); read-only actions +/// run for real so Test This Step shows realistic upstream data. +/// +/// The heuristic is verb-prefix based — connector authors who add +/// new write actions just need to use a recognizable prefix. +/// First-party connectors today: `send_message`, `post_*`, +/// `write_*`, `create_*`, `delete_*`, `update_*`, `publish_*`, +/// `commit_*`, `push_*`, `react`, `dispatch`, `react_to_message`, +/// `set_*` (config writes). Read-side actions use `get_*`, `list_*`, +/// `read_*`, `fetch_*`, `search_*`, `query_*`, `wait_*`, plus the +/// browser primitives `navigate`, `evaluate`, `screenshot`, +/// `extract_text`, `get_html`, `query_all`, `fill_form`, `click`. +/// +/// `fill_form` + `click` are ambiguous — they mutate page state but +/// don't reach external systems. We classify them as read-only +/// (false) so chained recipes like "navigate → fill_form → click → +/// extract_text" produce useful Test This Step output. Connectors +/// that ship truly destructive actions under those names should +/// rename them. +fn is_side_effecting_action(name: &str) -> bool { + const WRITE_PREFIXES: &[&str] = &[ + "send_", + "post_", + "write_", + "create_", + "delete_", + "remove_", + "update_", + "publish_", + "commit_", + "push_", + "dispatch_", + "set_", + "ban_", + "kick_", + "mute_", + "broadcast_", + "react_", + "reply_", + "subscribe_", + "unsubscribe_", + "approve_", + "deny_", + ]; + const WRITE_EXACT: &[&str] = &[ + "send", + "post", + "write", + "publish", + "commit", + "react", + "react_to_message", + "dispatch", + "ban", + "kick", + "mute", + ]; + if WRITE_EXACT.contains(&name) { + return true; + } + WRITE_PREFIXES.iter().any(|p| name.starts_with(p)) +} +/// Run one connector action against the chain. +/// +/// Returns the [`StepOutput`] the caller records; the dry-run path returns a +/// stub step rather than calling the connector. +#[allow(clippy::too_many_arguments)] +pub(super) async fn run_connector_step( + connector: &str, + action_name: &str, + params: &serde_json::Map, + bridge: &CapabilityBridge, + execution: &ExecutionContext, + chain: &mut ChainContext, + run_id: &str, + kind: &'static str, + started: std::time::Instant, + dry_run: bool, +) -> Result { + // Resolve `${trigger.*}` / `${last_*_output.*}` / + // `${stepN.*}` in every param string before handing to + // the connector. + let raw = serde_json::Value::Object(params.clone()); + let resolved = resolve_chain_value(&raw, chain, Some(run_id)); + let input = resolved; + + // Dry-run stubs side-effecting connector actions but + // lets read-only actions (HTTP get, browser navigate, + // extract_text, etc.) run for real — that's the whole + // point of "Test This Step": fetch real upstream data + // to validate downstream rendering without spamming + // the destination channel. + if dry_run && is_side_effecting_action(action_name) { + tracing::info!( + connector = %connector, + action = %action_name, + "DRY RUN — side-effecting connector action stubbed" + ); + let step = StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ + "success": true, + "message": format!( + "dry-run: would call {connector}.{action_name}" + ), + "output": { + "connector": connector, + "action": action_name, + "params": input, + }, + "dry_run": true, + }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }; + return Ok(step); + } + + let effective_tier = momentum_to_wasm_tier(execution.momentum); + let exec = bridge + .execute_with_origin( + connector, + action_name, + input, + effective_tier, + execution.origin.clone(), + ) + .await; + match exec { + Ok(result) => { + tracing::info!( + connector = %connector, + action = %action_name, + success = result.success, + "connector action executed" + ); + let index = chain.next_step_index(); + // Capture both the structured output AND the + // human message so downstream templates can read + // either. `output` keys exposed in the chain + // alias: `last_connector_output.output.*` is the + // structured data, `last_connector_output.message` + // is the plain-text result. + let payload = serde_json::json!({ + "success": result.success, + "message": result.message, + "output": result.output, + }); + Ok(StepOutput { + index, + kind: kind.into(), + name: None, + output: payload, + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + Err(e) => { + tracing::warn!( + connector = %connector, + action = %action_name, + error = %e, + "connector action failed" + ); + Err(ChainError::StepFailed { + index: chain.next_step_index(), + kind: kind.into(), + message: e.to_string(), + }) + } + } +} + +#[cfg(test)] +mod side_effect_tests { + use super::is_side_effecting_action; + + #[test] + fn send_message_is_side_effecting() { + assert!(is_side_effecting_action("send_message")); + } + + #[test] + fn get_is_read_only() { + assert!(!is_side_effecting_action("get")); + assert!(!is_side_effecting_action("get_html")); + assert!(!is_side_effecting_action("list_repos")); + } + + #[test] + fn browser_navigation_is_read_only() { + assert!(!is_side_effecting_action("navigate")); + assert!(!is_side_effecting_action("evaluate")); + assert!(!is_side_effecting_action("screenshot")); + assert!(!is_side_effecting_action("query_all")); + assert!(!is_side_effecting_action("wait_for_selector")); + assert!(!is_side_effecting_action("extract_text")); + } + + #[test] + fn write_prefixes_are_side_effecting() { + for name in [ + "post_status", + "write_file", + "create_issue", + "delete_message", + "update_repo", + "publish_release", + "commit_change", + "push_branch", + "set_config", + "ban_user", + "kick_member", + "mute_user", + ] { + assert!( + is_side_effecting_action(name), + "expected {name} to be side-effecting" + ); + } + } +} diff --git a/crates/springtale-runtime/src/dispatch/entry.rs b/crates/springtale-runtime/src/dispatch/entry.rs new file mode 100644 index 00000000..5ec07cbd --- /dev/null +++ b/crates/springtale-runtime/src/dispatch/entry.rs @@ -0,0 +1,477 @@ +//! Dispatch entry points — what callers outside this crate reach for. +//! +//! [`dispatch_action`] and [`dispatch_actions`] own the chain-fire +//! envelope: they build the [`ChainContext`], hand each top-level action +//! to [`super::step::run_step`], and record the executions-log row. The +//! per-action work lives in the sibling modules. + +use std::sync::Arc; + +use springtale_cooperation::execution::ExecutionContext; +use springtale_core::rule::action::Action; +use springtale_core::rule::{ChainContext, ChainError}; +use springtale_sentinel::Sentinel; + +use super::step::run_step; +use crate::cooperation::CapabilityBridge; + +/// Dispatch one top-level rule action with full chain-context +/// threading. Returns the final [`ChainContext`] containing every +/// recorded [`StepOutput`]. +/// +/// `trigger_payload` is the JSON the trigger fired with — referenced +/// by recipe templates as `${trigger.path}`. Cron triggers pass +/// `Value::Null`. Webhook / connector-event triggers pass the +/// inbound payload. +pub fn dispatch_action<'a>( + action: &'a Action, + bridge: &'a CapabilityBridge, + sentinel: &'a Arc, + execution: ExecutionContext, + trigger_payload: serde_json::Value, +) -> std::pin::Pin< + Box> + Send + 'a>, +> { + dispatch_actions( + std::slice::from_ref(action), + bridge, + sentinel, + execution, + trigger_payload, + ) +} +/// Dispatch a sequence of top-level actions as a single chain fire. +/// Used by `trigger_dispatch` when a [`RuleMatch::actions`] holds +/// `Vec` — each action becomes a step in the shared +/// `ChainContext`, so `${last_*_output}` and `${stepN.*}` resolve +/// across the whole rule. +pub fn dispatch_actions<'a>( + actions: &'a [Action], + bridge: &'a CapabilityBridge, + sentinel: &'a Arc, + execution: ExecutionContext, + trigger_payload: serde_json::Value, +) -> std::pin::Pin< + Box> + Send + 'a>, +> { + Box::pin(async move { + let recorder = bridge.recorder(); + let trigger_summary = summarize_trigger(&trigger_payload, execution.mode); + // Best-effort recorder.begin — failures fall through to + // dispatch so the chain still runs. The privacy invariant + // is about NOT writing content; missing rows are fine. + if let Err(e) = recorder.begin(&execution, &trigger_summary, None).await { + tracing::warn!(error = %e, "executions recorder.begin failed"); + } + + let execution_id = execution.execution_id.to_string(); + let mut chain = ChainContext::new(trigger_payload); + let mut steps_emitted = 0usize; + let mut final_status = springtale_store::schema::executions::ExecutionStatus::Succeeded; + let mut error_kind: Option<&'static str> = None; + let mut chain_outcome: Result<(), ChainError> = Ok(()); + + for action in actions { + match run_step(action, bridge, sentinel, &execution, &mut chain, 0).await { + Ok(()) => { + // Record any new steps the action appended to + // chain.steps. Chain action expands into + // multiple sub-steps so we drain everything past + // `steps_emitted`. + while steps_emitted < chain.steps.len() { + let step = &chain.steps[steps_emitted]; + if let Err(e) = recorder.record_step(&execution_id, step).await { + tracing::warn!(error = %e, "executions recorder.record_step failed"); + } + steps_emitted += 1; + } + } + Err(ChainError::Suppressed) => { + // Flush any steps that did run (dedupe step itself). + while steps_emitted < chain.steps.len() { + let step = &chain.steps[steps_emitted]; + if let Err(e) = recorder.record_step(&execution_id, step).await { + tracing::warn!(error = %e, "executions recorder.record_step failed"); + } + steps_emitted += 1; + } + final_status = springtale_store::schema::executions::ExecutionStatus::Empty; + chain_outcome = Ok(()); + break; + } + Err(e) => { + while steps_emitted < chain.steps.len() { + let step = &chain.steps[steps_emitted]; + if let Err(rec_err) = recorder.record_step(&execution_id, step).await { + tracing::warn!(error = %rec_err, "executions recorder.record_step failed"); + } + steps_emitted += 1; + } + final_status = springtale_store::schema::executions::ExecutionStatus::Failed; + error_kind = Some(classify_chain_error(&e)); + chain_outcome = Err(e); + break; + } + } + } + + if let Err(e) = recorder + .finish(&execution_id, final_status, error_kind) + .await + { + tracing::warn!(error = %e, "executions recorder.finish failed"); + } + + match chain_outcome { + Ok(()) => Ok(chain), + Err(e) => Err(e), + } + }) +} +/// Build a short summary string the executions log records for the +/// firing trigger. Sized for a status line — no payload, just the +/// kind + the obvious discriminator. +fn summarize_trigger( + trigger: &serde_json::Value, + mode: springtale_cooperation::execution::ExecutionMode, +) -> String { + use springtale_cooperation::execution::ExecutionMode as M; + match mode { + M::Cron => trigger + .get("expression") + .and_then(|v| v.as_str()) + .map(|e| format!("Cron {e}")) + .unwrap_or_else(|| "Cron".to_owned()), + M::Webhook => "Webhook".to_owned(), + M::ConnectorEvent => trigger + .get("trigger_name") + .and_then(|v| v.as_str()) + .map(|t| format!("Event {t}")) + .unwrap_or_else(|| "ConnectorEvent".to_owned()), + M::FileWatch => "FileWatch".to_owned(), + M::Manual => "Manual".to_owned(), + M::Cooperation => "Cooperation".to_owned(), + M::Retry => "Retry".to_owned(), + M::DryRun => "DryRun".to_owned(), + } +} +/// Map a chain error to the same enum-tag set the recorder writes +/// for step errors. Keeps the audit trail consistent — privacy +/// invariant says no full messages reach the DB. +fn classify_chain_error(err: &ChainError) -> &'static str { + match err { + ChainError::Suppressed => "suppressed", + ChainError::StepNotYetRun(_) => "template_step_unresolved", + ChainError::StepNameNotFound(_) => "template_name_unresolved", + ChainError::DuplicateStepName(_) => "template_duplicate_name", + ChainError::DepthExceeded { .. } => "chain_depth_exceeded", + ChainError::StepFailed { .. } => "step_failed", + ChainError::Template(_) => "template_invalid", + } +} +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use springtale_cooperation::execution::{ + ExecutionContext as CoopExecutionContext, ExecutionMode as CoopExecutionMode, + }; + use springtale_store::SqliteBackend; + use springtale_store::backend::StorageBackend; + use springtale_store::schema::executions::ExecutionFilter; + use std::sync::Arc; + + /// Build a bridge wired against an in-memory SqliteBackend with + /// a real StoreRecorder — used by tests that assert on + /// executions-log rows after a chain runs. + fn bridge_with_recorded_store() -> (CapabilityBridge, Arc) { + let store: Arc = Arc::new(SqliteBackend::open_in_memory().unwrap()); + let recorder: Arc = Arc::new( + crate::operations::executions::StoreRecorder::new(store.clone()), + ); + let registry = Arc::new(tokio::sync::RwLock::new( + springtale_connector::registry::store::ConnectorRegistry::default(), + )); + let bridge = CapabilityBridge::new(registry) + .with_store(store.clone()) + .with_recorder(recorder); + (bridge, store) + } + + fn manual_execution_ctx() -> CoopExecutionContext { + CoopExecutionContext::for_global( + springtale_core::rule::types::RuleId::new(), + CoopExecutionMode::Manual, + ) + } + + #[tokio::test] + async fn dispatch_records_execution_and_step_rows() { + let (bridge, store) = bridge_with_recorded_store(); + let sentinel = Arc::new(springtale_sentinel::Sentinel::new( + springtale_sentinel::SentinelConfig::default(), + store.clone(), + )); + let execution = manual_execution_ctx(); + let exec_id = execution.execution_id.to_string(); + + // SendMessage: simplest non-network action — produces one step. + let action = Action::SendMessage { + text: "hello".into(), + }; + let chain = dispatch_action( + &action, + &bridge, + &sentinel, + execution, + serde_json::Value::Null, + ) + .await + .unwrap(); + assert_eq!(chain.steps.len(), 1); + + // executions row recorded. + let list = store + .list_executions(ExecutionFilter::default()) + .await + .unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, exec_id); + assert_eq!( + list[0].status, + springtale_store::schema::executions::ExecutionStatus::Succeeded + ); + assert_eq!( + list[0].mode, + springtale_store::schema::executions::ExecutionMode::Manual + ); + + // execution_steps row recorded with sizes only. + let steps = store.get_execution_steps(&exec_id).await.unwrap(); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].step_kind, "send_message"); + assert!(steps[0].output_bytes > 0, "output_bytes captured size"); + assert!( + steps[0].input_blob_ref.is_none() && steps[0].output_blob_ref.is_none(), + "privacy default: no content retained" + ); + } + + #[tokio::test] + async fn dispatch_dry_run_stubs_sendmessage_and_returns_dry_run_flag() { + let (bridge, store) = bridge_with_recorded_store(); + let sentinel = Arc::new(springtale_sentinel::Sentinel::new( + springtale_sentinel::SentinelConfig::default(), + store.clone(), + )); + let execution = CoopExecutionContext::for_global( + springtale_core::rule::types::RuleId::new(), + CoopExecutionMode::DryRun, + ); + + let action = Action::SendMessage { + text: "would have sent this".into(), + }; + let chain = dispatch_action( + &action, + &bridge, + &sentinel, + execution, + serde_json::Value::Null, + ) + .await + .unwrap(); + + assert_eq!(chain.steps.len(), 1); + let step = &chain.steps[0]; + assert_eq!(step.kind, "send_message"); + assert_eq!( + step.output.get("dry_run").and_then(|v| v.as_bool()), + Some(true) + ); + + // Executions log captured the run in DryRun mode. + let runs = store + .list_executions(ExecutionFilter::default()) + .await + .unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!( + runs[0].mode, + springtale_store::schema::executions::ExecutionMode::DryRun + ); + } + + #[tokio::test] + async fn dispatch_records_failure_status_on_step_failure() { + // WriteFile with a relative path is rejected at the + // dispatcher's pre-flight — yields a StepFailed chain error. + let (bridge, store) = bridge_with_recorded_store(); + let sentinel = Arc::new(springtale_sentinel::Sentinel::new( + springtale_sentinel::SentinelConfig::default(), + store.clone(), + )); + let execution = manual_execution_ctx(); + let exec_id = execution.execution_id.to_string(); + + let action = Action::WriteFile { + destination: "relative.txt".into(), + content: "data".into(), + delete_source: false, + }; + let result = dispatch_action( + &action, + &bridge, + &sentinel, + execution, + serde_json::Value::Null, + ) + .await; + assert!(result.is_err()); + + let list = store + .list_executions(ExecutionFilter::default()) + .await + .unwrap(); + assert_eq!(list.len(), 1); + assert_eq!( + list[0].status, + springtale_store::schema::executions::ExecutionStatus::Failed, + "WriteFile rejected → executions row marked failed" + ); + assert_eq!(list[0].error_kind.as_deref(), Some("step_failed")); + let _ = exec_id; // unused but kept for parallel-with-success test + } + + /// Minimal native connector whose single action declares no + /// `destructive` hint — the "unknown hint" case the sentinel must + /// treat as destructive (MCP `destructiveHint` default `true`). + struct HintlessConnector { + manifest: springtale_connector::manifest::types::ConnectorManifest, + } + + impl HintlessConnector { + fn new(name: &str) -> Self { + use springtale_connector::manifest::SignatureAlgorithm; + use springtale_connector::manifest::types::{ + ActionDecl, Capability, ConnectorManifest, TriggerDecl, + }; + Self { + manifest: ConnectorManifest { + name: name.to_owned(), + version: "0.1.0".into(), + author: "test".into(), + description: "hintless".into(), + capabilities: vec![Capability::NetworkOutbound { + host: "api.example.com".into(), + }], + triggers: vec![TriggerDecl { + name: "test_event".into(), + description: "test".into(), + schema: None, + }], + actions: vec![ActionDecl { + read_only: false, + destructive: None, + poll_interval_secs: None, + name: "echo".into(), + description: "echo".into(), + input_schema: None, + output_schema: None, + }], + data_disclosure: vec![], + roles: vec![], + wasm_hash: None, + signature_alg: SignatureAlgorithm::default(), + signature: None, + }, + } + } + } + + #[async_trait::async_trait] + impl springtale_connector::connector::trait_::Connector for HintlessConnector { + fn triggers(&self) -> &[springtale_connector::manifest::types::TriggerDecl] { + &self.manifest.triggers + } + fn actions(&self) -> &[springtale_connector::manifest::types::ActionDecl] { + &self.manifest.actions + } + async fn execute( + &self, + action: &str, + input: serde_json::Value, + ) -> Result< + springtale_connector::connector::trait_::ActionResult, + springtale_connector::ConnectorError, + > { + Ok(springtale_connector::connector::trait_::ActionResult { + success: true, + output: serde_json::json!({"echoed": input, "action": action}), + message: "ok".into(), + }) + } + async fn on_event( + &self, + trigger: &str, + _handler: springtale_connector::connector::trait_::EventHandler, + ) -> Result< + springtale_connector::connector::subscription::Subscription, + springtale_connector::ConnectorError, + > { + Ok( + springtale_connector::connector::subscription::Subscription { + id: springtale_connector::connector::subscription::SubscriptionId(0), + trigger: trigger.to_owned(), + }, + ) + } + async fn remove_event( + &self, + _sub: &springtale_connector::connector::subscription::Subscription, + ) -> Result<(), springtale_connector::ConnectorError> { + Ok(()) + } + fn manifest(&self) -> &springtale_connector::manifest::types::ConnectorManifest { + &self.manifest + } + } + + #[tokio::test] + async fn dispatch_quarantines_hintless_connector_action_under_default_deny() { + let store: Arc = Arc::new(SqliteBackend::open_in_memory().unwrap()); + let mut registry = springtale_connector::registry::store::ConnectorRegistry::new( + springtale_connector::capability::grant::CapabilityPolicy::AllowAll, + ); + registry + .install_native(Box::new(HintlessConnector::new("hintless"))) + .unwrap(); + let bridge = CapabilityBridge::new(Arc::new(tokio::sync::RwLock::new(registry))) + .with_store(store.clone()); + // `Sentinel::new` wires `DefaultDenyApprovalGate`. + let sentinel = Arc::new(springtale_sentinel::Sentinel::new( + springtale_sentinel::SentinelConfig::default(), + store.clone(), + )); + let execution = manual_execution_ctx(); + + let action = Action::RunConnector { + connector: "hintless".into(), + action: "echo".into(), + params: serde_json::Map::new(), + }; + let err = dispatch_action( + &action, + &bridge, + &sentinel, + execution, + serde_json::Value::Null, + ) + .await + .unwrap_err(); + assert!( + matches!(&err, ChainError::StepFailed { message, .. } if message.contains("quarantined")), + "expected sentinel quarantine, got {err:?}" + ); + } +} diff --git a/crates/springtale-runtime/src/dispatch/extract.rs b/crates/springtale-runtime/src/dispatch/extract.rs new file mode 100644 index 00000000..fafaca91 --- /dev/null +++ b/crates/springtale-runtime/src/dispatch/extract.rs @@ -0,0 +1,51 @@ +//! The `Action::Extract` arm — the extraction ladder's step wrapper. + +use springtale_cooperation::execution::ExecutionContext; +use springtale_core::rule::template_resolve::resolve_chain_value; +use springtale_core::rule::{ChainContext, ChainError, StepOutput}; + +use crate::cooperation::CapabilityBridge; +/// Resolve the extraction source against the chain and run the ladder. +#[allow(clippy::too_many_arguments)] +pub(super) async fn run_extract_step( + source: &str, + extract_kind: &springtale_core::rule::action::ExtractKind, + bridge: &CapabilityBridge, + execution: &ExecutionContext, + chain: &mut ChainContext, + run_id: &str, + kind: &'static str, + started: std::time::Instant, +) -> Result { + // Resolve `source` as a path against the chain — e.g. + // `"last_connector_output.body"` → the HTTP body string, + // or `"trigger.payload"` → the trigger event JSON. + let resolved_source = resolve_chain_value( + &serde_json::Value::String(format!("${{{source}}}")), + chain, + Some(run_id), + ); + + // The AI adapter for LlmSchema extraction. We pass it + // through opt-in — Phase A only fires non-LLM tiers; + // Phase B activates LlmSchema and the adapter is read. + let adapter_arc = bridge.ai_adapter_for(execution).await; + let ai_ref: Option<&dyn springtale_ai::AiAdapter> = Some(&*adapter_arc); + + let extracted = crate::extraction::extract(&resolved_source, extract_kind, ai_ref).await; + match extracted { + Ok(value) => Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: value, + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }), + Err(e) => Err(ChainError::StepFailed { + index: chain.next_step_index(), + kind: kind.into(), + message: e.to_string(), + }), + } +} diff --git a/crates/springtale-runtime/src/dispatch/mod.rs b/crates/springtale-runtime/src/dispatch/mod.rs new file mode 100644 index 00000000..4ca1ad65 --- /dev/null +++ b/crates/springtale-runtime/src/dispatch/mod.rs @@ -0,0 +1,53 @@ +//! Shared rule-action dispatcher — executes matched rule actions. +//! +//! Both springtaled (daemon) and springtale-bot route action dispatch +//! through this module. Per ARCHITECTURE.md §6.10, this is the single +//! enforcement point that calls `sentinel.evaluate()` before every +//! action. +//! +//! ## Phase 0 rework (chain context + real I/O) +//! +//! Pre-Phase-0 this module returned `Result` and +//! discarded step outputs between iterations. `Action::AiComplete` +//! was a stub returning `"ai: noop"`. `Action::RunConnector` captured +//! only the connector's plain-text `message` and dropped its +//! structured `output: Value`. The net effect: ~12 shipped builtin +//! recipes referenced `${last_ai_output}` / `${last_connector_output}` +//! in their TOML, but the dispatcher had no path that resolved those +//! placeholders — users received literal `${last_ai_output}` strings +//! in their messaging channels. +//! +//! The new shape closes all four gaps: +//! +//! 1. Return type is `Result` — +//! `ChainContext` carries every step's typed `output`, the +//! `last_*_output` aliases, and the trigger payload. Callers read +//! it to surface results or persist to the executions log. +//! 2. Before each step runs, action parameters are template-resolved +//! against the chain via [`resolve_chain_value`] — `${trigger.x}`, +//! `${last_ai_output}`, `${stepN.field}`, `${step.NAME.field}` all +//! bind to live values. +//! 3. `RunConnector` captures the connector's `ActionResult.output` +//! (the structured JSON) into `StepOutput.output`, not just the +//! human message. +//! 4. `AiComplete` calls the real adapter via +//! [`CapabilityBridge::ai_adapter_for`] — falls back to +//! `NoopAdapter` (clean error, not silent stubbing) when no +//! adapter is wired. +//! +//! Cooperation alignment: every dispatch carries an +//! [`ExecutionContext`] from `springtale-cooperation::execution`, so +//! the runtime knows which agent in which formation at which momentum +//! tier is firing. The bridge consults the tier for capability +//! routing (per-tier WASM `InstancePre` selection, §16). The sentinel +//! consults the tier for rate-budget scaling: Cold = 1/30s, Warming = +//! 12/min, Hot = 60/min, Fever = 600/min — the Phase 0.5 mapping in +//! [`crate::cooperation::momentum_to_throttle_tier`]. + +pub mod chain; +pub mod connector; +pub mod entry; +pub mod extract; +pub mod step; + +pub use entry::{dispatch_action, dispatch_actions}; diff --git a/crates/springtale-runtime/src/dispatch/step.rs b/crates/springtale-runtime/src/dispatch/step.rs new file mode 100644 index 00000000..9b41c83d --- /dev/null +++ b/crates/springtale-runtime/src/dispatch/step.rs @@ -0,0 +1,515 @@ +//! The step runner — sentinel gate, template resolution, and the +//! per-`Action` arm dispatch that every chain step goes through. +//! +//! Arms with enough substance to stand alone live in sibling modules +//! ([`super::connector`], [`super::chain`], [`super::extract`]); the rest +//! are inline here because they are a handful of lines each. + +use std::sync::Arc; + +use springtale_ai::{AiOptions, AiRequest}; +use springtale_cooperation::execution::ExecutionContext; +use springtale_core::rule::action::Action; +use springtale_core::rule::template_resolve::resolve_chain_template; +use springtale_core::rule::{ChainContext, ChainError, StepOutput}; +use springtale_sentinel::impact::ActionHints; +use springtale_sentinel::sentinel::EvaluateRequest; +use springtale_sentinel::{Sentinel, Verdict}; + +use super::{chain, connector, extract}; +use crate::cooperation::{CapabilityBridge, momentum_to_throttle_tier}; + +/// Maximum size for WriteFile action content (10 MiB). +const MAX_WRITE_FILE_BYTES: usize = 10 * 1024 * 1024; + +/// Run one action against the chain. Recursive — `Action::Chain` +/// expands into multiple sub-steps that all share the chain context. +pub(super) fn run_step<'a>( + action: &'a Action, + bridge: &'a CapabilityBridge, + sentinel: &'a Arc, + execution: &'a ExecutionContext, + chain: &'a mut ChainContext, + depth: u32, +) -> std::pin::Pin> + Send + 'a>> { + Box::pin(run_step_inner( + action, bridge, sentinel, execution, chain, depth, + )) +} + +async fn run_step_inner( + action: &Action, + bridge: &CapabilityBridge, + sentinel: &Arc, + execution: &ExecutionContext, + chain: &mut ChainContext, + depth: u32, +) -> Result<(), ChainError> { + let run_id = execution.execution_id.to_string(); + + // ── Sentinel ───────────────────────────────────────────────── + // Throw the (unresolved) action at sentinel for connector-name + // routing only — the resolver below produces the executable + // version. Sentinel doesn't read action parameters; it reads the + // connector name, the manifest's advisory hints for the named + // action, and the envelope's policy / autonomy. + let connector_name = match action { + Action::RunConnector { connector, .. } => connector.as_str(), + _ => "system", + }; + let throttle_tier = momentum_to_throttle_tier(execution.momentum); + let hints = if let Action::RunConnector { + connector, + action: name, + .. + } = action + { + let reg = bridge.registry().read().await; + reg.get(connector) + .and_then(|e| e.host.actions().iter().find(|d| d.name == *name).cloned()) + .map(|d| ActionHints { + read_only: d.read_only, + destructive: d.destructive, + }) + } else { + None + }; + let action_name = if let Action::RunConnector { action: name, .. } = action { + Some(name.as_str()) + } else { + None + }; + let verdict = sentinel + .evaluate(EvaluateRequest { + action, + connector_name, + tier: throttle_tier, + hints, + action_name, + policy: execution.policy, + autonomy: execution.autonomy, + origin: execution.origin.as_ref(), + }) + .await; + match verdict { + Verdict::Go => {} + Verdict::Throttle(duration) => { + tracing::info!( + connector = connector_name, + delay_ms = duration.as_millis() as u64, + "sentinel: throttling action" + ); + chain.throttles += 1; + tokio::time::sleep(duration).await; + } + Verdict::Pause(reason) => { + return Err(ChainError::StepFailed { + index: chain.next_step_index(), + kind: action_kind(action).into(), + message: format!("sentinel paused: {reason}"), + }); + } + Verdict::Quarantine(reason) => { + return Err(ChainError::StepFailed { + index: chain.next_step_index(), + kind: action_kind(action).into(), + message: format!("sentinel quarantined: {reason}"), + }); + } + } + + let kind = action_kind(action); + let started = std::time::Instant::now(); + let dry_run = matches!( + execution.mode, + springtale_cooperation::execution::ExecutionMode::DryRun + ); + + // ── Action arm dispatch ───────────────────────────────────── + let outcome: Result = match action { + Action::RunConnector { + connector, + action: action_name, + params, + } => { + connector::run_connector_step( + connector, + action_name, + params, + bridge, + execution, + chain, + &run_id, + kind, + started, + dry_run, + ) + .await + } + + Action::Notify { title, body } => { + let resolved_title = resolve_chain_template(title, chain, Some(&run_id)); + let resolved_body = resolve_chain_template(body, chain, Some(&run_id)); + if dry_run { + tracing::info!( + title = %resolved_title, + "DRY RUN — Notify stubbed" + ); + } else { + tracing::info!(title = %resolved_title, body = %resolved_body, "NOTIFICATION"); + } + Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ + "title": resolved_title, + "body": resolved_body, + "dry_run": dry_run, + }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + + Action::SendMessage { text } => { + let resolved = resolve_chain_template(text, chain, Some(&run_id)); + if dry_run { + tracing::info!(text_len = resolved.len(), "DRY RUN — SendMessage stubbed"); + } else { + tracing::info!(text = %resolved, "SendMessage (no destination context)"); + } + Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ + "text": resolved, + "dry_run": dry_run, + }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + + Action::WriteFile { + destination, + content, + delete_source: _, + } => { + let resolved_destination = resolve_chain_template(destination, chain, Some(&run_id)); + let resolved_content = resolve_chain_template(content, chain, Some(&run_id)); + + if resolved_content.len() > MAX_WRITE_FILE_BYTES { + return Err(ChainError::StepFailed { + index: chain.next_step_index(), + kind: kind.into(), + message: format!( + "file content size ({} bytes) exceeds maximum ({MAX_WRITE_FILE_BYTES} bytes)", + resolved_content.len() + ), + }); + } + let path = std::path::Path::new(&resolved_destination); + if !path.is_absolute() { + return Err(ChainError::StepFailed { + index: chain.next_step_index(), + kind: kind.into(), + message: "WriteFile requires absolute path".to_string(), + }); + } + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(ChainError::StepFailed { + index: chain.next_step_index(), + kind: kind.into(), + message: "WriteFile path must not contain '..'".to_string(), + }); + } + if dry_run { + tracing::info!( + path = %resolved_destination, + bytes = resolved_content.len(), + "DRY RUN — WriteFile stubbed" + ); + } else { + tokio::fs::write(&resolved_destination, &resolved_content) + .await + .map_err(|e| ChainError::StepFailed { + index: chain.next_step_index(), + kind: kind.into(), + message: format!("failed to write file {resolved_destination}: {e}"), + })?; + tracing::info!(path = %resolved_destination, "file written"); + } + Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ + "path": resolved_destination, + "bytes": resolved_content.len(), + "dry_run": dry_run, + }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + + Action::RunShell { command } => { + let resolved = resolve_chain_template(command, chain, Some(&run_id)); + // ShellExec requires capability approval flow — actual + // execution is gated outside the dispatcher. The + // dispatcher records the request so the capability layer + // and audit trail see it. + tracing::info!( + command = %resolved, + "SHELL (not executed — requires ShellExec approval)" + ); + Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ + "command": resolved, + "executed": false, + "reason": "ShellExec capability gate", + }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + + Action::Delay { seconds } => { + if dry_run { + tracing::info!(seconds = seconds, "DRY RUN — Delay stubbed"); + } else { + tokio::time::sleep(std::time::Duration::from_secs(*seconds)).await; + tracing::debug!(seconds = seconds, "delay completed"); + } + Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ "seconds": seconds }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + + Action::Chain { steps } => { + chain::run_chain_steps(steps, bridge, sentinel, execution, chain, depth).await?; + // Chain records no wrapper step of its own — its sub-steps are + // already in `chain.steps`, so skip the post-step alias refresh. + sentinel.report_success(connector_name); + return Ok(()); + } + + Action::Transform { operation, params } => { + // Transform is a placeholder today — operation-specific + // implementations land in Phase A (the extraction ladder + // replaces most Transform use cases). For now we record + // the transform request so the chain context surfaces it. + tracing::debug!(operation = %operation, "transform pass-through"); + Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ + "operation": operation, + "params": params, + }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + + Action::AiComplete { prompt, .. } => { + // Resolve `${...}` placeholders in the prompt before the + // model sees it. Critical: this is how `${last_connector_output}` + // ends up in the prompt for "summarize this fetched + // body" recipes. + // + // OWASP LLM01:2025 indirect-injection guard: every + // substituted value is wrapped in `` tags + // by the AI-specific resolver, and the rule explaining the + // tags is prepended to the system prompt. The model + // therefore sees both (a) explicit instructions that the + // tagged content is untrusted data, and (b) the tagged + // values themselves. + let resolved_user_prompt = + springtale_core::rule::template_resolve::resolve_chain_template_for_ai( + prompt, + chain, + Some(&run_id), + ); + let resolved_prompt = format!( + "{rule}\n\n{prompt}", + rule = springtale_core::rule::template_resolve::AI_EXTERNAL_CONTEXT_RULE, + prompt = resolved_user_prompt, + ); + + // Route through the bridge — falls back to NoopAdapter + // when no adapter is wired. NoopAdapter returns + // `AiError::Disabled`, which we surface as a step error + // (not a silent stub). + let adapter_arc = bridge.ai_adapter_for(execution).await; + let request = AiRequest::Complete { + prompt: resolved_prompt.clone(), + }; + let options = AiOptions::default(); + let response = adapter_arc.complete(request, options).await; + match response { + Ok(ai_response) => { + tracing::debug!( + prompt_len = resolved_prompt.len(), + content_len = ai_response.content.len(), + finish_reason = ?ai_response.finish_reason, + "AI complete" + ); + Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ + "text": ai_response.content, + "finish_reason": ai_response.finish_reason, + }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + Err(e) => { + tracing::warn!(error = %e, "AI complete failed"); + Err(ChainError::StepFailed { + index: chain.next_step_index(), + kind: kind.into(), + message: e.to_string(), + }) + } + } + } + + Action::Extract { + source, + kind: extract_kind, + } => { + extract::run_extract_step( + source, + extract_kind, + bridge, + execution, + chain, + &run_id, + kind, + started, + ) + .await + } + + Action::Dedupe { + key, + bucket, + history, + } => { + // Resolve key + bucket templates against the chain. + let resolved_key = resolve_chain_template(key, chain, Some(&run_id)); + let resolved_bucket = resolve_chain_template(bucket, chain, Some(&run_id)); + + // Bridge holds the store handle. Test builds without a + // store wired fall through to "fresh" (the default impl + // on the StorageBackend trait) so dispatcher tests don't + // need a real DB just to exercise non-dedupe arms. + let formation_id = execution.formation_id.map(|f| f.0.to_string()); + let rule_id = execution.rule_id.0.to_string(); + + // Dry-run: never write to the dedupe table. We want + // Test This Step to render the downstream steps as + // if the data were fresh — without polluting the + // real dedupe state for the next production fire. + let outcome = if dry_run { + springtale_store::schema::dedupe::DedupeOutcome::Fresh + } else { + match bridge.store() { + Some(store) => crate::dedupe::check_and_record( + store, + formation_id.as_deref(), + &rule_id, + &resolved_bucket, + &resolved_key, + *history, + ) + .await + .map_err(|e| ChainError::StepFailed { + index: chain.next_step_index(), + kind: kind.into(), + message: e.to_string(), + })?, + None => springtale_store::schema::dedupe::DedupeOutcome::Fresh, + } + }; + + // SeenBefore short-circuits the chain. The Chain runner + // arm above catches `ChainError::Suppressed` and ends + // the execution cleanly with status `empty`. + if matches!( + outcome, + springtale_store::schema::dedupe::DedupeOutcome::SeenBefore + ) { + tracing::info!( + rule = %rule_id, + bucket = %resolved_bucket, + "dedupe: key seen before — chain suppressed" + ); + return Err(ChainError::Suppressed); + } + + Ok(StepOutput { + index: chain.next_step_index(), + kind: kind.into(), + name: None, + output: serde_json::json!({ + "outcome": "fresh", + "bucket": resolved_bucket, + "dry_run": dry_run, + }), + duration_ms: started.elapsed().as_millis() as u64, + error: None, + }) + } + }; + + // ── Record outcome + sentinel report ──────────────────────── + match outcome { + Ok(step) => { + chain.record_step(step); + sentinel.report_success(connector_name); + Ok(()) + } + Err(e) => { + sentinel.report_failure(connector_name); + Err(e) + } + } +} +/// Stable kind tag the dispatcher writes into [`StepOutput::kind`]. +/// Mirrors the [`Action`] variant discriminant so chain-context +/// readers can filter by kind without re-matching the original +/// variant. +fn action_kind(action: &Action) -> &'static str { + match action { + Action::RunConnector { .. } => "run_connector", + Action::SendMessage { .. } => "send_message", + Action::WriteFile { .. } => "write_file", + Action::RunShell { .. } => "run_shell", + Action::Notify { .. } => "notify", + Action::Chain { .. } => "chain", + Action::Transform { .. } => "transform", + Action::Delay { .. } => "delay", + Action::AiComplete { .. } => "ai_complete", + Action::Extract { .. } => "extract", + Action::Dedupe { .. } => "dedupe", + } +} diff --git a/crates/springtale-runtime/src/operations/config.rs b/crates/springtale-runtime/src/operations/config.rs index 596b0049..2c6dd799 100644 --- a/crates/springtale-runtime/src/operations/config.rs +++ b/crates/springtale-runtime/src/operations/config.rs @@ -213,22 +213,66 @@ pub async fn upsert_connector_config( } } +/// Config key holding a formation's guard-mode flag. The single source of +/// truth for the durable copy — every reader goes through +/// [`formation_guard_engaged`] and the only writer is +/// [`toggle_formation_guard`]. +fn guard_key(formation_id: &str) -> String { + format!("guard:{formation_id}") +} + +/// Whether guard mode is engaged for a formation, read from the durable +/// config row. Deploy copies this into the live formation's +/// `constraints.guard_mode`, and [`toggle_formation_guard`] keeps the live +/// copy in step afterward, so the two agree. +pub async fn formation_guard_engaged( + store: &dyn springtale_store::backend::StorageBackend, + formation_id: &str, +) -> bool { + !get_config(store, &guard_key(formation_id)) + .await + .unwrap_or(Value::Null) + .is_null() +} + /// Toggle guard mode for a formation. /// /// Replaces the frontend read-modify-write pattern on guard config. +/// +/// Writes the durable config row AND posts `FormationCommand::SetGuard` so the +/// live `Formation` in the bot tick loop picks the change up on its next +/// command drain. Without the command the live `constraints.guard_mode` would +/// keep whatever value deploy gave it, and engaging guard would protect +/// nothing until the formation was redeployed. pub async fn toggle_formation_guard( state: &RuntimeState, formation_id: &str, ) -> Result { - let key = format!("guard:{formation_id}"); - let current = get_config(&*state.store, &key).await?; - let is_enabled = !current.is_null(); + let key = guard_key(formation_id); + let is_enabled = formation_guard_engaged(&*state.store, formation_id).await; if is_enabled { set_config(&*state.store, &key, Value::Null).await?; } else { set_config(&*state.store, &key, serde_json::json!({ "enabled": true })).await?; } - Ok(!is_enabled) // returns new state + let engaged = !is_enabled; + if let Ok(fid) = springtale_cooperation::types::FormationId::parse(formation_id) { + let _ = state + .formation_cmd_tx + .send( + springtale_cooperation::command::FormationCommand::SetGuard { + formation_id: fid, + engaged, + }, + ) + .await; + } else { + tracing::warn!( + formation = %formation_id, + "guard toggled on an unparseable formation id — live formation not updated" + ); + } + Ok(engaged) // returns new state } #[cfg(test)] diff --git a/crates/springtale-runtime/src/operations/formations.rs b/crates/springtale-runtime/src/operations/formations.rs index 8eb389d4..77c82c8e 100644 --- a/crates/springtale-runtime/src/operations/formations.rs +++ b/crates/springtale-runtime/src/operations/formations.rs @@ -138,24 +138,16 @@ pub struct FormationInfo { /// Guard badge label derived from `guard_engaged`: "GUARD" when the /// guard toggle is engaged, "--" otherwise. pub guard_status: String, - /// True when guard mode is engaged for this formation. Read from the - /// `guard:{formation_id}` config row — the same key `toggle_formation_guard` - /// writes (finding 78 / plan 1.12). Gates Dissolve, ChangeIntent, - /// RemoveMember, and Rally in `commands.rs::is_enabled_for`. + /// True when guard mode is engaged for this formation. Read through + /// `config::formation_guard_engaged`, the single accessor for the + /// `guard:{formation_id}` config row that `toggle_formation_guard` writes + /// (finding 78 / plan 1.12). Gates Dissolve, ChangeIntent, RemoveMember, + /// and Rally in `commands.rs::is_enabled_for`. /// - /// KNOWN DIVERGENCE: this reads the config row, not the live formation's - /// `constraints.guard_mode`. The `formation:guard` toggle writes only the - /// config row; the live `Formation` in the bot tick loop (which - /// `tick_steps/handle_command.rs::guarded` checks) reads - /// `constraints.guard_mode`, set once at deploy/spawn time and never - /// refreshed from the config row afterward. `LiveFormationReader` has no - /// accessor for a live formation's `constraints.guard_mode` today, so - /// there is no way to source this field from the live formation without - /// extending that trait — out of scope for this change. The two can - /// therefore disagree: toggling guard on a formation whose bot process - /// already has it live-loaded updates the UI eligibility (this field) - /// immediately, but the live enforcement in `handle_command.rs` will not - /// see the change until the formation is redeployed. + /// The row and the live formation's `constraints.guard_mode` cannot + /// disagree: `toggle_formation_guard` writes the row and posts + /// `FormationCommand::SetGuard` in the same call, so live enforcement in + /// `handle_command.rs::guarded` sees a toggle without a redeploy. pub guard_engaged: bool, /// Rally tokens remaining (Monster Hunter carts, §15). pub rally_tokens: i64, @@ -582,11 +574,8 @@ pub async fn list_formations(state: &RuntimeState) -> Result, let momentum_label = tier_label(&momentum_tier); let capabilities = tier_capabilities(&momentum_tier); - // See `guard_engaged` doc comment for the live-vs-config divergence. - let guard_engaged = !config::get_config(&*state.store, &format!("guard:{}", f.id)) - .await - .unwrap_or(serde_json::Value::Null) - .is_null(); + // Single accessor for the guard row — see `guard_engaged`. + let guard_engaged = config::formation_guard_engaged(&*state.store, &f.id).await; let guard_status = guard_status_label(guard_engaged).to_owned(); // Operational count: prefer the live reader (accurate — reads diff --git a/docs/guide/formations.md b/docs/guide/formations.md index c8b5b67c..4cd69370 100644 --- a/docs/guide/formations.md +++ b/docs/guide/formations.md @@ -138,6 +138,13 @@ dissolve runs through the cadence tick loop once more to persist final state, then drops. Members that were exclusively in this formation become free; members in multiple formations keep running. +The dissolve also publishes a terminal outcome — on the formation gossip +bus and, when one is wired, into the cross-formation knowledge store. Both +carry `success_count` (the momentum FSM's `consecutive_successes`) and +`failure_count`, which is the momentum FSM's lifetime `interference_total`. +Retrieval scores priors on `success_count / (success_count + failure_count)`, +so the failure side has to be the real count. + ## Persistence Formation *state* persists — membership, intent, guard state, momentum, @@ -270,12 +277,15 @@ refuses synthesized actions classified `Destructive` badge on the formation detail card and is toggled via `POST /formations/{id}/toggle-guard`. -**Known defect.** The toggle writes a `guard:{formation_id}` config row, -but live enforcement reads `formation.constraints.guard_mode`, which is -set once at deploy and never refreshed. Toggling guard on an -already-running formation updates the API and the badge but does **not** -change what the running formation refuses until it is redeployed. The -divergence is noted in `crates/springtale-runtime/src/operations/formations.rs`. +The toggle is live. `operations::config::toggle_formation_guard` writes +the durable `guard:{formation_id}` config row *and* posts +`FormationCommand::SetGuard`, which the bot applies to the running +formation's `constraints.guard_mode` on its next command drain — the same +channel dissolve, pause and intent change ride. Deploy seeds the live flag +from the same row (`lifecycle::spawn_formation`), and every reader of the +row goes through `config::formation_guard_engaged`, so the badge, the API +and what the running formation actually refuses cannot disagree, with or +without a redeploy. The intent is to make accidental destruction harder: a formation that just hit Fever and is producing useful output is exactly the one you diff --git a/tauri/packages/ui/src/colony/panel/DetailPanel.tsx b/tauri/packages/ui/src/colony/panel/DetailPanel.tsx index 25e177b9..6b3ab9b5 100644 --- a/tauri/packages/ui/src/colony/panel/DetailPanel.tsx +++ b/tauri/packages/ui/src/colony/panel/DetailPanel.tsx @@ -8,6 +8,7 @@ import type { ColonySelection, } from "../types"; import { + AUTONOMY_COLORS, MOMENTUM_COLORS, MOMENTUM_NAMES, MOMENTUM_UNLOCKS, @@ -152,13 +153,11 @@ export const DetailPanel: Component<{ class={`colony-autonomy-pip ${a.autonomy === level ? "font-bold" : ""}`} classList={{ "is-active": a.autonomy === level }} style={{ - "--colony-color": [ - "var(--color-status-ok)", - "var(--color-role-scout)", - "var(--color-status-warn)", - "var(--color-status-error)", - "var(--color-role-analyst)", - ][level], + // One colour per `AUTONOMY_LABELS` entry: + // observe → suggest → approve → autonomous. + // A fifth colour outlived the SELF-DIRECT level + // that was cut from the label list. + "--colony-color": AUTONOMY_COLORS[level], }} > {level} diff --git a/tauri/packages/ui/src/colony/types.ts b/tauri/packages/ui/src/colony/types.ts index 2cc959b3..b18e79b1 100644 --- a/tauri/packages/ui/src/colony/types.ts +++ b/tauri/packages/ui/src/colony/types.ts @@ -256,6 +256,14 @@ export const ROLE_COLORS: Record = { /** The four levels the backend actually has (`AutonomyLevel`): observe → * suggest → approve → autonomous. No fifth level exists. */ export const AUTONOMY_LABELS = ["OBSERVE", "SUGGEST", "APPROVE", "AUTONOMOUS"]; +/** Pip colour per autonomy level — one entry per `AUTONOMY_LABELS` entry, + * indexed by the backend's `AutonomyLevel` ordinal. */ +export const AUTONOMY_COLORS = [ + "var(--color-status-ok)", + "var(--color-role-scout)", + "var(--color-status-warn)", + "var(--color-status-error)", +]; export const MOMENTUM_NAMES = ["COLD", "WARM", "HOT", "FEVER"]; export const MOMENTUM_COLORS = [ "var(--color-momentum-cold)", From 80fda6e9f170b041cd47e1d663e294e384163fde Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 11:32:23 -0700 Subject: [PATCH 2/5] =?UTF-8?q?starters:=20one=20way=20to=20start=20?= =?UTF-8?q?=E2=80=94=20delete=20the=20template=20system=20(finding=20112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipes are the starter system, so the parallel scaffold generator is gone rather than kept alongside it. Deleted `operations::templates`, `springtaled`'s `GET /templates` + `POST /templates/{name}` routes, `springtale new`, the `template` argument of `springtale init`, the `Template`/`TemplateFile`/`WriteReport` shared types and the `listTemplates`/`writeTemplate` items on the frontend `DataProvider`. The scaffolds map onto what already exists: telegram-bot and discord-bot are onboarding; cron-runner, github-monitor, llm-assistant and llm-swarm are recipes; blank-bot and cli-runner are `springtale init` alone. `github-monitor` and `llm-swarm` were the two the recipe catalogue lacked, added as builtins in the shape of the rest. Docs drop `springtale new` throughout; `docs/guide/templates.md` is deleted. Nothing migrates — this is pre-launch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/src/cli.rs | 21 +- apps/springtale-cli/src/commands/mod.rs | 1 - apps/springtale-cli/src/commands/new.rs | 42 - apps/springtale-cli/src/main.rs | 10 +- apps/springtaled/src/api/mod.rs | 3 - apps/springtaled/src/api/templates.rs | 36 - apps/springtaled/tests/one_way_to_start.rs | 117 +++ .../springtale-runtime/src/operations/mod.rs | 1 - .../recipes/builtin/ai_assistant.rs | 80 ++ .../src/operations/recipes/builtin/coding.rs | 91 ++ .../src/operations/templates.rs | 826 ------------------ docs/current-arch/ARCHITECTURE.md | 1 - docs/guide/architecture.md | 3 +- docs/guide/first-bot.md | 38 +- docs/guide/formations.md | 6 +- docs/guide/recipes.md | 20 +- docs/guide/templates.md | 96 -- docs/reference/cli.md | 35 - docs/visual-gallery.md | 2 +- tauri/packages/types/src/index.ts | 3 - tauri/packages/types/src/operations.ts | 24 - tauri/packages/ui/src/dashboard/types.ts | 6 - tauri/packages/ui/src/web/provider.ts | 11 - 23 files changed, 317 insertions(+), 1156 deletions(-) delete mode 100644 apps/springtale-cli/src/commands/new.rs delete mode 100644 apps/springtaled/src/api/templates.rs create mode 100644 apps/springtaled/tests/one_way_to_start.rs delete mode 100644 crates/springtale-runtime/src/operations/templates.rs delete mode 100644 docs/guide/templates.md diff --git a/apps/springtale-cli/src/cli.rs b/apps/springtale-cli/src/cli.rs index 289c8b21..80fe3a6d 100644 --- a/apps/springtale-cli/src/cli.rs +++ b/apps/springtale-cli/src/cli.rs @@ -71,23 +71,10 @@ pub enum Command { /// Initialize Springtale (create data directory, vault, config). /// After setup, optionally links a chat platform and starts the daemon. /// - /// With a `