@pasko70/pibo 3.5.0 → 3.5.1
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/dist/agent-runtime/routed-session.js +80 -19
- package/dist/agent-runtimes/codex-native/turn.js +27 -3
- package/dist/apps/chat/data/chat-data-mappers.js +8 -1
- package/dist/apps/chat/data/read-state-service.js +24 -3
- package/dist/apps/chat/message-command-dispatcher.js +16 -1
- package/dist/apps/chat/web-app.js +40 -19
- package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-B-auLrzD.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-BA_dsINH.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-eJZar_0-.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-wNNR2Bci.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-zcmEsIEp.js} +1 -1
- package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-DEkbN5Vo.js} +43 -43
- package/dist/apps/chat-ui/assets/index-DZK6Tzil.css +1 -0
- package/dist/apps/chat-ui/index.html +2 -2
- package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DZgW1fCB.js} +11 -11
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/cli-session/localSessionSource.js +3 -2
- package/dist/core/output-render-sequence.js +63 -6
- package/dist/core/session-router.js +99 -11
- package/dist/data/async-chat-storage.js +7 -2
- package/dist/data/bounded-worker-client.js +1 -1
- package/dist/data/chat-read-projections.js +4 -4
- package/dist/data/chat-storage-worker.js +24 -3
- package/dist/data/ingest-service.js +73 -4
- package/dist/data/message-command-store.js +153 -11
- package/dist/data/schema.js +24 -3
- package/dist/data/storage-maintenance.js +344 -0
- package/dist/data/storage-verification-worker.js +25 -0
- package/dist/debug/index.js +155 -1
- package/dist/debug/message-queue.js +108 -0
- package/dist/debug/output-collision-repair.js +140 -0
- package/dist/debug/output-integrity.js +38 -2
- package/dist/debug/output-repair.js +1 -0
- package/dist/debug/storage-backup.js +12 -4
- package/dist/debug/storage-maintenance.js +78 -0
- package/dist/gateway/cli.js +71 -7
- package/dist/gateway/server.js +1 -0
- package/dist/reliability/store.js +119 -23
- package/dist/session-ui/terminalRows.js +7 -8
- package/dist/sessions/pibo-data-store.js +18 -14
- package/dist/shared/trace-event-projection.js +13 -3
- package/dist/web/channel.js +114 -12
- package/dist/web/http.js +105 -44
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-hEkrlRk-.css +0 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { MessageCommandStore } from "../data/message-command-store.js";
|
|
2
|
+
const ACTIVE_STATES = new Set(["accepted", "waiting_slot", "initializing", "session_queue", "running"]);
|
|
3
|
+
const commandColumns = "id,request_key,session_id,room_id,event_id,stream_id,delivery,state,owner,token,lease_until,created_at,updated_at,error";
|
|
4
|
+
export function inspectMessageQueue(store, input = {}) {
|
|
5
|
+
const now = input.now ?? Date.now(), activeLimit = Math.max(1, Math.min(500, input.limit ?? 200)), terminalLimit = Math.min(20, activeLimit), after = Math.max(0, Math.trunc(input.afterStreamId ?? 0));
|
|
6
|
+
const activeRows = (input.sessionId
|
|
7
|
+
? store.db.prepare(`SELECT ${commandColumns} FROM message_commands WHERE session_id=? AND state IN ('accepted','waiting_slot','initializing','session_queue','running','interrupted') AND stream_id>? ORDER BY stream_id LIMIT ?`).all(input.sessionId, after, activeLimit + 1)
|
|
8
|
+
: store.db.prepare(`SELECT ${commandColumns} FROM message_commands WHERE state IN ('accepted','waiting_slot','initializing','session_queue','running','interrupted') AND stream_id>? ORDER BY stream_id LIMIT ?`).all(after, activeLimit + 1));
|
|
9
|
+
const terminalBefore = input.beforeTerminalStreamId;
|
|
10
|
+
const terminalRows = (input.sessionId
|
|
11
|
+
? terminalBefore === undefined
|
|
12
|
+
? store.db.prepare(`SELECT ${commandColumns} FROM message_commands WHERE session_id=? AND state IN ('completed','failed') ORDER BY stream_id DESC LIMIT ?`).all(input.sessionId, terminalLimit + 1)
|
|
13
|
+
: store.db.prepare(`SELECT ${commandColumns} FROM message_commands WHERE session_id=? AND state IN ('completed','failed') AND stream_id<? ORDER BY stream_id DESC LIMIT ?`).all(input.sessionId, terminalBefore, terminalLimit + 1)
|
|
14
|
+
: terminalBefore === undefined
|
|
15
|
+
? store.db.prepare(`SELECT ${commandColumns} FROM message_commands WHERE state IN ('completed','failed') ORDER BY stream_id DESC LIMIT ?`).all(terminalLimit + 1)
|
|
16
|
+
: store.db.prepare(`SELECT ${commandColumns} FROM message_commands WHERE state IN ('completed','failed') AND stream_id<? ORDER BY stream_id DESC LIMIT ?`).all(terminalBefore, terminalLimit + 1));
|
|
17
|
+
const activeTruncated = activeRows.length > activeLimit, terminalTruncated = terminalRows.length > terminalLimit, active = activeRows.slice(0, activeLimit), terminal = terminalRows.slice(0, terminalLimit);
|
|
18
|
+
const contextById = new Map([...terminal.map(row => [row.id, "recent_terminal"]), ...active.map(row => [row.id, "active"])]);
|
|
19
|
+
const selected = [...new Map([...terminal, ...active].map(row => [row.id, row])).values()].sort((a, b) => a.stream_id - b.stream_id), commands = new MessageCommandStore(store);
|
|
20
|
+
const projected = selected.map(row => {
|
|
21
|
+
const previous = store.db.prepare("SELECT id FROM message_commands WHERE session_id=? AND stream_id<? ORDER BY stream_id DESC LIMIT 1").get(row.session_id, row.stream_id);
|
|
22
|
+
const next = store.db.prepare("SELECT id FROM message_commands WHERE session_id=? AND stream_id>? ORDER BY stream_id LIMIT 1").get(row.session_id, row.stream_id);
|
|
23
|
+
const blocker = ACTIVE_STATES.has(row.state) || row.state === "interrupted" ? store.db.prepare(`SELECT id FROM message_commands WHERE session_id=? AND stream_id<? AND state IN ('accepted','waiting_slot','initializing','session_queue','running','interrupted') AND (?='queue' OR delivery='steer') ORDER BY stream_id LIMIT 1`).get(row.session_id, row.stream_id, row.delivery) : undefined;
|
|
24
|
+
const blockingRows = row.state === "interrupted" ? store.db.prepare(`SELECT id FROM message_commands WHERE session_id=? AND stream_id>? AND state IN ('accepted','waiting_slot','initializing','session_queue','running') AND (delivery='queue' OR ?='steer') ORDER BY stream_id LIMIT 201`).all(row.session_id, row.stream_id, row.delivery) : [];
|
|
25
|
+
return { id: row.id, context: contextById.get(row.id) ?? "recent_terminal", sessionId: row.session_id, roomId: row.room_id, eventId: row.event_id, streamId: row.stream_id, delivery: row.delivery, state: row.state, ...(row.owner ? { owner: row.owner } : {}), lease: { until: row.lease_until, fresh: Boolean(row.owner && row.lease_until > now) }, createdAt: row.created_at, updatedAt: row.updated_at, ...(row.error ? { error: row.error } : {}), ...(previous ? { previousCommandId: previous.id } : {}), ...(next ? { nextCommandId: next.id } : {}), ...(blocker && blocker.id !== row.id ? { blockedBy: blocker.id } : {}), blocks: blockingRows.slice(0, 200).map(item => item.id), blocksTruncated: blockingRows.length > 200, terminalOutcome: commands.terminalOutcome(row.session_id, row.event_id) ?? "ambiguous", terminalEvidence: commands.terminalEvidence(row.session_id, row.event_id) };
|
|
26
|
+
});
|
|
27
|
+
const nextCommands = input.sessionId ? [`pibo debug message-queue reconcile <command-id> --mark-failed --dry-run`, `pibo debug message-queue reconcile <command-id> --mark-failed --apply`] : ["pibo debug message-queue inspect --session <pibo-session-id>"];
|
|
28
|
+
if (activeTruncated && active.length)
|
|
29
|
+
nextCommands.push(`pibo debug message-queue inspect --session ${input.sessionId ?? "<pibo-session-id>"} --after-stream ${active.at(-1).stream_id}`);
|
|
30
|
+
if (terminalTruncated && terminal.length)
|
|
31
|
+
nextCommands.push(`pibo debug message-queue inspect --session ${input.sessionId ?? "<pibo-session-id>"} --before-terminal-stream ${Math.min(...terminal.map(row => row.stream_id))}`);
|
|
32
|
+
return { generatedAt: new Date(now).toISOString(), sessionId: input.sessionId, commands: projected, health: commands.health(now), truncated: activeTruncated || terminalTruncated, pagination: { active: { returned: active.length, truncated: activeTruncated, afterStreamId: after, ...(activeTruncated && active.length ? { nextAfterStreamId: active.at(-1).stream_id } : {}) }, terminalContext: { returned: terminal.length, truncated: terminalTruncated, ...(terminalBefore !== undefined ? { beforeStreamId: terminalBefore } : {}), ...(terminalTruncated && terminal.length ? { nextBeforeStreamId: Math.min(...terminal.map(row => row.stream_id)) } : {}) } }, nextCommands };
|
|
33
|
+
}
|
|
34
|
+
function readCommand(store, id) {
|
|
35
|
+
return store.db.prepare("SELECT id,request_key,session_id,room_id,event_id,stream_id,delivery,state,owner,token,lease_until,created_at,updated_at,error FROM message_commands WHERE id=?").get(id);
|
|
36
|
+
}
|
|
37
|
+
function successorRows(store, row) {
|
|
38
|
+
return store.db.prepare("SELECT id,request_key,session_id,room_id,event_id,stream_id,delivery,state,owner,token,lease_until,created_at,updated_at,error FROM message_commands WHERE session_id=? AND stream_id>? AND state IN ('accepted','waiting_slot') ORDER BY stream_id LIMIT 200").all(row.session_id, row.stream_id);
|
|
39
|
+
}
|
|
40
|
+
function projection(row, state, error) { return { id: row.id, sessionId: row.session_id, eventId: row.event_id, priorState: row.state, resultingState: state, error: error ?? undefined, token: row.token, updatedAt: row.updated_at }; }
|
|
41
|
+
export function reconcileMessageCommand(store, options) {
|
|
42
|
+
if (!/^cmd_[A-Za-z0-9-]+$/.test(options.commandId))
|
|
43
|
+
throw new Error("Reconciliation requires one exact command ID (cmd_...).");
|
|
44
|
+
const now = options.now ?? Date.now(), commands = new MessageCommandStore(store);
|
|
45
|
+
const action = () => {
|
|
46
|
+
const row = readCommand(store, options.commandId);
|
|
47
|
+
if (!row)
|
|
48
|
+
throw new Error(`Unknown durable message command "${options.commandId}".`);
|
|
49
|
+
const desired = options.decision === "mark-failed" ? "failed" : "completed";
|
|
50
|
+
if (row.state === desired) {
|
|
51
|
+
const priorAudit = store.eventLog.findByIdempotencyKey(`message-command-reconcile:${row.id}:${options.decision}`);
|
|
52
|
+
if (!priorAudit)
|
|
53
|
+
throw new Error(`Command ${row.id} is already ${row.state}, but no matching reconciliation audit exists; inspect the current state.`);
|
|
54
|
+
return { applied: false, alreadyApplied: true, decision: options.decision, command: projection(row, desired, row.error), successors: [], auditEventId: priorAudit.eventId, health: commands.health(now), nextAction: `pibo debug message-queue inspect --session ${row.session_id}` };
|
|
55
|
+
}
|
|
56
|
+
if (options.expected && (row.state !== options.expected.state || row.token !== options.expected.token || row.updated_at !== options.expected.updatedAt))
|
|
57
|
+
throw Object.assign(new Error("Command changed after inspection; inspect again before applying."), { code: "command_snapshot_changed" });
|
|
58
|
+
if (row.state !== "interrupted")
|
|
59
|
+
throw new Error(`Command ${row.id} is ${row.state}; only an interrupted command may be reconciled.`);
|
|
60
|
+
if (row.owner && row.lease_until > now)
|
|
61
|
+
throw Object.assign(new Error(`Command ${row.id} still has a live owner lease; reconciliation refused.`), { code: "command_live_lease" });
|
|
62
|
+
const evidence = commands.terminalEvidence(row.session_id, row.event_id), authoritativeCompleted = commands.terminalOutcome(row.session_id, row.event_id) === "completed";
|
|
63
|
+
if (options.decision === "confirm-completed" && !authoritativeCompleted && options.confirmWithoutEvidence !== row.id)
|
|
64
|
+
throw new Error(`No unambiguous completed terminal evidence exists. To explicitly confirm side effects, add --confirm-without-evidence ${row.id}.`);
|
|
65
|
+
const candidates = successorRows(store, row), selected = options.cancelSuccessors ? candidates : options.cancelSuccessorIds?.length ? options.cancelSuccessorIds.map(id => { const found = candidates.find(item => item.id === id); if (!found)
|
|
66
|
+
throw new Error(`Successor ${id} is not an unstarted FIFO successor of ${row.id}.`); return found; }) : [];
|
|
67
|
+
const liveSuccessor = selected.find(item => Boolean(item.owner && item.lease_until > now));
|
|
68
|
+
if (liveSuccessor)
|
|
69
|
+
throw Object.assign(new Error(`Selected successor ${liveSuccessor.id} still has a live owner lease; the entire reconciliation was refused.`), { code: "command_successor_live_lease", blockingCommandId: liveSuccessor.id, leaseUntil: liveSuccessor.lease_until });
|
|
70
|
+
const resultError = desired === "failed" ? "Operator marked interrupted durable message failed; command was not replayed." : null;
|
|
71
|
+
const plan = { applied: false, alreadyApplied: false, decision: options.decision, command: projection(row, desired, resultError), successors: selected.map(item => projection(item, "failed", "Cancelled during explicit predecessor reconciliation; command was never dispatched.")), evidence, healthBefore: commands.health(now), nextAction: `pibo debug message-queue inspect --session ${row.session_id}` };
|
|
72
|
+
if (!options.apply)
|
|
73
|
+
return plan;
|
|
74
|
+
const changed = Number(store.db.prepare("UPDATE message_commands SET state=?,error=?,owner=NULL,lease_until=0,updated_at=? WHERE id=? AND state='interrupted' AND token=? AND updated_at=?").run(desired, resultError, now, row.id, row.token, row.updated_at).changes);
|
|
75
|
+
if (changed !== 1)
|
|
76
|
+
throw Object.assign(new Error("Command changed during reconciliation; transaction rolled back."), { code: "command_snapshot_changed" });
|
|
77
|
+
for (const item of selected) {
|
|
78
|
+
const successorChanged = Number(store.db.prepare("UPDATE message_commands SET state='failed',error='Cancelled during explicit predecessor reconciliation; command was never dispatched.',owner=NULL,lease_until=0,updated_at=? WHERE id=? AND state IN ('accepted','waiting_slot') AND token=? AND updated_at=?").run(now, item.id, item.token, item.updated_at).changes);
|
|
79
|
+
if (successorChanged !== 1)
|
|
80
|
+
throw Object.assign(new Error(`Successor ${item.id} changed during reconciliation; transaction rolled back.`), { code: "command_snapshot_changed" });
|
|
81
|
+
}
|
|
82
|
+
const iso = new Date(now).toISOString();
|
|
83
|
+
// Session/navigation status is owned by current runtime and terminal product output.
|
|
84
|
+
// Reconciling a historical receipt must not overwrite a newer turn or live steer.
|
|
85
|
+
store.db.prepare("UPDATE telemetry_turns SET status=?,current_phase='reconciled',completed_at=COALESCE(completed_at,?),last_progress_at=?,updated_at=? WHERE pibo_session_id=? AND event_id=? AND status NOT IN ('completed','failed')").run(desired, iso, iso, iso, row.session_id, row.event_id);
|
|
86
|
+
options.beforeAudit?.();
|
|
87
|
+
const actor = (options.actor ?? process.env.USER ?? "operator").replace(/[^A-Za-z0-9_.@-]/g, "_").slice(0, 100) || "operator";
|
|
88
|
+
const audit = store.eventLog.appendEvent({ sessionId: row.session_id, roomId: row.room_id, topic: "pibo.audit", type: "durable_message_command.reconciled", source: "pibo-debug-cli", actorType: "operator", actorId: actor, eventId: `reconcile:${row.id}:${options.decision}`, idempotencyKey: `message-command-reconcile:${row.id}:${options.decision}`, retentionClass: "audit_event", previewText: `Durable command ${options.decision}`, attributes: { commandId: row.id, eventId: row.event_id, decision: options.decision, priorState: row.state, resultingState: desired, evidenceStreamIds: evidence.map(item => item.streamId), affectedSuccessorIds: selected.map(item => item.id), actor, source: "pibo-debug-cli", occurredAt: iso, replay: false } });
|
|
89
|
+
return { ...plan, applied: true, auditEventId: audit.eventId, health: commands.health(now) };
|
|
90
|
+
};
|
|
91
|
+
return options.apply ? store.transaction(action) : action();
|
|
92
|
+
}
|
|
93
|
+
export function formatMessageQueueInspection(result) {
|
|
94
|
+
const lines = ["Durable message queue", ` status: ${result.health.status}`, ` interrupted: ${result.health.interruptedPredecessors}`, ` FIFO blocked: ${result.health.blockedSuccessors}`, ` expired leases: ${result.health.expiredOwnedLeases}`];
|
|
95
|
+
for (const row of result.commands) {
|
|
96
|
+
lines.push(` stream=${row.streamId} ${row.id} context=${row.context} state=${row.state} delivery=${row.delivery} session=${row.sessionId} event=${row.eventId} owner=${row.owner ?? "-"} lease=${row.lease.fresh ? "fresh" : "stale/none"}${row.previousCommandId ? ` previous=${row.previousCommandId}` : ""}${row.nextCommandId ? ` next=${row.nextCommandId}` : ""}${row.blockedBy ? ` blockedBy=${row.blockedBy}` : ""}`);
|
|
97
|
+
if (row.terminalEvidence.length)
|
|
98
|
+
lines.push(` evidence: outcome=${row.terminalOutcome ?? "ambiguous"} ${row.terminalEvidence.map(item => `${item.type}@${item.streamId}`).join(", ")}`);
|
|
99
|
+
if (row.blocks.length)
|
|
100
|
+
lines.push(` blocks: ${row.blocks.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push("Next:", ...result.nextCommands.map(command => ` ${command}`));
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
export function formatMessageQueueReconciliation(result) {
|
|
106
|
+
const mode = "alreadyApplied" in result && result.alreadyApplied ? "already applied" : result.applied ? "applied" : "dry-run";
|
|
107
|
+
return [`Durable message reconciliation (${mode})`, ` command: ${result.command.id}`, ` transition: ${result.command.priorState} -> ${result.command.resultingState}`, ` successors: ${result.successors.map(item => item.id).join(", ") || "none"}`, ` replay: never`, ...("auditEventId" in result && result.auditEventId ? [` audit event: ${result.auditEventId}`] : []), ` next: ${result.nextAction}`].join("\n");
|
|
108
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { legacyOutputIdempotencyKey, outputPersistenceDeliveryKey } from "../data/ingest-service.js";
|
|
3
|
+
import { PiboDataStore } from "../data/pibo-store.js";
|
|
4
|
+
export function repairOutputCollision(input) {
|
|
5
|
+
if (!input.dataStore.exists)
|
|
6
|
+
throw new Error(`Debug store "pibo-data" not found at ${input.dataStore.path}`);
|
|
7
|
+
if (!input.reliabilityStore.exists)
|
|
8
|
+
throw new Error(`Debug store "reliability" not found at ${input.reliabilityStore.path}`);
|
|
9
|
+
if (input.apply && !input.keepExisting)
|
|
10
|
+
throw new Error("Collision apply requires --keep-existing; conflicting bodies are never selected automatically");
|
|
11
|
+
const reliability = new DatabaseSync(input.reliabilityStore.path, { readOnly: true });
|
|
12
|
+
let dead;
|
|
13
|
+
try {
|
|
14
|
+
dead = reliability.prepare("SELECT job_id AS jobId, payload_json AS payloadJson, last_error AS lastError, dead_at AS deadAt FROM pibo_dead_jobs WHERE job_id = ? AND queue IN ('output-persistence', 'output-persistence-cli')").get(input.jobId);
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
reliability.close();
|
|
18
|
+
}
|
|
19
|
+
if (!dead)
|
|
20
|
+
throw new Error(`Output-persistence dead letter "${input.jobId}" was not found`);
|
|
21
|
+
const collision = findCollisionEvent(dead.payloadJson, dead.lastError);
|
|
22
|
+
if (!collision)
|
|
23
|
+
throw new Error(`Dead letter "${input.jobId}" does not contain a bounded, valid collision delivery`);
|
|
24
|
+
const { event: incoming, key } = collision;
|
|
25
|
+
const data = new PiboDataStore(input.dataStore.path, { readOnly: !input.apply });
|
|
26
|
+
try {
|
|
27
|
+
const existing = data.db.prepare("SELECT stream_id AS streamId, session_id AS sessionId, event_id AS eventId, type, attributes_json AS attributesJson FROM event_log WHERE idempotency_key = ?").get(key);
|
|
28
|
+
if (!existing)
|
|
29
|
+
throw new Error(`Canonical output for collision key "${key}" was not found`);
|
|
30
|
+
const attrs = parseObject(existing.attributesJson);
|
|
31
|
+
const projections = inspectProjections(data.db, existing, incoming);
|
|
32
|
+
let auditStreamId;
|
|
33
|
+
let idempotent = false;
|
|
34
|
+
if (input.apply) {
|
|
35
|
+
const auditKey = `pibo.output.collision.reconcile:${input.jobId}:keep-existing`;
|
|
36
|
+
const prior = data.eventLog.findByIdempotencyKey(auditKey);
|
|
37
|
+
if (prior) {
|
|
38
|
+
auditStreamId = prior.streamId;
|
|
39
|
+
idempotent = true;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const at = input.now?.() ?? new Date().toISOString();
|
|
43
|
+
const sequence = Number(data.db.prepare("SELECT COALESCE(MAX(session_sequence), 0) + 1 AS value FROM event_log WHERE session_id = ?").get(existing.sessionId).value);
|
|
44
|
+
auditStreamId = data.eventLog.appendEvent({
|
|
45
|
+
sessionId: existing.sessionId, sessionSequence: sequence, topic: "pibo.audit", type: "pibo.output.collision_reconciled",
|
|
46
|
+
source: "pibo-debug-repair", actorType: "system", actorId: "pibo-debug-repair", eventId: existing.eventId ?? undefined,
|
|
47
|
+
idempotencyKey: auditKey, retentionClass: "audit_event", previewText: "Output collision reconciled by keeping existing canonical output",
|
|
48
|
+
attributes: { repairVersion: 1, deadJobId: input.jobId, outputIdempotencyKey: key, decision: "keep-existing", projections, sideEffectsReplayed: false },
|
|
49
|
+
createdAt: at, indexedAt: at,
|
|
50
|
+
}).streamId;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
resultType: "debug.repair.output-collision", mode: input.apply ? "apply" : "dry-run", jobId: input.jobId,
|
|
55
|
+
collisionKey: key, decision: "keep-existing", applied: Boolean(input.apply), idempotent,
|
|
56
|
+
conflict: {
|
|
57
|
+
...(typeof attrs.identityFingerprint === "string" ? { existingFingerprint: attrs.identityFingerprint } : {}),
|
|
58
|
+
...(collisionFingerprint(data.db, existing.sessionId, key) ? { incomingFingerprint: collisionFingerprint(data.db, existing.sessionId, key) } : {}),
|
|
59
|
+
bodyCompared: false,
|
|
60
|
+
},
|
|
61
|
+
projections, ...(auditStreamId !== undefined ? { auditStreamId } : {}),
|
|
62
|
+
warnings: [
|
|
63
|
+
"The incoming body is never compared, copied, or replayed by this repair.",
|
|
64
|
+
"Apply records an idempotent keep-existing decision and does not delete the dead letter or replay side effects.",
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
data.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function inspectProjections(db, existing, incoming) {
|
|
73
|
+
const transcriptCount = incoming.type === "assistant_message" ? projectionCount(db, "SELECT COUNT(*) AS count FROM chat_messages WHERE session_id = ? AND source_stream_id = ? AND role = 'assistant'", existing.sessionId, existing.streamId) : 0;
|
|
74
|
+
const traceCount = projectionCount(db, "SELECT COUNT(*) AS count FROM observations WHERE session_id = ? AND event_stream_id = ?", existing.sessionId, existing.streamId);
|
|
75
|
+
const session = db.prepare("SELECT room_id AS roomId FROM sessions WHERE id = ?").get(existing.sessionId);
|
|
76
|
+
const navigationCount = session?.roomId ? projectionCount(db, "SELECT COUNT(*) AS count FROM session_navigation WHERE session_id = ? AND room_id = ?", existing.sessionId, session.roomId) : 0;
|
|
77
|
+
return {
|
|
78
|
+
transcript: incoming.type !== "assistant_message" ? "not_applicable" : projectionState(transcriptCount),
|
|
79
|
+
trace: projectionState(traceCount),
|
|
80
|
+
navigation: !session?.roomId ? "not_applicable" : projectionState(navigationCount),
|
|
81
|
+
command: "not_applicable",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function projectionState(count) { return count === 1 ? "complete" : count === 0 ? "missing" : "inconsistent"; }
|
|
85
|
+
function projectionCount(db, sql, ...values) {
|
|
86
|
+
try {
|
|
87
|
+
return Number(db.prepare(sql).get(...values).count);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function collisionFingerprint(db, sessionId, key) {
|
|
94
|
+
const row = db.prepare("SELECT json_extract(attributes_json, '$.incomingFingerprint') AS value FROM event_log WHERE session_id = ? AND type = 'pibo.output.identity_collision' AND json_extract(attributes_json, '$.outputIdempotencyKey') = ? ORDER BY stream_id DESC LIMIT 1").get(sessionId, key);
|
|
95
|
+
return row?.value ?? undefined;
|
|
96
|
+
}
|
|
97
|
+
function findCollisionEvent(payloadJson, lastError) {
|
|
98
|
+
let value;
|
|
99
|
+
try {
|
|
100
|
+
value = JSON.parse(payloadJson);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const key = lastError?.match(/Pibo output identity collision for "([^"]+)"/)?.[1];
|
|
106
|
+
const candidates = [];
|
|
107
|
+
const visit = (item, depth) => {
|
|
108
|
+
if (depth > 6 || candidates.length > 200 || !item || typeof item !== "object")
|
|
109
|
+
return;
|
|
110
|
+
if (!Array.isArray(item) && "type" in item && "piboSessionId" in item)
|
|
111
|
+
candidates.push(item);
|
|
112
|
+
if (Array.isArray(item))
|
|
113
|
+
for (const child of item.slice(0, 100))
|
|
114
|
+
visit(child, depth + 1);
|
|
115
|
+
else
|
|
116
|
+
for (const field of ["state", "deliveries", "event", "payload"])
|
|
117
|
+
visit(item[field], depth + 1);
|
|
118
|
+
};
|
|
119
|
+
visit(value, 0);
|
|
120
|
+
if (!key)
|
|
121
|
+
return undefined;
|
|
122
|
+
const event = candidates.find((item) => {
|
|
123
|
+
try {
|
|
124
|
+
return outputPersistenceDeliveryKey(item) === key || legacyOutputIdempotencyKey(item) === key;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return event ? { event, key } : undefined;
|
|
131
|
+
}
|
|
132
|
+
function parseObject(value) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(value);
|
|
135
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -188,7 +188,10 @@ export function inspectOutputIntegrity(input) {
|
|
|
188
188
|
findings.push(...queryRows(db, `
|
|
189
189
|
SELECT session_id AS sessionId, event_id AS eventId, stream_id AS streamId,
|
|
190
190
|
created_at AS createdAt,
|
|
191
|
-
${COLLISION_KEY_SQL} AS idempotencyKey
|
|
191
|
+
${COLLISION_KEY_SQL} AS idempotencyKey,
|
|
192
|
+
json_extract(attributes_json, '$.existingProvenance') AS existingProvenance,
|
|
193
|
+
json_extract(attributes_json, '$.incomingProvenance') AS incomingProvenance,
|
|
194
|
+
json_extract(attributes_json, '$.fieldDifferences') AS fieldDifferences
|
|
192
195
|
FROM event_log
|
|
193
196
|
WHERE type = 'pibo.output.identity_collision' ${scope.sql}
|
|
194
197
|
ORDER BY stream_id DESC
|
|
@@ -200,6 +203,9 @@ export function inspectOutputIntegrity(input) {
|
|
|
200
203
|
streamId: row.streamId,
|
|
201
204
|
lastAt: row.createdAt,
|
|
202
205
|
...(row.idempotencyKey ? { idempotencyKey: row.idempotencyKey } : {}),
|
|
206
|
+
...(parseRecord(row.existingProvenance) ? { existingProvenance: parseRecord(row.existingProvenance) } : {}),
|
|
207
|
+
...(parseDifferences(row.fieldDifferences).length ? { fieldDifferences: parseDifferences(row.fieldDifferences) } : {}),
|
|
208
|
+
...(parseRecord(row.incomingProvenance) ? { incomingProvenance: parseRecord(row.incomingProvenance) } : {}),
|
|
203
209
|
})));
|
|
204
210
|
findings.push(...queryRows(db, `
|
|
205
211
|
WITH output_keys AS (
|
|
@@ -332,6 +338,7 @@ export function inspectOutputIntegrity(input) {
|
|
|
332
338
|
return {
|
|
333
339
|
resultType: "debug.integrity.output",
|
|
334
340
|
readOnly: true,
|
|
341
|
+
health: { status: Math.max(identityCollisions, deadIdentityCollisions) >= 1 ? "degraded" : "healthy", threshold: 1, observedCollisions: Math.max(identityCollisions, deadIdentityCollisions) },
|
|
335
342
|
scope: {
|
|
336
343
|
...(input.piboSessionId ? { piboSessionId: input.piboSessionId } : {}),
|
|
337
344
|
...(input.since ? { since: input.since } : {}),
|
|
@@ -411,6 +418,8 @@ export function formatOutputIntegrityAudit(audit) {
|
|
|
411
418
|
const lines = [
|
|
412
419
|
`pibo debug integrity output`,
|
|
413
420
|
`readOnly\t${audit.readOnly}`,
|
|
421
|
+
`health\t${audit.health.status}`,
|
|
422
|
+
`collisionThreshold\t${audit.health.threshold}`,
|
|
414
423
|
`session\t${scope}`,
|
|
415
424
|
...(audit.scope.since ? [`since\t${audit.scope.since}`] : []),
|
|
416
425
|
...(audit.scope.before ? [`before\t${audit.scope.before}`] : []),
|
|
@@ -560,6 +569,33 @@ function countRows(db, sql, params) {
|
|
|
560
569
|
function tableExists(db, table) {
|
|
561
570
|
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table));
|
|
562
571
|
}
|
|
572
|
+
function parseRecord(value) {
|
|
573
|
+
if (!value)
|
|
574
|
+
return undefined;
|
|
575
|
+
try {
|
|
576
|
+
const parsed = JSON.parse(value);
|
|
577
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function parseDifferences(value) {
|
|
584
|
+
if (!value)
|
|
585
|
+
return [];
|
|
586
|
+
try {
|
|
587
|
+
const parsed = JSON.parse(value);
|
|
588
|
+
if (!Array.isArray(parsed))
|
|
589
|
+
return [];
|
|
590
|
+
return parsed.filter((item) => Boolean(item) && typeof item === "object" && typeof item.field === "string" && typeof item.change === "string").slice(0, 24);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function provenanceLabel(value) {
|
|
597
|
+
return value ? `${String(value.producer ?? "unknown")}/${String(value.projection ?? "unknown")}/${String(value.phase ?? "unknown")}` : "unknown";
|
|
598
|
+
}
|
|
563
599
|
function pendingJobFinding(row) {
|
|
564
600
|
return {
|
|
565
601
|
kind: "pending_output_job",
|
|
@@ -597,7 +633,7 @@ function findingDetail(finding) {
|
|
|
597
633
|
if (finding.kind === "tool_lifecycle")
|
|
598
634
|
return `tool=${finding.toolCallId ?? "-"},ordinal=${finding.toolInvocationOrdinal ?? 0},called=${finding.called ?? 0},started=${finding.started ?? 0},finished=${finding.finished ?? 0}`;
|
|
599
635
|
if (finding.kind === "identity_collision")
|
|
600
|
-
return finding.idempotencyKey ?? `stream=${finding.streamId ?? "-"}`;
|
|
636
|
+
return `${finding.idempotencyKey ?? `stream=${finding.streamId ?? "-"}`},existing=${provenanceLabel(finding.existingProvenance)},incoming=${provenanceLabel(finding.incomingProvenance)},fields=${finding.fieldDifferences?.map((item) => `${item.field}:${item.change}`).join("|") ?? "unknown"}`;
|
|
601
637
|
if (finding.kind === "output_key_reuse")
|
|
602
638
|
return `uses=${finding.uses ?? 0},key=${finding.idempotencyKey ?? "-"}`;
|
|
603
639
|
if (finding.kind === "session_trace_status")
|
|
@@ -43,6 +43,7 @@ export function repairOutputTurn(input) {
|
|
|
43
43
|
actorId: "pibo-debug-repair",
|
|
44
44
|
event: current.terminalEvent,
|
|
45
45
|
createdAt,
|
|
46
|
+
persistenceProvenance: { producer: "debug-repair", projection: "product-history", phase: "operator-repair" },
|
|
46
47
|
});
|
|
47
48
|
const audit = data.eventLog.appendEvent({
|
|
48
49
|
sessionId: input.piboSessionId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { createStorageBackup, readStorageBackupManifest, verifyStorageBackup, restoreStorageBackup } from "../data/storage-backup.js";
|
|
3
|
+
import { recordStorageMaintenance } from "../data/storage-maintenance.js";
|
|
3
4
|
export async function runStorageBackupCli(args) {
|
|
4
5
|
const action = args[0];
|
|
5
6
|
if (!action || args.includes("--help") || args.includes("-h")) {
|
|
@@ -19,12 +20,19 @@ No source deletion, vacuum, or overwrite of an existing restore destination.`);
|
|
|
19
20
|
const required = (name) => { const value = values[name]; if (typeof value !== "string" || !value)
|
|
20
21
|
throw Error(`Missing --${name}`); return value; };
|
|
21
22
|
let result;
|
|
22
|
-
if (action === "create")
|
|
23
|
-
|
|
23
|
+
if (action === "create") {
|
|
24
|
+
const source = required("source");
|
|
25
|
+
result = await createStorageBackup({ source, payloadRoot: required("payload-root"), destination: required("destination"), resume: values.resume, maxBytes: values["max-bytes"] ? Number(values["max-bytes"]) : undefined, maxPayloads: values["max-payloads"] ? Number(values["max-payloads"]) : undefined, maxMilliseconds: values["max-ms"] ? Number(values["max-ms"]) : undefined, maxWalGrowthBytes: values["max-wal-growth"] ? Number(values["max-wal-growth"]) : undefined });
|
|
26
|
+
recordStorageMaintenance(source, { operation: "backup", at: new Date().toISOString(), status: result.status, destination: required("destination") });
|
|
27
|
+
}
|
|
24
28
|
else if (action === "inspect")
|
|
25
29
|
result = await readStorageBackupManifest(required("archive"));
|
|
26
|
-
else if (action === "verify")
|
|
27
|
-
|
|
30
|
+
else if (action === "verify") {
|
|
31
|
+
const archive = required("archive");
|
|
32
|
+
result = await verifyStorageBackup(archive, AbortSignal.timeout(60000));
|
|
33
|
+
const manifest = await readStorageBackupManifest(archive);
|
|
34
|
+
recordStorageMaintenance(manifest.source, { operation: "backup", at: new Date().toISOString(), status: "verified", archive });
|
|
35
|
+
}
|
|
28
36
|
else if (action === "restore")
|
|
29
37
|
result = await restoreStorageBackup(required("archive"), required("destination"), AbortSignal.timeout(60000));
|
|
30
38
|
else
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { piboHomePath } from "../core/pibo-home.js";
|
|
3
|
+
import { checkpointStorage, inspectStorageStatus, maintainStorageRetention, verifyStorage } from "../data/storage-maintenance.js";
|
|
4
|
+
import { resolveDebugStore } from "./stores.js";
|
|
5
|
+
export async function runStorageMaintenanceCli(args) {
|
|
6
|
+
const action = args[0];
|
|
7
|
+
if (!action || args.includes("--help") || args.includes("-h")) {
|
|
8
|
+
printHelp();
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const { values } = parseArgs({ args: args.slice(1), strict: true, options: {
|
|
12
|
+
store: { type: "string" }, path: { type: "string" }, json: { type: "boolean" },
|
|
13
|
+
"timeout-ms": { type: "string" }, full: { type: "boolean" }, quick: { type: "boolean" },
|
|
14
|
+
apply: { type: "boolean" }, "dry-run": { type: "boolean" }, mode: { type: "string" },
|
|
15
|
+
before: { type: "string" }, limit: { type: "string" }, "payload-root": { type: "string" },
|
|
16
|
+
"database-warn-bytes": { type: "string" }, "wal-warn-bytes": { type: "string" }, "payload-warn-bytes": { type: "string" },
|
|
17
|
+
} });
|
|
18
|
+
if (values.apply && values["dry-run"])
|
|
19
|
+
throw new Error("Choose either --dry-run or --apply");
|
|
20
|
+
const path = typeof values.path === "string" ? values.path : resolveStorePath(values.store);
|
|
21
|
+
let result;
|
|
22
|
+
if (action === "status" || action === "doctor") {
|
|
23
|
+
result = inspectStorageStatus({ path, databaseWarnBytes: numberValue(values["database-warn-bytes"]), walWarnBytes: numberValue(values["wal-warn-bytes"]), payloadWarnBytes: numberValue(values["payload-warn-bytes"]) });
|
|
24
|
+
}
|
|
25
|
+
else if (action === "verify") {
|
|
26
|
+
if (values.full && values.quick)
|
|
27
|
+
throw new Error("Choose either --quick or --full");
|
|
28
|
+
result = await verifyStorage({ path, mode: values.full ? "full" : "quick", timeoutMs: numberValue(values["timeout-ms"]) });
|
|
29
|
+
}
|
|
30
|
+
else if (action === "checkpoint") {
|
|
31
|
+
const mode = values.mode ?? "passive";
|
|
32
|
+
if (!new Set(["passive", "restart", "truncate"]).has(mode))
|
|
33
|
+
throw new Error("Checkpoint --mode must be passive, restart, or truncate");
|
|
34
|
+
result = checkpointStorage({ path, mode: mode, apply: values.apply });
|
|
35
|
+
}
|
|
36
|
+
else if (action === "retention") {
|
|
37
|
+
if (typeof values.before !== "string")
|
|
38
|
+
throw new Error("Storage retention requires --before <iso-date>");
|
|
39
|
+
result = await maintainStorageRetention({ path, before: values.before, limit: numberValue(values.limit), apply: values.apply, payloadRoot: typeof values["payload-root"] === "string" ? values["payload-root"] : piboHomePath("payloads") });
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
throw new Error(`Unknown storage action "${action}"; run pibo debug storage --help`);
|
|
43
|
+
if (values.json)
|
|
44
|
+
console.log(JSON.stringify(result, null, 2));
|
|
45
|
+
else
|
|
46
|
+
console.log(formatText(result));
|
|
47
|
+
}
|
|
48
|
+
function resolveStorePath(value) {
|
|
49
|
+
if (!value || value === "pibo-data")
|
|
50
|
+
return resolveDebugStore("pibo-data").path;
|
|
51
|
+
if (value === "reliability")
|
|
52
|
+
return resolveDebugStore("reliability").path;
|
|
53
|
+
throw new Error("--store must be pibo-data or reliability; use --path for another SQLite store");
|
|
54
|
+
}
|
|
55
|
+
function numberValue(value) { if (value === undefined)
|
|
56
|
+
return undefined; const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < 0)
|
|
57
|
+
throw new Error(`Invalid numeric value "${value}"`); return parsed; }
|
|
58
|
+
function formatText(value) { const object = value; const lines = [`pibo debug storage ${String(object.resultType ?? "result").split(".").at(-1)}`]; for (const [key, item] of Object.entries(object)) {
|
|
59
|
+
if (key === "resultType" || typeof item === "object")
|
|
60
|
+
continue;
|
|
61
|
+
lines.push(`${key}\t${String(item)}`);
|
|
62
|
+
} if (Array.isArray(object.warnings))
|
|
63
|
+
lines.push(...object.warnings.map((warning) => `warning\t${String(warning)}`)); lines.push("", "Use --json for bounded row, size, projection, and progress detail."); return lines.join("\n"); }
|
|
64
|
+
function printHelp() {
|
|
65
|
+
console.log(`pibo debug storage - bounded SQLite health and maintenance
|
|
66
|
+
|
|
67
|
+
Commands:
|
|
68
|
+
status [--store pibo-data|reliability | --path <sqlite>] [--json]
|
|
69
|
+
doctor [--store pibo-data|reliability | --path <sqlite>] [--json]
|
|
70
|
+
verify [--quick|--full] [--timeout-ms <n>] [--store <name>|--path <sqlite>] [--json]
|
|
71
|
+
checkpoint [--mode passive|restart|truncate] [--dry-run|--apply] [--store <name>|--path <sqlite>] [--json]
|
|
72
|
+
retention --before <iso-date> [--limit <1..10000>] [--dry-run|--apply] [--payload-root <dir>] [--store <name>|--path <sqlite>] [--json]
|
|
73
|
+
|
|
74
|
+
Status is read-only and reports DB/WAL/SHM/payload size, pages/freelist, bounded row counts, payload-reference integrity, maintenance metadata, and degraded thresholds.
|
|
75
|
+
Verification runs in a cancellable worker. Timeout is partial and never healthy. Quick/full checks can still be I/O intensive; use backup verification or offline checks when the online budget is insufficient.
|
|
76
|
+
Checkpoint and retention default to dry-run. Apply is bounded and audited in the database maintenance sidecar. Retention deletes only eligible live_delta rows; chat messages, audit events, idempotency evidence, and referenced payloads are preserved.
|
|
77
|
+
`);
|
|
78
|
+
}
|
package/dist/gateway/cli.js
CHANGED
|
@@ -231,14 +231,40 @@ function activeRun(value) {
|
|
|
231
231
|
piboSessionId: stringValue(obj.controllerPiboSessionId) ?? stringValue(obj.piboSessionId),
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
|
+
function runJobReliability(value) {
|
|
235
|
+
const obj = objectValue(value);
|
|
236
|
+
if (!obj || (obj.status !== "ok" && obj.status !== "degraded")
|
|
237
|
+
|| !Number.isInteger(obj.expiredOrphanRunJobs) || Number(obj.expiredOrphanRunJobs) < 0
|
|
238
|
+
|| !Number.isInteger(obj.orphanRunDeadLetters) || Number(obj.orphanRunDeadLetters) < 0)
|
|
239
|
+
return undefined;
|
|
240
|
+
return {
|
|
241
|
+
status: obj.status,
|
|
242
|
+
expiredOrphanRunJobs: Number(obj.expiredOrphanRunJobs),
|
|
243
|
+
orphanRunDeadLetters: Number(obj.orphanRunDeadLetters),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function durableMessageQueueStatus(value) {
|
|
247
|
+
const obj = objectValue(value);
|
|
248
|
+
if (!obj)
|
|
249
|
+
return undefined;
|
|
250
|
+
const status = obj.status === "healthy" || obj.status === "degraded" || obj.status === "ambiguous" ? obj.status : undefined;
|
|
251
|
+
const storage = objectValue(obj.storage);
|
|
252
|
+
const counts = Array.isArray(obj.counts) ? obj.counts.flatMap(value => { const row = objectValue(value); return row ? [{ state: stringValue(row.state), delivery: stringValue(row.delivery), count: numberValue(row.count), bytes: numberValue(row.bytes) }] : []; }) : undefined;
|
|
253
|
+
const affectedScopes = Array.isArray(obj.affectedScopes) ? obj.affectedScopes.flatMap(value => { const row = objectValue(value); return row ? [{ sessionId: stringValue(row.sessionId), roomId: stringValue(row.roomId), blockingCommandId: stringValue(row.blockingCommandId), blockedSince: numberValue(row.blockedSince), blockedSuccessors: numberValue(row.blockedSuccessors) }] : []; }) : undefined;
|
|
254
|
+
return { status, storage: storage ? { available: booleanValue(storage.available), error: stringValue(storage.error) } : undefined, counts, interruptedPredecessors: numberValue(obj.interruptedPredecessors), blockedSuccessors: numberValue(obj.blockedSuccessors), expiredOwnedLeases: numberValue(obj.expiredOwnedLeases), oldestDispatchableWaitMs: numberValue(obj.oldestDispatchableWaitMs), oldestBlockedWaitMs: numberValue(obj.oldestBlockedWaitMs), affectedScopes, degradedReasons: Array.isArray(obj.degradedReasons) && obj.degradedReasons.every(v => typeof v === "string") ? obj.degradedReasons : undefined, admissionCapacity: obj.admissionCapacity, bounded: obj.bounded };
|
|
255
|
+
}
|
|
234
256
|
function parseGatewaySafetyPayload(payload, reachable) {
|
|
235
257
|
const obj = objectValue(payload);
|
|
236
258
|
const mode = obj && (obj.mode === "dev" || obj.mode === "prod" || obj.mode === "fallback") ? obj.mode : "unknown";
|
|
237
259
|
const runtimeStatuses = Array.isArray(obj?.runtimeStatuses) ? obj.runtimeStatuses.map(runtimeStatus).filter((item) => Boolean(item)) : [];
|
|
238
260
|
const activeRuns = Array.isArray(obj?.activeRuns) ? obj.activeRuns.map(activeRun).filter((item) => Boolean(item)) : [];
|
|
261
|
+
const reliability = obj?.reliability === undefined ? undefined : runJobReliability(obj.reliability);
|
|
262
|
+
const durableMessageQueue = durableMessageQueueStatus(obj?.durableMessageQueue);
|
|
239
263
|
const incomplete = !Array.isArray(obj?.runtimeStatuses) || !Array.isArray(obj?.activeRuns)
|
|
240
|
-
|| runtimeStatuses.length !== obj.runtimeStatuses.length || activeRuns.length !== obj.activeRuns.length
|
|
241
|
-
|
|
264
|
+
|| runtimeStatuses.length !== obj.runtimeStatuses.length || activeRuns.length !== obj.activeRuns.length
|
|
265
|
+
|| (obj?.reliability !== undefined && !reliability)
|
|
266
|
+
|| (obj?.durableMessageQueue !== undefined && !durableMessageQueue?.status);
|
|
267
|
+
return { reachable, mode, generation: stringValue(obj?.generation), health: obj?.health, runtimeStatuses, activeRuns, reliability, durableMessageQueue, ambiguous: incomplete || booleanValue(obj?.ambiguous) };
|
|
242
268
|
}
|
|
243
269
|
export function checkActiveWork(status, target = "web") {
|
|
244
270
|
const reasons = [];
|
|
@@ -337,7 +363,8 @@ function printSafetyStatus(target, status) {
|
|
|
337
363
|
console.log(` mode: ${status.mode}`);
|
|
338
364
|
if (status.error)
|
|
339
365
|
console.log(` status error: ${status.error}`);
|
|
340
|
-
console.log(
|
|
366
|
+
console.log(" runtime queue layer:");
|
|
367
|
+
console.log(` sessions: ${status.runtimeStatuses.length}`);
|
|
341
368
|
for (const session of status.runtimeStatuses) {
|
|
342
369
|
console.log(` ${session.piboSessionId ?? "unknown"}: processing=${session.processing === true} streaming=${session.streaming === true} queued=${session.queuedMessages ?? 0}`);
|
|
343
370
|
if (session.activeEventId)
|
|
@@ -359,6 +386,31 @@ function printSafetyStatus(target, status) {
|
|
|
359
386
|
console.log(` active yielded runs: ${status.activeRuns.length}`);
|
|
360
387
|
for (const run of status.activeRuns)
|
|
361
388
|
console.log(` ${run.runId ?? "unknown"}: ${run.status ?? "active"}${run.toolName ? ` (${run.toolName})` : ""} session=${run.piboSessionId ?? "unknown"}`);
|
|
389
|
+
if (status.reliability) {
|
|
390
|
+
console.log(` run-job reliability: ${status.reliability.status}`);
|
|
391
|
+
console.log(` expired orphan jobs: ${status.reliability.expiredOrphanRunJobs}`);
|
|
392
|
+
console.log(` orphan DLQ records: ${status.reliability.orphanRunDeadLetters}`);
|
|
393
|
+
}
|
|
394
|
+
console.log(" durable message queue layer:");
|
|
395
|
+
const durable = status.durableMessageQueue;
|
|
396
|
+
if (!durable)
|
|
397
|
+
console.log(" status: unavailable (gateway app did not report this layer)");
|
|
398
|
+
else {
|
|
399
|
+
console.log(` status: ${durable.status ?? "ambiguous"}`);
|
|
400
|
+
console.log(` storage: ${durable.storage?.available === true ? "available" : "unavailable"}${durable.storage?.error ? ` (${durable.storage.error})` : ""}`);
|
|
401
|
+
for (const row of durable.counts ?? [])
|
|
402
|
+
console.log(` ${row.delivery ?? "unknown"}.${row.state ?? "unknown"}: count=${row.count ?? 0} bytes=${row.bytes ?? 0}`);
|
|
403
|
+
console.log(` interrupted predecessors: ${durable.interruptedPredecessors ?? 0}`);
|
|
404
|
+
console.log(` FIFO-blocked successors: ${durable.blockedSuccessors ?? 0}`);
|
|
405
|
+
console.log(` expired owned leases: ${durable.expiredOwnedLeases ?? 0}`);
|
|
406
|
+
console.log(` oldest dispatchable wait: ${durable.oldestDispatchableWaitMs ?? 0}ms`);
|
|
407
|
+
console.log(` oldest blocked wait: ${durable.oldestBlockedWaitMs ?? 0}ms`);
|
|
408
|
+
for (const scope of durable.affectedScopes ?? [])
|
|
409
|
+
console.log(` affected session=${scope.sessionId ?? "unknown"} room=${scope.roomId ?? "unknown"} blocker=${scope.blockingCommandId ?? "unknown"} successors=${scope.blockedSuccessors ?? 0}`);
|
|
410
|
+
for (const reason of durable.degradedReasons ?? [])
|
|
411
|
+
console.log(` degraded: ${reason}`);
|
|
412
|
+
}
|
|
413
|
+
console.log(" next: pibo debug message-queue");
|
|
362
414
|
}
|
|
363
415
|
function managerRequiresShell(command) {
|
|
364
416
|
return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
|
|
@@ -385,8 +437,11 @@ async function waitForManagedGatewayHealth(target) {
|
|
|
385
437
|
async function runManagedGatewayCommand(target, command, args, argv = process.argv) {
|
|
386
438
|
if (command === "status" || command === "doctor") {
|
|
387
439
|
const status = await readGatewaySafetyStatus(target);
|
|
388
|
-
|
|
389
|
-
|
|
440
|
+
if (args.includes("--json"))
|
|
441
|
+
console.log(JSON.stringify({ ...status, nextCommands: [`pibo gateway ${target} doctor`, `pibo debug message-queue`] }, null, 2));
|
|
442
|
+
else
|
|
443
|
+
printSafetyStatus(target, status);
|
|
444
|
+
if (target === "web" && !args.includes("--json")) {
|
|
390
445
|
const active = checkActiveWork(status, target);
|
|
391
446
|
if (active.unsafe) {
|
|
392
447
|
console.log(" restart safety: blocked");
|
|
@@ -397,8 +452,16 @@ async function runManagedGatewayCommand(target, command, args, argv = process.ar
|
|
|
397
452
|
console.log(" restart safety: idle");
|
|
398
453
|
printRestartApproval(status);
|
|
399
454
|
}
|
|
400
|
-
if (command === "doctor")
|
|
401
|
-
process.exitCode = status.reachable
|
|
455
|
+
if (command === "doctor") {
|
|
456
|
+
process.exitCode = status.reachable
|
|
457
|
+
&& !status.error
|
|
458
|
+
&& status.mode === expectedMode(target)
|
|
459
|
+
&& status.reliability?.status !== "degraded"
|
|
460
|
+
&& status.durableMessageQueue?.status === "healthy"
|
|
461
|
+
&& status.durableMessageQueue.storage?.available === true
|
|
462
|
+
? 0
|
|
463
|
+
: 1;
|
|
464
|
+
}
|
|
402
465
|
return true;
|
|
403
466
|
}
|
|
404
467
|
if (command === "start") {
|
|
@@ -730,6 +793,7 @@ Commands:
|
|
|
730
793
|
dev doctor Check dev gateway health
|
|
731
794
|
|
|
732
795
|
Options:
|
|
796
|
+
--json Print status or doctor output as JSON with next discovery commands
|
|
733
797
|
--force --confirm <snapshot-token>
|
|
734
798
|
Restart only the work explicitly approved from web status
|
|
735
799
|
|
package/dist/gateway/server.js
CHANGED
|
@@ -427,6 +427,7 @@ export class PiboGatewayServer {
|
|
|
427
427
|
listSessionRuntimeStatuses: () => this.requireRouter().listSessionRuntimeStatuses(),
|
|
428
428
|
getRuntimeCapacityStatus: () => this.requireRouter().getRuntimeCapacityStatus(),
|
|
429
429
|
listRuns: (options) => this.requireRouter().listRuns(options),
|
|
430
|
+
getRunJobReliabilityStatus: () => this.requireRouter().getRunJobReliabilityStatus(),
|
|
430
431
|
snapshotSignalSession: (piboSessionId) => this.requireRouter().snapshotSignalSession(piboSessionId),
|
|
431
432
|
snapshotSignalTree: (rootPiboSessionId) => this.requireRouter().snapshotSignalTree(rootPiboSessionId),
|
|
432
433
|
snapshotSignalStatuses: () => this.requireRouter().snapshotSignalStatuses(),
|