@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.
Files changed (46) hide show
  1. package/dist/agent-runtime/routed-session.js +80 -19
  2. package/dist/agent-runtimes/codex-native/turn.js +27 -3
  3. package/dist/apps/chat/data/chat-data-mappers.js +8 -1
  4. package/dist/apps/chat/data/read-state-service.js +24 -3
  5. package/dist/apps/chat/message-command-dispatcher.js +16 -1
  6. package/dist/apps/chat/web-app.js +40 -19
  7. package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-B-auLrzD.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-BA_dsINH.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-eJZar_0-.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-wNNR2Bci.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-zcmEsIEp.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-DEkbN5Vo.js} +43 -43
  13. package/dist/apps/chat-ui/assets/index-DZK6Tzil.css +1 -0
  14. package/dist/apps/chat-ui/index.html +2 -2
  15. package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DZgW1fCB.js} +11 -11
  16. package/dist/apps/chat-vscode-web/index.html +1 -1
  17. package/dist/cli-session/localSessionSource.js +3 -2
  18. package/dist/core/output-render-sequence.js +63 -6
  19. package/dist/core/session-router.js +99 -11
  20. package/dist/data/async-chat-storage.js +7 -2
  21. package/dist/data/bounded-worker-client.js +1 -1
  22. package/dist/data/chat-read-projections.js +4 -4
  23. package/dist/data/chat-storage-worker.js +24 -3
  24. package/dist/data/ingest-service.js +73 -4
  25. package/dist/data/message-command-store.js +153 -11
  26. package/dist/data/schema.js +24 -3
  27. package/dist/data/storage-maintenance.js +344 -0
  28. package/dist/data/storage-verification-worker.js +25 -0
  29. package/dist/debug/index.js +155 -1
  30. package/dist/debug/message-queue.js +108 -0
  31. package/dist/debug/output-collision-repair.js +140 -0
  32. package/dist/debug/output-integrity.js +38 -2
  33. package/dist/debug/output-repair.js +1 -0
  34. package/dist/debug/storage-backup.js +12 -4
  35. package/dist/debug/storage-maintenance.js +78 -0
  36. package/dist/gateway/cli.js +71 -7
  37. package/dist/gateway/server.js +1 -0
  38. package/dist/reliability/store.js +119 -23
  39. package/dist/session-ui/terminalRows.js +7 -8
  40. package/dist/sessions/pibo-data-store.js +18 -14
  41. package/dist/shared/trace-event-projection.js +13 -3
  42. package/dist/web/channel.js +114 -12
  43. package/dist/web/http.js +105 -44
  44. package/npm-shrinkwrap.json +2 -2
  45. package/package.json +1 -1
  46. 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
- result = await createStorageBackup({ source: required("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 });
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
- result = await verifyStorageBackup(required("archive"), AbortSignal.timeout(60000));
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
+ }
@@ -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
- return { reachable, mode, generation: stringValue(obj?.generation), health: obj?.health, runtimeStatuses, activeRuns, ambiguous: incomplete || booleanValue(obj?.ambiguous) };
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(` runtime sessions: ${status.runtimeStatuses.length}`);
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
- printSafetyStatus(target, status);
389
- if (target === "web") {
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 && !status.error && status.mode === expectedMode(target) ? 0 : 1;
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
 
@@ -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(),