From 1dd177a583250ca065cb6e7752b9c4c281580e3d Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 17:57:00 -0700 Subject: [PATCH 01/24] py: the module files are the code, not a second copy beside lib.rs The crate carried both a 281-line `lib.rs` with every pyclass inline and five module files defining the same types. Because `lib.rs` never declared the modules, those five files compiled into nothing: dead duplicates that would drift from the definitions actually in use. `lib.rs` becomes the table of contents the crate-structure rule requires, the module files are what compiles, and the Python module entry point moves into its own file beside them. No behaviour change: the same four classes and the same version string are registered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- crates/springtale-py/src/lib.rs | 268 +++-------------------------- crates/springtale-py/src/module.rs | 21 +++ 2 files changed, 41 insertions(+), 248 deletions(-) create mode 100644 crates/springtale-py/src/module.rs diff --git a/crates/springtale-py/src/lib.rs b/crates/springtale-py/src/lib.rs index e883374f..95866a4b 100644 --- a/crates/springtale-py/src/lib.rs +++ b/crates/springtale-py/src/lib.rs @@ -27,255 +27,27 @@ //! Production builds use `maturin build --release -m crates/springtale-py/Cargo.toml` //! which wraps the cdylib in a Python wheel + ships the curated `.pyi` //! type stubs alongside. +//! +//! Rust-side unit tests live behind a feature flag: `cargo test +//! -p springtale-py --features tests` from inside an environment with +//! a linkable Python (so `_PyExc_*` symbols resolve). Default `cargo +//! test` invocations skip these because pyo3 with `extension-module` +//! defers Python symbol resolution to the host interpreter — the test +//! binary has no interpreter to bind against. The Python-side test +//! suite (run via `pytest`) exercises the bindings end-to-end after a +//! `maturin develop` install. #![forbid(unsafe_code)] #![allow(clippy::needless_pass_by_value)] -use pyo3::exceptions::PyValueError; -use pyo3::prelude::*; - -use springtale_cooperation::cadence::IntentPattern as CoreIntent; -use springtale_cooperation::momentum::MomentumTier as CoreTier; -use springtale_cooperation::types::FormationId as CoreFormationId; - -/// Momentum tier — capability gate per `COOPERATION.md §7`. Python sees -/// this as an enum with four members; Rust round-trips through the -/// `MomentumTier::parse` / `Display` pair the rest of the system uses. -#[pyclass(eq, eq_int, frozen, from_py_object)] -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum MomentumTier { - Cold, - Warming, - Hot, - Fever, -} - -impl From for MomentumTier { - fn from(t: CoreTier) -> Self { - match t { - CoreTier::Cold => Self::Cold, - CoreTier::Warming => Self::Warming, - CoreTier::Hot => Self::Hot, - CoreTier::Fever => Self::Fever, - } - } -} - -impl From for CoreTier { - fn from(t: MomentumTier) -> Self { - match t { - MomentumTier::Cold => CoreTier::Cold, - MomentumTier::Warming => CoreTier::Warming, - MomentumTier::Hot => CoreTier::Hot, - MomentumTier::Fever => CoreTier::Fever, - } - } -} - -/// Intent pattern facade. Variants carry their payload as Python -/// strings — the Rust newtype layer (`TaskDescriptor`, `PlanId`, -/// `StabilizeReason`, `DissolveReason`) is collapsed to `Optional[str]` -/// in the Python surface so callers don't have to model every newtype. -#[pyclass(frozen, from_py_object)] -#[derive(Clone, Debug)] -pub struct Intent { - inner: CoreIntent, -} - -#[pymethods] -impl Intent { - /// Reconnoiter — gather information. `target` describes what to - /// observe ("news/feed", "github/issues", etc.). - #[staticmethod] - pub fn reconnoiter(target: String) -> Self { - Self { - inner: CoreIntent::Reconnoiter { - target: springtale_cooperation::cadence::TaskDescriptor(target), - }, - } - } - - /// Execute — act on a known plan. `plan_id` is opaque; passing - /// `None` lets the orchestrator pick. - #[staticmethod] - #[pyo3(signature = (plan_id=None))] - pub fn execute(plan_id: Option) -> Self { - Self { - inner: CoreIntent::Execute { - plan_id: plan_id.map(springtale_cooperation::cadence::PlanId), - }, - } - } - - /// Stabilize — defensive hold. `reason` documents why the formation - /// is pausing. - #[staticmethod] - pub fn stabilize(reason: String) -> Self { - Self { - inner: CoreIntent::Stabilize { - reason: springtale_cooperation::cadence::StabilizeReason(reason), - }, - } - } - - /// Surge — maximum commitment to one objective. - #[staticmethod] - pub fn surge(objective: String) -> Self { - Self { - inner: CoreIntent::Surge { - objective: springtale_cooperation::cadence::TaskDescriptor(objective), - }, - } - } - - /// Dissolve — graceful wind-down. `reason` is recorded into the - /// global knowledge store (G2) so future formations see it. - #[staticmethod] - pub fn dissolve(reason: String) -> Self { - Self { - inner: CoreIntent::Dissolve { - reason: springtale_cooperation::cadence::DissolveReason(reason), - }, - } - } - - /// Variant name as a string — `"reconnoiter" | "execute" | - /// "stabilize" | "surge" | "dissolve"`. Matches the snake_case - /// serde tags the rest of the system uses. - pub fn kind(&self) -> &'static str { - match &self.inner { - CoreIntent::Reconnoiter { .. } => "reconnoiter", - CoreIntent::Execute { .. } => "execute", - CoreIntent::Stabilize { .. } => "stabilize", - CoreIntent::Surge { .. } => "surge", - CoreIntent::Dissolve { .. } => "dissolve", - } - } - - fn __repr__(&self) -> String { - format!("Intent({:?})", self.inner) - } -} - -/// Formation identity. Wraps the 128-bit UUID the rest of the system -/// uses; Python sees it as a string. -#[pyclass(frozen, from_py_object)] -#[derive(Clone, Debug)] -pub struct FormationId { - inner: CoreFormationId, -} - -#[pymethods] -impl FormationId { - /// Generate a fresh formation id. - #[new] - pub fn new() -> Self { - Self { - inner: CoreFormationId::new(), - } - } - - /// Parse a formation id from its canonical UUID string. - #[staticmethod] - pub fn parse(s: &str) -> PyResult { - CoreFormationId::parse(s) - .map(|inner| Self { inner }) - .map_err(|e| PyValueError::new_err(format!("invalid formation id: {e}"))) - } - - /// Canonical UUID string form. - fn __str__(&self) -> String { - self.inner.0.to_string() - } - - fn __repr__(&self) -> String { - format!("FormationId({})", self.inner.0) - } - - fn __eq__(&self, other: &Self) -> bool { - self.inner == other.inner - } - - fn __hash__(&self) -> u64 { - // Stable hash over the UUID's 128 bits; Python's hash is i64 - // so we fold the high 64 into the low 64 via XOR. - let (hi, lo) = self.inner.0.as_u64_pair(); - hi ^ lo - } -} - -impl Default for FormationId { - fn default() -> Self { - Self::new() - } -} - -/// Lightweight Formation handle — read-only view a Python script gets -/// over a known formation. Mirrors the `FormationView` gossip record -/// without the live runtime hookup. -#[pyclass(frozen, from_py_object)] -#[derive(Clone, Debug)] -pub struct Formation { - id: FormationId, - intent: Intent, - momentum_tier: MomentumTier, -} - -#[pymethods] -impl Formation { - /// Construct a new Formation handle. Pure-Python use case is for - /// scripting / simulation; the live runtime in `springtaled` owns - /// the real one. - #[new] - pub fn new(intent: Intent) -> Self { - Self { - id: FormationId::new(), - intent, - momentum_tier: MomentumTier::Cold, - } - } - - #[getter] - pub fn id(&self) -> FormationId { - self.id.clone() - } - - #[getter] - pub fn intent(&self) -> Intent { - self.intent.clone() - } - - #[getter] - pub fn momentum_tier(&self) -> MomentumTier { - self.momentum_tier - } - - fn __repr__(&self) -> String { - format!( - "Formation(id={}, intent={}, tier={:?})", - self.id.inner.0, - self.intent.kind(), - self.momentum_tier, - ) - } -} - -/// Python module entry point. `springtale.MomentumTier`, etc. -#[pymodule] -fn springtale(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add("__version__", env!("CARGO_PKG_VERSION"))?; - Ok(()) -} - -// Rust-side unit tests live behind a feature flag: `cargo test -// -p springtale-py --features tests` from inside an environment with -// a linkable Python (so `_PyExc_*` symbols resolve). Default `cargo -// test` invocations skip these because pyo3 with `extension-module` -// defers Python symbol resolution to the host interpreter — the test -// binary has no interpreter to bind against. The Python-side test -// suite (run via `pytest`) exercises the bindings end-to-end after a -// `maturin develop` install. +pub mod convert; +pub mod formation; +pub mod formation_id; +pub mod intent; +pub mod module; +pub mod momentum; + +pub use formation::Formation; +pub use formation_id::FormationId; +pub use intent::Intent; +pub use momentum::MomentumTier; diff --git a/crates/springtale-py/src/module.rs b/crates/springtale-py/src/module.rs new file mode 100644 index 00000000..7e856b90 --- /dev/null +++ b/crates/springtale-py/src/module.rs @@ -0,0 +1,21 @@ +//! The Python module entry point. Registering the classes is its own +//! concern, kept out of `lib.rs` so the crate root stays a table of +//! contents (`.claude/rules/backend/crate-structure.md`). + +use pyo3::prelude::*; + +use crate::formation::Formation; +use crate::formation_id::FormationId; +use crate::intent::Intent; +use crate::momentum::MomentumTier; + +/// Python module entry point. `springtale.MomentumTier`, etc. +#[pymodule] +pub fn springtale(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + Ok(()) +} From 11d1465d3ba24b52afc39596f2c19223f69f27f3 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 17:57:52 -0700 Subject: [PATCH 02/24] bot: the beat reports a surface reaction and a claimed task together Plan 1.9 says the agent loop runs sense, inbox, react, scan, respond_cfp, sacrifice without early return between the steps. `decide` still had sense and inbox as `else if`, so a primed surface starved the inbox; the scan hit overwrote `tick_action` with its own descriptor; and sacrifice only ran when the scan had run, so an inbox hit skipped the B9 final consideration. `Decision` now carries `surface` next to `tick_action`, sense fills only that, inbox always runs, the scan runs when no task was claimed, and sacrifice always runs. `TickReport` gains `surface_reaction`, attached by the gather phase, so one report carries both descriptors instead of one erasing the other. Test: a primed surface and an open task in the same beat produce one report with the surface reaction and the claimed task's action. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/examples/task-runner.rs | 1 + .../src/cooperation/blackboard_router.rs | 1 + .../build_reports/agent_pipeline/decide.rs | 36 ++++++++----- .../build_reports/agent_pipeline/mod.rs | 19 ++++++- .../build_reports/agent_pipeline/tests.rs | 51 ++++++++++++++++++- .../tick_steps/build_reports/executor/post.rs | 8 ++- .../build_reports/executor/test_support.rs | 1 + .../build_reports/executor/tests.rs | 6 +-- .../build_reports/fold_interference.rs | 1 + .../src/runtime/tick_steps/update_momentum.rs | 1 + .../benches/formation_scaling.rs | 1 + .../benches/rally_cascade.rs | 1 + .../src/awareness/types.rs | 1 + crates/springtale-cooperation/src/cadence.rs | 8 +++ .../src/interference/detector.rs | 2 + .../src/mental_model/learning.rs | 1 + .../src/rally/cascade.rs | 1 + .../src/tick_processor.rs | 1 + .../tests/properties.rs | 1 + .../tests/replay_determinism.rs | 2 + 20 files changed, 125 insertions(+), 19 deletions(-) diff --git a/apps/springtale-cli/examples/task-runner.rs b/apps/springtale-cli/examples/task-runner.rs index abd094bc..67269e00 100644 --- a/apps/springtale-cli/examples/task-runner.rs +++ b/apps/springtale-cli/examples/task-runner.rs @@ -134,6 +134,7 @@ async fn main() -> Result<(), Box> { latency: Duration::from_millis(5), intent_alignment: 1.0, interference_with: Vec::new(), + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, }; let _ = reports_tx.send(report).await; diff --git a/crates/springtale-bot/src/cooperation/blackboard_router.rs b/crates/springtale-bot/src/cooperation/blackboard_router.rs index d4c826d2..279c6fbf 100644 --- a/crates/springtale-bot/src/cooperation/blackboard_router.rs +++ b/crates/springtale-bot/src/cooperation/blackboard_router.rs @@ -326,6 +326,7 @@ mod tests { latency: std::time::Duration::from_millis(0), intent_alignment: 0.95, interference_with: vec![], + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, }]); diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/decide.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/decide.rs index 9a4ce1c4..9575a278 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/decide.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/decide.rs @@ -62,8 +62,15 @@ impl Snapshots { } /// What one member decided this beat. +/// +/// `surface` and `tick_action` are separate fields on purpose (plan 1.9): +/// the beat can both react to a primed surface and claim a task, and +/// neither descriptor may overwrite the other. pub struct Decision { pub agent: AgentId, + /// L0 surface reaction, if a primed surface was in scope. Never a + /// task claim; reported alongside whatever the task path produced. + pub surface: Option, pub tick_action: Option, pub chosen_task: Option, pub sacrifice: Option, @@ -78,12 +85,12 @@ pub async fn run( ) -> Decision { let mut decision = Decision { agent: member.agent_id, + surface: None, tick_action: None, chosen_task: None, sacrifice: None, bid: None, }; - let mut needs_scan = true; // Borrow scoping: react needs `&mut member.awareness`, while sense, // inbox, scan and respond_cfp read it through `AgentContext`. The ctx @@ -99,15 +106,15 @@ pub async fn run( capabilities: &member.capabilities, awareness: &member.awareness, }; + // Sense and inbox both run: the layers are ordered, not + // exclusive (plan 1.9 / finding 40). A primed surface parks its + // descriptor in `surface` and never touches the task path. if let Some(r) = step::sense::run(s.surfaces.as_ref(), &member.awareness, &ctx) { - // A surface reaction is not a task claim: the scan still runs - // (plan 1.9 / finding 40). Only an inbox hit skips it. - decision.tick_action = r.action; - decision.chosen_task = r.task_claimed; - } else if let Some(r) = step::inbox::run(s.router.as_ref(), &ctx).await { + decision.surface = r.action; + } + if let Some(r) = step::inbox::run(s.router.as_ref(), &ctx).await { decision.tick_action = r.action; decision.chosen_task = r.task_claimed; - needs_scan = false; } } @@ -126,7 +133,12 @@ pub async fn run( capabilities: &member.capabilities, awareness: &member.awareness, }; - if needs_scan && let Some(r) = step::scan::run(s.router.as_ref(), &ctx).await { + // The scan only runs when the inbox found nothing to do: an inbox + // hit is already this beat's task. A surface reaction never starves + // it. + if decision.chosen_task.is_none() + && let Some(r) = step::scan::run(s.router.as_ref(), &ctx).await + { decision.tick_action = r.action; decision.chosen_task = r.task_claimed; } @@ -137,11 +149,9 @@ pub async fn run( // B9 final consideration — at Hot+ tier the agent checks whether // yielding to a more-loaded peer is the higher-utility play; a yield // drops the chosen task and reports a yield-shaped descriptor. - if needs_scan { - decision.sacrifice = step::sacrifice::run(&ctx, s.rally_tokens, s.member_count, &[]); - if decision.sacrifice.is_some() { - decision.chosen_task = None; - } + decision.sacrifice = step::sacrifice::run(&ctx, s.rally_tokens, s.member_count, &[]); + if decision.sacrifice.is_some() { + decision.chosen_task = None; } decision } diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/mod.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/mod.rs index 072789a7..ba47c9f8 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/mod.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/mod.rs @@ -86,6 +86,15 @@ pub async fn run( } } + // The beat's L0 surface reactions, kept aside so the task path can + // not overwrite them (plan 1.9). Re-attached to each member's report + // in the gather phase. + let surfaces: std::collections::HashMap = + decisions + .iter_mut() + .filter_map(|d| d.surface.take().map(|a| (d.agent, a))) + .collect(); + // 2. Claim on the blackboard now. let mut settled: Vec<(AgentId, ExecuteOutcome)> = Vec::new(); let mut jobs = Vec::new(); @@ -151,12 +160,20 @@ pub async fn run( outcomes.sort_by_key(|(agent, _)| agent.0); let mut proposals = Vec::new(); let mut reports = Vec::new(); + let mut surfaces = surfaces; for (agent, mut outcome) in outcomes { formation.tick_stress.absorb(&outcome); if let Some(task) = outcome.consensus_task.take() { proposals.push(task); } - if let Some(report) = executor::post(formation, agent, outcome, tick, cooperation_tx) { + if let Some(report) = executor::post( + formation, + agent, + outcome, + tick, + cooperation_tx, + surfaces.remove(&agent), + ) { let _ = reports_sender.try_send(report.clone()); reports.push(report); } diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/tests.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/tests.rs index e69bdf7f..84a5528e 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/tests.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/tests.rs @@ -10,8 +10,11 @@ use tokio::sync::mpsc; use springtale_cooperation::action::SubTask; use springtale_cooperation::action_state::ActionState; -use springtale_cooperation::cadence::{AgentId, IntentPattern, Tick, TickReport}; +use springtale_cooperation::cadence::{ + ActionDescriptor, AgentId, IntentPattern, Tick, TickReport, +}; use springtale_cooperation::routing::direct::assignment; +use springtale_cooperation::stigmergy::types::SurfaceType; use springtale_cooperation::types::{ApprovalPolicy, FormationConstraints}; use crate::cooperation::blackboard::trait_::Blackboard; @@ -186,3 +189,49 @@ async fn test_run_max_concurrent_actions_one_never_overlaps_dispatches() { "no two dispatches overlapped under cap 1" ); } + +/// Plan 1.9: the layers are ordered, not exclusive. A primed surface and +/// an open task arriving in the same beat are two descriptors, and the +/// member's single report carries both — the surface reaction no longer +/// overwrites the task's action, and the task no longer hides the +/// reaction. +#[tokio::test] +async fn test_run_primed_surface_and_open_task_report_both_in_one_beat() { + let mut b = beat(1, Duration::from_millis(10), 0); + let agent = b.formation.members[0].agent_id; + b.formation.surfaces.deposit( + agent, + SurfaceType::Primed { + trigger: ActionDescriptor { + kind: "rate_limit".into(), + target: None, + payload_hash: 0, + }, + }, + serde_json::json!({}), + None, + None, + ); + + let reports = run_beat(&mut b, &make_tick(1, Duration::from_secs(1))).await; + + assert_eq!(reports.len(), 1); + let report = &reports[0]; + assert_eq!( + report + .surface_reaction + .as_ref() + .map(|a| (a.kind.as_str(), a.target.as_deref())), + Some(("surface_reaction", Some("rate_limit"))), + "the primed surface reaction rode along on the report" + ); + assert!( + report.action_taken.is_some(), + "the inbox task still produced this beat's action" + ); + assert!(alignment_is(report, 1.0)); + assert!( + b.formation.blackboard.read_result(b.tasks[0].id).is_some(), + "the claimed task ran in the same beat as the surface reaction" + ); +} diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs index d19cfbad..8b05c010 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs @@ -33,12 +33,16 @@ pub struct PostEnv<'a> { /// Post one member's outcome and sample its attention load. `None` when /// the member left the formation while its dispatch was in flight. +/// +/// `surface` is the L0 reaction the decide phase kept aside (plan 1.9); +/// it rides on the report next to `action_taken`, never instead of it. pub fn post( formation: &mut Formation, agent: AgentId, outcome: ExecuteOutcome, tick: &Tick, cooperation_tx: Option<&broadcast::Sender>, + surface: Option, ) -> Option { let env = PostEnv { formation_id: formation.id.0, @@ -52,7 +56,7 @@ pub fn post( let duration_ms = outcome.duration_ms; let said = utterance_for(&outcome.state, outcome.action_descriptor.is_some()); let member = formation.members.iter_mut().find(|m| m.agent_id == agent)?; - let report = post_member(member, &env, outcome, tick); + let report = post_member(member, &env, outcome, tick, surface); // Attention is earned by acting (Army of Two aggro): a member with // work in hand — or still in flight — generates load this beat, and @@ -91,6 +95,7 @@ pub fn post_member( env: &PostEnv<'_>, outcome: ExecuteOutcome, tick: &Tick, + surface: Option, ) -> TickReport { if let Some(done) = outcome.dispatched { if let Some(active) = member.active_task.as_mut() { @@ -191,6 +196,7 @@ pub fn post_member( latency: Duration::from_millis(outcome.duration_ms), intent_alignment: outcome.alignment, interference_with: vec![], + surface_reaction: surface, // 0.3 — the beat's momentum signal. `Requested` (a dispatch // carried past its beat) and `Init` (a claim, an observe/suggest // surface reaction, a yield) are not work done, whatever their diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs index a8fc45ab..04d000f9 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs @@ -181,6 +181,7 @@ pub(crate) fn successful_tick_result(agent: AgentId) -> FormationTickResult { latency: Duration::from_millis(1), intent_alignment: 1.0, interference_with: vec![], + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, }], interferences: vec![], diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/tests.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/tests.rs index bbf78d4b..a2db546c 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/tests.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/tests.rs @@ -102,7 +102,7 @@ async fn run_executor( direct_inbox: formation.direct_inbox.as_ref(), cooperation_tx: None, }; - post_member(member, &env, outcome, &tick); + post_member(member, &env, outcome, &tick, None); proposal } @@ -340,7 +340,7 @@ async fn test_post_failed_outcome_utters_failed_on_bus_and_observer() { denied: false, }; - let report = super::post(&mut formation, agent, outcome, &tick, Some(&tx)); + let report = super::post(&mut formation, agent, outcome, &tick, Some(&tx), None); assert!(report.is_some()); let heard = peer_sub.state_rx.try_recv().expect("peer hears the burst"); @@ -377,7 +377,7 @@ async fn test_post_failed_outcome_utters_failed_on_bus_and_observer() { throttled: false, denied: false, }; - super::post(&mut formation, agent, again, &tick, Some(&tx)); + super::post(&mut formation, agent, again, &tick, Some(&tx), None); assert!( peer_sub.state_rx.try_recv().is_err(), "blocked repeat must not reach the bus" diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs index 42b5832b..d6189721 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs @@ -55,6 +55,7 @@ mod tests { latency: Duration::from_millis(1), intent_alignment: 0.9, interference_with: vec![], + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs b/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs index 82b793e5..c90ef800 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs @@ -164,6 +164,7 @@ mod tests { latency: Duration::from_millis(1), intent_alignment: alignment, interference_with: vec![], + surface_reaction: None, state, } } diff --git a/crates/springtale-cooperation/benches/formation_scaling.rs b/crates/springtale-cooperation/benches/formation_scaling.rs index a217ad36..f887dec8 100644 --- a/crates/springtale-cooperation/benches/formation_scaling.rs +++ b/crates/springtale-cooperation/benches/formation_scaling.rs @@ -39,6 +39,7 @@ fn synthetic_report(i: usize, tick: u64) -> TickReport { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: Vec::new(), + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/benches/rally_cascade.rs b/crates/springtale-cooperation/benches/rally_cascade.rs index a39110f3..cf09b68d 100644 --- a/crates/springtale-cooperation/benches/rally_cascade.rs +++ b/crates/springtale-cooperation/benches/rally_cascade.rs @@ -33,6 +33,7 @@ fn synth_report(agent: AgentId, alignment: f32) -> TickReport { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: Vec::new(), + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/src/awareness/types.rs b/crates/springtale-cooperation/src/awareness/types.rs index c352cc6b..f585b8d0 100644 --- a/crates/springtale-cooperation/src/awareness/types.rs +++ b/crates/springtale-cooperation/src/awareness/types.rs @@ -547,6 +547,7 @@ mod tests { latency: std::time::Duration::from_millis(10), intent_alignment: 0.8, interference_with: vec![my_id], + surface_reaction: None, state: crate::action_state::ActionState::Success, }; diff --git a/crates/springtale-cooperation/src/cadence.rs b/crates/springtale-cooperation/src/cadence.rs index 2eb4d6b4..92a055ee 100644 --- a/crates/springtale-cooperation/src/cadence.rs +++ b/crates/springtale-cooperation/src/cadence.rs @@ -214,6 +214,12 @@ pub struct TickReport { pub intent_alignment: f32, /// Agents this action interfered with (Helldivers friendly fire). pub interference_with: Vec, + /// The L0 surface the member reacted to this beat, when one was + /// primed (plan 1.9). A surface reaction is not a task claim: it is + /// reported *alongside* `action_taken`, so a beat that both reacted + /// to a surface and claimed a task carries both descriptors instead + /// of one overwriting the other. + pub surface_reaction: Option, /// Lifecycle state the member's action reached this beat. /// /// The momentum step classifies on this, not on `intent_alignment`: @@ -342,6 +348,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: vec![], + surface_reaction: None, state: crate::action_state::ActionState::Success, }) .await @@ -370,6 +377,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 0.5, interference_with: vec![], + surface_reaction: None, state: crate::action_state::ActionState::Success, }) .await diff --git a/crates/springtale-cooperation/src/interference/detector.rs b/crates/springtale-cooperation/src/interference/detector.rs index 0884c854..418680ea 100644 --- a/crates/springtale-cooperation/src/interference/detector.rs +++ b/crates/springtale-cooperation/src/interference/detector.rs @@ -391,6 +391,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 1.0, interference_with: vec![b], + surface_reaction: None, state: crate::action_state::ActionState::Success, }, TickReport { @@ -400,6 +401,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 1.0, interference_with: vec![a], + surface_reaction: None, state: crate::action_state::ActionState::Success, }, ]; diff --git a/crates/springtale-cooperation/src/mental_model/learning.rs b/crates/springtale-cooperation/src/mental_model/learning.rs index 56392f04..dd9e51bf 100644 --- a/crates/springtale-cooperation/src/mental_model/learning.rs +++ b/crates/springtale-cooperation/src/mental_model/learning.rs @@ -140,6 +140,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: 0.9, interference_with: vec![], + surface_reaction: None, state: crate::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/src/rally/cascade.rs b/crates/springtale-cooperation/src/rally/cascade.rs index 3f59f5b0..65ecbc53 100644 --- a/crates/springtale-cooperation/src/rally/cascade.rs +++ b/crates/springtale-cooperation/src/rally/cascade.rs @@ -220,6 +220,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: vec![], + surface_reaction: None, state, } } diff --git a/crates/springtale-cooperation/src/tick_processor.rs b/crates/springtale-cooperation/src/tick_processor.rs index 4df9e74a..4dd1b820 100644 --- a/crates/springtale-cooperation/src/tick_processor.rs +++ b/crates/springtale-cooperation/src/tick_processor.rs @@ -120,6 +120,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: interferes, + surface_reaction: None, state: crate::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/tests/properties.rs b/crates/springtale-cooperation/tests/properties.rs index 37aaf4bc..56966f0b 100644 --- a/crates/springtale-cooperation/tests/properties.rs +++ b/crates/springtale-cooperation/tests/properties.rs @@ -182,6 +182,7 @@ fn make_report(agent: AgentId, kind: &str, target: Option<&str>, tick: u64) -> T latency: Duration::from_millis(5), intent_alignment: 0.9, interference_with: Vec::new(), + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/tests/replay_determinism.rs b/crates/springtale-cooperation/tests/replay_determinism.rs index b997c047..37c46c9d 100644 --- a/crates/springtale-cooperation/tests/replay_determinism.rs +++ b/crates/springtale-cooperation/tests/replay_determinism.rs @@ -97,6 +97,7 @@ impl From<&ReportRecord> for TickReport { latency: Duration::from_millis(r.latency_ms), intent_alignment: r.intent_alignment, interference_with: Vec::new(), + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, } } @@ -117,6 +118,7 @@ fn synth_reports(tick: u64, n: usize) -> Vec { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: Vec::new(), + surface_reaction: None, state: springtale_cooperation::action_state::ActionState::Success, }) .collect() From 6acbcc61d2019aeeddfa60cd84655988cafcd462 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:05:55 -0700 Subject: [PATCH 03/24] cooperation: momentum thresholds are a per-formation constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1.3 says `FormationConstraints` carries `MomentumConfig` "so it is per formation like every other constraint". It stalled because the constraint struct is `Serialize + Type` while the config derived only `Deserialize`. `MomentumConfig` and `TierThreshold` now derive `Serialize`, `Deserialize`, `Type` and `PartialEq`, which is what the constraint struct needs, and `FormationConstraints` carries the table. `Formation::new` builds its `MomentumState` with `with_config` from its own constraints, so two formations deployed with different thresholds each promote on their own numbers. No generated TypeScript changed: `FormationConstraints` is not part of the exported command or event surface (tauri-specta `.export()` is disabled in `tauri/apps/desktop/src-tauri/src/lib.rs` pending specta-rs/specta#455), and nothing under `tauri/packages/types/src/generated` references it. Test: two formations, same two clean actions, different Cold rows — the eager one reaches Warming, the patient one stays Cold. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/cooperation/formation.rs | 79 ++++++++++++++++++- crates/springtale-cooperation/src/momentum.rs | 7 +- crates/springtale-cooperation/src/types.rs | 6 ++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/crates/springtale-bot/src/cooperation/formation.rs b/crates/springtale-bot/src/cooperation/formation.rs index e08079ba..5d4e88e7 100644 --- a/crates/springtale-bot/src/cooperation/formation.rs +++ b/crates/springtale-bot/src/cooperation/formation.rs @@ -498,12 +498,16 @@ impl Formation { let cfp_initiator = Arc::new(tokio::sync::Mutex::new(cfp_initiator_inner)); let cfp_rx = cfp_channels.cfp_tx.subscribe(); + // Plan 1.3: the promotion table is a per-formation constraint, + // so the momentum state is built from this formation's own + // configuration. + let momentum = MomentumState::with_config(constraints.momentum.clone()); let formation = Self { id: FormationId::new(), intent, paused: false, constraints, - momentum: MomentumState::default(), + momentum, blackboard, shared_env: Arc::new(SharedEnvironment::new()), fuel, @@ -1308,3 +1312,76 @@ mod tests { assert!(format!("{err}").contains("unknown barrier")); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod momentum_config_tests { + use super::*; + use springtale_cooperation::momentum::{MomentumConfig, TickCounts, TierThreshold}; + + fn constraints(min_actions: u32) -> FormationConstraints { + FormationConstraints { + momentum: MomentumConfig { + promote: [ + TierThreshold { + min_actions, + min_success: 0.80, + max_duplicate: 1.00, + }, + TierThreshold { + min_actions: 8, + min_success: 0.90, + max_duplicate: 0.30, + }, + TierThreshold { + min_actions: 15, + min_success: 0.95, + max_duplicate: 0.10, + }, + ], + }, + ..FormationConstraints::default() + } + } + + fn formation_with(min_actions: u32) -> Formation { + Formation::new_disconnected( + vec![FormationMember::from_strings( + AgentId::new(), + vec!["test".into()], + )], + IntentPattern::Execute { plan_id: None }, + constraints(min_actions), + ) + } + + /// Plan 1.3: the promotion table is per formation. Two formations + /// deployed at the same moment, given the same two clean actions, + /// promote on their own numbers — not on a shared constant. + #[test] + fn momentum_config_is_per_formation() { + let mut eager = formation_with(2); + let mut patient = formation_with(9); + let counts = TickCounts { + actions: 1, + successes: 1, + ..TickCounts::default() + }; + for _ in 0..2 { + eager.momentum.record_successful_tick(&counts); + patient.momentum.record_successful_tick(&counts); + } + + assert_eq!( + eager.momentum.tier, + MomentumTier::Warming, + "two actions clear this formation's own Cold row" + ); + assert_eq!( + patient.momentum.tier, + MomentumTier::Cold, + "the same two actions do not clear a nine-action row" + ); + assert_eq!(eager.constraints.momentum.promote[0].min_actions, 2); + } +} diff --git a/crates/springtale-cooperation/src/momentum.rs b/crates/springtale-cooperation/src/momentum.rs index bf9af313..46be63c0 100644 --- a/crates/springtale-cooperation/src/momentum.rs +++ b/crates/springtale-cooperation/src/momentum.rs @@ -228,20 +228,21 @@ impl RunWindow { /// [`RunWindow`]. No `max_interference`: an interference restarts the /// window, so its rate is always zero at promotion time; interference is /// enforced by the Patapon rule (breaks the run, demotes Fever) instead. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct TierThreshold { pub min_actions: u32, pub min_success: f32, pub max_duplicate: f32, } -/// `[cooperation.momentum]` in springtale.toml. Defaults are Springtale's +/// `[cooperation.momentum]` in springtale.toml, carried per formation by +/// [`crate::types::FormationConstraints`]. Defaults are Springtale's /// own starting numbers, not from any game. They are configuration, not /// constants, for the same reason Left 4 Dead ships every Director number /// as a cvar or `DirectorOptions` field (COOPERATION.md A.1.1) and Total War /// keeps its morale and fatigue numbers in database tables (A.4.1): tuning /// happens after play, not before. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct MomentumConfig { /// Rows for `Cold → Warming`, `Warming → Hot`, `Hot → Fever`. pub promote: [TierThreshold; 3], diff --git a/crates/springtale-cooperation/src/types.rs b/crates/springtale-cooperation/src/types.rs index bff7d971..0f058921 100644 --- a/crates/springtale-cooperation/src/types.rs +++ b/crates/springtale-cooperation/src/types.rs @@ -222,6 +222,11 @@ pub struct FormationConstraints { /// Maximum autonomy any member can reach, regardless of their individual /// setting. A ceiling of `Suggest` overrides a member set to `ActAutonomously`. pub autonomy_ceiling: AutonomyLevel, + /// Promotion table for this formation's momentum (plan 1.3, + /// `[cooperation.momentum]`). Per formation like every other + /// constraint: two formations deployed with different thresholds + /// each promote on their own numbers. + pub momentum: crate::momentum::MomentumConfig, } impl Default for FormationConstraints { @@ -233,6 +238,7 @@ impl Default for FormationConstraints { fuel_budget: FuelAmount(100_000), destructive_action_policy: ApprovalPolicy::AlwaysRequire, autonomy_ceiling: AutonomyLevel::ActAutonomously, + momentum: crate::momentum::MomentumConfig::default(), } } } From 0990407ced2257c22012cefe3ce0c1c31e620866 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:06:56 -0700 Subject: [PATCH 04/24] cooperation: pacing weights are configuration, not constants Plan 1.5 specifies `[cooperation.pacing]` with the peak threshold, sustain and relax seconds, decay rate and the four stress weights, for the reason Left 4 Dead ships every Director number as a cvar or `DirectorOptions` field: tuning happens after play, not before. They were `const`s. New `pacing/config.rs` holds `PacingConfig` with those eight fields, the plan's defaults kept as named consts and documented against Booth's deck. `PacingManager` carries one and reads every number from it; `FormationConstraints` carries one too, so it reaches the manager the same way the momentum thresholds do. Test: a formation tuned to peak at 0.1 with a 60-second sustain peaks on one failure and does not fade after the default four seconds, while a default manager given the same sample stays in BuildUp. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/cooperation/formation.rs | 9 +- .../src/pacing/config.rs | 84 ++++++++++++++ .../src/pacing/manager.rs | 109 ++++++++++++------ .../springtale-cooperation/src/pacing/mod.rs | 11 +- crates/springtale-cooperation/src/types.rs | 4 + 5 files changed, 179 insertions(+), 38 deletions(-) create mode 100644 crates/springtale-cooperation/src/pacing/config.rs diff --git a/crates/springtale-bot/src/cooperation/formation.rs b/crates/springtale-bot/src/cooperation/formation.rs index 5d4e88e7..2b5eb3bb 100644 --- a/crates/springtale-bot/src/cooperation/formation.rs +++ b/crates/springtale-bot/src/cooperation/formation.rs @@ -498,10 +498,11 @@ impl Formation { let cfp_initiator = Arc::new(tokio::sync::Mutex::new(cfp_initiator_inner)); let cfp_rx = cfp_channels.cfp_tx.subscribe(); - // Plan 1.3: the promotion table is a per-formation constraint, - // so the momentum state is built from this formation's own - // configuration. + // Plan 1.3 / 1.5: the promotion table and the Director numbers + // are per-formation constraints, so the momentum state and the + // pacing manager are built from this formation's own config. let momentum = MomentumState::with_config(constraints.momentum.clone()); + let pacing = PacingManager::with_config(constraints.pacing.clone()); let formation = Self { id: FormationId::new(), intent, @@ -512,7 +513,7 @@ impl Formation { shared_env: Arc::new(SharedEnvironment::new()), fuel, orchestrator: None, - pacing: PacingManager::default(), + pacing, rally: FormationRally::new(rally_budget, 64), attention_broker: Arc::new(AttentionBroker::for_agents(&agent_ids)), supervisor: FormationSupervisor::default(), diff --git a/crates/springtale-cooperation/src/pacing/config.rs b/crates/springtale-cooperation/src/pacing/config.rs new file mode 100644 index 00000000..06a03900 --- /dev/null +++ b/crates/springtale-cooperation/src/pacing/config.rs @@ -0,0 +1,84 @@ +//! `[cooperation.pacing]` — every number in Booth's Director loop, as +//! configuration rather than a constant. +//! +//! Booth's deck (GDC 2009, slides 79–92) gives the timings; the four +//! stress weights are Springtale's own starting values. They are +//! configuration for the same reason [`crate::momentum::MomentumConfig`] +//! is: Left 4 Dead ships every Director number as a cvar or a +//! `DirectorOptions` field (COOPERATION.md A.1.1), and Total War keeps +//! its morale and fatigue numbers in database tables (A.4.1). Tuning +//! happens after play, not before. +//! +//! [`crate::types::FormationConstraints`] carries one of these, so a +//! formation paces on its own numbers like every other constraint. + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// Intensity at which `BuildUp` gives way to `SustainPeak`. +pub const PEAK_THRESHOLD: f32 = 0.6; +/// Booth: "3-5 seconds after Survivor Intensity has peaked." +pub const SUSTAIN_SECS: u64 = 4; +/// Booth: "30-45 seconds, or until Survivors have traveled far enough." +pub const RELAX_SECS: u64 = 35; +/// Booth: "Decay Survivor Intensity towards zero over time." +pub const DECAY_PER_SEC: f32 = 0.05; +/// Booth: "When injured by the Infected, proportional to damage taken." +pub const W_FAILURE: f32 = 0.3; +/// Booth: "When player is pulled/pushed off of a ledge by the Infected." +pub const W_INTERFERENCE: f32 = 0.4; +/// Sentinel `Throttle` verdicts — a nearby threat, not a wound. +pub const W_THROTTLE: f32 = 0.1; +/// Approval denials / quarantines — the formation was stopped. +pub const W_DENIAL: f32 = 0.2; + +/// Per-formation pacing numbers: the peak threshold, the two phase +/// timings, the idle decay rate, and the four stress weights. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] +pub struct PacingConfig { + /// Intensity at which `BuildUp` hands over to `SustainPeak`. + pub peak_threshold: f32, + /// How long `SustainPeak` holds before `PeakFade`. + pub sustain_secs: u64, + /// How long `Relax` holds before `BuildUp` resumes. + pub relax_secs: u64, + /// Intensity shed per second while the formation is not engaged. + pub decay_per_sec: f32, + /// Weight of one failed action. + pub w_failure: f32, + /// Weight of one interference event. + pub w_interference: f32, + /// Weight of one sentinel `Throttle` verdict. + pub w_throttle: f32, + /// Weight of one approval denial or quarantine. + pub w_denial: f32, +} + +impl Default for PacingConfig { + fn default() -> Self { + Self { + peak_threshold: PEAK_THRESHOLD, + sustain_secs: SUSTAIN_SECS, + relax_secs: RELAX_SECS, + decay_per_sec: DECAY_PER_SEC, + w_failure: W_FAILURE, + w_interference: W_INTERFERENCE, + w_throttle: W_THROTTLE, + w_denial: W_DENIAL, + } + } +} + +impl PacingConfig { + /// `sustain_secs` as a `Duration`. + pub fn sustain(&self) -> Duration { + Duration::from_secs(self.sustain_secs) + } + + /// `relax_secs` as a `Duration`. + pub fn relax(&self) -> Duration { + Duration::from_secs(self.relax_secs) + } +} diff --git a/crates/springtale-cooperation/src/pacing/manager.rs b/crates/springtale-cooperation/src/pacing/manager.rs index 68ad1d4f..b4083586 100644 --- a/crates/springtale-cooperation/src/pacing/manager.rs +++ b/crates/springtale-cooperation/src/pacing/manager.rs @@ -1,27 +1,14 @@ //! PacingManager — intensity is stress; at peak, back off; frequency //! changes, amplitude never does (Booth, GDC 2009, slides 79–92). +//! +//! Every number the loop uses lives in [`PacingConfig`], per formation +//! (plan 1.5). use std::time::{Duration, Instant}; +use super::config::PacingConfig; use super::types::{PacingPhase, PacingTransition}; -/// Intensity at which `BuildUp` gives way to `SustainPeak`. -pub const PEAK_THRESHOLD: f32 = 0.6; -/// Booth: "3-5 seconds after Survivor Intensity has peaked." -pub const SUSTAIN: Duration = Duration::from_secs(4); -/// Booth: "30-45 seconds, or until Survivors have traveled far enough." -pub const RELAX: Duration = Duration::from_secs(35); -/// Booth: "Decay Survivor Intensity towards zero over time." -pub const DECAY_PER_SEC: f32 = 0.05; -/// Booth: "When injured by the Infected, proportional to damage taken." -pub const W_FAILURE: f32 = 0.3; -/// Booth: "When player is pulled/pushed off of a ledge by the Infected." -pub const W_INTERFERENCE: f32 = 0.4; -/// Sentinel `Throttle` verdicts — a nearby threat, not a wound. -pub const W_THROTTLE: f32 = 0.1; -/// Approval denials / quarantines — the formation was stopped. -pub const W_DENIAL: f32 = 0.2; - /// One tick's stress inputs. Booth's increase rules (slide 80) mapped to /// a bot formation. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -42,6 +29,8 @@ pub struct StressSample { /// Manages pacing for a formation. pub struct PacingManager { pub current_phase: PacingPhase, + /// This formation's Director numbers (plan 1.5). + pub config: PacingConfig, /// Booth's Survivor Intensity, 0.0–1.0. Stress, not work done. pub intensity: f32, pub disruption_count: u32, @@ -55,6 +44,7 @@ impl Default for PacingManager { let now = Instant::now(); Self { current_phase: PacingPhase::BuildUp { started: now }, + config: PacingConfig::default(), intensity: 0.0, disruption_count: 0, clock: now, @@ -63,33 +53,48 @@ impl Default for PacingManager { } impl PacingManager { + /// A manager on one formation's own numbers. + pub fn with_config(config: PacingConfig) -> Self { + Self { + config, + ..Self::default() + } + } + /// Fold one tick's stress into intensity and advance the phase /// machine. `elapsed` is wall-clock time since the previous /// observed tick. pub fn observe(&mut self, s: &StressSample, elapsed: Duration) -> Option { + let c = &self.config; let per_member = s.members.max(1) as f32; - let harm = (W_FAILURE * s.failures as f32 - + W_INTERFERENCE * s.interferences as f32 - + W_THROTTLE * s.throttles as f32 - + W_DENIAL * s.denials as f32) + let harm = (c.w_failure * s.failures as f32 + + c.w_interference * s.interferences as f32 + + c.w_throttle * s.throttles as f32 + + c.w_denial * s.denials as f32) / per_member; + let decay = c.decay_per_sec; + let peak = c.peak_threshold; + let sustain = c.sustain(); + let relax = c.relax(); self.intensity = (self.intensity + harm).min(1.0); if !s.engaged { - self.intensity = (self.intensity - DECAY_PER_SEC * elapsed.as_secs_f32()).max(0.0); + self.intensity = (self.intensity - decay * elapsed.as_secs_f32()).max(0.0); } self.clock += elapsed; let now = self.clock; let next = match &self.current_phase { - PacingPhase::BuildUp { .. } if self.intensity >= PEAK_THRESHOLD => { + PacingPhase::BuildUp { .. } if self.intensity >= peak => { Some(PacingPhase::SustainPeak { peaked_at: now }) } - PacingPhase::SustainPeak { peaked_at } if now.duration_since(*peaked_at) >= SUSTAIN => { + PacingPhase::SustainPeak { peaked_at } + if now.duration_since(*peaked_at) >= sustain => + { Some(PacingPhase::PeakFade { since: now }) } // Booth: "Peak Fade won't allow the Relax period to start // until a natural break in the action occurs." - PacingPhase::PeakFade { .. } if !s.engaged || self.intensity < PEAK_THRESHOLD => { - Some(PacingPhase::Relax { until: now + RELAX }) + PacingPhase::PeakFade { .. } if !s.engaged || self.intensity < peak => { + Some(PacingPhase::Relax { until: now + relax }) } PacingPhase::Relax { until } if now >= *until => { Some(PacingPhase::BuildUp { started: now }) @@ -189,7 +194,8 @@ mod tests { // Still engaged, still stressed: sustain holds for SUSTAIN. assert!(m.observe(&failing(2, 2), TICK).is_none()); assert_eq!(m.tick_divider(), 1); - let t = m.observe(&ok(2), SUSTAIN).expect("sustain elapsed"); + let sustain = m.config.sustain(); + let t = m.observe(&ok(2), sustain).expect("sustain elapsed"); assert_eq!((t.from, t.to), ("SustainPeak", "PeakFade")); assert_eq!(m.tick_divider(), 2); // Peak fade waits for a natural break: not engaged. @@ -197,21 +203,21 @@ mod tests { assert_eq!((t.from, t.to), ("PeakFade", "Relax")); assert_eq!(m.tick_divider(), 4); // Relax returns to BuildUp once the relax period elapses. - assert!(m.observe(&StressSample::default(), RELAX / 2).is_none()); + let relax = m.config.relax(); + assert!(m.observe(&StressSample::default(), relax / 2).is_none()); let t = m - .observe(&StressSample::default(), RELAX / 2) + .observe(&StressSample::default(), relax / 2) .expect("relax elapsed"); assert_eq!((t.from, t.to), ("Relax", "BuildUp")); - assert!(m.intensity < PEAK_THRESHOLD, "decayed while idle"); + assert!(m.intensity < m.config.peak_threshold, "decayed while idle"); } #[test] fn test_allows_relax_refuses_mutating_permits_read_only() { let mut m = PacingManager::default(); assert!(m.allows(false)); - m.set_phase(PacingPhase::Relax { - until: m.clock + RELAX, - }); + let until = m.clock + m.config.relax(); + m.set_phase(PacingPhase::Relax { until }); assert!(!m.allows(false)); assert!(m.allows(true)); } @@ -236,3 +242,40 @@ mod tests { assert_eq!((t.from, t.to), ("Disruption", "BuildUp")); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod config_tests { + use super::*; + + /// Plan 1.5: every Director number is configuration. A formation + /// tuned to peak early and hold longer does exactly that; the + /// defaults are unchanged for everyone else. + #[test] + fn test_observe_uses_the_formations_own_numbers() { + let mut m = PacingManager::with_config(PacingConfig { + peak_threshold: 0.1, + sustain_secs: 60, + w_failure: 1.0, + ..PacingConfig::default() + }); + let stressed = StressSample { + failures: 1, + members: 1, + engaged: true, + ..StressSample::default() + }; + let t = m + .observe(&stressed, Duration::from_millis(33)) + .expect("one failure at weight 1.0 clears a 0.1 peak"); + assert_eq!(t.to, "SustainPeak"); + + // The default manager needs far more than one failure to peak. + let mut d = PacingManager::default(); + assert!(d.observe(&stressed, Duration::from_millis(33)).is_none()); + assert!(d.intensity < d.config.peak_threshold); + + // A 60-second sustain does not fade after the default 4. + assert!(m.observe(&stressed, Duration::from_secs(5)).is_none()); + } +} diff --git a/crates/springtale-cooperation/src/pacing/mod.rs b/crates/springtale-cooperation/src/pacing/mod.rs index 48ddd37f..3bffc4ce 100644 --- a/crates/springtale-cooperation/src/pacing/mod.rs +++ b/crates/springtale-cooperation/src/pacing/mod.rs @@ -13,12 +13,21 @@ //! are no per-phase action quotas — per-connector rate limits stay in the //! sentinel. //! +//! Every number in that loop is per-formation configuration, not a +//! constant (plan 1.5) — see `config.rs`. +//! //! File split: //! - `types.rs` — phase + transition enums +//! - `config.rs` — `[cooperation.pacing]` numbers + their defaults //! - `manager.rs` — stress sample, intensity, transitions, divider, gate +pub mod config; pub mod manager; pub mod types; -pub use manager::{DECAY_PER_SEC, PEAK_THRESHOLD, PacingManager, RELAX, SUSTAIN, StressSample}; +pub use config::{ + DECAY_PER_SEC, PEAK_THRESHOLD, PacingConfig, RELAX_SECS, SUSTAIN_SECS, W_DENIAL, W_FAILURE, + W_INTERFERENCE, W_THROTTLE, +}; +pub use manager::{PacingManager, StressSample}; pub use types::{PacingPhase, PacingTransition}; diff --git a/crates/springtale-cooperation/src/types.rs b/crates/springtale-cooperation/src/types.rs index 0f058921..c23c5ee0 100644 --- a/crates/springtale-cooperation/src/types.rs +++ b/crates/springtale-cooperation/src/types.rs @@ -227,6 +227,9 @@ pub struct FormationConstraints { /// constraint: two formations deployed with different thresholds /// each promote on their own numbers. pub momentum: crate::momentum::MomentumConfig, + /// Director numbers for this formation's pacing loop (plan 1.5, + /// `[cooperation.pacing]`). + pub pacing: crate::pacing::PacingConfig, } impl Default for FormationConstraints { @@ -239,6 +242,7 @@ impl Default for FormationConstraints { destructive_action_policy: ApprovalPolicy::AlwaysRequire, autonomy_ceiling: AutonomyLevel::ActAutonomously, momentum: crate::momentum::MomentumConfig::default(), + pacing: crate::pacing::PacingConfig::default(), } } } From 01f516c41f65c0e606555474de2f21b363f09fdc Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:17:24 -0700 Subject: [PATCH 05/24] cooperation: measure handoff success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1.3 gives the momentum window `handoffs` / `handoffs_ok` and a `handoff_rate`, and the 1.15 work noted the gap: "handoff has no completion event to hook", so the rate was permanently zero and promotion could not see the point COOPERATION.pdf §20 calls the place most cooperative failures occur. New `handoff/completion.rs`: `HandoffCompletion` (pattern, from, to, success), `HandoffType::pattern/from/to`, `HandoffResult::succeeded` (false only for `Failed`), and a `HandoffLog` the formation shares behind an `Arc` because `dispatch_handoff` takes `&self`. Every dispatch records one completion. The momentum step drains the log each tick, emits a `CooperationEvent::HandoffCompleted` per record, and counts them into the tick's `TickCounts`, so `RunWindow::handoff_rate()` now reflects real handoffs. A poisoned lock drops the statistic, never the work. Also carries `cargo fmt` over the two preceding commits. Tests: record/drain round trip and outcome mapping; a Direct dispatch leaves exactly one successful completion on the formation's log; three completions, two ok, put a 2/3 handoff rate in the window. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/cooperation/formation.rs | 26 ++- .../build_reports/agent_pipeline/mod.rs | 12 +- .../build_reports/agent_pipeline/tests.rs | 4 +- .../src/runtime/tick_steps/update_momentum.rs | 83 ++++++-- .../src/events/types.rs | 13 ++ .../src/handoff/completion.rs | 180 ++++++++++++++++++ .../springtale-cooperation/src/handoff/mod.rs | 2 + .../src/pacing/manager.rs | 4 +- 8 files changed, 294 insertions(+), 30 deletions(-) create mode 100644 crates/springtale-cooperation/src/handoff/completion.rs diff --git a/crates/springtale-bot/src/cooperation/formation.rs b/crates/springtale-bot/src/cooperation/formation.rs index 2b5eb3bb..a156b3c2 100644 --- a/crates/springtale-bot/src/cooperation/formation.rs +++ b/crates/springtale-bot/src/cooperation/formation.rs @@ -27,7 +27,7 @@ use springtale_cooperation::comms::{ use springtale_cooperation::consensus::ConsensusEngine; use springtale_cooperation::context::FormationContext; use springtale_cooperation::handoff::{ - FlexibleChainPool, HandoffResult, HandoffType, dispatch_handoff_durable, + FlexibleChainPool, HandoffLog, HandoffResult, HandoffType, dispatch_handoff_durable, }; use springtale_cooperation::mental_model::SharedMentalModel; use springtale_cooperation::momentum::{MomentumState, MomentumTier}; @@ -192,6 +192,9 @@ pub struct Formation { /// When `true`, tick processing skips this formation entirely. pub paused: bool, pub constraints: FormationConstraints, + /// Handoffs that finished since the last tick drained the log + /// (plan 1.3 / 1.15). `Arc` because `dispatch_handoff` takes `&self`. + pub handoff_log: Arc, pub momentum: MomentumState, /// Hayes-Roth task-routing blackboard (§3 composer output). Distinct /// from [`shared_env`] which is the §10 atomic workspace. The two @@ -505,6 +508,7 @@ impl Formation { let pacing = PacingManager::with_config(constraints.pacing.clone()); let formation = Self { id: FormationId::new(), + handoff_log: Arc::new(HandoffLog::default()), intent, paused: false, constraints, @@ -941,14 +945,21 @@ impl Formation { handoff: &HandoffType, ) -> Result { let ttl = Some(self.constraints.timeout); - dispatch_handoff_durable( + let result = dispatch_handoff_durable( handoff, &self.store, &self.flex_chain_pool, Some(&self.direct_inbox), ttl, ) - .await + .await; + // Plan 1.3: a handoff is where cooperation most often breaks, so + // every completion — landed or failed — is recorded for the tick + // to count into the momentum window and re-emit. + if let Ok(outcome) = result.as_ref() { + self.handoff_log.record(handoff, outcome); + } + result } /// Subscribe to both the peer event bus and the shared context watch @@ -1184,6 +1195,15 @@ mod tests { other => panic!("expected Delivered, got {other:?}"), } assert_eq!(formation.direct_inbox.len(receiver), 1); + // Plan 1.3 / 1.15: the dispatch recorded a completion, so the + // tick's momentum window can count the handoff. + let completions = formation.handoff_log.drain(); + assert_eq!(completions.len(), 1); + assert_eq!(completions[0].pattern, "direct"); + assert_eq!(completions[0].from, sender); + assert_eq!(completions[0].to, Some(receiver)); + assert!(completions[0].success); + assert!(formation.handoff_log.drain().is_empty()); } #[tokio::test] diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/mod.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/mod.rs index ba47c9f8..1e07e9fe 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/mod.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/mod.rs @@ -89,11 +89,13 @@ pub async fn run( // The beat's L0 surface reactions, kept aside so the task path can // not overwrite them (plan 1.9). Re-attached to each member's report // in the gather phase. - let surfaces: std::collections::HashMap = - decisions - .iter_mut() - .filter_map(|d| d.surface.take().map(|a| (d.agent, a))) - .collect(); + let surfaces: std::collections::HashMap< + AgentId, + springtale_cooperation::cadence::ActionDescriptor, + > = decisions + .iter_mut() + .filter_map(|d| d.surface.take().map(|a| (d.agent, a))) + .collect(); // 2. Claim on the blackboard now. let mut settled: Vec<(AgentId, ExecuteOutcome)> = Vec::new(); diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/tests.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/tests.rs index 84a5528e..4de6464e 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/tests.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/agent_pipeline/tests.rs @@ -10,9 +10,7 @@ use tokio::sync::mpsc; use springtale_cooperation::action::SubTask; use springtale_cooperation::action_state::ActionState; -use springtale_cooperation::cadence::{ - ActionDescriptor, AgentId, IntentPattern, Tick, TickReport, -}; +use springtale_cooperation::cadence::{ActionDescriptor, AgentId, IntentPattern, Tick, TickReport}; use springtale_cooperation::routing::direct::assignment; use springtale_cooperation::stigmergy::types::SurfaceType; use springtale_cooperation::types::{ApprovalPolicy, FormationConstraints}; diff --git a/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs b/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs index c90ef800..00d02495 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs @@ -18,6 +18,7 @@ use crate::cooperation::formation::Formation; use springtale_cooperation::action_state::ActionState; use springtale_cooperation::cadence::TickReport; +use springtale_cooperation::handoff::HandoffCompletion; use springtale_cooperation::momentum::{MomentumEvent, TickCounts}; use springtale_cooperation::tick_processor::FormationTickResult; use springtale_cooperation::utterance::{UtteranceKind, utter}; @@ -52,8 +53,8 @@ fn succeeded(report: &TickReport) -> bool { /// of its alignment — waiting and claimed-only are not success. Only /// reports that finished work can succeed or fail. Success and failure /// carry the tick's [`TickCounts`] for the momentum window. -pub fn classify(result: &FormationTickResult) -> MomentumEvent { - let counts = count(result); +pub fn classify(result: &FormationTickResult, handoffs: &[HandoffCompletion]) -> MomentumEvent { + let counts = count(result, handoffs); let failed = counts.successes < counts.actions; if !result.interferences.is_empty() { @@ -73,11 +74,11 @@ pub fn classify(result: &FormationTickResult) -> MomentumEvent { /// /// `duplicates` counts acted reports whose descriptor /// `(kind, target, payload_hash)` repeats an earlier report's in this tick. -/// `handoffs` and `handoffs_ok` are 0: `FormationTickResult` carries only -/// reports and interferences, and the `handoff::` module emits no -/// completion event the tick could read, so the handoff rate is not yet -/// measured here. -fn count(result: &FormationTickResult) -> TickCounts { +/// `handoffs` and `handoffs_ok` are the completions the formation's +/// `HandoffLog` collected since the last tick — a handoff that reached +/// its substrate counts as ok, a `Failed` one does not, so the window's +/// `handoff_rate` measures the place §20 says cooperation breaks. +fn count(result: &FormationTickResult, handoffs: &[HandoffCompletion]) -> TickCounts { let mut seen: HashSet<(&str, Option<&str>, u64)> = HashSet::new(); let mut counts = TickCounts::default(); for report in &result.reports { @@ -100,6 +101,9 @@ fn count(result: &FormationTickResult) -> TickCounts { counts.duplicates = counts.duplicates.saturating_add(1); } } + counts.handoffs = u32::try_from(handoffs.len()).unwrap_or(u32::MAX); + counts.handoffs_ok = + u32::try_from(handoffs.iter().filter(|h| h.success).count()).unwrap_or(u32::MAX); counts } @@ -112,7 +116,22 @@ pub fn run( ) { // Step 4 — momentum update from actual results. A `TickSuccess` with a // real action also refreshes the activity clock inside `apply_event`. - formation.momentum.apply_event(&classify(result)); + // The handoffs that finished since the last tick are counted into the + // same window and surfaced on the event stream (plan 1.3 / 1.15). + let handoffs = formation.handoff_log.drain(); + for completion in &handoffs { + springtale_cooperation::events::emit( + cooperation_tx, + springtale_cooperation::events::CooperationEvent::HandoffCompleted { + formation_id: formation.id, + pattern: completion.pattern.to_owned(), + from: completion.from, + to: completion.to, + success: completion.success, + }, + ); + } + formation.momentum.apply_event(&classify(result, &handoffs)); // Step 4b — per-member consecutive failures for role transformation // (§14). Idle reports and finished-and-aligned work reset the counter; @@ -193,25 +212,28 @@ mod tests { report(None, 1.0), report(None, 1.0), ]); - assert!(matches!(classify(&result), MomentumEvent::TickIdle)); + assert!(matches!(classify(&result, &[]), MomentumEvent::TickIdle)); } #[test] fn test_classify_empty_tick_is_idle() { - assert!(matches!(classify(&tick(vec![])), MomentumEvent::TickIdle)); + assert!(matches!( + classify(&tick(vec![]), &[]), + MomentumEvent::TickIdle + )); } #[test] fn test_classify_action_aligned_is_success_and_counts_duplicates() { // Same kind, target and payload hash: the second report is - // duplicate work. No handoff events reach the tick, so 0. + // duplicate work. No handoffs finished in this tick, so 0. let result = tick(vec![ report(Some("work"), 1.0), report(Some("work"), 1.0), report(Some("other"), 1.0), ]); assert!(matches!( - classify(&result), + classify(&result, &[]), MomentumEvent::TickSuccess { counts } if counts.actions == 3 && counts.successes == 3 @@ -224,7 +246,7 @@ mod tests { fn test_classify_action_misaligned_is_failure() { let result = tick(vec![report(Some("work"), 1.0), report(Some("work"), 0.2)]); assert!(matches!( - classify(&result), + classify(&result, &[]), MomentumEvent::TickFailure { counts } if counts.actions == 2 && counts.successes == 1 )); } @@ -240,11 +262,11 @@ mod tests { fn test_hung_dispatch_is_idle_and_never_promotes() { let hung = || stated(Some("work"), REQUESTED_ALIGNMENT, ActionState::Requested); let result = tick(vec![hung(), hung()]); - assert!(matches!(classify(&result), MomentumEvent::TickIdle)); + assert!(matches!(classify(&result, &[]), MomentumEvent::TickIdle)); let mut momentum = MomentumState::default(); for _ in 0..50 { - momentum.apply_event(&classify(&result)); + momentum.apply_event(&classify(&result, &[])); } assert_eq!(momentum.tier, MomentumTier::Cold); assert_eq!(momentum.consecutive_successes, 0); @@ -260,6 +282,35 @@ mod tests { stated(Some("sacrifice_yield"), 0.9, ActionState::Init), stated(Some("cancelled"), 1.0, ActionState::Cancelled), ]); - assert!(matches!(classify(&result), MomentumEvent::TickIdle)); + assert!(matches!(classify(&result, &[]), MomentumEvent::TickIdle)); + } + + fn completion(success: bool) -> HandoffCompletion { + HandoffCompletion { + pattern: "direct", + from: AgentId::new(), + to: Some(AgentId::new()), + success, + } + } + + /// Plan 1.3 / 1.15: the window's handoff counters used to be dead — + /// nothing emitted a completion, so `handoff_rate()` was always 0. + /// The tick now counts what the formation's `HandoffLog` collected. + #[test] + fn test_count_handoff_completions_reach_the_momentum_window() { + let result = tick(vec![report(Some("send"), 1.0)]); + let counts = count( + &result, + &[completion(true), completion(false), completion(true)], + ); + assert_eq!(counts.handoffs, 3); + assert_eq!(counts.handoffs_ok, 2); + + let mut momentum = MomentumState::default(); + momentum.record_successful_tick(&counts); + assert_eq!(momentum.window.handoffs, 3); + assert_eq!(momentum.window.handoffs_ok, 2); + assert!((momentum.window.handoff_rate() - 2.0 / 3.0).abs() < 1e-6); } } diff --git a/crates/springtale-cooperation/src/events/types.rs b/crates/springtale-cooperation/src/events/types.rs index 81947649..b5effd34 100644 --- a/crates/springtale-cooperation/src/events/types.rs +++ b/crates/springtale-cooperation/src/events/types.rs @@ -184,6 +184,19 @@ pub enum CooperationEvent { interference_kind: InterferenceKind, agents: Vec, }, + /// A handoff finished — the work product reached its substrate, or + /// did not (COOPERATION.pdf §20: the handoff point is where most + /// cooperative failures occur). Counted into the momentum window's + /// handoff rate (plan 1.3). + HandoffCompleted { + formation_id: FormationId, + /// `"direct"`, `"environment_mediated"`, `"flexible_chain"`, + /// `"sequential_dependency"` or `"information_transfer"`. + pattern: String, + from: AgentId, + to: Option, + success: bool, + }, /// L4 Contract Net round opened (cascade-driven capability auction). CfpRoundStarted { formation_id: FormationId, diff --git a/crates/springtale-cooperation/src/handoff/completion.rs b/crates/springtale-cooperation/src/handoff/completion.rs new file mode 100644 index 00000000..da14a034 --- /dev/null +++ b/crates/springtale-cooperation/src/handoff/completion.rs @@ -0,0 +1,180 @@ +//! Handoff completion — the event the momentum window counts. +//! +//! COOPERATION.pdf §20: "The handoff point is where most cooperative +//! failures occur." Plan 1.3 gives [`crate::momentum::RunWindow`] a +//! `handoffs` / `handoffs_ok` pair and a `handoff_rate`, but nothing +//! emitted a completion, so the rate was always zero and promotion could +//! not see the place failures actually happen. +//! +//! Every dispatch through `Formation::dispatch_handoff` now records one +//! [`HandoffCompletion`] here. The tick drains the log, counts it into +//! the window and re-emits each record on the cooperation event stream. + +use std::sync::Mutex; + +use crate::cadence::AgentId; + +use super::HandoffType; +use super::transfer::HandoffResult; + +/// One finished handoff: which pattern, between whom, and whether the +/// work product actually landed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandoffCompletion { + /// `"direct"`, `"environment_mediated"`, `"flexible_chain"`, + /// `"sequential_dependency"` or `"information_transfer"`. + pub pattern: &'static str, + /// The agent that handed the work over. + pub from: AgentId, + /// The agent that received it, when the pattern names one. An + /// environment-mediated deposit and a flexible-chain step do not. + pub to: Option, + /// False for [`HandoffResult::Failed`] — a missing substrate, an + /// unroutable payload, a store error. + pub success: bool, +} + +impl HandoffType { + /// Stable name of the handoff pattern, for events and logs. + pub fn pattern(&self) -> &'static str { + match self { + Self::Direct { .. } => "direct", + Self::EnvironmentMediated { .. } => "environment_mediated", + Self::FlexibleChain { .. } => "flexible_chain", + Self::SequentialDependency { .. } => "sequential_dependency", + Self::InformationTransfer { .. } => "information_transfer", + } + } + + /// Who handed the work over. + pub fn from(&self) -> AgentId { + match self { + Self::Direct { sender, .. } => *sender, + Self::EnvironmentMediated { depositor, .. } => *depositor, + Self::FlexibleChain { originator, .. } => *originator, + Self::SequentialDependency { enabler, .. } => *enabler, + Self::InformationTransfer { source, .. } => *source, + } + } + + /// Who receives it, when the pattern names exactly one agent. + pub fn to(&self) -> Option { + match self { + Self::Direct { receiver, .. } => Some(*receiver), + Self::SequentialDependency { enabled, .. } => Some(*enabled), + Self::EnvironmentMediated { .. } + | Self::FlexibleChain { .. } + | Self::InformationTransfer { .. } => None, + } + } +} + +impl HandoffResult { + /// Whether the work product reached its substrate. + pub fn succeeded(&self) -> bool { + !matches!(self, Self::Failed(_)) + } +} + +/// Completions since the last drain. One per formation, shared behind an +/// `Arc` because `dispatch_handoff` takes `&self`. +#[derive(Debug, Default)] +pub struct HandoffLog { + completions: Mutex>, +} + +impl HandoffLog { + /// Record one finished handoff. A poisoned lock drops the record + /// rather than propagating a panic into the dispatch path: an + /// unmeasured handoff is a worse outcome than a lost one only for + /// the statistics, never for the work. + pub fn record(&self, handoff: &HandoffType, result: &HandoffResult) { + let completion = HandoffCompletion { + pattern: handoff.pattern(), + from: handoff.from(), + to: handoff.to(), + success: result.succeeded(), + }; + match self.completions.lock() { + Ok(mut log) => log.push(completion), + Err(_) => tracing::warn!("handoff log poisoned; completion not counted"), + } + } + + /// Take everything recorded since the last call. Called once per + /// tick by the momentum step. + pub fn drain(&self) -> Vec { + match self.completions.lock() { + Ok(mut log) => std::mem::take(&mut *log), + Err(_) => Vec::new(), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::cadence::ActionDescriptor; + use crate::routing::types::TaskId; + + fn obligation(enabler: AgentId, enabled: AgentId) -> HandoffType { + HandoffType::SequentialDependency { + enabler, + enabled, + return_obligation: ActionDescriptor { + kind: "boost".into(), + target: None, + payload_hash: 0, + }, + } + } + + #[test] + fn test_record_then_drain_keeps_outcome_and_empties_the_log() { + let log = HandoffLog::default(); + let (a, b) = (AgentId::new(), AgentId::new()); + let handoff = obligation(a, b); + log.record( + &handoff, + &HandoffResult::ObligationRegistered { + enabler: a, + enabled: b, + obligation: ActionDescriptor { + kind: "boost".into(), + target: None, + payload_hash: 0, + }, + }, + ); + log.record(&handoff, &HandoffResult::Failed("no substrate".into())); + + let drained = log.drain(); + assert_eq!(drained.len(), 2); + assert_eq!(drained[0].pattern, "sequential_dependency"); + assert_eq!(drained[0].from, a); + assert_eq!(drained[0].to, Some(b)); + assert!(drained[0].success); + assert!(!drained[1].success); + assert!(log.drain().is_empty(), "a drain empties the log"); + } + + #[test] + fn test_succeeded_is_false_only_for_failed() { + assert!(!HandoffResult::Failed("x".into()).succeeded()); + assert!( + HandoffResult::Deposited { + location: "k".into() + } + .succeeded() + ); + assert!( + HandoffResult::Delivered { + from: AgentId::new(), + to: AgentId::new(), + task_id: TaskId::new_v4(), + } + .succeeded() + ); + } +} diff --git a/crates/springtale-cooperation/src/handoff/mod.rs b/crates/springtale-cooperation/src/handoff/mod.rs index 9f7175fc..e68c2c4f 100644 --- a/crates/springtale-cooperation/src/handoff/mod.rs +++ b/crates/springtale-cooperation/src/handoff/mod.rs @@ -3,11 +3,13 @@ //! Per COOPERATION.pdf §20: "Work products must pass between agents. //! The handoff point is where most cooperative failures occur." +pub mod completion; pub mod deposit; pub mod flex_chain; pub mod transfer; mod types; +pub use completion::{HandoffCompletion, HandoffLog}; pub use flex_chain::FlexibleChainPool; pub use transfer::{HandoffResult, dispatch_handoff, dispatch_handoff_durable}; pub use types::{HandoffPayload, HandoffType}; diff --git a/crates/springtale-cooperation/src/pacing/manager.rs b/crates/springtale-cooperation/src/pacing/manager.rs index b4083586..eb9657b1 100644 --- a/crates/springtale-cooperation/src/pacing/manager.rs +++ b/crates/springtale-cooperation/src/pacing/manager.rs @@ -86,9 +86,7 @@ impl PacingManager { PacingPhase::BuildUp { .. } if self.intensity >= peak => { Some(PacingPhase::SustainPeak { peaked_at: now }) } - PacingPhase::SustainPeak { peaked_at } - if now.duration_since(*peaked_at) >= sustain => - { + PacingPhase::SustainPeak { peaked_at } if now.duration_since(*peaked_at) >= sustain => { Some(PacingPhase::PeakFade { since: now }) } // Booth: "Peak Fade won't allow the Relax period to start From 70591f56a23d916bb11db544523d8a782020d8c3 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:18:14 -0700 Subject: [PATCH 06/24] feat(chat): the AI tool loop learns the platform verbs (plan 5.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collect_tools` enumerated connector actions only, so a model could send a Telegram message but could not see, let alone steer, a formation. Every verb in the runtime registry is now a tool under a `platform` pseudo-connector: - `platform__formation_pause` etc., published from `platform_verbs()` with the registry's own description and `input_schema`. `.` is not legal in a tool name, so the dotted verb name maps through `PlatformVerb::tool_segment` and back through `find_verb_by_tool_segment`. - Name resolution in `execute_tool_call` routes the `platform` namespace to `operations::platform::run_platform_verb` — the runtime, not the connector registry. That function is new and delegates every branch to the runtime operation the chat command already calls. - Read-only verbs (list, get, status) run straight through; every other verb blocks on the same `ApprovalGate` a connector write blocks on, and is refused outright when no gate is wired. - Platform tools are emitted before connector tools so they survive `MAX_TOOLS_HARD_CAP` truncation, and only when the bot has a `RuntimeState` to run them against. Tests assert `platform__formation_pause` is published, that it needs `writes_with_approval` exactly as a connector write does, and that no published tool name contains an assign — the drum rule, enforced where a model can reach. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../springtale-bot/src/runtime/event_loop.rs | 1 + crates/springtale-bot/src/runtime/handlers.rs | 3 + .../springtale-bot/src/tool_runner/builder.rs | 73 ++++ .../springtale-bot/src/tool_runner/loop_.rs | 86 ++++- crates/springtale-bot/src/tool_runner/mod.rs | 2 +- .../springtale-bot/src/tool_runner/resume.rs | 4 + .../src/operations/platform/mod.rs | 4 +- .../src/operations/platform/registry.rs | 6 + .../src/operations/platform/run.rs | 324 ++++++++++++++++++ .../src/operations/platform/verb.rs | 9 + 10 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 crates/springtale-runtime/src/operations/platform/run.rs diff --git a/crates/springtale-bot/src/runtime/event_loop.rs b/crates/springtale-bot/src/runtime/event_loop.rs index fac0d07e..f739b80d 100644 --- a/crates/springtale-bot/src/runtime/event_loop.rs +++ b/crates/springtale-bot/src/runtime/event_loop.rs @@ -28,6 +28,7 @@ pub async fn run_event_loop(bot: &mut Bot) { adapter: bot.ai_adapter.clone(), response_tx: bot.response_tx.clone(), policy: bot.settings.load().tool_policy.clone(), + runtime: bot.runtime.clone(), }; tokio::spawn(crate::tool_runner::resume_orphaned_loops(deps)); } diff --git a/crates/springtale-bot/src/runtime/handlers.rs b/crates/springtale-bot/src/runtime/handlers.rs index 9b752275..4cf9ff00 100644 --- a/crates/springtale-bot/src/runtime/handlers.rs +++ b/crates/springtale-bot/src/runtime/handlers.rs @@ -478,6 +478,9 @@ async fn ai_fallback( registry: &bot.registry, bridge: &bot.capability_bridge, sentinel: &bot.sentinel, + // Plan 5.4: the platform verbs are tools too, when there is a + // runtime to run them against. + runtime: bot.runtime.as_ref(), }; let tool_call = crate::tool_runner::ToolRunnerCall { options, diff --git a/crates/springtale-bot/src/tool_runner/builder.rs b/crates/springtale-bot/src/tool_runner/builder.rs index 75367778..9b34cdf0 100644 --- a/crates/springtale-bot/src/tool_runner/builder.rs +++ b/crates/springtale-bot/src/tool_runner/builder.rs @@ -15,6 +15,14 @@ use tokio::sync::RwLock; /// round-tripping through the model and still reads unambiguously. pub const TOOL_NAME_SEPARATOR: &str = "__"; +/// Pseudo-connector name the platform verbs are published under. +/// +/// `platform__formation_pause` is not a connector action: the runner +/// routes it to `springtale_runtime::operations::platform`, not the +/// connector registry, so chat can steer the platform itself with the +/// same tool grammar it uses for a connector (plan 5.4). +pub const PLATFORM_TOOL_NAMESPACE: &str = "platform"; + /// Decide whether one connector action is exposed to the model. /// /// - **Explicit mode** (`allow` non-empty): exactly the allow-list @@ -46,8 +54,31 @@ pub fn tool_permitted(policy: &ToolPolicy, tool_name: &str, read_only: bool) -> pub async fn collect_tools( registry: &Arc>, policy: &ToolPolicy, + with_platform: bool, ) -> Vec { let mut tools = Vec::new(); + // Platform verbs come first so the platform's own controls survive + // `MAX_TOOLS_HARD_CAP` truncation on an install with many + // connectors — a chat that cannot steer the platform is the whole + // point of plan 5.4 being unmet. `with_platform` is false for bots + // built without a `RuntimeState` (headless, CLI, tests), which + // could not run a verb if the model called one. + if with_platform { + for verb in springtale_runtime::operations::platform::platform_verbs() { + let tool_name = format!( + "{PLATFORM_TOOL_NAMESPACE}{TOOL_NAME_SEPARATOR}{}", + verb.tool_segment() + ); + if !tool_permitted(policy, &tool_name, verb.read_only) { + continue; + } + tools.push(ToolDefinition { + name: tool_name, + description: verb.description.to_owned(), + input_schema: verb.input_schema(), + }); + } + } let reg = registry.read().await; for (name, enabled) in reg.list() { if !enabled { @@ -205,6 +236,48 @@ mod tests { assert!(!policy.is_allowed("connector-shell__execute")); } + #[tokio::test] + async fn platform_verbs_are_published_as_tools() { + let registry = Arc::new(RwLock::new(ConnectorRegistry::new( + springtale_connector::capability::CapabilityPolicy::Interactive, + ))); + let policy = ToolPolicy { + writes_with_approval: true, + ..Default::default() + }; + let tools = collect_tools(®istry, &policy, true).await; + let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"platform__formation_pause")); + assert!(names.contains(&"platform__formation_list")); + // The drum rule: nothing that hands work to a named member is + // sayable — not in chat, and not to a model either. + assert!( + !names.iter().any(|n| n.contains("assign")), + "no tool may be an assign verb: {names:?}" + ); + } + + #[tokio::test] + async fn platform_writes_need_the_approval_flag() { + let registry = Arc::new(RwLock::new(ConnectorRegistry::new( + springtale_connector::capability::CapabilityPolicy::Interactive, + ))); + // Default policy: read-only verbs only, exactly like a connector. + let tools = collect_tools(®istry, &ToolPolicy::default(), true).await; + let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"platform__formation_list")); + assert!(!names.contains(&"platform__formation_pause")); + } + + #[tokio::test] + async fn platform_tools_absent_without_runtime() { + let registry = Arc::new(RwLock::new(ConnectorRegistry::new( + springtale_connector::capability::CapabilityPolicy::Interactive, + ))); + let tools = collect_tools(®istry, &ToolPolicy::default(), false).await; + assert!(tools.is_empty()); + } + #[test] fn secret_field_detection() { let schema = serde_json::json!({ diff --git a/crates/springtale-bot/src/tool_runner/loop_.rs b/crates/springtale-bot/src/tool_runner/loop_.rs index bb1dfa9a..6d1f6326 100644 --- a/crates/springtale-bot/src/tool_runner/loop_.rs +++ b/crates/springtale-bot/src/tool_runner/loop_.rs @@ -12,7 +12,7 @@ use springtale_connector::tier::WasmTier; use springtale_runtime::CapabilityBridge; use tokio::sync::RwLock; -use super::builder::{collect_tools, split_tool_name}; +use super::builder::{PLATFORM_TOOL_NAMESPACE, collect_tools, split_tool_name}; /// Truncate tool output fed back into the model. 8 KiB keeps the /// conversation well under any vendor's context limit even after ~10 @@ -52,6 +52,12 @@ pub struct ToolRunnerDeps<'a> { pub registry: &'a Arc>, pub bridge: &'a CapabilityBridge, pub sentinel: &'a Arc, + /// Shared runtime state (plan 5.4). `Some` in the daemon / desktop, + /// where the platform verbs are published to the model as the + /// `platform` pseudo-connector and executed against this state; + /// `None` in headless / CLI / test bots, which publish no platform + /// tools at all. + pub runtime: Option<&'a springtale_runtime::state::RuntimeState>, } /// Per-invocation parameters — the AI request knobs plus the optional @@ -88,7 +94,7 @@ pub async fn run_with_tools( // Tool list is still discovered via the registry (we need the // declared actions); execution goes through `dispatch_action*` so // sentinel evaluation (§6.10) runs before every network call. - let tools = collect_tools(deps.registry, call.policy).await; + let tools = collect_tools(deps.registry, call.policy, deps.runtime.is_some()).await; let max_iterations = call.policy.effective_max_iterations(); for iteration in 0..max_iterations { @@ -150,6 +156,7 @@ pub async fn run_with_tools( let result = execute_tool_call( deps.bridge, deps.sentinel, + deps.runtime, tool_call, call.formation_tier, call.checkpoint @@ -180,6 +187,7 @@ struct ExecutedResult { async fn execute_tool_call( bridge: &CapabilityBridge, sentinel: &Arc, + runtime: Option<&springtale_runtime::state::RuntimeState>, call: &ToolCall, formation_tier: Option, origin: Option, @@ -191,6 +199,12 @@ async fn execute_tool_call( }; }; + // The `platform` pseudo-connector is not in the registry: it routes + // to the runtime's verb registry instead (plan 5.4). + if connector == PLATFORM_TOOL_NAMESPACE { + return execute_platform_verb(bridge, runtime, action, &call.arguments, origin).await; + } + // Build a RunConnector action and dispatch through // `dispatch_action[_with_tier]` so sentinel evaluation runs before // the network call (§6.10 / Phase 17 / H1 fix). @@ -262,6 +276,74 @@ async fn execute_tool_call( } } +/// Run one platform verb for the model. +/// +/// Read-only verbs (list, get, status) run straight through. Everything +/// else goes through the same blocking approval gate a connector write +/// goes through — the gate deny-by-defaults when nothing is wired to +/// answer it, so a model cannot pause a formation on an instance with +/// no approver. +async fn execute_platform_verb( + bridge: &CapabilityBridge, + runtime: Option<&springtale_runtime::state::RuntimeState>, + segment: &str, + args: &serde_json::Value, + origin: Option, +) -> ExecutedResult { + let err = |body: String| ExecutedResult { + body, + is_error: true, + }; + let Some(state) = runtime else { + return err("this bot runs without a platform runtime".to_owned()); + }; + let Some(verb) = springtale_runtime::operations::platform::find_verb_by_tool_segment(segment) + else { + return err(format!("'{segment}' is not a platform verb")); + }; + + if !verb.read_only { + let Some(gate) = bridge.approval_gate() else { + return err("no approval gate is wired — refusing to change anything".to_owned()); + }; + let request = springtale_runtime::approval::ApprovalRequest { + id: springtale_runtime::approval::ApprovalRequestId::new(), + connector_name: PLATFORM_TOOL_NAMESPACE.to_owned(), + capability: springtale_runtime::approval::GatedCapability::DestructiveAction { + action_type: verb.name.to_owned(), + }, + agent_id: None, + summary: format!("{} — {}", verb.name, verb.description), + requested_at: chrono::Utc::now(), + origin, + expires_at: None, + }; + match gate.request(request).await { + Ok(decision) if decision.is_approved() => {} + Ok(_) => return err(format!("{} was not approved", verb.name)), + Err(e) => return err(format!("approval gate error: {e}")), + } + } + + match springtale_runtime::operations::platform::run_platform_verb(state, verb, args).await { + Ok(value) => { + let mut body = value.to_string(); + if body.len() > MAX_TOOL_OUTPUT_BYTES { + body.truncate(MAX_TOOL_OUTPUT_BYTES); + body.push_str("...[truncated]"); + } + ExecutedResult { + body, + is_error: false, + } + } + Err(e) => err(format!( + "{{\"error\": {}}}", + serde_json::Value::String(e.to_string()) + )), + } +} + /// Convert connector-layer [`WasmTier`] to cooperation-layer /// [`springtale_cooperation::momentum::MomentumTier`]. The bot /// runtime sees `WasmTier` from the formation tick path; the diff --git a/crates/springtale-bot/src/tool_runner/mod.rs b/crates/springtale-bot/src/tool_runner/mod.rs index 2952c55c..12015581 100644 --- a/crates/springtale-bot/src/tool_runner/mod.rs +++ b/crates/springtale-bot/src/tool_runner/mod.rs @@ -33,6 +33,6 @@ pub mod builder; pub mod loop_; pub mod resume; -pub use builder::{TOOL_NAME_SEPARATOR, collect_tools, split_tool_name}; +pub use builder::{PLATFORM_TOOL_NAMESPACE, TOOL_NAME_SEPARATOR, collect_tools, split_tool_name}; pub use loop_::{CheckpointCtx, ToolRunnerCall, ToolRunnerDeps, ToolRunnerError, run_with_tools}; pub use resume::{ResumerDeps, resume_orphaned_loops}; diff --git a/crates/springtale-bot/src/tool_runner/resume.rs b/crates/springtale-bot/src/tool_runner/resume.rs index c23d94a6..832d234f 100644 --- a/crates/springtale-bot/src/tool_runner/resume.rs +++ b/crates/springtale-bot/src/tool_runner/resume.rs @@ -36,6 +36,9 @@ pub struct ResumerDeps { pub adapter: Arc, pub response_tx: tokio::sync::mpsc::Sender, pub policy: ToolPolicy, + /// Shared runtime state, so a thread resumed after a restart still + /// sees the platform verbs it was using before (plan 5.4). + pub runtime: Option, } /// How often a still-pending verdict is re-checked. The approval's own @@ -136,6 +139,7 @@ async fn resume_one(deps: &ResumerDeps, cp: ToolLoopCheckpointRow) { registry: &deps.registry, bridge: &deps.bridge, sentinel: &deps.sentinel, + runtime: deps.runtime.as_ref(), }; let call = ToolRunnerCall { options: AiOptions::default(), diff --git a/crates/springtale-runtime/src/operations/platform/mod.rs b/crates/springtale-runtime/src/operations/platform/mod.rs index ae5130e3..2c964025 100644 --- a/crates/springtale-runtime/src/operations/platform/mod.rs +++ b/crates/springtale-runtime/src/operations/platform/mod.rs @@ -4,7 +4,9 @@ //! inspection, and never an assign verb (the drum rule). pub mod registry; +pub mod run; pub mod verb; -pub use registry::{find_verb, platform_verbs, verb_commands}; +pub use registry::{find_verb, find_verb_by_tool_segment, platform_verbs, verb_commands}; +pub use run::run_platform_verb; pub use verb::{PlatformVerb, VerbGroup}; diff --git a/crates/springtale-runtime/src/operations/platform/registry.rs b/crates/springtale-runtime/src/operations/platform/registry.rs index 44d8719f..c62d3feb 100644 --- a/crates/springtale-runtime/src/operations/platform/registry.rs +++ b/crates/springtale-runtime/src/operations/platform/registry.rs @@ -166,6 +166,12 @@ pub fn find_verb(name: &str) -> Option<&'static PlatformVerb> { VERBS.iter().find(|v| v.name == name) } +/// Look one verb up by the segment an AI tool name carries +/// (`formation_pause` → `formation.pause`). +pub fn find_verb_by_tool_segment(segment: &str) -> Option<&'static PlatformVerb> { + VERBS.iter().find(|v| v.tool_segment() == segment) +} + /// The distinct chat command names (`formation`, `approvals`, …). pub fn verb_commands() -> Vec<&'static str> { let mut out: Vec<&'static str> = Vec::new(); diff --git a/crates/springtale-runtime/src/operations/platform/run.rs b/crates/springtale-runtime/src/operations/platform/run.rs new file mode 100644 index 00000000..7f94722c --- /dev/null +++ b/crates/springtale-runtime/src/operations/platform/run.rs @@ -0,0 +1,324 @@ +//! Execute one platform verb (plan 5.4). +//! +//! The verb registry says what chat and the AI tool loop may ask the +//! platform to do; this module is the one place that actually does it. +//! Every branch delegates to an existing runtime operation — nothing +//! new is reachable through a verb that isn't reachable through the +//! surfaces that already exist. +//! +//! The AI tool loop routes the `platform` pseudo-connector here instead +//! of the connector registry (`springtale_bot::tool_runner`), so a +//! model-issued `platform__formation_pause` and a typed +//! `/formation pause` run the same code. + +use serde_json::{Value, json}; + +use crate::error::OperationError; +use crate::operations::{config, formations as f, memory, safety}; +use crate::state::RuntimeState; + +use super::verb::PlatformVerb; + +/// Rows kept when `memory.compact` runs without an explicit window. +/// Matches the `/memory compact` default so the two surfaces prune the +/// same amount. +const DEFAULT_MEMORY_KEEP: usize = 100; + +/// Pull a string argument out of the tool/JSON argument object. +fn arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, OperationError> { + args.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| OperationError::Validation(format!("missing argument '{key}'"))) +} + +/// Resolve a user-typed formation reference to `(id, name)`. +/// +/// Exact name first, then a unique case-insensitive prefix. An +/// ambiguous prefix is an error rather than a guess — steering the +/// wrong formation is the expensive mistake. +async fn resolve_formation( + state: &RuntimeState, + needle: &str, +) -> Result<(String, String), OperationError> { + let needle = needle.trim(); + if needle.is_empty() { + return Err(OperationError::Validation("which formation?".to_owned())); + } + let list = f::list_formations(state).await?; + if let Some(hit) = list.iter().find(|x| x.name.eq_ignore_ascii_case(needle)) { + return Ok((hit.id.clone(), hit.name.clone())); + } + let lower = needle.to_lowercase(); + let mut hits = list + .iter() + .filter(|x| x.name.to_lowercase().starts_with(&lower)); + match (hits.next(), hits.next()) { + (Some(hit), None) => Ok((hit.id.clone(), hit.name.clone())), + (Some(_), Some(_)) => Err(OperationError::Validation(format!( + "'{needle}' matches more than one formation — say the whole name" + ))), + _ => Err(OperationError::NotFound(format!( + "no formation called '{needle}'" + ))), + } +} + +/// Run one verb and return its structured result. +/// +/// The caller decides whether an approval was needed — this function +/// executes what it is handed. `read_only` on the verb is the input to +/// that decision, not something enforced here. +pub async fn run_platform_verb( + state: &RuntimeState, + verb: &PlatformVerb, + args: &Value, +) -> Result { + match verb.name { + // ── formation ──────────────────────────────────────────────── + "formation.list" => { + let list = f::list_formations(state).await?; + let rows: Vec = list + .iter() + .map(|x| { + json!({ + "id": x.id, + "name": x.name, + "status": x.status, + "intent": x.intent, + "members": x.member_count, + "momentum": x.momentum_label, + }) + }) + .collect(); + Ok(json!({ "formations": rows })) + } + "formation.get" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + let d = f::get_formation(state, &id).await?; + Ok(json!({ + "name": name, + "status": d.info.status, + "intent": d.info.intent, + "momentum": d.info.momentum_label, + "members": d.info.members, + })) + } + "formation.deploy" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + f::deploy_formation(state, &id).await?; + Ok(json!({ "formation": name, "status": "deployed" })) + } + "formation.pause" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + f::pause_formation(state, &id).await?; + Ok(json!({ "formation": name, "status": "paused" })) + } + "formation.resume" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + f::resume_formation(state, &id).await?; + Ok(json!({ "formation": name, "status": "resumed" })) + } + "formation.dissolve" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + f::dissolve_formation(state, &id).await?; + Ok(json!({ "formation": name, "status": "dissolved" })) + } + "formation.rally" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + f::rally_formation(state, &id).await?; + Ok(json!({ "formation": name, "status": "rallied" })) + } + "formation.intent" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + match args.get("intent").and_then(Value::as_str) { + Some(intent) if !intent.trim().is_empty() => { + f::update_intent(state, &id, intent.trim()).await?; + Ok(json!({ "formation": name, "intent": intent.trim() })) + } + _ => { + let next = f::cycle_intent(state, &id).await?; + Ok(json!({ "formation": name, "intent": next })) + } + } + } + "formation.guard" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + let on = config::toggle_formation_guard(state, &id).await?; + Ok(json!({ "formation": name, "guard": on })) + } + "formation.add_member" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + let connector = arg(args, "connector")?; + f::add_member(state, &id, connector).await?; + Ok(json!({ "formation": name, "added": connector })) + } + "formation.remove_member" => { + let (id, name) = resolve_formation(state, arg(args, "formation")?).await?; + let connector = arg(args, "connector")?; + f::remove_member(state, &id, connector).await?; + Ok(json!({ "formation": name, "removed": connector })) + } + // ── approvals ──────────────────────────────────────────────── + "approvals.list" => { + let rows: Vec = crate::operations::approvals::pending(state) + .await + .iter() + .map(|r| { + json!({ + "id": r.id.to_string(), + "connector": r.connector_name, + "summary": r.summary, + }) + }) + .collect(); + Ok(json!({ "pending": rows })) + } + "approvals.approve" | "approvals.deny" => { + let approve = verb.name == "approvals.approve"; + let id = arg(args, "id")?; + let uuid = uuid::Uuid::parse_str(id) + .map_err(|_| OperationError::Validation(format!("'{id}' is not an approval id")))?; + let req = crate::operations::approvals::ResolveRequest { + decision: if approve { + crate::operations::approvals::ResolveDecision::Approve + } else { + crate::operations::approvals::ResolveDecision::Deny + }, + approver: Some("owner (chat)".to_owned()), + reason: Some("denied from chat".to_owned()), + }; + crate::operations::approvals::resolve( + state, + crate::approval::ApprovalRequestId(uuid), + req, + ) + .await + .map_err(|e| OperationError::Validation(e.to_string()))?; + Ok(json!({ + "id": id, + "decision": if approve { "approved" } else { "denied" }, + })) + } + // ── memory ─────────────────────────────────────────────────── + "memory.audit" => { + let audit = memory::audit_memory(&*state.store).await?; + serde_json::to_value(audit).map_err(|e| OperationError::Serialization(e.to_string())) + } + "memory.compact" => { + let keep = args + .get("keep") + .and_then(Value::as_u64) + .map_or(DEFAULT_MEMORY_KEEP, |n| n as usize); + let deleted = memory::compact_memory(&*state.store, keep).await?; + Ok(json!({ "kept_per_session": keep, "deleted": deleted })) + } + // ── safety ─────────────────────────────────────────────────── + "safety.get" => { + let cfg = safety::get_safety_config(state).await?; + Ok(json!({ + "window_title": cfg.window_title, + "auto_lock_minutes": cfg.auto_lock_minutes, + "content_protected": cfg.content_protected, + "panic_taps": cfg.panic_tap_count, + "disguise_active": cfg.disguise_active, + })) + } + "safety.set" => { + let key = arg(args, "key")?; + let value = arg(args, "value")?; + let mut cfg = safety::get_safety_config(state).await?; + match key { + "window-title" => cfg.window_title = value.to_owned(), + "auto-lock-minutes" => { + cfg.auto_lock_minutes = value.parse().map_err(|_| { + OperationError::Validation("minutes must be a number".to_owned()) + })? + } + "content-protected" => { + cfg.content_protected = matches!(value, "true" | "on" | "yes") + } + "panic-taps" => { + cfg.panic_tap_count = value.parse().map_err(|_| { + OperationError::Validation("taps must be a number".to_owned()) + })? + } + other => { + return Err(OperationError::Validation(format!( + "'{other}' is not a safety setting" + ))); + } + } + safety::save_safety_config(state, cfg).await?; + Ok(json!({ "key": key, "value": value })) + } + // ── model configuration ────────────────────────────────────── + "ai.get" => { + let cfg = config::get_config(&*state.store, &config::AiTarget::Colony.key()).await?; + Ok(json!({ + "adapter": cfg.get("type").and_then(Value::as_str).unwrap_or("noop"), + "model": cfg.get("model").and_then(Value::as_str), + })) + } + "ai.set" => { + let requested = arg(args, "adapter")?; + let adapter = match requested { + "none" | "noop" => "noop", + a @ ("ollama" | "openai" | "anthropic") => a, + other => { + return Err(OperationError::Validation(format!( + "'{other}' is not an adapter" + ))); + } + }; + // Keep whatever else is configured (model, host, key + // reference) and change only the adapter type — same as + // `/ai set`. + let mut cfg = + config::get_config(&*state.store, &config::AiTarget::Colony.key()).await?; + if !cfg.is_object() { + cfg = json!({}); + } + if let Some(map) = cfg.as_object_mut() { + map.insert("type".to_owned(), json!(adapter)); + } + config::configure_ai_adapter(state, config::AiTarget::Colony, cfg).await?; + Ok(json!({ "adapter": adapter })) + } + other => Err(OperationError::NotFound(format!( + "'{other}' is not a platform verb" + ))), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn arg_rejects_missing_and_blank() { + let args = json!({ "formation": " ", "connector": "kick" }); + assert!(arg(&args, "formation").is_err()); + assert!(arg(&args, "missing").is_err()); + assert_eq!(arg(&args, "connector").unwrap(), "kick"); + } + + #[tokio::test] + async fn unknown_verb_is_not_found() { + // A verb value that is not in the registry can only be built by + // hand; running it must fail rather than silently no-op. + let verb = PlatformVerb { + name: "formation.assign", + description: "not a verb", + group: super::super::verb::VerbGroup::Intervention, + read_only: false, + args: &[], + }; + // No RuntimeState is needed: the match arm falls through to the + // catch-all before touching state, so a null state pointer is + // never dereferenced. We assert on the branch via `verb.name`. + assert!(super::super::find_verb(verb.name).is_none()); + } +} diff --git a/crates/springtale-runtime/src/operations/platform/verb.rs b/crates/springtale-runtime/src/operations/platform/verb.rs index 45b402af..15e9de03 100644 --- a/crates/springtale-runtime/src/operations/platform/verb.rs +++ b/crates/springtale-runtime/src/operations/platform/verb.rs @@ -79,6 +79,15 @@ impl PlatformVerb { } } + /// The verb's name as it appears inside an AI tool name. + /// + /// OpenAI's tool-name regex (`^[a-zA-Z0-9_-]{1,64}$`) forbids `.`, + /// so `formation.pause` publishes as `formation_pause`. The mapping + /// is reversed by [`super::registry::find_verb_by_tool_segment`]. + pub fn tool_segment(&self) -> String { + self.name.replace('.', "_") + } + /// True when the verb's first argument is a formation name. pub fn takes_formation(&self) -> bool { self.args.first() == Some(&"formation") From a4ebc30d0164a36d69b98cb0792a26ce6b2848d2 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:18:14 -0700 Subject: [PATCH 07/24] feat(chat): sentence templates per locale, matched in the speaker's language (plan 5.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue was pinned to `const CHAT_LOCALE = "en"` and seven of the eight locale files were empty stubs, so chat understood one language while the interface spoke eight. - `es`, `fr`, `pt`, `tl` and `ar` now carry real sentence templates for all twenty platform verbs, with the same slots the English file uses (`{formation}`, `{connector}`, `{intent}`, `{id}`, `{key}`, `{value}`, `{adapter}`) — filled from live state at match time, never hard-coded. Tagalog is written in the Taglish register technical commands are actually typed in. - `ja` and `th` stay stubs, and their files now say why: the NLU tokenizer splits on non-alphanumeric characters, and neither language puts spaces between words, so a template would collapse to one token and could never match. Populating them is blocked on word segmentation (lindera / ICU), not on translation. Both fall back to English, which at least matches loan words and the formation name. - `build_catalog` takes the speaker: their `language` preference picks the sentence file, region tags narrow to the base language, and anything uncovered falls back to English. Tests: every translated locale covers every verb (no half-translated file), and no translated phrase invents or drops a slot. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/conversation/augment.rs | 2 +- .../springtale-bot/src/conversation/engine.rs | 58 ++++++++++++++--- .../src/conversation/sentences/ar.yaml | 53 +++++++++++++--- .../src/conversation/sentences/catalog.rs | 62 +++++++++++++++++-- .../src/conversation/sentences/es.yaml | 52 +++++++++++++--- .../src/conversation/sentences/fr.yaml | 52 +++++++++++++--- .../src/conversation/sentences/ja.yaml | 20 ++++-- .../src/conversation/sentences/pt.yaml | 52 +++++++++++++--- .../src/conversation/sentences/th.yaml | 20 ++++-- .../src/conversation/sentences/tl.yaml | 53 +++++++++++++--- 10 files changed, 361 insertions(+), 63 deletions(-) diff --git a/crates/springtale-bot/src/conversation/augment.rs b/crates/springtale-bot/src/conversation/augment.rs index 57d3a265..d02e3992 100644 --- a/crates/springtale-bot/src/conversation/augment.rs +++ b/crates/springtale-bot/src/conversation/augment.rs @@ -32,7 +32,7 @@ pub async fn ai_assisted_start( return Ok(None); } - let catalog = engine::build_catalog(bot).await?; + let catalog = engine::build_catalog(bot, Some(&key.user_id)).await?; if catalog.intents.is_empty() { return Ok(None); } diff --git a/crates/springtale-bot/src/conversation/engine.rs b/crates/springtale-bot/src/conversation/engine.rs index 184660ae..f5769be5 100644 --- a/crates/springtale-bot/src/conversation/engine.rs +++ b/crates/springtale-bot/src/conversation/engine.rs @@ -24,6 +24,8 @@ use super::nlu::intent::{self, IntentDecision}; use springtale_runtime::operations::recipes::types::RecipeFilter; +use crate::conversation::sentences; + /// Run a dialogue turn if a setup frame is active. `Ok(None)` means /// "no active frame — not my turn", so the caller proceeds to routing. pub async fn continue_active( @@ -38,7 +40,7 @@ pub async fn continue_active( }; frame.bump_seq(); - let catalog = build_catalog(bot).await?; + let catalog = build_catalog(bot, Some(&key.user_id)).await?; let reply = drive(bot, &mut session, &mut frame, &catalog, text).await?; save_session(&bot.store, &session).await?; Ok(Some(reply)) @@ -51,7 +53,7 @@ pub async fn try_start( key: &SessionKey, text: &str, ) -> Result, ConversationError> { - let catalog = build_catalog(bot).await?; + let catalog = build_catalog(bot, Some(&key.user_id)).await?; let decision = intent::decide(intent::rank(text, &catalog)); let now = chrono::Utc::now(); @@ -100,7 +102,7 @@ pub async fn start_recipe( recipe_id: &str, utterance: &str, ) -> Result, ConversationError> { - let catalog = build_catalog(bot).await?; + let catalog = build_catalog(bot, Some(&key.user_id)).await?; let Some(doc) = catalog.find(recipe_id).cloned() else { return Ok(None); }; @@ -114,7 +116,9 @@ pub async fn start_recipe( /// fallback (replacing the old static suggestion) when no command, no /// frame, and no AI handle the message. pub async fn capability_reply(bot: &Bot) -> Result { - let catalog = build_catalog(bot).await?; + // No session key on this path: the capability reply lists what the + // bot can do, not what one speaker said, so it reads English. + let catalog = build_catalog(bot, None).await?; let examples: Vec = catalog .intents .iter() @@ -127,7 +131,10 @@ pub async fn capability_reply(bot: &Bot) -> Result { // ── internals ──────────────────────────────────────────────────────── -pub(super) async fn build_catalog(bot: &Bot) -> Result { +pub(super) async fn build_catalog( + bot: &Bot, + user_id: Option<&str>, +) -> Result { let recipes = springtale_runtime::operations::recipes::list_recipes(&*bot.store, RecipeFilter::default()) .await?; @@ -135,17 +142,48 @@ pub(super) async fn build_catalog(bot: &Bot) -> Result) -> String { + let Some(user_id) = user_id else { + return DEFAULT_LOCALE.to_owned(); + }; + let language = match crate::state::prefs::load_or_default(&bot.store, user_id).await { + Ok(prefs) => prefs.language, + Err(e) => { + tracing::debug!(error = %e, "prefs unreadable — chat falls back to English"); + return DEFAULT_LOCALE.to_owned(); + } + }; + let base = language + .split(['-', '_']) + .next() + .unwrap_or(DEFAULT_LOCALE) + .to_lowercase(); + if sentences::LOCALES.contains(&base.as_str()) { + base + } else { + DEFAULT_LOCALE.to_owned() + } +} + +/// Fallback when the speaker is unknown or their language is not one +/// the sentence files cover. +const DEFAULT_LOCALE: &str = "en"; /// Formation names from the store, or none when this bot has no runtime /// (headless / CLI / tests) — then the platform documents simply carry diff --git a/crates/springtale-bot/src/conversation/sentences/ar.yaml b/crates/springtale-bot/src/conversation/sentences/ar.yaml index 3ed34b88..c768aca4 100644 --- a/crates/springtale-bot/src/conversation/sentences/ar.yaml +++ b/crates/springtale-bot/src/conversation/sentences/ar.yaml @@ -1,9 +1,48 @@ -# STUB — ar sentence templates for the platform verbs (plan 5.4). +# Arabic sentence templates for the platform verbs (plan 5.4). # -# Only `en` is populated today. This file exists so the layout is the -# one the plan asks for (one file per locale the UI speaks) and so a -# translator has somewhere to write. `verbs` being empty means the ar -# chat falls back to the English phrasings; nothing breaks, but nothing -# is translated either. +# Modern Standard Arabic imperatives — the register a written command +# is given in. Same verbs and slots as `en.yaml`; `{formation}` and +# friends are filled from live state at match time. Arabic is written +# with spaces between words, so the NLU tokenizer segments it correctly. locale: ar -verbs: {} +verbs: + formation.list: + phrases: ["اعرض التشكيلات", "ما هي التشكيلات", "قائمة التشكيلات", "اعرض المستعمرة"] + formation.get: + phrases: ["اعرض {formation}", "ما حالة {formation}", "كيف حال {formation}", "أخبرني عن {formation}"] + formation.deploy: + phrases: ["انشر {formation}", "ابدأ {formation}", "أطلق {formation}"] + formation.pause: + phrases: ["أوقف {formation} مؤقتا", "علق {formation}", "توقف عن {formation} الآن"] + formation.resume: + phrases: ["استأنف {formation}", "أكمل {formation}", "تابع {formation}"] + formation.dissolve: + phrases: ["حل {formation}", "أنه {formation}", "أغلق {formation}"] + formation.rally: + phrases: ["اجمع {formation}", "أعد تجميع {formation}", "ركز {formation}"] + formation.intent: + phrases: ["غير هدف {formation}", "اجعل هدف {formation} {intent}", "ماذا تفعل {formation}"] + formation.guard: + phrases: ["احم {formation}", "فعل الحارس في {formation}", "غير الحارس في {formation}"] + formation.add_member: + phrases: ["أضف {connector} إلى {formation}", "ضع {connector} في {formation}"] + formation.remove_member: + phrases: ["أزل {connector} من {formation}", "احذف {connector} من {formation}"] + approvals.list: + phrases: ["اعرض الموافقات", "ما الذي ينتظر الموافقة", "قائمة الموافقات", "هل هناك شيء معلق"] + approvals.approve: + phrases: ["وافق على {id}", "اسمح بـ {id}"] + approvals.deny: + phrases: ["ارفض {id}", "لا توافق على {id}"] + memory.audit: + phrases: ["دقق الذاكرة", "ماذا تتذكر", "اعرض ما هو مخزن"] + memory.compact: + phrases: ["اضغط الذاكرة", "نظف الذاكرة", "انس الرسائل القديمة"] + safety.get: + phrases: ["اعرض إعدادات الأمان", "ما حالة الأمان", "ما هي إعدادات الأمان"] + safety.set: + phrases: ["اضبط {key} في الأمان على {value}", "غير إعداد الأمان {key}"] + ai.get: + phrases: ["ما النموذج الذي تستخدمه", "اعرض محول الذكاء الاصطناعي", "ما الذكاء الاصطناعي المضبوط"] + ai.set: + phrases: ["استخدم {adapter}", "غير النموذج إلى {adapter}", "اضبط محول الذكاء الاصطناعي على {adapter}"] diff --git a/crates/springtale-bot/src/conversation/sentences/catalog.rs b/crates/springtale-bot/src/conversation/sentences/catalog.rs index c4fd285d..b51fa9db 100644 --- a/crates/springtale-bot/src/conversation/sentences/catalog.rs +++ b/crates/springtale-bot/src/conversation/sentences/catalog.rs @@ -7,8 +7,11 @@ //! `{locale}.yaml` beside this file, one per language //! `packages/ui/src/i18n/locales` speaks. //! -//! Only `en` is populated today; the other seven are stubs, and a -//! locale with no phrases falls back to English. +//! Six locales are populated — `en`, `es`, `fr`, `pt`, `tl`, `ar`. `ja` +//! and `th` are deliberately still stubs: the tokenizer segments on +//! spaces and those two scripts are written without them, so templates +//! could not match (each file says so at the top). A locale with no +//! phrases falls back to English. use std::collections::HashMap; use std::sync::OnceLock; @@ -42,8 +45,8 @@ impl SentenceCatalog { } } -/// Locales shipped with a sentence file. `en` is real; the rest are -/// stubs awaiting translation. +/// Locales shipped with a sentence file — the same eight the UI speaks +/// (`packages/ui/src/i18n/locales`). pub const LOCALES: &[&str] = &["en", "ar", "es", "fr", "ja", "pt", "th", "tl"]; const EN: &str = include_str!("en.yaml"); @@ -109,6 +112,10 @@ mod tests { use super::*; use springtale_runtime::operations::platform::platform_verbs; + /// Locales with real sentence templates. `ja` and `th` are stubs + /// pending a word segmenter — see their files. + const TRANSLATED: &[&str] = &["en", "es", "fr", "pt", "tl", "ar"]; + #[test] fn test_every_locale_file_parses() { for locale in LOCALES { @@ -127,10 +134,55 @@ mod tests { } } + /// Every locale that ships phrases ships them for EVERY verb — a + /// half-translated file would silently answer some verbs in one + /// language and some in another. + #[test] + fn test_translated_locales_cover_every_platform_verb() { + for locale in TRANSLATED { + let cat = for_locale(locale); + for verb in platform_verbs() { + assert!( + cat.verbs + .get(verb.name) + .is_some_and(|v| !v.phrases.is_empty()), + "locale `{locale}` has no sentence template for `{}`", + verb.name + ); + } + } + } + + /// A verb's slots must survive translation: a translated phrase may + /// reorder them, but it may not invent or drop one. + #[test] + fn test_translated_phrases_use_declared_slots() { + for locale in TRANSLATED { + for verb in platform_verbs() { + for phrase in for_locale(locale).phrases(verb.name) { + for slot in phrase + .split('{') + .skip(1) + .filter_map(|s| s.split('}').next()) + { + assert!( + verb.args.contains(&slot) + || matches!(slot, "intent" | "key" | "value" | "adapter" | "id"), + "locale `{locale}`: `{}` uses unknown slot `{{{slot}}}`", + verb.name + ); + } + } + } + } + } + #[test] fn test_stub_locale_falls_back_to_english() { + // `ja` and `th` are stubs on purpose (no word segmentation). + assert!(for_locale("ja").verbs.is_empty()); assert_eq!( - for_locale("fr").phrases("formation.pause"), + for_locale("ja").phrases("formation.pause"), english().phrases("formation.pause") ); } diff --git a/crates/springtale-bot/src/conversation/sentences/es.yaml b/crates/springtale-bot/src/conversation/sentences/es.yaml index a1981061..2d9a9bf2 100644 --- a/crates/springtale-bot/src/conversation/sentences/es.yaml +++ b/crates/springtale-bot/src/conversation/sentences/es.yaml @@ -1,9 +1,47 @@ -# STUB — es sentence templates for the platform verbs (plan 5.4). +# Spanish sentence templates for the platform verbs (plan 5.4). # -# Only `en` is populated today. This file exists so the layout is the -# one the plan asks for (one file per locale the UI speaks) and so a -# translator has somewhere to write. `verbs` being empty means the es -# chat falls back to the English phrasings; nothing breaks, but nothing -# is translated either. +# Same verbs, same slots as `en.yaml`: `{formation}`, `{connector}`, +# `{intent}`, `{id}`, `{key}`, `{value}`, `{adapter}` are filled at +# match time from live state, never hard-coded. locale: es -verbs: {} +verbs: + formation.list: + phrases: ["lista las formaciones", "muestra las formaciones", "qué formaciones hay", "ver la colonia"] + formation.get: + phrases: ["muestra {formation}", "cómo va {formation}", "estado de {formation}", "háblame de {formation}"] + formation.deploy: + phrases: ["despliega {formation}", "inicia {formation}", "lanza {formation}", "pon en marcha {formation}"] + formation.pause: + phrases: ["pausa {formation}", "detén {formation}", "para {formation} por ahora", "congela {formation}"] + formation.resume: + phrases: ["reanuda {formation}", "continúa con {formation}", "quita la pausa a {formation}", "sigue con {formation}"] + formation.dissolve: + phrases: ["disuelve {formation}", "desmantela {formation}", "cierra {formation}", "elimina {formation}"] + formation.rally: + phrases: ["reagrupa {formation}", "reúne {formation}", "enfoca {formation}"] + formation.intent: + phrases: ["cambia la intención de {formation}", "pon {formation} en {intent}", "qué está haciendo {formation}"] + formation.guard: + phrases: ["protege {formation}", "activa la guardia de {formation}", "cambia la guardia de {formation}"] + formation.add_member: + phrases: ["añade {connector} a {formation}", "mete {connector} en {formation}"] + formation.remove_member: + phrases: ["quita {connector} de {formation}", "saca {connector} de {formation}"] + approvals.list: + phrases: ["lista las aprobaciones", "qué está esperando aprobación", "muestra la cola de aprobaciones", "hay algo pendiente"] + approvals.approve: + phrases: ["aprueba {id}", "permite {id}", "dile que sí a {id}"] + approvals.deny: + phrases: ["rechaza {id}", "deniega {id}", "dile que no a {id}"] + memory.audit: + phrases: ["audita la memoria", "qué recuerdas", "muestra lo que tienes guardado"] + memory.compact: + phrases: ["compacta la memoria", "limpia la memoria", "olvida los mensajes antiguos"] + safety.get: + phrases: ["muestra la configuración de seguridad", "estado de seguridad", "cómo está la seguridad"] + safety.set: + phrases: ["pon {key} de seguridad en {value}", "cambia el ajuste de seguridad {key}"] + ai.get: + phrases: ["qué modelo estás usando", "muestra el adaptador de ia", "qué ia está configurada"] + ai.set: + phrases: ["usa {adapter}", "cambia el modelo a {adapter}", "configura el adaptador de ia a {adapter}"] diff --git a/crates/springtale-bot/src/conversation/sentences/fr.yaml b/crates/springtale-bot/src/conversation/sentences/fr.yaml index 5c194a74..a674ec64 100644 --- a/crates/springtale-bot/src/conversation/sentences/fr.yaml +++ b/crates/springtale-bot/src/conversation/sentences/fr.yaml @@ -1,9 +1,47 @@ -# STUB — fr sentence templates for the platform verbs (plan 5.4). +# French sentence templates for the platform verbs (plan 5.4). # -# Only `en` is populated today. This file exists so the layout is the -# one the plan asks for (one file per locale the UI speaks) and so a -# translator has somewhere to write. `verbs` being empty means the fr -# chat falls back to the English phrasings; nothing breaks, but nothing -# is translated either. +# Same verbs, same slots as `en.yaml`: `{formation}`, `{connector}`, +# `{intent}`, `{id}`, `{key}`, `{value}`, `{adapter}` are filled at +# match time from live state, never hard-coded. locale: fr -verbs: {} +verbs: + formation.list: + phrases: ["liste les formations", "montre les formations", "quelles formations", "voir la colonie"] + formation.get: + phrases: ["montre {formation}", "où en est {formation}", "statut de {formation}", "parle-moi de {formation}"] + formation.deploy: + phrases: ["déploie {formation}", "démarre {formation}", "lance {formation}", "mets {formation} en route"] + formation.pause: + phrases: ["mets {formation} en pause", "arrête {formation} pour l'instant", "suspends {formation}", "gèle {formation}"] + formation.resume: + phrases: ["reprends {formation}", "relance {formation}", "enlève la pause de {formation}", "continue avec {formation}"] + formation.dissolve: + phrases: ["dissous {formation}", "démantèle {formation}", "ferme {formation}", "supprime {formation}"] + formation.rally: + phrases: ["rassemble {formation}", "regroupe {formation}", "recentre {formation}"] + formation.intent: + phrases: ["change l'intention de {formation}", "mets {formation} en {intent}", "que fait {formation}"] + formation.guard: + phrases: ["protège {formation}", "active la garde de {formation}", "bascule la garde de {formation}"] + formation.add_member: + phrases: ["ajoute {connector} à {formation}", "mets {connector} dans {formation}"] + formation.remove_member: + phrases: ["retire {connector} de {formation}", "enlève {connector} de {formation}"] + approvals.list: + phrases: ["liste les approbations", "qu'est-ce qui attend une approbation", "montre la file d'approbation", "y a-t-il quelque chose en attente"] + approvals.approve: + phrases: ["approuve {id}", "autorise {id}", "dis oui à {id}"] + approvals.deny: + phrases: ["refuse {id}", "rejette {id}", "dis non à {id}"] + memory.audit: + phrases: ["audite la mémoire", "de quoi te souviens-tu", "montre ce qui est stocké"] + memory.compact: + phrases: ["compacte la mémoire", "nettoie la mémoire", "oublie les vieux messages"] + safety.get: + phrases: ["montre les réglages de sécurité", "état de la sécurité", "quelle est la configuration de sécurité"] + safety.set: + phrases: ["règle {key} de sécurité sur {value}", "change le réglage de sécurité {key}"] + ai.get: + phrases: ["quel modèle utilises-tu", "montre l'adaptateur ia", "quelle ia est configurée"] + ai.set: + phrases: ["utilise {adapter}", "change le modèle pour {adapter}", "règle l'adaptateur ia sur {adapter}"] diff --git a/crates/springtale-bot/src/conversation/sentences/ja.yaml b/crates/springtale-bot/src/conversation/sentences/ja.yaml index dd5cfacf..e323e9b1 100644 --- a/crates/springtale-bot/src/conversation/sentences/ja.yaml +++ b/crates/springtale-bot/src/conversation/sentences/ja.yaml @@ -1,9 +1,17 @@ -# STUB — ja sentence templates for the platform verbs (plan 5.4). +# STUB — ja (Japanese) sentence templates for the platform verbs (plan 5.4). # -# Only `en` is populated today. This file exists so the layout is the -# one the plan asks for (one file per locale the UI speaks) and so a -# translator has somewhere to write. `verbs` being empty means the ja -# chat falls back to the English phrasings; nothing breaks, but nothing -# is translated either. +# Deliberately empty, and not for want of translation. The NLU +# tokenizer (`conversation::nlu::normalize::raw_tokens`) splits an +# utterance on non-alphanumeric characters, i.e. on spaces. Japanese is +# written without spaces between words, so a template like the Japanese +# for "pause {formation}" would collapse to ONE token and could only +# ever match a byte-identical utterance — worse than the English +# fallback, which at least matches the loan words and the formation +# name a Japanese speaker types. +# +# Populating this file is blocked on word segmentation for Japanese in +# the tokenizer (a dictionary segmenter such as lindera, or ICU break +# iteration), not on the phrasings. Until then `verbs` stays empty and +# this locale falls back to English (`SentenceCatalog::phrases`). locale: ja verbs: {} diff --git a/crates/springtale-bot/src/conversation/sentences/pt.yaml b/crates/springtale-bot/src/conversation/sentences/pt.yaml index c2d22c17..4faa0b2e 100644 --- a/crates/springtale-bot/src/conversation/sentences/pt.yaml +++ b/crates/springtale-bot/src/conversation/sentences/pt.yaml @@ -1,9 +1,47 @@ -# STUB — pt sentence templates for the platform verbs (plan 5.4). +# Portuguese sentence templates for the platform verbs (plan 5.4). # -# Only `en` is populated today. This file exists so the layout is the -# one the plan asks for (one file per locale the UI speaks) and so a -# translator has somewhere to write. `verbs` being empty means the pt -# chat falls back to the English phrasings; nothing breaks, but nothing -# is translated either. +# Same verbs, same slots as `en.yaml`: `{formation}`, `{connector}`, +# `{intent}`, `{id}`, `{key}`, `{value}`, `{adapter}` are filled at +# match time from live state, never hard-coded. locale: pt -verbs: {} +verbs: + formation.list: + phrases: ["lista as formações", "mostra as formações", "quais formações existem", "ver a colônia"] + formation.get: + phrases: ["mostra {formation}", "como está {formation}", "estado de {formation}", "fala sobre {formation}"] + formation.deploy: + phrases: ["implanta {formation}", "inicia {formation}", "lança {formation}", "coloca {formation} para rodar"] + formation.pause: + phrases: ["pausa {formation}", "para {formation} por enquanto", "suspende {formation}", "congela {formation}"] + formation.resume: + phrases: ["retoma {formation}", "continua com {formation}", "tira {formation} da pausa"] + formation.dissolve: + phrases: ["dissolve {formation}", "desfaz {formation}", "encerra {formation}", "remove {formation}"] + formation.rally: + phrases: ["reagrupa {formation}", "reúne {formation}", "foca {formation}"] + formation.intent: + phrases: ["muda a intenção de {formation}", "coloca {formation} em {intent}", "o que {formation} está fazendo"] + formation.guard: + phrases: ["protege {formation}", "ativa a guarda de {formation}", "alterna a guarda de {formation}"] + formation.add_member: + phrases: ["adiciona {connector} a {formation}", "põe {connector} em {formation}"] + formation.remove_member: + phrases: ["remove {connector} de {formation}", "tira {connector} de {formation}"] + approvals.list: + phrases: ["lista as aprovações", "o que está esperando aprovação", "mostra a fila de aprovações", "tem algo pendente"] + approvals.approve: + phrases: ["aprova {id}", "permite {id}", "diz sim para {id}"] + approvals.deny: + phrases: ["nega {id}", "rejeita {id}", "diz não para {id}"] + memory.audit: + phrases: ["audita a memória", "do que você se lembra", "mostra o que está guardado"] + memory.compact: + phrases: ["compacta a memória", "limpa a memória", "esquece as mensagens antigas"] + safety.get: + phrases: ["mostra as configurações de segurança", "estado da segurança", "como está a segurança"] + safety.set: + phrases: ["define {key} de segurança como {value}", "muda a configuração de segurança {key}"] + ai.get: + phrases: ["que modelo você está usando", "mostra o adaptador de ia", "qual ia está configurada"] + ai.set: + phrases: ["usa {adapter}", "muda o modelo para {adapter}", "define o adaptador de ia como {adapter}"] diff --git a/crates/springtale-bot/src/conversation/sentences/th.yaml b/crates/springtale-bot/src/conversation/sentences/th.yaml index d65cf79c..396f90a3 100644 --- a/crates/springtale-bot/src/conversation/sentences/th.yaml +++ b/crates/springtale-bot/src/conversation/sentences/th.yaml @@ -1,9 +1,17 @@ -# STUB — th sentence templates for the platform verbs (plan 5.4). +# STUB — th (Thai) sentence templates for the platform verbs (plan 5.4). # -# Only `en` is populated today. This file exists so the layout is the -# one the plan asks for (one file per locale the UI speaks) and so a -# translator has somewhere to write. `verbs` being empty means the th -# chat falls back to the English phrasings; nothing breaks, but nothing -# is translated either. +# Deliberately empty, and not for want of translation. The NLU +# tokenizer (`conversation::nlu::normalize::raw_tokens`) splits an +# utterance on non-alphanumeric characters, i.e. on spaces. Thai is +# written without spaces between words, so a template like the Thai +# for "pause {formation}" would collapse to ONE token and could only +# ever match a byte-identical utterance — worse than the English +# fallback, which at least matches the loan words and the formation +# name a Thai speaker types. +# +# Populating this file is blocked on word segmentation for Thai in +# the tokenizer (a dictionary segmenter such as lindera, or ICU break +# iteration), not on the phrasings. Until then `verbs` stays empty and +# this locale falls back to English (`SentenceCatalog::phrases`). locale: th verbs: {} diff --git a/crates/springtale-bot/src/conversation/sentences/tl.yaml b/crates/springtale-bot/src/conversation/sentences/tl.yaml index e725adab..166c0ba3 100644 --- a/crates/springtale-bot/src/conversation/sentences/tl.yaml +++ b/crates/springtale-bot/src/conversation/sentences/tl.yaml @@ -1,9 +1,48 @@ -# STUB — tl sentence templates for the platform verbs (plan 5.4). +# Tagalog sentence templates for the platform verbs (plan 5.4). # -# Only `en` is populated today. This file exists so the layout is the -# one the plan asks for (one file per locale the UI speaks) and so a -# translator has somewhere to write. `verbs` being empty means the tl -# chat falls back to the English phrasings; nothing breaks, but nothing -# is translated either. +# Written in the register Filipino users actually type technical +# commands in (Taglish): the verb stays English where the English word +# is the ordinary one, the grammar is Tagalog. Same verbs and slots as +# `en.yaml`; `{formation}` and friends are filled from live state. locale: tl -verbs: {} +verbs: + formation.list: + phrases: ["ilista ang mga formation", "ipakita ang mga formation", "anong mga formation meron", "tingnan ang colony"] + formation.get: + phrases: ["ipakita ang {formation}", "kumusta na ang {formation}", "status ng {formation}", "ano ang balita sa {formation}"] + formation.deploy: + phrases: ["i-deploy ang {formation}", "simulan ang {formation}", "ilunsad ang {formation}", "paandarin ang {formation}"] + formation.pause: + phrases: ["i-pause ang {formation}", "itigil muna ang {formation}", "ihinto ang {formation}", "sandaling itigil ang {formation}"] + formation.resume: + phrases: ["ituloy ang {formation}", "i-resume ang {formation}", "ipagpatuloy ang {formation}"] + formation.dissolve: + phrases: ["buwagin ang {formation}", "tanggalin ang {formation}", "isara ang {formation}"] + formation.rally: + phrases: ["tipunin ang {formation}", "pagsama-samahin ang {formation}", "ipokus ang {formation}"] + formation.intent: + phrases: ["palitan ang intent ng {formation}", "gawing {intent} ang {formation}", "ano ang ginagawa ng {formation}"] + formation.guard: + phrases: ["bantayan ang {formation}", "i-on ang guard ng {formation}", "palitan ang guard ng {formation}"] + formation.add_member: + phrases: ["idagdag ang {connector} sa {formation}", "ilagay ang {connector} sa {formation}"] + formation.remove_member: + phrases: ["alisin ang {connector} sa {formation}", "tanggalin ang {connector} sa {formation}"] + approvals.list: + phrases: ["ilista ang mga approval", "ano ang naghihintay ng approval", "ipakita ang approval queue", "may pending ba"] + approvals.approve: + phrases: ["aprubahan ang {id}", "payagan ang {id}", "sabihing oo sa {id}"] + approvals.deny: + phrases: ["tanggihan ang {id}", "huwag payagan ang {id}", "sabihing hindi sa {id}"] + memory.audit: + phrases: ["i-audit ang memory", "ano ang naaalala mo", "ipakita ang nakaimbak"] + memory.compact: + phrases: ["i-compact ang memory", "linisin ang memory", "kalimutan ang mga lumang mensahe"] + safety.get: + phrases: ["ipakita ang safety settings", "status ng safety", "ano ang safety config"] + safety.set: + phrases: ["gawing {value} ang safety {key}", "palitan ang safety setting na {key}"] + ai.get: + phrases: ["anong model ang ginagamit mo", "ipakita ang ai adapter", "anong ai ang naka-configure"] + ai.set: + phrases: ["gamitin ang {adapter}", "palitan ang model sa {adapter}", "gawing {adapter} ang ai adapter"] From 9b4cd37c607bc0afa46fe1ab74151d8d12e193df Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:18:26 -0700 Subject: [PATCH 08/24] fix(chat): read the live AI adapter, not the one the bot was built with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the model swapped `RuntimeState::ai_adapter` (an `ArcSwap`), which rule dispatch reads through the capability bridge — but the chat fallback held `Bot::ai_adapter`, the snapshot taken when the bot was built. So a model change landed on rules immediately and on chat only after a lock and unlock rebuilt the bot. `ai_fallback` now loads the same swappable handle when a runtime is wired, falling back to the built-with adapter for headless and test bots that have none. The availability check and the tool runner both see the current adapter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- crates/springtale-bot/src/runtime/handlers.rs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/springtale-bot/src/runtime/handlers.rs b/crates/springtale-bot/src/runtime/handlers.rs index 4cf9ff00..8d22e4f9 100644 --- a/crates/springtale-bot/src/runtime/handlers.rs +++ b/crates/springtale-bot/src/runtime/handlers.rs @@ -396,14 +396,39 @@ pub(super) async fn handle_incoming_message( /// Returns `Some(response)` if AI is available and responds successfully. /// Returns `None` if AI is unavailable, disabled, or errors — caller /// should fall back to the static "Unknown command" suggestion. +/// The AI adapter chat should use right now. +/// +/// `Bot::ai_adapter` is the adapter the bot was BUILT with. When a +/// runtime is wired, `RuntimeState::ai_adapter` is the swappable handle +/// every other dispatch path reads, and changing the model swaps it in +/// place. Chat reads the same handle so a model change lands on the +/// next message instead of the next unlock. +fn live_ai_adapter(bot: &Bot) -> std::sync::Arc { + match &bot.runtime { + Some(rt) => { + let guard = rt.ai_adapter.load(); + (**guard).clone() + } + None => bot.ai_adapter.clone(), + } +} + async fn ai_fallback( bot: &mut Bot, session_key: &crate::state::session::SessionKey, user_text: &str, source_connector: &str, ) -> Option { + // The adapter is hot-swapped through `RuntimeState::ai_adapter` + // when the model changes (`operations::config`), so read the LIVE + // handle rather than the snapshot the bot was built with — + // otherwise a model change reaches rule dispatch (which goes + // through the bridge's handle) but not chat, until a lock and + // unlock rebuilds the bot. + let adapter = live_ai_adapter(bot); + // Check if AI is available (NoopAdapter returns false → skip) - if !bot.ai_adapter.is_available().await { + if !adapter.is_available().await { return None; } @@ -474,7 +499,7 @@ async fn ai_fallback( // Formation-scoped tool invocation from the tick processor will pass // `Some(momentum_to_wasm_tier(tier))`. let tool_deps = crate::tool_runner::ToolRunnerDeps { - adapter: bot.ai_adapter.as_ref(), + adapter: adapter.as_ref(), registry: &bot.registry, bridge: &bot.capability_bridge, sentinel: &bot.sentinel, From 1cea97115f2171eb203b2722164f4f6524bf7428 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:18:45 -0700 Subject: [PATCH 09/24] sdk: connector WIT world, component example, real sandbox positive test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 83 / ALIGNMENT-PLAN 2.7: `crates/springtale-wit` shipped the cooperation world, but `sdk/connector-sdk` had no `.wit` at all, so the sandbox work rested on hand-written WebAssembly text and the SDK's documented ABI was the only statement of the contract. `sdk/connector-sdk/wit/connector.wit` states it. Two worlds, because host imports are gated by the manifest and not by the language: `connector` (exports `guest`, imports nothing) for a connector that declares no capabilities, and `networked-connector` which adds the `host` interface. `host` carries exactly one function, mirroring the one `register_http_request` actually provides, with the -1/-2 return values named as `access-error` variants. WASI imports are documented rather than declared: the host links the `wasi:http/proxy` set plus `wasi:cli/{exit,environment,terminal-*}` and `wasi:filesystem`, all against a `WasiCtx` that grants nothing, and never `wasi:sockets`. Declaring that world here would need the `wasi:*` packages vendored and would imply a grant the host does not make. `connector-hello-wasm` is now a `wasm32-wasip2` component built against that world with `wit-bindgen`, so the actions it exports and the `read_only` bits in its manifest are checked by the type system rather than by comment. Both example and SDK gain an empty `[workspace]` table so they resolve their own graph from any checkout path. The 2.7 gap is closed: the tier cache's positive test no longer proves linkage against hand-written WAT. It `include_bytes!`s the built component from `prebuilt/`, links it against the WASI p2 linker, instantiates it, and calls `execute` — lifting the result into a host-side mirror of the WIT record, so a renamed field fails the typed lookup. `greet` returns its greeting and an unknown action comes back through `action-result` instead of trapping. The artefact is checked in so the test needs no wasm target, and no Python or JavaScript toolchain, at test time; `prebuilt/README.md` says how to regenerate it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/wasm/tier/cache.rs | 77 ++++++ sdk/connector-sdk/Cargo.toml | 3 + sdk/connector-sdk/wit/connector.wit | 112 +++++++++ sdk/examples/connector-hello-wasm/Cargo.lock | 236 ++++++++++++++++-- sdk/examples/connector-hello-wasm/Cargo.toml | 9 +- .../connector-hello-wasm/prebuilt/README.md | 24 ++ .../prebuilt/connector_hello_wasm.wasm | Bin 0 -> 110083 bytes sdk/examples/connector-hello-wasm/src/lib.rs | 108 +++++--- 8 files changed, 522 insertions(+), 47 deletions(-) create mode 100644 sdk/connector-sdk/wit/connector.wit create mode 100644 sdk/examples/connector-hello-wasm/prebuilt/README.md create mode 100644 sdk/examples/connector-hello-wasm/prebuilt/connector_hello_wasm.wasm diff --git a/crates/springtale-connector/src/wasm/tier/cache.rs b/crates/springtale-connector/src/wasm/tier/cache.rs index efb14618..d470889d 100644 --- a/crates/springtale-connector/src/wasm/tier/cache.rs +++ b/crates/springtale-connector/src/wasm/tier/cache.rs @@ -359,6 +359,83 @@ mod tests { } } + /// The `connector-hello-wasm` example from `sdk/examples`, built for + /// `wasm32-wasip2` against the SDK's WIT world + /// (`sdk/connector-sdk/wit/connector.wit`) and checked in under + /// `prebuilt/`. Checked in on purpose: the test then needs no wasm + /// target and no Python or JavaScript toolchain at test time. + /// `sdk/examples/connector-hello-wasm/prebuilt/README.md` says how to + /// regenerate it; CI rebuilds the source on every push. + const HELLO_COMPONENT: &[u8] = include_bytes!(concat!( + "../../../../../sdk/examples/connector-hello-wasm/", + "prebuilt/connector_hello_wasm.wasm" + )); + + /// Host-side mirror of `action-result` in the WIT world. Lifting into + /// it is what proves the guest and the world agree on the record + /// shape — a renamed or reordered field fails the typed lookup. + #[derive(wasmtime::component::ComponentType, wasmtime::component::Lift)] + #[component(record)] + struct WitActionResult { + success: bool, + output: String, + message: String, + } + + /// The positive test for ALIGNMENT-PLAN 2.7. A real component — the + /// kind a community author produces — links against the host's WASI + /// Preview 2 linker and its exported `execute` runs to completion + /// inside the sandbox. The hand-written WAT below only ever proved + /// the import shape; this proves execution. + #[test] + fn hello_component_from_sdk_world_links_and_executes() { + let engine = Arc::new(WasmEngine::new(SandboxLimits::default()).unwrap()); + let cache = WasmTierCache::new(engine.clone()).unwrap(); + let component = Component::new(engine.engine(), HELLO_COMPONENT).unwrap(); + let pre = cache + .preinstantiate_component("connector-hello-wasm", &component) + .expect("SDK-world component must link against the WASI p2 linker"); + + let mut store = Store::new( + engine.engine(), + test_host_state("connector-hello-wasm", engine.as_ref()), + ); + store.set_fuel(u64::MAX / 2).ok(); + store.set_epoch_deadline(u64::MAX); + let instance = pre.instantiate(&mut store).expect("instantiate component"); + + let iface = instance + .get_export_index(&mut store, None, "springtale:connector/guest@0.1.0") + .expect("component must export the world's guest interface"); + let execute_idx = instance + .get_export_index(&mut store, Some(&iface), "execute") + .expect("guest must export execute"); + let execute = instance + .get_typed_func::<(String, String), (WitActionResult,)>(&mut store, &execute_idx) + .expect("execute must match the WIT signature"); + + let (result,) = execute + .call( + &mut store, + ("greet".to_owned(), r#"{"name":"kali"}"#.to_owned()), + ) + .expect("greet must not trap"); + assert!(result.success, "greet failed: {}", result.message); + assert!( + result.output.contains("Hello, kali!"), + "unexpected output: {}", + result.output + ); + + // An unknown action is reported through the record, not by + // trapping — the world says errors travel in `action-result`. + let (missing,) = execute + .call(&mut store, ("nope".to_owned(), "{}".to_owned())) + .expect("unknown action must not trap"); + assert!(!missing.success); + assert!(missing.message.contains("unknown action")); + } + /// A component shaped like `jco componentize` output: it imports the /// WASI Preview 2 interfaces a JS component always pulls in. Before /// the WASI context existed this could not instantiate at all diff --git a/sdk/connector-sdk/Cargo.toml b/sdk/connector-sdk/Cargo.toml index 39fbd282..097efdae 100644 --- a/sdk/connector-sdk/Cargo.toml +++ b/sdk/connector-sdk/Cargo.toml @@ -16,3 +16,6 @@ serde_json = "1" opt-level = "s" # optimize for size (smaller .wasm) lto = true # link-time optimization strip = true # strip debug info + +# Standalone: excluded from the root workspace (different target). +[workspace] diff --git a/sdk/connector-sdk/wit/connector.wit b/sdk/connector-sdk/wit/connector.wit new file mode 100644 index 00000000..c5d404ae --- /dev/null +++ b/sdk/connector-sdk/wit/connector.wit @@ -0,0 +1,112 @@ +// Springtale Connector WIT World — ALIGNMENT-PLAN 2.7 / finding 83. +// +// The interface description for community connectors. Until this file +// existed the sandbox work rested on hand-written WebAssembly text: the +// host linked a set of interfaces and the SDK documented an ABI, but +// nothing stated the contract in a form a component toolchain could +// consume. This is that statement. +// +// Two worlds, because host imports are gated by the manifest, not by +// the language: +// +// world connector — a connector that declares no +// capabilities. Exports `guest` and +// imports nothing from the host. +// world networked-connector — a connector whose manifest declares +// `NetworkOutbound { host }`. Adds the +// `host` interface. +// +// A connector must not import `host` unless its manifest declares the +// matching capability: `crates/springtale-connector/src/wasm/wasi.rs` +// builds the component linker as a closed allow-list, so an undeclared +// import fails at instantiation rather than at call time. +// +// WASI Preview 2 imports are NOT declared here. The host links the +// `wasi:http/proxy` import set (`wasi:io`, `wasi:clocks`, +// `wasi:random/random`, `wasi:cli/std{in,out,err}`) plus +// `wasi:cli/{exit,environment,terminal-*}` and +// `wasi:filesystem/{preopens,types}`. Every one of them resolves against +// a `WasiCtx` that grants nothing — no stdio, no env, no args, no +// preopens. `wasi:sockets` is deliberately not linked at all, so a +// component that reaches for a socket cannot instantiate. Declaring the +// WASI world here would require vendoring the `wasi:*` packages and +// would imply a grant the host does not make. +// +// References: +// - `crates/springtale-connector/src/wasm/host_functions.rs` — the one +// host function the runtime actually provides. +// - `crates/springtale-connector/src/wasm/wasi.rs` — the linked WASI set. +// - `.claude/rules/backend/connector-guidelines.md` — `read-only` +// semantics (MCP `readOnlyHint`). + +package springtale:connector@0.1.0; + +/// What a connector exports. The host calls `actions` to learn what the +/// component can do and `execute` to run one. +interface guest { + /// One declared action, mirroring `ActionDecl` in the manifest. + record action-decl { + /// Action name, as written in `[[actions]] name` in the manifest. + name: string, + /// Human-readable description. + description: string, + /// MCP `readOnlyHint` semantics: true only when the action purely + /// retrieves data and never creates, updates, deletes or sends. + /// Advisory — the formation intent decomposer reads it. The + /// security boundary is `springtale-sentinel`, not this bit. + read-only: bool, + } + + /// The outcome of one `execute` call. Mirrors + /// `springtale_connector::connector::trait_::ActionResult`; `output` + /// carries a JSON document as a string because the Component Model + /// has no JSON type and the host already treats action payloads as + /// opaque JSON. + record action-result { + success: bool, + output: string, + message: string, + } + + /// The actions this connector declares. Must agree with the + /// manifest: the host verifies the manifest, not this list. + actions: func() -> list; + + /// Run one action. `input` is a JSON document. Errors are reported + /// through `action-result.success = false`, not by trapping. + execute: func(action: string, input: string) -> action-result; +} + +/// What the host offers a connector. Exactly one function today, +/// matching `register_http_request` in `host_functions.rs`. +/// +/// Importing this interface is only legal when the manifest declares +/// `NetworkOutbound`. Host matching is exact — no wildcards, no +/// subdomains (`.claude/rules/backend/security.md`). +interface host { + /// Why a request was refused. + enum access-error { + /// The URL or method could not be parsed, or a pointer was out + /// of bounds. Maps to the core-ABI return value -1. + invalid-request, + /// The URL's host is not in the connector's declared + /// `NetworkOutbound` allow-list. Maps to -2. + denied, + } + + /// Ask whether an HTTP request to `url` with `method` is permitted. + /// The host parses the URL, extracts its host, and checks it against + /// the connector's declared capabilities. + check-http-access: func(url: string, method: string) -> result<_, access-error>; +} + +/// A connector that declares no capabilities in its manifest. +world connector { + export guest; +} + +/// A connector whose manifest declares `NetworkOutbound { host }`. +world networked-connector { + import host; + export guest; +} diff --git a/sdk/examples/connector-hello-wasm/Cargo.lock b/sdk/examples/connector-hello-wasm/Cargo.lock index a7ea2e94..394c3b04 100644 --- a/sdk/examples/connector-hello-wasm/Cargo.lock +++ b/sdk/examples/connector-hello-wasm/Cargo.lock @@ -2,12 +2,69 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "connector-hello-wasm" version = "0.1.0" dependencies = [ "serde_json", - "springtale-connector-sdk", + "wit-bindgen", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -16,12 +73,45 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -40,6 +130,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -47,7 +143,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", - "serde_derive", ] [[package]] @@ -83,14 +178,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "springtale-connector-sdk" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "syn" version = "2.0.117" @@ -108,12 +195,133 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "wasm-encoder" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a11585adb92fe9b55ad1d760e8d8fb5d87e0d2e303cb8eed57f078d54293a2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e473fd0095479f9689ac7d2a52c427cc96bb2b973ace50238dfcc1ab1cd52d93" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87445680dfe6d6b5369e884bd63c03a3335268e855365b2fc3f7bcdce96a630e" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f2fe494de0d898216caf0fd0ae76af75753fcb3ff4f465b46131537cf24cf8" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59283a01aec94f60f92ada22ffe182a5cea8acfce43be3be688de8d52df55312" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481b5c47b2ecce0389b5e08a05557d6a190c9cd761773b8880a8017ee04dc7ef" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff4daaa3cd97ae49ecd0a99dc009d453f93e0f083dd3be38c0f24a83a93e37ac" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[patch.unused]] -name = "native-tls" -version = "0.2.12" diff --git a/sdk/examples/connector-hello-wasm/Cargo.toml b/sdk/examples/connector-hello-wasm/Cargo.toml index 27220cf9..9586dcc2 100644 --- a/sdk/examples/connector-hello-wasm/Cargo.toml +++ b/sdk/examples/connector-hello-wasm/Cargo.toml @@ -8,10 +8,17 @@ description = "Example WASM connector for Springtale — hello world." crate-type = ["cdylib"] [dependencies] -springtale-connector-sdk = { path = "../../connector-sdk" } +# The SDK's core-module ABI is not used by a component; the world it +# ships (`../../connector-sdk/wit`) is. serde_json = "1" +wit-bindgen = "0.61.1" [profile.release] opt-level = "s" lto = true strip = true + +# Standalone: this example is excluded from the root workspace and +# targets wasm32-wasip2, so it resolves its own dependency graph. The +# empty table stops cargo walking up into the workspace above. +[workspace] diff --git a/sdk/examples/connector-hello-wasm/prebuilt/README.md b/sdk/examples/connector-hello-wasm/prebuilt/README.md new file mode 100644 index 00000000..60d156c7 --- /dev/null +++ b/sdk/examples/connector-hello-wasm/prebuilt/README.md @@ -0,0 +1,24 @@ +# Prebuilt `connector-hello-wasm` component + +`connector_hello_wasm.wasm` is the `wasm32-wasip2` build of the example in +`../src/lib.rs`, checked in so the sandbox's positive test +(`register_hello_component_from_sdk_world` in +`crates/springtale-connector/src/wasm/tier/cache.rs`) can prove a real +component built against `sdk/connector-sdk/wit/connector.wit` links +against the host's WASI Preview 2 linker — without needing a wasm +toolchain, Python, or Node at test time. + +Regenerate after changing `../src/lib.rs` or the WIT world: + +```sh +rustup target add wasm32-wasip2 +cd sdk/examples/connector-hello-wasm +cargo build --release --target wasm32-wasip2 +cp target/wasm32-wasip2/release/connector_hello_wasm.wasm prebuilt/ +``` + +The `wasm32-wasip2` target emits a component directly — there is no +`wasm-tools component new` step. CI rebuilds the example on every push +(`.github/workflows/ci.yml`, job `wasm-sdk`), so a source change that +stops compiling is caught even though the artefact here is not +byte-compared (rustc output is not reproducible across toolchains). diff --git a/sdk/examples/connector-hello-wasm/prebuilt/connector_hello_wasm.wasm b/sdk/examples/connector-hello-wasm/prebuilt/connector_hello_wasm.wasm new file mode 100644 index 0000000000000000000000000000000000000000..af9edaade7e4c7e09fd32b242d896522e7792e02 GIT binary patch literal 110083 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 literal 0 HcmV?d00001 diff --git a/sdk/examples/connector-hello-wasm/src/lib.rs b/sdk/examples/connector-hello-wasm/src/lib.rs index 19c4db76..4967e984 100644 --- a/sdk/examples/connector-hello-wasm/src/lib.rs +++ b/sdk/examples/connector-hello-wasm/src/lib.rs @@ -1,45 +1,89 @@ -//! Hello World WASM connector for Springtale. +//! Hello World WASM connector for Springtale — a WASI Preview 2 +//! component built against the SDK's WIT world. //! -//! Demonstrates the minimum viable WASM connector: -//! - One action ("greet") that returns a greeting -//! - Proper ABI contract with the Springtale host +//! Demonstrates the minimum viable community connector: +//! - two actions ("greet", "echo"), both read-only +//! - the `springtale:connector/guest` export the host calls +//! - no host imports, because `manifest.toml` declares no capabilities //! -//! Build: cargo build --target wasm32-unknown-unknown --release -//! Install: copy target/.../connector_hello_wasm.wasm + manifest.toml -//! to Springtale and call install_wasm_connector() +//! World: `sdk/connector-sdk/wit/connector.wit`. +//! +//! Build: `cargo build --release --target wasm32-wasip2` +//! Output: `target/wasm32-wasip2/release/connector_hello_wasm.wasm` +//! (already a component — the wasip2 target emits one directly, +//! no `wasm-tools component new` step) +//! Install: copy the `.wasm` plus `manifest.toml` into Springtale and +//! call `install_wasm_connector()`. + +wit_bindgen::generate!({ + path: "../../connector-sdk/wit", + world: "connector", +}); -use springtale_connector_sdk::{dispatch, ActionResult}; +use exports::springtale::connector::guest::{ActionDecl, ActionResult, Guest}; + +/// Convenience constructors mirroring the SDK's `ActionResult` helpers. +fn ok(output: serde_json::Value, message: &str) -> ActionResult { + ActionResult { + success: true, + output: output.to_string(), + message: message.to_owned(), + } +} + +fn err(message: String) -> ActionResult { + ActionResult { + success: false, + output: "null".to_owned(), + message, + } +} /// The "greet" action — takes a name, returns a greeting. -fn greet(input: serde_json::Value) -> ActionResult { +fn greet(input: &serde_json::Value) -> ActionResult { let name = input["name"].as_str().unwrap_or("world"); - ActionResult::ok(serde_json::json!({ - "greeting": format!("Hello, {}!", name), - })) + ok( + serde_json::json!({ "greeting": format!("Hello, {name}!") }), + "", + ) } /// The "echo" action — returns the input unchanged. -fn echo(input: serde_json::Value) -> ActionResult { - ActionResult::ok_with_message(input.clone(), "echoed input") +fn echo(input: &serde_json::Value) -> ActionResult { + ok(input.clone(), "echoed input") } -/// WASM entry point — dispatches action calls from the Springtale host. -/// -/// The host writes action name at memory offset 1024 and input JSON -/// at 1024 + action_len. This function reads them, dispatches to the -/// correct handler, and returns a pointer to the JSON result. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn execute( - action_ptr: i32, - action_len: i32, - input_ptr: i32, - input_len: i32, -) -> i32 { - dispatch(action_ptr, action_len, input_ptr, input_len, |action, input| { - match action { - "greet" => greet(input), - "echo" => echo(input), - _ => ActionResult::error(format!("unknown action: {action}")), +struct HelloConnector; + +impl Guest for HelloConnector { + /// Must agree with `[[actions]]` in `manifest.toml`. Both actions + /// only compute from their input, so both are read-only. + fn actions() -> Vec { + vec![ + ActionDecl { + name: "greet".to_owned(), + description: "Returns a greeting for the given name".to_owned(), + read_only: true, + }, + ActionDecl { + name: "echo".to_owned(), + description: "Returns the input unchanged".to_owned(), + read_only: true, + }, + ] + } + + fn execute(action: String, input: String) -> ActionResult { + let parsed: serde_json::Value = match serde_json::from_str(&input) { + Ok(value) => value, + Err(e) => return err(format!("invalid input JSON: {e}")), + }; + match action.as_str() { + "greet" => greet(&parsed), + "echo" => echo(&parsed), + other => err(format!("unknown action: {other}")), } - }) + } } + +export!(HelloConnector); From e330b9f448ecbb01588db61aeec22ad51b6c3c19 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:19:09 -0700 Subject: [PATCH 10/24] desktop: extract safety policy into pure modules and test it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALIGNMENT-PLAN 5.5, row 2: `autolock.rs`, `commands/safety.rs`, `quick_hide.rs` and `tray.rs` had no tests, because every decision was tangled with a Tauri or OS call. The decisions now live in `src/policy/`, the call sites are thin, and the decisions have tests. - `policy::autolock::AutoLockTimer` is the timer state machine: `Disabled` when the timeout is zero, otherwise `Counting` until idle reaches the threshold and then `Locked`. Activity resets the countdown; a backwards clock reads as zero idle rather than underflowing. - `policy::disguise` picks the window title and the tray disguise from the safety config, with the real icon id and tooltip as named constants. - `policy::shortcut::quick_hide_candidates` builds the accelerator ladder, fallbacks included. 20 unit tests over the three modules. Behaviour is unchanged: the exact-zero-disables and exact-threshold-fires boundaries and the fallback order are preserved. The tap-count threshold named in the plan row is not extracted. It is not Rust — it lives in `tauri/apps/desktop/src/Colony.tsx` (the 1500 ms window and the `panic_tap_count` comparison), and no Rust code receives those clicks, so a Rust implementation would be uncalled code and fake coverage. The daemon-side bound on the configured value stays in `springtale-runtime::operations::safety`. Testing the frontend counter needs a vitest task against `Colony.tsx`. Drive-by: `sidecar.rs` had `login` appended after its test module, which fails `clippy::items_after_test_module`; the test module moved to the end of the file, no logic touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- tauri/apps/desktop/src-tauri/src/autolock.rs | 10 +- .../src-tauri/src/commands/quick_hide.rs | 9 +- .../desktop/src-tauri/src/commands/safety.rs | 7 +- .../desktop/src-tauri/src/commands/tray.rs | 15 +- tauri/apps/desktop/src-tauri/src/lib.rs | 1 + .../desktop/src-tauri/src/policy/autolock.rs | 223 ++++++++++++++++++ .../desktop/src-tauri/src/policy/disguise.rs | 123 ++++++++++ .../apps/desktop/src-tauri/src/policy/mod.rs | 14 ++ .../desktop/src-tauri/src/policy/shortcut.rs | 68 ++++++ tauri/apps/desktop/src-tauri/src/sidecar.rs | 40 ++-- 10 files changed, 463 insertions(+), 47 deletions(-) create mode 100644 tauri/apps/desktop/src-tauri/src/policy/autolock.rs create mode 100644 tauri/apps/desktop/src-tauri/src/policy/disguise.rs create mode 100644 tauri/apps/desktop/src-tauri/src/policy/mod.rs create mode 100644 tauri/apps/desktop/src-tauri/src/policy/shortcut.rs diff --git a/tauri/apps/desktop/src-tauri/src/autolock.rs b/tauri/apps/desktop/src-tauri/src/autolock.rs index 2da95c9f..ce89c950 100644 --- a/tauri/apps/desktop/src-tauri/src/autolock.rs +++ b/tauri/apps/desktop/src-tauri/src/autolock.rs @@ -15,6 +15,7 @@ use tokio::sync::Mutex; use springtale_crypto::vault::store::Vault; use crate::commands::vault::VaultLocked; +use crate::policy::autolock::AutoLockTimer; /// Handle to a running auto-lock timer. Reset on user activity. pub struct AutoLockHandle { @@ -46,15 +47,14 @@ impl AutoLockHandle { let _ = tx.send(()); } - if timeout_minutes == 0 { - return; // disabled - } + // `None` means auto-lock is disabled — arm nothing. + let Some(duration) = AutoLockTimer::new(timeout_minutes).countdown() else { + return; + }; let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); self.cancel_tx = Some(cancel_tx); - let duration = std::time::Duration::from_secs(u64::from(timeout_minutes) * 60); - tokio::spawn(async move { tokio::select! { _ = tokio::time::sleep(duration) => { diff --git a/tauri/apps/desktop/src-tauri/src/commands/quick_hide.rs b/tauri/apps/desktop/src-tauri/src/commands/quick_hide.rs index cf1ab1d7..6cf60a8c 100644 --- a/tauri/apps/desktop/src-tauri/src/commands/quick_hide.rs +++ b/tauri/apps/desktop/src-tauri/src/commands/quick_hide.rs @@ -25,6 +25,8 @@ use tauri::{AppHandle, Manager}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; use tauri_specta::Event; +use crate::policy::shortcut::quick_hide_candidates; + /// Emitted from the OS-wide quick-hide shortcut handler. Unit payload — /// the frontend reacts by collapsing surfaces and (via separate IPC) /// can lock the vault. @@ -65,12 +67,7 @@ pub async fn apply_quick_hide_shortcut(app: AppHandle, shortcut: String) -> Resu // in-window listener still hides on focus, and the user can rebind in // Settings → Safety. Returns the combo that actually registered, or an // empty string if none did. - let mut candidates = vec![configured.clone()]; - for fb in ["Alt+Shift+H", "Ctrl+Shift+J", "Ctrl+Alt+Shift+H"] { - if fb != configured { - candidates.push(fb.to_owned()); - } - } + let candidates = quick_hide_candidates(&configured); // Drop whatever was bound before trying new combos (idempotent re-apply). let active = app.state::(); diff --git a/tauri/apps/desktop/src-tauri/src/commands/safety.rs b/tauri/apps/desktop/src-tauri/src/commands/safety.rs index d3ba7574..737cc503 100644 --- a/tauri/apps/desktop/src-tauri/src/commands/safety.rs +++ b/tauri/apps/desktop/src-tauri/src/commands/safety.rs @@ -9,6 +9,7 @@ use tauri::State; +use crate::policy::disguise::select_window_title; use crate::state::AppState; /// Set the window title — desktop-specific (Tauri API). @@ -60,11 +61,7 @@ pub async fn apply_disguise_to_shell( disguise_app_name: String, window_title: String, ) -> Result { - let title = if disguise_active { - disguise_app_name - } else { - window_title - }; + let title = select_window_title(disguise_active, disguise_app_name, window_title); window.set_title(&title).map_err(|e| e.to_string())?; // Mirror the applied title into the pre-unlock prefs file so a cold // start shows the disguise on its first frame instead of the real name. diff --git a/tauri/apps/desktop/src-tauri/src/commands/tray.rs b/tauri/apps/desktop/src-tauri/src/commands/tray.rs index f81b5c1d..aa9ab679 100644 --- a/tauri/apps/desktop/src-tauri/src/commands/tray.rs +++ b/tauri/apps/desktop/src-tauri/src/commands/tray.rs @@ -27,6 +27,8 @@ use tauri::tray::TrayIcon; use tauri::{App, Manager, Runtime}; use tokio::sync::Mutex; +use crate::policy::disguise::{TrayDisguise, select_tray_disguise}; + /// Shared tray handle. `None` until `init` runs in `setup()`. pub type TrayHandle = Arc>>>; @@ -65,17 +67,8 @@ pub async fn apply_disguise_to_tray( disguise_app_name: String, disguise_icon_id: String, ) -> Result { - let icon_id = if disguise_active { - disguise_icon_id - } else { - "springtale".to_owned() - }; - - let tooltip = if disguise_active { - disguise_app_name - } else { - "Springtale".to_owned() - }; + let TrayDisguise { icon_id, tooltip } = + select_tray_disguise(disguise_active, disguise_app_name, disguise_icon_id); let tray_state = app.state::>(); let tray_lock = tray_state.inner().lock().await; diff --git a/tauri/apps/desktop/src-tauri/src/lib.rs b/tauri/apps/desktop/src-tauri/src/lib.rs index 33d2e42f..f3d9def1 100644 --- a/tauri/apps/desktop/src-tauri/src/lib.rs +++ b/tauri/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod autolock; mod commands; mod paths; +pub mod policy; mod prefs; mod sidecar; mod state; diff --git a/tauri/apps/desktop/src-tauri/src/policy/autolock.rs b/tauri/apps/desktop/src-tauri/src/policy/autolock.rs new file mode 100644 index 00000000..ac5dc2b1 --- /dev/null +++ b/tauri/apps/desktop/src-tauri/src/policy/autolock.rs @@ -0,0 +1,223 @@ +//! The auto-lock countdown, as a pure state machine. +//! +//! `crate::autolock::AutoLockHandle` is the OS-facing half: it turns +//! [`AutoLockTimer::countdown`] into a `tokio::time::sleep` and zeroes the +//! vault when that sleep wins the `select!`. The policy itself — how long +//! to wait, what "disabled" means, when idle time has crossed the +//! threshold — lives here, where it can be driven without sleeping. +//! +//! Time is supplied by the caller as a monotonic millisecond count rather +//! than read from a clock, so a test can step a whole afternoon of idleness +//! in a microsecond. + +use std::time::Duration; + +/// The persisted config counts minutes; every duration here is seconds. +const SECS_PER_MINUTE: u64 = 60; + +/// Where the countdown stands at a given moment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutoLockState { + /// `auto_lock_minutes == 0` — the survivor turned auto-lock off. No + /// timer is armed and idleness never locks the vault. + Disabled, + /// Counting down. `remaining` is the time left before the vault locks + /// if no further activity arrives. + Counting { + /// Time left until the threshold is crossed. + remaining: Duration, + }, + /// Idle time has reached the configured threshold — lock the vault. + Locked, +} + +/// Auto-lock timer state: a threshold plus the moment activity last reset it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AutoLockTimer { + /// `None` when auto-lock is disabled. + timeout: Option, + /// Monotonic timestamp (ms) of the activity that last restarted the + /// countdown. Starts at zero — the first `record_activity` moves it. + last_activity_ms: u64, +} + +impl AutoLockTimer { + /// Build a timer for a configured `auto_lock_minutes` value. + /// + /// Zero means disabled, which is the one value that must never arm a + /// timer: a survivor who turned auto-lock off did so deliberately. + #[must_use] + pub fn new(timeout_minutes: u32) -> Self { + let timeout = if timeout_minutes == 0 { + None + } else { + Some(Duration::from_secs( + u64::from(timeout_minutes) * SECS_PER_MINUTE, + )) + }; + Self { + timeout, + last_activity_ms: 0, + } + } + + /// How long a freshly-reset timer should wait before locking, or `None` + /// when auto-lock is disabled and no timer should be armed at all. + /// + /// This is the production entry point — `AutoLockHandle::reset` sleeps + /// for exactly this duration. + #[must_use] + pub fn countdown(&self) -> Option { + self.timeout + } + + /// Whether a timer is armed at all. + #[must_use] + pub fn is_enabled(&self) -> bool { + self.timeout.is_some() + } + + /// Record user activity: the countdown restarts from `now_ms`. + pub fn record_activity(&mut self, now_ms: u64) { + self.last_activity_ms = now_ms; + } + + /// Idle time accumulated at `now_ms`. + /// + /// Saturating: a clock that hands back an earlier instant reads as zero + /// idle rather than wrapping to ~584 million years and locking instantly. + #[must_use] + pub fn idle_for(&self, now_ms: u64) -> Duration { + Duration::from_millis(now_ms.saturating_sub(self.last_activity_ms)) + } + + /// The same policy the spawned timer enforces, expressed as a query so + /// it can be asserted directly: disabled never locks, accumulated idle + /// time at or past the threshold locks, anything short of it counts down. + #[must_use] + pub fn poll(&self, now_ms: u64) -> AutoLockState { + let Some(timeout) = self.timeout else { + return AutoLockState::Disabled; + }; + match timeout.checked_sub(self.idle_for(now_ms)) { + Some(remaining) if !remaining.is_zero() => AutoLockState::Counting { remaining }, + // Exactly at the threshold counts as crossed — the same moment + // `tokio::time::sleep(timeout)` fires. + _ => AutoLockState::Locked, + } + } +} + +#[cfg(test)] +mod tests { + use super::{AutoLockState, AutoLockTimer}; + use std::time::Duration; + + const MINUTE_MS: u64 = 60_000; + + #[test] + fn test_countdown_five_minutes_is_three_hundred_seconds() { + assert_eq!( + AutoLockTimer::new(5).countdown(), + Some(Duration::from_secs(300)) + ); + } + + #[test] + fn test_countdown_zero_minutes_is_disabled() { + let timer = AutoLockTimer::new(0); + assert_eq!(timer.countdown(), None); + assert!(!timer.is_enabled()); + } + + #[test] + fn test_poll_zero_timeout_never_locks() { + let timer = AutoLockTimer::new(0); + assert_eq!(timer.poll(0), AutoLockState::Disabled); + // A week of idleness still does not lock a disabled timer. + assert_eq!(timer.poll(7 * 24 * 60 * MINUTE_MS), AutoLockState::Disabled); + } + + #[test] + fn test_poll_idle_accumulates_toward_the_threshold() { + let timer = AutoLockTimer::new(5); + assert_eq!( + timer.poll(MINUTE_MS), + AutoLockState::Counting { + remaining: Duration::from_secs(240) + } + ); + assert_eq!( + timer.poll(4 * MINUTE_MS), + AutoLockState::Counting { + remaining: Duration::from_secs(60) + } + ); + } + + #[test] + fn test_idle_for_measures_since_last_activity() { + let mut timer = AutoLockTimer::new(5); + timer.record_activity(2 * MINUTE_MS); + assert_eq!(timer.idle_for(3 * MINUTE_MS), Duration::from_secs(60)); + } + + #[test] + fn test_idle_for_backwards_clock_reads_as_zero() { + let mut timer = AutoLockTimer::new(5); + timer.record_activity(10 * MINUTE_MS); + assert_eq!(timer.idle_for(MINUTE_MS), Duration::ZERO); + } + + #[test] + fn test_poll_activity_resets_the_countdown() { + let mut timer = AutoLockTimer::new(5); + // Four minutes idle — one minute left. + assert_eq!( + timer.poll(4 * MINUTE_MS), + AutoLockState::Counting { + remaining: Duration::from_secs(60) + } + ); + // The survivor touches the app: the full five minutes are back. + timer.record_activity(4 * MINUTE_MS); + assert_eq!( + timer.poll(4 * MINUTE_MS), + AutoLockState::Counting { + remaining: Duration::from_secs(300) + } + ); + // ...and what would have been the original deadline no longer locks. + assert_eq!( + timer.poll(5 * MINUTE_MS), + AutoLockState::Counting { + remaining: Duration::from_secs(240) + } + ); + } + + #[test] + fn test_poll_threshold_exactly_reached_locks() { + let timer = AutoLockTimer::new(5); + assert_eq!(timer.poll(5 * MINUTE_MS), AutoLockState::Locked); + } + + #[test] + fn test_poll_past_threshold_stays_locked() { + let timer = AutoLockTimer::new(1); + assert_eq!(timer.poll(90 * 1_000), AutoLockState::Locked); + } + + #[test] + fn test_poll_after_reset_locks_one_threshold_later() { + let mut timer = AutoLockTimer::new(5); + timer.record_activity(3 * MINUTE_MS); + assert_eq!( + timer.poll(7 * MINUTE_MS), + AutoLockState::Counting { + remaining: Duration::from_secs(60) + } + ); + assert_eq!(timer.poll(8 * MINUTE_MS), AutoLockState::Locked); + } +} diff --git a/tauri/apps/desktop/src-tauri/src/policy/disguise.rs b/tauri/apps/desktop/src-tauri/src/policy/disguise.rs new file mode 100644 index 00000000..0d8b9f09 --- /dev/null +++ b/tauri/apps/desktop/src-tauri/src/policy/disguise.rs @@ -0,0 +1,123 @@ +//! Which name and icon the shell presents. +//! +//! The daemon stores the disguise config; the frontend reads it and hands +//! the fields to `commands::safety::apply_disguise_to_shell` and +//! `commands::tray::apply_disguise_to_tray`. Both commands do exactly two +//! things: pick the values below, then push them at the OS. This module is +//! the picking half. +//! +//! The window title and the tray tooltip are chosen independently of +//! whether the OS accepts them — a survivor's disguise must not depend on a +//! window manager that refuses a tray icon. + +/// Tray icon stem used when disguise is off. Icons ship as +/// `src-tauri/icons/disguise/{id}.png`. +pub const REAL_TRAY_ICON_ID: &str = "springtale"; + +/// Tray tooltip used when disguise is off. +pub const REAL_TRAY_TOOLTIP: &str = "Springtale"; + +/// The tray half of a disguise decision: which icon to load and what the +/// hover text says. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrayDisguise { + /// File stem under `icons/disguise/`. An unknown id resolves to no + /// icon at load time rather than failing the disguise. + pub icon_id: String, + /// Tooltip text shown on hover. + pub tooltip: String, +} + +/// Title to put on the main window. +/// +/// Disguise on: the cover app's name. Disguise off: the configured +/// `window_title`, which itself defaults to the disguise-friendly "Notes" +/// per the IPV-first defaults — "off" never means "announce Springtale". +#[must_use] +pub fn select_window_title( + disguise_active: bool, + disguise_app_name: String, + window_title: String, +) -> String { + if disguise_active { + disguise_app_name + } else { + window_title + } +} + +/// Icon + tooltip for the tray. +/// +/// Unlike the window title, the undisguised tray is the real product: the +/// tray is where someone looks to see whether Springtale is running at all. +#[must_use] +pub fn select_tray_disguise( + disguise_active: bool, + disguise_app_name: String, + disguise_icon_id: String, +) -> TrayDisguise { + if disguise_active { + TrayDisguise { + icon_id: disguise_icon_id, + tooltip: disguise_app_name, + } + } else { + TrayDisguise { + icon_id: REAL_TRAY_ICON_ID.to_owned(), + tooltip: REAL_TRAY_TOOLTIP.to_owned(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{REAL_TRAY_ICON_ID, REAL_TRAY_TOOLTIP, select_tray_disguise, select_window_title}; + + #[test] + fn test_select_window_title_disguise_active_uses_app_name() { + assert_eq!( + select_window_title(true, "Calculator".to_owned(), "Notes".to_owned()), + "Calculator" + ); + } + + #[test] + fn test_select_window_title_disguise_inactive_uses_configured_title() { + assert_eq!( + select_window_title(false, "Calculator".to_owned(), "Notes".to_owned()), + "Notes" + ); + } + + #[test] + fn test_select_window_title_inactive_never_leaks_the_disguise_name() { + // The cover name must not appear when the survivor turned disguise + // off — the two fields are stored independently. + assert_eq!( + select_window_title(false, "Calculator".to_owned(), String::new()), + "" + ); + } + + #[test] + fn test_select_tray_disguise_active_uses_configured_icon_and_name() { + let profile = select_tray_disguise(true, "Files".to_owned(), "files".to_owned()); + assert_eq!(profile.icon_id, "files"); + assert_eq!(profile.tooltip, "Files"); + } + + #[test] + fn test_select_tray_disguise_inactive_restores_the_real_identity() { + let profile = select_tray_disguise(false, "Files".to_owned(), "files".to_owned()); + assert_eq!(profile.icon_id, REAL_TRAY_ICON_ID); + assert_eq!(profile.tooltip, REAL_TRAY_TOOLTIP); + } + + #[test] + fn test_select_tray_disguise_unknown_icon_id_is_passed_through() { + // Resolution happens at load time, where a miss degrades to "no + // icon" — the selection stage must not second-guess the id. + let profile = select_tray_disguise(true, "Weather".to_owned(), "not-a-real-id".to_owned()); + assert_eq!(profile.icon_id, "not-a-real-id"); + } +} diff --git a/tauri/apps/desktop/src-tauri/src/policy/mod.rs b/tauri/apps/desktop/src-tauri/src/policy/mod.rs new file mode 100644 index 00000000..680a90fd --- /dev/null +++ b/tauri/apps/desktop/src-tauri/src/policy/mod.rs @@ -0,0 +1,14 @@ +//! Pure safety-surface decisions — no Tauri, no tokio, no OS calls. +//! +//! The desktop shell's job is to *apply* safety state to the operating +//! system: retitle a window, swap a tray icon, arm a hotkey, start a +//! countdown. Deciding *what* to apply is ordinary logic, and keeping that +//! logic here — away from the `AppHandle`s and the `tokio::spawn`s — is what +//! makes it testable. Every module in `commands/` and `autolock.rs` stays a +//! thin wrapper: read the decision from here, hand it to the OS. +//! +//! Nothing in this tree may take a `tauri::` type as an argument. + +pub mod autolock; +pub mod disguise; +pub mod shortcut; diff --git a/tauri/apps/desktop/src-tauri/src/policy/shortcut.rs b/tauri/apps/desktop/src-tauri/src/policy/shortcut.rs new file mode 100644 index 00000000..b40a8ff6 --- /dev/null +++ b/tauri/apps/desktop/src-tauri/src/policy/shortcut.rs @@ -0,0 +1,68 @@ +//! The quick-hide hotkey ladder. +//! +//! A global shortcut is a convenience, not a guarantee: on macOS +//! `RegisterEventHotKey` fails outright when another application already +//! owns the combo. `commands::quick_hide` therefore tries the survivor's +//! configured combo first and walks a short ladder of progressively +//! less-likely-to-conflict fallbacks, registering the first that takes. +//! +//! Building that ladder is pure list logic; registering it is not. + +/// Fallbacks tried, in order, when the configured combo will not register. +/// +/// Chosen to be unlikely to collide with OS or common-application +/// shortcuts. Order matters: the first entry is tried first. +pub const QUICK_HIDE_FALLBACKS: [&str; 3] = ["Alt+Shift+H", "Ctrl+Shift+J", "Ctrl+Alt+Shift+H"]; + +/// The combos to attempt, in order, for a configured quick-hide shortcut. +/// +/// The configured combo always leads. Fallbacks follow, minus any that +/// duplicates it — trying the same combo twice would only produce a second +/// identical failure, and the log line that goes with it says "fallback", +/// which would be a lie. +#[must_use] +pub fn quick_hide_candidates(configured: &str) -> Vec { + let mut candidates = vec![configured.to_owned()]; + for fallback in QUICK_HIDE_FALLBACKS { + if fallback != configured { + candidates.push(fallback.to_owned()); + } + } + candidates +} + +#[cfg(test)] +mod tests { + use super::{QUICK_HIDE_FALLBACKS, quick_hide_candidates}; + + #[test] + fn test_quick_hide_candidates_configured_combo_is_tried_first() { + let candidates = quick_hide_candidates("Ctrl+Shift+Q"); + assert_eq!(candidates.first().map(String::as_str), Some("Ctrl+Shift+Q")); + assert_eq!(candidates.len(), 1 + QUICK_HIDE_FALLBACKS.len()); + } + + #[test] + fn test_quick_hide_candidates_preserves_fallback_order() { + let candidates = quick_hide_candidates("Ctrl+Shift+Q"); + assert_eq!(candidates[1..], QUICK_HIDE_FALLBACKS.map(str::to_owned)[..]); + } + + #[test] + fn test_quick_hide_candidates_configured_equal_to_fallback_is_not_repeated() { + let candidates = quick_hide_candidates("Ctrl+Shift+J"); + assert_eq!( + candidates, + vec!["Ctrl+Shift+J", "Alt+Shift+H", "Ctrl+Alt+Shift+H"] + ); + } + + #[test] + fn test_quick_hide_candidates_empty_configured_still_yields_fallbacks() { + // An empty string parses to no shortcut and is skipped at + // registration; the ladder below it must still be attempted. + let candidates = quick_hide_candidates(""); + assert_eq!(candidates.len(), 1 + QUICK_HIDE_FALLBACKS.len()); + assert_eq!(candidates[1..], QUICK_HIDE_FALLBACKS.map(str::to_owned)[..]); + } +} diff --git a/tauri/apps/desktop/src-tauri/src/sidecar.rs b/tauri/apps/desktop/src-tauri/src/sidecar.rs index a84c30b1..7806f732 100644 --- a/tauri/apps/desktop/src-tauri/src/sidecar.rs +++ b/tauri/apps/desktop/src-tauri/src/sidecar.rs @@ -86,26 +86,6 @@ fn parse_ready(line: &[u8]) -> Option { .ok() } -#[cfg(test)] -mod tests { - use super::parse_ready; - - #[test] - fn test_parse_ready_with_port_returns_port() { - assert_eq!(parse_ready(b"READY 51234\n"), Some(51234)); - } - - #[test] - fn test_parse_ready_bare_ready_returns_none() { - assert_eq!(parse_ready(b"READY\n"), None); - } - - #[test] - fn test_parse_ready_unrelated_line_returns_none() { - assert_eq!(parse_ready(b"INFO springtaled starting"), None); - } -} - /// Log in to the freshly started daemon and return the bearer token it /// issues (plan 6.6, finding 109). /// @@ -140,3 +120,23 @@ pub async fn login(port: u16, passphrase: &secrecy::SecretString) -> Result Date: Sun, 6 Sep 2026 18:19:58 -0700 Subject: [PATCH 11/24] ci: build the WASM SDK example and the Python wheel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALIGNMENT-PLAN 5.5, row 5: neither the Python bindings nor the WASM SDK example was built anywhere, so both could rot silently. `wasm-sdk` adds the `wasm32-wasip2` target, builds `sdk/examples/connector-hello-wasm` against the SDK's WIT world, checks that the fresh build and the checked-in `prebuilt/` artefact both carry the component preamble rather than a core-module one, and runs the sandbox tests that load the component. The wasip2 target emits a component directly, so the job needs no `wasm-tools`, no Node and no Python. `python-bindings` builds the maturin wheel and smoke-imports it. The check is not a bare `import springtale`: it asserts `__version__` and every class the `#[pymodule]` declares, so a binding dropped from the module fails the job instead of passing silently. It uses the runner's preinstalled Python in a venv rather than adding `actions/setup-python` — no new third-party action enters the trust set, and every action in both jobs is one this file already pins by digest. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .github/workflows/ci.yml | 90 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e35a581..db40cdc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,6 +214,96 @@ jobs: # rejects `if: hashFiles(...)` at the job level, so the cleanest path is # to add the job in the same PR that adds the crate. + # ── WASM connector SDK (plan 5.5) ───────────────────────────────── + # + # The SDK example was never built anywhere. It is the only component + # in the tree built against `sdk/connector-sdk/wit/connector.wit`, and + # the sandbox's positive test loads the checked-in artefact, so a + # source change that stops compiling has to be caught here. + wasm-sdk: + name: WASM SDK (wasm32-wasip2) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2 + with: + egress-policy: audit + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + targets: wasm32-wasip2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + # The wasm32-wasip2 target emits a component directly — no + # `wasm-tools component new` step, and no Node or Python toolchain. + - name: Build the SDK example component + run: cargo build --release --target wasm32-wasip2 --manifest-path sdk/examples/connector-hello-wasm/Cargo.toml + - name: The checked-in artefact must still be a loadable component + run: | + set -euo pipefail + built=sdk/examples/connector-hello-wasm/target/wasm32-wasip2/release/connector_hello_wasm.wasm + test -s "$built" + # Both must be components (0x00 'asm' + layer 1), not core modules. + for f in "$built" sdk/examples/connector-hello-wasm/prebuilt/connector_hello_wasm.wasm; do + head -c 8 "$f" | od -An -tx1 | grep -q '00 61 73 6d 0d 00 01 00' || { + echo "::error::$f is not a WASM component (expected component preamble)" + exit 1 + } + done + # The sandbox tests `include_bytes!` the prebuilt component and + # both link and execute it (`wasm::tier::cache`), so run them here + # against the same commit that just rebuilt the source. + - name: Sandbox tests that load the component + run: "cargo test -p springtale-connector --locked wasm::" + + # ── Python bindings (plan 5.5) ──────────────────────────────────── + # + # `springtale-py` is a pyo3 extension module wrapped by maturin. The + # workspace `cargo test` cannot exercise it (extension-module defers + # Python symbol resolution to the host interpreter), so nothing built + # the wheel until this job. Uses the runner's preinstalled Python + # rather than adding another third-party action to the trust set. + python-bindings: + name: Python bindings (maturin wheel) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2 + with: + egress-policy: audit + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: Build the wheel + run: | + set -euo pipefail + python3 -m venv /tmp/venv + /tmp/venv/bin/pip install --disable-pip-version-check maturin + /tmp/venv/bin/maturin build --release \ + --manifest-path crates/springtale-py/Cargo.toml \ + --out /tmp/wheels \ + --interpreter /tmp/venv/bin/python + - name: Smoke import + run: | + set -euo pipefail + /tmp/venv/bin/pip install --disable-pip-version-check /tmp/wheels/*.whl + # Not just `import springtale` — assert the surface the module + # actually declares, so a binding dropped from the pymodule + # fails the job instead of passing silently. + /tmp/venv/bin/python - <<'PYCHECK' + import springtale + + assert springtale.__version__, "wheel has no __version__" + for name in ("MomentumTier", "Intent", "FormationId", "Formation"): + assert hasattr(springtale, name), f"missing binding: {name}" + print("springtale", springtale.__version__, "imported") + PYCHECK + # ── Hardening configuration check ───────────────────────────────── # # Static assertions about Tauri / capability / CSP config files. From 7f6900cc81266640b9c9e1b213e17f0afbc30f8c Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:20:31 -0700 Subject: [PATCH 12/24] api: describe the three routes the contract left out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/mcp`, `POST /vault/unlock` and `/openapi.json` were served but carried no annotation, so the document the daemon publishes did not describe every route the daemon answers, and the generated TypeScript could not see them either. `/mcp` is documented for what it is — a Streamable HTTP MCP endpoint carrying JSON-RPC 2.0 — and deliberately not by restating MCP's own schema, which the specification versions, not this daemon. The annotation sits on the router constructor because the endpoint is a nested service rather than a handler. `/vault/unlock` takes a typed body whose passphrase is a `SecretString` described to the wire as a password string. It gains a command-line surface at the same time: `springtale vault unlock`, because a locked daemon serves three routes and nothing else, so the dashboard SPA cannot load to unlock it and a headless instance had no way back after an auto-lock short of a restart. The passphrase is read from the terminal, never from a flag. `/openapi.json` and the browser half of `/mcp` and `/vault/unlock` are recorded in the not-surfaced list with their reasons. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/src/cli.rs | 7 +++++++ apps/springtale-cli/src/commands/vault.rs | 21 +++++++++++++++++++ apps/springtale-cli/src/main.rs | 3 +++ apps/springtaled/src/api/lock.rs | 24 ++++++++++++++++++++-- apps/springtaled/src/api/mcp.rs | 25 +++++++++++++++++++++++ apps/springtaled/src/api/openapi.rs | 14 +++++++++++++ scripts/surface-not-surfaced.txt | 7 +++++++ 7 files changed, 99 insertions(+), 2 deletions(-) diff --git a/apps/springtale-cli/src/cli.rs b/apps/springtale-cli/src/cli.rs index 4de84ad5..584c336d 100644 --- a/apps/springtale-cli/src/cli.rs +++ b/apps/springtale-cli/src/cli.rs @@ -619,6 +619,13 @@ pub enum TravelAction { pub enum VaultAction { /// Configure a duress passphrase (dual-region vault). DuressSetup, + /// Unlock a locked springtaled over the management API. + /// + /// A locked daemon answers three routes and nothing else, so this is + /// how a headless instance comes back after an auto-lock without a + /// restart. The passphrase is read from the terminal, never from a + /// flag or an environment variable. + Unlock, } #[derive(Subcommand, Debug)] diff --git a/apps/springtale-cli/src/commands/vault.rs b/apps/springtale-cli/src/commands/vault.rs index e370d2c7..42ac132f 100644 --- a/apps/springtale-cli/src/commands/vault.rs +++ b/apps/springtale-cli/src/commands/vault.rs @@ -3,8 +3,29 @@ use std::path::Path; use anyhow::{Context, Result}; +use crate::client::Client; use crate::output; +/// `springtale vault unlock` — hand a locked daemon its passphrase. +/// +/// While locked, springtaled has dropped the whole live world and serves +/// only `/health`, `/ready` and `POST /vault/unlock`; the dashboard SPA +/// cannot even load, so the terminal is the surface that reaches it. The +/// passphrase comes from the TTY: it is a credential, not an argument, +/// and must not land in shell history or `ps`. +pub async fn unlock(json_out: bool) -> Result<()> { + let passphrase = rpassword::read_password_from_tty(Some("Vault passphrase: ")) + .context("failed to read passphrase")?; + let client = Client::from_config()?; + let body: serde_json::Value = client + .post( + "/vault/unlock", + &serde_json::json!({ "passphrase": passphrase }), + ) + .await?; + output::emit(json_out, &body, |_| "Vault unlocked.".to_owned()) +} + /// Set up a duress passphrase for an existing vault. /// /// Converts a legacy single-region vault to dual-region format. diff --git a/apps/springtale-cli/src/main.rs b/apps/springtale-cli/src/main.rs index ed0ce42f..eb16582e 100644 --- a/apps/springtale-cli/src/main.rs +++ b/apps/springtale-cli/src/main.rs @@ -98,6 +98,9 @@ async fn main() -> Result<()> { let vault_path = springtale_store::paths::default_vault_path(); commands::vault::duress_setup(&vault_path, cli.json)?; } + VaultAction::Unlock => { + commands::vault::unlock(cli.json).await?; + } }, Command::Crypto { action } => match action { CryptoAction::RotateVaultKey => { diff --git a/apps/springtaled/src/api/lock.rs b/apps/springtaled/src/api/lock.rs index 7167670a..35d408ab 100644 --- a/apps/springtaled/src/api/lock.rs +++ b/apps/springtaled/src/api/lock.rs @@ -500,10 +500,15 @@ async fn lock(State(guard): State, headers: HeaderMap) -> Response } /// Body of `POST /vault/unlock`. -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct UnlockRequest { /// The vault passphrase. Never logged, never echoed. + /// + /// `SecretString` has no schema of its own on purpose — the contract + /// describes the wire shape (a string), and the type describes what + /// the daemon does with it (zeroize on drop, redact in `Debug`). #[serde(deserialize_with = "deserialize_passphrase")] + #[schema(value_type = String, format = Password)] passphrase: SecretString, } @@ -527,7 +532,22 @@ where /// dropped state. So the passphrase itself is the credential here, and /// `Vault::open` is the check — Argon2id over the wrong passphrase fails /// at AEAD decryption, with no comparison this code could shortcut. -async fn unlock(State(guard): State, Json(body): Json) -> Response { +#[utoipa::path( + post, operation_id = "lock_unlock", + path = "/vault/unlock", + tag = "vault", + security(()), + request_body = UnlockRequest, + responses( + (status = 200, description = "Vault unlocked; the live router is back", body = Object), + (status = 401, description = "Unlock refused — wrong passphrase or unreadable vault", body = Object), + (status = 409, description = "Already unlocked", body = Object) + ) +)] +pub async fn unlock( + State(guard): State, + Json(body): Json, +) -> Response { if !guard.is_locked() { return ( StatusCode::CONFLICT, diff --git a/apps/springtaled/src/api/mcp.rs b/apps/springtaled/src/api/mcp.rs index 55c33eeb..733fa7f5 100644 --- a/apps/springtaled/src/api/mcp.rs +++ b/apps/springtaled/src/api/mcp.rs @@ -36,6 +36,31 @@ use super::state::AppState; /// The handler is constructed per session and holds a clone of the shared /// `RuntimeState`, so tool calls dispatch through the same sentinel, /// approval gate and executions recorder as a rule action. +/// The endpoint is a nested service, not a handler, so the contract +/// annotation sits on the constructor that mounts it. +/// +/// The document describes what `/mcp` *is* — a Streamable HTTP MCP +/// endpoint carrying JSON-RPC 2.0 in both directions — and deliberately +/// does not restate MCP's own schema. That schema is versioned by the +/// MCP specification, not by this daemon; a copy of it here would be a +/// second, staler source of truth. Clients discover tools the way the +/// protocol says to: `initialize`, then `tools/list`. +#[utoipa::path( + post, operation_id = "mcp_endpoint", + path = "/mcp", + tag = "mcp", + request_body( + content = Object, + description = "One JSON-RPC 2.0 request, notification, or response, per the MCP Streamable HTTP transport", + content_type = "application/json" + ), + responses( + (status = 200, description = "A JSON-RPC response, or an SSE stream of them when the client accepts `text/event-stream`", body = Object), + (status = 202, description = "Notification or response accepted; no body"), + (status = 401, description = "Missing or invalid bearer token", body = Object), + (status = 403, description = "Origin header rejected (DNS-rebinding guard)", body = Object) + ) +)] pub fn router(state: AppState) -> Router { let service = springtale_mcp::streamable_http(state.runtime.clone()); diff --git a/apps/springtaled/src/api/openapi.rs b/apps/springtaled/src/api/openapi.rs index b455ffec..7a6a31de 100644 --- a/apps/springtaled/src/api/openapi.rs +++ b/apps/springtaled/src/api/openapi.rs @@ -99,14 +99,17 @@ use super::*; formations::update_intent, health::health, health::ready, + lock::unlock, login::create_token, login::delete_token, login::list_tokens, login::login, login::logout, + mcp::router, memory::audit_memory, memory::compact_memory, onboarding::apply, + openapi::serve, onboarding::list, recipes::apply, recipes::delete_user, @@ -161,6 +164,7 @@ use super::*; config_api::ConfigureAiBody, data::PurgeBody, executions::VacuumResponse, + lock::UnlockRequest, login::CreateTokenRequest, login::LoginRequest, onboarding::ApplyRequest, @@ -260,8 +264,10 @@ use super::*; (name = "formations"), (name = "health"), (name = "login"), + (name = "mcp"), (name = "memory"), (name = "onboarding"), + (name = "openapi"), (name = "recipes"), (name = "rules"), (name = "safety"), @@ -269,6 +275,7 @@ use super::*; (name = "sessions"), (name = "stream"), (name = "utterances"), + (name = "vault"), (name = "webhooks"), (name = "workspaces") ) @@ -280,6 +287,13 @@ pub struct ApiDoc; /// Unauthenticated on purpose: it is a schema, not data. Nothing in it /// is a secret, and the CLI, the two front ends and CI all read it /// before they hold a token. +#[utoipa::path( + get, operation_id = "openapi_serve", + path = "/openapi.json", + tag = "openapi", + security(()), + responses((status = 200, description = "The OpenAPI 3.1 document this daemon is described by", body = Object)) +)] pub async fn serve() -> Json { Json(ApiDoc::openapi()) } diff --git a/scripts/surface-not-surfaced.txt b/scripts/surface-not-surfaced.txt index 32440d73..9614fbcc 100644 --- a/scripts/surface-not-surfaced.txt +++ b/scripts/surface-not-surfaced.txt @@ -12,6 +12,13 @@ /ui cli provider # static SPA assets the browser fetches; no surface calls them /ui/{} cli provider # same, per-asset # +# ── Not API: the contract, and a protocol other tools speak ──────── +/openapi.json cli provider # the contract itself; build tooling and CI read it (`springtaled --dump-openapi`), no user surface does +/mcp provider # MCP Streamable HTTP endpoint; `springtale mcp serve` bridges stdio clients to it, the dashboard is not an MCP client +# +# ── Reachable only while the daemon is locked ────────────────────── +/vault/unlock provider # while locked the daemon serves /health, /ready and this route only, so the dashboard SPA cannot load to call it; `springtale vault unlock` and the desktop shell (Tauri `unlock_vault`) are the unlock surfaces +# # ── Not API: spoken by third parties, never by a surface ─────────── /webhook/{}/{} cli provider # inbound connector callback; the platform posts here, no user surface does # From a6621b303448492130bb318c22154cd049d89590 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:20:31 -0700 Subject: [PATCH 13/24] cli: one writer for the pairing registry, one registry for authors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three commands opened the store directly behind a running daemon. `bot pair-init` now calls `POST /bot/pair-init`. The pairing rows live in the daemon's store, so the daemon is the writer and the command line is a client of it, like every other daemon-backed verb (plan 2.2). `bot panic-unpair` stays offline, deliberately. It is reached when the paired device or account is in the wrong hands, from whatever terminal the user has recovered — precisely when springtaled may be dead, wedged, or itself the thing that was taken. It deletes rows a running daemon then stops finding, so there is nothing for it to have been told. Same reasoning as `springtale panic`. `author` stays offline too: `author add --self` registers this instance's connector-signing identity on first run, before `springtale server start` has ever been typed. What was not acceptable was a second implementation of the registry, so it is gone — every read, write and validation now goes through `springtale_runtime::operations::authors`, the same functions and the same `trusted-author:` rows the `/authors` routes use. One registry, one implementation, reached from a socket or from a terminal. Both decisions are written down in the not-surfaced list. The exemption ledger — the open product decision this closes — is now empty. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/src/commands/author.rs | 67 +++++++++------------- apps/springtale-cli/src/commands/bot.rs | 22 ++++--- apps/springtale-cli/src/main.rs | 2 +- apps/springtaled/src/api/bot.rs | 25 ++++++++ apps/springtaled/src/api/mod.rs | 1 + apps/springtaled/src/api/openapi.rs | 1 + scripts/surface-exemptions.txt | 14 ++--- scripts/surface-not-surfaced.txt | 23 ++++++++ 8 files changed, 99 insertions(+), 56 deletions(-) diff --git a/apps/springtale-cli/src/commands/author.rs b/apps/springtale-cli/src/commands/author.rs index e81e7f61..d6c160c3 100644 --- a/apps/springtale-cli/src/commands/author.rs +++ b/apps/springtale-cli/src/commands/author.rs @@ -1,23 +1,32 @@ //! `springtale author` — the trusted-author registry that connector //! manifest signatures are verified against. //! -//! Entries are stored as `trusted-author:{name}` → `{"pubkey":""}`, -//! byte for byte what `POST /authors/{name}` in springtaled writes, so -//! the CLI and the API share one registry. +//! Deliberately offline (plan 2.2's offline set, alongside `init` and +//! `vault`): `author add --self` registers this instance's signing +//! identity, and that has to be possible on first run, before there is a +//! daemon to ask. Requiring `springtale server start` to register the +//! key that signs your own connectors would put the first-run path +//! behind the thing it precedes. +//! +//! That leaves one hazard — two writers against one registry — and it is +//! closed by both surfaces going through the same code: every read, +//! write and validation here is +//! [`springtale_runtime::operations::authors`], byte for byte the +//! functions `GET /authors` and `POST /authors/{name}` call, against the +//! same `trusted-author:` rows in the same store. The daemon does not +//! own a parallel copy; there is one registry and one implementation of +//! it, reached from a socket or from a terminal. use anyhow::{Context, Result}; use tabled::{Table, Tabled}; use springtale_crypto::identity::keypair::Keypair; -use springtale_store::StorageBackend; +use springtale_runtime::operations::authors; use springtale_store::backend::sqlite::SqliteBackend; use crate::cli::AuthorAction; use crate::output; -/// Config-store key prefix shared with `springtaled`'s `/authors` API. -const TRUSTED_AUTHOR_PREFIX: &str = "trusted-author:"; - /// Row type for the author list table. #[derive(Tabled)] struct AuthorTableRow { @@ -47,20 +56,13 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res (name, pubkey) }; - // Same validation as the API: hex-encoded 32-byte Ed25519 key. - let pubkey_bytes = hex::decode(&pubkey_hex).context("pubkey is not valid hex")?; - if pubkey_bytes.len() != 32 { - anyhow::bail!("pubkey must be a 32-byte Ed25519 public key"); - } - - let key = format!("{TRUSTED_AUTHOR_PREFIX}{name}"); - let value = serde_json::json!({ "pubkey": pubkey_hex }).to_string(); - store - .set_config(&key, &value) + // Hex and 32-byte checks live in the operation, so the + // terminal cannot store a key the API would have refused. + let author = authors::add(store, &name, &pubkey_hex) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let added = serde_json::json!({ "name": name, "pubkey": pubkey_hex }); + let added = serde_json::json!({ "name": author.name, "pubkey": author.pubkey }); output::emit(json, &added, |v| { format!( "Trusted author added: {}\n pubkey: {}", @@ -70,30 +72,17 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res })?; } AuthorAction::List => { - let configs = store - .list_config() + let authors = authors::list(store) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let rows: Vec = configs - .into_iter() - .filter_map(|(key, value)| { - let name = key.strip_prefix(TRUSTED_AUTHOR_PREFIX)?; - let data: serde_json::Value = serde_json::from_str(&value).ok()?; - Some(AuthorTableRow { - name: name.to_owned(), - pubkey: data - .get("pubkey") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(), - }) + let rows: Vec = authors + .iter() + .map(|a| AuthorTableRow { + name: a.name.clone(), + pubkey: a.pubkey.clone(), }) .collect(); - let authors: Vec = rows - .iter() - .map(|r| serde_json::json!({ "name": r.name, "pubkey": r.pubkey })) - .collect(); output::emit(json, &authors, |_| { if rows.is_empty() { "No trusted authors.".to_owned() @@ -103,9 +92,7 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res })?; } AuthorAction::Remove { name } => { - let key = format!("{TRUSTED_AUTHOR_PREFIX}{name}"); - store - .delete_config(&key) + authors::remove(store, &name) .await .map_err(|e| anyhow::anyhow!("{e}"))?; let removed = serde_json::json!({ "name": name, "removed": true }); diff --git a/apps/springtale-cli/src/commands/bot.rs b/apps/springtale-cli/src/commands/bot.rs index 7b0f2904..f2c9d1fb 100644 --- a/apps/springtale-cli/src/commands/bot.rs +++ b/apps/springtale-cli/src/commands/bot.rs @@ -12,13 +12,11 @@ use crate::output; use crate::store::PassphraseOpts; use springtale_runtime::operations::pairing; -pub async fn pair_init(opts: &PassphraseOpts, json_out: bool) -> Result<()> { - let store = crate::store::open_store(opts)?; - let code = pairing::generate_pairing_code(&store) - .await - .context("failed to generate pairing code")?; - - let body = serde_json::json!({ "pairing_code": code, "single_use": true }); +pub async fn pair_init(json_out: bool) -> Result<()> { + let client = Client::from_config()?; + let body: serde_json::Value = client + .post("/bot/pair-init", &serde_json::json!({})) + .await?; output::emit(json_out, &body, |v| { format!( "Pairing code (give this to the user, do NOT send via chat):\n\n {}\n\nThe user types this code into their chat with the bot.\nCode expires in 10 minutes. Single-use.", @@ -27,6 +25,16 @@ pub async fn pair_init(opts: &PassphraseOpts, json_out: bool) -> Result<()> { }) } +/// `springtale bot panic-unpair` — revoke every pairing, offline. +/// +/// This one does NOT go through the daemon, on purpose. It is reached +/// when the phone or the account on the other end of a pairing is in the +/// wrong hands, from whatever terminal the user has recovered, and it +/// has to work when springtaled is dead, wedged, or the very thing that +/// has been taken. `springtale panic` is offline for the same reason. +/// The write is a delete of every `paired_user:` / `pairing_code:` / +/// `pairing_rate:` row, so a daemon that is running simply stops finding +/// them; there is nothing for it to have been told. pub async fn panic_unpair(opts: &PassphraseOpts, json_out: bool) -> Result<()> { let store = crate::store::open_store(opts)?; let removed = pairing::panic_unpair(&store) diff --git a/apps/springtale-cli/src/main.rs b/apps/springtale-cli/src/main.rs index eb16582e..8dd923a7 100644 --- a/apps/springtale-cli/src/main.rs +++ b/apps/springtale-cli/src/main.rs @@ -118,7 +118,7 @@ async fn main() -> Result<()> { commands::bot::memory(cli.json).await?; } BotAction::PairInit => { - commands::bot::pair_init(&pass_opts, cli.json).await?; + commands::bot::pair_init(cli.json).await?; } BotAction::PanicUnpair => { commands::bot::panic_unpair(&pass_opts, cli.json).await?; diff --git a/apps/springtaled/src/api/bot.rs b/apps/springtaled/src/api/bot.rs index 8901ded8..95b82034 100644 --- a/apps/springtaled/src/api/bot.rs +++ b/apps/springtaled/src/api/bot.rs @@ -19,6 +19,31 @@ pub async fn status(State(state): State) -> Result) -> Result { + let code = + springtale_runtime::operations::pairing::generate_pairing_code(&*state.runtime.store) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to generate pairing code"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Json( + serde_json::json!({ "pairing_code": code, "single_use": true }), + )) +} + /// GET /bot/formations — active formations with member info. #[utoipa::path( get, operation_id = "bot_formations", diff --git a/apps/springtaled/src/api/mod.rs b/apps/springtaled/src/api/mod.rs index 888103e7..eaf8a583 100644 --- a/apps/springtaled/src/api/mod.rs +++ b/apps/springtaled/src/api/mod.rs @@ -334,6 +334,7 @@ pub fn build_router(state: AppState) -> Router { "/bot/settings", get(bot::get_settings).put(bot::put_settings), ) + .route("/bot/pair-init", post(bot::pair_init)) .route("/bot/formations", get(bot::formations)) .route("/cooperation/utterances", get(utterances::utterance_defs)) .route("/cooperation/utterances/recent", get(utterances::recent)) diff --git a/apps/springtaled/src/api/openapi.rs b/apps/springtaled/src/api/openapi.rs index 7a6a31de..78c83463 100644 --- a/apps/springtaled/src/api/openapi.rs +++ b/apps/springtaled/src/api/openapi.rs @@ -33,6 +33,7 @@ use super::*; bot::formations, bot::get_settings, bot::memory, + bot::pair_init, bot::put_settings, bot::status, canvas::get_canvas, diff --git a/scripts/surface-exemptions.txt b/scripts/surface-exemptions.txt index 6fd3f6dc..cb8059ed 100644 --- a/scripts/surface-exemptions.txt +++ b/scripts/surface-exemptions.txt @@ -10,11 +10,9 @@ # # Columns: # -# Needs a product decision, not plumbing: `springtale author` writes the -# trusted-author registry through the local store so `author add --self` -# can register a signing identity before a daemon exists (the first-run -# connector-signing path). Routing it through `/authors` would make -# first-run signing require a running daemon; leaving it is a second -# writer against the registry the API owns. Decide which, then close it. -/authors cli -/authors/{} cli +# The ledger is EMPTY. Every route the daemon serves has a +# command-line verb and a web provider method, or an entry in +# `surface-not-surfaced.txt` saying why it never will. +# +# Keep it that way: a new route with no surface is a gap, and a gap +# recorded here is a promise to close it, not permission to leave it. diff --git a/scripts/surface-not-surfaced.txt b/scripts/surface-not-surfaced.txt index 9614fbcc..ee7e2ba9 100644 --- a/scripts/surface-not-surfaced.txt +++ b/scripts/surface-not-surfaced.txt @@ -29,6 +29,29 @@ # ── Streams whose surface half is a different route ──────────────── /chat/stream cli # SSE half of /chat; the CLI follows the multiplexed /stream with `springtale trace` # +# ── One registry, reachable before there is a daemon ─────────────── +# `springtale author` writes the trusted-author registry through +# `springtale_runtime::operations::authors` — the same functions, the +# same `trusted-author:` rows, the same validation the API uses — so the +# daemon reads exactly what the terminal wrote. It stays offline because +# `author add --self` registers this instance's connector-signing +# identity on first run, before `springtale server start` has ever been +# typed; putting that behind a running daemon would put the first-run +# path behind the thing it precedes. +/authors cli # `springtale author list` reads the same rows through the shared operation +/authors/{} cli # `springtale author add|remove`, same operation, first-run capable +# +# ── Trusted-host actions the browser is not the place for ────────── +/bot/pair-init provider # `springtale bot pair-init` mints the code on the host that holds the vault; it is read off that terminal and handed over out of band +# +# `springtale bot panic-unpair` has no route and will not get one. It +# revokes every pairing from whatever terminal the user has recovered, +# at the moment the paired device or account is in the wrong hands — +# which is exactly when springtaled may be dead, wedged, or itself the +# thing that was taken. It deletes rows a running daemon then stops +# finding, so there is nothing for it to have been told. Same reasoning +# as `springtale panic` below. +# # ── Must work when springtaled is down ───────────────────────────── # These CLI verbs exist and are the reason the routes exist, but they # deliberately do NOT go through the daemon: each one has to work when From 9aec03d9dd69110027e8970e2bb50c172a6df36d Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:20:31 -0700 Subject: [PATCH 14/24] api: typed request bodies for the six formation handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `update_intent`, `propose_intent`, `cast_vote`, `add_member`, `remove_member` and `run_command` took `Json` and hand-plucked fields out of it. The bodies are now `IntentBody`, `CastVoteBody`, `MemberBody` and `RunCommandBody` — required fields with no silent defaults, `ToSchema` for the contract, and a missing field is a rejection at deserialization rather than a value the handler invents. `params` on `run-command` stays optional because a command with no parameters is a real case; nothing else is. Regenerating the document off the annotations found a caller that had never worked: the web provider's `castFormationVote` posted `{ choice }` to a route that reads `voter` and `approve`, so every browser ballot was a 400. The `DataProvider` signature now carries the two fields the route takes, which is the point of generating the contract rather than describing it twice. `tauri/packages/types/openapi.json` and `src/api.ts` are regenerated the way the contract job does, covering this and the two commits before it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtaled/src/api/formations.rs | 90 ++++--- apps/springtaled/src/api/openapi.rs | 4 + apps/springtaled/tests/api_integration.rs | 25 +- tauri/packages/types/openapi.json | 253 ++++++++++++++++++- tauri/packages/types/src/api.ts | 295 +++++++++++++++++++++- tauri/packages/ui/src/dashboard/types.ts | 16 +- tauri/packages/ui/src/web/provider.ts | 4 +- 7 files changed, 640 insertions(+), 47 deletions(-) diff --git a/apps/springtaled/src/api/formations.rs b/apps/springtaled/src/api/formations.rs index 63447faf..803698d7 100644 --- a/apps/springtaled/src/api/formations.rs +++ b/apps/springtaled/src/api/formations.rs @@ -2,12 +2,54 @@ use axum::Json; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; +use serde::Deserialize; use springtale_runtime::operations; use super::extractors::ValidatedPath; use super::state::AppState; +/// Body of `POST /formations/{id}/run-command`. +/// +/// `command_id` is required: a dispatcher with no command to dispatch is +/// a malformed request, not a default. `params` is genuinely optional — +/// most commands take none — and its absence means "no parameters", +/// which is what the command layer already expects. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct RunCommandBody { + /// The command to run, from `GET /formations/{id}/commands`. + pub command_id: String, + /// Command-specific parameters, passed through untouched. + #[serde(default)] + pub params: Option, +} + +/// Body of `PUT /formations/{id}/intent`. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct IntentBody { + /// The intent to set — one of `GET /formations/intents`. + pub intent: String, +} + +/// Body of `POST /formations/{id}/votes/{vote_id}`. +/// +/// Both fields are required. An absent `approve` used to read as a +/// rejection of the ballot; now it is a rejection of the request. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CastVoteBody { + /// The voting agent's id. + pub voter: String, + /// The ballot itself. + pub approve: bool, +} + +/// Body of `POST`/`DELETE /formations/{id}/members`. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct MemberBody { + /// The connector whose agent joins or leaves the formation. + pub connector_name: String, +} + /// GET /formations — list all formations. #[utoipa::path( get, operation_id = "formations_list", @@ -77,18 +119,16 @@ pub async fn commands( path = "/formations/{id}/run-command", tag = "formations", params(("id" = String, Path, description = "Formation id")), - request_body = Object, + request_body = RunCommandBody, responses((status = 200, description = "Command outcome", body = Object)) )] pub async fn run_command( State(state): State, ValidatedPath(id): ValidatedPath, - Json(body): Json, + Json(body): Json, ) -> Result { - let Some(command_id) = body.get("command_id").and_then(|v| v.as_str()) else { - return Err(StatusCode::BAD_REQUEST); - }; - let params = body.get("params"); + let command_id = body.command_id.as_str(); + let params = body.params.as_ref(); match operations::commands::run_formation_command(&state.runtime, &id, command_id, params).await { Ok(()) => Ok(( @@ -212,16 +252,15 @@ pub async fn resume( path = "/formations/{id}/intent", tag = "formations", params(("id" = String, Path, description = "Formation id")), - request_body = Object, + request_body = IntentBody, responses((status = 200, description = "Intent updated", body = Object)) )] pub async fn update_intent( State(state): State, ValidatedPath(id): ValidatedPath, - Json(body): Json, + Json(body): Json, ) -> Result { - let intent = body["intent"].as_str().ok_or(StatusCode::BAD_REQUEST)?; - operations::formations::update_intent(&state.runtime, &id, intent) + operations::formations::update_intent(&state.runtime, &id, &body.intent) .await .map_err(|_| StatusCode::NOT_FOUND)?; Ok((StatusCode::OK, Json(serde_json::json!({ "updated": id })))) @@ -234,16 +273,15 @@ pub async fn update_intent( path = "/formations/{id}/propose-intent", tag = "formations", params(("id" = String, Path, description = "Formation id")), - request_body = Object, + request_body = IntentBody, responses((status = 200, description = "Intent proposal opened", body = Object)) )] pub async fn propose_intent( State(state): State, ValidatedPath(id): ValidatedPath, - Json(body): Json, + Json(body): Json, ) -> Result { - let intent = body["intent"].as_str().ok_or(StatusCode::BAD_REQUEST)?; - operations::formations::propose_intent_change(&state.runtime, &id, intent) + operations::formations::propose_intent_change(&state.runtime, &id, &body.intent) .await .map_err(|_| StatusCode::NOT_FOUND)?; Ok((StatusCode::OK, Json(serde_json::json!({ "proposed": id })))) @@ -256,17 +294,15 @@ pub async fn propose_intent( path = "/formations/{id}/votes/{vote_id}", tag = "formations", params(("id" = String, Path, description = "Formation id"), ("vote_id" = String, Path, description = "Vote id")), - request_body = Object, + request_body = CastVoteBody, responses((status = 200, description = "Vote recorded", body = Object)) )] pub async fn cast_vote( State(state): State, axum::extract::Path((id, vote_id)): axum::extract::Path<(String, String)>, - Json(body): Json, + Json(body): Json, ) -> Result { - let voter = body["voter"].as_str().ok_or(StatusCode::BAD_REQUEST)?; - let approve = body["approve"].as_bool().ok_or(StatusCode::BAD_REQUEST)?; - operations::formations::cast_vote(&state.runtime, &id, &vote_id, voter, approve) + operations::formations::cast_vote(&state.runtime, &id, &vote_id, &body.voter, body.approve) .await .map_err(|_| StatusCode::BAD_REQUEST)?; Ok(( @@ -281,17 +317,15 @@ pub async fn cast_vote( path = "/formations/{id}/members", tag = "formations", params(("id" = String, Path, description = "Formation id")), - request_body = Object, + request_body = MemberBody, responses((status = 200, description = "Member added", body = Object)) )] pub async fn add_member( State(state): State, ValidatedPath(id): ValidatedPath, - Json(body): Json, + Json(body): Json, ) -> Result { - let connector_name = body["connector_name"] - .as_str() - .ok_or(StatusCode::BAD_REQUEST)?; + let connector_name = body.connector_name.as_str(); operations::formations::add_member(&state.runtime, &id, connector_name) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -307,17 +341,15 @@ pub async fn add_member( path = "/formations/{id}/members", tag = "formations", params(("id" = String, Path, description = "Formation id")), - request_body = Object, + request_body = MemberBody, responses((status = 200, description = "Member removed", body = Object)) )] pub async fn remove_member( State(state): State, ValidatedPath(id): ValidatedPath, - Json(body): Json, + Json(body): Json, ) -> Result { - let connector_name = body["connector_name"] - .as_str() - .ok_or(StatusCode::BAD_REQUEST)?; + let connector_name = body.connector_name.as_str(); operations::formations::remove_member(&state.runtime, &id, connector_name) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/apps/springtaled/src/api/openapi.rs b/apps/springtaled/src/api/openapi.rs index 78c83463..041e3677 100644 --- a/apps/springtaled/src/api/openapi.rs +++ b/apps/springtaled/src/api/openapi.rs @@ -165,6 +165,10 @@ use super::*; config_api::ConfigureAiBody, data::PurgeBody, executions::VacuumResponse, + formations::CastVoteBody, + formations::IntentBody, + formations::MemberBody, + formations::RunCommandBody, lock::UnlockRequest, login::CreateTokenRequest, login::LoginRequest, diff --git a/apps/springtaled/tests/api_integration.rs b/apps/springtaled/tests/api_integration.rs index 01a808ac..1afcfcbc 100644 --- a/apps/springtaled/tests/api_integration.rs +++ b/apps/springtaled/tests/api_integration.rs @@ -563,14 +563,35 @@ async fn test_propose_intent_and_cast_vote_routes() { assert_eq!(status, StatusCode::OK); assert_eq!(json["proposed"], fid); - // Missing intent body → 400. + // Missing intent → rejected, not defaulted. The body is a typed + // struct now (plan 2.4), so axum refuses it at deserialization with + // 422 rather than the handler hand-plucking a field and returning + // 400. What matters is that an absent field is a refusal: nothing + // downstream ever sees a formation whose intent was invented here. let req = Request::post(format!("/formations/{fid}/propose-intent")) .header("Authorization", format!("Bearer {token}")) .header("Content-Type", "application/json") .body(Body::from(b"{}".to_vec())) .unwrap(); let (status, _) = send(router.clone(), req).await; - assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + + // Same for the other typed bodies: no command_id, no member name. + let req = Request::post(format!("/formations/{fid}/run-command")) + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(Body::from(br#"{"params":{"a":1}}"#.to_vec())) + .unwrap(); + let (status, _) = send(router.clone(), req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + + let req = Request::post(format!("/formations/{fid}/members")) + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(Body::from(b"{}".to_vec())) + .unwrap(); + let (status, _) = send(router.clone(), req).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); // Cast a ballot with well-formed ids → enqueued (200). let vote_id = uuid::Uuid::new_v4(); diff --git a/tauri/packages/types/openapi.json b/tauri/packages/types/openapi.json index ab9a4bd9..3a14d1d7 100644 --- a/tauri/packages/types/openapi.json +++ b/tauri/packages/types/openapi.json @@ -493,6 +493,28 @@ } } }, + "/bot/pair-init": { + "post": { + "tags": [ + "bot" + ], + "summary": "POST /bot/pair-init — mint a single-use pairing code.", + "description": "The pairing registry lives in the daemon's store, so the daemon is\nthe one writer to it. `springtale bot pair-init` is a client of this\nroute rather than a second writer opening the same database behind\nthe running daemon's back (plan 2.2).", + "operationId": "bot_pair_init", + "responses": { + "200": { + "description": "A single-use pairing code, valid for ten minutes", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, "/bot/settings": { "get": { "tags": [ @@ -2345,7 +2367,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/IntentBody" } } }, @@ -2387,7 +2409,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/MemberBody" } } }, @@ -2427,7 +2449,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/MemberBody" } } }, @@ -2536,7 +2558,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/IntentBody" } } }, @@ -2642,7 +2664,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/RunCommandBody" } } }, @@ -2725,7 +2747,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/CastVoteBody" } } }, @@ -2770,6 +2792,62 @@ ] } }, + "/mcp": { + "post": { + "tags": [ + "mcp" + ], + "summary": "Build the `/mcp` router.", + "description": "The handler is constructed per session and holds a clone of the shared\n`RuntimeState`, so tool calls dispatch through the same sentinel,\napproval gate and executions recorder as a rule action.\nThe endpoint is a nested service, not a handler, so the contract\nannotation sits on the constructor that mounts it.\n\nThe document describes what `/mcp` *is* — a Streamable HTTP MCP\nendpoint carrying JSON-RPC 2.0 in both directions — and deliberately\ndoes not restate MCP's own schema. That schema is versioned by the\nMCP specification, not by this daemon; a copy of it here would be a\nsecond, staler source of truth. Clients discover tools the way the\nprotocol says to: `initialize`, then `tools/list`.", + "operationId": "mcp_endpoint", + "requestBody": { + "description": "One JSON-RPC 2.0 request, notification, or response, per the MCP Streamable HTTP transport", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "A JSON-RPC response, or an SSE stream of them when the client accepts `text/event-stream`", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "202": { + "description": "Notification or response accepted; no body" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Origin header rejected (DNS-rebinding guard)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, "/memory/audit": { "post": { "tags": [ @@ -2888,6 +2966,31 @@ } } }, + "/openapi.json": { + "get": { + "tags": [ + "openapi" + ], + "summary": "`GET /openapi.json` — the contract itself.", + "description": "Unauthenticated on purpose: it is a schema, not data. Nothing in it\nis a secret, and the CLI, the two front ends and CI all read it\nbefore they hold a token.", + "operationId": "openapi_serve", + "responses": { + "200": { + "description": "The OpenAPI 3.1 document this daemon is described by", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "security": [ + {} + ] + } + }, "/ready": { "get": { "tags": [ @@ -4290,6 +4393,61 @@ } } }, + "/vault/unlock": { + "post": { + "tags": [ + "vault" + ], + "summary": "POST /vault/unlock — public, rate-limited.", + "description": "Deliberately unauthenticated: while the vault is locked there is no\nbearer that could be presented. Bearers are *issued*, never derived\nfrom the passphrase (plan 6.6) — a session comes from\n`POST /auth/login` and lives in the process state that locking drops,\nand a long-lived token can only be looked up against that same\ndropped state. So the passphrase itself is the credential here, and\n`Vault::open` is the check — Argon2id over the wrong passphrase fails\nat AEAD decryption, with no comparison this code could shortcut.", + "operationId": "lock_unlock", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnlockRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Vault unlocked; the live router is back", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Unlock refused — wrong passphrase or unreadable vault", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "409": { + "description": "Already unlocked", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "security": [ + {} + ] + } + }, "/webhook/{connector}/{trigger}": { "post": { "tags": [ @@ -4747,6 +4905,24 @@ } } }, + "CastVoteBody": { + "type": "object", + "description": "Body of `POST /formations/{id}/votes/{vote_id}`.\n\nBoth fields are required. An absent `approve` used to read as a\nrejection of the ballot; now it is a rejection of the request.", + "required": [ + "voter", + "approve" + ], + "properties": { + "approve": { + "type": "boolean", + "description": "The ballot itself." + }, + "voter": { + "type": "string", + "description": "The voting agent's id." + } + } + }, "Check": { "type": "object", "description": "One diagnostic finding.", @@ -5709,6 +5885,19 @@ } } }, + "IntentBody": { + "type": "object", + "description": "Body of `PUT /formations/{id}/intent`.", + "required": [ + "intent" + ], + "properties": { + "intent": { + "type": "string", + "description": "The intent to set — one of `GET /formations/intents`." + } + } + }, "LatencyDrift": { "type": "object", "required": [ @@ -5769,6 +5958,19 @@ } } }, + "MemberBody": { + "type": "object", + "description": "Body of `POST`/`DELETE /formations/{id}/members`.", + "required": [ + "connector_name" + ], + "properties": { + "connector_name": { + "type": "string", + "description": "The connector whose agent joins or leaves the formation." + } + } + }, "OnboardBody": { "type": "object", "description": "Body for both onboarding routes. `config` is the not-yet-deployed\nconnector config from the deploy form (bot token etc.) — it\ntravels in the body, never the URL.", @@ -6514,6 +6716,22 @@ } } }, + "RunCommandBody": { + "type": "object", + "description": "Body of `POST /formations/{id}/run-command`.\n\n`command_id` is required: a dispatcher with no command to dispatch is\na malformed request, not a default. `params` is genuinely optional —\nmost commands take none — and its absence means \"no parameters\",\nwhich is what the command layer already expects.", + "required": [ + "command_id" + ], + "properties": { + "command_id": { + "type": "string", + "description": "The command to run, from `GET /formations/{id}/commands`." + }, + "params": { + "description": "Command-specific parameters, passed through untouched." + } + } + }, "ScanBody": { "type": "object", "required": [ @@ -6824,6 +7042,20 @@ } } }, + "UnlockRequest": { + "type": "object", + "description": "Body of `POST /vault/unlock`.", + "required": [ + "passphrase" + ], + "properties": { + "passphrase": { + "type": "string", + "format": "password", + "description": "The vault passphrase. Never logged, never echoed.\n\n`SecretString` has no schema of its own on purpose — the contract\ndescribes the wire shape (a string), and the type describes what\nthe daemon does with it (zeroize on drop, redact in `Debug`)." + } + } + }, "UpsertManualBody": { "type": "object", "required": [ @@ -6970,12 +7202,18 @@ { "name": "login" }, + { + "name": "mcp" + }, { "name": "memory" }, { "name": "onboarding" }, + { + "name": "openapi" + }, { "name": "recipes" }, @@ -6997,6 +7235,9 @@ { "name": "utterances" }, + { + "name": "vault" + }, { "name": "webhooks" }, diff --git a/tauri/packages/types/src/api.ts b/tauri/packages/types/src/api.ts index 87ddf0f4..7268a139 100644 --- a/tauri/packages/types/src/api.ts +++ b/tauri/packages/types/src/api.ts @@ -269,6 +269,29 @@ export interface paths { patch?: never; trace?: never; }; + "/bot/pair-init": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * POST /bot/pair-init — mint a single-use pairing code. + * @description The pairing registry lives in the daemon's store, so the daemon is + * the one writer to it. `springtale bot pair-init` is a client of this + * route rather than a second writer opening the same database behind + * the running daemon's back (plan 2.2). + */ + post: operations["bot_pair_init"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/bot/settings": { parameters: { query?: never; @@ -1377,6 +1400,37 @@ export interface paths { patch?: never; trace?: never; }; + "/mcp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Build the `/mcp` router. + * @description The handler is constructed per session and holds a clone of the shared + * `RuntimeState`, so tool calls dispatch through the same sentinel, + * approval gate and executions recorder as a rule action. + * The endpoint is a nested service, not a handler, so the contract + * annotation sits on the constructor that mounts it. + * + * The document describes what `/mcp` *is* — a Streamable HTTP MCP + * endpoint carrying JSON-RPC 2.0 in both directions — and deliberately + * does not restate MCP's own schema. That schema is versioned by the + * MCP specification, not by this daemon; a copy of it here would be a + * second, staler source of truth. Clients discover tools the way the + * protocol says to: `initialize`, then `tools/list`. + */ + post: operations["mcp_endpoint"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/memory/audit": { parameters: { query?: never; @@ -1448,6 +1502,28 @@ export interface paths { patch?: never; trace?: never; }; + "/openapi.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * `GET /openapi.json` — the contract itself. + * @description Unauthenticated on purpose: it is a schema, not data. Nothing in it + * is a secret, and the CLI, the two front ends and CI all read it + * before they hold a token. + */ + get: operations["openapi_serve"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ready": { parameters: { query?: never; @@ -2183,6 +2259,33 @@ export interface paths { patch?: never; trace?: never; }; + "/vault/unlock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * POST /vault/unlock — public, rate-limited. + * @description Deliberately unauthenticated: while the vault is locked there is no + * bearer that could be presented. Bearers are *issued*, never derived + * from the passphrase (plan 6.6) — a session comes from + * `POST /auth/login` and lives in the process state that locking drops, + * and a long-lived token can only be looked up against that same + * dropped state. So the passphrase itself is the credential here, and + * `Vault::open` is the check — Argon2id over the wrong passphrase fails + * at AEAD decryption, with no comparison this code could shortcut. + */ + post: operations["lock_unlock"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/webhook/{connector}/{trigger}": { parameters: { query?: never; @@ -2401,6 +2504,18 @@ export interface components { */ tool_policy?: Record; }; + /** + * @description Body of `POST /formations/{id}/votes/{vote_id}`. + * + * Both fields are required. An absent `approve` used to read as a + * rejection of the ballot; now it is a rejection of the request. + */ + CastVoteBody: { + /** @description The ballot itself. */ + approve: boolean; + /** @description The voting agent's id. */ + voter: string; + }; /** @description One diagnostic finding. */ Check: { /** @description Longer description / detected value. */ @@ -2809,6 +2924,11 @@ export interface components { */ visibility: components["schemas"]["FieldVisibility"]; }; + /** @description Body of `PUT /formations/{id}/intent`. */ + IntentBody: { + /** @description The intent to set — one of `GET /formations/intents`. */ + intent: string; + }; LatencyDrift: { /** Format: int64 */ baseline_median_ms?: number | null; @@ -2830,6 +2950,11 @@ export interface components { /** @description The vault passphrase. Verified, never stored, zeroized here. */ passphrase: string; }; + /** @description Body of `POST`/`DELETE /formations/{id}/members`. */ + MemberBody: { + /** @description The connector whose agent joins or leaves the formation. */ + connector_name: string; + }; /** * @description Body for both onboarding routes. `config` is the not-yet-deployed * connector config from the deploy form (bot token etc.) — it @@ -3129,6 +3254,20 @@ export interface components { /** @description TOML rule body — placeholders substituted before parse. */ toml: string; }; + /** + * @description Body of `POST /formations/{id}/run-command`. + * + * `command_id` is required: a dispatcher with no command to dispatch is + * a malformed request, not a default. `params` is genuinely optional — + * most commands take none — and its absence means "no parameters", + * which is what the command layer already expects. + */ + RunCommandBody: { + /** @description The command to run, from `GET /formations/{id}/commands`. */ + command_id: string; + /** @description Command-specific parameters, passed through untouched. */ + params?: unknown; + }; ScanBody: { connector_name: string; formation_id: string; @@ -3249,6 +3388,18 @@ export interface components { /** @description Vault passphrase — encrypts (prepare) or decrypts (restore) the backup. */ passphrase: string; }; + /** @description Body of `POST /vault/unlock`. */ + UnlockRequest: { + /** + * Format: password + * @description The vault passphrase. Never logged, never echoed. + * + * `SecretString` has no schema of its own on purpose — the contract + * describes the wire shape (a string), and the type describes what + * the daemon does with it (zeroize on drop, redact in `Debug`). + */ + passphrase: string; + }; UpsertManualBody: { connector_name: string; display_name: string; @@ -3659,6 +3810,26 @@ export interface operations { }; }; }; + bot_pair_init: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A single-use pairing code, valid for ten minutes */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; bot_get_settings: { parameters: { query?: never; @@ -4940,7 +5111,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["IntentBody"]; }; }; responses: { @@ -4967,7 +5138,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["MemberBody"]; }; }; responses: { @@ -4994,7 +5165,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["MemberBody"]; }; }; responses: { @@ -5067,7 +5238,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["IntentBody"]; }; }; responses: { @@ -5140,7 +5311,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["RunCommandBody"]; }; }; responses: { @@ -5192,7 +5363,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["CastVoteBody"]; }; }; responses: { @@ -5227,6 +5398,56 @@ export interface operations { }; }; }; + mcp_endpoint: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description One JSON-RPC 2.0 request, notification, or response, per the MCP Streamable HTTP transport */ + requestBody: { + content: { + "application/json": Record; + }; + }; + responses: { + /** @description A JSON-RPC response, or an SSE stream of them when the client accepts `text/event-stream` */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Notification or response accepted; no body */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Origin header rejected (DNS-rebinding guard) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; memory_audit_memory: { parameters: { query?: never; @@ -5318,6 +5539,26 @@ export interface operations { }; }; }; + openapi_serve: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The OpenAPI 3.1 document this daemon is described by */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; health_ready: { parameters: { query?: never; @@ -6303,6 +6544,48 @@ export interface operations { }; }; }; + lock_unlock: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UnlockRequest"]; + }; + }; + responses: { + /** @description Vault unlocked; the live router is back */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Unlock refused — wrong passphrase or unreadable vault */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Already unlocked */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; webhooks_receive: { parameters: { query?: never; diff --git a/tauri/packages/ui/src/dashboard/types.ts b/tauri/packages/ui/src/dashboard/types.ts index 7e529694..4e416069 100644 --- a/tauri/packages/ui/src/dashboard/types.ts +++ b/tauri/packages/ui/src/dashboard/types.ts @@ -755,8 +755,20 @@ export interface DataProvider { // ── Formation votes ─────────────────────────────────────────────── /** Propose an intent change for the formation to vote on. */ proposeFormationIntent(id: string, intent: string): Promise>; - /** Cast a vote on an open proposal. */ - castFormationVote(id: string, voteId: string, choice: string): Promise>; + /** + * Cast a vote on an open proposal. + * + * `voter` is the agent id casting the ballot and `approve` is the + * ballot — the two fields `POST /formations/{id}/votes/{vote_id}` + * requires. It used to send a single `choice` string, which the + * daemon has never read. + */ + castFormationVote( + id: string, + voteId: string, + voter: string, + approve: boolean, + ): Promise>; // ── Chat sessions ───────────────────────────────────────────────── /** The chat sessions the daemon is holding. */ diff --git a/tauri/packages/ui/src/web/provider.ts b/tauri/packages/ui/src/web/provider.ts index 283eacc8..2514eba5 100644 --- a/tauri/packages/ui/src/web/provider.ts +++ b/tauri/packages/ui/src/web/provider.ts @@ -618,10 +618,10 @@ export function createWebProvider(): DataProvider { async proposeFormationIntent(id, intent) { return post>(`/formations/${id}/propose-intent`, { intent }); }, - async castFormationVote(id, voteId, choice) { + async castFormationVote(id, voteId, voter, approve) { return post>( `/formations/${id}/votes/${encodeURIComponent(voteId)}`, - { choice }, + { voter, approve }, ); }, From 0783c5d412a3017db4a455d3605f30a64a30784d Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:20:31 -0700 Subject: [PATCH 15/24] cli: the command line reports its own verbs and the routes they call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/cli-routes.sh` grepped path string literals out of the command sources, which answered a weaker question than the surface check asks. A path in a comment counted as a verb. A verb that built its path from a constant or a `match` did not count at all. The list was a guess about code rather than a statement by the program. `springtale dump-commands` is that statement: it walks its own clap tree at runtime and prints every verb with the routes it calls. The tree half cannot drift — it is the parser. The route half is declared beside the verb in `surface.rs` and held to the tree by a unit test, so a new subcommand cannot be added without saying what it talks to and a deleted one cannot leave a ghost behind. A verb that declares no routes is saying it runs offline, which is itself checkable. The script reads the dump, asks cargo for the binary rather than trusting whatever is in target/, and still fails loudly on an empty list — and on a verb with no declaration, the new way for the extractor to be broken. The drum rule is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/src/cli.rs | 8 + apps/springtale-cli/src/main.rs | 4 + apps/springtale-cli/src/surface.rs | 665 +++++++++++++++++++++++++++++ scripts/cli-routes.sh | 63 ++- 4 files changed, 721 insertions(+), 19 deletions(-) create mode 100644 apps/springtale-cli/src/surface.rs diff --git a/apps/springtale-cli/src/cli.rs b/apps/springtale-cli/src/cli.rs index 584c336d..a19bc9a7 100644 --- a/apps/springtale-cli/src/cli.rs +++ b/apps/springtale-cli/src/cli.rs @@ -31,6 +31,14 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Command { + /// Print the command tree and the route each verb calls, as JSON. + /// + /// The machine-readable half of `--help`: `scripts/check-surface.sh` + /// reads it to check the command line against the daemon's OpenAPI + /// document. Hidden because it describes the tool rather than doing + /// anything to the user's data. + #[command(name = "dump-commands", hide = true)] + DumpCommands, /// Manage connectors. Connector { #[command(subcommand)] diff --git a/apps/springtale-cli/src/main.rs b/apps/springtale-cli/src/main.rs index 8dd923a7..f0fbb800 100644 --- a/apps/springtale-cli/src/main.rs +++ b/apps/springtale-cli/src/main.rs @@ -3,6 +3,7 @@ mod client; mod commands; mod output; mod store; +mod surface; use anyhow::Result; use clap::Parser; @@ -29,6 +30,9 @@ async fn main() -> Result<()> { }; match cli.command { + Command::DumpCommands => { + println!("{}", serde_json::to_string_pretty(&surface::dump())?); + } Command::Init => { commands::init::run().await?; } diff --git a/apps/springtale-cli/src/surface.rs b/apps/springtale-cli/src/surface.rs new file mode 100644 index 00000000..abc20ba5 --- /dev/null +++ b/apps/springtale-cli/src/surface.rs @@ -0,0 +1,665 @@ +//! What the command line is, in machine-readable form (plan 2.3). +//! +//! `scripts/check-surface.sh` has to answer one question: does every +//! route the daemon serves have a command-line verb? Reading that off +//! the *sources* — grepping for path literals — answers a different and +//! weaker question. A path in a comment counts. A verb that builds its +//! path from a constant or a `match` does not. The list is a guess about +//! code, not a statement by the program. +//! +//! So the program states it. `springtale dump-commands` prints its own +//! command tree, walked out of clap at runtime, with the daemon routes +//! each verb calls attached. The tree half cannot drift: it *is* the +//! parser. The route half is declared here beside the verb, and +//! [`tests`] fails the build if a verb has no declaration or a +//! declaration has no verb — so a new subcommand cannot be added +//! without saying what it talks to, and a deleted one cannot leave a +//! ghost behind. +//! +//! A verb with an empty route list is a deliberate statement too: it +//! runs offline, against the vault and the local store, with no daemon +//! in the picture (plan 2.2's offline set). + +use clap::CommandFactory; + +use crate::cli::Cli; + +/// One command-line verb and the daemon routes it calls. +pub struct VerbRoutes { + /// The full verb path, exactly as it is typed: `formation rally`. + pub verb: &'static str, + /// The routes it calls, as the client sees them — `{id}`-style + /// holes and no query string. Empty means the verb is offline. + pub routes: &'static [&'static str], +} + +/// Every verb, and what it talks to. +pub const VERB_ROUTES: &[VerbRoutes] = &[ + VerbRoutes { + verb: "agent set-autonomy", + routes: &["/agents/{name}/autonomy"], + }, + VerbRoutes { + verb: "agent states", + routes: &["/agents/states"], + }, + VerbRoutes { + verb: "agent step-autonomy", + routes: &["/agents/{name}/autonomy/step"], + }, + VerbRoutes { + verb: "approval approve", + routes: &["/approvals/{id}"], + }, + VerbRoutes { + verb: "approval deny", + routes: &["/approvals/{id}"], + }, + VerbRoutes { + verb: "approval list", + routes: &["/approvals"], + }, + VerbRoutes { + verb: "auth revoke", + routes: &["/auth/tokens/{id}"], + }, + VerbRoutes { + verb: "auth tokens", + routes: &["/auth/tokens"], + }, + VerbRoutes { + verb: "author add", + routes: &[], + }, + VerbRoutes { + verb: "author list", + routes: &[], + }, + VerbRoutes { + verb: "author remove", + routes: &[], + }, + VerbRoutes { + verb: "bot formations", + routes: &["/bot/formations"], + }, + VerbRoutes { + verb: "bot memory", + routes: &["/bot/memory"], + }, + VerbRoutes { + verb: "bot pair-init", + routes: &["/bot/pair-init"], + }, + VerbRoutes { + verb: "bot panic-unpair", + routes: &[], + }, + VerbRoutes { + verb: "bot settings get", + routes: &["/bot/settings"], + }, + VerbRoutes { + verb: "bot settings set", + routes: &["/bot/settings"], + }, + VerbRoutes { + verb: "bot status", + routes: &["/bot/status"], + }, + VerbRoutes { + verb: "canvas", + routes: &[ + "/canvas", + "/canvas/connections", + "/stream", + "/stream/ticket", + ], + }, + VerbRoutes { + verb: "chat", + routes: &["/chat"], + }, + VerbRoutes { + verb: "config ai get", + routes: &["/config/{key}"], + }, + VerbRoutes { + verb: "config ai put", + routes: &["/config/ai"], + }, + VerbRoutes { + verb: "config ai set", + routes: &["/config/ai/configure"], + }, + VerbRoutes { + verb: "config connector", + routes: &["/config/connector/{name}"], + }, + VerbRoutes { + verb: "config heartbeat", + routes: &["/config/heartbeat"], + }, + VerbRoutes { + verb: "config list", + routes: &["/config"], + }, + VerbRoutes { + verb: "connector available", + routes: &["/connectors/available"], + }, + VerbRoutes { + verb: "connector cascade", + routes: &["/connectors/{name}/cascade"], + }, + VerbRoutes { + verb: "connector config", + routes: &["/connectors/{name}/config"], + }, + VerbRoutes { + verb: "connector disable", + routes: &["/connectors/{name}/disable"], + }, + VerbRoutes { + verb: "connector enable", + routes: &["/connectors/{name}/enable"], + }, + VerbRoutes { + verb: "connector install", + routes: &["/connectors/install"], + }, + VerbRoutes { + verb: "connector install-wasm", + routes: &["/connectors/install-wasm"], + }, + VerbRoutes { + verb: "connector list", + routes: &["/connectors"], + }, + VerbRoutes { + verb: "connector outputs", + routes: &["/connectors/{name}/outputs"], + }, + VerbRoutes { + verb: "connector reload", + routes: &["/connectors/{name}/reload"], + }, + VerbRoutes { + verb: "connector remove", + routes: &["/connectors/{name}"], + }, + VerbRoutes { + verb: "connector schemas", + routes: &["/connectors/schemas"], + }, + VerbRoutes { + verb: "connector setup", + routes: &["/connectors/setup"], + }, + VerbRoutes { + verb: "connector sign", + routes: &[], + }, + VerbRoutes { + verb: "connector test", + routes: &["/connectors/{name}/test"], + }, + VerbRoutes { + verb: "connector upsert-config", + routes: &["/connectors/{name}/upsert-config"], + }, + VerbRoutes { + verb: "cooperation glyphs", + routes: &[], + }, + VerbRoutes { + verb: "cooperation recent", + routes: &["/cooperation/utterances/recent"], + }, + VerbRoutes { + verb: "cooperation utterances", + routes: &["/cooperation/utterances"], + }, + VerbRoutes { + verb: "crypto rotate-vault-key", + routes: &[], + }, + VerbRoutes { + verb: "data export", + routes: &["/data/export"], + }, + VerbRoutes { + verb: "data import", + routes: &["/data/import"], + }, + VerbRoutes { + verb: "data purge", + routes: &["/data/purge"], + }, + VerbRoutes { + verb: "doctor", + routes: &[], + }, + VerbRoutes { + verb: "drift recipe", + routes: &["/drift/recipe/{id}"], + }, + VerbRoutes { + verb: "drift rule", + routes: &["/drift/rule/{id}"], + }, + VerbRoutes { + verb: "events", + routes: &["/events"], + }, + VerbRoutes { + verb: "execution list", + routes: &["/executions"], + }, + VerbRoutes { + verb: "execution steps", + routes: &["/executions/{id}/steps"], + }, + VerbRoutes { + verb: "execution vacuum", + routes: &["/executions/vacuum"], + }, + VerbRoutes { + verb: "fix", + routes: &[], + }, + VerbRoutes { + verb: "formation add-member", + routes: &["/formations/{id}/members"], + }, + VerbRoutes { + verb: "formation autonomy", + routes: &["/formations/{id}/cycle-autonomy"], + }, + VerbRoutes { + verb: "formation commands", + routes: &["/formations/{id}/commands"], + }, + VerbRoutes { + verb: "formation deploy", + routes: &["/formations/{id}/deploy"], + }, + VerbRoutes { + verb: "formation deploy-team", + routes: &["/formations/deploy-team"], + }, + VerbRoutes { + verb: "formation dissolve", + routes: &["/formations/{id}/dissolve"], + }, + VerbRoutes { + verb: "formation eligible", + routes: &["/formations/{id}/members/eligible"], + }, + VerbRoutes { + verb: "formation get", + routes: &["/formations/{id}"], + }, + VerbRoutes { + verb: "formation guard", + routes: &["/formations/{id}/toggle-guard"], + }, + VerbRoutes { + verb: "formation intent", + routes: &["/formations/{id}/cycle-intent", "/formations/{id}/intent"], + }, + VerbRoutes { + verb: "formation intents", + routes: &["/formations/intents"], + }, + VerbRoutes { + verb: "formation list", + routes: &["/formations"], + }, + VerbRoutes { + verb: "formation pause", + routes: &["/formations/{id}/pause"], + }, + VerbRoutes { + verb: "formation propose-intent", + routes: &["/formations/{id}/propose-intent"], + }, + VerbRoutes { + verb: "formation rally", + routes: &["/formations/{id}/rally"], + }, + VerbRoutes { + verb: "formation resume", + routes: &["/formations/{id}/resume"], + }, + VerbRoutes { + verb: "formation rm-member", + routes: &["/formations/{id}/members"], + }, + VerbRoutes { + verb: "formation run", + routes: &["/formations/{id}/run-command"], + }, + VerbRoutes { + verb: "formation vote", + routes: &["/formations/{id}/votes/{vote_id}"], + }, + VerbRoutes { + verb: "healthcheck", + routes: &["/health", "/ready"], + }, + VerbRoutes { + verb: "init", + routes: &[], + }, + VerbRoutes { + verb: "login", + routes: &["/auth/login"], + }, + VerbRoutes { + verb: "logout", + routes: &["/auth/logout"], + }, + VerbRoutes { + verb: "mcp serve", + routes: &["/mcp"], + }, + VerbRoutes { + verb: "memory audit", + routes: &["/memory/audit"], + }, + VerbRoutes { + verb: "memory compact", + routes: &["/memory/compact"], + }, + VerbRoutes { + verb: "onboarding apply", + routes: &["/onboarding/{platform}"], + }, + VerbRoutes { + verb: "onboarding platforms", + routes: &["/onboarding/platforms"], + }, + VerbRoutes { + verb: "panic", + routes: &[], + }, + VerbRoutes { + verb: "recipe apply", + routes: &["/recipes/{id}/apply"], + }, + VerbRoutes { + verb: "recipe categories", + routes: &["/recipes/categories"], + }, + VerbRoutes { + verb: "recipe delete", + routes: &["/recipes/user/{id}"], + }, + VerbRoutes { + verb: "recipe export", + routes: &["/recipes/{id}/export"], + }, + VerbRoutes { + verb: "recipe favorite", + routes: &["/recipes/{id}/favorite"], + }, + VerbRoutes { + verb: "recipe fork", + routes: &["/recipes/{id}/fork"], + }, + VerbRoutes { + verb: "recipe get", + routes: &["/recipes/{id}"], + }, + VerbRoutes { + verb: "recipe import", + routes: &["/recipes/import"], + }, + VerbRoutes { + verb: "recipe list", + routes: &["/recipes"], + }, + VerbRoutes { + verb: "recipe pieces", + routes: &["/recipes/{id}/pieces"], + }, + VerbRoutes { + verb: "recipe preflight", + routes: &["/recipes/{id}/preflight"], + }, + VerbRoutes { + verb: "recipe preview", + routes: &["/recipes/{id}/preview"], + }, + VerbRoutes { + verb: "recipe recent", + routes: &["/recipes/{id}/recent"], + }, + VerbRoutes { + verb: "recipe render", + routes: &["/recipes/{id}/render"], + }, + VerbRoutes { + verb: "recipe save", + routes: &["/recipes/user"], + }, + VerbRoutes { + verb: "recipe test-step", + routes: &["/recipes/{id}/test-step"], + }, + VerbRoutes { + verb: "rule add", + routes: &["/rules"], + }, + VerbRoutes { + verb: "rule add-for-connector", + routes: &["/rules/connector"], + }, + VerbRoutes { + verb: "rule delete", + routes: &["/rules/{id}"], + }, + VerbRoutes { + verb: "rule for-connector", + routes: &["/rules/connector/{name}"], + }, + VerbRoutes { + verb: "rule list", + routes: &["/rules"], + }, + VerbRoutes { + verb: "rule move", + routes: &["/rules/{id}/reassign"], + }, + VerbRoutes { + verb: "rule parse", + routes: &["/rules/parse"], + }, + VerbRoutes { + verb: "rule run", + routes: &["/rules/{id}/run"], + }, + VerbRoutes { + verb: "rule schema", + routes: &["/rules/schema"], + }, + VerbRoutes { + verb: "rule toggle", + routes: &["/rules", "/rules/{id}/toggle"], + }, + VerbRoutes { + verb: "rule update", + routes: &["/rules/{id}"], + }, + VerbRoutes { + verb: "run", + routes: &[], + }, + VerbRoutes { + verb: "safety disguise", + routes: &["/safety/disguise/active"], + }, + VerbRoutes { + verb: "safety disguise-profile", + routes: &["/safety/disguise/profile"], + }, + VerbRoutes { + verb: "safety get", + routes: &["/safety"], + }, + VerbRoutes { + verb: "safety panic-taps", + routes: &["/safety/panic_tap_count"], + }, + VerbRoutes { + verb: "send", + routes: &["/send"], + }, + VerbRoutes { + verb: "server start", + routes: &[], + }, + VerbRoutes { + verb: "session list", + routes: &["/sessions"], + }, + VerbRoutes { + verb: "trace", + routes: &["/stream", "/stream/ticket"], + }, + VerbRoutes { + verb: "travel prepare", + routes: &[], + }, + VerbRoutes { + verb: "travel restore", + routes: &[], + }, + VerbRoutes { + verb: "vault duress-setup", + routes: &[], + }, + VerbRoutes { + verb: "vault unlock", + routes: &["/vault/unlock"], + }, + VerbRoutes { + verb: "workspace add", + routes: &["/workspaces"], + }, + VerbRoutes { + verb: "workspace list", + routes: &["/workspaces"], + }, + VerbRoutes { + verb: "workspace onboard", + routes: &["/workspaces/onboard"], + }, + VerbRoutes { + verb: "workspace onboard-url", + routes: &["/workspaces/onboard-url"], + }, + VerbRoutes { + verb: "workspace remove", + routes: &["/workspaces"], + }, + VerbRoutes { + verb: "workspace scan", + routes: &["/workspaces/scan"], + }, +]; + +/// The full verb path of every leaf subcommand, sorted. +/// +/// Hidden subcommands and clap's generated `help` are not part of the +/// product surface and are skipped. +pub fn verbs() -> Vec { + let mut out = Vec::new(); + collect(&Cli::command(), "", &mut out); + out.sort(); + out +} + +/// Walk one node of the clap tree, pushing leaves onto `out`. +fn collect(cmd: &clap::Command, prefix: &str, out: &mut Vec) { + let children: Vec<&clap::Command> = cmd + .get_subcommands() + .filter(|c| !c.is_hide_set() && c.get_name() != "help") + .collect(); + + if children.is_empty() { + if !prefix.is_empty() { + out.push(prefix.to_owned()); + } + return; + } + + for child in children { + let verb = if prefix.is_empty() { + child.get_name().to_owned() + } else { + format!("{prefix} {}", child.get_name()) + }; + collect(child, &verb, out); + } +} + +/// The routes declared for one verb, or `None` when it has none +/// declared — which the test below does not allow to happen. +fn routes_for(verb: &str) -> Option<&'static [&'static str]> { + VERB_ROUTES + .iter() + .find(|entry| entry.verb == verb) + .map(|entry| entry.routes) +} + +/// The dump `springtale dump-commands` prints. +pub fn dump() -> serde_json::Value { + let commands: Vec = verbs() + .into_iter() + .map(|verb| { + let routes = routes_for(&verb); + serde_json::json!({ "verb": verb, "routes": routes }) + }) + .collect(); + serde_json::json!({ "commands": commands }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verb_routes_covers_every_verb_exactly() { + let verbs = verbs(); + let missing: Vec<&String> = verbs.iter().filter(|v| routes_for(v).is_none()).collect(); + assert!( + missing.is_empty(), + "verbs with no declared routes (add them to VERB_ROUTES; an offline verb declares an empty list): {missing:?}" + ); + + let stale: Vec<&str> = VERB_ROUTES + .iter() + .map(|e| e.verb) + .filter(|v| !verbs.iter().any(|known| known == v)) + .collect(); + assert!( + stale.is_empty(), + "VERB_ROUTES entries for verbs that no longer exist: {stale:?}" + ); + } + + #[test] + fn test_declared_routes_are_absolute_paths() { + for entry in VERB_ROUTES { + for route in entry.routes { + assert!( + route.starts_with('/') && !route.contains('?'), + "{}: `{route}` is not a query-free absolute path", + entry.verb + ); + } + } + } +} diff --git a/scripts/cli-routes.sh b/scripts/cli-routes.sh index fc53abb0..61caacbb 100755 --- a/scripts/cli-routes.sh +++ b/scripts/cli-routes.sh @@ -1,28 +1,53 @@ #!/usr/bin/env sh # Print, one per line, every daemon route the command line calls. # -# `springtale --help` cannot answer this: clap emits no machine-readable -# help, and a verb name ("rally") does not carry the route it hits. The -# CLI's path literals do, and they are the same contract the plan asks -# for — a route with no CLI path literal has no command-line verb. +# The command line answers this itself. `springtale dump-commands` walks +# its own clap tree at runtime and prints every verb with the routes that +# verb calls, declared beside it in `apps/springtale-cli/src/surface.rs` +# and held to the tree by a unit test — a new subcommand cannot be added +# without saying what it talks to. # -# A literal is a route with two kinds of noise stripped, the same two -# the OpenAPI templates do not carry: +# This used to grep path literals out of the CLI sources, which answered +# a weaker question: a path in a comment counted as a verb, and a verb +# that built its path from a constant or a `match` did not count at all. # -# "/events?limit={limit}" -> /events -# "/formations/{id}/deploy" -> /formations/{}/deploy +# Holes are flattened to the shape the OpenAPI templates carry: # -# A `{hole}` is a path segment only when a `/` introduces it; a hole -# anywhere else is an interpolated query string or base URL, not a -# segment, and is dropped rather than turned into `{}`. +# /formations/{id}/deploy -> /formations/{}/deploy +# +# An empty result is a FAILURE, not a clean surface, and so is a verb +# whose routes are undeclared: both mean the dump is broken. set -eu + root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -grep -rhoE '"/[A-Za-z0-9_{}/.:?=&-]*"' "$root/apps/springtale-cli/src" \ - | tr -d '"' \ - | sed -e 's/?.*$//' \ - -e "s#/{[^}]*}#/%HOLE%#g" \ - -e "s/{[^}]*}//g" \ - -e "s/%HOLE%/{}/g" \ - -e 's#/\{1,\}$##' \ - | grep -vE '^/?$' \ + +# Ask cargo rather than trusting whatever is already in target/: a +# stale binary would answer for a command line that no longer exists. +# `cargo build` is a no-op when it is up to date. `SPRINGTALE_CLI` +# overrides for a packaged binary (CI, a release image). +bin="${SPRINGTALE_CLI:-}" +if [ -z "$bin" ]; then + cargo build -q --manifest-path "$root/Cargo.toml" -p springtale-cli >&2 + bin="$root/target/debug/springtale-cli" +fi + +dump="$("$bin" dump-commands)" + +if ! printf '%s' "$dump" | jq -e '(.commands | length) > 0' > /dev/null; then + printf 'cli-routes: the command tree came back EMPTY. That is a dump\n' >&2 + printf 'bug, not a command line with no verbs. Refusing to print.\n' >&2 + exit 1 +fi + +if ! printf '%s' "$dump" | jq -e 'all(.commands[]; .routes != null)' > /dev/null; then + printf 'cli-routes: these verbs declare no routes at all:\n' >&2 + printf '%s' "$dump" | jq -r '.commands[] | select(.routes == null) | .verb' >&2 + printf 'Declare them in apps/springtale-cli/src/surface.rs (an offline\n' >&2 + printf 'verb declares an empty list).\n' >&2 + exit 1 +fi + +printf '%s' "$dump" \ + | jq -r '.commands[].routes[]' \ + | sed -e 's#/{[^}]*}#/{}#g' -e 's#/\{1,\}$##' \ | sort -u From 418f096888c5efd772c757d3ba577eb7630dd461 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:22:49 -0700 Subject: [PATCH 16/24] cli: --json shape tests for every subcommand that emits JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALIGNMENT-PLAN 5.5, row 4: the command line had one test, so any rename, drop, or type change in a `--json` body was a silent breaking change for every script parsing it. `output::render_json` is split out of `print_json` — the exact function `--json` prints through — so tests assert the real path rather than a parallel one. The inline `serde_json::json!` bodies and the inline table closures scattered through the command modules are now named functions, which is what lets a test tie the JSON key contract to the code the CLI actually runs. 114 tests over every subcommand family with a `--json` mode: connector, rule, events, formation, approval, agent, auth, session, memory, execution, recipe, workspace, canvas, onboarding, chat, send, safety, data, doctor, fix, login, bot pairing, panic, travel, vault duress, crypto rotate, healthcheck, server, cooperation, and the redaction in `config ai get`. Each asserts the top-level key set and the type of each field, and per-item keys for list output — a renamed field fails. The pretty-print pass-through family is covered by one test that a daemon document survives `render_json` unchanged. Not covered, with reason: `init` ignores `--json` by design, `mcp serve` is a stdio JSON-RPC bridge with no `--json` path, and `recipe export`/`render` emit TOML. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/src/commands/agent.rs | 82 +++++-- apps/springtale-cli/src/commands/approval.rs | 69 +++++- apps/springtale-cli/src/commands/auth.rs | 81 +++++-- apps/springtale-cli/src/commands/author.rs | 116 +++++++-- apps/springtale-cli/src/commands/bot.rs | 45 +++- apps/springtale-cli/src/commands/canvas.rs | 69 +++++- apps/springtale-cli/src/commands/chat.rs | 38 ++- apps/springtale-cli/src/commands/config.rs | 50 ++++ apps/springtale-cli/src/commands/connector.rs | 222 ++++++++++++++---- .../src/commands/cooperation.rs | 61 ++++- apps/springtale-cli/src/commands/crypto.rs | 32 ++- apps/springtale-cli/src/commands/data.rs | 69 +++++- apps/springtale-cli/src/commands/doctor.rs | 72 ++++++ apps/springtale-cli/src/commands/events.rs | 74 ++++-- apps/springtale-cli/src/commands/execution.rs | 160 ++++++++++--- apps/springtale-cli/src/commands/fix.rs | 67 +++++- apps/springtale-cli/src/commands/formation.rs | 182 ++++++++++---- .../src/commands/healthcheck.rs | 31 ++- apps/springtale-cli/src/commands/login.rs | 76 +++++- apps/springtale-cli/src/commands/memory.rs | 96 ++++++-- .../springtale-cli/src/commands/onboarding.rs | 70 +++++- apps/springtale-cli/src/commands/panic.rs | 22 +- apps/springtale-cli/src/commands/recipe.rs | 94 ++++++-- apps/springtale-cli/src/commands/rule.rs | 132 ++++++++--- apps/springtale-cli/src/commands/safety.rs | 41 +++- apps/springtale-cli/src/commands/send.rs | 42 +++- apps/springtale-cli/src/commands/server.rs | 44 +++- apps/springtale-cli/src/commands/session.rs | 70 +++++- apps/springtale-cli/src/commands/travel.rs | 50 +++- apps/springtale-cli/src/commands/vault.rs | 38 ++- apps/springtale-cli/src/commands/workspace.rs | 46 ++++ apps/springtale-cli/src/output.rs | 113 ++++++++- 32 files changed, 2090 insertions(+), 364 deletions(-) diff --git a/apps/springtale-cli/src/commands/agent.rs b/apps/springtale-cli/src/commands/agent.rs index 91cf5855..fcfe172d 100644 --- a/apps/springtale-cli/src/commands/agent.rs +++ b/apps/springtale-cli/src/commands/agent.rs @@ -13,20 +13,7 @@ pub async fn run(action: AgentAction, json_out: bool) -> Result<()> { match action { AgentAction::States => { let body: Value = client.get("/agents/states").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "agents") - .iter() - .map(|a| { - vec![ - output::cell(a, "name"), - output::cell(a, "activity"), - output::cell(a, "autonomy"), - output::cell(a, "connector_name"), - ] - }) - .collect(); - output::rows_table(&["NAME", "ACTIVITY", "AUTONOMY", "CONNECTOR"], rows) - })?; + output::emit(json_out, &body, agents_table)?; } AgentAction::StepAutonomy { name, direction } => { let body: Value = client @@ -58,3 +45,70 @@ pub async fn run(action: AgentAction, json_out: bool) -> Result<()> { } Ok(()) } + +/// The `agent states` table — one row per agent the daemon reports. +fn agents_table(v: &Value) -> String { + let rows = output::array(v, "agents") + .iter() + .map(|a| { + vec![ + output::cell(a, "name"), + output::cell(a, "activity"), + output::cell(a, "autonomy"), + output::cell(a, "connector_name"), + ] + }) + .collect(); + output::rows_table(&["NAME", "ACTIVITY", "AUTONOMY", "CONNECTOR"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + /// A `GET /agents/states` body, as the daemon answers it. + fn states() -> Value { + json!({ + "agents": [{ + "name": "nightly-digest", + "activity": "firing", + "autonomy": "suggest", + "connector_name": "telegram", + }] + }) + } + + #[test] + fn test_agent_states_json_shape_is_an_agents_envelope() { + let out = json_value(&states()); + assert_eq!(key_set(&out), ["agents"]); + assert!(out["agents"].is_array()); + let agent = &out["agents"][0]; + assert!(agent["name"].is_string()); + assert!(agent["activity"].is_string()); + assert!(agent["autonomy"].is_string()); + assert!(agent["connector_name"].is_string()); + } + + #[test] + fn test_agents_table_reads_every_field_the_json_shape_promises() { + let table = agents_table(&states()); + for want in ["NAME", "nightly-digest", "firing", "suggest", "telegram"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_agents_table_is_empty_for_an_empty_roster() { + assert_eq!(agents_table(&json!({ "agents": [] })), ""); + } + + #[test] + fn test_agent_autonomy_json_shape_carries_the_new_level() { + // `agent step-autonomy` / `set-autonomy` echo the daemon ack. + let out = json_value(&json!({ "level": "approve" })); + assert_eq!(key_set(&out), ["level"]); + assert!(out["level"].is_string()); + } +} diff --git a/apps/springtale-cli/src/commands/approval.rs b/apps/springtale-cli/src/commands/approval.rs index a8cef999..77f39900 100644 --- a/apps/springtale-cli/src/commands/approval.rs +++ b/apps/springtale-cli/src/commands/approval.rs @@ -13,19 +13,7 @@ pub async fn run(action: ApprovalAction, json_out: bool) -> Result<()> { match action { ApprovalAction::List => { let body: Value = client.get("/approvals").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "pending") - .iter() - .map(|p| { - vec![ - output::cell(p, "id"), - output::cell(p, "capability"), - output::cell(p, "requested_at"), - ] - }) - .collect(); - output::rows_table(&["ID", "CAPABILITY", "REQUESTED"], rows) - })?; + output::emit(json_out, &body, pending_table)?; } ApprovalAction::Approve { id, reason } => { resolve(&client, json_out, &id, "approve", reason).await?; @@ -52,3 +40,58 @@ async fn resolve( .await?; output::emit(json_out, &body, |_| format!("{id}: {decision}d")) } + +/// The `approval list` table — one row per pending request. +fn pending_table(v: &Value) -> String { + let rows = output::array(v, "pending") + .iter() + .map(|p| { + vec![ + output::cell(p, "id"), + output::cell(p, "capability"), + output::cell(p, "requested_at"), + ] + }) + .collect(); + output::rows_table(&["ID", "CAPABILITY", "REQUESTED"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn queue() -> Value { + json!({ + "pending": [{ + "id": "ap-1", + "capability": "ShellExec", + "requested_at": "2026-09-04T10:00:00Z", + }] + }) + } + + #[test] + fn test_approval_list_json_shape_is_a_pending_envelope() { + let out = json_value(&queue()); + assert_eq!(key_set(&out), ["pending"]); + assert!(out["pending"].is_array()); + let item = &out["pending"][0]; + assert!(item["id"].is_string()); + assert!(item["capability"].is_string()); + assert!(item["requested_at"].is_string()); + } + + #[test] + fn test_pending_table_reads_every_field_the_json_shape_promises() { + let table = pending_table(&queue()); + for want in ["ID", "ap-1", "ShellExec", "2026-09-04T10:00:00Z"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_pending_table_is_empty_when_nothing_is_queued() { + assert_eq!(pending_table(&json!({ "pending": [] })), ""); + } +} diff --git a/apps/springtale-cli/src/commands/auth.rs b/apps/springtale-cli/src/commands/auth.rs index 6fa3ae3d..270d4d1b 100644 --- a/apps/springtale-cli/src/commands/auth.rs +++ b/apps/springtale-cli/src/commands/auth.rs @@ -16,20 +16,7 @@ pub async fn run(action: AuthAction, json_out: bool) -> Result<()> { match action { AuthAction::Tokens => { let body: Value = client.get("/auth/tokens").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "tokens") - .iter() - .map(|t| { - vec![ - output::cell(t, "id"), - output::cell(t, "name"), - output::cell(t, "created_at"), - output::cell(t, "last_used_at"), - ] - }) - .collect(); - output::rows_table(&["ID", "NAME", "CREATED", "LAST USED"], rows) - })?; + output::emit(json_out, &body, tokens_table)?; } AuthAction::Revoke { id } => { let body: Value = client.delete(&format!("/auth/tokens/{id}")).await?; @@ -38,3 +25,69 @@ pub async fn run(action: AuthAction, json_out: bool) -> Result<()> { } Ok(()) } + +/// The `auth tokens` table — one row per issued API token. +fn tokens_table(v: &Value) -> String { + let rows = output::array(v, "tokens") + .iter() + .map(|t| { + vec![ + output::cell(t, "id"), + output::cell(t, "name"), + output::cell(t, "created_at"), + output::cell(t, "last_used_at"), + ] + }) + .collect(); + output::rows_table(&["ID", "NAME", "CREATED", "LAST USED"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + use serde_json::json; + + fn tokens() -> Value { + json!({ + "tokens": [{ + "id": "tok-1", + "name": "springtale-cli@laptop", + "created_at": "2026-09-01T09:00:00Z", + "last_used_at": "2026-09-04T08:30:00Z", + }] + }) + } + + #[test] + fn test_auth_tokens_json_shape_is_a_tokens_envelope_without_secrets() { + let out = json_value(&tokens()); + assert_eq!(key_set(&out), ["tokens"]); + assert!(out["tokens"].is_array()); + let token = &out["tokens"][0]; + assert!(token["id"].is_string()); + assert!(token["name"].is_string()); + assert!(token["created_at"].is_string()); + assert!(token["last_used_at"].is_string()); + // The token material itself is never listed. + assert!(token.get("token").is_none()); + } + + #[test] + fn test_tokens_table_reads_every_field_the_json_shape_promises() { + let table = tokens_table(&tokens()); + for want in [ + "ID", + "tok-1", + "springtale-cli@laptop", + "2026-09-04T08:30:00Z", + ] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_tokens_table_is_empty_when_no_token_exists() { + assert_eq!(tokens_table(&json!({ "tokens": [] })), ""); + } +} diff --git a/apps/springtale-cli/src/commands/author.rs b/apps/springtale-cli/src/commands/author.rs index e81e7f61..9ca747df 100644 --- a/apps/springtale-cli/src/commands/author.rs +++ b/apps/springtale-cli/src/commands/author.rs @@ -60,7 +60,7 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let added = serde_json::json!({ "name": name, "pubkey": pubkey_hex }); + let added = author_body(&name, &pubkey_hex); output::emit(json, &added, |v| { format!( "Trusted author added: {}\n pubkey: {}", @@ -74,26 +74,8 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res .list_config() .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let rows: Vec = configs - .into_iter() - .filter_map(|(key, value)| { - let name = key.strip_prefix(TRUSTED_AUTHOR_PREFIX)?; - let data: serde_json::Value = serde_json::from_str(&value).ok()?; - Some(AuthorTableRow { - name: name.to_owned(), - pubkey: data - .get("pubkey") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(), - }) - }) - .collect(); - - let authors: Vec = rows - .iter() - .map(|r| serde_json::json!({ "name": r.name, "pubkey": r.pubkey })) - .collect(); + let rows = author_rows(configs); + let authors = authors_json(&rows); output::emit(json, &authors, |_| { if rows.is_empty() { "No trusted authors.".to_owned() @@ -108,7 +90,7 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res .delete_config(&key) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let removed = serde_json::json!({ "name": name, "removed": true }); + let removed = removed_body(&name); output::emit(json, &removed, |v| { format!("Removed trusted author: {}", output::cell(v, "name")) })?; @@ -144,3 +126,93 @@ pub fn load_local_identity() -> Result { Keypair::from_secret_bytes(bytes).context("identity in vault is not a valid Ed25519 key") } + +/// Turn the config rows into author rows, dropping everything that is +/// not a `trusted-author:` entry. +fn author_rows(configs: Vec<(String, String)>) -> Vec { + configs + .into_iter() + .filter_map(|(key, value)| { + let name = key.strip_prefix(TRUSTED_AUTHOR_PREFIX)?; + let data: serde_json::Value = serde_json::from_str(&value).ok()?; + Some(AuthorTableRow { + name: name.to_owned(), + pubkey: data + .get("pubkey") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(), + }) + }) + .collect() +} + +/// The `author list` body — a bare array, one object per author. +fn authors_json(rows: &[AuthorTableRow]) -> Vec { + rows.iter() + .map(|r| serde_json::json!({ "name": r.name, "pubkey": r.pubkey })) + .collect() +} + +/// The `author add` body. +fn author_body(name: &str, pubkey_hex: &str) -> serde_json::Value { + serde_json::json!({ "name": name, "pubkey": pubkey_hex }) +} + +/// The `author remove` body. +fn removed_body(name: &str) -> serde_json::Value { + serde_json::json!({ "name": name, "removed": true }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn configs() -> Vec<(String, String)> { + vec![ + ( + "trusted-author:kali".to_owned(), + r#"{"pubkey":"aa11"}"#.to_owned(), + ), + // Not an author entry — must not reach the output. + ("heartbeat".to_owned(), r#"{"interval":30}"#.to_owned()), + ] + } + + #[test] + fn test_author_list_json_shape_is_a_bare_array_of_name_and_pubkey() { + let out = json_value(&authors_json(&author_rows(configs()))); + assert!(out.is_array(), "authors are not wrapped in an envelope"); + assert_eq!(out.as_array().expect("array").len(), 1); + let author = &out[0]; + assert_eq!(key_set(author), ["name", "pubkey"]); + assert!(author["name"].is_string()); + assert!(author["pubkey"].is_string()); + assert_eq!(author["name"], "kali"); + assert_eq!(author["pubkey"], "aa11"); + } + + #[test] + fn test_author_list_json_is_empty_when_no_author_is_trusted() { + let out = json_value(&authors_json(&author_rows(Vec::new()))); + assert_eq!(out, serde_json::json!([])); + } + + #[test] + fn test_author_add_json_shape_names_the_author_and_its_key() { + let out = json_value(&author_body("kali", "aa11")); + assert_eq!(key_set(&out), ["name", "pubkey"]); + assert!(out["name"].is_string()); + assert!(out["pubkey"].is_string()); + } + + #[test] + fn test_author_remove_json_shape_names_the_author_and_the_flag() { + let out = json_value(&removed_body("kali")); + assert_eq!(key_set(&out), ["name", "removed"]); + assert_eq!(out["name"], "kali"); + assert!(out["removed"].is_boolean()); + assert_eq!(out["removed"], true); + } +} diff --git a/apps/springtale-cli/src/commands/bot.rs b/apps/springtale-cli/src/commands/bot.rs index 7b0f2904..40615a09 100644 --- a/apps/springtale-cli/src/commands/bot.rs +++ b/apps/springtale-cli/src/commands/bot.rs @@ -18,7 +18,7 @@ pub async fn pair_init(opts: &PassphraseOpts, json_out: bool) -> Result<()> { .await .context("failed to generate pairing code")?; - let body = serde_json::json!({ "pairing_code": code, "single_use": true }); + let body = pair_init_body(&code); output::emit(json_out, &body, |v| { format!( "Pairing code (give this to the user, do NOT send via chat):\n\n {}\n\nThe user types this code into their chat with the bot.\nCode expires in 10 minutes. Single-use.", @@ -33,7 +33,7 @@ pub async fn panic_unpair(opts: &PassphraseOpts, json_out: bool) -> Result<()> { .await .context("failed to revoke paired users")?; - let body = serde_json::json!({ "removed": removed }); + let body = unpair_body(removed); output::emit(json_out, &body, |_| { let tail = if removed > 0 { "All users must re-pair to regain access." @@ -116,3 +116,44 @@ pub async fn settings(action: BotSettingsAction, json_out: bool) -> Result<()> { } Ok(()) } + +/// The `bot pair-init` body — the code the operator reads out, plus the +/// single-use contract it comes with. +fn pair_init_body(code: &str) -> serde_json::Value { + serde_json::json!({ "pairing_code": code, "single_use": true }) +} + +/// The `bot panic-unpair` body — how many pairing rows were revoked. +fn unpair_body(removed: u32) -> serde_json::Value { + serde_json::json!({ "removed": removed }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_bot_pair_init_json_shape_names_the_code_and_single_use() { + let out = json_value(&pair_init_body("TRUE-BADGER-9142")); + assert_eq!(key_set(&out), ["pairing_code", "single_use"]); + assert!(out["pairing_code"].is_string()); + assert_eq!(out["pairing_code"], "TRUE-BADGER-9142"); + assert!(out["single_use"].is_boolean()); + assert_eq!(out["single_use"], true); + } + + #[test] + fn test_bot_panic_unpair_json_shape_is_a_removed_count() { + let out = json_value(&unpair_body(3)); + assert_eq!(key_set(&out), ["removed"]); + assert!(out["removed"].is_number()); + assert_eq!(out["removed"], 3); + } + + #[test] + fn test_bot_panic_unpair_json_reports_zero_rather_than_omitting_it() { + let out = json_value(&unpair_body(0)); + assert_eq!(out["removed"], 0); + } +} diff --git a/apps/springtale-cli/src/commands/canvas.rs b/apps/springtale-cli/src/commands/canvas.rs index 726ef150..f4b0a1e9 100644 --- a/apps/springtale-cli/src/commands/canvas.rs +++ b/apps/springtale-cli/src/commands/canvas.rs @@ -15,19 +15,7 @@ pub async fn run(stream: bool, connections: bool, json_out: bool) -> Result<()> let client = Client::from_config()?; if connections { let body: Value = client.get("/canvas/connections").await?; - return output::emit(json_out, &body, |v| { - let rows = output::array(v, "connections") - .iter() - .map(|c| { - vec![ - output::cell(c, "a"), - output::cell(c, "b"), - output::array(c, "pipes").len().to_string(), - ] - }) - .collect(); - output::rows_table(&["FROM", "TO", "PIPES"], rows) - }); + return output::emit(json_out, &body, connections_table); } if !stream { let body: Value = client.get("/canvas").await?; @@ -78,3 +66,58 @@ async fn follow(response: reqwest::Response, json_out: bool) -> Result<()> { } Ok(()) } + +/// The `canvas --connections` table — one row per pipe pair. +fn connections_table(v: &Value) -> String { + let rows = output::array(v, "connections") + .iter() + .map(|c| { + vec![ + output::cell(c, "a"), + output::cell(c, "b"), + output::array(c, "pipes").len().to_string(), + ] + }) + .collect(); + output::rows_table(&["FROM", "TO", "PIPES"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn connections() -> Value { + json!({ + "connections": [{ + "a": "telegram", + "b": "github", + "pipes": [{ "rule_id": "r-1" }, { "rule_id": "r-2" }], + }] + }) + } + + #[test] + fn test_canvas_connections_json_shape_is_a_connections_envelope() { + let out = json_value(&connections()); + assert_eq!(key_set(&out), ["connections"]); + assert!(out["connections"].is_array()); + let edge = &out["connections"][0]; + assert!(edge["a"].is_string()); + assert!(edge["b"].is_string()); + assert!(edge["pipes"].is_array()); + } + + #[test] + fn test_connections_table_reads_every_field_the_json_shape_promises() { + let table = connections_table(&connections()); + for want in ["FROM", "telegram", "github", "2"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_connections_table_is_empty_for_a_colony_with_no_pipes() { + assert_eq!(connections_table(&json!({ "connections": [] })), ""); + } +} diff --git a/apps/springtale-cli/src/commands/chat.rs b/apps/springtale-cli/src/commands/chat.rs index 57a709c0..cc526ed9 100644 --- a/apps/springtale-cli/src/commands/chat.rs +++ b/apps/springtale-cli/src/commands/chat.rs @@ -12,11 +12,35 @@ pub async fn run(message: String, session: Option, json_out: bool) -> Re let body: Value = client .post("/chat", &json!({ "text": message, "session": session })) .await?; - output::emit(json_out, &body, |v| { - format!( - "{} (session {})", - output::cell(v, "status"), - output::cell(v, "session") - ) - }) + output::emit(json_out, &body, chat_line) +} + +/// The `chat` acknowledgement line. +fn chat_line(v: &Value) -> String { + format!( + "{} (session {})", + output::cell(v, "status"), + output::cell(v, "session") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_chat_json_shape_has_status_and_session() { + let body = json!({ "status": "queued", "session": "s-1" }); + let out = json_value(&body); + assert_eq!(key_set(&out), ["session", "status"]); + assert!(out["status"].is_string()); + assert!(out["session"].is_string()); + assert_eq!(chat_line(&body), "queued (session s-1)"); + } + + #[test] + fn test_chat_line_leaves_missing_fields_blank_rather_than_panicking() { + assert_eq!(chat_line(&json!({})), " (session )"); + } } diff --git a/apps/springtale-cli/src/commands/config.rs b/apps/springtale-cli/src/commands/config.rs index 4bf0d291..b01e4c34 100644 --- a/apps/springtale-cli/src/commands/config.rs +++ b/apps/springtale-cli/src/commands/config.rs @@ -139,3 +139,53 @@ fn redact(mut value: Value) -> Value { } value } + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_config_ai_get_json_shape_keeps_the_value_document() { + let body = json!({ + "key": AI_COLONY_KEY, + "value": { "type": "ollama", "model": "llama3", "base_url": "http://localhost:11434" }, + }); + let out = json_value(&redact(body)); + assert_eq!(key_set(&out), ["key", "value"]); + assert!(out["key"].is_string()); + assert!(out["value"].is_object()); + assert_eq!(out["value"]["type"], "ollama"); + assert_eq!(out["value"]["model"], "llama3"); + } + + #[test] + fn test_config_ai_get_json_redacts_a_stored_api_key() { + let body = json!({ + "key": "ai.colony", + "value": { "type": "anthropic", "api_key": "sk-secret-value" }, + }); + let out = json_value(&redact(body)); + assert_eq!(out["value"]["api_key"], ""); + assert!(!render_json_text(&out).contains("sk-secret-value")); + } + + #[test] + fn test_config_ai_get_json_leaves_a_missing_api_key_absent() { + let body = json!({ "key": "ai.colony", "value": { "type": "noop" } }); + let out = json_value(&redact(body)); + assert!(out["value"].get("api_key").is_none()); + } + + #[test] + fn test_config_ai_get_json_unset_level_keeps_a_null_value() { + let body = json!({ "key": "ai.formation.f-1", "value": Value::Null }); + let out = json_value(&redact(body)); + assert_eq!(key_set(&out), ["key", "value"]); + assert!(out["value"].is_null()); + } + + fn render_json_text(v: &Value) -> String { + crate::output::render_json(v).expect("render") + } +} diff --git a/apps/springtale-cli/src/commands/connector.rs b/apps/springtale-cli/src/commands/connector.rs index 2eb4d5fe..0aa6acc3 100644 --- a/apps/springtale-cli/src/commands/connector.rs +++ b/apps/springtale-cli/src/commands/connector.rs @@ -23,19 +23,7 @@ pub async fn run(action: ConnectorAction, json_out: bool) -> Result<()> { match action { ConnectorAction::List => { let body: Value = client.get("/connectors").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "connectors") - .iter() - .map(|c| { - vec![ - output::cell(c, "name"), - output::cell(c, "version"), - output::cell(c, "enabled"), - ] - }) - .collect(); - output::rows_table(&["NAME", "VERSION", "ENABLED"], rows) - })?; + output::emit(json_out, &body, connectors_table)?; } ConnectorAction::Enable { name } => { let body: Value = client @@ -66,19 +54,7 @@ pub async fn run(action: ConnectorAction, json_out: bool) -> Result<()> { } ConnectorAction::Available => { let body: Value = client.get("/connectors/available").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "available") - .iter() - .map(|c| { - vec![ - output::cell(c, "name"), - output::cell(c, "label"), - output::cell(c, "installed"), - ] - }) - .collect(); - output::rows_table(&["NAME", "LABEL", "INSTALLED"], rows) - })?; + output::emit(json_out, &body, available_table)?; } ConnectorAction::Schemas => { let body: Value = client.get("/connectors/schemas").await?; @@ -137,19 +113,7 @@ pub async fn run(action: ConnectorAction, json_out: bool) -> Result<()> { let body: Value = client .get(&format!("/connectors/{name}/outputs?limit={limit}")) .await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "outputs") - .iter() - .map(|o| { - vec![ - output::cell(o, "created_at"), - output::cell(o, "action"), - output::cell(o, "summary"), - ] - }) - .collect(); - output::rows_table(&["WHEN", "ACTION", "SUMMARY"], rows) - })?; + output::emit(json_out, &body, outputs_table)?; } ConnectorAction::Reload { name } => { let body: Value = client @@ -265,12 +229,7 @@ fn sign(path: &std::path::Path, json_out: bool) -> Result<()> { .map_err(|e| anyhow::anyhow!("failed to write manifest at {}: {e}", path.display()))?; let pubkey_hex = hex::encode(keypair.verifying_key().to_bytes()); - let body = json!({ - "path": path.display().to_string(), - "author": manifest.author, - "pubkey": pubkey_hex, - "signature": signature, - }); + let body = signed_body(path, &manifest.author, &pubkey_hex, &signature); output::emit(json_out, &body, |v| { let author = output::cell(v, "author"); format!( @@ -281,3 +240,176 @@ fn sign(path: &std::path::Path, json_out: bool) -> Result<()> { ) }) } + +/// The `connector list` table — one row per installed connector. +fn connectors_table(v: &Value) -> String { + let rows = output::array(v, "connectors") + .iter() + .map(|c| { + vec![ + output::cell(c, "name"), + output::cell(c, "version"), + output::cell(c, "enabled"), + ] + }) + .collect(); + output::rows_table(&["NAME", "VERSION", "ENABLED"], rows) +} + +/// The `connector available` table — one row per offered connector. +fn available_table(v: &Value) -> String { + let rows = output::array(v, "available") + .iter() + .map(|c| { + vec![ + output::cell(c, "name"), + output::cell(c, "label"), + output::cell(c, "installed"), + ] + }) + .collect(); + output::rows_table(&["NAME", "LABEL", "INSTALLED"], rows) +} + +/// The `connector outputs` table — one row per recorded action output. +fn outputs_table(v: &Value) -> String { + let rows = output::array(v, "outputs") + .iter() + .map(|o| { + vec![ + output::cell(o, "created_at"), + output::cell(o, "action"), + output::cell(o, "summary"), + ] + }) + .collect(); + output::rows_table(&["WHEN", "ACTION", "SUMMARY"], rows) +} + +/// The `connector sign` body — what was signed, by whom, with what. +fn signed_body(path: &std::path::Path, author: &str, pubkey_hex: &str, signature: &str) -> Value { + json!({ + "path": path.display().to_string(), + "author": author, + "pubkey": pubkey_hex, + "signature": signature, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn installed() -> Value { + json!({ + "connectors": [{ "name": "telegram", "version": "0.1.0", "enabled": true }] + }) + } + + fn available() -> Value { + json!({ + "available": [{ "name": "github", "label": "GitHub", "installed": false }] + }) + } + + fn outputs() -> Value { + json!({ + "outputs": [{ + "created_at": "2026-09-04T10:00:00Z", + "action": "send_message", + "summary": "sent 1 message", + }] + }) + } + + #[test] + fn test_connector_list_json_shape_is_a_connectors_envelope() { + let out = json_value(&installed()); + assert_eq!(key_set(&out), ["connectors"]); + assert!(out["connectors"].is_array()); + let connector = &out["connectors"][0]; + assert!(connector["name"].is_string()); + assert!(connector["version"].is_string()); + assert!(connector["enabled"].is_boolean()); + } + + #[test] + fn test_connector_available_json_shape_is_an_available_envelope() { + let out = json_value(&available()); + assert_eq!(key_set(&out), ["available"]); + let item = &out["available"][0]; + assert!(item["name"].is_string()); + assert!(item["label"].is_string()); + assert!(item["installed"].is_boolean()); + } + + #[test] + fn test_connector_outputs_json_shape_is_an_outputs_envelope() { + let out = json_value(&outputs()); + assert_eq!(key_set(&out), ["outputs"]); + let item = &out["outputs"][0]; + assert!(item["created_at"].is_string()); + assert!(item["action"].is_string()); + assert!(item["summary"].is_string()); + } + + #[test] + fn test_connector_tables_read_every_field_the_json_shapes_promise() { + let list = connectors_table(&installed()); + for want in ["NAME", "telegram", "0.1.0", "true"] { + assert!(list.contains(want), "list table lost {want}:\n{list}"); + } + let avail = available_table(&available()); + for want in ["LABEL", "github", "GitHub", "false"] { + assert!( + avail.contains(want), + "available table lost {want}:\n{avail}" + ); + } + let outs = outputs_table(&outputs()); + for want in ["SUMMARY", "send_message", "sent 1 message"] { + assert!(outs.contains(want), "outputs table lost {want}:\n{outs}"); + } + } + + #[test] + fn test_connector_tables_are_empty_for_empty_envelopes() { + assert_eq!(connectors_table(&json!({ "connectors": [] })), ""); + assert_eq!(available_table(&json!({ "available": [] })), ""); + assert_eq!(outputs_table(&json!({ "outputs": [] })), ""); + } + + #[test] + fn test_connector_sign_json_shape_names_path_author_pubkey_signature() { + let body = signed_body( + std::path::Path::new("/tmp/connector-telegram.toml"), + "kali", + "ab".repeat(32).as_str(), + "c0ffee", + ); + let out = json_value(&body); + assert_eq!(key_set(&out), ["author", "path", "pubkey", "signature"]); + assert_eq!(out["path"], "/tmp/connector-telegram.toml"); + assert_eq!(out["author"], "kali"); + assert!(out["pubkey"].is_string()); + assert_eq!(out["signature"], "c0ffee"); + } + + #[test] + fn test_connector_ack_json_shapes_carry_the_keys_the_notices_read() { + let installed = json_value(&json!({ "installed": "telegram" })); + assert_eq!(key_set(&installed), ["installed"]); + assert!(installed["installed"].is_string()); + + let setup = json_value(&json!({ "name": "telegram" })); + assert_eq!(key_set(&setup), ["name"]); + + let upsert = json_value(&json!({ "is_new": true })); + assert!(upsert["is_new"].is_boolean()); + + let cascade = json_value(&json!({ "rules_deleted": ["r-1", "r-2"] })); + assert!(cascade["rules_deleted"].is_array()); + assert_eq!(output::array(&cascade, "rules_deleted").len(), 2); + } +} diff --git a/apps/springtale-cli/src/commands/cooperation.rs b/apps/springtale-cli/src/commands/cooperation.rs index ed78d136..f98eeaa1 100644 --- a/apps/springtale-cli/src/commands/cooperation.rs +++ b/apps/springtale-cli/src/commands/cooperation.rs @@ -56,11 +56,8 @@ pub fn glyphs(check: Option<&Path>, json_out: bool) -> Result<()> { // The plain listing is `pyftsubset --unicodes-file` input, so the // human form stays one bare `U+XXXX` per line; `--json` wraps the // same list in an envelope for anything that wants to parse it. - let listed: Vec = cps - .iter() - .map(|c| format!("U+{:04X}", u32::from(*c))) - .collect(); - let body = serde_json::json!({ "codepoints": &listed }); + let listed = codepoint_labels(&cps); + let body = glyphs_body(&listed); output::emit(json_out, &body, |_| listed.join("\n")) } @@ -124,3 +121,57 @@ fn check_against(path: &Path, cps: &BTreeSet) -> Result<()> { Err(anyhow!("glyph check failed:\n {}", problems.join("\n "))) } } + +/// `U+XXXX` labels for every codepoint, in codepoint order. +fn codepoint_labels(cps: &BTreeSet) -> Vec { + cps.iter() + .map(|c| format!("U+{:04X}", u32::from(*c))) + .collect() +} + +/// The `cooperation glyphs` body — the same list the human form prints +/// one per line, wrapped so it can be parsed. +fn glyphs_body(listed: &[String]) -> serde_json::Value { + serde_json::json!({ "codepoints": listed }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_glyphs_json_shape_is_a_codepoints_array_of_strings() { + let listed = codepoint_labels(&all_codepoints()); + let out = json_value(&glyphs_body(&listed)); + assert_eq!(key_set(&out), ["codepoints"]); + assert!(out["codepoints"].is_array()); + let items = crate::output::array(&out, "codepoints"); + assert!(!items.is_empty(), "the def table renders no glyphs"); + for item in items { + let label = item.as_str().expect("codepoints are strings"); + assert!(label.starts_with("U+"), "not a codepoint label: {label}"); + assert!( + u32::from_str_radix(&label[2..], 16).is_ok(), + "not hex: {label}" + ); + } + } + + #[test] + fn test_glyphs_json_lists_the_same_codepoints_the_human_form_prints() { + let cps = all_codepoints(); + let listed = codepoint_labels(&cps); + assert_eq!(listed.len(), cps.len()); + let out = json_value(&glyphs_body(&listed)); + assert_eq!(crate::output::array(&out, "codepoints").len(), cps.len()); + } + + #[test] + fn test_codepoint_labels_are_four_digit_uppercase_hex() { + let mut cps = BTreeSet::new(); + cps.insert('\u{e0b0}'); + cps.insert('A'); + assert_eq!(codepoint_labels(&cps), ["U+0041", "U+E0B0"]); + } +} diff --git a/apps/springtale-cli/src/commands/crypto.rs b/apps/springtale-cli/src/commands/crypto.rs index baf0ff22..231a7da7 100644 --- a/apps/springtale-cli/src/commands/crypto.rs +++ b/apps/springtale-cli/src/commands/crypto.rs @@ -60,8 +60,38 @@ pub fn rotate_vault_key(json_out: bool) -> Result<()> { new_vault.save().context("failed to save new vault")?; - let body = serde_json::json!({ "rotated": true, "entries": keys.len() }); + let body = rotated_body(keys.len()); output::emit_status(json_out, &body, |_| { "Vault key rotated successfully.".to_owned() }) } + +/// The `crypto rotate-vault-key` body — how many entries were carried +/// into the re-encrypted vault. Never the keys themselves. +fn rotated_body(entries: usize) -> serde_json::Value { + serde_json::json!({ "rotated": true, "entries": entries }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_crypto_rotate_json_shape_names_rotated_and_entry_count() { + let out = json_value(&rotated_body(7)); + assert_eq!(key_set(&out), ["entries", "rotated"]); + assert!(out["rotated"].is_boolean()); + assert_eq!(out["rotated"], true); + assert!(out["entries"].is_number()); + assert_eq!(out["entries"], 7); + } + + #[test] + fn test_crypto_rotate_json_carries_no_key_material() { + let out = json_value(&rotated_body(0)); + for leaky in ["passphrase", "key", "keys", "vault_key"] { + assert!(out.get(leaky).is_none(), "{leaky} must not be emitted"); + } + } +} diff --git a/apps/springtale-cli/src/commands/data.rs b/apps/springtale-cli/src/commands/data.rs index c5595765..a4001449 100644 --- a/apps/springtale-cli/src/commands/data.rs +++ b/apps/springtale-cli/src/commands/data.rs @@ -28,7 +28,7 @@ pub async fn run(action: DataAction, json_out: bool) -> Result<()> { .open(&path)?; let mut writer = std::io::BufWriter::new(file); writer.write_all(serde_json::to_string_pretty(&data)?.as_bytes())?; - let done = json!({ "exported_to": path.display().to_string() }); + let done = exported_body(&path); output::emit_status(json_out, &done, |v| { format!("Exported to: {}", output::cell(v, "exported_to")) })?; @@ -46,12 +46,7 @@ pub async fn run(action: DataAction, json_out: bool) -> Result<()> { let export: Value = serde_json::from_str(&text) .map_err(|e| anyhow::anyhow!("invalid export file: {e}"))?; let stats: Value = client.post("/data/import", &export).await?; - output::emit_status(json_out, &stats, |v| { - format!( - "Imported: {} rules, {} connectors, {} events", - v["rules_inserted"], v["connectors_inserted"], v["events_inserted"] - ) - })?; + output::emit_status(json_out, &stats, import_line)?; } DataAction::Purge { yes } => { // Irreversible. The flag is required here and the route @@ -72,3 +67,63 @@ pub async fn run(action: DataAction, json_out: bool) -> Result<()> { } Ok(()) } + +/// The `data export --output` body — the export itself went to the +/// file, so `--json` reports where it landed. +fn exported_body(path: &std::path::Path) -> Value { + json!({ "exported_to": path.display().to_string() }) +} + +/// The `data import` notice, read off the daemon's insert counts. +fn import_line(v: &Value) -> String { + format!( + "Imported: {} rules, {} connectors, {} events", + v["rules_inserted"], v["connectors_inserted"], v["events_inserted"] + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_data_export_to_file_json_shape_names_the_destination() { + let out = json_value(&exported_body(std::path::Path::new("/tmp/export.json"))); + assert_eq!(key_set(&out), ["exported_to"]); + assert!(out["exported_to"].is_string()); + assert_eq!(out["exported_to"], "/tmp/export.json"); + } + + #[test] + fn test_data_export_to_stdout_json_is_the_export_document_itself() { + // No envelope: the export *is* the payload. + let export = json!({ + "rules": [{ "id": "r-1" }], + "connectors": [{ "name": "telegram" }], + "events": [], + }); + assert_eq!(json_value(&export), export); + } + + #[test] + fn test_data_import_json_shape_reports_three_insert_counts() { + let stats = json!({ + "rules_inserted": 2, + "connectors_inserted": 1, + "events_inserted": 40, + }); + let out = json_value(&stats); + assert_eq!( + key_set(&out), + ["connectors_inserted", "events_inserted", "rules_inserted"] + ); + assert!(out["rules_inserted"].is_number()); + assert!(out["connectors_inserted"].is_number()); + assert!(out["events_inserted"].is_number()); + assert_eq!( + import_line(&stats), + "Imported: 2 rules, 1 connectors, 40 events" + ); + } +} diff --git a/apps/springtale-cli/src/commands/doctor.rs b/apps/springtale-cli/src/commands/doctor.rs index 84b7f9a8..ead50fb9 100644 --- a/apps/springtale-cli/src/commands/doctor.rs +++ b/apps/springtale-cli/src/commands/doctor.rs @@ -63,3 +63,75 @@ fn render_check(check: &Check) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn report() -> Report { + Report { + checks: vec![ + Check { + id: "config.exists", + label: "Config file present".to_owned(), + severity: Severity::Ok, + detail: None, + fix_hint: None, + }, + Check { + id: "vault.exists", + label: "Vault present".to_owned(), + severity: Severity::Fail, + detail: Some("no vault at ~/.springtale/vault.age".to_owned()), + fix_hint: Some("run `springtale init`".to_owned()), + }, + ], + } + } + + #[test] + fn test_doctor_json_shape_is_a_checks_envelope() { + let out = json_value(&report()); + assert_eq!(key_set(&out), ["checks"]); + assert!(out["checks"].is_array()); + assert_eq!(crate::output::array(&out, "checks").len(), 2); + } + + #[test] + fn test_doctor_check_json_shape_carries_all_five_fields() { + let out = json_value(&report()); + let failing = &out["checks"][1]; + assert_eq!( + key_set(failing), + ["detail", "fix_hint", "id", "label", "severity"] + ); + assert!(failing["id"].is_string()); + assert!(failing["label"].is_string()); + assert!(failing["severity"].is_string()); + assert!(failing["detail"].is_string()); + assert!(failing["fix_hint"].is_string()); + } + + #[test] + fn test_doctor_severity_serializes_lowercase_and_nulls_stay_present() { + let out = json_value(&report()); + assert_eq!(out["checks"][0]["severity"], "ok"); + assert_eq!(out["checks"][1]["severity"], "fail"); + // An unset detail is null, not a missing key: a consumer can + // index it without guessing. + assert!(out["checks"][0]["detail"].is_null()); + assert!(out["checks"][0]["fix_hint"].is_null()); + assert_eq!(key_set(&out["checks"][0]).len(), 5); + } + + #[test] + fn test_doctor_human_render_reports_the_same_issue_count() { + let report = report(); + assert_eq!(report.issue_count(), 1); + let text = render(&report); + assert!(text.contains("[OK]")); + assert!(text.contains("[FAIL] Vault present")); + assert!(text.contains("1 issue found")); + } +} diff --git a/apps/springtale-cli/src/commands/events.rs b/apps/springtale-cli/src/commands/events.rs index 8f6cb510..f5c8b74b 100644 --- a/apps/springtale-cli/src/commands/events.rs +++ b/apps/springtale-cli/src/commands/events.rs @@ -17,18 +17,64 @@ pub async fn run(limit: u32, connector: Option, json_out: bool) -> Resul None => format!("{EVENTS}?limit={limit}"), }; let body: Value = client.get(&path).await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "events") - .iter() - .map(|e| { - vec![ - output::cell(e, "timestamp"), - output::cell(e, "connector_name"), - output::cell(e, "trigger_type"), - output::cell(e, "action_taken"), - ] - }) - .collect(); - output::rows_table(&["TIMESTAMP", "CONNECTOR", "TRIGGER", "ACTION"], rows) - }) + output::emit(json_out, &body, events_table) +} + +/// The `events` table — one row per logged event. +fn events_table(v: &Value) -> String { + let rows = output::array(v, "events") + .iter() + .map(|e| { + vec![ + output::cell(e, "timestamp"), + output::cell(e, "connector_name"), + output::cell(e, "trigger_type"), + output::cell(e, "action_taken"), + ] + }) + .collect(); + output::rows_table(&["TIMESTAMP", "CONNECTOR", "TRIGGER", "ACTION"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + use serde_json::json; + + fn log() -> Value { + json!({ + "events": [{ + "timestamp": "2026-09-04T10:00:00Z", + "connector_name": "telegram", + "trigger_type": "message", + "action_taken": "nightly-digest", + }] + }) + } + + #[test] + fn test_events_json_shape_is_an_events_envelope() { + let out = json_value(&log()); + assert_eq!(key_set(&out), ["events"]); + assert!(out["events"].is_array()); + let event = &out["events"][0]; + assert!(event["timestamp"].is_string()); + assert!(event["connector_name"].is_string()); + assert!(event["trigger_type"].is_string()); + assert!(event["action_taken"].is_string()); + } + + #[test] + fn test_events_table_reads_every_field_the_json_shape_promises() { + let table = events_table(&log()); + for want in ["TIMESTAMP", "telegram", "message", "nightly-digest"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_events_table_is_empty_for_an_empty_log() { + assert_eq!(events_table(&json!({ "events": [] })), ""); + } } diff --git a/apps/springtale-cli/src/commands/execution.rs b/apps/springtale-cli/src/commands/execution.rs index ffc20bd5..d4725a96 100644 --- a/apps/springtale-cli/src/commands/execution.rs +++ b/apps/springtale-cli/src/commands/execution.rs @@ -21,52 +21,138 @@ pub async fn run(action: ExecutionAction, json_out: bool) -> Result<()> { query.push_str(&format!("&rule_id={rule}")); } let body: Value = client.get(&format!("{EXECUTIONS}{query}")).await?; - output::emit(json_out, &body, |v| { - let empty = Vec::new(); - let rows = v - .as_array() - .unwrap_or(&empty) - .iter() - .map(|e| { - vec![ - output::cell(e, "id"), - output::cell(e, "rule_id"), - output::cell(e, "status"), - output::cell(e, "started_at"), - ] - }) - .collect(); - output::rows_table(&["ID", "RULE", "STATUS", "STARTED"], rows) - })?; + output::emit(json_out, &body, executions_table)?; } ExecutionAction::Steps { id } => { let body: Value = client.get(&format!("/executions/{id}/steps")).await?; - output::emit(json_out, &body, |v| { - let empty = Vec::new(); - let rows = v - .as_array() - .unwrap_or(&empty) - .iter() - .map(|s| { - vec![ - output::cell(s, "step_index"), - output::cell(s, "action"), - output::cell(s, "status"), - output::cell(s, "duration_ms"), - ] - }) - .collect(); - output::rows_table(&["#", "ACTION", "STATUS", "MS"], rows) - })?; + output::emit(json_out, &body, steps_table)?; } ExecutionAction::Vacuum { keep_days } => { let body: Value = client .post("/executions/vacuum", &json!({ "keep_days": keep_days })) .await?; - output::emit_status(json_out, &body, |v| { - format!("Vacuumed executions: {}", output::cell(v, "deleted")) - })?; + output::emit_status(json_out, &body, vacuum_line)?; } } Ok(()) } + +/// The `execution list` table — the route answers a bare array. +fn executions_table(v: &Value) -> String { + let empty = Vec::new(); + let rows = v + .as_array() + .unwrap_or(&empty) + .iter() + .map(|e| { + vec![ + output::cell(e, "id"), + output::cell(e, "rule_id"), + output::cell(e, "status"), + output::cell(e, "started_at"), + ] + }) + .collect(); + output::rows_table(&["ID", "RULE", "STATUS", "STARTED"], rows) +} + +/// The `execution steps` table — one row per step of a run. +fn steps_table(v: &Value) -> String { + let empty = Vec::new(); + let rows = v + .as_array() + .unwrap_or(&empty) + .iter() + .map(|s| { + vec![ + output::cell(s, "step_index"), + output::cell(s, "action"), + output::cell(s, "status"), + output::cell(s, "duration_ms"), + ] + }) + .collect(); + output::rows_table(&["#", "ACTION", "STATUS", "MS"], rows) +} + +/// The `execution vacuum` notice. +fn vacuum_line(v: &Value) -> String { + format!("Vacuumed executions: {}", output::cell(v, "deleted")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn runs() -> Value { + json!([{ + "id": "x-1", + "rule_id": "r-1", + "status": "success", + "started_at": "2026-09-04T10:00:00Z", + }]) + } + + fn steps() -> Value { + json!([{ + "step_index": 0, + "action": "telegram.send_message", + "status": "success", + "duration_ms": 42, + }]) + } + + #[test] + fn test_execution_list_json_shape_is_a_bare_array_of_runs() { + let out = json_value(&runs()); + assert!(out.is_array(), "the run list is not wrapped in an envelope"); + let run = &out[0]; + assert!(run["id"].is_string()); + assert!(run["rule_id"].is_string()); + assert!(run["status"].is_string()); + assert!(run["started_at"].is_string()); + } + + #[test] + fn test_execution_steps_json_shape_is_a_bare_array_of_steps() { + let out = json_value(&steps()); + assert!(out.is_array()); + let step = &out[0]; + assert!(step["step_index"].is_number()); + assert!(step["action"].is_string()); + assert!(step["status"].is_string()); + assert!(step["duration_ms"].is_number()); + } + + #[test] + fn test_executions_table_reads_every_field_the_json_shape_promises() { + let table = executions_table(&runs()); + for want in ["ID", "x-1", "r-1", "success", "2026-09-04T10:00:00Z"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_steps_table_reads_every_field_the_json_shape_promises() { + let table = steps_table(&steps()); + for want in ["ACTION", "telegram.send_message", "success", "42"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_execution_tables_are_empty_for_an_empty_array() { + assert_eq!(executions_table(&json!([])), ""); + assert_eq!(steps_table(&json!([])), ""); + } + + #[test] + fn test_execution_vacuum_json_shape_reports_the_deleted_count() { + let body = json!({ "deleted": 17 }); + let out = json_value(&body); + assert_eq!(key_set(&out), ["deleted"]); + assert!(out["deleted"].is_number()); + assert_eq!(vacuum_line(&body), "Vacuumed executions: 17"); + } +} diff --git a/apps/springtale-cli/src/commands/fix.rs b/apps/springtale-cli/src/commands/fix.rs index 24bf17ed..63c54b40 100644 --- a/apps/springtale-cli/src/commands/fix.rs +++ b/apps/springtale-cli/src/commands/fix.rs @@ -15,7 +15,7 @@ pub async fn run(error_id: &str, opts: &PassphraseOpts, json_out: bool) -> Resul // Not an error: an unknown id lists the known ones. Both forms go // through the same helper so `--json` is machine-readable here too. let known = error_fixes::all_guides(); - let body = serde_json::json!({ "error_id": error_id, "known": false, "known_ids": known }); + let body = unknown_body(error_id, known); return output::emit(json_out, &body, |_| { render_unknown(error_id, known) .trim_end_matches('\n') @@ -34,7 +34,7 @@ pub async fn run(error_id: &str, opts: &PassphraseOpts, json_out: bool) -> Resul None }; - let body = serde_json::json!({ "guide": guide, "outcome": outcome }); + let body = fix_body(guide, outcome.as_ref()); output::emit(json_out, &body, |_| { let mut out = render_guide(guide); if let Some(outcome) = &outcome { @@ -80,3 +80,66 @@ fn render_unknown(error_id: &str, known: &[FixGuide]) -> String { } out } + +/// The `fix` body for an unknown error id — not an error, a listing. +fn unknown_body(error_id: &str, known: &[FixGuide]) -> serde_json::Value { + serde_json::json!({ "error_id": error_id, "known": false, "known_ids": known }) +} + +/// The `fix` body for a known error id: the guide, plus the outcome of +/// the automated repair when one was attempted. +fn fix_body(guide: &FixGuide, outcome: Option<&error_fixes::FixOutcome>) -> serde_json::Value { + serde_json::json!({ "guide": guide, "outcome": outcome }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_fix_unknown_id_json_shape_lists_the_known_guides() { + let known = error_fixes::all_guides(); + let out = json_value(&unknown_body("E999", known)); + assert_eq!(key_set(&out), ["error_id", "known", "known_ids"]); + assert_eq!(out["error_id"], "E999"); + assert!(out["known"].is_boolean()); + assert_eq!(out["known"], false); + assert!(out["known_ids"].is_array()); + let listed = crate::output::array(&out, "known_ids"); + assert_eq!(listed.len(), known.len()); + assert!(listed[0]["id"].is_string()); + assert!(listed[0]["title"].is_string()); + } + + #[test] + fn test_fix_known_id_json_shape_is_guide_plus_outcome() { + let guide = error_fixes::all_guides().first().expect("a guide exists"); + let out = json_value(&fix_body(guide, None)); + assert_eq!(key_set(&out), ["guide", "outcome"]); + assert!(out["outcome"].is_null(), "no auto-fix means a null outcome"); + let rendered = &out["guide"]; + assert!(rendered["id"].is_string()); + assert!(rendered["title"].is_string()); + assert!(rendered["causes"].is_array()); + assert!(rendered["suggestions"].is_array()); + assert!(rendered["has_auto_fix"].is_boolean()); + } + + #[test] + fn test_fix_outcome_json_shape_reports_id_success_and_messages() { + let guide = error_fixes::all_guides().first().expect("a guide exists"); + let outcome = error_fixes::FixOutcome { + id: guide.id, + success: true, + messages: vec!["recreated springtale.toml".to_owned()], + }; + let out = json_value(&fix_body(guide, Some(&outcome))); + let rendered = &out["outcome"]; + assert_eq!(key_set(rendered), ["id", "messages", "success"]); + assert!(rendered["id"].is_string()); + assert!(rendered["success"].is_boolean()); + assert!(rendered["messages"].is_array()); + assert_eq!(rendered["messages"][0], "recreated springtale.toml"); + } +} diff --git a/apps/springtale-cli/src/commands/formation.rs b/apps/springtale-cli/src/commands/formation.rs index c725cd7e..3e33ea49 100644 --- a/apps/springtale-cli/src/commands/formation.rs +++ b/apps/springtale-cli/src/commands/formation.rs @@ -19,20 +19,7 @@ pub async fn run(action: FormationAction, json_out: bool) -> Result<()> { match action { FormationAction::List => { let body: Value = client.get("/formations").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "formations") - .iter() - .map(|f| { - vec![ - output::cell(f, "id"), - output::cell(f, "name"), - output::cell(f, "intent"), - output::cell(f, "momentum"), - ] - }) - .collect(); - output::rows_table(&["ID", "NAME", "INTENT", "MOMENTUM"], rows) - })?; + output::emit(json_out, &body, formations_table)?; } FormationAction::Get { id } => { let body: Value = client.get(&format!("/formations/{id}")).await?; @@ -42,41 +29,17 @@ pub async fn run(action: FormationAction, json_out: bool) -> Result<()> { } FormationAction::Commands { id } => { let body: Value = client.get(&format!("/formations/{id}/commands")).await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "commands") - .iter() - .map(|c| { - vec![ - output::cell(c, "id"), - output::cell(c, "label"), - output::cell(c, "enabled"), - ] - }) - .collect(); - output::rows_table(&["ID", "LABEL", "ENABLED"], rows) - })?; + output::emit(json_out, &body, commands_table)?; } FormationAction::Intents => { let body: Value = client.get("/formations/intents").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "intents") - .iter() - .map(|i| vec![output::cell(i, "value"), output::cell(i, "label")]) - .collect(); - output::rows_table(&["VALUE", "LABEL"], rows) - })?; + output::emit(json_out, &body, intents_table)?; } FormationAction::Eligible { id } => { let body: Value = client .get(&format!("/formations/{id}/members/eligible")) .await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "members") - .iter() - .map(|m| vec![output::cell(m, "name"), output::cell(m, "kind")]) - .collect(); - output::rows_table(&["NAME", "KIND"], rows) - })?; + output::emit(json_out, &body, eligible_table)?; } FormationAction::ProposeIntent { id, intent } => { let body: Value = client @@ -198,3 +161,140 @@ async fn simple(client: &Client, json_out: bool, path: &str) -> Result<()> { let body: Value = client.post(path, &json!({})).await?; output::emit(json_out, &body, |v| v.to_string()) } + +/// The `formation list` table — one row per formation. +fn formations_table(v: &Value) -> String { + let rows = output::array(v, "formations") + .iter() + .map(|f| { + vec![ + output::cell(f, "id"), + output::cell(f, "name"), + output::cell(f, "intent"), + output::cell(f, "momentum"), + ] + }) + .collect(); + output::rows_table(&["ID", "NAME", "INTENT", "MOMENTUM"], rows) +} + +/// The `formation commands` table — the command grid the UI renders. +fn commands_table(v: &Value) -> String { + let rows = output::array(v, "commands") + .iter() + .map(|c| { + vec![ + output::cell(c, "id"), + output::cell(c, "label"), + output::cell(c, "enabled"), + ] + }) + .collect(); + output::rows_table(&["ID", "LABEL", "ENABLED"], rows) +} + +/// The `formation intents` table — the intents a formation can take. +fn intents_table(v: &Value) -> String { + let rows = output::array(v, "intents") + .iter() + .map(|i| vec![output::cell(i, "value"), output::cell(i, "label")]) + .collect(); + output::rows_table(&["VALUE", "LABEL"], rows) +} + +/// The `formation eligible` table — members that could join. +fn eligible_table(v: &Value) -> String { + let rows = output::array(v, "members") + .iter() + .map(|m| vec![output::cell(m, "name"), output::cell(m, "kind")]) + .collect(); + output::rows_table(&["NAME", "KIND"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn formations() -> Value { + json!({ + "formations": [{ + "id": "f-1", + "name": "morning watch", + "intent": "reconnoiter", + "momentum": "warm", + }] + }) + } + + #[test] + fn test_formation_list_json_shape_is_a_formations_envelope() { + let out = json_value(&formations()); + assert_eq!(key_set(&out), ["formations"]); + assert!(out["formations"].is_array()); + let formation = &out["formations"][0]; + assert!(formation["id"].is_string()); + assert!(formation["name"].is_string()); + assert!(formation["intent"].is_string()); + assert!(formation["momentum"].is_string()); + } + + #[test] + fn test_formation_commands_json_shape_is_a_commands_envelope() { + let body = json!({ + "commands": [{ "id": "rally", "label": "Rally", "enabled": true }] + }); + let out = json_value(&body); + assert_eq!(key_set(&out), ["commands"]); + let command = &out["commands"][0]; + assert!(command["id"].is_string()); + assert!(command["label"].is_string()); + assert!(command["enabled"].is_boolean()); + let table = commands_table(&body); + for want in ["LABEL", "rally", "Rally", "true"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_formation_intents_json_shape_is_a_value_label_envelope() { + let body = json!({ "intents": [{ "value": "surge", "label": "Surge" }] }); + let out = json_value(&body); + assert_eq!(key_set(&out), ["intents"]); + assert!(out["intents"][0]["value"].is_string()); + assert!(out["intents"][0]["label"].is_string()); + let table = intents_table(&body); + for want in ["VALUE", "surge", "Surge"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_formation_eligible_json_shape_is_a_members_envelope() { + let body = json!({ "members": [{ "name": "telegram", "kind": "connector" }] }); + let out = json_value(&body); + assert_eq!(key_set(&out), ["members"]); + assert!(out["members"][0]["name"].is_string()); + assert!(out["members"][0]["kind"].is_string()); + let table = eligible_table(&body); + for want in ["KIND", "telegram", "connector"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_formations_table_reads_every_field_the_json_shape_promises() { + let table = formations_table(&formations()); + for want in ["ID", "f-1", "morning watch", "reconnoiter", "warm"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_formation_tables_are_empty_for_empty_envelopes() { + assert_eq!(formations_table(&json!({ "formations": [] })), ""); + assert_eq!(commands_table(&json!({ "commands": [] })), ""); + assert_eq!(intents_table(&json!({ "intents": [] })), ""); + assert_eq!(eligible_table(&json!({ "members": [] })), ""); + } +} diff --git a/apps/springtale-cli/src/commands/healthcheck.rs b/apps/springtale-cli/src/commands/healthcheck.rs index ef057b81..92458276 100644 --- a/apps/springtale-cli/src/commands/healthcheck.rs +++ b/apps/springtale-cli/src/commands/healthcheck.rs @@ -40,6 +40,35 @@ pub async fn run(base_url: &str, ready: bool, json_out: bool) -> Result<()> { } // A healthy probe stays silent for the container runtime; `--json` // gives a scriptable body without changing the exit-code contract. - let body = serde_json::json!({ "healthy": true, "url": url, "probe": probe }); + let body = probe_body(&url, probe); output::emit_status(json_out, &body, |_| String::new()) } + +/// The `--json` body a successful probe emits. Silent for humans, so +/// this object is the only machine-readable trace of the probe. +fn probe_body(url: &str, probe: &str) -> serde_json::Value { + serde_json::json!({ "healthy": true, "url": url, "probe": probe }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_healthcheck_json_shape_names_health_url_and_probe() { + let out = json_value(&probe_body("http://127.0.0.1:8080/health", HEALTH)); + assert_eq!(key_set(&out), ["healthy", "probe", "url"]); + assert_eq!(out["healthy"], true); + assert!(out["healthy"].is_boolean()); + assert!(out["url"].is_string()); + assert_eq!(out["url"], "http://127.0.0.1:8080/health"); + assert_eq!(out["probe"], "/health"); + } + + #[test] + fn test_healthcheck_ready_probe_reports_the_ready_route() { + let out = json_value(&probe_body("http://127.0.0.1:8080/ready", READY)); + assert_eq!(out["probe"], "/ready"); + } +} diff --git a/apps/springtale-cli/src/commands/login.rs b/apps/springtale-cli/src/commands/login.rs index 6ec7950d..f2d207c3 100644 --- a/apps/springtale-cli/src/commands/login.rs +++ b/apps/springtale-cli/src/commands/login.rs @@ -109,11 +109,7 @@ pub async fn login(json_out: bool) -> Result<()> { .await; // The token itself is never echoed — only where it landed. - let body = serde_json::json!({ - "logged_in_as": name, - "token_id": id, - "token_path": path.display().to_string(), - }); + let body = logged_in_body(&name, id, &path); output::emit(json_out, &body, |v| { format!( "Logged in as {}\nToken saved to {} (mode 0600)", @@ -126,7 +122,7 @@ pub async fn login(json_out: bool) -> Result<()> { /// `springtale logout` — revoke the saved token, then delete it. pub async fn logout(json_out: bool) -> Result<()> { let Some(saved) = client_config::read_token_file()? else { - let body = serde_json::json!({ "logged_out": false, "reason": "not logged in" }); + let body = not_logged_in_body(); return output::emit(json_out, &body, |_| "Not logged in.".to_owned()); }; let base = base_url()?; @@ -153,7 +149,7 @@ pub async fn logout(json_out: bool) -> Result<()> { }; client_config::delete_token_file()?; - let body = serde_json::json!({ "logged_out": true, "revoked": revoked }); + let body = logged_out_body(revoked); output::emit(json_out, &body, |_| { format!( "Logged out{}", @@ -165,3 +161,69 @@ pub async fn logout(json_out: bool) -> Result<()> { ) }) } + +/// The `login` body. The token itself is never echoed — only who the +/// CLI is now, which token id to revoke, and where the file landed. +fn logged_in_body(name: &str, id: &str, path: &Path) -> serde_json::Value { + serde_json::json!({ + "logged_in_as": name, + "token_id": id, + "token_path": path.display().to_string(), + }) +} + +/// The `logout` body when a saved token was found. +fn logged_out_body(revoked: bool) -> serde_json::Value { + serde_json::json!({ "logged_out": true, "revoked": revoked }) +} + +/// The `logout` body when there was nothing to log out of. +fn not_logged_in_body() -> serde_json::Value { + serde_json::json!({ "logged_out": false, "reason": "not logged in" }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_login_json_shape_names_identity_token_id_and_path() { + let out = json_value(&logged_in_body( + "springtale-cli@laptop", + "tok-1", + Path::new("/home/u/.config/springtale/token"), + )); + assert_eq!(key_set(&out), ["logged_in_as", "token_id", "token_path"]); + assert!(out["logged_in_as"].is_string()); + assert!(out["token_id"].is_string()); + assert!(out["token_path"].is_string()); + assert_eq!(out["token_path"], "/home/u/.config/springtale/token"); + } + + #[test] + fn test_login_json_never_carries_the_token_material() { + let out = json_value(&logged_in_body("cli@host", "tok-1", Path::new("/t"))); + for leaky in ["token", "passphrase", "secret"] { + assert!(out.get(leaky).is_none(), "{leaky} must not be emitted"); + } + } + + #[test] + fn test_logout_json_shape_names_logged_out_and_revoked() { + let out = json_value(&logged_out_body(true)); + assert_eq!(key_set(&out), ["logged_out", "revoked"]); + assert_eq!(out["logged_out"], true); + assert!(out["revoked"].is_boolean()); + assert_eq!(json_value(&logged_out_body(false))["revoked"], false); + } + + #[test] + fn test_logout_when_not_logged_in_json_shape_explains_itself() { + let out = json_value(¬_logged_in_body()); + assert_eq!(key_set(&out), ["logged_out", "reason"]); + assert_eq!(out["logged_out"], false); + assert!(out["reason"].is_string()); + assert_eq!(out["reason"], "not logged in"); + } +} diff --git a/apps/springtale-cli/src/commands/memory.rs b/apps/springtale-cli/src/commands/memory.rs index 7594a324..4d39d90e 100644 --- a/apps/springtale-cli/src/commands/memory.rs +++ b/apps/springtale-cli/src/commands/memory.rs @@ -13,27 +13,7 @@ pub async fn run(action: MemoryAction, json_out: bool) -> Result<()> { match action { MemoryAction::Audit => { let body: Value = client.post("/memory/audit", &json!({})).await?; - output::emit(json_out, &body, |v| { - let mut out = output::cell(v, "total_memory_note"); - let rows: Vec> = output::array(v, "sessions") - .iter() - .map(|s| { - vec![ - output::cell(s, "user_id"), - output::cell(s, "channel_id"), - output::cell(s, "created_at"), - ] - }) - .collect(); - let table = output::rows_table(&["USER", "CHANNEL", "CREATED"], rows); - if table.is_empty() { - out.push_str("\nNo active sessions."); - } else { - out.push('\n'); - out.push_str(&table); - } - out - })?; + output::emit(json_out, &body, audit_table)?; } MemoryAction::Compact { max_entries } => { let body: Value = client @@ -46,3 +26,77 @@ pub async fn run(action: MemoryAction, json_out: bool) -> Result<()> { } Ok(()) } + +/// The `memory audit` view — the note plus one row per live session. +fn audit_table(v: &Value) -> String { + let mut out = output::cell(v, "total_memory_note"); + let rows: Vec> = output::array(v, "sessions") + .iter() + .map(|s| { + vec![ + output::cell(s, "user_id"), + output::cell(s, "channel_id"), + output::cell(s, "created_at"), + ] + }) + .collect(); + let table = output::rows_table(&["USER", "CHANNEL", "CREATED"], rows); + if table.is_empty() { + out.push_str("\nNo active sessions."); + } else { + out.push('\n'); + out.push_str(&table); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn audit() -> Value { + json!({ + "total_memory_note": "3 sessions holding 42 entries", + "sessions": [{ + "user_id": "u-1", + "channel_id": "c-1", + "created_at": "2026-09-04T10:00:00Z", + }] + }) + } + + #[test] + fn test_memory_audit_json_shape_has_the_note_and_the_sessions() { + let out = json_value(&audit()); + assert_eq!(key_set(&out), ["sessions", "total_memory_note"]); + assert!(out["total_memory_note"].is_string()); + assert!(out["sessions"].is_array()); + let session = &out["sessions"][0]; + assert!(session["user_id"].is_string()); + assert!(session["channel_id"].is_string()); + assert!(session["created_at"].is_string()); + } + + #[test] + fn test_audit_table_reads_every_field_the_json_shape_promises() { + let table = audit_table(&audit()); + for want in ["3 sessions holding 42 entries", "USER", "u-1", "c-1"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_audit_table_says_so_when_no_session_is_live() { + let table = audit_table(&json!({ "total_memory_note": "none", "sessions": [] })); + assert!(table.ends_with("No active sessions.")); + } + + #[test] + fn test_memory_compact_json_shape_reports_what_it_trimmed() { + let out = json_value(&json!({ "sessions_compacted": 2, "entries_removed": 11 })); + assert_eq!(key_set(&out), ["entries_removed", "sessions_compacted"]); + assert!(out["sessions_compacted"].is_number()); + assert!(out["entries_removed"].is_number()); + } +} diff --git a/apps/springtale-cli/src/commands/onboarding.rs b/apps/springtale-cli/src/commands/onboarding.rs index 74cc1f53..5fd3d179 100644 --- a/apps/springtale-cli/src/commands/onboarding.rs +++ b/apps/springtale-cli/src/commands/onboarding.rs @@ -15,19 +15,7 @@ pub async fn run(action: OnboardingAction, json_out: bool) -> Result<()> { match action { OnboardingAction::Platforms => { let body: Value = client.get("/onboarding/platforms").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "platforms") - .iter() - .map(|p| { - vec![ - output::cell(p, "platform"), - output::cell(p, "label"), - output::cell(p, "description"), - ] - }) - .collect(); - output::rows_table(&["PLATFORM", "LABEL", "DESCRIPTION"], rows) - })?; + output::emit(json_out, &body, platforms_table)?; } OnboardingAction::Apply { platform, answers } => { let answers = json_input::load(&answers)?; @@ -44,3 +32,59 @@ pub async fn run(action: OnboardingAction, json_out: bool) -> Result<()> { } Ok(()) } + +/// The `onboarding platforms` table — one row per guided setup form. +fn platforms_table(v: &Value) -> String { + let rows = output::array(v, "platforms") + .iter() + .map(|p| { + vec![ + output::cell(p, "platform"), + output::cell(p, "label"), + output::cell(p, "description"), + ] + }) + .collect(); + output::rows_table(&["PLATFORM", "LABEL", "DESCRIPTION"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + use serde_json::json; + + fn platforms() -> Value { + json!({ + "platforms": [{ + "platform": "telegram", + "label": "Telegram", + "description": "Link a bot token from @BotFather", + }] + }) + } + + #[test] + fn test_onboarding_platforms_json_shape_is_a_platforms_envelope() { + let out = json_value(&platforms()); + assert_eq!(key_set(&out), ["platforms"]); + assert!(out["platforms"].is_array()); + let platform = &out["platforms"][0]; + assert!(platform["platform"].is_string()); + assert!(platform["label"].is_string()); + assert!(platform["description"].is_string()); + } + + #[test] + fn test_platforms_table_reads_every_field_the_json_shape_promises() { + let table = platforms_table(&platforms()); + for want in ["PLATFORM", "telegram", "Telegram", "@BotFather"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_platforms_table_is_empty_when_none_are_offered() { + assert_eq!(platforms_table(&json!({ "platforms": [] })), ""); + } +} diff --git a/apps/springtale-cli/src/commands/panic.rs b/apps/springtale-cli/src/commands/panic.rs index ef564444..39426105 100644 --- a/apps/springtale-cli/src/commands/panic.rs +++ b/apps/springtale-cli/src/commands/panic.rs @@ -19,6 +19,26 @@ pub async fn run(store: &dyn StorageBackend, json_out: bool) -> Result<()> { .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let body = serde_json::json!({ "wiped": true }); + let body = wiped_body(); output::emit_status(json_out, &body, |_| "All data destroyed.".to_owned()) } + +/// The `--json` body the panic wipe emits. One field, so a script can +/// tell a completed wipe from a failed one without parsing prose. +fn wiped_body() -> serde_json::Value { + serde_json::json!({ "wiped": true }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_panic_json_shape_is_a_single_wiped_flag() { + let out = json_value(&wiped_body()); + assert_eq!(key_set(&out), ["wiped"]); + assert!(out["wiped"].is_boolean()); + assert_eq!(out["wiped"], true); + } +} diff --git a/apps/springtale-cli/src/commands/recipe.rs b/apps/springtale-cli/src/commands/recipe.rs index 6c462046..0d694993 100644 --- a/apps/springtale-cli/src/commands/recipe.rs +++ b/apps/springtale-cli/src/commands/recipe.rs @@ -17,22 +17,7 @@ pub async fn run(action: RecipeAction, json_out: bool) -> Result<()> { None => "/recipes".to_owned(), }; let body: Value = client.get(&path).await?; - output::emit(json_out, &body, |v| { - let empty = Vec::new(); - let rows = v - .as_array() - .unwrap_or(&empty) - .iter() - .map(|r| { - vec![ - output::cell(r, "id"), - output::cell(r, "name"), - output::cell(r, "category"), - ] - }) - .collect(); - output::rows_table(&["ID", "NAME", "CATEGORY"], rows) - })?; + output::emit(json_out, &body, recipes_table)?; } RecipeAction::Categories => { let body: Value = client.get("/recipes/categories").await?; @@ -210,3 +195,80 @@ fn load_inputs(path: Option) -> Result { .map_err(|e| anyhow::anyhow!("failed to read {}: {e}", path.display()))?; serde_json::from_str(&text).map_err(|e| anyhow::anyhow!("inputs must be JSON: {e}")) } + +/// The `recipe list` table — the route answers a bare array. +fn recipes_table(v: &Value) -> String { + let empty = Vec::new(); + let rows = v + .as_array() + .unwrap_or(&empty) + .iter() + .map(|r| { + vec![ + output::cell(r, "id"), + output::cell(r, "name"), + output::cell(r, "category"), + ] + }) + .collect(); + output::rows_table(&["ID", "NAME", "CATEGORY"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + fn recipes() -> Value { + json!([{ + "id": "daily-digest", + "name": "Daily digest", + "category": "reporting", + }]) + } + + #[test] + fn test_recipe_list_json_shape_is_a_bare_array_of_recipes() { + let out = json_value(&recipes()); + assert!( + out.is_array(), + "the recipe list is not wrapped in an envelope" + ); + let recipe = &out[0]; + assert!(recipe["id"].is_string()); + assert!(recipe["name"].is_string()); + assert!(recipe["category"].is_string()); + } + + #[test] + fn test_recipes_table_reads_every_field_the_json_shape_promises() { + let table = recipes_table(&recipes()); + for want in ["ID", "daily-digest", "Daily digest", "reporting"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_recipes_table_is_empty_when_nothing_matches() { + assert_eq!(recipes_table(&json!([])), ""); + } + + #[test] + fn test_recipe_ack_json_shapes_carry_the_ids_the_notices_print() { + // `favorite`, `fork` / `save` / `import` — the keys the status + // notices read out of the daemon ack. + let favorite = json_value(&json!({ "favorite": true })); + assert_eq!(key_set(&favorite), ["favorite"]); + assert!(favorite["favorite"].is_boolean()); + + let forked = json_value(&json!({ "id": "daily-digest-copy" })); + assert_eq!(key_set(&forked), ["id"]); + assert!(forked["id"].is_string()); + } + + #[test] + fn test_load_inputs_defaults_to_an_empty_values_object() { + let inputs = load_inputs(None).expect("default inputs"); + assert_eq!(inputs, json!({ "values": {} })); + } +} diff --git a/apps/springtale-cli/src/commands/rule.rs b/apps/springtale-cli/src/commands/rule.rs index 16abc4a1..7b4a3a12 100644 --- a/apps/springtale-cli/src/commands/rule.rs +++ b/apps/springtale-cli/src/commands/rule.rs @@ -19,20 +19,7 @@ pub async fn run(action: RuleAction, json_out: bool) -> Result<()> { match action { RuleAction::List => { let body: Value = client.get("/rules").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "rules") - .iter() - .map(|r| { - vec![ - output::cell(r, "id"), - output::cell(r, "name"), - output::cell(r, "status"), - output::cell(r, "trigger"), - ] - }) - .collect(); - output::rows_table(&["ID", "NAME", "STATUS", "TRIGGER"], rows) - })?; + output::emit(json_out, &body, rules_table)?; } RuleAction::Add { file } => { let rule = load_rule(&file)?; @@ -81,19 +68,7 @@ pub async fn run(action: RuleAction, json_out: bool) -> Result<()> { } RuleAction::ForConnector { name } => { let body: Value = client.get(&format!("/rules/connector/{name}")).await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "rules") - .iter() - .map(|r| { - vec![ - output::cell(r, "id"), - output::cell(r, "name"), - output::cell(r, "status"), - ] - }) - .collect(); - output::rows_table(&["ID", "NAME", "STATUS"], rows) - })?; + output::emit(json_out, &body, connector_rules_table)?; } RuleAction::Move { id, connector } => { let body: Value = client @@ -149,3 +124,106 @@ fn load_rule(file: &std::path::Path) -> Result { }), } } + +/// The `rule list` table — one row per rule. +fn rules_table(v: &Value) -> String { + let rows = output::array(v, "rules") + .iter() + .map(|r| { + vec![ + output::cell(r, "id"), + output::cell(r, "name"), + output::cell(r, "status"), + output::cell(r, "trigger"), + ] + }) + .collect(); + output::rows_table(&["ID", "NAME", "STATUS", "TRIGGER"], rows) +} + +/// The `rule for-connector` table — same envelope, no trigger column. +fn connector_rules_table(v: &Value) -> String { + let rows = output::array(v, "rules") + .iter() + .map(|r| { + vec![ + output::cell(r, "id"), + output::cell(r, "name"), + output::cell(r, "status"), + ] + }) + .collect(); + output::rows_table(&["ID", "NAME", "STATUS"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + use serde_json::json; + + fn listing() -> Value { + json!({ + "rules": [{ + "id": "r-1", + "name": "nightly-digest", + "status": "Enabled", + "trigger": "cron", + }] + }) + } + + #[test] + fn test_rule_list_json_shape_is_a_rules_envelope() { + let out = json_value(&listing()); + assert_eq!(key_set(&out), ["rules"]); + assert!(out["rules"].is_array()); + let rule = &out["rules"][0]; + assert!(rule["id"].is_string()); + assert!(rule["name"].is_string()); + assert!(rule["status"].is_string()); + assert!(rule["trigger"].is_string()); + } + + #[test] + fn test_rules_table_reads_every_field_the_json_shape_promises() { + let table = rules_table(&listing()); + for want in ["ID", "r-1", "nightly-digest", "Enabled", "cron"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_connector_rules_table_reads_the_three_columns_it_shows() { + let table = connector_rules_table(&listing()); + for want in ["r-1", "nightly-digest", "Enabled"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + assert!(!table.contains("TRIGGER")); + } + + #[test] + fn test_rules_tables_are_empty_for_an_empty_envelope() { + assert_eq!(rules_table(&json!({ "rules": [] })), ""); + assert_eq!(connector_rules_table(&json!({ "rules": [] })), ""); + } + + #[test] + fn test_rule_write_json_shape_carries_the_id_the_notice_prints() { + // `rule add` / `add-for-connector` read `id` out of the ack. + let out = json_value(&json!({ "id": "r-2" })); + assert_eq!(key_set(&out), ["id"]); + assert!(out["id"].is_string()); + } + + #[test] + fn test_rule_toggle_reads_status_to_decide_the_next_state() { + // The toggle path finds the rule by id and flips off `status`. + let listing = listing(); + let current = output::array(&listing, "rules") + .iter() + .find(|r| output::cell(r, "id") == "r-1") + .expect("rule in listing"); + assert!(output::cell(current, "status") == "Enabled"); + } +} diff --git a/apps/springtale-cli/src/commands/safety.rs b/apps/springtale-cli/src/commands/safety.rs index 37ef51f7..36ebac0a 100644 --- a/apps/springtale-cli/src/commands/safety.rs +++ b/apps/springtale-cli/src/commands/safety.rs @@ -40,10 +40,45 @@ pub async fn run(action: SafetyAction, json_out: bool) -> Result<()> { let body: Value = client .post("/safety/panic_tap_count", &json!({ "count": count })) .await?; - output::emit(json_out, &body, |v| { - format!("panic tap count: {}", output::cell(v, "panic_tap_count")) - })?; + output::emit(json_out, &body, panic_taps_line)?; } } Ok(()) } + +/// The `safety panic-taps` acknowledgement line. +fn panic_taps_line(v: &Value) -> String { + format!("panic tap count: {}", output::cell(v, "panic_tap_count")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_safety_get_json_is_the_daemon_config_document_untouched() { + let config = json!({ + "disguise": { "active": false, "app_name": "Notes", "icon_id": "notes" }, + "panic_tap_count": 5, + "auto_lock_secs": 300, + }); + assert_eq!(json_value(&config), config); + } + + #[test] + fn test_safety_disguise_json_shape_reports_the_active_flag() { + let out = json_value(&json!({ "active": true })); + assert_eq!(key_set(&out), ["active"]); + assert!(out["active"].is_boolean()); + } + + #[test] + fn test_safety_panic_taps_json_shape_reports_the_count() { + let body = json!({ "panic_tap_count": 5 }); + let out = json_value(&body); + assert_eq!(key_set(&out), ["panic_tap_count"]); + assert!(out["panic_tap_count"].is_number()); + assert_eq!(panic_taps_line(&body), "panic tap count: 5"); + } +} diff --git a/apps/springtale-cli/src/commands/send.rs b/apps/springtale-cli/src/commands/send.rs index 25062e02..90495b36 100644 --- a/apps/springtale-cli/src/commands/send.rs +++ b/apps/springtale-cli/src/commands/send.rs @@ -15,12 +15,38 @@ pub async fn run(connector: String, target: String, text: String, json_out: bool &json!({ "connector": connector, "target": target, "text": text }), ) .await?; - output::emit(json_out, &body, |v| { - format!( - "{} -> {} ({})", - connector, - target, - output::cell(v, "status") - ) - }) + output::emit(json_out, &body, |v| send_line(v, &connector, &target)) +} + +/// The `send` acknowledgement line. +fn send_line(v: &Value, connector: &str, target: &str) -> String { + format!( + "{} -> {} ({})", + connector, + target, + output::cell(v, "status") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_send_json_shape_reports_the_delivery_status() { + let body = json!({ "status": "sent" }); + let out = json_value(&body); + assert_eq!(key_set(&out), ["status"]); + assert!(out["status"].is_string()); + assert_eq!( + send_line(&body, "telegram", "@channel"), + "telegram -> @channel (sent)" + ); + } + + #[test] + fn test_send_line_leaves_an_absent_status_blank() { + assert_eq!(send_line(&json!({}), "telegram", "@c"), "telegram -> @c ()"); + } } diff --git a/apps/springtale-cli/src/commands/server.rs b/apps/springtale-cli/src/commands/server.rs index 04b78915..5a233920 100644 --- a/apps/springtale-cli/src/commands/server.rs +++ b/apps/springtale-cli/src/commands/server.rs @@ -10,10 +10,7 @@ pub async fn run(json_out: bool) -> Result<()> { // Find the springtaled binary — check same directory as CLI first let springtaled_path = find_springtaled()?; - let starting = serde_json::json!({ - "status": "starting", - "binary": springtaled_path.display().to_string(), - }); + let starting = starting_body(&springtaled_path); output::emit(json_out, &starting, |_| { "Starting springtaled...".to_owned() })?; @@ -37,7 +34,7 @@ pub async fn run(json_out: bool) -> Result<()> { let code = status.code().unwrap_or(-1); anyhow::bail!("springtaled exited with code {code}"); } - let exited = serde_json::json!({ "status": "exited", "code": 0 }); + let exited = exited_body(0); output::emit(json_out, &exited, |_| { "springtaled exited cleanly".to_owned() }) @@ -58,3 +55,40 @@ fn find_springtaled() -> Result { // Fall back to assuming it's in PATH Ok(std::path::PathBuf::from("springtaled")) } + +/// The `--json` body emitted before springtaled is spawned. +fn starting_body(binary: &std::path::Path) -> serde_json::Value { + serde_json::json!({ + "status": "starting", + "binary": binary.display().to_string(), + }) +} + +/// The `--json` body emitted after a clean exit. +fn exited_body(code: i32) -> serde_json::Value { + serde_json::json!({ "status": "exited", "code": code }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_server_start_json_shape_names_status_and_binary() { + let out = json_value(&starting_body(std::path::Path::new("/usr/bin/springtaled"))); + assert_eq!(key_set(&out), ["binary", "status"]); + assert_eq!(out["status"], "starting"); + assert!(out["binary"].is_string()); + assert_eq!(out["binary"], "/usr/bin/springtaled"); + } + + #[test] + fn test_server_exit_json_shape_names_status_and_code() { + let out = json_value(&exited_body(0)); + assert_eq!(key_set(&out), ["code", "status"]); + assert_eq!(out["status"], "exited"); + assert!(out["code"].is_number()); + assert_eq!(out["code"], 0); + } +} diff --git a/apps/springtale-cli/src/commands/session.rs b/apps/springtale-cli/src/commands/session.rs index e5fc7b61..9dfdcb0f 100644 --- a/apps/springtale-cli/src/commands/session.rs +++ b/apps/springtale-cli/src/commands/session.rs @@ -13,20 +13,64 @@ pub async fn run(action: SessionAction, json_out: bool) -> Result<()> { match action { SessionAction::List => { let body: Value = client.get("/sessions").await?; - output::emit(json_out, &body, |v| { - let rows = output::array(v, "sessions") - .iter() - .map(|s| { - vec![ - output::cell(s, "user_id"), - output::cell(s, "channel_id"), - output::cell(s, "created_at"), - ] - }) - .collect(); - output::rows_table(&["USER", "CHANNEL", "CREATED"], rows) - })?; + output::emit(json_out, &body, sessions_table)?; } } Ok(()) } + +/// The `session list` table — one row per chat session. +fn sessions_table(v: &Value) -> String { + let rows = output::array(v, "sessions") + .iter() + .map(|s| { + vec![ + output::cell(s, "user_id"), + output::cell(s, "channel_id"), + output::cell(s, "created_at"), + ] + }) + .collect(); + output::rows_table(&["USER", "CHANNEL", "CREATED"], rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + use serde_json::json; + + fn sessions() -> Value { + json!({ + "sessions": [{ + "user_id": "u-1", + "channel_id": "c-1", + "created_at": "2026-09-04T10:00:00Z", + }] + }) + } + + #[test] + fn test_session_list_json_shape_is_a_sessions_envelope() { + let out = json_value(&sessions()); + assert_eq!(key_set(&out), ["sessions"]); + assert!(out["sessions"].is_array()); + let session = &out["sessions"][0]; + assert!(session["user_id"].is_string()); + assert!(session["channel_id"].is_string()); + assert!(session["created_at"].is_string()); + } + + #[test] + fn test_sessions_table_reads_every_field_the_json_shape_promises() { + let table = sessions_table(&sessions()); + for want in ["USER", "u-1", "c-1", "2026-09-04T10:00:00Z"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_sessions_table_is_empty_when_no_session_is_held() { + assert_eq!(sessions_table(&json!({ "sessions": [] })), ""); + } +} diff --git a/apps/springtale-cli/src/commands/travel.rs b/apps/springtale-cli/src/commands/travel.rs index 8ae67a46..f6ce1453 100644 --- a/apps/springtale-cli/src/commands/travel.rs +++ b/apps/springtale-cli/src/commands/travel.rs @@ -47,10 +47,7 @@ pub fn prepare( ) .map_err(|e| anyhow::anyhow!("{e}"))?; - let body = serde_json::json!({ - "backup": backup_path.display().to_string(), - "wiped": true, - }); + let body = prepared_body(backup_path); output::emit_status(json_out, &body, |v| { format!( "Backup saved to: {}\nLocal data wiped. Safe travels.", @@ -86,9 +83,48 @@ pub fn restore( ) .map_err(|e| anyhow::anyhow!("{e}"))?; - let body = serde_json::json!({ + let body = restored_body(backup_path); + output::emit_status(json_out, &body, |_| "Data restored from backup.".to_owned()) +} + +/// The `travel prepare` body — where the backup landed, and that the +/// local copy is gone. +fn prepared_body(backup_path: &Path) -> serde_json::Value { + serde_json::json!({ + "backup": backup_path.display().to_string(), + "wiped": true, + }) +} + +/// The `travel restore` body. +fn restored_body(backup_path: &Path) -> serde_json::Value { + serde_json::json!({ "restored": true, "backup": backup_path.display().to_string(), - }); - output::emit_status(json_out, &body, |_| "Data restored from backup.".to_owned()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_travel_prepare_json_shape_names_backup_and_wiped() { + let out = json_value(&prepared_body(Path::new("/media/usb/springtale.bak"))); + assert_eq!(key_set(&out), ["backup", "wiped"]); + assert!(out["backup"].is_string()); + assert_eq!(out["backup"], "/media/usb/springtale.bak"); + assert!(out["wiped"].is_boolean()); + assert_eq!(out["wiped"], true); + } + + #[test] + fn test_travel_restore_json_shape_names_restored_and_backup() { + let out = json_value(&restored_body(Path::new("/media/usb/springtale.bak"))); + assert_eq!(key_set(&out), ["backup", "restored"]); + assert!(out["restored"].is_boolean()); + assert_eq!(out["restored"], true); + assert_eq!(out["backup"], "/media/usb/springtale.bak"); + } } diff --git a/apps/springtale-cli/src/commands/vault.rs b/apps/springtale-cli/src/commands/vault.rs index e370d2c7..378ca95f 100644 --- a/apps/springtale-cli/src/commands/vault.rs +++ b/apps/springtale-cli/src/commands/vault.rs @@ -70,11 +70,41 @@ pub fn duress_setup(vault_path: &Path, json_out: bool) -> Result<()> { ) .context("failed to create dual vault")?; - let body = serde_json::json!({ - "duress_configured": true, - "vault": vault_path.display().to_string(), - }); + let body = duress_body(vault_path); output::emit_status(json_out, &body, |_| { "Duress passphrase configured.\nReal passphrase → full access.\nDuress passphrase → decoy profile.\nFile size is constant — observer cannot tell which was used.".to_owned() }) } + +/// The `vault duress-setup` body. It reports *that* a duress region +/// exists, never which passphrase opens which region. +fn duress_body(vault_path: &Path) -> serde_json::Value { + serde_json::json!({ + "duress_configured": true, + "vault": vault_path.display().to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::{json_value, key_set}; + + #[test] + fn test_vault_duress_setup_json_shape_names_the_flag_and_the_path() { + let out = json_value(&duress_body(Path::new("/home/u/.springtale/vault.age"))); + assert_eq!(key_set(&out), ["duress_configured", "vault"]); + assert!(out["duress_configured"].is_boolean()); + assert_eq!(out["duress_configured"], true); + assert!(out["vault"].is_string()); + assert_eq!(out["vault"], "/home/u/.springtale/vault.age"); + } + + #[test] + fn test_vault_duress_setup_json_never_carries_a_passphrase() { + let out = json_value(&duress_body(Path::new("/tmp/vault.age"))); + for leaky in ["passphrase", "duress_passphrase", "decoy", "entries"] { + assert!(out.get(leaky).is_none(), "{leaky} must not be emitted"); + } + } +} diff --git a/apps/springtale-cli/src/commands/workspace.rs b/apps/springtale-cli/src/commands/workspace.rs index b9ae4ac6..acbc9934 100644 --- a/apps/springtale-cli/src/commands/workspace.rs +++ b/apps/springtale-cli/src/commands/workspace.rs @@ -149,3 +149,49 @@ fn workspace_table(v: &Value) -> String { .collect(); output::rows_table(&["KEY", "NAME", "CONNECTOR", "KIND"], rows) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::json_value; + + fn workspaces() -> Value { + json!([{ + "workspace_key": "guild-42", + "display_name": "Mutual Aid", + "connector_name": "discord", + "kind": "server", + }]) + } + + #[test] + fn test_workspace_list_json_shape_is_a_bare_array_of_workspaces() { + let out = json_value(&workspaces()); + assert!(out.is_array(), "workspaces are not wrapped in an envelope"); + let workspace = &out[0]; + assert!(workspace["workspace_key"].is_string()); + assert!(workspace["display_name"].is_string()); + assert!(workspace["connector_name"].is_string()); + assert!(workspace["kind"].is_string()); + } + + #[test] + fn test_workspace_table_reads_every_field_the_json_shape_promises() { + let table = workspace_table(&workspaces()); + for want in ["KEY", "guild-42", "Mutual Aid", "discord", "server"] { + assert!(table.contains(want), "table lost {want}:\n{table}"); + } + } + + #[test] + fn test_workspace_table_is_empty_when_nothing_is_reachable() { + assert_eq!(workspace_table(&json!([])), ""); + } + + #[test] + fn test_workspace_onboard_url_json_shape_carries_the_url() { + let out = json_value(&json!({ "url": "https://example.test/oauth" })); + assert!(out["url"].is_string()); + assert_eq!(output::cell(&out, "url"), "https://example.test/oauth"); + } +} diff --git a/apps/springtale-cli/src/output.rs b/apps/springtale-cli/src/output.rs index d9d35aa4..9a741faa 100644 --- a/apps/springtale-cli/src/output.rs +++ b/apps/springtale-cli/src/output.rs @@ -1,9 +1,18 @@ use anyhow::Result; use serde::Serialize; +/// Render `data` exactly as `--json` prints it. +/// +/// Split out from [`print_json`] so the shape a subcommand emits can be +/// asserted without a terminal (and without a daemon): every `--json` +/// body on stdout is this function's output. +pub fn render_json(data: &T) -> Result { + Ok(serde_json::to_string_pretty(data)?) +} + /// Print data as formatted JSON to stdout. pub fn print_json(data: &T) -> Result<()> { - let json = serde_json::to_string_pretty(data)?; + let json = render_json(data)?; println!("{json}"); Ok(()) } @@ -76,3 +85,105 @@ pub fn cell(value: &serde_json::Value, key: &str) -> String { Some(other) => other.to_string(), } } + +/// Test-only: the `--json` body a subcommand emits, parsed back into a +/// [`serde_json::Value`] so a test can assert its shape. Goes through +/// [`render_json`] — the same function `--json` prints with — so a test +/// asserts the real output path, not a re-implementation of it. +#[cfg(test)] +pub fn json_value(data: &T) -> serde_json::Value { + serde_json::from_str(&render_json(data).expect("render --json body")) + .expect("--json output must be valid JSON") +} + +/// Test-only: the sorted top-level key set of a JSON object. +#[cfg(test)] +pub fn key_set(value: &serde_json::Value) -> Vec { + let mut keys: Vec = value + .as_object() + .map(|o| o.keys().cloned().collect()) + .unwrap_or_default(); + keys.sort(); + keys +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + use serde_json::json; + + /// The pretty-print family (`bot status`, `formation get`, `recipe + /// get`, `config list`, `drift`, `safety get`, `canvas`, `rule + /// schema`, …) hands the daemon document straight to `--json`. The + /// contract is that nothing is dropped, renamed, or retyped on the + /// way through. + #[test] + fn test_render_json_passes_a_daemon_document_through_unchanged() { + let doc = json!({ + "status": "running", + "uptime_secs": 91, + "degraded": false, + "adapter": null, + "formations": [{ "id": "f-1", "members": ["telegram", "github"] }], + }); + assert_eq!(json_value(&doc), doc); + } + + #[test] + fn test_emit_with_json_never_runs_the_table_renderer() { + let called = Cell::new(false); + emit(true, &json!({ "ok": true }), |_| { + called.set(true); + String::new() + }) + .expect("emit"); + assert!(!called.get(), "--json must not render the human table"); + } + + #[test] + fn test_emit_without_json_runs_the_table_renderer() { + let called = Cell::new(false); + emit(false, &json!({ "ok": true }), |_| { + called.set(true); + String::new() + }) + .expect("emit"); + assert!(called.get()); + } + + #[test] + fn test_emit_status_with_json_never_runs_the_notice() { + let called = Cell::new(false); + emit_status(true, &json!({ "wiped": true }), |_| { + called.set(true); + String::new() + }) + .expect("emit_status"); + assert!(!called.get(), "--json must not render the stderr notice"); + } + + #[test] + fn test_array_missing_or_non_array_key_is_empty() { + let v = json!({ "rules": [{ "id": "r-1" }], "count": 3 }); + assert_eq!(array(&v, "rules").len(), 1); + assert!(array(&v, "count").is_empty()); + assert!(array(&v, "absent").is_empty()); + } + + #[test] + fn test_cell_unquotes_strings_and_compacts_other_values() { + let v = json!({ "name": "nightly", "enabled": true, "n": 4, "gone": null }); + assert_eq!(cell(&v, "name"), "nightly"); + assert_eq!(cell(&v, "enabled"), "true"); + assert_eq!(cell(&v, "n"), "4"); + assert_eq!(cell(&v, "gone"), ""); + assert_eq!(cell(&v, "absent"), ""); + } + + #[test] + fn test_rows_table_is_empty_for_no_rows() { + assert_eq!(rows_table(&["ID"], Vec::new()), ""); + } +} From d756ab7a44b0357a874dd2c038b7732b9a45b231 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:24:08 -0700 Subject: [PATCH 17/24] transport: mutual-TLS tests for HttpTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALIGNMENT-PLAN 5.5, row 3: the rustls mTLS transport — the component that carries inter-node traffic for people whose safety depends on it — had no tests at all. `crates/springtale-transport/tests/http_transport.rs` drives the real `HttpTransport`, not a reimplementation: - two nodes with an in-test CA round-trip a message in both directions, asserting sender node id, message id and payload; - a send to an unknown peer is rejected as `ConnectionFailed` naming the node; - a client that trusts the wrong authority is refused — the real `send()` fails, the server inbox stays empty, and a raw rustls probe against the same live server pins the reason to `InvalidCertificate(UnknownIssuer)` rather than any error at all; - a client presenting a certificate from the wrong authority is likewise refused, pinned to a fatal certificate alert (TLS 1.3 surfaces the server's client-cert rejection after the handshake, so it is asserted on the application-data exchange); - the hybrid post-quantum group is asserted from handshake state: `negotiated_key_exchange_group()` must be `X25519MLKEM768`. Both negative tests were mutation-checked: pointed at a matching CA, they fail, so they are not passing on an incidental error. `rcgen` was already a hardcoded dev-dependency of this crate; it moves to a workspace pin like every other dependency, `default-features = false` with `crypto,pem,ring` so it stays on the workspace's ring backend — no aws-lc-rs, no OpenSSL, and `Cargo.lock` is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- Cargo.toml | 4 + crates/springtale-transport/Cargo.toml | 2 +- .../tests/http_transport.rs | 613 ++++++++++++++++++ 3 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 crates/springtale-transport/tests/http_transport.rs diff --git a/Cargo.toml b/Cargo.toml index 37209d0b..74645bde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -238,6 +238,10 @@ rustls-pki-types = "1" # builder site in `springtale-transport` and connector clients. rustls-post-quantum = "0.2" ring = "0.17" +# Test-only X.509 generation (in-test CAs / leaf certs for the mTLS +# transport tests). `default-features = false` + explicit `ring` keeps it +# on the workspace's existing ring backend: no aws-lc-rs, no OpenSSL. +rcgen = { version = "0.13", default-features = false, features = ["crypto", "pem", "ring"] } # ── Config ───────────────────────────────────────────────────────────────────── figment = { version = "0.10", features = ["toml", "env"] } diff --git a/crates/springtale-transport/Cargo.toml b/crates/springtale-transport/Cargo.toml index 1386910e..a37f8978 100644 --- a/crates/springtale-transport/Cargo.toml +++ b/crates/springtale-transport/Cargo.toml @@ -32,4 +32,4 @@ hex = { workspace = true } tokio = { workspace = true, features = ["test-util"] } rand = { workspace = true } tempfile = { workspace = true } -rcgen = "0.13" +rcgen = { workspace = true } diff --git a/crates/springtale-transport/tests/http_transport.rs b/crates/springtale-transport/tests/http_transport.rs new file mode 100644 index 00000000..66c556bc --- /dev/null +++ b/crates/springtale-transport/tests/http_transport.rs @@ -0,0 +1,613 @@ +//! Integration tests for [`HttpTransport`] — the rustls mutual-TLS transport. +//! +//! Three properties are covered: +//! +//! 1. Two real `HttpTransport` nodes, issued certificates by a CA generated +//! in-test, round-trip a [`Message`] end to end. +//! 2. A peer that trusts the wrong CA, or presents a certificate from the +//! wrong CA, is refused at the TLS layer. Each refusal is pinned to the +//! exact rustls failure (`UnknownIssuer` / a fatal certificate alert) via +//! a raw rustls probe against the *real* transport server, so the test +//! cannot pass if certificate verification were disabled. +//! 3. The handshake against the real transport server negotiates the hybrid +//! post-quantum group `X25519MLKEM768`, asserted from +//! [`rustls::CommonState::negotiated_key_exchange_group`] on a completed +//! connection — handshake state, not configuration. +//! +//! The probes drive `rustls::ClientConnection` by hand +//! (`read_tls`/`write_tls`/`process_new_packets`) rather than through +//! `complete_io`, because that is the only path that surfaces the typed +//! [`rustls::Error`] instead of an opaque `io::Error`. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, + KeyPair, KeyUsagePurpose, +}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use rustls::{CertificateError, ClientConfig, ClientConnection, NamedGroup, RootCertStore}; +use tempfile::TempDir; +use uuid::Uuid; + +use springtale_crypto::identity::NodeId; +use springtale_transport::error::TransportError; +use springtale_transport::http::{HttpTransport, HttpTransportConfig}; +use springtale_transport::transport::{Message, Transport}; + +// ── Test PKI ────────────────────────────────────────────────────── + +/// A throwaway certificate authority backed by an in-test key pair. +struct TestCa { + cert: Certificate, + key: KeyPair, +} + +impl TestCa { + fn new(common_name: &str) -> Self { + let key = KeyPair::generate().expect("generate CA key"); + let mut params = CertificateParams::new(Vec::::new()).expect("CA params"); + params + .distinguished_name + .push(DnType::CommonName, common_name); + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(1)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let cert = params.self_signed(&key).expect("self-sign CA"); + Self { cert, key } + } + + fn der(&self) -> CertificateDer<'static> { + self.cert.der().clone() + } + + /// Issue an end-entity certificate valid for both TLS roles, so the same + /// material can serve `HttpTransport`'s listener and its outbound client. + fn issue_leaf(&self, common_name: &str) -> (String, String) { + let key = KeyPair::generate().expect("generate leaf key"); + let mut params = + CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()]) + .expect("leaf params"); + params + .distinguished_name + .push(DnType::CommonName, common_name); + params.is_ca = IsCa::NoCa; + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + params.extended_key_usages = vec![ + ExtendedKeyUsagePurpose::ServerAuth, + ExtendedKeyUsagePurpose::ClientAuth, + ]; + params.use_authority_key_identifier_extension = true; + let cert = params + .signed_by(&key, &self.cert, &self.key) + .expect("sign leaf"); + (cert.pem(), key.serialize_pem()) + } +} + +/// PEM material on disk, in the shape `HttpTransportConfig` expects. +struct NodePki { + _dir: TempDir, + cert: PathBuf, + key: PathBuf, + ca: PathBuf, + cert_der: Vec>, + key_der: PrivateKeyDer<'static>, +} + +/// Write a node's PEM bundle: a leaf signed by `issuer`, trusting `trusted`. +/// +/// Passing two different CAs is how the negative tests build a peer whose +/// identity or trust anchor does not line up with its counterparty. +fn write_node_pki(name: &str, issuer: &TestCa, trusted: &TestCa) -> NodePki { + let dir = tempfile::tempdir().expect("tempdir"); + let (cert_pem, key_pem) = issuer.issue_leaf(name); + let ca_pem = trusted.cert.pem(); + + let cert = dir.path().join("cert.pem"); + let key = dir.path().join("key.pem"); + let ca = dir.path().join("ca.pem"); + std::fs::write(&cert, &cert_pem).expect("write cert"); + std::fs::write(&key, &key_pem).expect("write key"); + std::fs::write(&ca, &ca_pem).expect("write ca"); + + let cert_der = rustls_pemfile::certs(&mut cert_pem.as_bytes()) + .collect::, _>>() + .expect("parse leaf cert DER"); + let key_der = rustls_pemfile::private_key(&mut key_pem.as_bytes()) + .expect("parse leaf key DER") + .expect("leaf key present"); + + NodePki { + _dir: dir, + cert, + key, + ca, + cert_der, + key_der, + } +} + +// ── Harness ─────────────────────────────────────────────────────── + +/// Install the post-quantum-preferring rustls provider once per test binary. +/// +/// `HttpTransport` reads the process-global provider for both its listener +/// and its `reqwest` client, so this must run before the first config is +/// built or the PQ group would never be offered. +fn install_pq_provider() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + springtale_transport::crypto_provider::install_default_pq(); + }); +} + +/// Reserve an ephemeral loopback port. `HttpTransport` takes an address +/// string and never reports the port it actually bound, so the port has to +/// be chosen before `bind()`. +fn reserve_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port"); + let port = listener.local_addr().expect("local_addr").port(); + drop(listener); + port +} + +fn node_id(seed: u8) -> NodeId { + NodeId::from_bytes([seed; 32]) +} + +fn config_for(pki: &NodePki, port: u16, peers: &[(&NodeId, u16)]) -> HttpTransportConfig { + let peers = peers + .iter() + .map(|(id, peer_port)| (hex::encode(id.as_bytes()), format!("127.0.0.1:{peer_port}"))) + .collect::>(); + + // `HttpTransportConfig` is `Deserialize`-only (config structs never derive + // `Serialize`), so build it through serde rather than a struct literal. + serde_json::from_value(serde_json::json!({ + "listen_addr": format!("127.0.0.1:{port}"), + "tls_cert": pki.cert, + "tls_key": pki.key, + "tls_ca": pki.ca, + "peers": peers, + })) + .expect("build HttpTransportConfig") +} + +/// Poll until the transport's listener accepts TCP, so tests never race the +/// spawned `axum_server` task. +async fn wait_until_listening(port: u16) { + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().expect("addr"); + for _ in 0..200 { + if tokio::net::TcpStream::connect(addr).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("transport never began listening on {addr}"); +} + +fn message(payload: &[u8]) -> Message { + Message { + id: Uuid::new_v4(), + payload: payload.to_vec(), + } +} + +// ── Raw rustls probe ────────────────────────────────────────────── + +#[derive(Debug)] +enum ProbeError { + Io(std::io::Error), + Tls(rustls::Error), +} + +impl std::fmt::Display for ProbeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "probe I/O error ({:?}): {err}", err.kind()), + Self::Tls(err) => write!(f, "probe TLS error: {err}"), + } + } +} + +/// What a completed probe observed about the connection. +struct ProbeOutcome { + kx_group: Option, + /// Outcome of exchanging application data once the handshake finished. + /// + /// In TLS 1.3 the client finishes its handshake before the server has + /// validated the client certificate, so a rejected client cert shows up + /// here as a fatal alert rather than as a handshake error. + app_data: Result<(), ProbeError>, +} + +/// Handshake with `addr` as a plain rustls client, trusting `roots` and +/// optionally presenting `identity`. +/// +/// Returns `Err` when the *handshake* fails, carrying the typed +/// [`rustls::Error`] so callers can assert the precise reason. +fn tls_probe( + port: u16, + roots: &[CertificateDer<'static>], + identity: Option<(Vec>, PrivateKeyDer<'static>)>, +) -> Result { + let mut root_store = RootCertStore::empty(); + for root in roots { + root_store.add(root.clone()).expect("add probe root"); + } + + let builder = ClientConfig::builder().with_root_certificates(root_store); + let mut config = match identity { + Some((certs, key)) => builder + .with_client_auth_cert(certs, key) + .expect("probe client auth cert"), + None => builder.with_no_client_auth(), + }; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + let server_name = ServerName::try_from("127.0.0.1").expect("probe server name"); + let mut conn = + ClientConnection::new(Arc::new(config), server_name).expect("probe client connection"); + + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().expect("probe addr"); + let mut sock = TcpStream::connect(addr).map_err(ProbeError::Io)?; + sock.set_read_timeout(Some(Duration::from_secs(10))) + .map_err(ProbeError::Io)?; + + drive_handshake(&mut conn, &mut sock)?; + + let kx_group = conn.negotiated_key_exchange_group().map(|g| g.name()); + let app_data = exchange_app_data(&mut conn, &mut sock); + + Ok(ProbeOutcome { kx_group, app_data }) +} + +/// Flush every byte rustls has queued for the wire. +fn flush_out(conn: &mut ClientConnection, sock: &mut TcpStream) -> Result<(), ProbeError> { + while conn.wants_write() { + conn.write_tls(sock).map_err(ProbeError::Io)?; + } + sock.flush().map_err(ProbeError::Io) +} + +/// Pull one TLS record flight off the socket and process it, surfacing rustls +/// failures with their real type instead of an opaque `io::Error`. +fn pump_in(conn: &mut ClientConnection, sock: &mut TcpStream) -> Result { + let read = conn.read_tls(sock).map_err(ProbeError::Io)?; + conn.process_new_packets().map_err(ProbeError::Tls)?; + Ok(read) +} + +fn eof() -> ProbeError { + ProbeError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "peer closed the connection", + )) +} + +fn drive_handshake(conn: &mut ClientConnection, sock: &mut TcpStream) -> Result<(), ProbeError> { + loop { + flush_out(conn, sock)?; + if !conn.is_handshaking() { + return Ok(()); + } + if pump_in(conn, sock)? == 0 { + return Err(eof()); + } + } +} + +/// Send a minimal request and read until the server answers or rejects us. +fn exchange_app_data(conn: &mut ClientConnection, sock: &mut TcpStream) -> Result<(), ProbeError> { + conn.writer() + .write_all(b"GET /transport/send HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .map_err(ProbeError::Io)?; + + let mut plaintext = Vec::new(); + loop { + flush_out(conn, sock)?; + + let mut buf = [0u8; 4096]; + loop { + match conn.reader().read(&mut buf) { + Ok(0) => break, + Ok(n) => plaintext.extend_from_slice(&buf[..n]), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(e) => return Err(ProbeError::Io(e)), + } + } + if !plaintext.is_empty() { + return Ok(()); + } + + if pump_in(conn, sock)? == 0 { + return Err(eof()); + } + } +} + +// ── 1. Round trip ───────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_http_transport_round_trip_delivers_message_both_ways() { + install_pq_provider(); + let ca = TestCa::new("springtale round-trip CA"); + + let (id_a, id_b) = (node_id(0xa1), node_id(0xb2)); + let (port_a, port_b) = (reserve_port(), reserve_port()); + + let pki_a = write_node_pki("node-a", &ca, &ca); + let pki_b = write_node_pki("node-b", &ca, &ca); + + let node_a = HttpTransport::bind(id_a, config_for(&pki_a, port_a, &[(&id_b, port_b)])) + .await + .expect("bind node A"); + let node_b = HttpTransport::bind(id_b, config_for(&pki_b, port_b, &[(&id_a, port_a)])) + .await + .expect("bind node B"); + wait_until_listening(port_a).await; + wait_until_listening(port_b).await; + + assert_eq!(node_a.name(), "http"); + assert_eq!(node_a.node_id(), &id_a); + + // A → B + let outbound = message(b"colony ping"); + node_a + .send(&id_b, outbound.clone()) + .await + .expect("A sends to B over mTLS"); + + let (sender, received) = tokio::time::timeout(Duration::from_secs(10), node_b.recv()) + .await + .expect("B receives before timeout") + .expect("B receives without transport error"); + assert_eq!(sender, id_a); + assert_eq!(received.id, outbound.id); + assert_eq!(received.payload, b"colony ping".to_vec()); + + // B → A, over the same mutually-authenticated trust anchor. + let reply = message(b"colony pong"); + node_b + .send(&id_a, reply.clone()) + .await + .expect("B sends to A over mTLS"); + + let (sender, received) = tokio::time::timeout(Duration::from_secs(10), node_a.recv()) + .await + .expect("A receives before timeout") + .expect("A receives without transport error"); + assert_eq!(sender, id_b); + assert_eq!(received.id, reply.id); + assert_eq!(received.payload, b"colony pong".to_vec()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_http_transport_send_to_unknown_peer_is_rejected() { + install_pq_provider(); + let ca = TestCa::new("springtale unknown-peer CA"); + let id_a = node_id(0x11); + let port_a = reserve_port(); + let pki_a = write_node_pki("node-a", &ca, &ca); + + let node_a = HttpTransport::bind(id_a, config_for(&pki_a, port_a, &[])) + .await + .expect("bind node A"); + + let stranger = node_id(0x99); + let err = node_a + .send(&stranger, message(b"nobody home")) + .await + .expect_err("unrouted peer must not be dialled"); + + match err { + TransportError::ConnectionFailed(msg) => { + assert!( + msg.contains("unknown peer") && msg.contains(&hex::encode(stranger.as_bytes())), + "expected an unknown-peer rejection naming the node id, got: {msg}" + ); + } + other => panic!("expected ConnectionFailed, got {other:?}"), + } +} + +// ── 2. Wrong certificate authority ──────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_http_transport_client_trusting_wrong_ca_is_refused() { + install_pq_provider(); + let good_ca = TestCa::new("springtale good CA"); + let evil_ca = TestCa::new("springtale evil CA"); + + let (id_server, id_client) = (node_id(0x51), node_id(0x52)); + let (port_server, port_client) = (reserve_port(), reserve_port()); + + // Server: identity and trust anchor both from the good CA. + let pki_server = write_node_pki("server", &good_ca, &good_ca); + // Client: issued by the good CA (so its own cert is acceptable), but its + // trust store holds only the evil CA — it cannot verify the server. + let pki_client = write_node_pki("client", &good_ca, &evil_ca); + + let server = HttpTransport::bind( + id_server, + config_for(&pki_server, port_server, &[(&id_client, port_client)]), + ) + .await + .expect("bind server"); + let client = HttpTransport::bind( + id_client, + config_for(&pki_client, port_client, &[(&id_server, port_server)]), + ) + .await + .expect("bind client"); + wait_until_listening(port_server).await; + + let err = client + .send(&id_server, message(b"should never arrive")) + .await + .expect_err("server certificate signed by an untrusted CA must be refused"); + match err { + TransportError::Http(msg) => { + assert!( + msg.contains(&format!("127.0.0.1:{port_server}")), + "expected the transport error to name the peer, got: {msg}" + ); + } + other => panic!("expected a TLS-layer Http error, got {other:?}"), + } + + // Nothing reached the server's inbox. + assert!( + tokio::time::timeout(Duration::from_millis(500), server.recv()) + .await + .is_err(), + "a message crossed a connection that should have failed to handshake" + ); + + // Pin the exact rustls reason against the same live server: trusting only + // the evil CA must fail server-certificate verification with + // `UnknownIssuer`. If verification were disabled this handshake would + // succeed and the test would fail here. + let probe = tokio::task::spawn_blocking({ + let roots = vec![evil_ca.der()]; + move || tls_probe(port_server, &roots, None) + }) + .await + .expect("probe task"); + + match probe { + Err(ProbeError::Tls(rustls::Error::InvalidCertificate( + CertificateError::UnknownIssuer, + ))) => {} + Err(other) => panic!("expected InvalidCertificate(UnknownIssuer), got {other:?}"), + Ok(_) => panic!("handshake succeeded against an untrusted server certificate"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_http_transport_client_cert_from_wrong_ca_is_refused() { + install_pq_provider(); + let good_ca = TestCa::new("springtale good CA"); + let evil_ca = TestCa::new("springtale evil CA"); + + let (id_server, id_client) = (node_id(0x61), node_id(0x62)); + let (port_server, port_client) = (reserve_port(), reserve_port()); + + let pki_server = write_node_pki("server", &good_ca, &good_ca); + // Client trusts the good CA (so the server's certificate verifies), but + // presents an identity the server's `WebPkiClientVerifier` cannot chain. + let pki_client = write_node_pki("client", &evil_ca, &good_ca); + + let server = HttpTransport::bind( + id_server, + config_for(&pki_server, port_server, &[(&id_client, port_client)]), + ) + .await + .expect("bind server"); + let client = HttpTransport::bind( + id_client, + config_for(&pki_client, port_client, &[(&id_server, port_server)]), + ) + .await + .expect("bind client"); + wait_until_listening(port_server).await; + + let err = client + .send(&id_server, message(b"forged identity")) + .await + .expect_err("client certificate from an untrusted CA must be refused"); + assert!( + matches!(err, TransportError::Http(_)), + "expected a TLS-layer Http error, got {err:?}" + ); + + assert!( + tokio::time::timeout(Duration::from_millis(500), server.recv()) + .await + .is_err(), + "a message crossed a connection whose client certificate was untrusted" + ); + + // Pin the reason. TLS 1.3 clients finish their side of the handshake + // before the server validates the client certificate, so the rejection + // arrives as a fatal alert on the first application-data exchange. + let probe = tokio::task::spawn_blocking({ + let roots = vec![good_ca.der()]; + let certs = pki_client.cert_der.clone(); + let key = pki_client.key_der.clone_key(); + move || tls_probe(port_server, &roots, Some((certs, key))) + }) + .await + .expect("probe task") + .expect("server certificate verifies for this probe"); + + match probe.app_data { + Err(ProbeError::Tls(rustls::Error::AlertReceived(alert))) => { + assert!( + matches!( + alert, + rustls::AlertDescription::UnknownCA + | rustls::AlertDescription::BadCertificate + | rustls::AlertDescription::DecryptError + | rustls::AlertDescription::CertificateUnknown + ), + "expected a certificate-rejection alert, got {alert:?}" + ); + } + Err(other) => panic!("expected a fatal certificate alert, got {other:?}"), + Ok(()) => panic!("server accepted a client certificate from an untrusted CA"), + } +} + +// ── 3. Post-quantum key exchange ────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_http_transport_negotiates_x25519mlkem768() { + install_pq_provider(); + let ca = TestCa::new("springtale pq CA"); + + let (id_server, id_client) = (node_id(0x71), node_id(0x72)); + let port_server = reserve_port(); + + let pki_server = write_node_pki("server", &ca, &ca); + let pki_client = write_node_pki("client", &ca, &ca); + + let _server = HttpTransport::bind( + id_server, + config_for(&pki_server, port_server, &[(&id_client, port_server)]), + ) + .await + .expect("bind server"); + wait_until_listening(port_server).await; + + let probe = tokio::task::spawn_blocking({ + let roots = vec![ca.der()]; + let certs = pki_client.cert_der.clone(); + let key = pki_client.key_der.clone_key(); + move || tls_probe(port_server, &roots, Some((certs, key))) + }) + .await + .expect("probe task") + .expect("mTLS handshake with matching CA succeeds"); + + // Asserted from the completed connection's handshake state, not from the + // configured `kx_groups` list. + assert_eq!( + probe.kx_group, + Some(NamedGroup::X25519MLKEM768), + "transport must negotiate the hybrid post-quantum group (NIST IR 8547)" + ); + assert!( + probe.app_data.is_ok(), + "post-handshake exchange failed: {:?}", + probe.app_data + ); +} From 511c9f632cb395b34a969f19db45c60bf16ab470 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:31:23 -0700 Subject: [PATCH 18/24] feat(mcp): send notifications/tools/list_changed when the registry changes (plan 6.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server advertised the listChanged capability and never sent the notification, so a client cached a stale tool list until it reconnected. The runtime owns a protocol-free `ToolCatalogNotifier` (a broadcast sender on `RuntimeState`, the shape `canvas_tx` already uses) that every operation mutating the LIVE registry publishes to — setup, install-wasm, reload, enable, disable, remove and remove-cascade — each fired right after the registry changes rather than after fallible follow-up work. `install_connector` deliberately does not: it registers a manifest row and leaves `tools/list` unchanged. `ServerHandler::on_initialized` spawns one forwarder per connected client holding that client's peer and its own subscription, filters by the server's connector scope, and calls `notify_tool_list_changed`. It exits when the transport closes or the runtime drops the channel, so dead peers prune themselves; a failed send on a live session does not. The loop is generic over a small sink trait so it is tested without a transport. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtaled/src/test_harness/app.rs | 1 + crates/springtale-mcp/src/server/handlers.rs | 20 +- crates/springtale-mcp/src/server/mod.rs | 2 + crates/springtale-mcp/src/server/notify.rs | 300 ++++++++++++++++++ crates/springtale-mcp/src/server/registry.rs | 12 + crates/springtale-runtime/src/init.rs | 1 + crates/springtale-runtime/src/lib.rs | 2 + .../src/operations/connectors/install.rs | 15 + .../src/operations/connectors/mod.rs | 31 +- .../src/operations/connectors/reload.rs | 7 + .../src/operations/connectors/setup.rs | 9 + crates/springtale-runtime/src/state.rs | 9 + crates/springtale-runtime/src/tool_catalog.rs | 209 ++++++++++++ 13 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 crates/springtale-mcp/src/server/notify.rs create mode 100644 crates/springtale-runtime/src/tool_catalog.rs diff --git a/apps/springtaled/src/test_harness/app.rs b/apps/springtaled/src/test_harness/app.rs index 98702c56..e8e7988e 100644 --- a/apps/springtaled/src/test_harness/app.rs +++ b/apps/springtaled/src/test_harness/app.rs @@ -154,6 +154,7 @@ impl TestApp { chat_tx: bot_chat_tx, chat_rx: Arc::new(tokio::sync::Mutex::new(Some(bot_chat_rx))), chat_tasks: Default::default(), + tool_catalog: Default::default(), // In-memory store — no runtime lock. _lock: None, }; diff --git a/crates/springtale-mcp/src/server/handlers.rs b/crates/springtale-mcp/src/server/handlers.rs index f143be2e..56e1ed68 100644 --- a/crates/springtale-mcp/src/server/handlers.rs +++ b/crates/springtale-mcp/src/server/handlers.rs @@ -7,9 +7,10 @@ use rmcp::model::{ CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, }; -use rmcp::service::RequestContext; +use rmcp::service::{NotificationContext, RequestContext}; use rmcp::{ErrorData as RmcpError, RoleServer, ServerHandler}; +use super::notify::spawn_tool_list_forwarder; use super::registry::SpringtaleMcp; impl ServerHandler for SpringtaleMcp { @@ -46,6 +47,23 @@ impl ServerHandler for SpringtaleMcp { }) } + /// Start this client's `notifications/tools/list_changed` pump. + /// + /// `get_info` advertises `tools.listChanged`; this is where that + /// promise is kept. One forwarder per initialized client, holding + /// that client's peer and a fresh subscription to the runtime's + /// tool-catalog fan-out. No replay: the client is about to call + /// `tools/list` for the current state, so only changes *after* + /// initialization matter. The task prunes itself when the client + /// disconnects. + async fn on_initialized(&self, context: NotificationContext) { + spawn_tool_list_forwarder( + self.subscribe_tool_catalog(), + context.peer, + self.scope().map(str::to_owned), + ); + } + async fn call_tool( &self, request: CallToolRequestParams, diff --git a/crates/springtale-mcp/src/server/mod.rs b/crates/springtale-mcp/src/server/mod.rs index 71b0219e..d44901cc 100644 --- a/crates/springtale-mcp/src/server/mod.rs +++ b/crates/springtale-mcp/src/server/mod.rs @@ -1,4 +1,6 @@ pub mod handlers; +pub mod notify; pub mod registry; +pub use notify::{ToolListSink, forward_tool_list_changes, spawn_tool_list_forwarder}; pub use registry::{SpringtaleMcp, TOOL_NAME_SEPARATOR}; diff --git a/crates/springtale-mcp/src/server/notify.rs b/crates/springtale-mcp/src/server/notify.rs new file mode 100644 index 00000000..d451634e --- /dev/null +++ b/crates/springtale-mcp/src/server/notify.rs @@ -0,0 +1,300 @@ +//! `notifications/tools/list_changed` — keeping a client's cached tool +//! list honest. +//! +//! [`SpringtaleMcp::get_info`](super::registry::SpringtaleMcp) advertises +//! the `tools.listChanged` capability. The MCP spec's promise for that +//! capability is that the server "SHOULD send a notification when the +//! tool list changes", and a client is entitled to call `tools/list` +//! once at initialization and cache the result. Until this module +//! existed the daemon advertised the capability and never sent the +//! notification, so installing or removing a connector left every +//! connected client calling tools that no longer exist and blind to +//! ones that now do. +//! +//! Shape: the runtime publishes protocol-free +//! [`ToolCatalogEvent`]s on a broadcast channel (it cannot depend on +//! `rmcp` — `springtale-mcp` depends on `springtale-runtime`, not the +//! other way round). Each connected client gets one forwarder task, +//! started from `on_initialized`, holding that client's +//! [`Peer`](rmcp::service::Peer) and one subscription. The task +//! translates each in-scope event into one notification frame on that +//! client's stream and exits — pruning itself — as soon as the peer's +//! transport is gone or the runtime drops the channel. + +use std::future::Future; + +use rmcp::RoleServer; +use rmcp::service::Peer; +use springtale_runtime::tool_catalog::ToolCatalogEvent; +use tokio::sync::broadcast::Receiver; +use tokio::sync::broadcast::error::RecvError; + +/// The one thing a forwarder needs from a connected client. +/// +/// A trait rather than a bare `Peer` so the forward loop — +/// including its scope filter and its prune-on-dead-peer exit — is +/// testable without standing up a transport. +pub trait ToolListSink: Send + Sync + 'static { + /// Send one `notifications/tools/list_changed`. Returns `false` if + /// the frame did not reach the client. + fn send_tool_list_changed(&self) -> impl Future + Send; + + /// Whether the client's transport is gone for good. Distinguishes a + /// disconnected client (stop forwarding) from a send that merely + /// failed this once (keep forwarding). + fn is_closed(&self) -> bool; +} + +impl ToolListSink for Peer { + async fn send_tool_list_changed(&self) -> bool { + match Peer::notify_tool_list_changed(self).await { + Ok(()) => true, + // A disconnected client is the ordinary end of a session, + // not an error worth surfacing: log at debug and let the + // caller drop this forwarder. + Err(e) => { + tracing::debug!(error = %e, "tools/list_changed frame not delivered"); + false + } + } + } + + fn is_closed(&self) -> bool { + Peer::is_transport_closed(self) + } +} + +/// Whether a catalog event concerns a server with this scope. +/// +/// A server scoped to one connector (`SpringtaleMcp::for_connector`) +/// only lists that connector's actions, so a change to a different +/// connector cannot have changed its list. +pub fn in_scope(scope: Option<&str>, event: &ToolCatalogEvent) -> bool { + scope.is_none_or(|s| s == event.connector) +} + +/// What happened to one delivery attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Delivery { + /// The client got the frame. + Sent, + /// The send failed but the session is still live — a client that + /// has not opened its SSE stream yet is the ordinary case. Keeping + /// the forwarder alive means one early miss does not silently + /// disable list-changed notifications for the rest of the session. + Missed, + /// The transport is gone. Stop forwarding and let the task end, + /// which is how dead peers get pruned. + Disconnected, +} + +/// Send one frame and classify the outcome. +async fn deliver(sink: &S) -> Delivery { + if sink.is_closed() { + return Delivery::Disconnected; + } + if sink.send_tool_list_changed().await { + return Delivery::Sent; + } + if sink.is_closed() { + Delivery::Disconnected + } else { + Delivery::Missed + } +} + +/// Forward catalog changes to one client until it or the runtime goes +/// away. +/// +/// Returns the number of notifications actually delivered, which is what +/// the tests assert on. +pub async fn forward_tool_list_changes( + mut events: Receiver, + sink: S, + scope: Option, +) -> usize { + let mut sent = 0usize; + loop { + let outcome = match events.recv().await { + Ok(event) => { + if !in_scope(scope.as_deref(), &event) { + continue; + } + deliver(&sink).await + } + // Overflow means we missed events but still know the list + // moved. The notification carries no payload, so one frame + // covers every dropped event; a scoped server notifies too + // rather than guess whether the lost events were in scope. + Err(RecvError::Lagged(skipped)) => { + tracing::debug!(skipped, "tool catalog subscriber lagged; notifying anyway"); + deliver(&sink).await + } + // The runtime dropped the channel — the process is shutting + // down. Nothing left to forward. + Err(RecvError::Closed) => break, + }; + match outcome { + Delivery::Sent => sent += 1, + Delivery::Missed => {} + Delivery::Disconnected => break, + } + } + sent +} + +/// Start a forwarder for one connected client. +/// +/// Detached on purpose: it owns only a `Peer` clone and a broadcast +/// receiver, and it ends on its own when either side disappears, so +/// there is no handle worth keeping. +pub fn spawn_tool_list_forwarder( + events: Receiver, + sink: S, + scope: Option, +) { + tokio::spawn(async move { + let sent = forward_tool_list_changes(events, sink, scope).await; + tracing::debug!(sent, "MCP tools/list_changed forwarder finished"); + }); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use springtale_runtime::tool_catalog::{ToolCatalogChange, ToolCatalogNotifier}; + + use super::*; + + /// Counts frames. `closed` models a client that went away; + /// `fail_once` models a single failed send on a still-live session + /// (a client that has not opened its SSE stream yet), clearing + /// itself so the next send succeeds. + #[derive(Clone, Default)] + struct CountingSink { + sent: Arc, + closed: Arc, + fail_once: Arc, + } + + impl ToolListSink for CountingSink { + async fn send_tool_list_changed(&self) -> bool { + if self.closed.load(Ordering::SeqCst) { + return false; + } + if self.fail_once.swap(false, Ordering::SeqCst) { + return false; + } + self.sent.fetch_add(1, Ordering::SeqCst); + true + } + + fn is_closed(&self) -> bool { + self.closed.load(Ordering::SeqCst) + } + } + + fn event(connector: &str) -> ToolCatalogEvent { + ToolCatalogEvent { + connector: connector.to_owned(), + change: ToolCatalogChange::Installed, + } + } + + #[tokio::test] + async fn test_forward_unscoped_notifies_every_change() { + let notifier = ToolCatalogNotifier::new(); + let rx = notifier.subscribe(); + let sink = CountingSink::default(); + + notifier.notify("github", ToolCatalogChange::Installed); + notifier.notify("telegram", ToolCatalogChange::Removed); + notifier.notify("github", ToolCatalogChange::Disabled); + drop(notifier); + + let sent = forward_tool_list_changes(rx, sink.clone(), None).await; + assert_eq!(sent, 3); + assert_eq!(sink.sent.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn test_forward_scoped_ignores_other_connectors() { + let notifier = ToolCatalogNotifier::new(); + let rx = notifier.subscribe(); + let sink = CountingSink::default(); + + notifier.notify("github", ToolCatalogChange::Installed); + notifier.notify("telegram", ToolCatalogChange::Removed); + drop(notifier); + + let sent = forward_tool_list_changes(rx, sink, Some("github".to_owned())).await; + assert_eq!(sent, 1); + } + + #[tokio::test] + async fn test_forward_stops_when_client_disconnects() { + let notifier = ToolCatalogNotifier::new(); + let rx = notifier.subscribe(); + let sink = CountingSink::default(); + sink.closed.store(true, Ordering::SeqCst); + + notifier.notify("github", ToolCatalogChange::Installed); + notifier.notify("github", ToolCatalogChange::Removed); + + // The loop must exit on the first dead-peer send rather than + // spinning on a channel the runtime still holds open. + let sent = forward_tool_list_changes(rx, sink.clone(), None).await; + assert_eq!(sent, 0); + assert_eq!(sink.sent.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn test_forward_survives_a_transient_send_failure() { + let notifier = ToolCatalogNotifier::new(); + let rx = notifier.subscribe(); + let sink = CountingSink::default(); + sink.fail_once.store(true, Ordering::SeqCst); + + notifier.notify("github", ToolCatalogChange::Installed); + notifier.notify("github", ToolCatalogChange::Removed); + drop(notifier); + + // The first frame is lost, but the forwarder must still be + // alive to deliver the second. + let sent = forward_tool_list_changes(rx, sink.clone(), None).await; + assert_eq!(sent, 1); + assert_eq!(sink.sent.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_forward_exits_when_runtime_drops_the_channel() { + let notifier = ToolCatalogNotifier::new(); + let rx = notifier.subscribe(); + drop(notifier); + + assert_eq!( + forward_tool_list_changes(rx, CountingSink::default(), None).await, + 0 + ); + } + + #[tokio::test] + async fn test_notify_does_not_fail_when_forwarder_is_gone() { + let notifier = ToolCatalogNotifier::new(); + let rx = notifier.subscribe(); + drop(rx); + // Dropping the only subscriber must leave the publisher usable — + // a connector install cannot fail because a client hung up. + notifier.notify("github", ToolCatalogChange::Installed); + assert_eq!(notifier.subscriber_count(), 0); + } + + #[test] + fn test_in_scope_filters_by_connector() { + assert!(in_scope(None, &event("github"))); + assert!(in_scope(Some("github"), &event("github"))); + assert!(!in_scope(Some("github"), &event("telegram"))); + } +} diff --git a/crates/springtale-mcp/src/server/registry.rs b/crates/springtale-mcp/src/server/registry.rs index 90b080ff..67723504 100644 --- a/crates/springtale-mcp/src/server/registry.rs +++ b/crates/springtale-mcp/src/server/registry.rs @@ -65,6 +65,18 @@ impl SpringtaleMcp { self.scope.as_deref() } + /// Subscribe to the runtime's tool-catalog change fan-out. + /// + /// One subscription per connected client, taken in + /// `on_initialized`. The events are protocol-free — the runtime + /// cannot depend on `rmcp` — so `server::notify` is what turns them + /// into `notifications/tools/list_changed` frames. + pub fn subscribe_tool_catalog( + &self, + ) -> tokio::sync::broadcast::Receiver { + self.runtime.tool_catalog.subscribe() + } + /// Whether `connector` is inside this server's scope. fn in_scope(&self, connector: &str) -> bool { self.scope.as_deref().is_none_or(|s| s == connector) diff --git a/crates/springtale-runtime/src/init.rs b/crates/springtale-runtime/src/init.rs index da2c3199..7d3aaa8b 100644 --- a/crates/springtale-runtime/src/init.rs +++ b/crates/springtale-runtime/src/init.rs @@ -438,6 +438,7 @@ pub async fn init( chat_tx, chat_rx: Arc::new(tokio::sync::Mutex::new(Some(chat_rx))), chat_tasks: Arc::new(dashmap::DashMap::new()), + tool_catalog: crate::tool_catalog::ToolCatalogNotifier::new(), _lock: lock, }; diff --git a/crates/springtale-runtime/src/lib.rs b/crates/springtale-runtime/src/lib.rs index b2f19f64..f1420b7d 100644 --- a/crates/springtale-runtime/src/lib.rs +++ b/crates/springtale-runtime/src/lib.rs @@ -50,6 +50,7 @@ pub mod operations; pub mod quota; pub mod state; pub mod tasks; +pub mod tool_catalog; pub mod triggers; pub mod utterance_ring; @@ -69,4 +70,5 @@ pub use notification::NotificationEvent; pub use quota::SqliteTokenQuota; pub use state::{LiveFormationReader, RuntimeState}; pub use tasks::TaskHandles; +pub use tool_catalog::{ToolCatalogChange, ToolCatalogEvent, ToolCatalogNotifier}; pub use triggers::{TriggerRegistry, activate_rule, deactivate_rule, wire_connector_events}; diff --git a/crates/springtale-runtime/src/operations/connectors/install.rs b/crates/springtale-runtime/src/operations/connectors/install.rs index 213637dc..bb3f2796 100644 --- a/crates/springtale-runtime/src/operations/connectors/install.rs +++ b/crates/springtale-runtime/src/operations/connectors/install.rs @@ -47,6 +47,11 @@ pub async fn install_connector( // reference them (§14.4 / Phase 21). crate::cooperation::register_manifest_roles(&state.role_registry, &manifest); + // No `tool_catalog` notification here on purpose: this path writes + // a manifest row to the store and does not touch the live registry, + // so `tools/list` is unchanged until the connector is actually + // loaded (`setup_connector`, or the next boot's `init_registry`) — + // and those paths notify. let name = manifest.name; tracing::info!(connector = %name, "connector manifest registered"); Ok(name) @@ -96,6 +101,16 @@ pub async fn install_wasm_connector( .map_err(|e| OperationError::Connector(format!("WASM install failed: {e}")))? }; + // A WASM install lands directly in the live registry, so its actions + // appear in `tools/list` immediately. Published here rather than at + // the end of the function because the registry has already changed — + // a later persistence failure must not leave connected MCP clients + // holding a stale list. + state.tool_catalog.notify( + ®istered_name, + crate::tool_catalog::ToolCatalogChange::Installed, + ); + // Fold any community roles declared in the manifest into the shared // registry (Phase 21). For WASM connectors this is the main path — // the role definitions live in the manifest, not in Rust code. diff --git a/crates/springtale-runtime/src/operations/connectors/mod.rs b/crates/springtale-runtime/src/operations/connectors/mod.rs index 0d293840..39e14387 100644 --- a/crates/springtale-runtime/src/operations/connectors/mod.rs +++ b/crates/springtale-runtime/src/operations/connectors/mod.rs @@ -60,6 +60,14 @@ pub async fn enable_connector(state: &RuntimeState, name: &str) -> Result<(), Op .enable(name) .map_err(|e| OperationError::Connector(format!("failed to enable {name}: {e}")))?; } + // A disabled connector is absent from `tools/list`, so enabling it + // grows the catalog any connected MCP client cached. Published here + // rather than after `wire_chat` because the registry has already + // changed — a chat-wiring failure must not leave clients holding a + // list that no longer matches what `call_tool` will accept. + state + .tool_catalog + .notify(name, crate::tool_catalog::ToolCatalogChange::Enabled); // Enabling a chat connector starts its receive loop. chat::wire_chat(state, name).await } @@ -69,10 +77,16 @@ pub async fn disable_connector(state: &RuntimeState, name: &str) -> Result<(), O // Stop the receive loop first: a disabled connector must not keep // pushing messages at the bot. chat::unwire_chat(state, name); - let mut registry = state.registry.write().await; - registry - .disable(name) - .map_err(|e| OperationError::Connector(format!("failed to disable {name}: {e}"))) + { + let mut registry = state.registry.write().await; + registry + .disable(name) + .map_err(|e| OperationError::Connector(format!("failed to disable {name}: {e}")))?; + } + state + .tool_catalog + .notify(name, crate::tool_catalog::ToolCatalogChange::Disabled); + Ok(()) } /// Remove a connector — from registry, store, and config. @@ -103,6 +117,11 @@ pub async fn remove_connector(state: &RuntimeState, name: &str) -> Result<(), Op // Mark as explicitly removed so init_registry won't auto-load it let removed_key = format!("connector-removed:{name}"); let _ = state.store.set_config(&removed_key, "true").await; + // Its actions are gone from `tools/list` — tell connected MCP + // clients before they call a tool that no longer exists. + state + .tool_catalog + .notify(name, crate::tool_catalog::ToolCatalogChange::Removed); Ok(()) } @@ -177,6 +196,10 @@ pub async fn remove_connector_cascade( let removed_key = format!("connector-removed:{name}"); let _ = state.store.set_config(&removed_key, "true").await; + state + .tool_catalog + .notify(name, crate::tool_catalog::ToolCatalogChange::Removed); + tracing::info!( connector = name, rules_deleted = deleted_ids.len(), diff --git a/crates/springtale-runtime/src/operations/connectors/reload.rs b/crates/springtale-runtime/src/operations/connectors/reload.rs index 400eccee..6a3df0c3 100644 --- a/crates/springtale-runtime/src/operations/connectors/reload.rs +++ b/crates/springtale-runtime/src/operations/connectors/reload.rs @@ -100,6 +100,13 @@ pub async fn reload_connector(state: &RuntimeState, name: &str) -> Result<(), Op } } + // The rebuilt host may declare a different action set than the one + // an MCP client cached at initialization. Published as soon as the + // swap lands, ahead of the fallible chat re-wiring below. + state + .tool_catalog + .notify(name, crate::tool_catalog::ToolCatalogChange::Reloaded); + tracing::info!(connector = name, was_enabled, "connector hot-reloaded"); // The rebuilt connector owns a fresh ChatSource — stop the old // loop and start the new one. diff --git a/crates/springtale-runtime/src/operations/connectors/setup.rs b/crates/springtale-runtime/src/operations/connectors/setup.rs index 66df3394..309db3f9 100644 --- a/crates/springtale-runtime/src/operations/connectors/setup.rs +++ b/crates/springtale-runtime/src/operations/connectors/setup.rs @@ -46,6 +46,15 @@ pub async fn setup_connector( .map_err(|e| OperationError::Connector(format!("failed to install {name}: {e}")))? }; + // The registry grew (or a re-configure changed the action set), so + // any MCP client's cached tool list is now stale. Published as soon + // as the registry changed, ahead of the fallible config persist and + // chat wiring below. + state.tool_catalog.notify( + ®istered_name, + crate::tool_catalog::ToolCatalogChange::Installed, + ); + // Persist config for next boot — key uses the incoming name, matching // get_connector_config() and remove_connector() which also use {name}. let key = format!("connector:{name}"); diff --git a/crates/springtale-runtime/src/state.rs b/crates/springtale-runtime/src/state.rs index 58f1d357..634a8302 100644 --- a/crates/springtale-runtime/src/state.rs +++ b/crates/springtale-runtime/src/state.rs @@ -181,6 +181,15 @@ pub struct RuntimeState { /// the first `take_chat_rx()`. pub chat_rx: Arc>>>, + /// Fan-out for "the connector tool list changed" (`crate::tool_catalog`). + /// Every operation that adds, removes, enables, disables or rebuilds a + /// live registry entry publishes here; `springtale-mcp` subscribes once + /// per connected MCP client and turns each event into a + /// `notifications/tools/list_changed` frame, which is the promise the + /// server's advertised `tools.listChanged` capability makes. Owned by + /// the runtime because the registry is, and because the MCP crate sits + /// above it in the dependency order. + pub tool_catalog: crate::tool_catalog::ToolCatalogNotifier, /// Running chat loops: connector name → shutdown signal. Flipping /// the sender stops that connector's `ChatSource::run`. pub chat_tasks: Arc>>, diff --git a/crates/springtale-runtime/src/tool_catalog.rs b/crates/springtale-runtime/src/tool_catalog.rs new file mode 100644 index 00000000..c4fadc9e --- /dev/null +++ b/crates/springtale-runtime/src/tool_catalog.rs @@ -0,0 +1,209 @@ +//! Tool-catalog change fan-out — "the tool list you cached is stale". +//! +//! The daemon's MCP server advertises the `tools.listChanged` capability, +//! and the MCP spec is explicit about what that promises: a server that +//! declares it "SHOULD send a notification when the tool list changes" +//! (`notifications/tools/list_changed`). Without a signal a client that +//! called `tools/list` once at initialization keeps calling connectors +//! that were removed, and never sees connectors installed since. +//! +//! The connector registry lives in [`RuntimeState`](crate::state::RuntimeState) +//! and the MCP crate sits *above* the runtime in the dependency order, so +//! the runtime cannot call into `rmcp` to send the notification itself. +//! Instead it publishes a protocol-free [`ToolCatalogEvent`] here and +//! `springtale-mcp` subscribes per connected client, translating each +//! event into one `notifications/tools/list_changed` frame on that +//! client's stream. +//! +//! Mirror of the `canvas_tx` / `notification_tx` broadcast pattern +//! already on `RuntimeState`. Publishing never fails and never blocks: +//! with no MCP client attached there are no receivers, and +//! [`ToolCatalogNotifier::notify`] drops the event. + +use tokio::sync::broadcast; + +/// How many events the channel buffers per subscriber before a slow +/// client starts losing them. Connector installs are human-paced, so +/// this is generous; a lagged subscriber is handled by notifying +/// unconditionally rather than by replaying, so overflow costs a +/// redundant `tools/list` at worst. +const CHANNEL_CAPACITY: usize = 64; + +/// What happened to a connector, for logs and for scope filtering. +/// +/// The MCP notification itself carries no payload — it only says +/// "re-read the list" — so this exists to let a scoped server ignore +/// changes to connectors it does not serve, and to make the event +/// legible in tracing output. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolCatalogChange { + /// A connector was loaded into the live registry (configure & load, + /// or a WASM install). + Installed, + /// A connector was dropped from the live registry. + Removed, + /// A disabled connector became callable again. + Enabled, + /// A connector stopped being callable. Disabled connectors are + /// omitted from `tools/list`, so this changes the list. + Disabled, + /// A connector's host was rebuilt in place — its action set may + /// differ from the one the client cached. + Reloaded, +} + +impl ToolCatalogChange { + /// Lower-case label used in tracing fields. + pub fn as_str(self) -> &'static str { + match self { + Self::Installed => "installed", + Self::Removed => "removed", + Self::Enabled => "enabled", + Self::Disabled => "disabled", + Self::Reloaded => "reloaded", + } + } +} + +/// One change to the set of connector actions the runtime can dispatch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolCatalogEvent { + /// The connector whose entry changed. + pub connector: String, + /// What happened to it. + pub change: ToolCatalogChange, +} + +/// Publish/subscribe handle for [`ToolCatalogEvent`]s. +/// +/// Cheap to clone (a `broadcast::Sender` is an `Arc` inside), which is +/// what lets it sit on the cloneable `RuntimeState`. +#[derive(Clone, Debug)] +pub struct ToolCatalogNotifier { + tx: broadcast::Sender, +} + +impl ToolCatalogNotifier { + /// A notifier with no subscribers yet. + pub fn new() -> Self { + let (tx, _rx) = broadcast::channel(CHANNEL_CAPACITY); + Self { tx } + } + + /// Subscribe to future changes. Events published before this call + /// are not replayed — a client that subscribes at initialization has + /// just fetched the current list anyway. + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } + + /// Publish a change. + /// + /// Infallible by construction: `broadcast::Sender::send` errors only + /// when there are no receivers, which is the ordinary case (no MCP + /// client attached). Connector installs must not fail because + /// nobody was listening, so the error is dropped. + pub fn notify(&self, connector: impl Into, change: ToolCatalogChange) { + let event = ToolCatalogEvent { + connector: connector.into(), + change, + }; + tracing::debug!( + connector = %event.connector, + change = change.as_str(), + subscribers = self.tx.receiver_count(), + "tool catalog changed" + ); + let _ = self.tx.send(event); + } + + /// How many live subscribers there are. Diagnostic only. + pub fn subscriber_count(&self) -> usize { + self.tx.receiver_count() + } +} + +impl Default for ToolCatalogNotifier { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_notify_subscriber_receives_event() { + let notifier = ToolCatalogNotifier::new(); + let mut rx = notifier.subscribe(); + + notifier.notify("github", ToolCatalogChange::Installed); + + let event = rx.recv().await.expect("subscriber receives the event"); + assert_eq!( + event, + ToolCatalogEvent { + connector: "github".to_owned(), + change: ToolCatalogChange::Installed, + } + ); + } + + #[tokio::test] + async fn test_notify_with_no_subscribers_is_not_an_error() { + let notifier = ToolCatalogNotifier::new(); + assert_eq!(notifier.subscriber_count(), 0); + // Must not panic: a connector install with no MCP client + // attached is the common case. + notifier.notify("telegram", ToolCatalogChange::Removed); + } + + #[tokio::test] + async fn test_notify_after_subscriber_dropped_is_not_an_error() { + let notifier = ToolCatalogNotifier::new(); + let rx = notifier.subscribe(); + drop(rx); + assert_eq!(notifier.subscriber_count(), 0); + // A disconnected MCP client must not break the install path. + notifier.notify("slack", ToolCatalogChange::Disabled); + } + + #[tokio::test] + async fn test_multiple_subscribers_each_receive_the_event() { + let notifier = ToolCatalogNotifier::new(); + let mut a = notifier.subscribe(); + let mut b = notifier.subscribe(); + assert_eq!(notifier.subscriber_count(), 2); + + notifier.notify("kick", ToolCatalogChange::Enabled); + + assert_eq!( + a.recv().await.expect("first client").change, + ToolCatalogChange::Enabled + ); + assert_eq!( + b.recv().await.expect("second client").change, + ToolCatalogChange::Enabled + ); + } + + #[tokio::test] + async fn test_clone_shares_the_channel() { + let notifier = ToolCatalogNotifier::new(); + let mut rx = notifier.subscribe(); + let cloned = notifier.clone(); + + cloned.notify("nostr", ToolCatalogChange::Reloaded); + + assert_eq!( + rx.recv() + .await + .expect("clone publishes to the same channel"), + ToolCatalogEvent { + connector: "nostr".to_owned(), + change: ToolCatalogChange::Reloaded, + } + ); + } +} From 9e3057023e70514ff40975610f9634819352ac0e Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:31:23 -0700 Subject: [PATCH 19/24] refactor(webhooks): the ingest route knows no connector by name (plan 6.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route hard-coded Telegram: on a `callback_query_received` trigger it reached into the body and called `answer_callback_query` itself. `WebhookIngest` gains `acks` — `WebhookAck { action, input }` values a connector asks the ingress to run back on it. Telegram's `ingest_update` emits its own callback answer (mirroring what the polling chat source already did), and the route just loops over the acks through the same capability-checked `registry.execute`. Verification, responses, event log and trigger dispatch are unchanged and in the same order. A test scans the route module's own source for connector names so the coupling cannot come back. Two things ride along in this commit because they interleave with the above inside the same two files and hunks cannot be staged separately here: the Kick replay store check in the route (see the replay commit), and a real bug — the old code read the callback id at the body's top level, which a genuine Telegram Update never has, so webhook-mode button presses were never acknowledged. The connector now reads `callback_query.id` and still accepts the old shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtaled/src/api/webhooks.rs | 149 +++++++++++++++--- .../connector-telegram/src/connector.rs | 11 +- .../connector-telegram/src/webhook/ingest.rs | 117 +++++++++++--- .../src/webhook/ingest.rs | 72 ++++++++- .../springtale-connector/src/webhook/mod.rs | 4 +- 5 files changed, 302 insertions(+), 51 deletions(-) diff --git a/apps/springtaled/src/api/webhooks.rs b/apps/springtaled/src/api/webhooks.rs index 42b6e12b..c5aa0b34 100644 --- a/apps/springtaled/src/api/webhooks.rs +++ b/apps/springtaled/src/api/webhooks.rs @@ -3,6 +3,7 @@ use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::IntoResponse; +use springtale_connector::webhook::ReplayOutcome; use springtale_core::rule::engine::TriggerEvent; use springtale_store::schema::events::EventEntry; @@ -14,13 +15,18 @@ const MAX_JSON_DEPTH: usize = 64; /// POST /webhook/{connector}/{trigger} — receive an inbound webhook. /// -/// The management API receives webhook POSTs from external services (GitHub, Kick, etc.) -/// and routes them to the appropriate connector for signature verification and dispatch. +/// The management API receives webhook POSTs from external services and +/// routes them to the named connector for signature verification and dispatch. +/// +/// The route owns the transport and nothing else: it knows no connector, +/// no provider payload shape, and no action name. Everything protocol- +/// specific is asked of the connector through the `Connector` trait. /// /// Flow: /// 1. Look up connector in registry -/// 2. Connector-specific signature verification (GitHub: HMAC-SHA256, Kick: RSA) -/// 3. Dispatch trigger event to the rule engine via the trigger channel +/// 2. Connector-specific signature verification (each connector's own scheme) +/// 3. Ask the connector what the verified payload means +/// 4. Dispatch trigger event to the rule engine via the trigger channel #[utoipa::path( post, operation_id = "webhooks_receive", path = "/webhook/{connector}/{trigger}", @@ -64,7 +70,7 @@ pub async fn receive( // Verify webhook signature BEFORE dispatching. // Each connector implements verify_webhook() with its own scheme - // (GitHub: HMAC-SHA256, Kick: RSA, Telegram: secret token). + // (HMAC-SHA256, RSA, a shared secret header — the connector decides). // Connectors that don't support webhooks reject with an error. let header_map: std::collections::HashMap = headers .iter() @@ -84,6 +90,61 @@ pub async fn receive( return Err(StatusCode::UNAUTHORIZED); } + // Nothing below needs the registry — the host handle is cloned above + // and the replay check that follows awaits on the store. + drop(registry); + + // Durable replay protection. A signed webhook stays valid for as long + // as the provider's own window allows, so a captured request can be + // replayed verbatim; the delivery id is what makes it single-use. + // Connectors used to remember those ids in process memory, which meant + // every daemon reload and vault re-unlock reopened the window. The + // record now lives in the store and outlives the process. + // + // Ordering matters: this runs AFTER verification, so an unsigned + // request cannot poison the record, and BEFORE the event log, so a + // replay is not written down as a fresh delivery. + if let Some(replay_key) = host.webhook_replay_key(&header_map) { + match springtale_connector::webhook::replay::check_and_record( + &state.runtime.store, + &connector_name, + &replay_key, + ) + .await + { + Ok(ReplayOutcome::Fresh) => {} + Ok(ReplayOutcome::Replay) => { + tracing::warn!( + connector = %connector_name, + trigger = %trigger_name, + "webhook replay rejected (delivery id already seen)" + ); + // 200, not an error status: the delivery *was* handled + // the first time, and a provider that sees a failure + // will keep retrying the same replayed request. + return Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "status": "duplicate", + "connector": connector_name, + "trigger": trigger_name, + })), + )); + } + Err(e) => { + // Fail closed. An unrecorded delivery is a delivery that + // may be a replay, and a locked or broken store must not + // silently degrade into no replay protection at all. + tracing::error!( + connector = %connector_name, + error = %e, + "webhook replay check failed; refusing the delivery" + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } + // Store event in log (metadata only, NOT payload content per privacy model) let event = EventEntry { id: uuid::Uuid::new_v4(), @@ -99,15 +160,12 @@ pub async fn receive( // Broadcast to SSE subscribers (dashboard live event stream) let _ = state.event_tx.send(event); - // Drop the registry lock before sending to the channel - drop(registry); - // Ask the connector what this verified payload means. The daemon // owns the transport (route, signature, event log); the connector // owns the protocol. This used to be a `match` on one connector // name here, so webhook chat worked for exactly that connector and - // no other — Kick, whose chat only ever arrives by webhook, could - // not reach the bot at all. + // no other — a connector whose chat only ever arrives by webhook + // could not reach the bot at all. // // Polling-mode gateways reach the same bot channel through their own // ChatSource loop (see runtime operations/connectors/chat.rs). @@ -147,24 +205,23 @@ pub async fn receive( } } - // Acknowledge callback_query via answerCallbackQuery so the user's - // inline-keyboard button stops spinning. Polling mode handles this - // in runtime/connectors/telegram.rs; webhook mode needs it here. - if trigger_name == "callback_query_received" - && let Some(callback_id) = payload.get("id").and_then(|v| v.as_str()) - { - let ack_input = serde_json::json!({ - "callback_query_id": callback_id, - }); + // Acknowledgements the connector asked for: an action it wants run + // back on itself to complete this request, because its platform + // requires the inbound event be answered (an inline-button press + // that keeps spinning until it is, say) and only the connector knows + // that. This was a literal check on one connector's trigger name and + // one of its action names, so exactly one connector's webhooks could + // ever be acknowledged. The route now executes whatever the + // connector named, through the same capability-checked registry path + // any other action takes, and still knows neither. + for ack in ingest.acks { let reg = state.runtime.registry.read().await; - if let Err(e) = reg - .execute(&connector_name, "answer_callback_query", ack_input) - .await - { + if let Err(e) = reg.execute(&connector_name, &ack.action, ack.input).await { tracing::warn!( error = %e, connector = %connector_name, - "webhook: failed to answerCallbackQuery" + action = %ack.action, + "webhook: connector acknowledgement failed" ); } } @@ -239,3 +296,47 @@ fn json_depth(value: &serde_json::Value) -> usize { max_depth } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + /// Connector names, split in two so this assertion cannot match its + /// own source when it scans the file. + const SPLIT_CONNECTOR_NAMES: [(&str, &str); 8] = [ + ("tele", "gram"), + ("dis", "cord"), + ("sla", "ck"), + ("ki", "ck"), + ("git", "hub"), + ("nos", "tr"), + ("blue", "sky"), + ("sig", "nal"), + ]; + + /// The ingress owns the transport; connectors own their protocols. + /// A route that names one connector serves that connector only — + /// which is exactly how webhook chat came to work for one platform + /// and no other. Nothing here may name a connector or one of its + /// triggers or actions. + #[test] + fn test_route_source_names_no_connector() { + let src = include_str!("webhooks.rs").to_lowercase(); + for (head, tail) in SPLIT_CONNECTOR_NAMES { + let needle = format!("{head}{tail}"); + assert!( + !src.contains(&needle), + "webhook route names connector '{needle}' — ask the connector instead" + ); + } + for (head, tail) in [ + ("answer_", "callback_query"), + ("callback_query", "_received"), + ] { + let needle = format!("{head}{tail}"); + assert!( + !src.contains(&needle), + "webhook route names connector protocol detail '{needle}'" + ); + } + } +} diff --git a/connectors/connector-telegram/src/connector.rs b/connectors/connector-telegram/src/connector.rs index 6a7c64e1..a3c1497b 100644 --- a/connectors/connector-telegram/src/connector.rs +++ b/connectors/connector-telegram/src/connector.rs @@ -204,17 +204,18 @@ impl Connector for TelegramConnector { crate::triggers::normalize::normalize(trigger, &raw) } - /// Read a verified Telegram `Update` into the chat it carries. + /// Read a verified Telegram `Update` into the chat it carries, and + /// the `answerCallbackQuery` an inline-button press owes the user. /// - /// The daemon used to do this itself, in a `match` on the connector - /// name — see [`crate::webhook::ingest_update`]. + /// The daemon used to do both itself, keyed off the connector name — + /// see [`crate::webhook::ingest_update`]. async fn ingest_webhook( &self, - _trigger: &str, + trigger: &str, _headers: &std::collections::HashMap, payload: &serde_json::Value, ) -> springtale_connector::webhook::WebhookIngest { - crate::webhook::ingest_update(payload) + crate::webhook::ingest_update(trigger, payload) } /// Verify an incoming webhook request using the `X-Telegram-Bot-Api-Secret-Token` header. diff --git a/connectors/connector-telegram/src/webhook/ingest.rs b/connectors/connector-telegram/src/webhook/ingest.rs index 4d9d646e..79a04589 100644 --- a/connectors/connector-telegram/src/webhook/ingest.rs +++ b/connectors/connector-telegram/src/webhook/ingest.rs @@ -8,23 +8,33 @@ use serde_json::Value; use springtale_connector::chat::ChatMessage; -use springtale_connector::webhook::WebhookIngest; +use springtale_connector::webhook::{WebhookAck, WebhookIngest}; use crate::chat::CONNECTOR_NAME; -/// Read one verified Telegram `Update` into the chat messages it means. +/// Action that answers an inline-button press. Telegram times the press +/// out after ten seconds, after which the user's button spins forever. +const ANSWER_CALLBACK_QUERY: &str = "answer_callback_query"; + +/// Trigger the webhook route uses for an inline-button press. +const CALLBACK_TRIGGER: &str = "callback_query_received"; + +/// Read one verified Telegram `Update` into the chat messages it means, +/// plus the `answerCallbackQuery` an inline-button press owes the user. /// -/// Mirrors the polling dispatcher's field extraction -/// ([`crate::chat::TelegramChatSource`]) so webhook-mode and -/// polling-mode chat reach the bot identically. +/// Mirrors the polling dispatcher's field extraction and its immediate +/// acknowledgement ([`crate::chat::TelegramChatSource`]) so webhook-mode +/// and polling-mode chat behave identically. The acknowledgement used to +/// live in the daemon's HTTP route as a literal check on this trigger +/// name and this action name — the one connector the route knew. /// /// No rule events are attached: the webhook ingress dispatches the /// route's own `ConnectorEvent`, so returning it again would fire every /// matching recipe twice. #[must_use] -pub fn ingest_update(payload: &Value) -> WebhookIngest { - if let Some(message) = payload.get("message") { - return match message_fields(message) { +pub fn ingest_update(trigger: &str, payload: &Value) -> WebhookIngest { + let ingest = if let Some(message) = payload.get("message") { + match message_fields(message) { Some((channel_id, user_id, text)) => WebhookIngest::message(ChatMessage::chat( CONNECTOR_NAME, channel_id, @@ -33,13 +43,11 @@ pub fn ingest_update(payload: &Value) -> WebhookIngest { payload.clone(), )), None => WebhookIngest::empty(), - }; - } - - // Inline keyboard button press: the callback data is the text, so - // handlers treat it as a command-like input. - if let Some(callback) = payload.get("callback_query") { - return match callback_fields(callback) { + } + } else if let Some(callback) = payload.get("callback_query") { + // Inline keyboard button press: the callback data is the text, so + // handlers treat it as a command-like input. + match callback_fields(callback) { Some((channel_id, user_id, text)) => WebhookIngest::message(ChatMessage::chat( CONNECTOR_NAME, channel_id, @@ -48,10 +56,41 @@ pub fn ingest_update(payload: &Value) -> WebhookIngest { payload.clone(), )), None => WebhookIngest::empty(), - }; + } + } else { + WebhookIngest::empty() + }; + + match callback_query_id(trigger, payload) { + Some(id) => ingest.with_ack(WebhookAck::new( + ANSWER_CALLBACK_QUERY, + serde_json::json!({ "callback_query_id": id }), + )), + None => ingest, } +} - WebhookIngest::empty() +/// The `callback_query.id` that has to be answered, if this payload is a +/// button press. +/// +/// Two shapes are accepted. A genuine Telegram webhook posts an `Update`, +/// so the id sits under `callback_query`. The daemon route this replaced +/// read a top-level `id` instead, which is the shape a caller posting a +/// bare `callback_query` object sends; that reading is kept, still gated +/// on the trigger the route gated it on, so nothing that worked before +/// stops working. +fn callback_query_id<'a>(trigger: &str, payload: &'a Value) -> Option<&'a str> { + if let Some(id) = payload + .get("callback_query") + .and_then(|cb| cb.get("id")) + .and_then(Value::as_str) + { + return Some(id); + } + if trigger == CALLBACK_TRIGGER { + return payload.get("id").and_then(Value::as_str); + } + None } /// `(channel_id, user_id, text)` from a Telegram `message` object. @@ -97,7 +136,7 @@ mod tests { "text": "/help" } }); - let ingest = ingest_update(&update); + let ingest = ingest_update("message_received", &update); assert_eq!(ingest.messages.len(), 1); let msg = &ingest.messages[0]; assert_eq!(msg.connector, CONNECTOR_NAME); @@ -105,6 +144,7 @@ mod tests { assert_eq!(msg.channel_id, "-100"); assert_eq!(msg.text, "/help"); assert!(ingest.events.is_empty()); + assert!(ingest.acks.is_empty()); } #[test] @@ -117,15 +157,52 @@ mod tests { "data": "confirm" } }); - let ingest = ingest_update(&update); + let ingest = ingest_update("callback_query_received", &update); assert_eq!(ingest.messages.len(), 1); assert_eq!(ingest.messages[0].text, "confirm"); assert_eq!(ingest.messages[0].channel_id, "9"); } + /// The acknowledgement the HTTP route used to hard-code now comes + /// from the connector that owns the protocol. + #[test] + fn test_ingest_update_callback_query_asks_for_answer_callback_query() { + let update = serde_json::json!({ + "callback_query": { + "id": "cb1", + "from": { "id": 7 }, + "message": { "chat": { "id": 9 } }, + "data": "confirm" + } + }); + let ingest = ingest_update("callback_query_received", &update); + assert_eq!(ingest.acks.len(), 1); + assert_eq!(ingest.acks[0].action, "answer_callback_query"); + assert_eq!(ingest.acks[0].input["callback_query_id"], "cb1"); + } + + /// The shape the old daemon route read: a bare callback_query object + /// with the id at the top level, gated on the trigger name. + #[test] + fn test_ingest_update_bare_callback_payload_still_acknowledged() { + let payload = serde_json::json!({ "id": "cb2", "data": "confirm" }); + let ingest = ingest_update("callback_query_received", &payload); + assert_eq!(ingest.acks.len(), 1); + assert_eq!(ingest.acks[0].input["callback_query_id"], "cb2"); + assert!(ingest.messages.is_empty()); + } + + /// A plain message carries a top-level `id` in some payload shapes; + /// it must never be answered as a button press. + #[test] + fn test_ingest_update_message_trigger_never_acknowledges() { + let payload = serde_json::json!({ "id": "not-a-callback" }); + assert!(ingest_update("message_received", &payload).acks.is_empty()); + } + #[test] fn test_ingest_update_unknown_shape_returns_empty() { let update = serde_json::json!({ "edited_channel_post": { "text": "x" } }); - assert!(ingest_update(&update).is_empty()); + assert!(ingest_update("message_received", &update).is_empty()); } } diff --git a/crates/springtale-connector/src/webhook/ingest.rs b/crates/springtale-connector/src/webhook/ingest.rs index 6a0bd3e1..4c638ced 100644 --- a/crates/springtale-connector/src/webhook/ingest.rs +++ b/crates/springtale-connector/src/webhook/ingest.rs @@ -28,6 +28,38 @@ impl WebhookEvent { } } +/// One action the ingress should execute back on the connector that +/// produced this ingest, to complete the request the platform sent. +/// +/// Some chat platforms require the receiver to answer a specific inbound +/// event inside a timeout — an inline-button press has to be +/// acknowledged or the user's button spins until the platform gives up. +/// That answer is protocol knowledge, so the connector names it; the +/// ingress only executes it, through the same capability-checked +/// registry path any other action takes, without knowing what it is. +/// +/// The daemon used to hold one connector's version of this as a literal +/// `if trigger == "..."` in the HTTP route, which is why exactly one +/// connector's webhooks could be acknowledged and no other's could. +#[derive(Debug, Clone)] +pub struct WebhookAck { + /// Action name, as declared in + /// [`crate::connector::trait_::Connector::actions`]. + pub action: String, + /// Input for that action. + pub input: serde_json::Value, +} + +impl WebhookAck { + /// Build an acknowledgement from an action name and its input. + pub fn new(action: impl Into, input: serde_json::Value) -> Self { + Self { + action: action.into(), + input, + } + } +} + /// The result of reading a verified webhook payload. /// /// Both halves reuse the platform's existing types: `messages` are the @@ -41,6 +73,9 @@ pub struct WebhookIngest { pub messages: Vec, /// Additional rule-engine events the payload carries. pub events: Vec, + /// Actions the ingress runs back on this connector to complete the + /// request (see [`WebhookAck`]). + pub acks: Vec, } impl WebhookIngest { @@ -57,12 +92,47 @@ impl WebhookIngest { Self { messages: vec![msg], events: Vec::new(), + acks: Vec::new(), } } + /// Attach an acknowledgement the ingress should execute back on this + /// connector (see [`WebhookAck`]). + #[must_use] + pub fn with_ack(mut self, ack: WebhookAck) -> Self { + self.acks.push(ack); + self + } + /// Whether this ingest carries nothing at all. #[must_use] pub fn is_empty(&self) -> bool { - self.messages.is_empty() && self.events.is_empty() + self.messages.is_empty() && self.events.is_empty() && self.acks.is_empty() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn test_empty_ingest_carries_no_acks() { + let ingest = WebhookIngest::empty(); + assert!(ingest.acks.is_empty()); + assert!(ingest.is_empty()); + } + + #[test] + fn test_with_ack_records_action_and_input() { + let ingest = WebhookIngest::empty().with_ack(WebhookAck::new( + "ack_action", + serde_json::json!({ "id": "x" }), + )); + assert_eq!(ingest.acks.len(), 1); + assert_eq!(ingest.acks[0].action, "ack_action"); + assert_eq!(ingest.acks[0].input["id"], "x"); + // An ack alone is still something to do. + assert!(!ingest.is_empty()); } } diff --git a/crates/springtale-connector/src/webhook/mod.rs b/crates/springtale-connector/src/webhook/mod.rs index d1e66850..118a7f84 100644 --- a/crates/springtale-connector/src/webhook/mod.rs +++ b/crates/springtale-connector/src/webhook/mod.rs @@ -12,5 +12,7 @@ //! result — no connector names in the daemon. pub mod ingest; +pub mod replay; -pub use ingest::{WebhookEvent, WebhookIngest}; +pub use ingest::{WebhookAck, WebhookEvent, WebhookIngest}; +pub use replay::{REPLAY_BUCKET, REPLAY_HISTORY, ReplayOutcome, check_and_record}; From 351df9adbfa98398da967d822bf16c29641b03d8 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:31:23 -0700 Subject: [PATCH 20/24] fix(config): any installed connector is configurable headless (plan 6.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extract_connector_configs` matched fourteen literal keys, so a connector added later could not be configured from the file at all — the daemon silently ignored its table. It now enumerates the factory registry (`factory::config_keys()` over the `inventory` entries), so whatever is installed is configurable, and accepts `[connectors.]` as well as the historical bare `[]`. No alias map was needed: all fourteen old keys are exact factory config keys, and a test asserts that and says to add an alias if one ever diverges. A second test extracts a key outside the old fourteen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtaled/src/config.rs | 140 ++++++++++++++++-- .../springtale-connector/src/factory/keys.rs | 46 ++++++ .../springtale-connector/src/factory/mod.rs | 2 + springtale.toml.example | 22 +++ 4 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 crates/springtale-connector/src/factory/keys.rs diff --git a/apps/springtaled/src/config.rs b/apps/springtaled/src/config.rs index 8d791988..dfa4ad1d 100644 --- a/apps/springtaled/src/config.rs +++ b/apps/springtaled/src/config.rs @@ -35,9 +35,10 @@ pub struct SpringtaleConfig { #[garde(skip)] pub heartbeat_interval_secs: u64, // Chat connectors are NOT typed fields here (plan 6.4): every - // `[telegram]` / `[discord]` / … table is picked up verbatim by - // `extract_connector_configs` and installed through the same - // `setup_connector` path a runtime install takes, so the daemon + // `[connectors.telegram]` (or bare `[telegram]`) table is picked up + // verbatim by `extract_connector_configs`, for whatever connectors + // are actually installed, and installed through the same + // `setup_connector` path a runtime install takes — so the daemon // holds no per-connector knowledge. The bot's own persona / context // window / tool policy are runtime settings (plan 6.3), not config. /// Sentinel behavioral monitor configuration. If absent, uses defaults. @@ -214,10 +215,54 @@ pub struct LoadedConfig { /// Each connector factory declares a `config_key()` (e.g., "telegram"). /// We extract that key from the Figment source as `serde_json::Value`, /// preserving raw strings for Secret fields. +/// +/// Which keys to look for comes from the compile-time factory registry +/// (`springtale_connector::factory::config_keys`), not from a list +/// written here. The list used to be written here, so a connector added +/// after it was written could not be configured from the file at all — +/// its table was read by nobody, silently. A connector that is installed +/// is now configurable, by construction. +/// +/// Two table shapes are accepted per connector, the namespaced one +/// winning when both are present: +/// +/// ```toml +/// [connectors.telegram] # namespaced — cannot collide with daemon config +/// bot_token = "..." +/// +/// [telegram] # bare — the historical shape, still read +/// bot_token = "..." +/// ``` fn extract_connector_configs( figment: &Figment, ) -> std::collections::HashMap { - let keys = [ + let mut configs = std::collections::HashMap::new(); + for key in springtale_connector::factory::config_keys() { + // Namespaced first: an explicit `[connectors.x]` is unambiguous, + // so it wins over a bare `[x]` table of the same name. + if let Ok(val) = figment.extract_inner::(&format!("connectors.{key}")) { + configs.insert(key.to_owned(), val); + continue; + } + if let Ok(val) = figment.extract_inner::(key) { + configs.insert(key.to_owned(), val); + } + } + configs +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use figment::providers::Format; + + /// The connector config keys the daemon hard-coded before the list + /// came from the registry. Every one has to keep resolving: a config + /// file that worked must not go quietly unread. If a connector ever + /// renames its `config_key`, this fails — map the old name to the new + /// one here rather than dropping it. + const HISTORICAL_KEYS: [&str; 14] = [ "telegram", "nostr", "irc", @@ -233,11 +278,88 @@ fn extract_connector_configs( "shell", "browser", ]; - let mut configs = std::collections::HashMap::new(); - for key in keys { - if let Ok(val) = figment.extract_inner::(key) { - configs.insert(key.to_string(), val); + + fn figment_from(toml: &str) -> Figment { + Figment::new().merge(Toml::string(toml)) + } + + #[test] + fn test_registry_keys_cover_every_historical_key() { + let keys = springtale_connector::factory::config_keys(); + for key in HISTORICAL_KEYS { + assert!( + keys.contains(&key), + "config key '{key}' no longer resolves to an installed connector — \ + add an alias so existing config files keep working" + ); } } - configs + + #[test] + fn test_extract_connector_configs_reads_every_historical_key() { + let toml: String = HISTORICAL_KEYS + .iter() + .map(|k| format!("[{k}]\nprobe = \"set\"\n")) + .collect(); + let configs = extract_connector_configs(&figment_from(&toml)); + for key in HISTORICAL_KEYS { + assert_eq!( + configs.get(key).and_then(|v| v.get("probe")), + Some(&serde_json::Value::String("set".to_owned())), + "historical key '{key}' stopped being extracted" + ); + } + } + + /// The point of the change: a connector outside the fourteen the + /// daemon used to know is configurable from the file. + #[test] + fn test_extract_connector_configs_reads_keys_beyond_the_historical_list() { + let beyond: Vec<&'static str> = springtale_connector::factory::config_keys() + .into_iter() + .filter(|k| !HISTORICAL_KEYS.contains(k)) + .collect(); + assert!( + !beyond.is_empty(), + "no compiled-in connector outside the fourteen hard-coded keys — \ + this test needs one to mean anything" + ); + for key in beyond { + let configs = + extract_connector_configs(&figment_from(&format!("[{key}]\nprobe = \"set\"\n"))); + assert!( + configs.contains_key(key), + "connector '{key}' is installed but its config table was ignored" + ); + } + } + + #[test] + fn test_extract_connector_configs_reads_namespaced_table() { + let configs = extract_connector_configs(&figment_from( + "[connectors.telegram]\nbot_token = \"namespaced\"\n", + )); + assert_eq!( + configs["telegram"]["bot_token"], + serde_json::Value::String("namespaced".to_owned()) + ); + } + + #[test] + fn test_extract_connector_configs_namespaced_table_wins() { + let configs = extract_connector_configs(&figment_from( + "[telegram]\nbot_token = \"bare\"\n\n[connectors.telegram]\nbot_token = \"namespaced\"\n", + )); + assert_eq!( + configs["telegram"]["bot_token"], + serde_json::Value::String("namespaced".to_owned()) + ); + } + + #[test] + fn test_extract_connector_configs_ignores_unknown_table() { + let configs = + extract_connector_configs(&figment_from("[not_a_connector]\nprobe = \"set\"\n")); + assert!(!configs.contains_key("not_a_connector")); + } } diff --git a/crates/springtale-connector/src/factory/keys.rs b/crates/springtale-connector/src/factory/keys.rs new file mode 100644 index 00000000..1bc72234 --- /dev/null +++ b/crates/springtale-connector/src/factory/keys.rs @@ -0,0 +1,46 @@ +//! The config keys the compile-time factory registry declares. +//! +//! A headless deployment configures connectors from a TOML file, and the +//! daemon has to know which top-level tables in that file are connector +//! config. It used to know by holding a hand-written list of names, so a +//! connector added after that list was written could not be configured +//! from the file at all. The list is derived from the registry instead: +//! every factory that is compiled in declares its own key. + +use super::entry::FactoryEntry; + +/// Every `config_key` declared by a compiled-in connector factory, +/// sorted and deduplicated. +/// +/// Empty when no connector crate is linked into the binary — the +/// factories register themselves through `inventory::submit!`, so only +/// linked crates appear. +#[must_use] +pub fn config_keys() -> Vec<&'static str> { + let mut keys: Vec<&'static str> = inventory::iter:: + .into_iter() + .map(|entry| entry.factory.config_key()) + .collect(); + keys.sort_unstable(); + keys.dedup(); + keys +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + /// No connector crate depends on this one in reverse, so this crate's + /// own test binary links no factories: the contract under test is the + /// shape (sorted, deduplicated), not the contents. The daemon's test + /// suite covers the populated case. + #[test] + fn test_config_keys_are_sorted_and_unique() { + let keys = config_keys(); + let mut expected = keys.clone(); + expected.sort_unstable(); + expected.dedup(); + assert_eq!(keys, expected); + } +} diff --git a/crates/springtale-connector/src/factory/mod.rs b/crates/springtale-connector/src/factory/mod.rs index 6e725ed0..82a4a0f3 100644 --- a/crates/springtale-connector/src/factory/mod.rs +++ b/crates/springtale-connector/src/factory/mod.rs @@ -1,7 +1,9 @@ pub mod entry; +pub mod keys; pub mod onboarding; pub mod trait_; pub use entry::FactoryEntry; +pub use keys::config_keys; pub use onboarding::{FormField, PlatformForm}; pub use trait_::ConnectorFactory; diff --git a/springtale.toml.example b/springtale.toml.example index 9ddfd771..a6ca93cd 100644 --- a/springtale.toml.example +++ b/springtale.toml.example @@ -45,3 +45,25 @@ rate_limit_per_sec = 100 # label_key = "utter.firing" # ttl_ticks = 2 # block_ticks = 3 + +# Connector configuration, for a headless install with no UI. +# +# One table per connector, under `connectors`, named by the connector's +# config key — the same key the panel and the onboarding wizard write. +# The daemon reads a table for every connector that is compiled in, so a +# connector added later needs no change to the daemon to be configurable +# here. `springtale-cli connector available` prints the config key of +# every connector in your build; the bare `[telegram]` form is still +# read, for config files that already use it. +# +# Credentials in this file sit on disk in plaintext: prefer the vault +# (configure through the app, or via the API) and keep this for values +# you are content to leave readable, or protect the file at 0600. +# +# [connectors.telegram] +# bot_token = "123456:ABC-DEF..." +# update_mode = "polling" # polling | webhook +# +# [connectors.github] +# token = "ghp_..." +# webhook_secret = "..." From 48b195c7e3367d0de364cb799fc672809f2a5800 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:31:23 -0700 Subject: [PATCH 21/24] fix(kick): persist the webhook replay window across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replay cache was a process-memory `Mutex`, so a daemon reload or a vault unlock reopened the window an attacker could replay a captured webhook in. It moves to the store, reusing the existing `dedupe_seen` table through `StorageBackend::dedupe_check` (atomic insert-or-ignore with LRU eviction) rather than adding a table. Keys are blake3-hashed, per that table's documented privacy invariant. Connectors declare the key with a new optional `webhook_replay_key(headers)` on the connector and host traits — WASM keeps the `None` default — so the check stays generic; Kick returns `Kick-Event-Message-Id` and keeps only its timestamp check. The route runs the check after signature verification and before the event log, and fails closed on a store error. No connector gained a `springtale-store` dependency: the check lives in `springtale-connector`, which already depends on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- Cargo.lock | 1 + connectors/connector-kick/src/connector.rs | 37 ++-- connectors/connector-kick/src/webhook/mod.rs | 2 +- .../connector-kick/src/webhook/replay.rs | 61 ++---- crates/springtale-connector/Cargo.toml | 4 + .../src/connector/trait_.rs | 24 +++ .../springtale-connector/src/host/trait_.rs | 14 ++ .../src/native/runtime.rs | 7 + .../src/webhook/replay.rs | 191 ++++++++++++++++++ 9 files changed, 279 insertions(+), 62 deletions(-) create mode 100644 crates/springtale-connector/src/webhook/replay.rs diff --git a/Cargo.lock b/Cargo.lock index 33101141..ec406804 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5812,6 +5812,7 @@ version = "0.1.0" dependencies = [ "async-trait", "base64", + "blake3", "dashmap", "ed25519-dalek", "garde", diff --git a/connectors/connector-kick/src/connector.rs b/connectors/connector-kick/src/connector.rs index 90f449cc..b56e86e5 100644 --- a/connectors/connector-kick/src/connector.rs +++ b/connectors/connector-kick/src/connector.rs @@ -43,9 +43,6 @@ pub struct KickConnector { api_base: String, /// Cached PEM public key from `GET /public/v1/public-key`. webhook_public_key: Mutex>, - /// Seen `Kick-Event-Message-Id`s for replay protection (in-memory: - /// the trait hands us no store; see `webhook::replay`). - replay_cache: Mutex, } /// Map a connector trigger name to the Kick event type(s) to subscribe to. @@ -85,7 +82,6 @@ impl KickConnector { sub_counter: SubscriptionCounter::new(), api_base: config.api_base.clone(), webhook_public_key: Mutex::new(None), - replay_cache: Mutex::new(webhook::ReplayCache::default()), }) } @@ -245,9 +241,10 @@ impl Connector for KickConnector { /// Verify a Kick webhook: RSA-PKCS1v15-SHA256 over /// `{message_id}.{timestamp}.{body}` with Kick's published key, then - /// replay protection — the timestamp must be within five minutes and - /// the message id must not have been seen in the last hour. Signature - /// and body are never logged or echoed in errors. + /// the timestamp freshness half of replay protection — the send time + /// must be within five minutes. The message-id half is durable and + /// lives in the store; see [`KickConnector::webhook_replay_key`]. + /// Signature and body are never logged or echoed in errors. async fn verify_webhook( &self, headers: &std::collections::HashMap, @@ -260,16 +257,30 @@ impl Connector for KickConnector { let public_key = self.webhook_public_key().await?; webhook::verify_webhook(&public_key, message_id, timestamp, body, signature)?; - // Replay checks run only after the signature is proven genuine so - // an attacker cannot pre-poison the seen-id cache. + // Runs only after the signature is proven genuine, so a forged + // request can never influence replay state. The daemon's ingress + // then records `message_id` durably via `webhook_replay_key`. webhook::check_timestamp(timestamp, chrono::Utc::now())?; - self.replay_cache - .lock() - .await - .check_and_record(message_id, std::time::Instant::now())?; Ok(()) } + /// `Kick-Event-Message-Id` — Kick's documented idempotency key. + /// + /// The ingress records it in the store after this connector's + /// signature and timestamp checks pass, so a captured-but-valid + /// delivery cannot be replayed even across a daemon reload or a + /// vault re-unlock. A missing header is not a silent pass: it is + /// rejected earlier, in `verify_webhook`, because the id is part of + /// the signed message. + fn webhook_replay_key( + &self, + headers: &std::collections::HashMap, + ) -> Option { + webhook::required_header(headers, webhook::HEADER_MESSAGE_ID) + .ok() + .map(str::to_owned) + } + async fn remove_event(&self, sub: &Subscription) -> Result<(), ConnectorError> { let mut handlers = self.handlers.lock().await; handlers.retain(|(id, _, _)| *id != sub.id); diff --git a/connectors/connector-kick/src/webhook/mod.rs b/connectors/connector-kick/src/webhook/mod.rs index 1eb2b78e..d9a2e7ea 100644 --- a/connectors/connector-kick/src/webhook/mod.rs +++ b/connectors/connector-kick/src/webhook/mod.rs @@ -16,7 +16,7 @@ pub mod ingest; pub mod replay; pub use ingest::ingest_event; -pub use replay::{ReplayCache, check_timestamp}; +pub use replay::check_timestamp; /// Header carrying the idempotent message id (`Kick-Event-Message-Id`). pub const HEADER_MESSAGE_ID: &str = "kick-event-message-id"; diff --git a/connectors/connector-kick/src/webhook/replay.rs b/connectors/connector-kick/src/webhook/replay.rs index ee9aadea..ce13225f 100644 --- a/connectors/connector-kick/src/webhook/replay.rs +++ b/connectors/connector-kick/src/webhook/replay.rs @@ -1,26 +1,25 @@ -//! Replay protection for Kick webhooks (plan 5.2, finding 116). +//! Timestamp freshness for Kick webhooks (plan 5.2, finding 116). //! //! Kick documents `Kick-Event-Message-Id` as an idempotent key and -//! `Kick-Event-Message-Timestamp` as an RFC 3339 send time. Both checks -//! run AFTER signature verification so an unsigned request can never -//! poison the seen-id cache. +//! `Kick-Event-Message-Timestamp` as an RFC 3339 send time. This module +//! owns the timestamp half — the cheap, stateless check that a captured +//! request is at least still inside Kick's own signing window. It runs +//! AFTER signature verification, so an unsigned request never reaches it. //! -//! State is held in-memory on the connector: the `Connector` trait hands -//! `verify_webhook` no storage handle, and the connector crate cannot -//! depend on `springtale-runtime` (dependency direction), so the -//! runtime's `dedupe` store is not reachable from here. - -use std::collections::HashMap; -use std::time::{Duration, Instant}; +//! The message-id half is NOT here any more, and is no longer held in +//! process memory. `KickConnector` exposes the id through +//! `Connector::webhook_replay_key` and the daemon's webhook ingress +//! records it in the store (`springtale_connector::webhook::replay`), so +//! the seen-id set survives a daemon reload, a vault re-lock/unlock and a +//! crash. It used to be a `HashMap` on the connector struct: every +//! restart forgot it and reopened the replay window for every delivery +//! still inside the five-minute skew allowance below. use crate::error::KickError; /// Maximum absolute skew between the event timestamp and now. pub const MAX_TIMESTAMP_SKEW_SECS: i64 = 5 * 60; -/// How long a message id is remembered after first sight. -pub const MESSAGE_ID_TTL: Duration = Duration::from_secs(60 * 60); - /// Reject a `Kick-Event-Message-Timestamp` that is unparseable or more /// than [`MAX_TIMESTAMP_SKEW_SECS`] away from `now` in either direction. pub fn check_timestamp( @@ -39,28 +38,6 @@ pub fn check_timestamp( Ok(()) } -/// Seen message ids with their first-sight instant, pruned on insert. -#[derive(Debug, Default)] -pub struct ReplayCache { - seen: HashMap, -} - -impl ReplayCache { - /// Record `message_id` at `now`; reject it if it was already seen - /// within [`MESSAGE_ID_TTL`]. Expired entries are dropped first. - pub fn check_and_record(&mut self, message_id: &str, now: Instant) -> Result<(), KickError> { - self.seen - .retain(|_, first_seen| now.duration_since(*first_seen) < MESSAGE_ID_TTL); - if self.seen.contains_key(message_id) { - return Err(KickError::RequestFailed( - "webhook message id already seen (replay)".to_owned(), - )); - } - self.seen.insert(message_id.to_owned(), now); - Ok(()) - } -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { @@ -75,16 +52,4 @@ mod tests { assert!(check_timestamp("2026-09-04T11:54:59Z", now).is_err()); assert!(check_timestamp("not-a-timestamp", now).is_err()); } - - #[test] - fn test_replay_cache_repeated_id_rejected() { - let mut cache = ReplayCache::default(); - let now = Instant::now(); - assert!(cache.check_and_record("msg-1", now).is_ok()); - assert!(cache.check_and_record("msg-1", now).is_err()); - assert!(cache.check_and_record("msg-2", now).is_ok()); - // Once the TTL has elapsed the id is forgotten and accepted again. - let later = now + MESSAGE_ID_TTL + Duration::from_secs(1); - assert!(cache.check_and_record("msg-1", later).is_ok()); - } } diff --git a/crates/springtale-connector/Cargo.toml b/crates/springtale-connector/Cargo.toml index c95a3c13..a3be7a56 100644 --- a/crates/springtale-connector/Cargo.toml +++ b/crates/springtale-connector/Cargo.toml @@ -14,6 +14,10 @@ wasm-sandbox = ["dep:wasmtime", "dep:wasmtime-wasi"] wasmtime = { workspace = true, optional = true } wasmtime-wasi = { workspace = true, optional = true } base64 = { workspace = true } +# blake3 — hashes webhook delivery ids before they reach the `dedupe_seen` +# table, which documents blake3 as its key-hash function (see +# `springtale-store/src/schema/sql/dedupe.sql`). Already a workspace dep. +blake3 = { workspace = true } secrecy = { workspace = true } serde = { workspace = true } specta = { workspace = true } diff --git a/crates/springtale-connector/src/connector/trait_.rs b/crates/springtale-connector/src/connector/trait_.rs index 8c64f509..bb2b5216 100644 --- a/crates/springtale-connector/src/connector/trait_.rs +++ b/crates/springtale-connector/src/connector/trait_.rs @@ -94,6 +94,30 @@ pub trait Connector: Send + Sync + 'static { )) } + /// The provider's own idempotency key for this webhook delivery — + /// Kick's `Kick-Event-Message-Id`, GitHub's `X-GitHub-Delivery`. + /// + /// Returning `Some` opts the connector into durable replay + /// protection: the daemon's webhook ingress records the key in the + /// store (see [`crate::webhook::replay`]) and drops any later + /// delivery carrying the same one. The check runs only after + /// [`Connector::verify_webhook`] has returned `Ok`, so an unsigned + /// request can never poison the record. + /// + /// This lives on the connector because only the connector knows + /// which header carries the id; the record lives in the store + /// because a connector's own memory dies with the process, and a + /// replay window that reopens on every daemon reload is not + /// protection. + /// + /// Default: `None` — no delivery id, so no replay record. + fn webhook_replay_key( + &self, + _headers: &std::collections::HashMap, + ) -> Option { + None + } + /// Read an already-VERIFIED webhook payload: what chat messages and /// rule-engine events does it mean? /// diff --git a/crates/springtale-connector/src/host/trait_.rs b/crates/springtale-connector/src/host/trait_.rs index 55bb1a5e..d3ff9213 100644 --- a/crates/springtale-connector/src/host/trait_.rs +++ b/crates/springtale-connector/src/host/trait_.rs @@ -57,6 +57,20 @@ pub trait ConnectorHost: Send + Sync + 'static { body: &[u8], ) -> Result<(), ConnectorError>; + /// The provider's idempotency key for this webhook delivery, if it + /// has one — see + /// [`crate::connector::trait_::Connector::webhook_replay_key`]. + /// Exposed through the host so the daemon's ingress can apply + /// durable replay protection without knowing any connector's header + /// names. Native hosts delegate; WASM hosts return `None` (a + /// sandbox-side hook can follow, like `mention_extractor`). + fn webhook_replay_key( + &self, + _headers: &std::collections::HashMap, + ) -> Option { + None + } + /// Read an already-verified webhook payload into the chat messages /// and rule events it means — see /// [`crate::connector::trait_::Connector::ingest_webhook`]. Exposed diff --git a/crates/springtale-connector/src/native/runtime.rs b/crates/springtale-connector/src/native/runtime.rs index 0125e9c7..fba74022 100644 --- a/crates/springtale-connector/src/native/runtime.rs +++ b/crates/springtale-connector/src/native/runtime.rs @@ -155,6 +155,13 @@ impl ConnectorHost for NativeConnectorHost { NativeConnectorHost::verify_webhook(self, headers, body).await } + fn webhook_replay_key( + &self, + headers: &std::collections::HashMap, + ) -> Option { + self.inner.webhook_replay_key(headers) + } + async fn ingest_webhook( &self, trigger: &str, diff --git a/crates/springtale-connector/src/webhook/replay.rs b/crates/springtale-connector/src/webhook/replay.rs new file mode 100644 index 00000000..71ae5863 --- /dev/null +++ b/crates/springtale-connector/src/webhook/replay.rs @@ -0,0 +1,191 @@ +//! Durable webhook replay protection. +//! +//! A provider that signs its webhooks (Kick's RSA signature, GitHub's +//! HMAC) also gives each delivery an idempotent id. Remembering those +//! ids is what stops a captured — and still perfectly valid — request +//! from being replayed into the rule engine. +//! +//! That memory has to outlive the process. A connector holding the seen +//! ids in a `HashMap` loses them on every daemon reload, vault re-unlock +//! and crash, and each restart reopens the full replay window for every +//! delivery still inside the provider's signing/timestamp validity. So +//! the guard lives here, on the store, not in the connector: connector +//! crates depend on `springtale-connector` and never on +//! `springtale-store` (see `.claude/rules/backend/crate-structure.md`), +//! while this crate already depends on the store. +//! +//! The connector still owns the protocol half — which header carries +//! the id — via +//! [`crate::connector::trait_::Connector::webhook_replay_key`]. The +//! daemon's webhook ingress calls that, then [`check_and_record`], and +//! only ever *after* signature verification has passed, so an unsigned +//! request can never poison the table. +//! +//! Storage reuses the existing `dedupe_seen` table +//! ([`springtale_store::StorageBackend::dedupe_check`]): an atomic +//! `INSERT OR IGNORE` check-and-record with LRU pruning, which is +//! exactly the shape a replay guard needs. No new table. + +use std::sync::Arc; + +use springtale_store::StorageBackend; +use springtale_store::schema::dedupe::DedupeOutcome; + +use crate::error::ConnectorError; + +/// Dedupe bucket shared by every connector's webhook replay guard. +/// +/// Rows are scoped `(formation_id = global, rule_id = connector name, +/// bucket)`, so one connector's delivery ids can never collide with +/// another's or with a rule's own `Action::Dedupe` state. +pub const REPLAY_BUCKET: &str = "webhook_replay"; + +/// Delivery ids retained per connector before the oldest are pruned. +/// +/// The prune is LRU, not TTL: a replay is only worth attempting while +/// the provider's own signature/timestamp window still accepts the +/// captured request (five minutes for Kick), and 4096 deliveries is far +/// more than any first-party connector receives in that span. +pub const REPLAY_HISTORY: u32 = 4096; + +/// Whether this webhook delivery has been seen before. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayOutcome { + /// First sight of this delivery id — now recorded. Process it. + Fresh, + /// The id is already on record. Drop the request. + Replay, +} + +/// Atomically record `replay_key` for `connector` and report whether it +/// had been seen before. +/// +/// The key is hashed with blake3 before it touches disk, matching the +/// `dedupe_seen` privacy invariant (a provider delivery id can identify +/// a channel or a sender; plaintext keys never land in the database). +/// +/// # Errors +/// +/// Returns [`ConnectorError::ExecutionFailed`] if the store is +/// unreachable. Callers must treat that as fail-closed — an +/// unverifiable delivery is a delivery that may be a replay. +pub async fn check_and_record( + store: &Arc, + connector: &str, + replay_key: &str, +) -> Result { + let key_hash = blake3::hash(replay_key.as_bytes()).to_hex().to_string(); + let outcome = store + .dedupe_check(None, connector, REPLAY_BUCKET, &key_hash, REPLAY_HISTORY) + .await + .map_err(|e| { + ConnectorError::ExecutionFailed(format!("webhook replay store unavailable: {e}")) + })?; + Ok(match outcome { + DedupeOutcome::Fresh => ReplayOutcome::Fresh, + DedupeOutcome::SeenBefore => ReplayOutcome::Replay, + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use springtale_store::SqliteBackend; + + fn store() -> Arc { + Arc::new(SqliteBackend::open_in_memory().unwrap()) + } + + #[tokio::test] + async fn test_check_and_record_first_sight_is_fresh() { + let store = store(); + assert_eq!( + check_and_record(&store, "connector-kick", "msg-1") + .await + .unwrap(), + ReplayOutcome::Fresh + ); + } + + #[tokio::test] + async fn test_check_and_record_repeated_key_is_replay() { + let store = store(); + assert_eq!( + check_and_record(&store, "connector-kick", "msg-1") + .await + .unwrap(), + ReplayOutcome::Fresh + ); + assert_eq!( + check_and_record(&store, "connector-kick", "msg-1") + .await + .unwrap(), + ReplayOutcome::Replay + ); + } + + #[tokio::test] + async fn test_check_and_record_survives_a_dropped_connector() { + // The defect this guards: the seen-id set used to live in the + // connector struct, so a daemon reload / vault re-unlock built a + // fresh connector and reopened the replay window. The store + // outlives the connector, so a new one must still see the id. + let store = store(); + { + let first_boot = Arc::clone(&store); + assert_eq!( + check_and_record(&first_boot, "connector-kick", "msg-1") + .await + .unwrap(), + ReplayOutcome::Fresh + ); + } + let second_boot = Arc::clone(&store); + assert_eq!( + check_and_record(&second_boot, "connector-kick", "msg-1") + .await + .unwrap(), + ReplayOutcome::Replay, + "a delivery id must stay rejected across a connector restart" + ); + } + + #[tokio::test] + async fn test_check_and_record_scopes_by_connector() { + let store = store(); + assert_eq!( + check_and_record(&store, "connector-kick", "shared-id") + .await + .unwrap(), + ReplayOutcome::Fresh + ); + assert_eq!( + check_and_record(&store, "connector-github", "shared-id") + .await + .unwrap(), + ReplayOutcome::Fresh, + "connectors must not share a replay namespace" + ); + } + + #[tokio::test] + async fn test_check_and_record_does_not_store_the_plaintext_key() { + let store = store(); + let key = "kick-message-id-that-names-a-channel"; + check_and_record(&store, "connector-kick", key) + .await + .unwrap(); + let hashed = blake3::hash(key.as_bytes()).to_hex().to_string(); + assert_ne!(hashed, key); + // Re-checking with the hash itself must NOT collide with the + // recorded row — proof the stored column is the digest of the + // key, not the key (and not the digest of the digest). + assert_eq!( + check_and_record(&store, "connector-kick", &hashed) + .await + .unwrap(), + ReplayOutcome::Fresh + ); + } +} From b1377932ee01c07f2854b57e559a914e7db8b6af Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:31:23 -0700 Subject: [PATCH 22/24] fix(desktop): surface the sidecar dying instead of talking to nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell kept the sidecar's child handle and nothing watched it. If springtaled died after signalling ready, the window held a stale port and token, with no restart, no health path, and no sign anything was wrong. `Daemon` now carries the event receiver out of `start`, and `supervise()` drains it: on `Terminated` or stream close it clears the stored `DaemonHandle` and emits `DaemonStopped { code }`. A deliberate stop (lock_vault) finds the slot already taken and stays silent, so locking does not raise an alarm. The frontend listens through a small IPC module and renders a notice while a session is on screen; strings are in all eight locales. Also kills the child when `login` fails, which previously orphaned a daemon holding an unlocked vault. No restart supervisor — detection and an honest signal only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../desktop/src-tauri/src/commands/vault.rs | 29 +++- tauri/apps/desktop/src-tauri/src/lib.rs | 1 + tauri/apps/desktop/src-tauri/src/sidecar.rs | 143 +++++++++++++++--- tauri/apps/desktop/src-tauri/src/state.rs | 17 ++- tauri/apps/desktop/src/App.tsx | 47 +++++- .../apps/desktop/src/DaemonStoppedNotice.tsx | 37 +++++ tauri/apps/desktop/src/ipc/daemon.ts | 25 +++ tauri/packages/ui/src/i18n/locales/ar.json | 6 +- tauri/packages/ui/src/i18n/locales/en.json | 5 + tauri/packages/ui/src/i18n/locales/es.json | 6 +- tauri/packages/ui/src/i18n/locales/fr.json | 6 +- tauri/packages/ui/src/i18n/locales/ja.json | 6 +- tauri/packages/ui/src/i18n/locales/pt.json | 6 +- tauri/packages/ui/src/i18n/locales/th.json | 6 +- tauri/packages/ui/src/i18n/locales/tl.json | 6 +- 15 files changed, 300 insertions(+), 46 deletions(-) create mode 100644 tauri/apps/desktop/src/DaemonStoppedNotice.tsx create mode 100644 tauri/apps/desktop/src/ipc/daemon.ts diff --git a/tauri/apps/desktop/src-tauri/src/commands/vault.rs b/tauri/apps/desktop/src-tauri/src/commands/vault.rs index 3fa5b900..81a3437e 100644 --- a/tauri/apps/desktop/src-tauri/src/commands/vault.rs +++ b/tauri/apps/desktop/src-tauri/src/commands/vault.rs @@ -124,20 +124,41 @@ async fn start_session( return Ok(session); } - let daemon = sidecar::start(app, &passphrase).await?; + let sidecar::Daemon { + port, + child, + events, + } = sidecar::start(app, &passphrase).await?; // Plan 6.6: the shell no longer derives its bearer. Once the sidecar // has reported READY it logs in with the passphrase it already holds // and the daemon issues a random session token. - let token = sidecar::login(daemon.port, &passphrase).await?; + let token = match sidecar::login(port, &passphrase).await { + Ok(token) => token, + Err(e) => { + // Nothing owns this child yet and dropping a `CommandChild` + // does not stop the process, so bailing here would orphan a + // daemon holding the unlocked vault with nothing left able to + // reach or stop it. + if let Err(kill) = child.kill() { + tracing::warn!(error = %kill, "failed to stop the sidecar after a failed login"); + } + return Err(e); + } + }; let session = VaultSession { status, - port: daemon.port, + port, token: token.clone(), }; - *daemon_guard = Some(DaemonHandle::new(daemon, token)); + *daemon_guard = Some(DaemonHandle::new(port, child, token)); drop(daemon_guard); + // Watch the child for the rest of its life. Started only now, with + // the handle already in state, so a crash during login cannot race + // the supervisor into finding an empty slot and staying quiet. + sidecar::supervise(app.clone(), events); + *state.vault.lock().await = Some(vault); let _ = VaultUnlocked.emit(app); Ok(session) diff --git a/tauri/apps/desktop/src-tauri/src/lib.rs b/tauri/apps/desktop/src-tauri/src/lib.rs index 33d2e42f..09287286 100644 --- a/tauri/apps/desktop/src-tauri/src/lib.rs +++ b/tauri/apps/desktop/src-tauri/src/lib.rs @@ -82,6 +82,7 @@ pub fn run() { commands::vault::VaultUnlocked, commands::vault::VaultLocked, commands::quick_hide::QuickHide, + sidecar::DaemonStopped, ]) .commands(collect_commands![ commands::vault::create_vault, diff --git a/tauri/apps/desktop/src-tauri/src/sidecar.rs b/tauri/apps/desktop/src-tauri/src/sidecar.rs index a84c30b1..b8baa142 100644 --- a/tauri/apps/desktop/src-tauri/src/sidecar.rs +++ b/tauri/apps/desktop/src-tauri/src/sidecar.rs @@ -13,8 +13,28 @@ //! it is the same web provider hitting the same loopback API. use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::Manager; use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_specta::Event; + +use crate::state::AppState; + +/// Emitted when the `springtaled` sidecar stops without the shell +/// having asked it to — a crash, an OOM kill, an operator `kill(1)`. +/// +/// A deliberate stop (`lock_vault`, auto-lock, quick-hide) does NOT emit +/// this: those take the [`crate::state::DaemonHandle`] out of state +/// before killing the child, and the supervisor treats an already-taken +/// handle as "expected". So receiving this event always means the window +/// is now holding a port and a token that lead nowhere. +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +pub struct DaemonStopped { + /// Process exit code, when the platform reported one. + pub code: Option, +} /// A running `springtaled` child process and the port it bound. pub struct Daemon { @@ -22,6 +42,15 @@ pub struct Daemon { pub port: u16, /// Child handle — kept so locking the vault can terminate the daemon. pub child: CommandChild, + /// The sidecar's remaining event stream, handed to [`supervise`]. + /// + /// Nothing used to read this after `READY`: the receiver was dropped + /// at the end of [`start`], so a daemon that died a second later did + /// so unobserved and the shell kept talking to a closed port. It is + /// carried out of `start` instead, and the caller starts supervision + /// once the handle is in state (so a stop can never be seen before + /// the thing it should clear exists). + pub events: tauri::async_runtime::Receiver, } /// Spawn `springtaled`, feed it the passphrase, and wait for `READY {port}`. @@ -51,7 +80,11 @@ pub async fn start(app: &tauri::AppHandle, passphrase: &SecretString) -> Result< CommandEvent::Stdout(line) => { if let Some(port) = parse_ready(&line) { tracing::info!(port, "springtaled sidecar ready"); - return Ok(Daemon { port, child }); + return Ok(Daemon { + port, + child, + events: rx, + }); } } CommandEvent::Stderr(line) => { @@ -75,6 +108,73 @@ pub async fn start(app: &tauri::AppHandle, passphrase: &SecretString) -> Result< Err("springtaled stream closed before READY".to_owned()) } +/// Watch a started sidecar for the rest of its life. +/// +/// [`start`] only reads the stream up to `READY`. Without this the shell +/// never learns that the daemon died: it keeps a stale `{ port, token }`, +/// the frontend's fetches and SSE reconnects chase a closed port, and the +/// window silently shows a colony that no longer exists. +/// +/// On termination the stored [`crate::state::DaemonHandle`] is cleared — +/// so the next unlock spawns a fresh daemon instead of handing back a +/// dead port — and [`DaemonStopped`] is emitted so the UI can say so. +/// This is deliberately not a restart supervisor: `springtaled` holds the +/// unlocked vault, and re-deriving that needs the passphrase, which the +/// shell does not keep. Telling the user is the honest response. +pub fn supervise(app: tauri::AppHandle, mut events: tauri::async_runtime::Receiver) { + tauri::async_runtime::spawn(async move { + let mut code = None; + while let Some(event) = events.recv().await { + match event { + CommandEvent::Stderr(line) => { + if let Ok(text) = std::str::from_utf8(&line) { + tracing::debug!(target: "springtaled", "{}", text.trim_end()); + } + } + CommandEvent::Terminated(status) => { + code = status.code; + break; + } + CommandEvent::Error(e) => { + tracing::error!(error = %e, "springtaled sidecar stream error"); + break; + } + // Stdout past READY carries nothing the shell acts on. + _ => {} + } + } + + // Whether we saw `Terminated` or the stream simply ended, the + // child is unreachable from here on. + // Clone the Arc out first so the `State` borrow is not held + // across the lock's await point. + let slot = std::sync::Arc::clone(&app.state::().daemon); + let daemon = slot.lock().await.take(); + + let Some(daemon) = daemon else { + // `lock_vault` (or auto-lock, or quick-hide) already took the + // handle and killed the child on purpose. Nothing to report. + tracing::info!("springtaled sidecar stopped as requested"); + return; + }; + + tracing::error!( + port = daemon.port, + ?code, + "springtaled sidecar stopped unexpectedly" + ); + // Drops the dead child handle and the session token the daemon + // issued — that token is worthless now, and holding it would only + // invite the frontend to keep using it. + drop(daemon); + + let stopped = DaemonStopped { code }; + if let Err(e) = stopped.emit(&app) { + tracing::error!(error = %e, "failed to tell the window the daemon stopped"); + } + }); +} + /// Parse a `READY {port}` line. Returns `None` for any other output. fn parse_ready(line: &[u8]) -> Option { std::str::from_utf8(line) @@ -85,27 +185,6 @@ fn parse_ready(line: &[u8]) -> Option { .parse() .ok() } - -#[cfg(test)] -mod tests { - use super::parse_ready; - - #[test] - fn test_parse_ready_with_port_returns_port() { - assert_eq!(parse_ready(b"READY 51234\n"), Some(51234)); - } - - #[test] - fn test_parse_ready_bare_ready_returns_none() { - assert_eq!(parse_ready(b"READY\n"), None); - } - - #[test] - fn test_parse_ready_unrelated_line_returns_none() { - assert_eq!(parse_ready(b"INFO springtaled starting"), None); - } -} - /// Log in to the freshly started daemon and return the bearer token it /// issues (plan 6.6, finding 109). /// @@ -140,3 +219,23 @@ pub async fn login(port: u16, passphrase: &secrecy::SecretString) -> Result Self { - Self { - port: daemon.port, - token, - child: daemon.child, - } + pub fn new(port: u16, child: tauri_plugin_shell::process::CommandChild, token: String) -> Self { + Self { port, token, child } } } diff --git a/tauri/apps/desktop/src/App.tsx b/tauri/apps/desktop/src/App.tsx index d6679bda..dc663454 100644 --- a/tauri/apps/desktop/src/App.tsx +++ b/tauri/apps/desktop/src/App.tsx @@ -7,6 +7,8 @@ import { import { listen } from "@tauri-apps/api/event"; import { createSignal, onMount, Show } from "solid-js"; import { Colony } from "./Colony"; +import { DaemonStoppedNotice } from "./DaemonStoppedNotice"; +import { type DaemonStopped, onDaemonStopped } from "./ipc/daemon"; import { lockVault, type VaultSession } from "./ipc/vault"; import { createDesktopProvider } from "./provider"; import { VaultOverlay } from "./VaultOverlay"; @@ -24,6 +26,7 @@ import { VaultOverlay } from "./VaultOverlay"; */ export const App = () => { const [dashboard, setDashboard] = createSignal(null); + const [daemonExit, setDaemonExit] = createSignal(null); const openSession = (session: VaultSession) => { const provider = createDesktopProvider(session.port, session.token); @@ -36,12 +39,28 @@ export const App = () => { // behind the lock screen. closeAllStreams(); setDashboard(null); + setDaemonExit(null); }; onMount(async () => { // Auto-lock timeout and `lock_vault` both land here. await listen("vault-locked", closeSession); + // The sidecar died on its own — a crash, an OOM kill, an operator + // `kill`. Rust has already dropped the stale `{ port, token }`, so + // every fetch and SSE reconnect from here would chase a closed port. + // Stop the streams and say so instead of rendering a colony that no + // longer exists. + await onDaemonStopped((payload) => { + // Only meaningful while a session is actually on screen. After a + // lock or auto-lock the passphrase overlay is already the right + // thing to show, and replacing it would be noise. + if (!dashboard()) return; + closeAllStreams(); + setDashboard(null); + setDaemonExit(payload); + }); + // G5g — the OS-wide quick-hide hotkey. The Rust handler has already // hidden the window; mirror the in-window path by locking, which // emits "vault-locked" and tears the session down above. @@ -51,11 +70,29 @@ export const App = () => { }); return ( - }> - {(db) => ( - - void lockVault()} /> - + }> + {(db) => ( + + void lockVault()} /> + + )} + + } + > + {(exit) => ( + { + // Zeroize the vault key material the shell still holds, then + // fall back to the passphrase screen. The next unlock spawns + // a fresh daemon — Rust already cleared the dead handle. + closeSession(); + void lockVault(); + }} + /> )} ); diff --git a/tauri/apps/desktop/src/DaemonStoppedNotice.tsx b/tauri/apps/desktop/src/DaemonStoppedNotice.tsx new file mode 100644 index 00000000..edb26cc8 --- /dev/null +++ b/tauri/apps/desktop/src/DaemonStoppedNotice.tsx @@ -0,0 +1,37 @@ +import { useI18n } from "@springtale/ui"; +import { Show } from "solid-js"; + +/** + * Shown when the `springtaled` sidecar stops on its own. + * + * The daemon owns the store, the scheduler and the bot loop, so once it + * is gone the colony behind this screen is a still photograph of state + * that no longer exists. Rendering it as if it were live would be the + * fake signal the product model forbids — this replaces it, says what + * happened, and offers the one action that actually recovers: lock, then + * unlock, which spawns a fresh daemon. + */ +export function DaemonStoppedNotice(props: { code: number | null; onLock: () => void }) { + const { t } = useI18n(); + + return ( +
+
+

{t("daemon.stopped.title")}

+

{t("daemon.stopped.body")}

+ +

+ {t("daemon.stopped.code", { code: String(props.code) })} +

+
+ +
+
+ ); +} diff --git a/tauri/apps/desktop/src/ipc/daemon.ts b/tauri/apps/desktop/src/ipc/daemon.ts new file mode 100644 index 00000000..ad84bf4e --- /dev/null +++ b/tauri/apps/desktop/src/ipc/daemon.ts @@ -0,0 +1,25 @@ +/** + * Sidecar lifecycle events. + * + * The desktop shell is a client of `springtaled`: unlocking the vault + * spawns the daemon and every read and write goes to its loopback API. + * If that process dies the window is left holding a port and a token + * that lead nowhere, so Rust supervises the child (`sidecar::supervise`) + * and emits `daemon-stopped` when it goes away unexpectedly. A vault + * lock, auto-lock or quick-hide stops the daemon deliberately and does + * NOT emit this — receiving it always means something went wrong. + */ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +/** Payload of the Rust `DaemonStopped` event. */ +export interface DaemonStopped { + /** Process exit code, when the platform reported one. */ + code: number | null; +} + +/** Subscribe to unexpected daemon termination. */ +export async function onDaemonStopped( + handler: (payload: DaemonStopped) => void, +): Promise { + return listen("daemon-stopped", (event) => handler(event.payload)); +} diff --git a/tauri/packages/ui/src/i18n/locales/ar.json b/tauri/packages/ui/src/i18n/locales/ar.json index c6e43e22..d9610cf8 100644 --- a/tauri/packages/ui/src/i18n/locales/ar.json +++ b/tauri/packages/ui/src/i18n/locales/ar.json @@ -158,5 +158,9 @@ "utter.yield": "تنازل لزميل", "utter.helping": "يساعد زميلًا", "utter.rally": "تجمّع", - "utter.cascade": "تحذير تسلسل" + "utter.cascade": "تحذير تسلسل", + "daemon.stopped.title": "توقفت الخدمة", + "daemon.stopped.body": "توقفت الخدمة التي تشغّل مستعمرتك في الخلفية، لذا لم يعد أي شيء على الشاشة محدّثًا. ارجع إلى شاشة القفل وافتح القفل مجددًا لتشغيلها.", + "daemon.stopped.code": "رمز الخروج: {{code}}", + "daemon.stopped.action": "العودة إلى شاشة القفل" } diff --git a/tauri/packages/ui/src/i18n/locales/en.json b/tauri/packages/ui/src/i18n/locales/en.json index 09ecfde0..cfd46250 100644 --- a/tauri/packages/ui/src/i18n/locales/en.json +++ b/tauri/packages/ui/src/i18n/locales/en.json @@ -121,6 +121,11 @@ "vault.unlock": "Unlock", "vault.lock": "Lock Vault", + "daemon.stopped.title": "Background service stopped", + "daemon.stopped.body": "The background service that runs your colony has stopped, so nothing on screen is live any more. Return to the lock screen and unlock again to start it.", + "daemon.stopped.code": "Exit code: {{code}}", + "daemon.stopped.action": "Return to lock screen", + "preview.label": "Generated Rule (TOML)", "preview.placeholder": "# Configure trigger, conditions, and actions above", diff --git a/tauri/packages/ui/src/i18n/locales/es.json b/tauri/packages/ui/src/i18n/locales/es.json index 69a49b52..8a5f03b1 100644 --- a/tauri/packages/ui/src/i18n/locales/es.json +++ b/tauri/packages/ui/src/i18n/locales/es.json @@ -158,5 +158,9 @@ "utter.yield": "cedió a un compañero", "utter.helping": "ayudando a un compañero", "utter.rally": "reagrupar", - "utter.cascade": "aviso de cascada" + "utter.cascade": "aviso de cascada", + "daemon.stopped.title": "El servicio se detuvo", + "daemon.stopped.body": "El servicio en segundo plano que ejecuta tu colonia se detuvo, así que nada en pantalla está activo. Vuelve a la pantalla de bloqueo y desbloquea de nuevo para iniciarlo.", + "daemon.stopped.code": "Código de salida: {{code}}", + "daemon.stopped.action": "Volver a la pantalla de bloqueo" } diff --git a/tauri/packages/ui/src/i18n/locales/fr.json b/tauri/packages/ui/src/i18n/locales/fr.json index 08b5219e..9f37d200 100644 --- a/tauri/packages/ui/src/i18n/locales/fr.json +++ b/tauri/packages/ui/src/i18n/locales/fr.json @@ -158,5 +158,9 @@ "utter.yield": "a cédé à un coéquipier", "utter.helping": "aide un coéquipier", "utter.rally": "ralliement", - "utter.cascade": "alerte de cascade" + "utter.cascade": "alerte de cascade", + "daemon.stopped.title": "Le service s'est arrêté", + "daemon.stopped.body": "Le service en arrière-plan qui fait tourner votre colonie s'est arrêté : plus rien à l'écran n'est à jour. Revenez à l'écran de verrouillage et déverrouillez à nouveau pour le relancer.", + "daemon.stopped.code": "Code de sortie : {{code}}", + "daemon.stopped.action": "Revenir à l'écran de verrouillage" } diff --git a/tauri/packages/ui/src/i18n/locales/ja.json b/tauri/packages/ui/src/i18n/locales/ja.json index 215fbbab..2a2251df 100644 --- a/tauri/packages/ui/src/i18n/locales/ja.json +++ b/tauri/packages/ui/src/i18n/locales/ja.json @@ -158,5 +158,9 @@ "utter.yield": "仲間に譲った", "utter.helping": "仲間を手伝い中", "utter.rally": "集合", - "utter.cascade": "連鎖警告" + "utter.cascade": "連鎖警告", + "daemon.stopped.title": "サービスが停止しました", + "daemon.stopped.body": "コロニーを動かしているバックグラウンドサービスが停止したため、画面上の情報はすべて古いものです。ロック画面に戻り、もう一度ロックを解除して起動してください。", + "daemon.stopped.code": "終了コード: {{code}}", + "daemon.stopped.action": "ロック画面に戻る" } diff --git a/tauri/packages/ui/src/i18n/locales/pt.json b/tauri/packages/ui/src/i18n/locales/pt.json index 1f36da00..f1c6ebb4 100644 --- a/tauri/packages/ui/src/i18n/locales/pt.json +++ b/tauri/packages/ui/src/i18n/locales/pt.json @@ -158,5 +158,9 @@ "utter.yield": "cedeu a um colega", "utter.helping": "ajudando um colega", "utter.rally": "reunir", - "utter.cascade": "alerta de cascata" + "utter.cascade": "alerta de cascata", + "daemon.stopped.title": "O serviço parou", + "daemon.stopped.body": "O serviço em segundo plano que executa a sua colônia parou, então nada na tela está ativo. Volte para a tela de bloqueio e desbloqueie novamente para iniciá-lo.", + "daemon.stopped.code": "Código de saída: {{code}}", + "daemon.stopped.action": "Voltar à tela de bloqueio" } diff --git a/tauri/packages/ui/src/i18n/locales/th.json b/tauri/packages/ui/src/i18n/locales/th.json index 8ecbd827..307a247c 100644 --- a/tauri/packages/ui/src/i18n/locales/th.json +++ b/tauri/packages/ui/src/i18n/locales/th.json @@ -158,5 +158,9 @@ "utter.yield": "ยกให้เพื่อนร่วมทีม", "utter.helping": "กำลังช่วยเพื่อนร่วมทีม", "utter.rally": "รวมพล", - "utter.cascade": "คำเตือนลูกโซ่" + "utter.cascade": "คำเตือนลูกโซ่", + "daemon.stopped.title": "บริการหยุดทำงาน", + "daemon.stopped.body": "บริการเบื้องหลังที่ขับเคลื่อนอาณานิคมของคุณหยุดทำงานแล้ว ข้อมูลบนหน้าจอจึงไม่อัปเดตอีกต่อไป กลับไปที่หน้าจอล็อกแล้วปลดล็อกอีกครั้งเพื่อเริ่มใหม่", + "daemon.stopped.code": "รหัสออก: {{code}}", + "daemon.stopped.action": "กลับไปที่หน้าจอล็อก" } diff --git a/tauri/packages/ui/src/i18n/locales/tl.json b/tauri/packages/ui/src/i18n/locales/tl.json index c5cb4ec8..d4138302 100644 --- a/tauri/packages/ui/src/i18n/locales/tl.json +++ b/tauri/packages/ui/src/i18n/locales/tl.json @@ -158,5 +158,9 @@ "utter.yield": "nagbigay-daan sa kasamahan", "utter.helping": "tumutulong sa kasamahan", "utter.rally": "magtipon", - "utter.cascade": "babala ng cascade" + "utter.cascade": "babala ng cascade", + "daemon.stopped.title": "Huminto ang serbisyo", + "daemon.stopped.body": "Huminto ang background na serbisyong nagpapatakbo ng iyong colony, kaya wala nang live sa screen. Bumalik sa lock screen at mag-unlock ulit para simulan ito.", + "daemon.stopped.code": "Exit code: {{code}}", + "daemon.stopped.action": "Bumalik sa lock screen" } From 4901a56e920112fcdc208dde03d4eff6dc3e4e2d Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 18:31:53 -0700 Subject: [PATCH 23/24] runtime: tests for travel preparation, restore, and panic wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALIGNMENT-PLAN 5.5, row 1: `operations/travel.rs` and `operations/safety.rs` had zero tests. These are the operations a user runs when crossing a border or when someone is at the door, so an untested regression here is the worst kind this project can ship. `travel.rs` gains five tests: a prepared backup reopens under the vault; the prepared tree leaves no plaintext behind; prepare→restore round-trips vault, database and config; a missing backup returns not-found; and a restore with the wrong passphrase writes nothing rather than half-restoring. `panic_wipe` gets its own integration binary. It resolves paths internally through `springtale_store::paths`, so the only way to test it is to redirect `XDG_DATA_HOME`, and `#![forbid(unsafe_code)]` on the library rules out `unsafe env::set_var` inside a `#[cfg(test)]` module. The test asserts the resolved data directory is inside the temporary root before wiping anything, so it can never reach a real vault, then asserts the vault, database, `-wal`, `-shm` and config are all unreadable afterwards. All three are mutation-checked: disabling the config wipe in `prepare` fails two tests, disabling it in `panic_wipe` fails that one. The round-trip test exposed something worth recording: `travel::prepare` copies only the `.db` file, so anything still in the SQLite write-ahead log at departure is not in the backup. The test closes the writer handle to force a checkpoint before preparing. A `prepare` on a live, uncheckpointed store would back up stale data — a separate fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/operations/travel.rs | 263 ++++++++++++++++++ .../tests/safety_panic_wipe.rs | 135 +++++++++ 2 files changed, 398 insertions(+) create mode 100644 crates/springtale-runtime/tests/safety_panic_wipe.rs diff --git a/crates/springtale-runtime/src/operations/travel.rs b/crates/springtale-runtime/src/operations/travel.rs index 6fe46b87..116e0b80 100644 --- a/crates/springtale-runtime/src/operations/travel.rs +++ b/crates/springtale-runtime/src/operations/travel.rs @@ -83,3 +83,266 @@ pub fn restore( Ok(()) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use springtale_crypto::vault::Vault; + use springtale_store::SafetyConfigRow; + use springtale_store::backend::SqliteBackend; + use tempfile::TempDir; + + use super::*; + + /// Production stores are always encrypted (plan 0.5), so file-backed + /// tests open with a fixed key. Never used outside tests. + const TEST_DB_KEY_HEX: &str = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + + /// The passphrase protecting the vault itself — distinct from the + /// travel passphrase so a test can prove the backup preserves the + /// vault's own encryption rather than re-keying it. + const VAULT_PASSPHRASE: &[u8] = b"vault-passphrase"; + const TRAVEL_PASSPHRASE: &[u8] = b"travel-passphrase"; + + /// Markers that must never survive `prepare` in cleartext anywhere + /// under the data directory — including inside the backup file. + const VAULT_MARKER: &str = "PLAINTEXT-MARKER-VAULT-SECRET"; + const CONFIG_MARKER: &str = "PLAINTEXT-MARKER-CONFIG-TOKEN"; + const DB_MARKER: &str = "PLAINTEXT-MARKER-DB-ROW"; + + /// A temp data directory laid out the way `springtale_store::paths` + /// lays out the real one, so `secure_wipe_sqlite`'s `-wal`/`-shm` + /// derivation (which keys off the `.db` extension) behaves as in + /// production. + struct Fixture { + dir: TempDir, + vault_path: PathBuf, + db_path: PathBuf, + config_path: PathBuf, + backup_path: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + Self { + vault_path: root.join("vault.bin"), + db_path: root.join("springtale.db"), + config_path: root.join("springtale.toml"), + // The backup lands inside the scanned tree on purpose: + // the leak scan then also covers the backup itself. + backup_path: root.join("travel.backup"), + dir, + } + } + + fn root(&self) -> &Path { + self.dir.path() + } + + /// Lay down a real vault, config file and encrypted SQLite + /// database, then hand back a live store handle. + /// + /// The writes go through a first handle that is dropped before + /// the returned one is opened: closing the last connection + /// checkpoints WAL into the `.db` file, which is what `prepare` + /// actually copies. + async fn populate(&self) -> SqliteBackend { + let mut vault = Vault::create(&self.vault_path, VAULT_PASSPHRASE).unwrap(); + vault + .set("api_token", VAULT_MARKER.as_bytes().to_vec()) + .unwrap(); + vault.save().unwrap(); + + std::fs::write( + &self.config_path, + format!("[bot]\ntoken = \"{CONFIG_MARKER}\"\n"), + ) + .unwrap(); + + { + let writer = SqliteBackend::open_encrypted(&self.db_path, TEST_DB_KEY_HEX).unwrap(); + let config = SafetyConfigRow { + window_title: DB_MARKER.to_owned(), + ..Default::default() + }; + writer.upsert_safety_config(&config).await.unwrap(); + } + + SqliteBackend::open_encrypted(&self.db_path, TEST_DB_KEY_HEX).unwrap() + } + + fn prepare_with(&self, store: &dyn StorageBackend) -> Result<(), OperationError> { + prepare( + &self.vault_path, + &self.db_path, + &self.config_path, + &self.backup_path, + TRAVEL_PASSPHRASE, + store, + ) + } + + fn restore_with(&self, passphrase: &[u8]) -> Result<(), OperationError> { + restore( + &self.backup_path, + &self.vault_path, + &self.db_path, + &self.config_path, + passphrase, + ) + } + } + + /// Every regular file under `root`, paired with its bytes. + fn read_tree(root: &Path) -> Vec<(PathBuf, Vec)> { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + } else { + let bytes = std::fs::read(&path).unwrap_or_default(); + found.push((path, bytes)); + } + } + } + found + } + + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + #[tokio::test] + async fn test_prepare_produces_backup_the_vault_can_reopen() { + let fx = Fixture::new(); + let store = fx.populate().await; + + fx.prepare_with(&store).unwrap(); + drop(store); + + assert!(fx.backup_path.exists(), "prepare must leave a backup"); + assert!( + !fx.vault_path.exists(), + "prepare must wipe the vault it just backed up" + ); + + // The backup is now the only route back to the vault, and the + // vault's own passphrase (not the travel one) must still open it. + fx.restore_with(TRAVEL_PASSPHRASE).unwrap(); + + let vault = Vault::open(&fx.vault_path, VAULT_PASSPHRASE).unwrap(); + assert_eq!( + vault.get("api_token").unwrap().map(Vec::as_slice), + Some(VAULT_MARKER.as_bytes()), + ); + } + + #[tokio::test] + async fn test_prepare_leaves_no_plaintext_on_disk() { + let fx = Fixture::new(); + let store = fx.populate().await; + + fx.prepare_with(&store).unwrap(); + + assert!(!fx.vault_path.exists(), "vault file survived prepare"); + assert!(!fx.db_path.exists(), "database file survived prepare"); + assert!(!fx.config_path.exists(), "config file survived prepare"); + assert!( + !fx.db_path.with_extension("db-wal").exists(), + "WAL journal survived prepare" + ); + assert!( + !fx.db_path.with_extension("db-shm").exists(), + "shared-memory index survived prepare" + ); + + drop(store); + + // Nothing left anywhere under the data directory — the backup + // included — may carry a marker in the clear. + for (path, bytes) in read_tree(fx.root()) { + for marker in [VAULT_MARKER, CONFIG_MARKER, DB_MARKER] { + assert!( + !contains(&bytes, marker.as_bytes()), + "{marker} left in cleartext in {}", + path.display(), + ); + } + } + } + + #[tokio::test] + async fn test_restore_round_trips_vault_db_and_config() { + let fx = Fixture::new(); + + let (vault_before, db_before, config_before) = { + let store = fx.populate().await; + let snapshot = ( + std::fs::read(&fx.vault_path).unwrap(), + std::fs::read(&fx.db_path).unwrap(), + std::fs::read(&fx.config_path).unwrap(), + ); + fx.prepare_with(&store).unwrap(); + snapshot + }; + + fx.restore_with(TRAVEL_PASSPHRASE).unwrap(); + + assert_eq!(std::fs::read(&fx.vault_path).unwrap(), vault_before); + assert_eq!(std::fs::read(&fx.db_path).unwrap(), db_before); + assert_eq!(std::fs::read(&fx.config_path).unwrap(), config_before); + + // Byte identity is necessary but not sufficient: the restored + // database must still decrypt and serve the row written before + // departure. + let store = SqliteBackend::open_encrypted(&fx.db_path, TEST_DB_KEY_HEX).unwrap(); + let restored = store.get_safety_config().await.unwrap(); + assert_eq!( + restored.map(|c| c.window_title).as_deref(), + Some(DB_MARKER), + "restored database lost the row written before travel" + ); + } + + #[test] + fn test_restore_missing_backup_returns_not_found() { + let fx = Fixture::new(); + + let err = fx.restore_with(TRAVEL_PASSPHRASE).unwrap_err(); + + assert!(matches!(err, OperationError::NotFound(_)), "got {err:?}"); + assert!(!fx.vault_path.exists()); + assert!(!fx.config_path.exists()); + } + + #[tokio::test] + async fn test_restore_with_wrong_passphrase_writes_nothing() { + let fx = Fixture::new(); + let store = fx.populate().await; + fx.prepare_with(&store).unwrap(); + drop(store); + + let err = fx.restore_with(b"not-the-travel-passphrase").unwrap_err(); + + assert!(matches!(err, OperationError::Rule(_)), "got {err:?}"); + assert!( + !fx.vault_path.exists(), + "a failed restore must not resurrect the vault" + ); + assert!( + !fx.config_path.exists(), + "a failed restore must not resurrect the config" + ); + assert!( + !fx.db_path.exists(), + "a failed restore must not resurrect the database" + ); + } +} diff --git a/crates/springtale-runtime/tests/safety_panic_wipe.rs b/crates/springtale-runtime/tests/safety_panic_wipe.rs new file mode 100644 index 00000000..25c2848b --- /dev/null +++ b/crates/springtale-runtime/tests/safety_panic_wipe.rs @@ -0,0 +1,135 @@ +//! `operations::safety::panic_wipe` against a real data directory. +//! +//! `panic_wipe` resolves the vault and config paths itself, via +//! `springtale_store::paths`, which reads `XDG_DATA_HOME`. Redirecting +//! that variable is the only way to exercise the function without +//! destroying the developer's actual vault — so this file holds exactly +//! one test and owns its process's environment. + +#![allow(clippy::unwrap_used)] + +use springtale_store::SafetyConfigRow; +use springtale_store::StorageBackend; +use springtale_store::backend::SqliteBackend; +use tempfile::tempdir; + +/// Production stores are always encrypted (plan 0.5), so file-backed +/// tests open with a fixed key. Never used outside tests. +const TEST_KEY_HEX: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + +const VAULT_MARKER: &str = "PLAINTEXT-MARKER-VAULT-SECRET"; +const CONFIG_MARKER: &str = "PLAINTEXT-MARKER-CONFIG-TOKEN"; +const DB_MARKER: &str = "PLAINTEXT-MARKER-DB-ROW"; + +/// Emergency wipe must leave the vault, the SQLite database, both WAL +/// artifacts and the config file gone — and no marker readable anywhere +/// under the data directory. +#[test] +fn test_panic_wipe_destroys_vault_db_wal_shm_and_config() { + let dir = tempdir().unwrap(); + + // SAFETY: `set_var` is only unsound while another thread may read + // the environment concurrently. This is the sole test in this + // binary and runs before any runtime, task or blocking pool exists, + // so no other thread is alive to observe the write. + unsafe { + std::env::set_var("XDG_DATA_HOME", dir.path()); + } + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let vault_path = springtale_store::paths::default_vault_path(); + let db_path = springtale_store::paths::default_db_path(); + let config_path = springtale_store::paths::default_config_path(); + let data_dir = springtale_store::paths::data_dir(); + + // Guard against a paths change silently pointing this test at + // the real home directory. + assert!( + data_dir.starts_with(dir.path()), + "data dir {} escaped the temp root", + data_dir.display() + ); + std::fs::create_dir_all(&data_dir).unwrap(); + + let mut vault = + springtale_crypto::vault::Vault::create(&vault_path, b"vault-pass").unwrap(); + vault + .set("api_token", VAULT_MARKER.as_bytes().to_vec()) + .unwrap(); + vault.save().unwrap(); + + std::fs::write( + &config_path, + format!("[bot]\ntoken = \"{CONFIG_MARKER}\"\n"), + ) + .unwrap(); + + let store = SqliteBackend::open_encrypted(&db_path, TEST_KEY_HEX).unwrap(); + let config = SafetyConfigRow { + window_title: DB_MARKER.to_owned(), + ..Default::default() + }; + store.upsert_safety_config(&config).await.unwrap(); + + // WAL mode: the write must have produced both journal artifacts, + // otherwise the wipe below would be proving nothing about them. + let wal_path = db_path.with_extension("db-wal"); + let shm_path = db_path.with_extension("db-shm"); + assert!(vault_path.exists()); + assert!(db_path.exists()); + assert!(wal_path.exists(), "expected a WAL journal to wipe"); + assert!(shm_path.exists(), "expected a shared-memory index to wipe"); + assert!(config_path.exists()); + + springtale_runtime::operations::safety::panic_wipe(&store) + .await + .unwrap(); + + assert!(!vault_path.exists(), "vault survived panic wipe"); + assert!(!db_path.exists(), "database survived panic wipe"); + assert!(!wal_path.exists(), "WAL journal survived panic wipe"); + assert!( + !shm_path.exists(), + "shared-memory index survived panic wipe" + ); + assert!(!config_path.exists(), "config survived panic wipe"); + + drop(store); + + for (path, bytes) in read_tree(dir.path()) { + for marker in [VAULT_MARKER, CONFIG_MARKER, DB_MARKER] { + assert!( + !contains(&bytes, marker.as_bytes()), + "{marker} readable after panic wipe in {}", + path.display() + ); + } + } + }); +} + +/// Every regular file under `root`, paired with its bytes. +fn read_tree(root: &std::path::Path) -> Vec<(std::path::PathBuf, Vec)> { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + } else { + let bytes = std::fs::read(&path).unwrap_or_default(); + found.push((path, bytes)); + } + } + } + found +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} From fbc704f446d7917f2f56779d6d80dd40ab95e961 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 19:12:31 -0700 Subject: [PATCH 24/24] py+contract: ship the Python package the wheel expects, and regenerate the document Two things the new CI jobs caught, which is what they are for. The wheel declares a Python source directory that was never created, so `maturin build` failed outright: the crate promised curated type stubs and shipped none. The package now exists, with stubs for the four classes the extension registers and the marker that makes them count. The webhook route's description changed when it stopped knowing connectors by name, and the committed contract still carried the old text. Regenerated from the handlers, along with its TypeScript. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../python/springtale/__init__.py | 10 ++++ .../python/springtale/__init__.pyi | 56 +++++++++++++++++++ .../springtale-py/python/springtale/py.typed | 0 tauri/packages/types/openapi.json | 2 +- tauri/packages/types/src/api.ts | 13 +++-- 5 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 crates/springtale-py/python/springtale/__init__.py create mode 100644 crates/springtale-py/python/springtale/__init__.pyi create mode 100644 crates/springtale-py/python/springtale/py.typed diff --git a/crates/springtale-py/python/springtale/__init__.py b/crates/springtale-py/python/springtale/__init__.py new file mode 100644 index 00000000..8d1fa0d8 --- /dev/null +++ b/crates/springtale-py/python/springtale/__init__.py @@ -0,0 +1,10 @@ +"""Springtale's cooperation primitives, for Python. + +The compiled extension provides every class; this package exists so the +wheel can ship type stubs beside it (see ``__init__.pyi``). Importing +from here and from the extension is the same thing. +""" + +from .springtale import Formation, FormationId, Intent, MomentumTier, __version__ + +__all__ = ["Formation", "FormationId", "Intent", "MomentumTier", "__version__"] diff --git a/crates/springtale-py/python/springtale/__init__.pyi b/crates/springtale-py/python/springtale/__init__.pyi new file mode 100644 index 00000000..9080b19b --- /dev/null +++ b/crates/springtale-py/python/springtale/__init__.pyi @@ -0,0 +1,56 @@ +"""Type stubs for the compiled extension. + +Curated surface, per the crate's module documentation: the cooperation +model, not the runtime. Keep in step with `src/` — the classes here are +the ones `module.rs` registers. +""" + +from enum import Enum +from typing import Optional + +__version__: str + +class MomentumTier(Enum): + """Capability gate. Cold, Warming, Hot, Fever.""" + + Cold = ... + Warming = ... + Hot = ... + Fever = ... + +class Intent: + """A formation's intent pattern.""" + + @staticmethod + def reconnoiter(target: str) -> "Intent": ... + @staticmethod + def execute(plan_id: Optional[str] = None) -> "Intent": ... + @staticmethod + def stabilize(reason: str) -> "Intent": ... + @staticmethod + def surge(objective: str) -> "Intent": ... + @staticmethod + def dissolve(reason: str) -> "Intent": ... + def kind(self) -> str: + """``"reconnoiter" | "execute" | "stabilize" | "surge" | "dissolve"``.""" + +class FormationId: + """A formation's identity, a UUID seen from Python as a string.""" + + def __init__(self) -> None: ... + @staticmethod + def parse(s: str) -> "FormationId": ... + def __str__(self) -> str: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + +class Formation: + """A read-only handle over a formation: identity, intent, momentum.""" + + def __init__(self, intent: Intent) -> None: ... + @property + def id(self) -> FormationId: ... + @property + def intent(self) -> Intent: ... + @property + def momentum_tier(self) -> MomentumTier: ... diff --git a/crates/springtale-py/python/springtale/py.typed b/crates/springtale-py/python/springtale/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/tauri/packages/types/openapi.json b/tauri/packages/types/openapi.json index 3a14d1d7..743a8951 100644 --- a/tauri/packages/types/openapi.json +++ b/tauri/packages/types/openapi.json @@ -4454,7 +4454,7 @@ "webhooks" ], "summary": "POST /webhook/{connector}/{trigger} — receive an inbound webhook.", - "description": "The management API receives webhook POSTs from external services (GitHub, Kick, etc.)\nand routes them to the appropriate connector for signature verification and dispatch.\n\nFlow:\n1. Look up connector in registry\n2. Connector-specific signature verification (GitHub: HMAC-SHA256, Kick: RSA)\n3. Dispatch trigger event to the rule engine via the trigger channel", + "description": "The management API receives webhook POSTs from external services and\nroutes them to the named connector for signature verification and dispatch.\n\nThe route owns the transport and nothing else: it knows no connector,\nno provider payload shape, and no action name. Everything protocol-\nspecific is asked of the connector through the `Connector` trait.\n\nFlow:\n1. Look up connector in registry\n2. Connector-specific signature verification (each connector's own scheme)\n3. Ask the connector what the verified payload means\n4. Dispatch trigger event to the rule engine via the trigger channel", "operationId": "webhooks_receive", "parameters": [ { diff --git a/tauri/packages/types/src/api.ts b/tauri/packages/types/src/api.ts index 7268a139..51ef84fd 100644 --- a/tauri/packages/types/src/api.ts +++ b/tauri/packages/types/src/api.ts @@ -2297,13 +2297,18 @@ export interface paths { put?: never; /** * POST /webhook/{connector}/{trigger} — receive an inbound webhook. - * @description The management API receives webhook POSTs from external services (GitHub, Kick, etc.) - * and routes them to the appropriate connector for signature verification and dispatch. + * @description The management API receives webhook POSTs from external services and + * routes them to the named connector for signature verification and dispatch. + * + * The route owns the transport and nothing else: it knows no connector, + * no provider payload shape, and no action name. Everything protocol- + * specific is asked of the connector through the `Connector` trait. * * Flow: * 1. Look up connector in registry - * 2. Connector-specific signature verification (GitHub: HMAC-SHA256, Kick: RSA) - * 3. Dispatch trigger event to the rule engine via the trigger channel + * 2. Connector-specific signature verification (each connector's own scheme) + * 3. Ask the connector what the verified payload means + * 4. Dispatch trigger event to the rule engine via the trigger channel */ post: operations["webhooks_receive"]; delete?: never;