Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1dd177a
py: the module files are the code, not a second copy beside lib.rs
radicalkjax Sep 7, 2026
11d1465
bot: the beat reports a surface reaction and a claimed task together
radicalkjax Sep 7, 2026
6acbcc6
cooperation: momentum thresholds are a per-formation constraint
radicalkjax Sep 7, 2026
0990407
cooperation: pacing weights are configuration, not constants
radicalkjax Sep 7, 2026
01f516c
cooperation: measure handoff success
radicalkjax Sep 7, 2026
70591f5
feat(chat): the AI tool loop learns the platform verbs (plan 5.4)
radicalkjax Sep 7, 2026
a4ebc30
feat(chat): sentence templates per locale, matched in the speaker's l…
radicalkjax Sep 7, 2026
9b4cd37
fix(chat): read the live AI adapter, not the one the bot was built with
radicalkjax Sep 7, 2026
1cea971
sdk: connector WIT world, component example, real sandbox positive test
radicalkjax Sep 7, 2026
e330b9f
desktop: extract safety policy into pure modules and test it
radicalkjax Sep 7, 2026
897ac45
ci: build the WASM SDK example and the Python wheel
radicalkjax Sep 7, 2026
7f6900c
api: describe the three routes the contract left out
radicalkjax Sep 7, 2026
a6621b3
cli: one writer for the pairing registry, one registry for authors
radicalkjax Sep 7, 2026
9aec03d
api: typed request bodies for the six formation handlers
radicalkjax Sep 7, 2026
0783c5d
cli: the command line reports its own verbs and the routes they call
radicalkjax Sep 7, 2026
418f096
cli: --json shape tests for every subcommand that emits JSON
radicalkjax Sep 7, 2026
d756ab7
transport: mutual-TLS tests for HttpTransport
radicalkjax Sep 7, 2026
15cfe98
Merge branch 'api/finish-the-contract' into cooperation/finish-phase-one
radicalkjax Sep 7, 2026
e1ee8cd
Merge branch 'py/module-structure' into cooperation/finish-phase-one
radicalkjax Sep 7, 2026
511c9f6
feat(mcp): send notifications/tools/list_changed when the registry ch…
radicalkjax Sep 7, 2026
9e30570
refactor(webhooks): the ingest route knows no connector by name (plan…
radicalkjax Sep 7, 2026
351df9a
fix(config): any installed connector is configurable headless (plan 6.4)
radicalkjax Sep 7, 2026
48b195c
fix(kick): persist the webhook replay window across restarts
radicalkjax Sep 7, 2026
b137793
fix(desktop): surface the sidecar dying instead of talking to nothing
radicalkjax Sep 7, 2026
4901a56
runtime: tests for travel preparation, restore, and panic wipe
radicalkjax Sep 7, 2026
0aa1315
Merge branch 'product/finish-the-surfaces' into cooperation/finish-ph…
radicalkjax Sep 7, 2026
db92a72
travel: check point the write-ahead log before backing the database up
radicalkjax Sep 7, 2026
fbc704f
py+contract: ship the Python package the wheel expects, and regenerat…
radicalkjax Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,96 @@
# 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.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions apps/springtale-cli/examples/task-runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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;
Expand Down
15 changes: 15 additions & 0 deletions apps/springtale-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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)]
Expand Down
82 changes: 68 additions & 14 deletions apps/springtale-cli/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
}
69 changes: 56 additions & 13 deletions apps/springtale-cli/src/commands/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand All @@ -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": [] })), "");
}
}
Loading
Loading