@pasko70/pibo 3.4.4 → 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 +111 -25
- package/dist/agent-runtimes/codex-native/turn.js +27 -3
- package/dist/agent-runtimes/pi/adapter.js +1 -0
- package/dist/agent-runtimes/pi/routed-session.js +2 -2
- package/dist/apps/chat/bounded-event-stream.js +98 -0
- package/dist/apps/chat/chat-settings-routes.js +3 -3
- package/dist/apps/chat/data/chat-data-mappers.js +40 -14
- package/dist/apps/chat/data/event-command-service.js +30 -21
- package/dist/apps/chat/data/history-query-service.js +34 -25
- package/dist/apps/chat/data/read-state-service.js +37 -3
- package/dist/apps/chat/data/session-query-service.js +19 -11
- package/dist/apps/chat/data/timeline-query-service.js +19 -7
- package/dist/apps/chat/message-command-dispatcher.js +149 -0
- package/dist/apps/chat/output-compactor.js +9 -0
- package/dist/apps/chat/output-event-policy.js +9 -1
- package/dist/apps/chat/stream.js +21 -3
- package/dist/apps/chat/telemetry-retention-service.js +113 -8
- package/dist/apps/chat/trace-response-cache.js +46 -0
- package/dist/apps/chat/trace-v2.js +12 -6
- package/dist/apps/chat/trace.js +1 -1
- package/dist/apps/chat/web-app.js +645 -297
- package/dist/apps/chat-ui/assets/{dist-V06sfuZa.js → dist-B-auLrzD.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-CH3SvpYV.js → dist-BA_dsINH.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-23lt_7qm.js → dist-eJZar_0-.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-Bf2KScPo.js → dist-wNNR2Bci.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DsgL8w-W.js → dist-zcmEsIEp.js} +1 -1
- package/dist/apps/chat-ui/assets/index-DEkbN5Vo.js +229 -0
- 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-DZgW1fCB.js +44 -0
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/cli-session/localSessionSource.js +3 -2
- package/dist/compute/pool/seeds.js +25 -3
- package/dist/core/events.js +4 -0
- package/dist/core/output-render-sequence.js +65 -6
- package/dist/core/provider-capacity.js +33 -0
- package/dist/core/provider-telemetry.js +21 -6
- package/dist/core/runtime-capacity.js +174 -0
- package/dist/core/runtime-telemetry.js +40 -12
- package/dist/core/session-router.js +178 -13
- package/dist/data/async-chat-reads.js +38 -0
- package/dist/data/async-chat-storage.js +96 -0
- package/dist/data/async-telemetry-maintenance.js +9 -0
- package/dist/data/bounded-worker-client.js +256 -0
- package/dist/data/chat-read-projections.js +159 -0
- package/dist/data/chat-read-worker.js +73 -0
- package/dist/data/chat-storage-worker.js +167 -0
- package/dist/data/ingest-service.js +152 -18
- package/dist/data/message-command-store.js +290 -0
- package/dist/data/payload-store.js +92 -11
- package/dist/data/pibo-store.js +10 -8
- package/dist/data/schema.js +39 -4
- package/dist/data/session-store.js +3 -1
- package/dist/data/storage-backup.js +278 -0
- package/dist/data/storage-maintenance.js +344 -0
- package/dist/data/storage-verification-worker.js +25 -0
- package/dist/data/telemetry-capture.js +188 -0
- package/dist/data/telemetry-command.js +3 -0
- package/dist/data/telemetry-maintenance-worker.js +40 -0
- package/dist/data/telemetry-maintenance.js +110 -0
- package/dist/data/telemetry-retention.js +16 -7
- package/dist/data/telemetry-worker.js +111 -0
- package/dist/data/telemetry-writer.js +150 -83
- package/dist/data/telemetry.js +5 -0
- package/dist/debug/index.js +207 -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 +41 -0
- package/dist/debug/storage-maintenance.js +78 -0
- package/dist/debug/telemetry-capture.js +66 -0
- package/dist/gateway/cli.js +71 -7
- package/dist/gateway/server.js +3 -0
- package/dist/providers/openai-gpt56.js +11 -6
- package/dist/reliability/store.js +119 -23
- package/dist/session-ui/terminalRows.js +54 -13
- package/dist/sessions/pibo-data-store.js +19 -14
- package/dist/shared/debug-features.js +4 -0
- package/dist/shared/model-inference-metrics.js +23 -0
- package/dist/shared/trace-event-projection.js +69 -2
- package/dist/shared/trace-history.js +9 -0
- package/dist/shared/trace-live-reducer.js +1 -0
- package/dist/shared/trace-patch-nodes.js +19 -0
- package/dist/web/channel.js +117 -12
- package/dist/web/http.js +135 -41
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-BOceJ0jM.css +0 -1
- package/dist/apps/chat-ui/assets/index-BOemYq-V.js +0 -228
- package/dist/apps/chat-vscode-web/assets/index-CMwTB8o8.js +0 -43
package/dist/debug/index.js
CHANGED
|
@@ -8,6 +8,20 @@ export async function runDebugCli(argv = process.argv) {
|
|
|
8
8
|
printDebugDiscovery();
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
|
+
if (args[0] === "backup") {
|
|
12
|
+
const { runStorageBackupCli } = await import("./storage-backup.js");
|
|
13
|
+
await runStorageBackupCli(args.slice(1));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (args[0] === "message-queue") {
|
|
17
|
+
await runDebugMessageQueue(args.slice(1));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (args[0] === "storage") {
|
|
21
|
+
const { runStorageMaintenanceCli } = await import("./storage-maintenance.js");
|
|
22
|
+
await runStorageMaintenanceCli(args.slice(1));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
11
25
|
if (args[0] === "db") {
|
|
12
26
|
await runDebugDb(args.slice(1));
|
|
13
27
|
return;
|
|
@@ -98,6 +112,92 @@ export async function runDebugCli(argv = process.argv) {
|
|
|
98
112
|
process.exitCode = 1;
|
|
99
113
|
}
|
|
100
114
|
}
|
|
115
|
+
async function runDebugMessageQueue(args) {
|
|
116
|
+
if (!args.length || args[0] === "--help" || args[0] === "-h") {
|
|
117
|
+
console.log(`pibo debug message-queue - inspect and conservatively reconcile durable messages
|
|
118
|
+
|
|
119
|
+
Commands:
|
|
120
|
+
inspect --session <ps_...> [--after-stream <n>] [--before-terminal-stream <n>] [--json]
|
|
121
|
+
reconcile <cmd_...> --mark-failed [--cancel-successors] [--dry-run|--apply] [--json]
|
|
122
|
+
reconcile <cmd_...> --confirm-completed [--confirm-without-evidence <cmd_...>] [--dry-run|--apply] [--json]
|
|
123
|
+
|
|
124
|
+
Safety:
|
|
125
|
+
Dry-run is the default. --apply mutates the exact command transactionally.
|
|
126
|
+
Replay is unsupported: this command never executes a message or repeats side effects.
|
|
127
|
+
/clear only cancels unstarted runtime/queue work; it does not reconcile an interrupted durable predecessor.
|
|
128
|
+
|
|
129
|
+
Next:
|
|
130
|
+
pibo debug message-queue inspect --session <pibo-session-id>`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const command = args[0], json = args.includes("--json");
|
|
134
|
+
const value = (flag) => { const index = args.indexOf(flag); if (index < 0)
|
|
135
|
+
return undefined; const result = args[index + 1]; if (!result || result.startsWith("--"))
|
|
136
|
+
throw new Error(`${flag} requires a value`); return result; };
|
|
137
|
+
const { PiboDataStore } = await import("../data/pibo-store.js");
|
|
138
|
+
const descriptor = resolveDebugStore("pibo-data");
|
|
139
|
+
if (!descriptor.exists)
|
|
140
|
+
throw new Error(`Pibo data store not found at ${descriptor.path}`);
|
|
141
|
+
const mutating = command === "reconcile" && args.includes("--apply");
|
|
142
|
+
const store = new PiboDataStore(descriptor.path, { readOnly: !mutating });
|
|
143
|
+
try {
|
|
144
|
+
const module = await import("./message-queue.js");
|
|
145
|
+
if (command === "inspect") {
|
|
146
|
+
const sessionId = value("--session");
|
|
147
|
+
if (!sessionId)
|
|
148
|
+
throw new Error("message-queue inspect requires --session <pibo-session-id>");
|
|
149
|
+
const parseStream = (flag) => { const raw = value(flag); if (raw === undefined)
|
|
150
|
+
return undefined; const parsed = Number(raw); if (!Number.isSafeInteger(parsed) || parsed < 1)
|
|
151
|
+
throw new Error(`${flag} requires a positive integer stream id`); return parsed; };
|
|
152
|
+
const result = module.inspectMessageQueue(store, { sessionId, afterStreamId: parseStream("--after-stream"), beforeTerminalStreamId: parseStream("--before-terminal-stream") });
|
|
153
|
+
if (json)
|
|
154
|
+
console.log(JSON.stringify(result, null, 2));
|
|
155
|
+
else
|
|
156
|
+
console.log(module.formatMessageQueueInspection(result));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (command === "reconcile") {
|
|
160
|
+
const commandId = args[1];
|
|
161
|
+
if (!commandId || commandId.startsWith("--"))
|
|
162
|
+
throw new Error("message-queue reconcile requires an exact <cmd_...> ID");
|
|
163
|
+
if (args.includes("--replay"))
|
|
164
|
+
throw new Error("Replay is unsupported because provider and tool side effects cannot be proven idempotent. Choose --mark-failed or --confirm-completed.");
|
|
165
|
+
const decisions = [args.includes("--mark-failed") ? "mark-failed" : undefined, args.includes("--confirm-completed") ? "confirm-completed" : undefined].filter(Boolean);
|
|
166
|
+
if (decisions.length !== 1)
|
|
167
|
+
throw new Error("Choose exactly one decision: --mark-failed or --confirm-completed");
|
|
168
|
+
if (args.includes("--apply") && args.includes("--dry-run"))
|
|
169
|
+
throw new Error("Choose either --dry-run or --apply");
|
|
170
|
+
const known = new Set(["--json", "--mark-failed", "--confirm-completed", "--cancel-successors", "--dry-run", "--apply", "--confirm-without-evidence", "--cancel-successor"]);
|
|
171
|
+
for (let index = 2; index < args.length; index++) {
|
|
172
|
+
const item = args[index];
|
|
173
|
+
if (!item.startsWith("--"))
|
|
174
|
+
continue;
|
|
175
|
+
if (!known.has(item))
|
|
176
|
+
throw new Error(`Unknown message-queue reconciliation option "${item}"`);
|
|
177
|
+
if (item === "--confirm-without-evidence" || item === "--cancel-successor")
|
|
178
|
+
index++;
|
|
179
|
+
}
|
|
180
|
+
const cancelSuccessorIds = [];
|
|
181
|
+
for (let index = 0; index < args.length; index++)
|
|
182
|
+
if (args[index] === "--cancel-successor") {
|
|
183
|
+
const id = args[index + 1];
|
|
184
|
+
if (!id)
|
|
185
|
+
throw new Error("--cancel-successor requires a command ID");
|
|
186
|
+
cancelSuccessorIds.push(id);
|
|
187
|
+
}
|
|
188
|
+
const result = module.reconcileMessageCommand(store, { commandId, decision: decisions[0], apply: args.includes("--apply"), confirmWithoutEvidence: value("--confirm-without-evidence"), cancelSuccessors: args.includes("--cancel-successors"), cancelSuccessorIds });
|
|
189
|
+
if (json)
|
|
190
|
+
console.log(JSON.stringify({ ...result, nextCommands: [result.nextAction] }, null, 2));
|
|
191
|
+
else
|
|
192
|
+
console.log(module.formatMessageQueueReconciliation(result));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`Unknown pibo debug message-queue command "${command}". Run pibo debug message-queue --help.`);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
store.close();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
101
201
|
async function runDebugIntegrity(args) {
|
|
102
202
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
103
203
|
printDebugIntegrityDiscovery();
|
|
@@ -207,6 +307,31 @@ async function runDebugRepair(args) {
|
|
|
207
307
|
printDebugRepairDiscovery();
|
|
208
308
|
return;
|
|
209
309
|
}
|
|
310
|
+
if (args[0] === "output-collision") {
|
|
311
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
312
|
+
printDebugRepairDiscovery();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const jobIndex = args.indexOf("--job");
|
|
316
|
+
const jobId = jobIndex >= 0 ? args[jobIndex + 1] : undefined;
|
|
317
|
+
if (!jobId || jobId.startsWith("-"))
|
|
318
|
+
throw new Error("pibo debug repair output-collision requires --job <dead-job-id>");
|
|
319
|
+
const allowed = new Set(["--job", jobId, "--json", "--dry-run", "--apply", "--keep-existing"]);
|
|
320
|
+
const unknown = args.slice(1).find((arg) => !allowed.has(arg));
|
|
321
|
+
if (unknown)
|
|
322
|
+
throw new Error(`Unknown output collision repair option "${unknown}"`);
|
|
323
|
+
if (args.includes("--apply") && args.includes("--dry-run"))
|
|
324
|
+
throw new Error("Choose either --dry-run or --apply");
|
|
325
|
+
const { repairOutputCollision } = await import("./output-collision-repair.js");
|
|
326
|
+
const result = repairOutputCollision({ dataStore: resolveDebugStore("pibo-data"), reliabilityStore: resolveDebugStore("reliability"), jobId, apply: args.includes("--apply"), keepExisting: args.includes("--keep-existing") });
|
|
327
|
+
console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : [
|
|
328
|
+
"pibo debug repair output-collision", `mode\t${result.mode}`, `job\t${result.jobId}`, `decision\t${result.decision}`,
|
|
329
|
+
`applied\t${result.applied}`, `idempotent\t${result.idempotent}`, `transcript\t${result.projections.transcript}`,
|
|
330
|
+
`trace\t${result.projections.trace}`, `navigation\t${result.projections.navigation}`, `command\t${result.projections.command}`,
|
|
331
|
+
...(result.auditStreamId ? [`audit\t${result.auditStreamId}`] : []), "", ...result.warnings.map((warning) => `warning\t${warning}`),
|
|
332
|
+
].join("\n"));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
210
335
|
if (args[0] !== "output") {
|
|
211
336
|
throw new Error(`Unknown pibo debug repair command "${args[0]}". Run pibo debug repair --help.`);
|
|
212
337
|
}
|
|
@@ -536,11 +661,55 @@ async function runDebugSignals(args) {
|
|
|
536
661
|
console.log(formatSignalSnapshotText(payload));
|
|
537
662
|
}
|
|
538
663
|
}
|
|
664
|
+
async function runDebugTelemetryMaintenance(args) {
|
|
665
|
+
if (!args.length || args.includes("--help") || args.includes("-h")) {
|
|
666
|
+
console.log(`pibo debug telemetry maintenance - bounded optional diagnostic cleanup
|
|
667
|
+
Commands:
|
|
668
|
+
status Read the durable job and progress
|
|
669
|
+
start --before <iso> --apply Start a job; no deletion until step or gateway worker
|
|
670
|
+
step --apply Execute at most 128 rows / 4 ms cooperatively
|
|
671
|
+
pause --apply Pause between batches
|
|
672
|
+
resume --apply Mark the paused job ready
|
|
673
|
+
cancel --apply Cancel without undoing committed batches
|
|
674
|
+
Use --json for structured output. Product history, receipts and payload files are excluded.`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const action = args[0];
|
|
678
|
+
if (!["status", "start", "step", "pause", "resume", "cancel"].includes(action))
|
|
679
|
+
throw Error("Unknown maintenance action; use maintenance --help");
|
|
680
|
+
const options = parseOptions(args.slice(1));
|
|
681
|
+
if (action !== "status" && !options.apply)
|
|
682
|
+
throw Error("Maintenance mutations require --apply; inspect status first");
|
|
683
|
+
const store = resolveDebugStore("pibo-data");
|
|
684
|
+
if (!store.exists)
|
|
685
|
+
throw Error("Pibo data store is missing");
|
|
686
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
687
|
+
const { TelemetryMaintenance } = await import("../data/telemetry-maintenance.js");
|
|
688
|
+
const db = new DatabaseSync(store.path, { readOnly: action === "status" });
|
|
689
|
+
db.exec("PRAGMA busy_timeout=10; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON");
|
|
690
|
+
try {
|
|
691
|
+
const maintenance = new TelemetryMaintenance(db);
|
|
692
|
+
const result = action === "status" ? maintenance.status() : action === "start" ? maintenance.start(options.before ?? "") : action === "step" ? maintenance.step() : maintenance.control(action);
|
|
693
|
+
console.log(JSON.stringify(result ?? { status: "not_started" }, null, options.json ? 2 : undefined));
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
db.close();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
539
699
|
async function runDebugTelemetry(args) {
|
|
540
700
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
541
701
|
printDebugTelemetryDiscovery();
|
|
542
702
|
return;
|
|
543
703
|
}
|
|
704
|
+
if (args[0] === "maintenance") {
|
|
705
|
+
await runDebugTelemetryMaintenance(args.slice(1));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (args[0] === "capture") {
|
|
709
|
+
const { runTelemetryCaptureCli } = await import("./telemetry-capture.js");
|
|
710
|
+
await runTelemetryCaptureCli(args.slice(1));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
544
713
|
const command = args[0];
|
|
545
714
|
const options = parseOptions(args.slice(1));
|
|
546
715
|
const { formatJson } = await import("./sql.js");
|
|
@@ -858,7 +1027,7 @@ async function runDebugJobs(args) {
|
|
|
858
1027
|
const reliability = new PiboReliabilityStore(store.path);
|
|
859
1028
|
try {
|
|
860
1029
|
if (command === "list") {
|
|
861
|
-
const jobs = reliability.
|
|
1030
|
+
const jobs = reliability.inspectJobs({ queue: options.queue, limit: options.limit ? Number(options.limit) : undefined });
|
|
862
1031
|
if (options.json)
|
|
863
1032
|
console.log(formatJson({ jobs: jobs.map(observableJobRow) }));
|
|
864
1033
|
else
|
|
@@ -884,6 +1053,27 @@ async function runDebugJobs(args) {
|
|
|
884
1053
|
console.log(formatRows([compactJobRow(job)]));
|
|
885
1054
|
return;
|
|
886
1055
|
}
|
|
1056
|
+
if (command === "reconcile-runs") {
|
|
1057
|
+
if (options.apply === options.dryRun) {
|
|
1058
|
+
throw new Error("pibo debug jobs reconcile-runs requires exactly one of --dry-run or --apply");
|
|
1059
|
+
}
|
|
1060
|
+
const result = reliability.reconcileOrphanRunJobs({ apply: options.apply });
|
|
1061
|
+
const output = {
|
|
1062
|
+
checkedAt: result.checkedAt,
|
|
1063
|
+
mode: result.apply ? "apply" : "dry-run",
|
|
1064
|
+
candidateCount: result.candidates.length,
|
|
1065
|
+
moved: result.moved,
|
|
1066
|
+
jobs: result.candidates.map(observableJobRow),
|
|
1067
|
+
};
|
|
1068
|
+
if (options.json)
|
|
1069
|
+
console.log(formatJson(output));
|
|
1070
|
+
else {
|
|
1071
|
+
console.log(formatRows([{ checkedAt: output.checkedAt, mode: output.mode, candidateCount: output.candidateCount, moved: output.moved }]));
|
|
1072
|
+
if (result.candidates.length)
|
|
1073
|
+
console.log(formatRows(result.candidates.map(compactJobRow)));
|
|
1074
|
+
}
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
887
1077
|
throw new Error(`Unknown pibo debug jobs command "${command}". Run pibo debug jobs --help.`);
|
|
888
1078
|
}
|
|
889
1079
|
finally {
|
|
@@ -1208,6 +1398,10 @@ function compactJobRow(job) {
|
|
|
1208
1398
|
eventId: correlation.eventId,
|
|
1209
1399
|
phase: correlation.phase,
|
|
1210
1400
|
workerId: job.workerId,
|
|
1401
|
+
claimExpiresAt: job.claimExpiresAt,
|
|
1402
|
+
claimExpired: job.claimExpired,
|
|
1403
|
+
missingRunRecord: job.missingRunRecord,
|
|
1404
|
+
effectiveLiveness: job.effectiveLiveness,
|
|
1211
1405
|
lastError: job.lastError,
|
|
1212
1406
|
};
|
|
1213
1407
|
}
|
|
@@ -1311,6 +1505,9 @@ function printDebugDiscovery() {
|
|
|
1311
1505
|
console.log(`pibo debug - inspect local Pibo data
|
|
1312
1506
|
|
|
1313
1507
|
Commands:
|
|
1508
|
+
backup Create, verify or restore an explicit SQLite and payload snapshot
|
|
1509
|
+
message-queue Inspect or reconcile interrupted durable message commands
|
|
1510
|
+
storage Bounded SQLite status, verification, checkpoint, and retention
|
|
1314
1511
|
db Inspect and query local SQLite stores
|
|
1315
1512
|
session Inspect one Pibo Session by id or Chat URL
|
|
1316
1513
|
summary Show compact session diagnosis and drill-down commands
|
|
@@ -1333,6 +1530,7 @@ Commands:
|
|
|
1333
1530
|
pty Run and inspect interactive CLI/TUI commands under a PTY
|
|
1334
1531
|
|
|
1335
1532
|
Next:
|
|
1533
|
+
pibo debug storage status --json
|
|
1336
1534
|
pibo debug db
|
|
1337
1535
|
pibo debug summary <pibo-session-id>
|
|
1338
1536
|
pibo debug final <pibo-session-id>
|
|
@@ -1406,6 +1604,8 @@ function printDebugRepairDiscovery() {
|
|
|
1406
1604
|
Usage:
|
|
1407
1605
|
pibo debug repair output <pibo-session-id> <event-id> [--dry-run|--apply] [--json]
|
|
1408
1606
|
pibo debug repair output --session <pibo-session-id> [--since <iso-date>] [--before <iso-date>] [--limit n] [--dry-run|--apply] [--json]
|
|
1607
|
+
pibo debug repair output-collision --job <dead-job-id> [--dry-run] [--json]
|
|
1608
|
+
pibo debug repair output-collision --job <dead-job-id> --apply --keep-existing [--json]
|
|
1409
1609
|
|
|
1410
1610
|
Behavior:
|
|
1411
1611
|
Dry-run is the default. --apply is required for every mutation.
|
|
@@ -1414,6 +1614,8 @@ Behavior:
|
|
|
1414
1614
|
Repair never invents assistant content. Without an exact Reliability terminal, only a completed assistant record can justify message_finished.
|
|
1415
1615
|
Every applied terminal writes a pibo.output.repair_applied audit event in the same transaction.
|
|
1416
1616
|
Repair does not delete or replay pending or dead output-persistence jobs.
|
|
1617
|
+
Collision dry-run reports transcript, trace, navigation, and command projection state.
|
|
1618
|
+
Collision apply requires --keep-existing, records an idempotent audit, and never compares or replays conflicting bodies or side effects.
|
|
1417
1619
|
|
|
1418
1620
|
Next:
|
|
1419
1621
|
pibo debug repair output ps_... <event-id> --dry-run --json
|
|
@@ -1491,6 +1693,8 @@ Commands:
|
|
|
1491
1693
|
stale List read-only stale active work
|
|
1492
1694
|
stats Show telemetry retention counts and byte estimates
|
|
1493
1695
|
prune Dry-run telemetry retention cleanup unless --apply is explicit
|
|
1696
|
+
maintenance Inspect or control bounded cleanup; use maintenance --help
|
|
1697
|
+
capture Scope provider detail and inspect inert archives; use capture --help
|
|
1494
1698
|
|
|
1495
1699
|
Next:
|
|
1496
1700
|
pibo debug telemetry sessions --active
|
|
@@ -1652,9 +1856,11 @@ Usage:
|
|
|
1652
1856
|
pibo debug jobs list [--queue queue] [--limit n] [--json]
|
|
1653
1857
|
pibo debug jobs dead [--queue queue] [--limit n] [--json]
|
|
1654
1858
|
pibo debug jobs replay <job-id> [--json]
|
|
1859
|
+
pibo debug jobs reconcile-runs (--dry-run|--apply) [--json]
|
|
1655
1860
|
|
|
1656
1861
|
Next:
|
|
1657
1862
|
pibo debug jobs list --queue runs
|
|
1863
|
+
pibo debug jobs reconcile-runs --dry-run
|
|
1658
1864
|
pibo debug jobs dead --queue runs
|
|
1659
1865
|
`);
|
|
1660
1866
|
}
|
|
@@ -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,
|