@ouro.bot/cli 0.1.0-alpha.742 → 0.1.0-alpha.744
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 +32 -0
- package/deploy/unraid/Dockerfile +7 -2
- package/deploy/unraid/README.txt +704 -64
- 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/bundle-meta.json +2 -2
- package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +1 -1
- 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 +13 -10
- 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/core.js +37 -7
- package/dist/heart/cross-chat-delivery.js +1 -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 +126 -42
- 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/external-events/router.js +1025 -43
- 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 +47 -103
- package/dist/senses/sanctuary-health.js +53 -105
- 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 +212 -66
- package/dist/senses/telegram-effect-adapter.js +787 -0
- package/dist/senses/telegram.js +728 -48
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
|
@@ -33,75 +33,40 @@ 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
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
throw new Error(`Sanctuary health private turn blocked: ${budget.reason}`);
|
|
73
|
-
let delivered = false;
|
|
74
|
-
const result = await (0, core_1.runAgent)([
|
|
75
|
-
{
|
|
76
|
-
role: "user",
|
|
77
|
-
content: [
|
|
78
|
-
"A durable Sanctuary health event is pending.",
|
|
79
|
-
`Event id: ${input.eventId}`,
|
|
80
|
-
`Deterministic payload: ${input.payload}`,
|
|
81
|
-
"Summarize it briefly for Ari. Call send_message exactly once with friendId=operator, channel=telegram, and the summary as content, then rest.",
|
|
82
|
-
].join("\n"),
|
|
83
|
-
},
|
|
84
|
-
], privateTurnCallbacks(input.onProviderInvocation), "inner", undefined, {
|
|
85
|
-
toolProfile: "sanctuary-health-private",
|
|
86
|
-
tools: [definition.tool],
|
|
87
|
-
toolContext: { signin: async () => undefined },
|
|
88
|
-
execTool: async (name, args) => {
|
|
89
|
-
if (name !== "send_message")
|
|
90
|
-
throw new Error(`Sanctuary health profile cannot execute ${name}`);
|
|
91
|
-
if (delivered)
|
|
92
|
-
throw new Error("Sanctuary health Telegram delivery was already attempted");
|
|
93
|
-
if (args.friendId !== "operator" || args.channel !== "telegram" || typeof args.content !== "string" || args.content.trim().length === 0) {
|
|
94
|
-
throw new Error("Sanctuary health send_message target or content is invalid");
|
|
95
|
-
}
|
|
96
|
-
await input.deliver(args.content);
|
|
97
|
-
delivered = true;
|
|
98
|
-
return "delivered";
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
if (result.outcome === "errored") {
|
|
102
|
-
throw result.error ?? new Error("Sanctuary health private turn failed");
|
|
103
|
-
}
|
|
104
|
-
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];
|
|
105
70
|
}
|
|
106
71
|
async function runSanctuaryHealthHabit(agentName, options = {}) {
|
|
107
72
|
if (!options.createSweep && !(0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(agentName).ok) {
|
|
@@ -113,45 +78,24 @@ async function runSanctuaryHealthHabit(agentName, options = {}) {
|
|
|
113
78
|
statePath: path.join((0, identity_1.getAgentRoot)(agentName), "state", "health", "sanctuary-health.json"),
|
|
114
79
|
});
|
|
115
80
|
const result = await sweep();
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
const deliver = async (content) => {
|
|
127
|
-
if (attempted)
|
|
128
|
-
throw new Error("Sanctuary health Telegram delivery was already attempted");
|
|
129
|
-
attempted = true;
|
|
130
|
-
await sweep.cacheDeliveryPayload?.(result.deliveryId, content);
|
|
131
|
-
await sweep.markDeliveryAttempting?.(result.deliveryId);
|
|
132
|
-
const messageIds = await (0, telegram_client_1.sendTelegramText)(api, credentials.authorizedChatId, content);
|
|
133
|
-
await sweep.markDelivered?.(result.deliveryId, messageIds);
|
|
134
|
-
};
|
|
135
|
-
const privateResult = result.cachedMessage
|
|
136
|
-
? (await deliver(result.cachedMessage), { delivered: true })
|
|
137
|
-
: await (async () => {
|
|
138
|
-
options.acceptanceMetrics?.onPrivateTurnStart();
|
|
139
|
-
return (options.runPrivateTurn ?? runSanctuaryHealthPrivateTurn)({
|
|
140
|
-
agentName,
|
|
141
|
-
eventId,
|
|
142
|
-
payload,
|
|
143
|
-
deliver,
|
|
144
|
-
onProviderInvocation: options.acceptanceMetrics?.onProviderInvocation,
|
|
145
|
-
});
|
|
146
|
-
})();
|
|
147
|
-
if (!privateResult.delivered && !attempted) {
|
|
148
|
-
await deliver(payload);
|
|
149
|
-
privateResult.delivered = true;
|
|
150
|
-
}
|
|
151
|
-
(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 } });
|
|
152
|
-
return { ok: true, message: privateResult.delivered ? "health sweep completed and delivered" : "health event remains pending", data: { incidentCount: result.incidents.length, delivered: privateResult.delivered } };
|
|
153
|
-
}
|
|
154
|
-
finally {
|
|
155
|
-
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 } };
|
|
156
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 } };
|
|
157
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");
|
|
@@ -234,6 +235,10 @@ function load(filePath) {
|
|
|
234
235
|
throw new Error("Sanctuary health state is corrupt", { cause: error });
|
|
235
236
|
}
|
|
236
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
|
+
}
|
|
237
242
|
function save(filePath, state) {
|
|
238
243
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
239
244
|
const temporary = `${filePath}.tmp-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
|
|
@@ -294,30 +299,9 @@ function createSanctuaryHealthSweep(options) {
|
|
|
294
299
|
const startedAt = now().toISOString();
|
|
295
300
|
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_start", message: "Sanctuary deterministic health sweep started", meta: { statePath: options.statePath, ...acceptanceEventMeta() } });
|
|
296
301
|
const pendingState = load(options.statePath);
|
|
297
|
-
if (pendingState.outbox
|
|
298
|
-
(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() } });
|
|
299
|
-
return {
|
|
300
|
-
message: pendingState.outbox.message,
|
|
301
|
-
incidents: Object.values(pendingState.incidents),
|
|
302
|
-
deliveryId: pendingState.outbox.id,
|
|
303
|
-
...(pendingState.outbox.summarizedMessage ? { cachedMessage: pendingState.outbox.summarizedMessage } : {}),
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
if (pendingState.outbox?.status === "attempting" && pendingState.outbox.summarizedMessage) {
|
|
307
|
-
pendingState.outbox.status = "pending";
|
|
308
|
-
pendingState.updatedAt = now().toISOString();
|
|
309
|
-
save(options.statePath, pendingState);
|
|
310
|
-
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_end", message: "Sanctuary health delivery retry recovered from durable outbox", meta: { incidentCount: Object.keys(pendingState.incidents).length, deliveryId: pendingState.outbox.id, deliveryStatus: pendingState.outbox.status, ...acceptanceEventMeta() } });
|
|
311
|
-
return {
|
|
312
|
-
message: pendingState.outbox.message,
|
|
313
|
-
incidents: Object.values(pendingState.incidents),
|
|
314
|
-
deliveryId: pendingState.outbox.id,
|
|
315
|
-
cachedMessage: pendingState.outbox.summarizedMessage,
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
if (pendingState.outbox?.status === "attempting") {
|
|
319
|
-
pendingState.indeterminateDeliveries.push(pendingState.outbox);
|
|
302
|
+
if (pendingState.outbox) {
|
|
320
303
|
pendingState.outbox = null;
|
|
304
|
+
pendingState.indeterminateDeliveries = [];
|
|
321
305
|
pendingState.updatedAt = now().toISOString();
|
|
322
306
|
save(options.statePath, pendingState);
|
|
323
307
|
}
|
|
@@ -326,42 +310,49 @@ function createSanctuaryHealthSweep(options) {
|
|
|
326
310
|
throw new Error("Sanctuary health runtime is unavailable");
|
|
327
311
|
const [containersResult, storageResult, disksResult, notificationsResult, endpoints] = await Promise.all([
|
|
328
312
|
runtime.listContainers(), runtime.getStorage(), runtime.getDisks(), runtime.getNotifications(),
|
|
329
|
-
Promise.all(
|
|
313
|
+
Promise.all(exports.SANCTUARY_PUBLIC_ENDPOINTS.map((url) => probeSanctuaryEndpoint(url, fetchImpl))),
|
|
330
314
|
]);
|
|
331
315
|
const incidents = new Map();
|
|
332
|
-
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
|
+
};
|
|
333
324
|
const containers = record(containersResult)?.ok ? record(record(containersResult)?.data)?.containers : null;
|
|
334
325
|
if (!Array.isArray(containers))
|
|
335
326
|
add("containers:unavailable", "container status is unavailable");
|
|
336
327
|
else
|
|
337
328
|
for (const container of containers) {
|
|
338
329
|
if ((container.autostart === true || REQUIRED_CONTAINERS.has(container.name)) && container.state !== "running")
|
|
339
|
-
add(`container:${container.id}
|
|
330
|
+
add(`container:${container.id}:availability`, `${container.name} is ${container.state}`);
|
|
340
331
|
if (container.state === "unknown" || container.degraded === true)
|
|
341
|
-
add(`container:${container.id}:
|
|
332
|
+
add(`container:${container.id}:health`, `${container.name} status is degraded`);
|
|
342
333
|
}
|
|
343
334
|
const storage = record(storageResult)?.ok ? record(record(storageResult)?.data) : null;
|
|
344
335
|
const array = record(storage?.array);
|
|
345
336
|
if (!array || array.degraded || typeof array.usedPercent !== "number")
|
|
346
|
-
add("storage:array:
|
|
337
|
+
add("storage:array:capacity", "array capacity is unavailable");
|
|
347
338
|
else if (array.usedPercent >= 90)
|
|
348
|
-
add("storage:array:
|
|
339
|
+
add("storage:array:capacity", `array usage is ${array.usedPercent}%`);
|
|
349
340
|
for (const share of Array.isArray(storage?.shares) ? storage.shares : [])
|
|
350
341
|
if (share.degraded || typeof share.usedPercent !== "number")
|
|
351
|
-
add(`storage:share:${share.name}:
|
|
342
|
+
add(`storage:share:${share.name}:capacity`, `${share.name} capacity is unavailable`);
|
|
352
343
|
else if (share.usedPercent >= 90)
|
|
353
|
-
add(`storage:share:${share.name}:
|
|
344
|
+
add(`storage:share:${share.name}:capacity`, `${share.name} usage is ${share.usedPercent}%`);
|
|
354
345
|
const disks = record(disksResult)?.ok ? record(record(disksResult)?.data) : null;
|
|
355
346
|
if (!disks)
|
|
356
347
|
add("disks:unavailable", "disk health is unavailable");
|
|
357
348
|
else {
|
|
358
349
|
for (const disk of Array.isArray(disks.disks) ? disks.disks : []) {
|
|
359
350
|
if (disk.smart !== "passed")
|
|
360
|
-
add(`disk:${disk.id}:smart
|
|
351
|
+
add(`disk:${disk.id}:smart`, `${disk.name} SMART is ${disk.smart}`);
|
|
361
352
|
if (typeof disk.temperatureC !== "number")
|
|
362
|
-
add(`disk:${disk.id}:temperature
|
|
353
|
+
add(`disk:${disk.id}:temperature`, `${disk.name} temperature is unavailable`);
|
|
363
354
|
else if (disk.temperatureC >= 50)
|
|
364
|
-
add(`disk:${disk.id}:temperature
|
|
355
|
+
add(`disk:${disk.id}:temperature`, `${disk.name} is ${disk.temperatureC}°C`);
|
|
365
356
|
}
|
|
366
357
|
const parity = record(disks.parity);
|
|
367
358
|
if (!parity || parity.result !== "success" || typeof parity.ageHours !== "number" || parity.ageHours >= 45 * 24)
|
|
@@ -377,84 +368,41 @@ function createSanctuaryHealthSweep(options) {
|
|
|
377
368
|
if (!endpoint.ok)
|
|
378
369
|
add(`endpoint:${endpoint.url}`, `${endpoint.url} returned ${endpoint.status || "no response"}`);
|
|
379
370
|
const previous = load(options.statePath);
|
|
380
|
-
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
|
+
}));
|
|
381
376
|
const opened = Object.values(current).filter((incident) => !previous.incidents[incident.id]);
|
|
377
|
+
const changed = Object.values(current).filter((incident) => incident.transition === "changed");
|
|
382
378
|
const recovered = Object.values(previous.incidents).filter((incident) => !current[incident.id]);
|
|
383
|
-
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());
|
|
384
|
-
const part = (type) => localParts.find((item) => item.type === type)?.value ?? "";
|
|
385
|
-
const day = `${part("year")}-${part("month")}-${part("day")}`;
|
|
386
|
-
const digestDue = Number(part("hour")) >= 9
|
|
387
|
-
&& (Object.keys(current).length > 0 || previous.indeterminateDeliveries.length > 0)
|
|
388
|
-
&& previous.lastDigestDay !== day;
|
|
389
|
-
const lines = [
|
|
390
|
-
...opened.map((incident) => `🚨 ${incident.summary}`),
|
|
391
|
-
...recovered.map((incident) => `✅ recovered: ${incident.summary}`),
|
|
392
|
-
...(digestDue && Object.keys(current).length > 0 ? [`📋 still broken: ${Object.values(current).map((incident) => incident.summary).join("; ")}`] : []),
|
|
393
|
-
...(digestDue && previous.indeterminateDeliveries.length > 0
|
|
394
|
-
? previous.indeterminateDeliveries.map((delivery) => `⚠️ prior Telegram delivery was indeterminate: ${delivery.message}`)
|
|
395
|
-
: []),
|
|
396
|
-
];
|
|
397
|
-
const message = lines.length ? boundedHealthText(lines.join("\n")) : null;
|
|
398
|
-
const deliveryKind = digestDue && (opened.length > 0 || recovered.length > 0)
|
|
399
|
-
? "transition_and_digest"
|
|
400
|
-
: digestDue ? "digest" : "transition";
|
|
401
|
-
const delivery = message ? { id: (0, node_crypto_1.randomUUID)(), message, status: "pending", createdAt: now().toISOString(), kind: deliveryKind } : null;
|
|
402
379
|
const acceptanceMeta = acceptanceEventMeta();
|
|
403
380
|
const completedAt = now().toISOString();
|
|
381
|
+
const incidentDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(current)).digest("hex");
|
|
404
382
|
const sweepReceipt = {
|
|
405
383
|
sweepId,
|
|
406
384
|
startedAt,
|
|
407
385
|
completedAt,
|
|
408
|
-
incidentDigest
|
|
386
|
+
incidentDigest,
|
|
409
387
|
opened: opened.length,
|
|
410
388
|
recovered: recovered.length,
|
|
411
|
-
digestDue,
|
|
412
|
-
...(delivery ? { deliveryId: delivery.id } : {}),
|
|
389
|
+
digestDue: false,
|
|
413
390
|
...(acceptanceMeta.scenarioHandleDigest ? { scenarioHandleDigest: acceptanceMeta.scenarioHandleDigest } : {}),
|
|
414
391
|
};
|
|
415
392
|
const next = {
|
|
416
393
|
incidents: current,
|
|
417
|
-
lastDigestDay:
|
|
394
|
+
lastDigestDay: null,
|
|
418
395
|
updatedAt: now().toISOString(),
|
|
419
|
-
outbox:
|
|
420
|
-
indeterminateDeliveries:
|
|
396
|
+
outbox: null,
|
|
397
|
+
indeterminateDeliveries: [],
|
|
421
398
|
deliveredReceipts: previous.deliveredReceipts,
|
|
422
399
|
sweepReceipts: [...previous.sweepReceipts, sweepReceipt].slice(-500),
|
|
423
400
|
};
|
|
424
401
|
save(options.statePath, next);
|
|
425
|
-
(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() } });
|
|
426
|
-
|
|
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 };
|
|
427
405
|
};
|
|
428
406
|
const sweep = (() => withHealthLock(options.statePath, runSweep, options.lease));
|
|
429
|
-
sweep.markDeliveryAttempting = (deliveryId) => withHealthLock(options.statePath, () => {
|
|
430
|
-
const state = load(options.statePath);
|
|
431
|
-
if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "pending")
|
|
432
|
-
throw new Error(`Sanctuary health delivery ${deliveryId} is not pending`);
|
|
433
|
-
state.outbox.status = "attempting";
|
|
434
|
-
state.updatedAt = now().toISOString();
|
|
435
|
-
save(options.statePath, state);
|
|
436
|
-
}, options.lease);
|
|
437
|
-
sweep.cacheDeliveryPayload = (deliveryId, message) => withHealthLock(options.statePath, () => {
|
|
438
|
-
if (typeof message !== "string" || message.trim().length === 0 || Buffer.byteLength(message) > MAX_HEALTH_TEXT_BYTES)
|
|
439
|
-
throw new Error("Sanctuary health cached delivery payload must be nonempty and bounded");
|
|
440
|
-
const state = load(options.statePath);
|
|
441
|
-
if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "pending")
|
|
442
|
-
throw new Error(`Sanctuary health delivery ${deliveryId} is not pending`);
|
|
443
|
-
state.outbox.summarizedMessage = message;
|
|
444
|
-
state.updatedAt = now().toISOString();
|
|
445
|
-
save(options.statePath, state);
|
|
446
|
-
}, options.lease);
|
|
447
|
-
sweep.markDelivered = (deliveryId, messageIds) => withHealthLock(options.statePath, () => {
|
|
448
|
-
if (!Array.isArray(messageIds) || messageIds.length < 1 || messageIds.length > 100 || !messageIds.every((id) => Number.isSafeInteger(id) && id > 0)) {
|
|
449
|
-
throw new Error("Sanctuary health delivery receipt requires canonical Telegram message ids");
|
|
450
|
-
}
|
|
451
|
-
const state = load(options.statePath);
|
|
452
|
-
if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "attempting")
|
|
453
|
-
throw new Error(`Sanctuary health delivery ${deliveryId} is not attempting`);
|
|
454
|
-
state.deliveredReceipts = [...state.deliveredReceipts, { deliveryId, kind: state.outbox.kind, messageIds: [...messageIds], deliveredAt: now().toISOString() }].slice(-100);
|
|
455
|
-
state.outbox = null;
|
|
456
|
-
state.updatedAt = now().toISOString();
|
|
457
|
-
save(options.statePath, state);
|
|
458
|
-
}, options.lease);
|
|
459
407
|
return sweep;
|
|
460
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 } });
|