Return compact activation summaries instead of discovery schemas
ci / check (push) Failing after 2m40s

Card: SAU-158
This commit is contained in:
SAU-158 implementer
2026-10-26 22:24:43 +00:00
parent a14f051f97
commit 9a8c143856
3 changed files with 221 additions and 13 deletions
+21 -1
View File
@@ -334,7 +334,7 @@ At start, the core starts each service in `/config/boot.yaml`, in order:
| `core.kill` | `pid` | `{}` | | `core.kill` | `pid` | `{}` |
| `core.lock` | `path`, `wait`? | `{"held": bool}` | | `core.lock` | `path`, `wait`? | `{"held": bool}` |
| `core.unlock` | `path` | none (a notification) | | `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.revert` | `plugin`, `force`? | like `core.activate` |
| `core.log` | filters (section 7) | journal entries | | `core.log` | filters (section 7) | journal entries |
| `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. | | `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, If a check fails, nothing changes, and the error -32003 lists each problem,
with a counterexample where possible. 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/<name>/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 `<plugin>.rpc.discover` and `/proc/core/plugins/<name>/discover.json`.
An identical-hash no-op and `core.revert` return the same summary shape.
- The core runs one activation at a time. - The core runs one activation at a time.
- `plugins: [...]` activates several plugins together. The checks then use - `plugins: [...]` activates several plugins together. The checks then use
the new set. the new set.
+94 -8
View File
@@ -6,6 +6,44 @@ use crate::{
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::{collections::BTreeMap, path::PathBuf}; use std::{collections::BTreeMap, path::PathBuf};
/// Method descriptions plus their reachable local references, without expanding cycles.
fn methods(document: &Value) -> BTreeMap<String, Value> {
fn references(value: &Value, document: &Value, found: &mut BTreeMap<String, Value>) {
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 { struct Candidate {
plugin: String, plugin: String,
copy: PathBuf, copy: PathBuf,
@@ -113,7 +151,31 @@ impl Host {
.map(|candidate| candidate.plugin.clone()) .map(|candidate| candidate.plugin.clone())
.collect(); .collect();
self.refresh_graph_except(&names).await?; 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(&copy, bytes).await.is_ok()
&& let Ok(interface) =
self.discover_copy(&candidate.plugin, &copy, hash).await
{
*old = interface;
}
let _ = tokio::fs::remove_file(copy).await;
}
}
}
let mut proposed = previous.clone(); let mut proposed = previous.clone();
for candidate in candidates.iter() { for candidate in candidates.iter() {
proposed.insert(candidate.plugin.clone(), candidate.interface.clone()); 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 .await
} }
/// Installs staged files and snapshots all old admissions under the shared write gate. /// Installs staged files and snapshots all old admissions under the shared write gate.
@@ -157,6 +219,7 @@ impl Host {
candidates: &[Candidate], candidates: &[Candidate],
previous: BTreeMap<String, Interface>, previous: BTreeMap<String, Interface>,
proposed: BTreeMap<String, Interface>, proposed: BTreeMap<String, Interface>,
checked: bool,
) -> RpcResult { ) -> RpcResult {
let admission = self.0.admission.write().await; let admission = self.0.admission.write().await;
// Every candidate already has a temporary name before any installed file changes. // Every candidate already has a temporary name before any installed file changes.
@@ -183,12 +246,35 @@ impl Host {
.map_err(io_error)?; .map_err(io_error)?;
} }
let mut upgrades = Vec::new(); let mut upgrades = Vec::new();
let mut documents = serde_json::Map::new(); let mut summaries = serde_json::Map::new();
for candidate in candidates { for candidate in candidates {
documents.insert( let old = previous.get(&candidate.plugin);
candidate.plugin.clone(), let before = methods(old.map(|old| &old.document).unwrap_or(&Value::Null));
candidate.interface.document.clone(), 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 if let Some(old) = previous
.get(&candidate.plugin) .get(&candidate.plugin)
.filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash) .filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash)
@@ -216,6 +302,6 @@ impl Host {
for upgrade in upgrades { for upgrade in upgrades {
self.begin_upgrade(upgrade); self.begin_upgrade(upgrade);
} }
Ok(Value::Object(documents)) Ok(Value::Object(summaries))
} }
} }
+106 -4
View File
@@ -43,6 +43,86 @@ fn fixture(version: usize) -> PathBuf {
})[version - 1] })[version - 1]
.clone() .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); struct TestWorld(PathBuf);
impl TestWorld { impl TestWorld {
/// Creates an isolated world with two version documents per plugin. /// 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")); world.plugin("notes", document("add"), document("add"));
let host = world.start().await; let host = world.start().await;
let result = activate(&host, "notes").await.unwrap(); 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!( assert_eq!(
call(&host, "notes.version", json!({})).await["version"], call(&host, "notes.version", json!({})).await["version"],
"2" "2"
@@ -577,7 +669,12 @@ async fn activates_group() {
consumer("notes", document("other")), consumer("notes", document("other")),
); );
let host = world.start().await; 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!( assert_eq!(
call(&host, "agent.version", json!({})).await["version"], call(&host, "agent.version", json!({})).await["version"],
"2" "2"
@@ -594,12 +691,13 @@ async fn force_skips_checks() {
consumer("absent", document("add")), consumer("absent", document("add")),
); );
let host = world.start().await; let host = world.start().await;
call( let result = call(
&host, &host,
"core.activate", "core.activate",
json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}), json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}),
) )
.await; .await;
assert_eq!(result["notes"]["interface_check"], "skipped");
host.stop().await; host.stop().await;
} }
/// PROTOCOL 10.3: revert activates the previous copy and preserves the replaced version. /// 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")); world.plugin("notes", document("add"), document("add"));
let host = world.start().await; let host = world.start().await;
activate(&host, "notes").await.unwrap(); 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!( assert_eq!(
call(&host, "notes.version", json!({})).await["version"], call(&host, "notes.version", json!({})).await["version"],
"1" "1"