@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 +15 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +151 -24
- package/deploy/unraid/sanctuary-replay-gate.mjs +533 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +103 -26
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/delegated-command.js +9 -3
- package/dist/a2a/replay-harness.js +147 -0
- package/dist/heart/awaiting/a2a-await-delivery.js +6 -0
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +9 -4
- package/dist/senses/a2a-entry.js +2 -0
- package/dist/senses/private-runtime.js +1 -1
- package/npm-shrinkwrap.json +5 -5
- package/package.json +1 -1
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
893
|
-
|
|
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
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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 (
|
|
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))
|