diff --git a/crates/spt/tests/adapter_fanout_e2e.rs b/crates/spt/tests/adapter_fanout_e2e.rs new file mode 100644 index 00000000..e5a71635 --- /dev/null +++ b/crates/spt/tests/adapter_fanout_e2e.rs @@ -0,0 +1,116 @@ +//! releases#335 int gate (REQ-ADAPTER-UPDATE-PARALLEL): the REAL `spt adapter +//! update` over THREE registered `gh_release` adapters runs their updates at +//! once — one thread per adapter — and still reports exactly like the serial +//! sweep did: one summary line per adapter in SELECTION order, then the 0/3/1 +//! exit. +//! +//! Concurrency is proven on STATE, not on a stopwatch. Each adapter's +//! `[update.post]` is the `post_step_fixture` in `hold` mode: it drops its name +//! into a shared rendezvous dir and waits until all three names are there. The +//! post-step runs inside its adapter's thread, so the three can only meet if the +//! three adapters are in flight together. The serial loop this replaces reaches +//! the second adapter only after the first post-step returns — which in hold mode +//! is a timeout — so there every adapter reports `POST_HOLD_TIMEOUT` and the +//! sweep exits 1. The wall time is printed for the record, never asserted: a +//! shared runner's load is not this product's property. +//! +//! The GitHub latest-version query is short-circuited by `SPT_TEST_GH_LATEST` at +//! the installed version, so each adapter is a version no-op and the post-step +//! still runs unconditionally (ADR-0029) — the same seam `adapter_post_step.rs` +//! uses. +//! +//! NOTE: the file/binary name avoids the substring "update" — Windows' +//! installer-detection heuristic forces UAC elevation on a manifest-less exe +//! whose name contains it (os error 740; the post_step_fixture precedent). + +use std::path::PathBuf; +use std::process::Command; +use std::time::Instant; + +mod common; +use common::CommandNoWindowExt; + +use spt_store::perch; + +// [int->REQ-ADAPTER-UPDATE-PARALLEL] +#[test] +fn three_adapters_update_at_once_and_report_like_the_serial_sweep() { + let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt")); + let fixture = common::sibling_bin("post_step_fixture"); + assert!( + fixture.exists(), + "the post-step fixture must be built (cargo test -p spt builds it): {}", + fixture.display() + ); + + let home = tempfile::tempdir().unwrap(); + std::env::set_var("SPT_HOME", home.path()); + let adapters = perch::adapters_dir(); + let fixture_for_toml = fixture.to_string_lossy().replace('\\', "/"); + for name in ["aa", "bb", "cc"] { + let manifest = format!( + "[adapter]\nname = \"{name}\"\nkind = \"harness\"\nversion = \"1.0.0\"\n\ + min_spt_core_version = \"0\"\n\n\ + [update]\navenue = \"gh_release\"\nrepo = \"u/{name}\"\n\n\ + [update.post]\ncommand = '{fixture_for_toml}'\n" + ); + let src = perch::spt_home().join("srcs").join(name); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("manifest.toml"), &manifest).unwrap(); + spt_runtime::registry::register(&adapters, &src, 1).unwrap(); + } + std::env::remove_var("SPT_HOME"); + + let hold_dir = home.path().join("rendezvous"); + let started = Instant::now(); + let out = Command::new(&spt_bin) + .no_window() + // A requested list in a NON-registry order: the summary must follow it. + .args(["adapter", "update", "cc,aa,bb"]) + .env("SPT_HOME", home.path()) + .env("SPT_TEST_GH_LATEST", "1.0.0") + .env("POST_FIXTURE_MODE", "hold") + .env("POST_FIXTURE_HOLD_DIR", &hold_dir) + .env("POST_FIXTURE_HOLD_N", "3") + // Far below UPDATE_POST_TIMEOUT (120 s), far above any honest meet. + .env("POST_FIXTURE_HOLD_MS", "30000") + .output() + .expect("spawn spt adapter update"); + let wall = started.elapsed(); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + println!("PARALLEL_WALL: {wall:?} for 3 adapters (not asserted)"); + + assert!( + !stderr.contains("POST_HOLD_TIMEOUT") && !stderr.contains("ADAPTER_UPDATE_POST_FAIL"), + "every post-step met the other two — the adapters ran at once:\nstderr=\n{stderr}" + ); + for name in ["aa", "bb", "cc"] { + assert!( + stdout.lines().any(|l| l == format!("POST_HELD:{name}: 3 in together")), + "{name}'s post-step ran inside its own thread and printed whole:\nstdout=\n{stdout}" + ); + } + + // Reported exactly like the serial sweep: the blocks first, then one + // summary line per adapter in the REQUESTED order, then exit 0. + let summary: Vec<&str> = stderr + .lines() + .filter(|l| l.starts_with("ADAPTER_UPDATE_SUMMARY:")) + .collect(); + assert_eq!( + summary, + vec![ + "ADAPTER_UPDATE_SUMMARY:cc: up to date (1.0.0)", + "ADAPTER_UPDATE_SUMMARY:aa: up to date (1.0.0)", + "ADAPTER_UPDATE_SUMMARY:bb: up to date (1.0.0)", + ], + "stderr=\n{stderr}" + ); + let tail: Vec<&str> = stderr.lines().rev().take(3).collect(); + assert!( + tail.iter().all(|l| l.starts_with("ADAPTER_UPDATE_SUMMARY:")), + "the summary closes the output, after every adapter's block:\nstderr=\n{stderr}" + ); + assert_eq!(out.status.code(), Some(0), "stderr=\n{stderr}"); +} diff --git a/crates/spt/tests/adapter_swap_e2e.rs b/crates/spt/tests/adapter_swap_e2e.rs new file mode 100644 index 00000000..466ce92e --- /dev/null +++ b/crates/spt/tests/adapter_swap_e2e.rs @@ -0,0 +1,240 @@ +//! The REAL `spt adapter update` over a real `.spt` archive, end to end, for the +//! two things the swap does to files the archive does not simply replace: +//! +//! - releases#278 (REQ-ADAPTER-UPDATE-PRUNES-STRINGS): `strings/` MIRRORS the +//! archive. v1 shipped the flat `strings/skills/a.md`; v2 ships only +//! `strings/skills/a/SKILL.md`. After the update no `a.md` is left for a +//! harness scan to find — while a root binary v2 dropped stays (additive). +//! - releases#62 (REQ-ADAPTER-ENTRY-EXEC-BIT, Unix): v2's declared entry binary +//! arrives at mode 0644. The update forces its exec bit, loud, BEFORE the +//! `[update.post]` that runs it — so the post-step succeeding is itself proof +//! the heal came first. A second, up-to-date run heals a mode-only regression +//! the content swap can never see. +//! +//! Hermetic: the fake `gh` CLI (`gh_fixture`, copied to `gh(.exe)` on a +//! PATH-prepended dir) serves the release channel from a local dir — the +//! `composite_e2e.rs` shape. The archive is built with the system `tar`, which +//! keeps the file modes the tree carries. +//! +//! NOTE: the file/binary name avoids the substring "update" — Windows' +//! installer-detection heuristic forces UAC elevation on a manifest-less exe +//! whose name contains it (os error 740; the post_step_fixture precedent). + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +mod common; +use common::CommandNoWindowExt; + +/// A node home with the fake `gh` on a private PATH dir. +struct Rig { + root: tempfile::TempDir, + home: PathBuf, + path_env: String, + gh_root: PathBuf, +} + +impl Rig { + fn new() -> Rig { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let bindir = root.path().join("bin"); + std::fs::create_dir_all(&bindir).unwrap(); + let gh = bindir.join(if cfg!(windows) { "gh.exe" } else { "gh" }); + std::fs::copy(common::sibling_bin("gh_fixture"), &gh).expect("gh_fixture must be built"); + #[cfg(unix)] + set_mode(&gh, 0o755); + let path_env = format!( + "{}{}{}", + bindir.display(), + if cfg!(windows) { ";" } else { ":" }, + std::env::var("PATH").unwrap_or_default() + ); + let gh_root = root.path().join("gh-root"); + std::fs::create_dir_all(gh_root.join("assets")).unwrap(); + Rig { + root, + home, + path_env, + gh_root, + } + } + + /// Publish `tree` as the latest release `v`'s `adapter.spt`. + fn publish(&self, tree: &Path, version: &str) { + let out = self.root.path().join(format!("adapter-{version}.spt")); + let st = Command::new("tar") + .arg("-cf") + .arg(&out) + .arg("-C") + .arg(tree) + .arg(".") + .status() + .expect("system tar (present on Win10+ and Linux)"); + assert!(st.success(), "tar the adapter tree"); + std::fs::copy(&out, self.gh_root.join("assets").join("adapter.spt")).unwrap(); + std::fs::write(self.gh_root.join("tag.txt"), format!("v{version}")).unwrap(); + } + + /// Register `tree` (copied to the install dir) as the installed adapter. + fn install(&self, name: &str, tree: &Path) -> PathBuf { + std::env::set_var("SPT_HOME", &self.home); + let install = spt_store::perch::spt_home().join("srcs").join(name); + copy_tree(tree, &install); + spt_runtime::registry::register(&spt_store::perch::adapters_dir(), &install, 1000) + .unwrap(); + std::env::remove_var("SPT_HOME"); + install + } + + fn run_update(&self, name: &str) -> (bool, String, String) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_spt")); + cmd.no_window() + .args(["adapter", "update", name]) + .env("SPT_HOME", &self.home) + .env("PATH", &self.path_env) + .env("SPT_FAKE_GH_ROOT", &self.gh_root); + let out = common::output_bounded(cmd, Duration::from_secs(120)); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + eprintln!("=== adapter update {name}: ok={} ===\n{stdout}\n{stderr}", out.status.success()); + (out.status.success(), stdout, stderr) + } + + fn tree(&self, label: &str) -> PathBuf { + let t = self.root.path().join(format!("tree-{label}")); + std::fs::create_dir_all(&t).unwrap(); + t + } +} + +fn manifest(name: &str, version: &str, extra: &str) -> String { + format!( + "[adapter]\nname = \"{name}\"\nkind = \"harness\"\nversion = \"{version}\"\n\ + min_spt_core_version = \"0\"\n\n\ + [update]\navenue = \"gh_release\"\nrepo = \"u/{name}\"\ntransport = \"gh\"\n\n{extra}" + ) +} + +fn write(path: &Path, bytes: &[u8]) { + if let Some(p) = path.parent() { + std::fs::create_dir_all(p).unwrap(); + } + std::fs::write(path, bytes).unwrap(); +} + +fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.path().is_dir() { + copy_tree(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +#[cfg(unix)] +fn set_mode(path: &Path, mode: u32) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); +} + +#[cfg(unix)] +fn mode(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).unwrap().permissions().mode() & 0o7777 +} + +// [int->REQ-ADAPTER-UPDATE-PRUNES-STRINGS] +#[test] +fn an_update_prunes_retired_strings_and_keeps_dropped_binaries() { + let rig = Rig::new(); + + let v1 = rig.tree("v1"); + write(&v1.join("manifest.toml"), manifest("pp", "1.0.0", "").as_bytes()); + write(&v1.join("strings/skills/a.md"), b"v1 flat skill"); + write(&v1.join("strings/briefs/b.md"), b"v1 brief"); + write(&v1.join("old-helper.bin"), b"v1 helper binary"); + let install = rig.install("pp", &v1); + + let v2 = rig.tree("v2"); + write(&v2.join("manifest.toml"), manifest("pp", "1.1.0", "").as_bytes()); + write(&v2.join("strings/skills/a/SKILL.md"), b"v2 skill"); + rig.publish(&v2, "1.1.0"); + + let (ok, _stdout, stderr) = rig.run_update("pp"); + assert!(ok, "the update exits 0: {stderr}"); + assert!( + stderr.contains("ADAPTER_UPDATE_SUMMARY:pp: updated 1.0.0 -> 1.1.0"), + "{stderr}" + ); + assert_eq!( + std::fs::read(install.join("strings/skills/a/SKILL.md")).unwrap(), + b"v2 skill" + ); + assert!( + !install.join("strings/skills/a.md").exists(), + "the retired flat skill is gone — nothing left for a harness scan to find" + ); + assert!(!install.join("strings/briefs").exists(), "a dir the prune emptied is removed"); + assert!( + install.join("old-helper.bin").exists(), + "a binary v2 dropped stays: outside strings/ the swap is additive" + ); +} + +// [int->REQ-ADAPTER-ENTRY-EXEC-BIT] +#[cfg(unix)] +#[test] +fn a_declared_entry_arriving_without_its_exec_bit_is_forced_loud_then_runs() { + let rig = Rig::new(); + let post = "[update.post]\ncommand = \"{adapter_dir}/entry-bin\"\n"; + + let v1 = rig.tree("v1"); + write(&v1.join("manifest.toml"), manifest("xx", "1.0.0", "").as_bytes()); + let install = rig.install("xx", &v1); + + // v2 declares entry-bin (its post-step) and ships it at 0644, plus an + // undeclared script at 0644 that must keep its mode. + let v2 = rig.tree("v2"); + write(&v2.join("manifest.toml"), manifest("xx", "1.1.0", post).as_bytes()); + write(&v2.join("entry-bin"), b"#!/bin/sh\nexit 0\n"); + write(&v2.join("not-declared.sh"), b"#!/bin/sh\nexit 0\n"); + set_mode(&v2.join("entry-bin"), 0o644); + set_mode(&v2.join("not-declared.sh"), 0o644); + rig.publish(&v2, "1.1.0"); + + let (ok, _stdout, stderr) = rig.run_update("xx"); + let entry = install.join("entry-bin"); + assert!( + stderr.contains(&format!( + "ADAPTER_ENTRY_EXEC_FORCED:xx: {} extracted 0644 — packaging defect upstream", + entry.display() + )), + "the forced bit is loud and names the packaging defect:\n{stderr}" + ); + assert!( + ok && !stderr.contains("ADAPTER_UPDATE_POST_FAIL"), + "the post-step RAN the healed entry — the heal came before it:\n{stderr}" + ); + assert_eq!(mode(&entry), 0o755); + assert_eq!(mode(&install.join("not-declared.sh")), 0o644, "never a blanket chmod"); + + // Mode-only regression: same content, bit lost. The content swap cannot see + // it; the next update run (up to date, nothing swapped) heals it anyway. + set_mode(&entry, 0o644); + let (ok, _stdout, stderr) = rig.run_update("xx"); + assert!(stderr.contains("ADAPTER_UPDATE_UPTODATE:xx"), "{stderr}"); + assert!(stderr.contains("ADAPTER_ENTRY_EXEC_FORCED:xx:"), "{stderr}"); + assert!(ok, "{stderr}"); + assert_eq!(mode(&entry), 0o755); + + // Healthy: a third run says nothing about the exec bit. + let (ok, _stdout, stderr) = rig.run_update("xx"); + assert!(ok && !stderr.contains("ADAPTER_ENTRY_EXEC_FORCED"), "{stderr}"); +} diff --git a/crates/spt/tests/fixtures/post_step_fixture.rs b/crates/spt/tests/fixtures/post_step_fixture.rs index c79c362a..4e929fbf 100644 --- a/crates/spt/tests/fixtures/post_step_fixture.rs +++ b/crates/spt/tests/fixtures/post_step_fixture.rs @@ -15,6 +15,12 @@ //! - `sentinel` — print the reserved `!!update-message!!` token, exit 0. //! - `empty` — print nothing, exit 0 (no-notice path). //! - `fail` — print a diagnostic to stderr, exit 3 (failure-isolated path). +//! - `hold` — releases#335 rendezvous: drop `` into +//! `POST_FIXTURE_HOLD_DIR`, then wait (bounded by `POST_FIXTURE_HOLD_MS`, +//! default 30000) until `POST_FIXTURE_HOLD_N` names are there. All in +//! together → print `POST_HELD:: in together`, exit 0. Timed out +//! → the post-steps were NOT running at once: `POST_HOLD_TIMEOUT` on +//! stderr, exit 4. Concurrency proven on state, not on a stopwatch. use std::io::{self, Read, Write}; @@ -32,6 +38,7 @@ fn main() { "custom" => println!("Plugin synced — run /reload-plugins"), "sentinel" => println!("!!update-message!!"), "empty" => {} // print nothing + "hold" => hold(&seam), "fail" => { eprintln!("post-update fixture: simulated plugin-sync failure"); let _ = io::stdout().flush(); @@ -44,3 +51,37 @@ fn main() { } let _ = io::stdout().flush(); } + +fn hold(seam: &str) { + let name = serde_json::from_str::(seam.trim()) + .ok() + .and_then(|v| v["adapter_name"].as_str().map(str::to_string)) + .unwrap_or_else(|| "unnamed".to_string()); + let dir = std::path::PathBuf::from( + std::env::var("POST_FIXTURE_HOLD_DIR").expect("hold mode needs POST_FIXTURE_HOLD_DIR"), + ); + let want: usize = std::env::var("POST_FIXTURE_HOLD_N") + .ok() + .and_then(|n| n.parse().ok()) + .expect("hold mode needs POST_FIXTURE_HOLD_N"); + let budget_ms: u64 = std::env::var("POST_FIXTURE_HOLD_MS") + .ok() + .and_then(|n| n.parse().ok()) + .unwrap_or(30_000); + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(dir.join(&name), b"in"); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(budget_ms); + loop { + let n = std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0); + if n >= want { + println!("POST_HELD:{name}: {n} in together"); + return; + } + if std::time::Instant::now() >= deadline { + eprintln!("POST_HOLD_TIMEOUT:{name}: only {n} of {want} post-steps ever ran at once"); + let _ = io::stdout().flush(); + std::process::exit(4); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} diff --git a/docs-site/src/harness-contract/manifest.md b/docs-site/src/harness-contract/manifest.md index 1d89ed2c..acd72398 100644 --- a/docs-site/src/harness-contract/manifest.md +++ b/docs-site/src/harness-contract/manifest.md @@ -810,6 +810,42 @@ substitution. Use it to tell the operator what to do after updating — e.g. With `file_pull`, **you** sign your releases with your own key; spt-core's release keys never extend to adapter content. + +**What an update does to files you stop shipping.** An update replaces only the +files whose content changed, and what happens to a file your new release no +longer contains depends on where it lives: + +- **`strings/` mirrors your archive.** A file under `strings/` that the new + release does not ship is **removed** once the update has landed, along with any + directory that leaves empty. Rename `skills/a.md` to `skills/a/SKILL.md` and + the old `a.md` is gone after the update — a harness that scans `strings/` never + finds a skill you retired. +- **Everything else is additive.** A binary (or any other file outside + `strings/`) that you drop stays on the node until the next clean install. A + running process is never pulled out from under an update, so do not rely on an + update to delete an old executable; stop referencing it instead. + + +**Ship your binaries executable.** On Linux and macOS, every binary your +manifest runs must carry its exec bit inside the `.spt` archive — an archive +packed on Windows often drops it (mode `0644`). spt-core keeps the modes your +archive carries, with one safety net: after every install or update it checks +the binaries your manifest **declares** — the program of each command spt-core +runs (`[service]`, `[message-idle-translation-binary]`, `[digest].extractor`, +`[session.*]` roles, `[update]` / `[update.post]`, `[shell]`) when that program +resolves to a file inside your install — and if one arrived without its exec +bit, spt-core sets it and prints + +```text +ADAPTER_ENTRY_EXEC_FORCED:: extracted 0644 — packaging defect upstream +``` + +Treat that line as a bug report against your packaging: the node keeps working, +but the next node that installs a different way may not. The check runs on +every update run, even when no file changed, so a node that already installed +the broken mode heals on its next `spt adapter update`. Files your manifest does +not run are never touched. + ### `gh_release` — ship updates from your GitHub releases (since v0.8.0) The simplest avenue to publish for: distribute exactly as you do for @@ -829,6 +865,15 @@ version against the installed one and, when newer, fetches the release `.spt` archive — the same archive `spt adapter add --release` installs — then re-extracts and re-registers it. `repo` is the only required field. + +**Your update runs beside other adapters' updates.** A sweep updates every +selected adapter at once, one thread each. Your own steps stay in order — +fetch, verify, swap, re-register, then your `[update.post]` — but your +post-step may run while another adapter's post-step is running, so it must +not assume it has the node to itself (no shared fixed temp paths, no global +locks it expects to be uncontested). spt-core serializes the swap-and-register +step across adapters itself; you do not need to. + **Trust is opt-in signing, fail-closed.** Declare no `signing_key` and the fetched `.spt` is trusted on HTTPS + GitHub, exactly like first acquisition. Declare a `signing_key` and the fetched `.spt` is verified against a **detached diff --git a/docs-site/src/self-update/overview.md b/docs-site/src/self-update/overview.md index 91ce5ad7..30e88444 100644 --- a/docs-site/src/self-update/overview.md +++ b/docs-site/src/self-update/overview.md @@ -95,6 +95,15 @@ gets a summary line, and the exit is nonzero if any failed. A registered adapter without a release channel (a local dev registration) is skipped loudly, not failed. + +The adapters in the leg update **at the same time**, one per thread, so the +leg takes as long as its slowest adapter rather than the sum of all of them. +Output stays readable: each adapter's lines are held and printed together as +one block when that adapter finishes (a fast adapter's block can appear before +a slow one's), and the summary lines still come last, in the order you named +the adapters (or registry order for a sweep). The core leg still runs first; +the adapters leg starts only after it. + ## How updates move Peer-propagated: one node fetches a release; paired nodes offer/fetch staged diff --git a/docs/MANIFEST.md b/docs/MANIFEST.md index 781d270c..f946f7e9 100644 --- a/docs/MANIFEST.md +++ b/docs/MANIFEST.md @@ -704,6 +704,15 @@ message = "Run `/reload-plugins` in any ongoing sessions." # optional; shown o `spt adapter update [name]` (with no name, every registered `gh_release` adapter; with a name, just that one) compares the repo's latest GitHub release version against the installed adapter version and, when newer, fetches the release `.spt` (the same archive primitive as `spt adapter add --release`), then re-extracts and re-registers it in the adapter's durable `_github/` home (pointer-mode, re-read live). The network fetch lives in the `spt` CLI, never the daemon. **`repo` is required**; `asset` defaults to `adapter.spt`; `signing_key` is **optional**. + +**Update semantics — `strings/` mirrors, binaries are additive (releases#278).** The CRC swap replaces only content-changed files. A file the new archive no longer ships is PRUNED if it lives under `strings/` (removed after the swap commits, emptied dirs under `strings/` removed, `strings/` itself kept; a prune that cannot remove its file prints `CRC_SWAP_PRUNE_FAILED:` and the update still stands) and LEFT IN PLACE anywhere else, so a running binary is never yanked. The swap's own `.new` / `.old[-N]` litter is never a prune row. Both apply routes (CLI direct and daemon-coordinated) run the one plan (`spt_daemon::crc_swap::plan_crc_swap`). + + +**Exec bit on declared entry binaries (releases#62, ruled (b) 2026-09-24; F-028 contract).** Packaging MUST carry the exec bit on every binary the manifest runs; core PRESERVES archive modes. Safety net, Unix only: after every install (`spt adapter add`), every update run (applied OR up-to-date — the CRC swap compares content only, so a mode-only difference is never swapped and heals here), and inside the daemon-coordinated apply before it restarts the translation child, `spt_runtime::entry_exec::force_entry_exec` sets the exec bit on each DECLARED entry binary that lacks it and prints `ADAPTER_ENTRY_EXEC_FORCED:: extracted — packaging defect upstream` (a failed chmod prints `ADAPTER_ENTRY_EXEC_FAILED:`). Declared = the program token of a spawned command (`[session.*]` roles, `[history].normalize_command`, `[digest].extractor`, `[message-idle-translation-binary]` command/path, `[service].command`, `[update].command`, `[update.post].command`, `[shell].spawn`/`wake_command`) filled with `{adapter_dir}`/`{adapter_name}` and resolved per REQ-INSTALL-11 to a file INSIDE the install dir. Never a blanket chmod. + + +**One thread per adapter (releases#335).** A sweep (or a comma-list) updates every selected adapter at once — fetch, verify, floor peek and `[update.post]` run in parallel, one thread per adapter; the COMMIT half (swap into the install dir, registry re-register, service/serving nudges) is serialized across them by a process lock, because the registry write is an unlocked read-modify-write. Each adapter's output is held and printed as one whole block when it finishes; the `ADAPTER_UPDATE_SUMMARY:` lines follow in selection order and the 0/3/1 exit is unchanged. A post-step must therefore not assume it is the only one running. + **Transport — public HTTPS or private `gh`.** The optional **`transport`** selects how the asset bytes + the latest-release version are fetched: `https` (direct, the public-repo path), `gh` (shell the pre-authorized [`gh` CLI](https://cli.github.com/) — `gh release download` for the asset, `gh api` for the version — the **private-repo** path), or **`auto`** (the default: prefer `gh` when it is installed and authenticated, else fall back to HTTPS). Because `gh` honors OAuth + `GH_TOKEN`, an adapter shipping from a **private** repo updates with no token in spt-core's hands — spt never reads or stores a credential. `spt adapter add --release` takes `--gh` / `--https` to force the choice (default auto). A signed private adapter's `.sig` is fetched over the **same** transport, so verification still works. Transport is additive — the verify→extract→register path is unchanged. diff --git a/traceable-reqs.toml b/traceable-reqs.toml index c445d35c..faa69fbb 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -7873,3 +7873,18 @@ required_stages = ["doc", "impl", "unit", "int"] id = "REQ-UPDATE-DOCS-UNSTAGED-SKIP-LOUD" title = "When the staged signed set declares a docs bundle and none is staged at apply, landing prints UPDATE_DOCS_SKIPPED: signed set declares docs but none staged on stderr instead of returning silently (releases#330). A docs-less set stays silent, a bundle already landed for this set stays silent, and the binary apply outcome never changes." required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-ADAPTER-UPDATE-PARALLEL" +title = "The adapters leg of `spt adapter update` / `spt update adapters` / the composite `spt update` (and the background updater that calls it) runs ONE THREAD PER SELECTED ADAPTER after the core leg (releases#335, milestone #331). Each thread holds its own output (spt_proto::emit::Capture, which also catches callee emit_line_err! lines) and the parent prints it as one whole block when that adapter finishes, never interleaved; ADAPTER_UPDATE_SUMMARY lines keep selection order and the 0/3/1 exit is unchanged; [update.post] runs inside its adapter's thread; the floor basis stays the staged core. The commit half (swap, registry re-register, service/serving nudges) is serialized by a process lock because the registry write is an unlocked read-modify-write; fetch scratch and staged archive are keyed per adapter. A panicking thread reports FAILED in its own block. Gate: unit per-adapter output isolation + concurrency proven on state; int three mock adapters with sleeps finish in about max not sum, summary/exit identical to the serial shape." +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-ADAPTER-UPDATE-PRUNES-STRINGS" +title = "An adapter update makes the install's strings/ tree MIRROR the archive's (releases#278): plan_crc_swap gains a PRUNE row class for files under dest/strings/ absent from the staged strings/, removed only AFTER the swap commits. Nothing outside strings/ is ever pruned (binaries and other files stay additive) and .old/.new swap litter is untouched. Gate: unit stale strings/skills/old.md plus stale dest/foo.exe yields exactly one prune row; int real adapter update v1 (skills/a.md) to v2 (skills/a/SKILL.md) leaves no a.md (mutation: remove the prune arm, red); doc MANIFEST.md + harness-contract update semantics." +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-ADAPTER-ENTRY-EXEC-BIT" +title = "On Unix an adapter install/update forces the exec bit on the manifest-DECLARED entry binaries only (releases#62, ruled option b 2026-09-24), loud: ADAPTER_ENTRY_EXEC_FORCED:: extracted - packaging defect upstream. A mode-only difference on a declared entry (content-equal, so crc_swap never swaps) is healed in place via set_permissions, operator-visible, never silent. Undeclared files keep their extracted mode. Gate: unit on the declared-entry set + mode decision; int on Linux (kitsubito) an archive whose entry lacks +x ends executable and says so; doc harness-contract/MANIFEST exec-bit contract (F-028)." +required_stages = ["doc", "impl", "unit", "int"]