@ouro.bot/cli 0.1.0-alpha.863 → 0.1.0-alpha.865

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.865",
6
+ "changes": [
7
+ "Added an operator-set replay window: while the host's replay gate holds it open for a replay peer, that peer's delegated-command owner notices and A2A await deliveries go to a local sink instead of the owner's Telegram chat, with every authorization check unchanged, a failed sink write refusing the command, and a nerves event for each notice sunk. The window counts only when its directory is root-owned and not group- or other-writable and the window file, opened once with O_NOFOLLOW and checked by descriptor, is a root-owned regular file nobody else can write, and a sink record satisfies an await's noticed check only for the same friend inside such a window.",
8
+ "Added the host-side replay gate (sanctuary-replay-gate.mjs) that provisions two replay peers and replays seven real requests over A2A, reading each outcome back from tool traces, the steward policy hash, the books ledger, the Sonarr queue, await archives and Telegram effect records; unreadable evidence now fails a case instead of skipping or passing it.",
9
+ "Made Sanctuary upgrades hold the new version uncommitted until the replay gate passes: a pass commits, pins the template and prunes images, a failed gate rolls back to the predecessor and exits 1, and the commit is refused unless the held upgrade is still the live one. Added --no-gate, --plant and verify --gate, plus a --hold-commit option on the root lifecycle because a completed upgrade cannot be rolled back."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.864",
14
+ "changes": [
15
+ "Butler media status, diagnosis and queue now share one stall classification (never_started, unfinished_for_days, no_peers): a partial download with no ETA is reported as stalled with its percent, age and unchanged-since time instead of as downloading, and the diagnosis asks the owner (keep_waiting, find_another_release, give_up) instead of steering to a blocklist that would delete the partial.",
16
+ "A partial download older than 72 hours that still reports a real ETA is now reported as slow but moving, not stalled, and a stalled partial reported beside a never-started download keeps the blocklist advice for the latter."
17
+ ]
18
+ },
4
19
  {
5
20
  "version": "0.1.0-alpha.863",
6
21
  "changes": [
@@ -29,11 +29,21 @@
29
29
  // refuses without a rotated incoming-token. Run `upgrade` detached so an SSH drop
30
30
  // cannot interrupt it: setsid nohup node <this> upgrade <version> > /var/log/ouro-upgrade.log 2>&1 &
31
31
  //
32
- // Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>]
32
+ // An `upgrade` ends with the replay gate (sanctuary-replay-gate.mjs): the new Butler is held UNCOMMITTED (the lifecycle
33
+ // keeps its journal and the predecessor's records, because a committed upgrade cannot be rolled back), the gate replays
34
+ // real requests over A2A and reads the outcome back from machine state, and only a pass commits, pins the template and
35
+ // prunes images. A failed gate runs `upgrade-rollback`, confirms the predecessor is healthy, and exits 1.
36
+ // --no-gate skip the gate and commit straight away (emergencies; logged loudly)
37
+ // --plant <case> make that gate case fail on purpose, proving the rollback end to end
38
+ // verify --gate run the gate against the live version without upgrading
39
+ //
40
+ // Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>]
41
+ // sanctuary-butler-upgrade.mjs verify [--gate]
33
42
 
34
- import { execFileSync } from "node:child_process"
43
+ import { execFileSync, spawnSync } from "node:child_process"
35
44
  import { createHash } from "node:crypto"
36
- import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, chmodSync } from "node:fs"
45
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, chmodSync, realpathSync } from "node:fs"
46
+ import { fileURLToPath } from "node:url"
37
47
  import { tmpdir } from "node:os"
38
48
  import { join } from "node:path"
39
49
 
@@ -61,6 +71,10 @@ const UPGRADE_JOURNAL = `${ROOT}/upgrade.json`
61
71
  const MAINTENANCE = "/run/ouro-authority-maintenance"
62
72
  // The stop hook's flag: every keeper watchdog version stands down while it exists.
63
73
  const SHUTDOWN_FLAG = "/run/ouro-authority-shutdown"
74
+ const GATE_SCRIPT = `${ROOT}/package/deploy/unraid/sanctuary-replay-gate.mjs`
75
+ const GATE_PROVISION = `${BUNDLE}/state/replay-client/provision.json`
76
+ // The whole gate (every case, including the six-minute await wait) must finish well inside this; a hung gate is a failed gate.
77
+ export const GATE_TIMEOUT_MS = 45 * 60 * 1000
64
78
  const UPGRADE_STEPS = ["stop", "switch", "resident", "migrate", "start"]
65
79
 
66
80
  const sh = (file, args, opts = {}) => execFileSync(file, args, { encoding: "utf8", maxBuffer: 64 << 20, ...opts })
@@ -845,9 +859,62 @@ function lifecycleFailure(e) {
845
859
  return `${detail || e.message}${last ? `\n last root failure: ${last}` : ""}`
846
860
  }
847
861
 
848
- function upgrade(version, rehearse) {
862
+ /**
863
+ * The end of a gated upgrade, with every side effect injected so the decision is testable: a pass commits the held
864
+ * upgrade, pins the template and prunes; a fail rolls back to the predecessor and confirms it is healthy. Nothing is
865
+ * committed, pinned or pruned unless the gate passed.
866
+ */
867
+ export function completeGatedUpgrade({ runGate, commit, rollback, confirmRollback, pin, prune, log = console.log }) {
868
+ const gate = runGate()
869
+ if (gate.ok) {
870
+ commit()
871
+ pin()
872
+ prune()
873
+ log("GATE PASS")
874
+ return { passed: true }
875
+ }
876
+ log(`gate failed: ${gate.detail ?? "see the case lines above"}`)
877
+ rollback()
878
+ const restored = confirmRollback()
879
+ log(`GATE FAIL — ROLLED BACK to ${restored}`)
880
+ return { passed: false, rolledBackTo: restored }
881
+ }
882
+
883
+ export function runReplayGate(plant, { spawn = spawnSync, exists = existsSync } = {}) {
884
+ if (!exists(GATE_SCRIPT)) return { ok: false, detail: `the replay gate is missing from the live package (${GATE_SCRIPT})` }
885
+ const result = spawn("/usr/local/bin/node", [GATE_SCRIPT, "run", ...(plant ? ["--plant", plant] : [])], { stdio: "inherit", timeout: GATE_TIMEOUT_MS, killSignal: "SIGKILL" })
886
+ return result.status === 0 ? { ok: true } : { ok: false, detail: `the replay gate exited ${result.status ?? result.signal}` }
887
+ }
888
+
889
+ /**
890
+ * The commit step runs the lifecycle's `upgrade` again, which would START A FRESH UNGATED UPGRADE if the keeper had
891
+ * rolled the held one back mid-gate. So commit only while the journal still exists and the new image is still live.
892
+ */
893
+ export function heldUpgradeProblem({ journalExists, liveImage, expectedImage }) {
894
+ if (!journalExists) return "the held upgrade's journal is gone (the keeper or a boot rolled it back mid-gate)"
895
+ if (liveImage !== expectedImage) return `the live Butler is ${liveImage}, not the held ${expectedImage}`
896
+ return null
897
+ }
898
+
899
+ function butlerState() {
900
+ try { return docker(["inspect", CONTAINER, "--format", "{{.Config.Image}} {{.State.Status}}/{{.State.Health.Status}}"], { stdio: ["ignore", "pipe", "ignore"] }).trim() } catch { return "? ?/?" }
901
+ }
902
+
903
+ function waitForButler(imageRef, seconds) {
904
+ for (let waited = 0; waited <= seconds; waited += 10) {
905
+ const [liveImage, state] = butlerState().split(" ")
906
+ if (liveImage === imageRef && state === "running/healthy") return true
907
+ sh("/bin/sleep", ["10"])
908
+ }
909
+ return false
910
+ }
911
+
912
+ function upgrade(version, rehearse, { noGate = false, plant } = {}) {
849
913
  say(`in-place upgrade to ${version}${rehearse ? ` — REHEARSAL: stop after "${rehearse}", then roll back` : ""}`)
850
914
  if (rehearse && !UPGRADE_STEPS.includes(rehearse)) fail(`--rehearse takes one of: ${UPGRADE_STEPS.join(", ")}`)
915
+ const gated = !rehearse && !noGate
916
+ if (noGate && !rehearse) console.log("\n!! --no-gate: this upgrade will NOT be replay-gated or auto-rolled-back on a behavioural regression")
917
+ if (gated && !existsSync(GATE_PROVISION)) fail(`replay peers are not provisioned (${GATE_PROVISION}); run \`sanctuary-replay-gate.mjs provision\` first, or pass --no-gate`)
851
918
  if (!existsSync(`${ROOT}/active.json`) || !existsSync(`${ROOT}/activation.json`)) fail("no installed authority; use prepare + install")
852
919
  if (existsSync(JOURNAL)) fail("a template transaction is pending; resolve it first")
853
920
  let id = imageId(version)
@@ -867,13 +934,14 @@ function upgrade(version, rehearse) {
867
934
  ok("new package staged beside the live one and verified")
868
935
  }
869
936
  const lifecycle = `${ROOT}/incoming-package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js`
937
+ const priorImage = butlerState().split(" ")[0]
870
938
  const jellyfinBefore = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
871
939
  const policyBefore = existsSync(POLICY) ? sha12(POLICY) : fail("steward policy missing")
872
940
  pauseSupervision()
873
941
  let failure = null
874
942
  try {
875
- say("lifecycle upgrade (stop → switch → resident → migrate → start)")
876
- ok(sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version), ...(rehearse ? ["--fail-after", rehearse] : [])], { stdio: ["ignore", "pipe", "pipe"] }).trim())
943
+ say(`lifecycle upgrade (stop → switch → resident → migrate → start${gated ? ", held uncommitted for the gate" : ""})`)
944
+ ok(sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version), ...(rehearse ? ["--fail-after", rehearse] : gated ? ["--hold-commit"] : [])], { stdio: ["ignore", "pipe", "pipe"] }).trim())
877
945
  } catch (e) { failure = e }
878
946
  // Only the lifecycle's own rehearsal stop is planned; anything else is a real failure,
879
947
  // even during a rehearsal (the first 830 -> 835 rehearsal was mislabelled "as planned").
@@ -886,20 +954,58 @@ function upgrade(version, rehearse) {
886
954
  resumeSupervision()
887
955
  fail(`ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept; the next authority boot (or \`upgrade-rollback\`) retries it.`)
888
956
  }
889
- } else { pinTemplate(version); pruneButlerImages() }
957
+ } else if (!gated) { pinTemplate(version); pruneButlerImages() }
890
958
  resumeSupervision()
891
959
  say("preservation")
892
- docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim() === jellyfinBefore ? ok("jellyfin unchanged") : fail("JELLYFIN CHANGED")
893
- sha12(POLICY) === policyBefore ? ok("steward policy unchanged") : fail("STEWARD POLICY CHANGED")
960
+ const preserved = []
961
+ const jellyfinNow = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
962
+ jellyfinNow === jellyfinBefore ? ok("jellyfin unchanged") : (gated && !failure ? (bad("JELLYFIN CHANGED"), preserved.push("jellyfin changed")) : fail("JELLYFIN CHANGED"))
963
+ sha12(POLICY) === policyBefore ? ok("steward policy unchanged") : (gated && !failure ? (bad("STEWARD POLICY CHANGED"), preserved.push("steward policy changed")) : fail("STEWARD POLICY CHANGED"))
894
964
  const live = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}} {{.State.Status}}/{{.State.Health.Status}}"]).trim()
895
965
  ok(`butler: ${live}`)
896
966
  if (failure && !planned) fail("upgrade rolled back; the Butler is on its prior version (see above)")
897
- console.log(rehearse ? "\nREHEARSAL done — the upgrade reached the planned step and the rollback restored the predecessor." : `\nUPGRADE done — ${version} live. Run verify.`)
967
+ if (gated && !failure) {
968
+ say(`replay gate${plant ? ` (planted failure: ${plant})` : ""}`)
969
+ const stillHeld = () => heldUpgradeProblem({ journalExists: existsSync(UPGRADE_JOURNAL), liveImage: butlerState().split(" ")[0], expectedImage: image(version) })
970
+ const outcome = completeGatedUpgrade({
971
+ runGate: () => {
972
+ if (preserved.length) return { ok: false, detail: preserved.join("; ") }
973
+ if (!waitForButler(image(version), 300)) return { ok: false, detail: `the new Butler did not become healthy on ${image(version)}` }
974
+ const result = runReplayGate(plant)
975
+ if (!result.ok) return result
976
+ const problem = stillHeld()
977
+ return problem ? { ok: false, detail: problem } : result
978
+ },
979
+ commit: () => {
980
+ const problem = stillHeld()
981
+ if (problem) throw new Error(`refusing to commit: ${problem}`)
982
+ pauseSupervision()
983
+ try { ok(`commit: ${sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version)], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
984
+ finally { resumeSupervision() }
985
+ },
986
+ rollback: () => {
987
+ pauseSupervision()
988
+ try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
989
+ catch (e) { fail(`ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept; the next authority boot (or \`upgrade-rollback\`) retries it.`) }
990
+ finally { resumeSupervision() }
991
+ },
992
+ confirmRollback: () => {
993
+ if (!waitForButler(priorImage, 300)) fail(`rolled back, but ${priorImage} is not running healthy; inspect \`docker logs ${CONTAINER}\``)
994
+ return priorImage
995
+ },
996
+ pin: () => pinTemplate(version),
997
+ prune: () => pruneButlerImages(),
998
+ })
999
+ if (!outcome.passed) process.exit(1)
1000
+ console.log(`\nUPGRADE done — ${version} live and gate-verified. Run verify.`)
1001
+ return
1002
+ }
1003
+ console.log(rehearse ? "\nREHEARSAL done — the upgrade reached the planned step and the rollback restored the predecessor." : `\nUPGRADE done — ${version} live (NOT gate-verified). Run verify.`)
898
1004
  }
899
1005
 
900
1006
  // ---- verify ---------------------------------------------------------------
901
1007
 
902
- function verify() {
1008
+ function verify(withGate = false) {
903
1009
  say("verify")
904
1010
  const st = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}} {{.State.Status}}/{{.State.Health.Status}} restarts={{.RestartCount}}"]).trim()
905
1011
  ok(`butler: ${st}`)
@@ -908,6 +1014,11 @@ function verify() {
908
1014
  existsSync(POLICY) ? ok(`steward policy ${sha12(POLICY)}`) : bad("steward policy missing")
909
1015
  const jf = docker(["inspect", "jellyfin", "--format", "{{.State.Status}} restarts={{.RestartCount}}"]).trim()
910
1016
  ok(`jellyfin ${jf}`)
1017
+ if (withGate) {
1018
+ say("replay gate (live version, no upgrade)")
1019
+ const gate = runReplayGate(undefined)
1020
+ gate.ok ? ok("replay gate passed") : bad(`replay gate failed: ${gate.detail}`)
1021
+ }
911
1022
  process.exit(RED === 0 ? 0 : 1)
912
1023
  }
913
1024
 
@@ -915,19 +1026,35 @@ function fail(m) { console.error(`\nREFUSING: ${m}`); process.exit(1) }
915
1026
 
916
1027
  // ---- entry ----------------------------------------------------------------
917
1028
 
918
- const [phase, version, flag, rehearse] = process.argv.slice(2)
919
- if (!phase || !["preflight", "prepare", "install", "verify", "upgrade"].includes(phase) || (flag !== undefined && !(phase === "upgrade" && flag === "--rehearse" && rehearse))) {
920
- console.error("Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>]")
921
- process.exit(2)
1029
+ export function parseUpgradeArgs(argv) {
1030
+ const usage = "Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>] | verify [--gate]"
1031
+ const [phase, ...rest] = argv
1032
+ if (!phase || !["preflight", "prepare", "install", "verify", "upgrade"].includes(phase)) throw new Error(usage)
1033
+ const parsed = { phase, version: undefined, rehearse: undefined, noGate: false, plant: undefined, gate: false }
1034
+ for (let i = 0; i < rest.length; i += 1) {
1035
+ const arg = rest[i]
1036
+ if (arg === "--rehearse" && phase === "upgrade" && rest[i + 1]) parsed.rehearse = rest[++i]
1037
+ else if (arg === "--no-gate" && phase === "upgrade") parsed.noGate = true
1038
+ else if (arg === "--plant" && phase === "upgrade" && rest[i + 1]) parsed.plant = rest[++i]
1039
+ else if (arg === "--gate" && phase === "verify") parsed.gate = true
1040
+ else if (!arg.startsWith("--") && parsed.version === undefined && phase !== "verify") parsed.version = arg
1041
+ else throw new Error(usage)
1042
+ }
1043
+ if (parsed.noGate && parsed.plant) throw new Error("--plant needs the gate; it cannot be combined with --no-gate")
1044
+ if (parsed.rehearse && (parsed.noGate || parsed.plant)) throw new Error("--rehearse does not use the gate; do not combine it with --no-gate or --plant")
1045
+ if (phase !== "verify" && !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(parsed.version || "")) throw new Error("a semver version is required, e.g. 0.1.0-alpha.829")
1046
+ return parsed
922
1047
  }
923
- if (phase !== "verify" && !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(version || "")) {
924
- console.error("a semver version is required, e.g. 0.1.0-alpha.829")
925
- process.exit(2)
1048
+
1049
+ function run(argv) {
1050
+ let args
1051
+ try { args = parseUpgradeArgs(argv) } catch (error) { console.error(error.message); process.exit(2) }
1052
+ requireRoot()
1053
+ if (args.phase === "preflight") preflight(args.version)
1054
+ else if (args.phase === "prepare") prepare(args.version)
1055
+ else if (args.phase === "install") install(args.version)
1056
+ else if (args.phase === "verify") verify(args.gate)
1057
+ else if (args.phase === "upgrade") upgrade(args.version, args.rehearse, { noGate: args.noGate, plant: args.plant })
926
1058
  }
927
- requireRoot()
928
1059
 
929
- if (phase === "preflight") preflight(version)
930
- else if (phase === "prepare") prepare(version)
931
- else if (phase === "install") install(version)
932
- else if (phase === "verify") verify()
933
- else if (phase === "upgrade") upgrade(version, rehearse)
1060
+ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) run(process.argv.slice(2))