@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
|
@@ -571,7 +571,8 @@ export async function mediaRequestStatus(a) {
|
|
|
571
571
|
last_grab_title: grabs[0]?.sourceTitle ?? null,
|
|
572
572
|
monitored: entity ? Boolean(entity.monitored) : null,
|
|
573
573
|
}
|
|
574
|
-
|
|
574
|
+
const statusNow = Date.now()
|
|
575
|
+
result.download = computeDownloadState(queueRecs, statusNow, (r) => observeProgress(progressKey(r, kind), r.sizeleft ?? 0, statusNow))
|
|
575
576
|
|
|
576
577
|
const chain = await chainHealth()
|
|
577
578
|
result.chain = chain
|
|
@@ -604,21 +605,51 @@ export function isStalledRow(r, nowMs) {
|
|
|
604
605
|
return left > 0 && ageHours >= STUCK_INCOMPLETE_AFTER_HOURS
|
|
605
606
|
}
|
|
606
607
|
|
|
607
|
-
|
|
608
|
+
const rowPercent = (r) => ((r.size ?? 0) > 0 ? Math.round((((r.size ?? 0) - (r.sizeleft ?? 0)) / r.size) * 100) : null)
|
|
609
|
+
|
|
610
|
+
// The one stall classification, shared by the status, diagnosis, queue, fill and blocklist
|
|
611
|
+
// surfaces. `previous` is the persisted progress memory for this download (or undefined).
|
|
612
|
+
// Returns null while the download may still be moving; otherwise the kind, whether the stall
|
|
613
|
+
// is confirmed (only confirmed stalls are acted on) and the facts the owner is told.
|
|
614
|
+
export function classifyStall(row, nowMs, previous) {
|
|
615
|
+
const why = stallReason(row, nowMs, previous)
|
|
616
|
+
if (!why) return null
|
|
617
|
+
const left = row.sizeleft ?? 0
|
|
618
|
+
const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
|
|
619
|
+
const unchanged = previous && previous.sizeleft === left ? previous.at : null
|
|
620
|
+
return {
|
|
621
|
+
kind: why.stall, confirmed: why.confirmed, state: why.confirmed ? "stalled" : "suspected",
|
|
622
|
+
percent_complete: rowPercent(row),
|
|
623
|
+
age_hours: row.added ? Math.round((nowMs - Date.parse(row.added)) / 3_600_000) : null,
|
|
624
|
+
no_eta: noEta,
|
|
625
|
+
unchanged_since: unchanged === null ? null : new Date(unchanged).toISOString(),
|
|
626
|
+
unchanged_hours: unchanged === null ? null : Math.round((nowMs - unchanged) / 3_600_000),
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// `previousFor(row)` reads the progress memory for a queue row; without it nothing is confirmed
|
|
631
|
+
// beyond the age rules.
|
|
632
|
+
export function computeDownloadState(queueRecs, nowMs, previousFor = () => undefined) {
|
|
608
633
|
const byDownload = new Map()
|
|
609
634
|
for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
|
|
610
635
|
const downloads = [...byDownload.values()]
|
|
611
636
|
const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
|
|
612
637
|
const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
|
|
613
|
-
const
|
|
614
|
-
.
|
|
615
|
-
.
|
|
638
|
+
const classified = downloads
|
|
639
|
+
.map((r) => ({ r, c: classifyStall(r, nowMs, previousFor(r)) }))
|
|
640
|
+
.filter((x) => x.c)
|
|
641
|
+
.map(({ r, c }) => ({
|
|
616
642
|
title: r.title ?? null,
|
|
617
643
|
added_at: r.added ?? null,
|
|
618
|
-
age_hours: Math.round((nowMs - Date.parse(r.added)) / 3_600_000),
|
|
619
644
|
size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
|
|
620
645
|
queue_id: r.id ?? null,
|
|
646
|
+
...c,
|
|
621
647
|
}))
|
|
648
|
+
const slowItems = downloads
|
|
649
|
+
.filter((r) => isStalledRow(r, nowMs) && (r.sizeleft ?? 0) < (r.size ?? 0) && !classifyStall(r, nowMs, previousFor(r)))
|
|
650
|
+
.map((r) => ({ title: r.title ?? null, percent_complete: rowPercent(r), timeleft: r.timeleft ?? null, age_hours: Math.round((nowMs - Date.parse(r.added)) / 3_600_000) }))
|
|
651
|
+
const stalledItems = classified.filter((i) => i.confirmed)
|
|
652
|
+
const suspectedItems = classified.filter((i) => !i.confirmed)
|
|
622
653
|
return {
|
|
623
654
|
active_downloads: downloads.length,
|
|
624
655
|
active_items: queueRecs.length,
|
|
@@ -630,6 +661,8 @@ export function computeDownloadState(queueRecs, nowMs) {
|
|
|
630
661
|
percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
|
|
631
662
|
stalled: stalledItems.length > 0,
|
|
632
663
|
stalled_items: stalledItems,
|
|
664
|
+
suspected_items: suspectedItems,
|
|
665
|
+
slow_items: slowItems,
|
|
633
666
|
}
|
|
634
667
|
}
|
|
635
668
|
|
|
@@ -645,12 +678,29 @@ export function diagnose({ shelf, result, chain, entity, kind }) {
|
|
|
645
678
|
}
|
|
646
679
|
if (result.download.stalled) {
|
|
647
680
|
const items = result.download.stalled_items
|
|
681
|
+
const partials = items.filter((i) => i.kind !== "never_started")
|
|
682
|
+
const neverStarted = items.filter((i) => i.kind === "never_started")
|
|
683
|
+
if (partials.length) {
|
|
684
|
+
// A partial download holds progress that removing it loses, and the owner's standing
|
|
685
|
+
// instruction for this case is to be asked, so nothing here acts on its own.
|
|
686
|
+
const facts = partials.map((i) => {
|
|
687
|
+
const since = i.unchanged_since ? ` since ${i.unchanged_since} (${i.unchanged_hours} hours)` : ""
|
|
688
|
+
return `${i.title ?? "A release"} is stuck at ${i.percent_complete}% with no download activity${since}; no seeders are serving the rest`
|
|
689
|
+
})
|
|
690
|
+
return { stuck_stage: "download", stuck_reason: "stalled_partial_download",
|
|
691
|
+
likely_fix: "ask_owner", human_action_required: true,
|
|
692
|
+
choices: ["keep_waiting", "find_another_release", "give_up"],
|
|
693
|
+
percent_complete: result.download.percent_complete,
|
|
694
|
+
detail: items,
|
|
695
|
+
...(neverStarted.length ? { never_started: { likely_fix: "blocklist_and_research", items: neverStarted } } : {}),
|
|
696
|
+
summary: `${facts.join(". ")}. Ask the owner whether to keep waiting, find another release, or give up; do not remove it on your own, because removing a partial download loses its progress.${neverStarted.length ? ` Separately, ${neverStarted.length} download(s) never started (${neverStarted.map((i) => i.title ?? "a release").join("; ")}); those can be blocklisted and searched again (blocklist_and_research) without asking.` : ""} media_fill_missing can look for a seeded alternative without deleting the partial first; it removes the partial only after a seeded replacement is grabbed.` }
|
|
697
|
+
}
|
|
648
698
|
const oldest = Math.max(...items.map((i) => i.age_hours))
|
|
649
699
|
return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
|
|
650
700
|
likely_fix: "blocklist_and_research", human_action_required: false,
|
|
651
701
|
percent_complete: result.download.percent_complete,
|
|
652
702
|
detail: items,
|
|
653
|
-
summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded
|
|
703
|
+
summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded anything in ${oldest} hours, which means no seeders rather than a quiet queue. Nothing was lost by waiting this long; blocklist the release and search again for a different one.` }
|
|
654
704
|
}
|
|
655
705
|
if (result.download.active_downloads > 0) {
|
|
656
706
|
const left = result.download.size_left_bytes
|
|
@@ -660,9 +710,12 @@ export function diagnose({ shelf, result, chain, entity, kind }) {
|
|
|
660
710
|
const what = result.download.active_downloads === 1 && result.download.active_items > 1
|
|
661
711
|
? `a ${result.download.active_items}-episode pack`
|
|
662
712
|
: `${result.download.active_downloads} download(s)`
|
|
713
|
+
const suspected = result.download.suspected_items ?? []
|
|
714
|
+
const slow = result.download.slow_items ?? []
|
|
663
715
|
return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
|
|
664
716
|
percent_complete: pct,
|
|
665
|
-
|
|
717
|
+
...(suspected.length ? { stall_suspected: true, detail: suspected } : {}),
|
|
718
|
+
summary: `Downloading now — ${what}${pct === null ? "" : `, ${pct}% done`}, about ${gb} GB to go.${slow.length ? ` Slow but still moving: ${slow.map((i) => `${i.title ?? "a release"} is ${i.percent_complete}% done after ${i.age_hours} hours with an ETA of ${i.timeleft}`).join("; ")}.` : ""}${suspected.length ? ` It reports no ETA after ${Math.max(...suspected.map((i) => i.age_hours))} hours, so it may be stalled; the next check 30 minutes or more later confirms it. Do not promise an ETA.` : ""}` }
|
|
666
719
|
}
|
|
667
720
|
if (result.download.errors.length) {
|
|
668
721
|
return { stuck_stage: "download", stuck_reason: "download_client_error", likely_fix: "retry_or_replace_release",
|
|
@@ -707,6 +760,12 @@ async function mediaDiagnoseAndFix(a) {
|
|
|
707
760
|
human_action_required: true,
|
|
708
761
|
human_action_reason: "The acquisition chain itself is down (see before.chain.problems). Re-searching cannot help until indexers are restored." }
|
|
709
762
|
}
|
|
763
|
+
if (action === "ask_owner") {
|
|
764
|
+
return { action, result: "owner_decision_needed", before, after: null, human_action_required: true,
|
|
765
|
+
choices: before.diagnosis?.choices ?? ["keep_waiting", "find_another_release", "give_up"],
|
|
766
|
+
...(before.diagnosis?.never_started ? { never_started: before.diagnosis.never_started } : {}),
|
|
767
|
+
human_action_reason: before.diagnosis?.summary ?? "The owner decides." }
|
|
768
|
+
}
|
|
710
769
|
if (a.dry_run) {
|
|
711
770
|
return { action, dry_run: true, result: "would_apply", before,
|
|
712
771
|
would_do: describeAction(action), human_action_required: false }
|
|
@@ -731,9 +790,10 @@ async function mediaDiagnoseAndFix(a) {
|
|
|
731
790
|
// Removing with blocklist=true is what stops the same dead release being
|
|
732
791
|
// grabbed straight back. The search that follows is then free to pick a
|
|
733
792
|
// different one.
|
|
734
|
-
|
|
793
|
+
// Only a download that never started: a partial holds progress and is the owner's call.
|
|
794
|
+
const stalled = (before.download.stalled_items ?? []).filter((i) => i.kind === "never_started")
|
|
735
795
|
if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
|
|
736
|
-
human_action_reason: "No download has been sitting at zero long enough to call it stalled." }
|
|
796
|
+
human_action_reason: "No download has been sitting at zero long enough to call it stalled. A partial download is not removed this way: ask the owner." }
|
|
737
797
|
for (const item of stalled) {
|
|
738
798
|
if (item.queue_id === null) continue
|
|
739
799
|
const client = kind === "series" ? sonarr : radarr
|
|
@@ -857,10 +917,13 @@ async function itemQueue(kind, serviceId) {
|
|
|
857
917
|
return rows.filter((r) => r[idKey] === serviceId)
|
|
858
918
|
}
|
|
859
919
|
|
|
860
|
-
function queueReport(rows) {
|
|
920
|
+
function queueReport(rows, kind = "series") {
|
|
861
921
|
const now = Date.now()
|
|
862
|
-
return rows.map((r) =>
|
|
863
|
-
|
|
922
|
+
return rows.map((r) => {
|
|
923
|
+
const c = classifyStall(r, now, peekProgress(progressKey(r, kind), r.sizeleft ?? 0, now))
|
|
924
|
+
return { queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
|
|
925
|
+
size_left_bytes: r.sizeleft ?? 0, stalled: Boolean(c?.confirmed), ...(c ? { stall_kind: c.kind } : {}) }
|
|
926
|
+
})
|
|
864
927
|
}
|
|
865
928
|
|
|
866
929
|
// ------------------------------------------------------------ tool: media_queue
|
|
@@ -868,13 +931,15 @@ function queueReport(rows) {
|
|
|
868
931
|
const QUEUE_VIEW_MAX_ROWS = 100
|
|
869
932
|
|
|
870
933
|
// One queue row as the owner's torrent or usenet client reports it through Sonarr/Radarr.
|
|
871
|
-
export function queueRowView(r, nowMs) {
|
|
934
|
+
export function queueRowView(r, nowMs, previous) {
|
|
935
|
+
const c = classifyStall(r, nowMs, previous)
|
|
872
936
|
return {
|
|
873
937
|
queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
|
|
874
938
|
tracked_download_state: r.trackedDownloadState ?? null, tracked_download_status: r.trackedDownloadStatus ?? null,
|
|
875
939
|
percent: pct(r), timeleft: r.timeleft ?? null, size_left_bytes: r.sizeleft ?? 0, size_bytes: r.size ?? 0,
|
|
876
940
|
protocol: r.protocol ?? null, download_client: r.downloadClient ?? null, indexer: r.indexer ?? null,
|
|
877
|
-
added: r.added ?? null, stalled:
|
|
941
|
+
added: r.added ?? null, stalled: Boolean(c?.confirmed),
|
|
942
|
+
...(c ? { stall: { kind: c.kind, state: c.state, percent_complete: c.percent_complete, age_hours: c.age_hours, no_eta: c.no_eta, unchanged_since: c.unchanged_since } } : {}),
|
|
878
943
|
...(r.errorMessage ? { error_message: String(r.errorMessage).slice(0, 300) } : {}),
|
|
879
944
|
}
|
|
880
945
|
}
|
|
@@ -903,12 +968,13 @@ export async function mediaQueue(a) {
|
|
|
903
968
|
}
|
|
904
969
|
total = Math.max(total ?? 0, rows.length)
|
|
905
970
|
const now = Date.now()
|
|
906
|
-
|
|
971
|
+
// Looking records the sighting in the local progress memory (never in Sonarr/Radarr), so this call can confirm a stall too.
|
|
972
|
+
const shown = rows.slice(0, QUEUE_VIEW_MAX_ROWS).map((r) => queueRowView(r, now, observeProgress(progressKey(r, a.kind), r.sizeleft ?? 0, now)))
|
|
907
973
|
return {
|
|
908
974
|
kind: a.kind, ...(item ? { service_id: item.id, title: item.title } : {}),
|
|
909
975
|
count: total, shown: shown.length, ...(total > shown.length ? { truncated: true } : {}), queue: shown,
|
|
910
976
|
summary: !total ? "Nothing for this is in the Sonarr/Radarr queue."
|
|
911
|
-
: `${total} queue row${total === 1 ? "" : "s"}${total > shown.length ? ` (showing ${shown.length})` : ""}; ${shown.filter((r) => r.stalled).length} stalled.`,
|
|
977
|
+
: `${total} queue row${total === 1 ? "" : "s"}${total > shown.length ? ` (showing ${shown.length})` : ""}; ${shown.filter((r) => r.stalled).length} stalled${shown.some((r) => r.stall?.state === "suspected") ? `, ${shown.filter((r) => r.stall?.state === "suspected").length} suspected (no ETA, to be confirmed by a check 30 minutes later)` : ""}.`,
|
|
912
978
|
note: "Sonarr/Radarr do not report seeders or peers. A row stuck at the same percent with no timeleft is the stall signal; for a release's seeder count use media_indexer_search.",
|
|
913
979
|
}
|
|
914
980
|
}
|
|
@@ -925,7 +991,7 @@ export async function mediaSearchNow(a) {
|
|
|
925
991
|
if (!found.item) return { result: found.result, kind: a.kind, candidates: found.candidates }
|
|
926
992
|
const { item } = found
|
|
927
993
|
const command = await runSearch(a.kind, item.id)
|
|
928
|
-
return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id)) }
|
|
994
|
+
return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id), a.kind) }
|
|
929
995
|
}
|
|
930
996
|
|
|
931
997
|
// Whether a download may be removed without the owner naming it. A download that has never
|
|
@@ -952,10 +1018,11 @@ export async function mediaBlocklistStalled(a) {
|
|
|
952
1018
|
if (Array.isArray(a.queue_ids) && a.queue_ids.length) {
|
|
953
1019
|
const own = new Set(rows.map((r) => r.id))
|
|
954
1020
|
const foreign = a.queue_ids.filter((id) => !own.has(id))
|
|
955
|
-
if (foreign.length) return { result: "queue_id_not_for_item", foreign_queue_ids: foreign, queue: queueReport(rows) }
|
|
1021
|
+
if (foreign.length) return { result: "queue_id_not_for_item", foreign_queue_ids: foreign, queue: queueReport(rows, a.kind) }
|
|
956
1022
|
targets = rows.filter((r) => a.queue_ids.includes(r.id))
|
|
957
1023
|
} else {
|
|
958
|
-
|
|
1024
|
+
// Candidates only: removalVerdict below confirms a no-ETA partial against the progress memory.
|
|
1025
|
+
targets = rows.filter((r) => stallReason(r, now, undefined) !== null)
|
|
959
1026
|
}
|
|
960
1027
|
// A season pack is many queue rows sharing one downloadId; deleting one removes
|
|
961
1028
|
// the download for all, so delete once per download.
|
|
@@ -972,7 +1039,7 @@ export async function mediaBlocklistStalled(a) {
|
|
|
972
1039
|
// deleted on "yes please get them").
|
|
973
1040
|
const ownerWords = typeof a.owner_words === "string" ? a.owner_words.trim() : ""
|
|
974
1041
|
if (ownerWords && !(Array.isArray(a.queue_ids) && a.queue_ids.length)) {
|
|
975
|
-
return { result: "owner_words_need_queue_ids", message: "owner_words removes only downloads the owner named: pass their queue_ids too.", queue: queueReport(rows) }
|
|
1042
|
+
return { result: "owner_words_need_queue_ids", message: "owner_words removes only downloads the owner named: pass their queue_ids too.", queue: queueReport(rows, a.kind) }
|
|
976
1043
|
}
|
|
977
1044
|
const kept = []
|
|
978
1045
|
if (!ownerWords) {
|
|
@@ -984,10 +1051,10 @@ export async function mediaBlocklistStalled(a) {
|
|
|
984
1051
|
})
|
|
985
1052
|
}
|
|
986
1053
|
if (!targets.length && kept.length) {
|
|
987
|
-
return { result: "kept_downloads", kept, queue: queueReport(rows),
|
|
1054
|
+
return { result: "kept_downloads", kept, queue: queueReport(rows, a.kind),
|
|
988
1055
|
message: "Nothing removed. These downloads are not confirmed dead, and removing one deletes its progress. A partial download counts as dead only after two calls at least 30 minutes apart see no progress and no ETA; call again later to confirm. For missing episodes call media_fill_missing instead: it replaces a stalled download only when a seeded alternative exists. Pass owner_words only with the owner's own words asking to remove, clear or cancel this download; 'get them' is not that." }
|
|
989
1056
|
}
|
|
990
|
-
if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows) }
|
|
1057
|
+
if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows, a.kind) }
|
|
991
1058
|
const removed = []
|
|
992
1059
|
const failed = []
|
|
993
1060
|
for (const row of targets) {
|
|
@@ -1001,7 +1068,7 @@ export async function mediaBlocklistStalled(a) {
|
|
|
1001
1068
|
}
|
|
1002
1069
|
}
|
|
1003
1070
|
const command = a.research === false ? null : await runSearch(a.kind, serviceId)
|
|
1004
|
-
return { removed, ...(failed.length ? { failed } : {}), ...(kept.length ? { kept } : {}), ...(ownerWords ? { removed_on_owner_words: ownerWords } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId)) }
|
|
1071
|
+
return { removed, ...(failed.length ? { failed } : {}), ...(kept.length ? { kept } : {}), ...(ownerWords ? { removed_on_owner_words: ownerWords } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId), a.kind) }
|
|
1005
1072
|
}
|
|
1006
1073
|
|
|
1007
1074
|
// ------------------------------- tools: episodes / releases / blocklist / import
|
|
@@ -1332,11 +1399,16 @@ function matchUnblocked(release, { seriesId, seriesTitle, seriesYear, wanted, ha
|
|
|
1332
1399
|
// `confirmed` is false when the only sign is a missing ETA not yet seen to persist; such a
|
|
1333
1400
|
// download is reported but never replaced.
|
|
1334
1401
|
export function stallReason(row, nowMs, previous) {
|
|
1335
|
-
if (isStalledRow(row, nowMs)) return { stall: (row.sizeleft ?? 0) >= (row.size ?? 0) ? "never_started" : "unfinished_for_days", confirmed: true }
|
|
1336
1402
|
const left = row.sizeleft ?? 0
|
|
1403
|
+
const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
|
|
1404
|
+
const unchangedFor30 = Boolean(previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS)
|
|
1405
|
+
if (isStalledRow(row, nowMs)) {
|
|
1406
|
+
if (left >= (row.size ?? 0)) return { stall: "never_started", confirmed: true }
|
|
1407
|
+
// Old and unfinished is not dead by age alone: a slow torrent with a real ETA is still moving.
|
|
1408
|
+
return noEta || unchangedFor30 ? { stall: "unfinished_for_days", confirmed: true } : null
|
|
1409
|
+
}
|
|
1337
1410
|
const ageHours = row.added ? (nowMs - Date.parse(row.added)) / 3_600_000 : 0
|
|
1338
1411
|
const moving = row.status === "downloading" || row.status === "warning"
|
|
1339
|
-
const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
|
|
1340
1412
|
if (!(moving && left > 0 && noEta && ageHours >= STALL_AFTER_HOURS)) return null
|
|
1341
1413
|
const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
|
|
1342
1414
|
return { stall: "no_peers", confirmed: Boolean(unchanged) }
|
|
@@ -1381,6 +1453,11 @@ function saveProgress(nowMs) {
|
|
|
1381
1453
|
process.stderr.write(`media-mcp: download progress not saved: ${e.message}\n`)
|
|
1382
1454
|
}
|
|
1383
1455
|
}
|
|
1456
|
+
// Read-only look at the memory: what observeProgress would return, without recording a sighting.
|
|
1457
|
+
function peekProgress(key, sizeleft, nowMs) {
|
|
1458
|
+
const previous = loadProgress().get(key)
|
|
1459
|
+
return previous && nowMs - previous.seen <= PROGRESS_CONTINUITY_MS ? previous : undefined
|
|
1460
|
+
}
|
|
1384
1461
|
export const progressKey = (row, kind = "series") => `${kind}:${row.downloadId ?? `row:${row.id}`}`.toUpperCase()
|
|
1385
1462
|
function observeProgress(downloadId, sizeleft, nowMs) {
|
|
1386
1463
|
const seen = loadProgress()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.865</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -41,6 +41,7 @@ const crypto_1 = require("crypto");
|
|
|
41
41
|
const fs = __importStar(require("fs"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
43
|
const runtime_1 = require("../nerves/runtime");
|
|
44
|
+
const replay_harness_1 = require("./replay-harness");
|
|
44
45
|
const relationship_authorization_1 = require("../repertoire/relationship-authorization");
|
|
45
46
|
const NOTICE_EXCERPT_CHARS = 200;
|
|
46
47
|
/** The relationship profile that names the principal a delegated peer acts for. */
|
|
@@ -51,7 +52,7 @@ exports.A2A_PRINCIPAL_PROFILE_ID = "sanctuary-owner";
|
|
|
51
52
|
* command id. A request id with no such record never came through a signed, owner-notified delegated command.
|
|
52
53
|
* A missing record is `false`; an unreadable or malformed one throws, so the caller retries instead of deciding.
|
|
53
54
|
*/
|
|
54
|
-
function delegatedCommandWasNoticed(agentRoot, commandId, principalFriendId) {
|
|
55
|
+
function delegatedCommandWasNoticed(agentRoot, commandId, principalFriendId, delegateFriendId) {
|
|
55
56
|
const idempotencyKey = `owner-notice:delegated:${commandId}`;
|
|
56
57
|
const file = path.join(agentRoot, "state", "telegram", "effects", `${(0, crypto_1.createHash)("sha256").update(idempotencyKey).digest("hex")}.json`);
|
|
57
58
|
let raw;
|
|
@@ -60,7 +61,7 @@ function delegatedCommandWasNoticed(agentRoot, commandId, principalFriendId) {
|
|
|
60
61
|
}
|
|
61
62
|
catch (error) {
|
|
62
63
|
if (error.code === "ENOENT")
|
|
63
|
-
return
|
|
64
|
+
return delegateFriendId !== undefined && (0, replay_harness_1.isReplayWindowOpen)(agentRoot, delegateFriendId) && (0, replay_harness_1.replayNoticeRecorded)(agentRoot, `delegated:${commandId}`, delegateFriendId);
|
|
64
65
|
throw error;
|
|
65
66
|
}
|
|
66
67
|
const artifact = JSON.parse(raw);
|
|
@@ -107,7 +108,12 @@ async function admitDelegatedCommand(input) {
|
|
|
107
108
|
return refuse("principal_unresolved");
|
|
108
109
|
const noticeId = `delegated:${input.commandId}`;
|
|
109
110
|
try {
|
|
110
|
-
|
|
111
|
+
const text = delegatedCommandNotice({ delegateName: input.friend.name, text: input.text });
|
|
112
|
+
const agentRoot = input.options.agentRoot;
|
|
113
|
+
if (agentRoot && (0, replay_harness_1.isReplayWindowOpen)(agentRoot, input.friend.id))
|
|
114
|
+
(0, replay_harness_1.appendReplayNotice)(agentRoot, { noticeId, text, friendId: input.friend.id });
|
|
115
|
+
else
|
|
116
|
+
await input.options.notifyPrincipal({ noticeId, text });
|
|
111
117
|
}
|
|
112
118
|
catch {
|
|
113
119
|
return refuse("notice_failed");
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.REPLAY_WINDOW_MAX_MS = void 0;
|
|
37
|
+
exports.replayWindowPath = replayWindowPath;
|
|
38
|
+
exports.replaySinkPath = replaySinkPath;
|
|
39
|
+
exports.isReplayWindowOpen = isReplayWindowOpen;
|
|
40
|
+
exports.appendReplayNotice = appendReplayNotice;
|
|
41
|
+
exports.replayNoticeRecorded = replayNoticeRecorded;
|
|
42
|
+
const fs = __importStar(require("node:fs"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const runtime_1 = require("../nerves/runtime");
|
|
45
|
+
/**
|
|
46
|
+
* The replay gate drives the Butler over its real A2A path to check its behaviour after an upgrade. Every delegated
|
|
47
|
+
* command and every A2A-filed await would otherwise post to the owner's Telegram chat. While the host's gate holds a
|
|
48
|
+
* replay window open for a peer, those notices are appended to a local sink instead.
|
|
49
|
+
*
|
|
50
|
+
* The window narrows where a notice goes, never whether a command is authorized: the grant, family trust, sole owner
|
|
51
|
+
* and notify-before-run checks are unchanged, and "notify" means "the sink write succeeded". The window file lives
|
|
52
|
+
* under the bundle's state directory, is written by the host's gate and cleared when its run ends, and every entry
|
|
53
|
+
* expires on its own (and is ignored if it claims to last longer than the cap).
|
|
54
|
+
*/
|
|
55
|
+
exports.REPLAY_WINDOW_MAX_MS = 2 * 60 * 60 * 1000;
|
|
56
|
+
function replayDir(agentRoot) {
|
|
57
|
+
return path.join(agentRoot, "state", "replay");
|
|
58
|
+
}
|
|
59
|
+
function replayWindowPath(agentRoot) {
|
|
60
|
+
return path.join(replayDir(agentRoot), "window.json");
|
|
61
|
+
}
|
|
62
|
+
function replaySinkPath(agentRoot) {
|
|
63
|
+
return path.join(replayDir(agentRoot), "notices.ndjson");
|
|
64
|
+
}
|
|
65
|
+
/** A root-controlled directory: a real directory (no symlink), owned by the trusted uid, writable by neither group nor other. */
|
|
66
|
+
function isTrustedDirectory(target, trustedUid) {
|
|
67
|
+
try {
|
|
68
|
+
const stat = fs.lstatSync(target);
|
|
69
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === trustedUid && (stat.mode & 0o022) === 0;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reads the window file only if the file actually opened is trusted. The path is opened once with O_NOFOLLOW, then the
|
|
77
|
+
* descriptor itself is checked (regular file, trusted owner, not group- or other-writable) and read, so a rename loop on
|
|
78
|
+
* the Butler's own state directory cannot swap a different file in between the check and the read. Any error is "no window".
|
|
79
|
+
*/
|
|
80
|
+
function readTrustedWindow(file, trustedUid) {
|
|
81
|
+
let fd;
|
|
82
|
+
try {
|
|
83
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
84
|
+
const stat = fs.fstatSync(fd);
|
|
85
|
+
if (!stat.isFile() || stat.uid !== trustedUid || (stat.mode & 0o022) !== 0)
|
|
86
|
+
return undefined;
|
|
87
|
+
return JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (fd !== undefined)
|
|
94
|
+
fs.closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* True only when the window file lists `friendId` with an expiry that is in the future and within the cap. Any unreadable or malformed file means "closed".
|
|
99
|
+
* The state directory is the Butler's own, so it could swap in a directory and window of its own making: only a replay
|
|
100
|
+
* directory and window file that root owns, and nobody else can write, count; anything else fails toward Telegram.
|
|
101
|
+
*/
|
|
102
|
+
function isReplayWindowOpen(agentRoot, friendId, now = Date.now(), trustedUid = 0) {
|
|
103
|
+
if (!isTrustedDirectory(replayDir(agentRoot), trustedUid))
|
|
104
|
+
return false;
|
|
105
|
+
const parsed = readTrustedWindow(replayWindowPath(agentRoot), trustedUid);
|
|
106
|
+
const friends = parsed?.friends;
|
|
107
|
+
if (!friends || typeof friends !== "object")
|
|
108
|
+
return false;
|
|
109
|
+
const entry = friends[friendId];
|
|
110
|
+
const expiresAt = typeof entry?.expiresAt === "string"
|
|
111
|
+
? Date.parse(entry.expiresAt)
|
|
112
|
+
: Number.NaN;
|
|
113
|
+
return Number.isFinite(expiresAt) && expiresAt > now && expiresAt <= now + exports.REPLAY_WINDOW_MAX_MS;
|
|
114
|
+
}
|
|
115
|
+
/** Appends one notice to the sink; throws when it cannot be written, so the caller refuses exactly as a failed Telegram send does. */
|
|
116
|
+
function appendReplayNotice(agentRoot, notice, now = Date.now()) {
|
|
117
|
+
fs.mkdirSync(replayDir(agentRoot), { recursive: true });
|
|
118
|
+
const line = JSON.stringify({ at: new Date(now).toISOString(), ...notice });
|
|
119
|
+
fs.appendFileSync(replaySinkPath(agentRoot), `${line}\n`, { mode: 0o600 });
|
|
120
|
+
(0, runtime_1.emitNervesEvent)({
|
|
121
|
+
component: "senses",
|
|
122
|
+
event: "senses.a2a_replay_notice_sunk",
|
|
123
|
+
message: "wrote an owner notice to the replay sink instead of Telegram",
|
|
124
|
+
meta: { noticeId: notice.noticeId, friendId: notice.friendId },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** True when the sink holds a notice with this id for this friend (a missing sink is "no"). The sink is Butler-writable: callers must also require a trusted window. */
|
|
128
|
+
function replayNoticeRecorded(agentRoot, noticeId, friendId) {
|
|
129
|
+
let raw;
|
|
130
|
+
try {
|
|
131
|
+
raw = fs.readFileSync(replaySinkPath(agentRoot), "utf8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return raw.split("\n").some((line) => {
|
|
137
|
+
if (!line)
|
|
138
|
+
return false;
|
|
139
|
+
try {
|
|
140
|
+
const entry = JSON.parse(line);
|
|
141
|
+
return entry.noticeId === noticeId && entry.friendId === friendId;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -37,6 +37,7 @@ exports.createA2AAwaitOwnerDeliverer = createA2AAwaitOwnerDeliverer;
|
|
|
37
37
|
const path = __importStar(require("path"));
|
|
38
38
|
const friends_1 = require("@ouro.bot/friends");
|
|
39
39
|
const identity_1 = require("../identity");
|
|
40
|
+
const replay_harness_1 = require("../../a2a/replay-harness");
|
|
40
41
|
const runtime_1 = require("../../nerves/runtime");
|
|
41
42
|
/**
|
|
42
43
|
* An A2A peer is a client that calls this agent's server; there is no channel back to it. When an await filed from
|
|
@@ -52,6 +53,11 @@ function createA2AAwaitOwnerDeliverer(agentName, notifyOwner = defaultNotifyOwne
|
|
|
52
53
|
}
|
|
53
54
|
try {
|
|
54
55
|
const peer = await new friends_1.FileFriendStore(path.join((0, identity_1.getAgentRoot)(agentName), "friends")).get(request.friendId);
|
|
56
|
+
const agentRoot = (0, identity_1.getAgentRoot)(agentName);
|
|
57
|
+
if ((0, replay_harness_1.isReplayWindowOpen)(agentRoot, request.friendId)) {
|
|
58
|
+
(0, replay_harness_1.appendReplayNotice)(agentRoot, { noticeId: request.deliveryId, text: request.content, friendId: request.friendId });
|
|
59
|
+
return { status: "delivered_now", detail: "written to the replay sink (replay window open for this peer)" };
|
|
60
|
+
}
|
|
55
61
|
const from = peer?.name ?? "a connected agent";
|
|
56
62
|
await notifyOwner({ noticeId: request.deliveryId, text: `Follow-up on a request from ${from}: ${request.content}` });
|
|
57
63
|
return { status: "delivered_now", detail: "sent to the owner's Telegram chat as the agent" };
|
|
@@ -765,6 +765,10 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
765
765
|
if (input.failAfter === step)
|
|
766
766
|
throw new Error(`Sanctuary upgrade rehearsal stopped after ${step}`);
|
|
767
767
|
}
|
|
768
|
+
// A held upgrade is live but uncommitted: its journal and the predecessor's records stay, so `upgrade-rollback`
|
|
769
|
+
// still undoes it exactly. Running `upgrade` again without the hold commits it. A completed upgrade cannot be rolled back.
|
|
770
|
+
if (input.holdCommit)
|
|
771
|
+
return;
|
|
768
772
|
target().#bundle(["--operation", "commit"]);
|
|
769
773
|
// A copy of the live token must not linger as if it were a fresh rotation (D-044).
|
|
770
774
|
this.#remove(`${ROOT}/incoming-token`);
|
|
@@ -1128,10 +1132,11 @@ async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdo
|
|
|
1128
1132
|
return;
|
|
1129
1133
|
}
|
|
1130
1134
|
const repin = argv.length === 3 && argv[0] === "repin-execution";
|
|
1131
|
-
const
|
|
1135
|
+
const holdCommit = argv[0] === "upgrade" && argv.length === 4 && argv[3] === "--hold-commit";
|
|
1136
|
+
const upgrade = argv[0] === "upgrade" && (argv.length === 3 || holdCommit || (argv.length === 5 && argv[3] === "--fail-after"));
|
|
1132
1137
|
const rollback = argv.length === 1 && argv[0] === "upgrade-rollback";
|
|
1133
1138
|
if (!repin && !upgrade && !rollback && (argv.length !== 1 || argv[0] !== "boot"))
|
|
1134
|
-
throw new Error("Usage: sanctuary-authority-root-lifecycle <boot|repin-execution <prlimit-sha256> <setsid-sha256>|upgrade <target-image-id> <image-reference> [--fail-after <step
|
|
1139
|
+
throw new Error("Usage: sanctuary-authority-root-lifecycle <boot|repin-execution <prlimit-sha256> <setsid-sha256>|upgrade <target-image-id> <image-reference> [--fail-after <step>|--hold-commit]|upgrade-rollback|vault snapshot|vault presence|vault remove|vault restore>");
|
|
1135
1140
|
// Boot never waits: the host keeper retries it. An operator upgrade waits out a boot in flight.
|
|
1136
1141
|
await (0, session_transaction_1.withSessionTurnLease)(TEMPLATE_JOURNAL, async () => {
|
|
1137
1142
|
const activationPath = `${ROOT}/activation.json`;
|
|
@@ -1147,8 +1152,8 @@ async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdo
|
|
|
1147
1152
|
write('{"repinned":true,"gatewayRestartRequired":true}\n');
|
|
1148
1153
|
}
|
|
1149
1154
|
else if (upgrade) {
|
|
1150
|
-
await lifecycle.upgrade({ targetImageId: argv[1], imageReference: argv[2], ...(argv[4] ? { failAfter: argv[4] } : {}) });
|
|
1151
|
-
write(`${JSON.stringify({ upgraded: argv[2] })}\n`);
|
|
1155
|
+
await lifecycle.upgrade({ targetImageId: argv[1], imageReference: argv[2], ...(argv[4] ? { failAfter: argv[4] } : {}), ...(holdCommit ? { holdCommit: true } : {}) });
|
|
1156
|
+
write(`${JSON.stringify({ upgraded: argv[2], ...(holdCommit ? { held: true } : {}) })}\n`);
|
|
1152
1157
|
}
|
|
1153
1158
|
else if (rollback) {
|
|
1154
1159
|
write(`${JSON.stringify({ rolledBack: await lifecycle.rollbackUpgrade() })}\n`);
|
package/dist/senses/a2a-entry.js
CHANGED
|
@@ -47,6 +47,7 @@ function argValue(name) {
|
|
|
47
47
|
const runtime_logging_1 = require("../heart/daemon/runtime-logging");
|
|
48
48
|
const runtime_1 = require("../nerves/runtime");
|
|
49
49
|
const delegated_command_1 = require("../a2a/delegated-command");
|
|
50
|
+
const identity_1 = require("../heart/identity");
|
|
50
51
|
(0, runtime_logging_1.configureDaemonRuntimeLogger)("a2a");
|
|
51
52
|
(0, runtime_1.emitNervesEvent)({
|
|
52
53
|
component: "senses",
|
|
@@ -101,6 +102,7 @@ Promise.all([
|
|
|
101
102
|
...(agentName === "sanctuary" ? {
|
|
102
103
|
delegation: {
|
|
103
104
|
principalProfileId: delegated_command_1.A2A_PRINCIPAL_PROFILE_ID,
|
|
105
|
+
agentRoot: (0, identity_1.getAgentRoot)(agentName),
|
|
104
106
|
notifyPrincipal: async (notice) => {
|
|
105
107
|
const { sendTelegramOwnerNotice } = await Promise.resolve().then(() => __importStar(require("./telegram")));
|
|
106
108
|
await sendTelegramOwnerNotice(agentName, { ...notice, signal: AbortSignal.timeout(30_000) });
|
|
@@ -166,7 +166,7 @@ async function resolveA2APrincipal(agentRoot, store, peer, requestId) {
|
|
|
166
166
|
if (owners.length !== 1 || owners[0].id === peer.id)
|
|
167
167
|
throw new StaleRelationshipAwaitError("delegating owner cannot be resolved");
|
|
168
168
|
const owner = owners[0];
|
|
169
|
-
if (!(0, delegated_command_1.delegatedCommandWasNoticed)(agentRoot, requestId, owner.id))
|
|
169
|
+
if (!(0, delegated_command_1.delegatedCommandWasNoticed)(agentRoot, requestId, owner.id, peer.id))
|
|
170
170
|
throw new StaleRelationshipAwaitError("request was not a delegated command the owner was notified of");
|
|
171
171
|
return owner;
|
|
172
172
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.865",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.865",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|
|
@@ -7034,9 +7034,9 @@
|
|
|
7034
7034
|
}
|
|
7035
7035
|
},
|
|
7036
7036
|
"node_modules/shell-quote": {
|
|
7037
|
-
"version": "1.
|
|
7038
|
-
"resolved": "https://
|
|
7039
|
-
"integrity": "
|
|
7037
|
+
"version": "1.12.0",
|
|
7038
|
+
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
|
7039
|
+
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
|
7040
7040
|
"license": "MIT",
|
|
7041
7041
|
"engines": {
|
|
7042
7042
|
"node": ">= 0.4"
|