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. 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/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/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/apps/springtale-cli/src/cli.rs b/apps/springtale-cli/src/cli.rs index 4de84ad5..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)] @@ -619,6 +627,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/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..1f6bb3f0 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,12 +92,10 @@ 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 }); + let removed = removed_body(&name); output::emit(json, &removed, |v| { format!("Removed trusted author: {}", output::cell(v, "name")) })?; @@ -144,3 +131,76 @@ pub fn load_local_identity() -> Result { Keypair::from_secret_bytes(bytes).context("identity in vault is not a valid Ed25519 key") } + +/// The `author list` body — a bare array, one object per author. The +/// command emits the operation's own rows; this is the same shape, +/// asserted by the output tests. +#[cfg(test)] +fn authors_json(rows: &[AuthorTableRow]) -> Vec { + rows.iter() + .map(|r| serde_json::json!({ "name": r.name, "pubkey": r.pubkey })) + .collect() +} + +/// The `author add` body, as the command emits it. +#[cfg(test)] +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}; + + /// Rows as `operations::authors::list` returns them. Parsing the + /// `trusted-author:` config entries is the operation's job, so this + /// starts from its output rather than re-implementing the parse. + fn rows() -> Vec { + vec![AuthorTableRow { + name: "kali".to_owned(), + pubkey: "aa11".to_owned(), + }] + } + + #[test] + fn test_author_list_json_shape_is_a_bare_array_of_name_and_pubkey() { + let out = json_value(&authors_json(&rows())); + 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(&[])); + 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..0fb861e5 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,13 +25,23 @@ 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) .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 +124,47 @@ pub async fn settings(action: BotSettingsAction, json_out: bool) -> Result<()> { } Ok(()) } + +/// The `bot pair-init` body as the daemon returns it — the code the +/// operator reads out, plus the single-use contract it comes with. The +/// route builds this now; the shape is kept here so the rendering test +/// asserts against the real one. +#[cfg(test)] +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..32511370 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. @@ -70,11 +91,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/main.rs b/apps/springtale-cli/src/main.rs index ed0ce42f..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?; } @@ -98,6 +102,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 => { @@ -115,7 +122,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/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()), ""); + } +} 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/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/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/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/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 b455ffec..041e3677 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, @@ -99,14 +100,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 +165,11 @@ use super::*; config_api::ConfigureAiBody, data::PurgeBody, executions::VacuumResponse, + formations::CastVoteBody, + formations::IntentBody, + formations::MemberBody, + formations::RunCommandBody, + lock::UnlockRequest, login::CreateTokenRequest, login::LoginRequest, onboarding::ApplyRequest, @@ -260,8 +269,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 +280,7 @@ use super::*; (name = "sessions"), (name = "stream"), (name = "utterances"), + (name = "vault"), (name = "webhooks"), (name = "workspaces") ) @@ -280,6 +292,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/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/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/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/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/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/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-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"] 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/cooperation/formation.rs b/crates/springtale-bot/src/cooperation/formation.rs index e08079ba..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 @@ -498,17 +501,23 @@ 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 / 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(), + handoff_log: Arc::new(HandoffLog::default()), intent, paused: false, constraints, - momentum: MomentumState::default(), + momentum, blackboard, 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(), @@ -936,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 @@ -1179,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] @@ -1308,3 +1333,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-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..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,10 +499,13 @@ 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, + // 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/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..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 @@ -86,6 +86,17 @@ 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< + 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(); let mut jobs = Vec::new(); @@ -151,12 +162,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..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,8 +10,9 @@ 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 +187,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..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; @@ -164,6 +183,7 @@ mod tests { latency: Duration::from_millis(1), intent_alignment: alignment, interference_with: vec![], + surface_reaction: None, state, } } @@ -192,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 @@ -223,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 )); } @@ -239,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); @@ -259,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-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-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/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/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/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/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}; 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 + ); + } +} 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/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/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/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/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..eb9657b1 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,46 @@ 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 +192,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 +201,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 +240,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/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/src/types.rs b/crates/springtale-cooperation/src/types.rs index bff7d971..c23c5ee0 100644 --- a/crates/springtale-cooperation/src/types.rs +++ b/crates/springtale-cooperation/src/types.rs @@ -222,6 +222,14 @@ 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, + /// Director numbers for this formation's pacing loop (plan 1.5, + /// `[cooperation.pacing]`). + pub pacing: crate::pacing::PacingConfig, } impl Default for FormationConstraints { @@ -233,6 +241,8 @@ impl Default for FormationConstraints { fuel_budget: FuelAmount(100_000), destructive_action_policy: ApprovalPolicy::AlwaysRequire, autonomy_ceiling: AutonomyLevel::ActAutonomously, + momentum: crate::momentum::MomentumConfig::default(), + pacing: crate::pacing::PacingConfig::default(), } } } 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() 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-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/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(()) +} 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/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") diff --git a/crates/springtale-runtime/src/operations/travel.rs b/crates/springtale-runtime/src/operations/travel.rs index 6fe46b87..47141134 100644 --- a/crates/springtale-runtime/src/operations/travel.rs +++ b/crates/springtale-runtime/src/operations/travel.rs @@ -28,6 +28,13 @@ pub fn prepare( passphrase: &[u8], store: &dyn StorageBackend, ) -> Result<(), OperationError> { + // Fold the write-ahead log into the database file first. The backup + // copies `.db` as a file, so without this every row written since the + // last automatic checkpoint would be left behind in the `-wal` and the + // traveller would carry a backup that silently predates their last + // work. + store.checkpoint().map_err(OperationError::Store)?; + // Export encrypted backup springtale_crypto::vault::backup::export_backup( vault_path, @@ -83,3 +90,299 @@ 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) + } + + /// A row written through the live handle, still in the write-ahead + /// log at departure, must be in the backup. `prepare` checkpoints + /// first; without that the backup carries only what an automatic + /// checkpoint happened to have folded in, which is a backup that + /// looks complete and silently predates the traveller's last work. + #[tokio::test] + async fn test_prepare_backs_up_rows_still_in_the_write_ahead_log() { + const LATE_MARKER: &str = "WRITTEN-JUST-BEFORE-LEAVING"; + + let fx = Fixture::new(); + let store = fx.populate().await; + + // Written through the handle `prepare` is given, and never + // closed before the backup: this lives in the `-wal` file. + let config = SafetyConfigRow { + window_title: LATE_MARKER.to_owned(), + ..Default::default() + }; + store.upsert_safety_config(&config).await.unwrap(); + + fx.prepare_with(&store).unwrap(); + drop(store); + fx.restore_with(TRAVEL_PASSPHRASE).unwrap(); + + let restored = SqliteBackend::open_encrypted(&fx.db_path, TEST_DB_KEY_HEX).unwrap(); + let safety = restored.get_safety_config().await.unwrap(); + assert_eq!( + safety.map(|c| c.window_title).unwrap_or_default(), + LATE_MARKER, + "the backup lost a row that was still in the write-ahead log" + ); + } + + #[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/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, + } + ); + } +} 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) +} diff --git a/crates/springtale-store/src/backend/sqlite/mod.rs b/crates/springtale-store/src/backend/sqlite/mod.rs index ec068a59..3ea06e5a 100644 --- a/crates/springtale-store/src/backend/sqlite/mod.rs +++ b/crates/springtale-store/src/backend/sqlite/mod.rs @@ -865,6 +865,22 @@ impl super::trait_::StorageBackend for SqliteBackend { Ok(()) } + + /// Truncating checkpoint: fold the write-ahead log into the database + /// file and empty it, so a file copy carries every committed row. + /// + /// Without this, `travel prepare` backed up only the `.db` and left + /// anything still in the `-wal` behind — a backup that looks complete + /// and silently predates the last writes. + fn checkpoint(&self) -> Result<(), StoreError> { + let conn = self + .conn + .lock() + .map_err(|_| StoreError::Database("lock poisoned".into()))?; + conn.pragma_update(None, "wal_checkpoint", "TRUNCATE") + .map_err(|e| StoreError::Database(format!("wal checkpoint failed: {e}")))?; + Ok(()) + } } #[cfg(test)] diff --git a/crates/springtale-store/src/backend/trait_.rs b/crates/springtale-store/src/backend/trait_.rs index 9b511972..190fca19 100644 --- a/crates/springtale-store/src/backend/trait_.rs +++ b/crates/springtale-store/src/backend/trait_.rs @@ -862,4 +862,19 @@ pub trait StorageBackend: Send + Sync + 'static { fn panic_wipe(&self) -> Result<(), StoreError> { Ok(()) } + + /// Fold the write-ahead log back into the database file. + /// + /// A WAL-mode database keeps recent commits in a sidecar file, so a + /// backup that copies only the `.db` silently leaves out everything + /// written since the last automatic checkpoint. Anything that copies + /// the database as a file — travel mode, the operator backup — calls + /// this first. + /// + /// Not async, for the same reason as `panic_wipe`: it is called on + /// paths that are about to destroy or move the file. The default is a + /// no-op, correct for backends with no write-ahead log. + fn checkpoint(&self) -> Result<(), StoreError> { + Ok(()) + } } 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 + ); +} 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 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 32440d73..ee7e2ba9 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 # @@ -22,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 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 00000000..af9edaad Binary files /dev/null and b/sdk/examples/connector-hello-wasm/prebuilt/connector_hello_wasm.wasm differ 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); 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 = "..." 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/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..dda39ad4 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; @@ -82,6 +83,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/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..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/types/openapi.json b/tauri/packages/types/openapi.json index ab9a4bd9..743a8951 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,13 +4393,68 @@ } } }, + "/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": [ "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": [ { @@ -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..51ef84fd 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; @@ -2194,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; @@ -2401,6 +2509,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 +2929,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 +2955,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 +3259,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 +3393,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 +3815,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 +5116,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["IntentBody"]; }; }; responses: { @@ -4967,7 +5143,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["MemberBody"]; }; }; responses: { @@ -4994,7 +5170,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["MemberBody"]; }; }; responses: { @@ -5067,7 +5243,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["IntentBody"]; }; }; responses: { @@ -5140,7 +5316,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["RunCommandBody"]; }; }; responses: { @@ -5192,7 +5368,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Record; + "application/json": components["schemas"]["CastVoteBody"]; }; }; responses: { @@ -5227,6 +5403,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 +5544,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 +6549,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/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" } 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 }, ); },