@ouro.bot/cli 0.1.0-alpha.741 → 0.1.0-alpha.743
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 +42 -0
- package/deploy/unraid/Dockerfile +7 -2
- package/deploy/unraid/README.txt +717 -106
- package/deploy/unraid/migrate-sanctuary-bundle.mjs +21 -0
- package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
- package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
- package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
- package/deploy/unraid/ouro-events/install-usenet-guard.sh +434 -0
- package/deploy/unraid/ouro-events/usenet-health.sh +178 -0
- package/deploy/unraid/sanctuary-acceptance-contract.json +4 -3
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +5 -1
- package/deploy/unraid/sanctuary.ouro/agent.json +2 -2
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +2 -2
- package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +1 -1
- package/deploy/unraid/sanctuary.ouro/provider-readiness.json +3 -10
- package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +3 -1
- package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +3 -1
- package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +4 -0
- package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +6 -2
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +4 -2
- package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +19 -17
- package/deploy/unraid/sanctuary.xml +12 -9
- package/dist/arc/cares.js +171 -37
- package/dist/arc/obligations.js +38 -0
- package/dist/heart/awaiting/await-alert.js +4 -2
- package/dist/heart/awaiting/await-expiry.js +39 -4
- package/dist/heart/awaiting/await-parser.js +8 -0
- package/dist/heart/context-loss-gauntlet.js +31 -2
- package/dist/heart/context-loss-sentinel.js +2 -0
- package/dist/heart/core.js +37 -7
- package/dist/heart/cross-chat-delivery.js +1 -0
- package/dist/heart/daemon/container-healthcheck.js +3 -1
- package/dist/heart/daemon/container-runtime.js +13 -0
- package/dist/heart/daemon/container-spec-auditor-main.js +11 -7
- package/dist/heart/daemon/container-spec-auditor.js +38 -11
- package/dist/heart/daemon/daemon-entry.js +19 -5
- package/dist/heart/daemon/daemon.js +154 -22
- package/dist/heart/daemon/sanctuary-acceptance-adapter.js +151 -87
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +26 -29
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +42 -51
- package/dist/heart/daemon/sanctuary-bundle-migration.js +398 -0
- package/dist/heart/daemon/sanctuary-scheduler-origin.js +11 -5
- package/dist/heart/daemon/supercronic-supervisor.js +16 -1
- package/dist/heart/external-events/router.js +1025 -43
- package/dist/heart/provider-models.js +10 -3
- package/dist/heart/session-events.js +23 -5
- package/dist/heart/steward-policy.js +302 -0
- package/dist/heart/tool-approval.js +3 -1
- package/dist/heart/turn-context.js +1 -0
- package/dist/mind/context.js +8 -3
- package/dist/mind/pending.js +25 -0
- package/dist/mind/prompt.js +49 -1
- package/dist/repertoire/guardrails.js +2 -1
- package/dist/repertoire/relationship-authorization.js +194 -0
- package/dist/repertoire/tools-awaiting.js +229 -26
- package/dist/repertoire/tools-base.js +15 -0
- package/dist/repertoire/tools-continuity.js +259 -4
- package/dist/repertoire/tools-notes.js +29 -3
- package/dist/repertoire/tools-session.js +219 -4
- package/dist/repertoire/tools-steward-policy.js +88 -0
- package/dist/repertoire/tools-telegram-contacts.js +37 -0
- package/dist/repertoire/tools-unraid.js +113 -6
- package/dist/repertoire/tools.js +104 -9
- package/dist/repertoire/unraid-restart.js +86 -4
- package/dist/senses/private-runtime-worker.js +36 -3
- package/dist/senses/private-runtime.js +310 -63
- package/dist/senses/sanctuary-health-acceptance-probe.js +8 -6
- package/dist/senses/sanctuary-health-runner.js +51 -96
- package/dist/senses/sanctuary-health.js +54 -93
- package/dist/senses/sanctuary-runtime.js +36 -2
- package/dist/senses/sanctuary-sab.js +80 -0
- package/dist/senses/shared-turn.js +64 -9
- package/dist/senses/telegram-admission.js +684 -0
- package/dist/senses/telegram-approval-runtime.js +46 -6
- package/dist/senses/telegram-client.js +236 -72
- package/dist/senses/telegram-effect-adapter.js +787 -0
- package/dist/senses/telegram-entry.js +4 -1
- package/dist/senses/telegram.js +728 -48
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
|
@@ -33,114 +33,69 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.runSanctuaryHealthPrivateTurn = runSanctuaryHealthPrivateTurn;
|
|
37
36
|
exports.runSanctuaryHealthHabit = runSanctuaryHealthHabit;
|
|
38
37
|
const path = __importStar(require("node:path"));
|
|
38
|
+
const router_1 = require("../heart/external-events/router");
|
|
39
39
|
const identity_1 = require("../heart/identity");
|
|
40
|
-
const
|
|
41
|
-
const
|
|
40
|
+
const machine_identity_1 = require("../heart/machine-identity");
|
|
41
|
+
const runtime_credentials_1 = require("../heart/runtime-credentials");
|
|
42
42
|
const runtime_1 = require("../nerves/runtime");
|
|
43
|
-
const tools_1 = require("../repertoire/tools");
|
|
44
|
-
const telegram_client_1 = require("./telegram-client");
|
|
45
43
|
const sanctuary_health_1 = require("./sanctuary-health");
|
|
46
44
|
const sanctuary_runtime_1 = require("./sanctuary-runtime");
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
agent:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
{
|
|
73
|
-
role: "user",
|
|
74
|
-
content: [
|
|
75
|
-
"A durable Sanctuary health event is pending.",
|
|
76
|
-
`Event id: ${input.eventId}`,
|
|
77
|
-
`Deterministic payload: ${input.payload}`,
|
|
78
|
-
"Summarize it briefly for Ari. Call send_message exactly once with friendId=operator, channel=telegram, and the summary as content, then rest.",
|
|
79
|
-
].join("\n"),
|
|
80
|
-
},
|
|
81
|
-
], privateTurnCallbacks(input.onProviderInvocation), "inner", undefined, {
|
|
82
|
-
toolProfile: "sanctuary-health-private",
|
|
83
|
-
tools: [definition.tool],
|
|
84
|
-
toolContext: { signin: async () => undefined },
|
|
85
|
-
execTool: async (name, args) => {
|
|
86
|
-
if (name !== "send_message")
|
|
87
|
-
throw new Error(`Sanctuary health profile cannot execute ${name}`);
|
|
88
|
-
if (delivered)
|
|
89
|
-
throw new Error("Sanctuary health Telegram delivery was already attempted");
|
|
90
|
-
if (args.friendId !== "operator" || args.channel !== "telegram" || typeof args.content !== "string" || args.content.trim().length === 0) {
|
|
91
|
-
throw new Error("Sanctuary health send_message target or content is invalid");
|
|
92
|
-
}
|
|
93
|
-
await input.deliver(args.content);
|
|
94
|
-
delivered = true;
|
|
95
|
-
return "delivered";
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
if (result.outcome === "errored") {
|
|
99
|
-
throw result.error ?? new Error("Sanctuary health private turn failed");
|
|
100
|
-
}
|
|
101
|
-
return { delivered };
|
|
45
|
+
function evidenceInputs(agentName, result) {
|
|
46
|
+
const revision = result.observationRevision;
|
|
47
|
+
const current = result.incidents.map((incident) => ({
|
|
48
|
+
agent: agentName,
|
|
49
|
+
source: "sanctuary-health",
|
|
50
|
+
eventType: "health.observed",
|
|
51
|
+
eventId: incident.id,
|
|
52
|
+
observationRevision: incident.observationRevision ?? revision,
|
|
53
|
+
transition: incident.transition ?? result.transition ?? "changed",
|
|
54
|
+
summary: incident.summary,
|
|
55
|
+
evidence: [incident.summary],
|
|
56
|
+
priority: "high",
|
|
57
|
+
}));
|
|
58
|
+
const recovered = (result.recovered ?? []).map((incident) => ({
|
|
59
|
+
agent: agentName,
|
|
60
|
+
source: "sanctuary-health",
|
|
61
|
+
eventType: "health.observed",
|
|
62
|
+
eventId: incident.id,
|
|
63
|
+
observationRevision: incident.observationRevision ?? revision,
|
|
64
|
+
transition: "recovered",
|
|
65
|
+
summary: `recovered: ${incident.summary}`,
|
|
66
|
+
evidence: [`recovered: ${incident.summary}`],
|
|
67
|
+
priority: "high",
|
|
68
|
+
}));
|
|
69
|
+
return [...current, ...recovered];
|
|
102
70
|
}
|
|
103
71
|
async function runSanctuaryHealthHabit(agentName, options = {}) {
|
|
72
|
+
if (!options.createSweep && !(0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(agentName).ok) {
|
|
73
|
+
const machine = (0, machine_identity_1.loadOrCreateMachineIdentity)();
|
|
74
|
+
await (0, runtime_credentials_1.refreshMachineRuntimeCredentialConfig)(agentName, machine.machineId, { preserveCachedOnFailure: true });
|
|
75
|
+
}
|
|
104
76
|
const sweep = options.createSweep?.(agentName) ?? (0, sanctuary_health_1.createSanctuaryHealthSweep)({
|
|
105
77
|
toolContext: (0, sanctuary_runtime_1.createSanctuaryToolContext)(agentName),
|
|
106
78
|
statePath: path.join((0, identity_1.getAgentRoot)(agentName), "state", "health", "sanctuary-health.json"),
|
|
107
79
|
});
|
|
108
80
|
const result = await sweep();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
const deliver = async (content) => {
|
|
120
|
-
if (attempted)
|
|
121
|
-
throw new Error("Sanctuary health Telegram delivery was already attempted");
|
|
122
|
-
attempted = true;
|
|
123
|
-
await sweep.cacheDeliveryPayload?.(result.deliveryId, content);
|
|
124
|
-
await sweep.markDeliveryAttempting?.(result.deliveryId);
|
|
125
|
-
const messageIds = await (0, telegram_client_1.sendTelegramText)(api, credentials.authorizedChatId, content);
|
|
126
|
-
await sweep.markDelivered?.(result.deliveryId, messageIds);
|
|
127
|
-
};
|
|
128
|
-
const privateResult = result.cachedMessage
|
|
129
|
-
? (await deliver(result.cachedMessage), { delivered: true })
|
|
130
|
-
: await (async () => {
|
|
131
|
-
options.acceptanceMetrics?.onPrivateTurnStart();
|
|
132
|
-
return (options.runPrivateTurn ?? runSanctuaryHealthPrivateTurn)({
|
|
133
|
-
agentName,
|
|
134
|
-
eventId,
|
|
135
|
-
payload,
|
|
136
|
-
deliver,
|
|
137
|
-
onProviderInvocation: options.acceptanceMetrics?.onProviderInvocation,
|
|
138
|
-
});
|
|
139
|
-
})();
|
|
140
|
-
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_habit", message: "Sanctuary native health habit completed", meta: { agentName, incidentCount: result.incidents.length, delivered: privateResult.delivered } });
|
|
141
|
-
return { ok: true, message: privateResult.delivered ? "health sweep completed and delivered" : "health event remains pending", data: { incidentCount: result.incidents.length, delivered: privateResult.delivered } };
|
|
142
|
-
}
|
|
143
|
-
finally {
|
|
144
|
-
api.stop();
|
|
81
|
+
const inputs = evidenceInputs(agentName, result);
|
|
82
|
+
if (inputs.length === 0) {
|
|
83
|
+
(0, runtime_1.emitNervesEvent)({
|
|
84
|
+
component: "senses",
|
|
85
|
+
event: "senses.sanctuary_health_habit",
|
|
86
|
+
message: "Sanctuary health evidence submitted",
|
|
87
|
+
meta: { agentName, incidentCount: 0, submitted: 0, wakesRequested: 0 },
|
|
88
|
+
});
|
|
89
|
+
return { ok: true, message: "health evidence submitted", data: { incidentCount: 0, submitted: 0, wakesRequested: 0 } };
|
|
145
90
|
}
|
|
91
|
+
const submit = options.submitEvidence ?? ((input) => (0, router_1.recordExternalEvent)(input));
|
|
92
|
+
const receipts = await Promise.all(inputs.map((input) => submit(input)));
|
|
93
|
+
const wakesRequested = receipts.some((receipt) => receipt.shouldWake) ? 1 : 0;
|
|
94
|
+
(0, runtime_1.emitNervesEvent)({
|
|
95
|
+
component: "senses",
|
|
96
|
+
event: "senses.sanctuary_health_habit",
|
|
97
|
+
message: "Sanctuary health evidence submitted",
|
|
98
|
+
meta: { agentName, incidentCount: result.incidents.length, submitted: inputs.length, wakesRequested },
|
|
99
|
+
});
|
|
100
|
+
return { ok: true, message: "health evidence submitted", data: { incidentCount: result.incidents.length, submitted: inputs.length, wakesRequested } };
|
|
146
101
|
}
|
|
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SANCTUARY_PUBLIC_ENDPOINTS = void 0;
|
|
36
37
|
exports.withSanctuaryHealthStateLease = withSanctuaryHealthStateLease;
|
|
38
|
+
exports.readSanctuaryHealthState = readSanctuaryHealthState;
|
|
37
39
|
exports.probeSanctuaryEndpoint = probeSanctuaryEndpoint;
|
|
38
40
|
exports.createSanctuaryHealthSweep = createSanctuaryHealthSweep;
|
|
39
41
|
const fs = __importStar(require("node:fs"));
|
|
@@ -41,7 +43,7 @@ const path = __importStar(require("node:path"));
|
|
|
41
43
|
const node_crypto_1 = require("node:crypto");
|
|
42
44
|
const runtime_1 = require("../nerves/runtime");
|
|
43
45
|
const sanctuary_acceptance_marker_1 = require("../heart/daemon/sanctuary-acceptance-marker");
|
|
44
|
-
|
|
46
|
+
exports.SANCTUARY_PUBLIC_ENDPOINTS = [
|
|
45
47
|
"https://media.mendelow.cloud/",
|
|
46
48
|
"https://books.mendelow.cloud/",
|
|
47
49
|
"https://requests.mendelow.cloud/",
|
|
@@ -51,24 +53,23 @@ const REQUIRED_CONTAINERS = new Set(["calibre", "calibre-web", "Cloudflare-DDNS"
|
|
|
51
53
|
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
52
54
|
const MAX_HEALTH_STATE_BYTES = 4 * 1024 * 1024;
|
|
53
55
|
const MAX_HEALTH_TEXT_BYTES = 50_000;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const parsed = Date.parse(value);
|
|
58
|
-
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
59
|
-
}
|
|
60
|
-
function boundedHealthText(value) {
|
|
61
|
-
if (Buffer.byteLength(value) <= MAX_HEALTH_TEXT_BYTES)
|
|
56
|
+
const MAX_HEALTH_INCIDENT_BYTES = 4_096;
|
|
57
|
+
function boundedIncidentText(value) {
|
|
58
|
+
if (Buffer.byteLength(value) <= MAX_HEALTH_INCIDENT_BYTES)
|
|
62
59
|
return value;
|
|
63
|
-
const ellipsis = "…";
|
|
64
|
-
const contentByteLimit = MAX_HEALTH_TEXT_BYTES - Buffer.byteLength(ellipsis);
|
|
65
60
|
let output = "";
|
|
66
61
|
for (const character of value) {
|
|
67
|
-
if (Buffer.byteLength(output) + Buffer.byteLength(character) >
|
|
62
|
+
if (Buffer.byteLength(output) + Buffer.byteLength(character) > MAX_HEALTH_INCIDENT_BYTES - 3)
|
|
68
63
|
break;
|
|
69
64
|
output += character;
|
|
70
65
|
}
|
|
71
|
-
return `${output}
|
|
66
|
+
return `${output}…`;
|
|
67
|
+
}
|
|
68
|
+
function canonicalIsoTimestamp(value) {
|
|
69
|
+
if (typeof value !== "string" || value.length > 30)
|
|
70
|
+
return false;
|
|
71
|
+
const parsed = Date.parse(value);
|
|
72
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
72
73
|
}
|
|
73
74
|
const healthLockTails = new Map();
|
|
74
75
|
const healthLeaseBrand = Symbol("sanctuary-health-state-lease");
|
|
@@ -106,6 +107,7 @@ async function acquireHealthFileLease(statePath, timeoutMs) {
|
|
|
106
107
|
const ownerPath = path.join(leasePath, "owner.json");
|
|
107
108
|
const deadline = Date.now() + timeoutMs;
|
|
108
109
|
const nonce = (0, node_crypto_1.createHash)("sha256").update((0, node_crypto_1.randomUUID)()).digest("hex");
|
|
110
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
109
111
|
for (;;) {
|
|
110
112
|
try {
|
|
111
113
|
fs.mkdirSync(leasePath, { recursive: false, mode: 0o700 });
|
|
@@ -233,6 +235,10 @@ function load(filePath) {
|
|
|
233
235
|
throw new Error("Sanctuary health state is corrupt", { cause: error });
|
|
234
236
|
}
|
|
235
237
|
}
|
|
238
|
+
/** Read-only projection for status/orientation surfaces; mutation remains lease-owned by the sweep. */
|
|
239
|
+
function readSanctuaryHealthState(filePath) {
|
|
240
|
+
return load(filePath);
|
|
241
|
+
}
|
|
236
242
|
function save(filePath, state) {
|
|
237
243
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
238
244
|
const temporary = `${filePath}.tmp-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
|
|
@@ -293,18 +299,9 @@ function createSanctuaryHealthSweep(options) {
|
|
|
293
299
|
const startedAt = now().toISOString();
|
|
294
300
|
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_start", message: "Sanctuary deterministic health sweep started", meta: { statePath: options.statePath, ...acceptanceEventMeta() } });
|
|
295
301
|
const pendingState = load(options.statePath);
|
|
296
|
-
if (pendingState.outbox
|
|
297
|
-
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_end", message: "Sanctuary health delivery recovered from durable outbox", meta: { incidentCount: Object.keys(pendingState.incidents).length, deliveryId: pendingState.outbox.id, deliveryStatus: pendingState.outbox.status, ...acceptanceEventMeta() } });
|
|
298
|
-
return {
|
|
299
|
-
message: pendingState.outbox.message,
|
|
300
|
-
incidents: Object.values(pendingState.incidents),
|
|
301
|
-
deliveryId: pendingState.outbox.id,
|
|
302
|
-
...(pendingState.outbox.summarizedMessage ? { cachedMessage: pendingState.outbox.summarizedMessage } : {}),
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
if (pendingState.outbox?.status === "attempting") {
|
|
306
|
-
pendingState.indeterminateDeliveries.push(pendingState.outbox);
|
|
302
|
+
if (pendingState.outbox) {
|
|
307
303
|
pendingState.outbox = null;
|
|
304
|
+
pendingState.indeterminateDeliveries = [];
|
|
308
305
|
pendingState.updatedAt = now().toISOString();
|
|
309
306
|
save(options.statePath, pendingState);
|
|
310
307
|
}
|
|
@@ -313,42 +310,49 @@ function createSanctuaryHealthSweep(options) {
|
|
|
313
310
|
throw new Error("Sanctuary health runtime is unavailable");
|
|
314
311
|
const [containersResult, storageResult, disksResult, notificationsResult, endpoints] = await Promise.all([
|
|
315
312
|
runtime.listContainers(), runtime.getStorage(), runtime.getDisks(), runtime.getNotifications(),
|
|
316
|
-
Promise.all(
|
|
313
|
+
Promise.all(exports.SANCTUARY_PUBLIC_ENDPOINTS.map((url) => probeSanctuaryEndpoint(url, fetchImpl))),
|
|
317
314
|
]);
|
|
318
315
|
const incidents = new Map();
|
|
319
|
-
const add = (id,
|
|
316
|
+
const add = (id, rawSummary) => {
|
|
317
|
+
const summary = boundedIncidentText(rawSummary);
|
|
318
|
+
incidents.set(id, {
|
|
319
|
+
id,
|
|
320
|
+
summary,
|
|
321
|
+
observationRevision: (0, node_crypto_1.createHash)("sha256").update(`${id}\0${summary}`).digest("hex"),
|
|
322
|
+
});
|
|
323
|
+
};
|
|
320
324
|
const containers = record(containersResult)?.ok ? record(record(containersResult)?.data)?.containers : null;
|
|
321
325
|
if (!Array.isArray(containers))
|
|
322
326
|
add("containers:unavailable", "container status is unavailable");
|
|
323
327
|
else
|
|
324
328
|
for (const container of containers) {
|
|
325
329
|
if ((container.autostart === true || REQUIRED_CONTAINERS.has(container.name)) && container.state !== "running")
|
|
326
|
-
add(`container:${container.id}
|
|
330
|
+
add(`container:${container.id}:availability`, `${container.name} is ${container.state}`);
|
|
327
331
|
if (container.state === "unknown" || container.degraded === true)
|
|
328
|
-
add(`container:${container.id}:
|
|
332
|
+
add(`container:${container.id}:health`, `${container.name} status is degraded`);
|
|
329
333
|
}
|
|
330
334
|
const storage = record(storageResult)?.ok ? record(record(storageResult)?.data) : null;
|
|
331
335
|
const array = record(storage?.array);
|
|
332
336
|
if (!array || array.degraded || typeof array.usedPercent !== "number")
|
|
333
|
-
add("storage:array:
|
|
337
|
+
add("storage:array:capacity", "array capacity is unavailable");
|
|
334
338
|
else if (array.usedPercent >= 90)
|
|
335
|
-
add("storage:array:
|
|
339
|
+
add("storage:array:capacity", `array usage is ${array.usedPercent}%`);
|
|
336
340
|
for (const share of Array.isArray(storage?.shares) ? storage.shares : [])
|
|
337
341
|
if (share.degraded || typeof share.usedPercent !== "number")
|
|
338
|
-
add(`storage:share:${share.name}:
|
|
342
|
+
add(`storage:share:${share.name}:capacity`, `${share.name} capacity is unavailable`);
|
|
339
343
|
else if (share.usedPercent >= 90)
|
|
340
|
-
add(`storage:share:${share.name}:
|
|
344
|
+
add(`storage:share:${share.name}:capacity`, `${share.name} usage is ${share.usedPercent}%`);
|
|
341
345
|
const disks = record(disksResult)?.ok ? record(record(disksResult)?.data) : null;
|
|
342
346
|
if (!disks)
|
|
343
347
|
add("disks:unavailable", "disk health is unavailable");
|
|
344
348
|
else {
|
|
345
349
|
for (const disk of Array.isArray(disks.disks) ? disks.disks : []) {
|
|
346
350
|
if (disk.smart !== "passed")
|
|
347
|
-
add(`disk:${disk.id}:smart
|
|
351
|
+
add(`disk:${disk.id}:smart`, `${disk.name} SMART is ${disk.smart}`);
|
|
348
352
|
if (typeof disk.temperatureC !== "number")
|
|
349
|
-
add(`disk:${disk.id}:temperature
|
|
353
|
+
add(`disk:${disk.id}:temperature`, `${disk.name} temperature is unavailable`);
|
|
350
354
|
else if (disk.temperatureC >= 50)
|
|
351
|
-
add(`disk:${disk.id}:temperature
|
|
355
|
+
add(`disk:${disk.id}:temperature`, `${disk.name} is ${disk.temperatureC}°C`);
|
|
352
356
|
}
|
|
353
357
|
const parity = record(disks.parity);
|
|
354
358
|
if (!parity || parity.result !== "success" || typeof parity.ageHours !== "number" || parity.ageHours >= 45 * 24)
|
|
@@ -364,84 +368,41 @@ function createSanctuaryHealthSweep(options) {
|
|
|
364
368
|
if (!endpoint.ok)
|
|
365
369
|
add(`endpoint:${endpoint.url}`, `${endpoint.url} returned ${endpoint.status || "no response"}`);
|
|
366
370
|
const previous = load(options.statePath);
|
|
367
|
-
const current = Object.fromEntries([...incidents.entries()].sort(([a], [b]) => a.localeCompare(b)))
|
|
371
|
+
const current = Object.fromEntries([...incidents.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, incident]) => {
|
|
372
|
+
const prior = previous.incidents[id];
|
|
373
|
+
const transition = !prior ? "opened" : prior.observationRevision === incident.observationRevision ? "unchanged" : "changed";
|
|
374
|
+
return [id, { ...incident, transition }];
|
|
375
|
+
}));
|
|
368
376
|
const opened = Object.values(current).filter((incident) => !previous.incidents[incident.id]);
|
|
377
|
+
const changed = Object.values(current).filter((incident) => incident.transition === "changed");
|
|
369
378
|
const recovered = Object.values(previous.incidents).filter((incident) => !current[incident.id]);
|
|
370
|
-
const localParts = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23" }).formatToParts(now());
|
|
371
|
-
const part = (type) => localParts.find((item) => item.type === type)?.value ?? "";
|
|
372
|
-
const day = `${part("year")}-${part("month")}-${part("day")}`;
|
|
373
|
-
const digestDue = Number(part("hour")) >= 9
|
|
374
|
-
&& (Object.keys(current).length > 0 || previous.indeterminateDeliveries.length > 0)
|
|
375
|
-
&& previous.lastDigestDay !== day;
|
|
376
|
-
const lines = [
|
|
377
|
-
...opened.map((incident) => `🚨 ${incident.summary}`),
|
|
378
|
-
...recovered.map((incident) => `✅ recovered: ${incident.summary}`),
|
|
379
|
-
...(digestDue && Object.keys(current).length > 0 ? [`📋 still broken: ${Object.values(current).map((incident) => incident.summary).join("; ")}`] : []),
|
|
380
|
-
...(digestDue && previous.indeterminateDeliveries.length > 0
|
|
381
|
-
? previous.indeterminateDeliveries.map((delivery) => `⚠️ prior Telegram delivery was indeterminate: ${delivery.message}`)
|
|
382
|
-
: []),
|
|
383
|
-
];
|
|
384
|
-
const message = lines.length ? boundedHealthText(lines.join("\n")) : null;
|
|
385
|
-
const deliveryKind = digestDue && (opened.length > 0 || recovered.length > 0)
|
|
386
|
-
? "transition_and_digest"
|
|
387
|
-
: digestDue ? "digest" : "transition";
|
|
388
|
-
const delivery = message ? { id: (0, node_crypto_1.randomUUID)(), message, status: "pending", createdAt: now().toISOString(), kind: deliveryKind } : null;
|
|
389
379
|
const acceptanceMeta = acceptanceEventMeta();
|
|
390
380
|
const completedAt = now().toISOString();
|
|
381
|
+
const incidentDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(current)).digest("hex");
|
|
391
382
|
const sweepReceipt = {
|
|
392
383
|
sweepId,
|
|
393
384
|
startedAt,
|
|
394
385
|
completedAt,
|
|
395
|
-
incidentDigest
|
|
386
|
+
incidentDigest,
|
|
396
387
|
opened: opened.length,
|
|
397
388
|
recovered: recovered.length,
|
|
398
|
-
digestDue,
|
|
399
|
-
...(delivery ? { deliveryId: delivery.id } : {}),
|
|
389
|
+
digestDue: false,
|
|
400
390
|
...(acceptanceMeta.scenarioHandleDigest ? { scenarioHandleDigest: acceptanceMeta.scenarioHandleDigest } : {}),
|
|
401
391
|
};
|
|
402
392
|
const next = {
|
|
403
393
|
incidents: current,
|
|
404
|
-
lastDigestDay:
|
|
394
|
+
lastDigestDay: null,
|
|
405
395
|
updatedAt: now().toISOString(),
|
|
406
|
-
outbox:
|
|
407
|
-
indeterminateDeliveries:
|
|
396
|
+
outbox: null,
|
|
397
|
+
indeterminateDeliveries: [],
|
|
408
398
|
deliveredReceipts: previous.deliveredReceipts,
|
|
409
399
|
sweepReceipts: [...previous.sweepReceipts, sweepReceipt].slice(-500),
|
|
410
400
|
};
|
|
411
401
|
save(options.statePath, next);
|
|
412
|
-
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_end", message: "Sanctuary deterministic health sweep completed", meta: { incidentCount: incidents.size, opened: opened.length, recovered: recovered.length, digestDue, ...acceptanceEventMeta() } });
|
|
413
|
-
|
|
402
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_end", message: "Sanctuary deterministic health sweep completed", meta: { incidentCount: incidents.size, opened: opened.length, recovered: recovered.length, digestDue: false, ...acceptanceEventMeta() } });
|
|
403
|
+
const transition = changed.length > 0 || (opened.length > 0 && recovered.length > 0) ? "changed" : opened.length > 0 ? "opened" : recovered.length > 0 ? "recovered" : "unchanged";
|
|
404
|
+
return { message: null, incidents: Object.values(current), recovered, observationRevision: incidentDigest, transition };
|
|
414
405
|
};
|
|
415
406
|
const sweep = (() => withHealthLock(options.statePath, runSweep, options.lease));
|
|
416
|
-
sweep.markDeliveryAttempting = (deliveryId) => withHealthLock(options.statePath, () => {
|
|
417
|
-
const state = load(options.statePath);
|
|
418
|
-
if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "pending")
|
|
419
|
-
throw new Error(`Sanctuary health delivery ${deliveryId} is not pending`);
|
|
420
|
-
state.outbox.status = "attempting";
|
|
421
|
-
state.updatedAt = now().toISOString();
|
|
422
|
-
save(options.statePath, state);
|
|
423
|
-
}, options.lease);
|
|
424
|
-
sweep.cacheDeliveryPayload = (deliveryId, message) => withHealthLock(options.statePath, () => {
|
|
425
|
-
if (typeof message !== "string" || message.trim().length === 0 || Buffer.byteLength(message) > MAX_HEALTH_TEXT_BYTES)
|
|
426
|
-
throw new Error("Sanctuary health cached delivery payload must be nonempty and bounded");
|
|
427
|
-
const state = load(options.statePath);
|
|
428
|
-
if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "pending")
|
|
429
|
-
throw new Error(`Sanctuary health delivery ${deliveryId} is not pending`);
|
|
430
|
-
state.outbox.summarizedMessage = message;
|
|
431
|
-
state.updatedAt = now().toISOString();
|
|
432
|
-
save(options.statePath, state);
|
|
433
|
-
}, options.lease);
|
|
434
|
-
sweep.markDelivered = (deliveryId, messageIds) => withHealthLock(options.statePath, () => {
|
|
435
|
-
if (!Array.isArray(messageIds) || messageIds.length < 1 || messageIds.length > 100 || !messageIds.every((id) => Number.isSafeInteger(id) && id > 0)) {
|
|
436
|
-
throw new Error("Sanctuary health delivery receipt requires canonical Telegram message ids");
|
|
437
|
-
}
|
|
438
|
-
const state = load(options.statePath);
|
|
439
|
-
if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "attempting")
|
|
440
|
-
throw new Error(`Sanctuary health delivery ${deliveryId} is not attempting`);
|
|
441
|
-
state.deliveredReceipts = [...state.deliveredReceipts, { deliveryId, kind: state.outbox.kind, messageIds: [...messageIds], deliveredAt: now().toISOString() }].slice(-100);
|
|
442
|
-
state.outbox = null;
|
|
443
|
-
state.updatedAt = now().toISOString();
|
|
444
|
-
save(options.statePath, state);
|
|
445
|
-
}, options.lease);
|
|
446
407
|
return sweep;
|
|
447
408
|
}
|
|
@@ -42,11 +42,14 @@ const node_async_hooks_1 = require("node:async_hooks");
|
|
|
42
42
|
const identity_1 = require("../heart/identity");
|
|
43
43
|
const runtime_credentials_1 = require("../heart/runtime-credentials");
|
|
44
44
|
const unraid_restart_1 = require("../repertoire/unraid-restart");
|
|
45
|
+
const steward_policy_1 = require("../heart/steward-policy");
|
|
45
46
|
const unraid_client_1 = require("../repertoire/unraid-client");
|
|
46
47
|
const tools_unraid_1 = require("../repertoire/tools-unraid");
|
|
47
48
|
const runtime_1 = require("../nerves/runtime");
|
|
48
49
|
const sanctuary_acceptance_marker_1 = require("../heart/daemon/sanctuary-acceptance-marker");
|
|
49
50
|
const sanctuary_grounding_1 = require("./sanctuary-grounding");
|
|
51
|
+
const sanctuary_health_1 = require("./sanctuary-health");
|
|
52
|
+
const sanctuary_sab_1 = require("./sanctuary-sab");
|
|
50
53
|
const sanctuaryToolReceipts = new node_async_hooks_1.AsyncLocalStorage();
|
|
51
54
|
const sanctuaryToolGroundings = new node_async_hooks_1.AsyncLocalStorage();
|
|
52
55
|
const acceptanceLedgerTails = new Map();
|
|
@@ -165,6 +168,7 @@ function createSanctuaryToolContext(agentName) {
|
|
|
165
168
|
const endpoint = required(initial, "unraidGraphqlUrl");
|
|
166
169
|
const readClient = new unraid_client_1.UnraidClient({ endpoint, apiKey: required(initial, "unraidReadApiKey") });
|
|
167
170
|
const reads = (0, tools_unraid_1.createUnraidReadTools)(readClient);
|
|
171
|
+
const sab = (0, sanctuary_sab_1.createSanctuarySabClient)();
|
|
168
172
|
const acceptanceRead = (toolName, read) => async (...args) => {
|
|
169
173
|
try {
|
|
170
174
|
const result = await read(...args);
|
|
@@ -187,6 +191,8 @@ function createSanctuaryToolContext(agentName) {
|
|
|
187
191
|
},
|
|
188
192
|
acceptanceScenarioHandleDigest: () => (0, sanctuary_acceptance_marker_1.readSanctuaryAcceptanceMarker)(agentName)?.scenarioHandleDigest,
|
|
189
193
|
acceptanceApproval: sanctuary_acceptance_marker_1.readSanctuaryAcceptanceApproval,
|
|
194
|
+
reserveRoutineAction: (input) => (0, steward_policy_1.consumeRoutineActionGrant)((0, identity_1.getAgentRoot)(agentName), input),
|
|
195
|
+
transitionRoutineAction: (input) => (0, steward_policy_1.transitionRoutineActionReceipt)((0, identity_1.getAgentRoot)(agentName), input),
|
|
190
196
|
});
|
|
191
197
|
return {
|
|
192
198
|
sanctuary: {
|
|
@@ -196,11 +202,39 @@ function createSanctuaryToolContext(agentName) {
|
|
|
196
202
|
getDisks: acceptanceRead("unraid_get_disks", reads.getDisks),
|
|
197
203
|
getNotifications: acceptanceRead("unraid_get_notifications", reads.getNotifications),
|
|
198
204
|
getSystem: acceptanceRead("unraid_get_system", reads.getSystem),
|
|
199
|
-
|
|
200
|
-
const
|
|
205
|
+
checkServices: acceptanceRead("unraid_check_services", async () => {
|
|
206
|
+
const services = await Promise.all(sanctuary_health_1.SANCTUARY_PUBLIC_ENDPOINTS.map(async (url) => ({ name: new URL(url).hostname.split(".")[0], ...await (0, sanctuary_health_1.probeSanctuaryEndpoint)(url) })));
|
|
207
|
+
return { ok: true, data: { observedAt: new Date().toISOString(), services, degraded: services.some((service) => !service.ok) } };
|
|
208
|
+
}),
|
|
209
|
+
getDownloadQueue: acceptanceRead("sanctuary_get_download_queue", sab.readQueue),
|
|
210
|
+
resumeDownloadQueue: async () => {
|
|
211
|
+
try {
|
|
212
|
+
const result = await sab.resumeQueue();
|
|
213
|
+
collectToolResult(result);
|
|
214
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_download_resume_receipt", message: "Sanctuary download queue resume completed", meta: { changed: result.changed, verified: result.verified, receiptDigest: result.receiptDigest } });
|
|
215
|
+
return { ok: true, data: result };
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.sanctuary_download_resume_error", message: "Sanctuary download queue resume failed", meta: { category: error instanceof Error ? error.name : "unknown" } });
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
restartContainer: async (args, execution) => {
|
|
223
|
+
const result = execution ? await restart(args, execution) : await restart(args);
|
|
201
224
|
collectToolResult(result);
|
|
202
225
|
return result;
|
|
203
226
|
},
|
|
227
|
+
recoverRoutineActions: () => (0, steward_policy_1.recoverRoutineActionReceipts)((0, identity_1.getAgentRoot)(agentName), {
|
|
228
|
+
observeTarget: async (target) => {
|
|
229
|
+
const observed = await reads.listContainers();
|
|
230
|
+
if (!observed.ok)
|
|
231
|
+
throw new Error(observed.error.message);
|
|
232
|
+
const matches = observed.data.containers.filter((container) => container.id === target.id && container.name === target.name);
|
|
233
|
+
if (matches.length !== 1)
|
|
234
|
+
return { ...target, state: "unknown" };
|
|
235
|
+
return { id: matches[0].id, name: matches[0].name, state: matches[0].state };
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
204
238
|
},
|
|
205
239
|
};
|
|
206
240
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSanctuarySabClient = createSanctuarySabClient;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const runtime_1 = require("../nerves/runtime");
|
|
7
|
+
const DEFAULT_INI_PATH = "/run/sanctuary/sabnzbd.ini";
|
|
8
|
+
const BASE_URL = "http://127.0.0.1:8090/api";
|
|
9
|
+
function apiKey(iniPath) {
|
|
10
|
+
let contents = "";
|
|
11
|
+
try {
|
|
12
|
+
contents = (0, node_fs_1.readFileSync)(iniPath, "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error("SAB queue verification credential is unavailable");
|
|
16
|
+
}
|
|
17
|
+
const value = contents.match(/^\s*api_key\s*=\s*(\S+)\s*$/mu)?.[1];
|
|
18
|
+
if (!value)
|
|
19
|
+
throw new Error("SAB queue verification credential is unavailable");
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function queuedJobs(value) {
|
|
23
|
+
const parsed = typeof value === "string" && /^\d{1,7}$/u.test(value) ? Number(value) : value;
|
|
24
|
+
if (!Number.isSafeInteger(parsed) || Number(parsed) < 0 || Number(parsed) > 1_000_000)
|
|
25
|
+
throw new Error("SAB queue response is malformed");
|
|
26
|
+
return Number(parsed);
|
|
27
|
+
}
|
|
28
|
+
function createSanctuarySabClient(options = {}) {
|
|
29
|
+
const iniPath = options.iniPath ?? DEFAULT_INI_PATH;
|
|
30
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
31
|
+
let cachedApiKey = null;
|
|
32
|
+
const request = async (mode) => {
|
|
33
|
+
cachedApiKey ??= apiKey(iniPath);
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetchImpl(`${BASE_URL}?mode=${mode}${mode === "queue" ? "&output=json" : ""}&apikey=${encodeURIComponent(cachedApiKey)}`, { signal: AbortSignal.timeout(15_000) });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error("SAB queue request failed");
|
|
40
|
+
}
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
throw new Error("SAB queue request failed");
|
|
43
|
+
return response;
|
|
44
|
+
};
|
|
45
|
+
const readQueue = async () => {
|
|
46
|
+
const response = await request("queue");
|
|
47
|
+
const body = await response.json();
|
|
48
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
49
|
+
throw new Error("SAB queue response is malformed");
|
|
50
|
+
const queue = body.queue;
|
|
51
|
+
if (!queue || typeof queue !== "object" || Array.isArray(queue))
|
|
52
|
+
throw new Error("SAB queue response is malformed");
|
|
53
|
+
const fields = queue;
|
|
54
|
+
if (typeof fields.paused !== "boolean" || (fields.status !== undefined && (typeof fields.status !== "string" || Buffer.byteLength(fields.status) > 64)))
|
|
55
|
+
throw new Error("SAB queue response is malformed");
|
|
56
|
+
const status = typeof fields.status === "string" ? fields.status : "unknown";
|
|
57
|
+
const jobs = fields.noofslots === undefined ? 0 : queuedJobs(fields.noofslots);
|
|
58
|
+
const observedAt = options.now?.() ?? new Date().toISOString();
|
|
59
|
+
const stateDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify({ paused: fields.paused, status, queuedJobs: jobs })).digest("hex");
|
|
60
|
+
return { paused: fields.paused, status, queuedJobs: jobs, observedAt, stateDigest };
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
readQueue,
|
|
64
|
+
async resumeQueue() {
|
|
65
|
+
const before = await readQueue();
|
|
66
|
+
if (!before.paused) {
|
|
67
|
+
const receiptDigest = (0, node_crypto_1.createHash)("sha256").update(`sabnzbd.resume\0${before.stateDigest}\0${before.stateDigest}`).digest("hex");
|
|
68
|
+
return { changed: false, before, after: before, verified: true, receiptDigest };
|
|
69
|
+
}
|
|
70
|
+
const response = await request("resume");
|
|
71
|
+
await response.body?.cancel().catch(() => undefined);
|
|
72
|
+
const after = await readQueue();
|
|
73
|
+
if (after.paused)
|
|
74
|
+
throw new Error("SAB queue resume could not be verified");
|
|
75
|
+
const receiptDigest = (0, node_crypto_1.createHash)("sha256").update(`sabnzbd.resume\0${before.stateDigest}\0${after.stateDigest}`).digest("hex");
|
|
76
|
+
return { changed: true, before, after, verified: true, receiptDigest };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_sab_loaded", message: "typed Sanctuary SAB queue client loaded", meta: { operations: 2 } });
|