diff --git a/PROTOCOL.md b/PROTOCOL.md index 41ea54f..ac47812 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -334,7 +334,7 @@ At start, the core starts each service in `/config/boot.yaml`, in order: | `core.kill` | `pid` | `{}` | | `core.lock` | `path`, `wait`? | `{"held": bool}` | | `core.unlock` | `path` | none (a notification) | -| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | the new `rpc.discover` documents | +| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | short activation summaries, keyed by plugin | | `core.revert` | `plugin`, `force`? | like `core.activate` | | `core.log` | filters (section 7) | journal entries | | `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. | @@ -434,6 +434,26 @@ imported method: If a check fails, nothing changes, and the error -32003 lists each problem, with a counterexample where possible. +The result is an object keyed by plugin name. Each value contains: + +- `plugin`: the plugin name. +- `wasm`: the installed world path (`/plugins//plugin.wasm`). +- `hash`: the SHA-256 of the installed copy. +- `previous`: the replaced version's SHA-256, or `null` on first installation. + For an identical-hash no-op, this is the current hash. +- `method_count`: the number of methods in the new main interface. +- `added`, `removed`, `changed`: sorted method-name arrays compared with the + previous discovery document. A change to a method description or any + transitively referenced local schema counts as changed. Unrelated schemas + and endpoint interfaces do not affect these arrays. With no previous + document, all methods are added. +- `interface_check`: `"passed"` after successful checks, or `"skipped"` with + `force: true` (not a claim of compatibility). + +The result does not include schemas. The full document remains available +through `.rpc.discover` and `/proc/core/plugins//discover.json`. +An identical-hash no-op and `core.revert` return the same summary shape. + - The core runs one activation at a time. - `plugins: [...]` activates several plugins together. The checks then use the new set. diff --git a/cli/src/activation.rs b/cli/src/activation.rs index 8b75e03..2729ff3 100644 --- a/cli/src/activation.rs +++ b/cli/src/activation.rs @@ -6,6 +6,44 @@ use crate::{ use serde_json::{Value, json}; use std::{collections::BTreeMap, path::PathBuf}; +/// Method descriptions plus their reachable local references, without expanding cycles. +fn methods(document: &Value) -> BTreeMap { + fn references(value: &Value, document: &Value, found: &mut BTreeMap) { + match value { + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) + && let Some(pointer) = reference.strip_prefix('#') + && !found.contains_key(reference) + && let Some(target) = document.pointer(pointer) + { + found.insert(reference.into(), target.clone()); + references(target, document, found); + } + for child in object.values() { + references(child, document, found); + } + } + Value::Array(array) => { + for child in array { + references(child, document, found); + } + } + _ => {} + } + } + document["methods"] + .as_array() + .into_iter() + .flatten() + .filter_map(|method| { + let name = method["name"].as_str()?; + let mut found = BTreeMap::new(); + references(method, document, &mut found); + Some((name.into(), json!({"method":method,"references":found}))) + }) + .collect() +} + struct Candidate { plugin: String, copy: PathBuf, @@ -113,7 +151,31 @@ impl Host { .map(|candidate| candidate.plugin.clone()) .collect(); self.refresh_graph_except(&names).await?; - let previous = self.0.graph.lock().unwrap().plugins.clone(); + let mut previous = self.0.graph.lock().unwrap().plugins.clone(); + // Startup discovery may not yet have visited a replaced plugin. Discover + // its immutable old copy for the diff, but never prevent repairing it. + for candidate in candidates.iter() { + if let Some(old) = previous.get_mut(&candidate.plugin) + && old.document.is_null() + && old.error.is_none() + { + if old.hash == candidate.interface.hash { + old.document = candidate.interface.document.clone(); + continue; + } + let directory = self.0.world.plugin_dir(&candidate.plugin); + if let Ok((bytes, hash)) = read_hashed(directory.join("plugin.wasm")).await { + let copy = directory.join(format!("discover-{}.wasm", uuid::Uuid::now_v7())); + if tokio::fs::write(©, bytes).await.is_ok() + && let Ok(interface) = + self.discover_copy(&candidate.plugin, ©, hash).await + { + *old = interface; + } + let _ = tokio::fs::remove_file(copy).await; + } + } + } let mut proposed = previous.clone(); for candidate in candidates.iter() { proposed.insert(candidate.plugin.clone(), candidate.interface.clone()); @@ -148,7 +210,7 @@ impl Host { ); } } - self.install_candidates(candidates, previous, proposed) + self.install_candidates(candidates, previous, proposed, params["force"] != true) .await } /// Installs staged files and snapshots all old admissions under the shared write gate. @@ -157,6 +219,7 @@ impl Host { candidates: &[Candidate], previous: BTreeMap, proposed: BTreeMap, + checked: bool, ) -> RpcResult { let admission = self.0.admission.write().await; // Every candidate already has a temporary name before any installed file changes. @@ -183,12 +246,35 @@ impl Host { .map_err(io_error)?; } let mut upgrades = Vec::new(); - let mut documents = serde_json::Map::new(); + let mut summaries = serde_json::Map::new(); for candidate in candidates { - documents.insert( - candidate.plugin.clone(), - candidate.interface.document.clone(), - ); + let old = previous.get(&candidate.plugin); + let before = methods(old.map(|old| &old.document).unwrap_or(&Value::Null)); + let after = methods(&candidate.interface.document); + let added: Vec<_> = after + .keys() + .filter(|name| !before.contains_key(*name)) + .collect(); + let removed: Vec<_> = before + .keys() + .filter(|name| !after.contains_key(*name)) + .collect(); + let changed: Vec<_> = after + .iter() + .filter(|(name, method)| before.get(*name).is_some_and(|old| old != *method)) + .map(|(name, _)| name) + .collect(); + summaries.insert(candidate.plugin.clone(), json!({ + "plugin": candidate.plugin, + "wasm": format!("/plugins/{}/plugin.wasm", candidate.plugin), + "hash": candidate.interface.hash, + "previous": old.map(|old| &old.hash).filter(|hash| !hash.is_empty()), + "method_count": candidate.interface.document["methods"].as_array().map_or(0, Vec::len), + "added": added, + "removed": removed, + "changed": changed, + "interface_check": if checked { "passed" } else { "skipped" }, + })); if let Some(old) = previous .get(&candidate.plugin) .filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash) @@ -216,6 +302,6 @@ impl Host { for upgrade in upgrades { self.begin_upgrade(upgrade); } - Ok(Value::Object(documents)) + Ok(Value::Object(summaries)) } } diff --git a/cli/tests/host/activation.rs b/cli/tests/host/activation.rs index 3be2f61..04769ee 100644 --- a/cli/tests/host/activation.rs +++ b/cli/tests/host/activation.rs @@ -43,6 +43,86 @@ fn fixture(version: usize) -> PathBuf { })[version - 1] .clone() } +fn fixture_hash(version: usize) -> String { + use sha2::{Digest, Sha256}; + format!( + "{:x}", + Sha256::digest(std::fs::read(fixture(version)).unwrap()) + ) +} + +/// PROTOCOL 10.3: summaries omit even large schemas and report sorted main-method diffs. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn summarizes_method_changes() { + let mut old = document("unchanged"); + old["methods"] = json!([ + {"name":"z_removed"}, {"name":"a_removed"}, {"name":"direct", "params":[]}, + {"name":"referenced", "result":{"schema":{"$ref":"#/components/schemas/Node"}}}, + {"name":"unrelated", "result":{"schema":{"type":"string"}}} + ]); + old["components"]["schemas"] = json!({ + "Node":{"properties":{"next":{"$ref":"#/components/schemas/Node"}, "value":{"$ref":"#/components/schemas/Value"}}}, + "Value":{"type":"string"}, "Unused":{"type":"string"} + }); + let mut next = old.clone(); + next["methods"].as_array_mut().unwrap().drain(..2); + next["methods"][0]["params"] = json!([{"name":"limit","schema":{"type":"integer"}}]); + next["methods"].as_array_mut().unwrap().extend( + json!([ + {"name":"z_added"}, {"name":"a_added"} + ]) + .as_array() + .unwrap() + .iter() + .cloned(), + ); + next["components"]["schemas"]["Value"]["type"] = json!("integer"); + next["components"]["schemas"]["Unused"]["description"] = json!("x".repeat(220_000)); + let world = TestWorld::new(); + world.plugin("notes", old, next.clone()); + let host = world.start().await; + let result = activate(&host, "notes").await.unwrap(); + assert_eq!(result["notes"]["method_count"], 5); + assert_eq!(result["notes"]["added"], json!(["a_added", "z_added"])); + assert_eq!( + result["notes"]["removed"], + json!(["a_removed", "z_removed"]) + ); + assert_eq!(result["notes"]["changed"], json!(["direct", "referenced"])); + assert!(result.to_string().len() < 1024); + assert_eq!(call(&host, "notes.rpc.discover", json!({})).await, next); + assert_eq!( + call( + &host, + "notes.read", + json!({"path":"/proc/core/plugins/notes/discover.json"}) + ) + .await, + next + ); + host.stop().await; +} + +/// PROTOCOL 10.3: first installation has no previous build; a no-op has no method changes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn summarizes_first_install_and_noop() { + let world = TestWorld::new(); + world.plugin("notes", document("add"), document("add")); + std::fs::remove_file(world.0.join("plugins/notes/plugin.wasm")).unwrap(); + let host = world.start().await; + let result = activate(&host, "notes").await.unwrap(); + assert_eq!(result["notes"]["previous"], Value::Null); + assert_eq!(result["notes"]["added"], json!(["add"])); + assert_eq!(result["notes"]["hash"], fixture_hash(2)); + let result = activate(&host, "notes").await.unwrap(); + assert_eq!(result["notes"]["previous"], fixture_hash(2)); + for field in ["added", "removed", "changed"] { + assert_eq!(result["notes"][field], json!([])); + } + assert!(!world.0.join("plugins/notes/plugin.prev.wasm").exists()); + host.stop().await; +} + struct TestWorld(PathBuf); impl TestWorld { /// Creates an isolated world with two version documents per plugin. @@ -481,7 +561,19 @@ async fn activates_copy() { world.plugin("notes", document("add"), document("add")); let host = world.start().await; let result = activate(&host, "notes").await.unwrap(); - assert!(result["notes"]["methods"].is_array()); + assert_eq!( + result, + json!({"notes": { + "plugin":"notes", "wasm":"/plugins/notes/plugin.wasm", + "hash":fixture_hash(2), "previous":fixture_hash(1), + "method_count":1, "added":[], "removed":[], "changed":[], + "interface_check":"passed" + }}) + ); + assert_eq!( + call(&host, "notes.rpc.discover", json!({})).await, + document("add") + ); assert_eq!( call(&host, "notes.version", json!({})).await["version"], "2" @@ -577,7 +669,12 @@ async fn activates_group() { consumer("notes", document("other")), ); let host = world.start().await; - call(&host,"core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await; + let result = call(&host,"core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await; + assert_eq!(result.as_object().unwrap().len(), 2); + assert_eq!(result["notes"]["added"], json!(["other"])); + assert_eq!(result["notes"]["removed"], json!(["add"])); + assert_eq!(result["agent"]["method_count"], 1); + assert_eq!(result["agent"]["interface_check"], "passed"); assert_eq!( call(&host, "agent.version", json!({})).await["version"], "2" @@ -594,12 +691,13 @@ async fn force_skips_checks() { consumer("absent", document("add")), ); let host = world.start().await; - call( + let result = call( &host, "core.activate", json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}), ) .await; + assert_eq!(result["notes"]["interface_check"], "skipped"); host.stop().await; } /// PROTOCOL 10.3: revert activates the previous copy and preserves the replaced version. @@ -609,7 +707,11 @@ async fn reverts_previous_version() { world.plugin("notes", document("add"), document("add")); let host = world.start().await; activate(&host, "notes").await.unwrap(); - call(&host, "core.revert", json!({"plugin":"notes"})).await; + let result = call(&host, "core.revert", json!({"plugin":"notes"})).await; + assert_eq!(result["notes"]["hash"], fixture_hash(1)); + assert_eq!(result["notes"]["previous"], fixture_hash(2)); + assert_eq!(result["notes"]["interface_check"], "passed"); + assert!(result["notes"].get("methods").is_none()); assert_eq!( call(&host, "notes.version", json!({})).await["version"], "1"