@pasko70/pibo 3.5.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-runtime/routed-session.js +80 -19
- package/dist/agent-runtimes/codex-native/turn.js +27 -3
- package/dist/apps/chat/data/chat-data-mappers.js +8 -1
- package/dist/apps/chat/data/read-state-service.js +24 -3
- package/dist/apps/chat/message-command-dispatcher.js +16 -1
- package/dist/apps/chat/web-app.js +40 -19
- package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-BQ-TQZet.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-CYTRhePp.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-Crj0LNXs.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-t24MVWWG.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-tFj2S0WU.js} +1 -1
- package/dist/apps/chat-ui/assets/index-Bc9O0z52.css +1 -0
- package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-Cr2oFRhI.js} +77 -77
- package/dist/apps/chat-ui/index.html +2 -2
- package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DgKYVP-6.js} +11 -11
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/cli-session/localSessionSource.js +3 -2
- package/dist/core/output-render-sequence.js +63 -6
- package/dist/core/session-router.js +99 -11
- package/dist/data/async-chat-storage.js +7 -2
- package/dist/data/bounded-worker-client.js +1 -1
- package/dist/data/chat-read-projections.js +4 -4
- package/dist/data/chat-storage-worker.js +24 -3
- package/dist/data/ingest-service.js +73 -4
- package/dist/data/message-command-store.js +153 -11
- package/dist/data/schema.js +24 -3
- package/dist/data/storage-maintenance.js +344 -0
- package/dist/data/storage-verification-worker.js +25 -0
- package/dist/debug/cache.js +136 -0
- package/dist/debug/index.js +201 -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/session.js +2 -0
- package/dist/debug/storage-backup.js +12 -4
- package/dist/debug/storage-maintenance.js +78 -0
- package/dist/debug/summary.js +1 -0
- package/dist/debug/trace.js +28 -0
- package/dist/gateway/cli.js +71 -7
- package/dist/gateway/server.js +1 -0
- package/dist/reliability/store.js +119 -23
- package/dist/session-ui/terminalRows.js +7 -8
- package/dist/sessions/pibo-data-store.js +18 -14
- package/dist/shared/cache-observability.js +73 -0
- package/dist/shared/model-inference-metrics.js +20 -7
- package/dist/shared/trace-event-projection.js +44 -6
- package/dist/web/channel.js +114 -12
- package/dist/web/http.js +105 -44
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-hEkrlRk-.css +0 -1
package/dist/debug/index.js
CHANGED
|
@@ -13,6 +13,15 @@ export async function runDebugCli(argv = process.argv) {
|
|
|
13
13
|
await runStorageBackupCli(args.slice(1));
|
|
14
14
|
return;
|
|
15
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
|
+
}
|
|
16
25
|
if (args[0] === "db") {
|
|
17
26
|
await runDebugDb(args.slice(1));
|
|
18
27
|
return;
|
|
@@ -25,6 +34,10 @@ export async function runDebugCli(argv = process.argv) {
|
|
|
25
34
|
await runDebugTrace(args.slice(1));
|
|
26
35
|
return;
|
|
27
36
|
}
|
|
37
|
+
if (args[0] === "cache") {
|
|
38
|
+
await runDebugCache(args.slice(1));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
28
41
|
if (args[0] === "summary") {
|
|
29
42
|
await runDebugSummary(args.slice(1));
|
|
30
43
|
return;
|
|
@@ -103,6 +116,92 @@ export async function runDebugCli(argv = process.argv) {
|
|
|
103
116
|
process.exitCode = 1;
|
|
104
117
|
}
|
|
105
118
|
}
|
|
119
|
+
async function runDebugMessageQueue(args) {
|
|
120
|
+
if (!args.length || args[0] === "--help" || args[0] === "-h") {
|
|
121
|
+
console.log(`pibo debug message-queue - inspect and conservatively reconcile durable messages
|
|
122
|
+
|
|
123
|
+
Commands:
|
|
124
|
+
inspect --session <ps_...> [--after-stream <n>] [--before-terminal-stream <n>] [--json]
|
|
125
|
+
reconcile <cmd_...> --mark-failed [--cancel-successors] [--dry-run|--apply] [--json]
|
|
126
|
+
reconcile <cmd_...> --confirm-completed [--confirm-without-evidence <cmd_...>] [--dry-run|--apply] [--json]
|
|
127
|
+
|
|
128
|
+
Safety:
|
|
129
|
+
Dry-run is the default. --apply mutates the exact command transactionally.
|
|
130
|
+
Replay is unsupported: this command never executes a message or repeats side effects.
|
|
131
|
+
/clear only cancels unstarted runtime/queue work; it does not reconcile an interrupted durable predecessor.
|
|
132
|
+
|
|
133
|
+
Next:
|
|
134
|
+
pibo debug message-queue inspect --session <pibo-session-id>`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const command = args[0], json = args.includes("--json");
|
|
138
|
+
const value = (flag) => { const index = args.indexOf(flag); if (index < 0)
|
|
139
|
+
return undefined; const result = args[index + 1]; if (!result || result.startsWith("--"))
|
|
140
|
+
throw new Error(`${flag} requires a value`); return result; };
|
|
141
|
+
const { PiboDataStore } = await import("../data/pibo-store.js");
|
|
142
|
+
const descriptor = resolveDebugStore("pibo-data");
|
|
143
|
+
if (!descriptor.exists)
|
|
144
|
+
throw new Error(`Pibo data store not found at ${descriptor.path}`);
|
|
145
|
+
const mutating = command === "reconcile" && args.includes("--apply");
|
|
146
|
+
const store = new PiboDataStore(descriptor.path, { readOnly: !mutating });
|
|
147
|
+
try {
|
|
148
|
+
const module = await import("./message-queue.js");
|
|
149
|
+
if (command === "inspect") {
|
|
150
|
+
const sessionId = value("--session");
|
|
151
|
+
if (!sessionId)
|
|
152
|
+
throw new Error("message-queue inspect requires --session <pibo-session-id>");
|
|
153
|
+
const parseStream = (flag) => { const raw = value(flag); if (raw === undefined)
|
|
154
|
+
return undefined; const parsed = Number(raw); if (!Number.isSafeInteger(parsed) || parsed < 1)
|
|
155
|
+
throw new Error(`${flag} requires a positive integer stream id`); return parsed; };
|
|
156
|
+
const result = module.inspectMessageQueue(store, { sessionId, afterStreamId: parseStream("--after-stream"), beforeTerminalStreamId: parseStream("--before-terminal-stream") });
|
|
157
|
+
if (json)
|
|
158
|
+
console.log(JSON.stringify(result, null, 2));
|
|
159
|
+
else
|
|
160
|
+
console.log(module.formatMessageQueueInspection(result));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (command === "reconcile") {
|
|
164
|
+
const commandId = args[1];
|
|
165
|
+
if (!commandId || commandId.startsWith("--"))
|
|
166
|
+
throw new Error("message-queue reconcile requires an exact <cmd_...> ID");
|
|
167
|
+
if (args.includes("--replay"))
|
|
168
|
+
throw new Error("Replay is unsupported because provider and tool side effects cannot be proven idempotent. Choose --mark-failed or --confirm-completed.");
|
|
169
|
+
const decisions = [args.includes("--mark-failed") ? "mark-failed" : undefined, args.includes("--confirm-completed") ? "confirm-completed" : undefined].filter(Boolean);
|
|
170
|
+
if (decisions.length !== 1)
|
|
171
|
+
throw new Error("Choose exactly one decision: --mark-failed or --confirm-completed");
|
|
172
|
+
if (args.includes("--apply") && args.includes("--dry-run"))
|
|
173
|
+
throw new Error("Choose either --dry-run or --apply");
|
|
174
|
+
const known = new Set(["--json", "--mark-failed", "--confirm-completed", "--cancel-successors", "--dry-run", "--apply", "--confirm-without-evidence", "--cancel-successor"]);
|
|
175
|
+
for (let index = 2; index < args.length; index++) {
|
|
176
|
+
const item = args[index];
|
|
177
|
+
if (!item.startsWith("--"))
|
|
178
|
+
continue;
|
|
179
|
+
if (!known.has(item))
|
|
180
|
+
throw new Error(`Unknown message-queue reconciliation option "${item}"`);
|
|
181
|
+
if (item === "--confirm-without-evidence" || item === "--cancel-successor")
|
|
182
|
+
index++;
|
|
183
|
+
}
|
|
184
|
+
const cancelSuccessorIds = [];
|
|
185
|
+
for (let index = 0; index < args.length; index++)
|
|
186
|
+
if (args[index] === "--cancel-successor") {
|
|
187
|
+
const id = args[index + 1];
|
|
188
|
+
if (!id)
|
|
189
|
+
throw new Error("--cancel-successor requires a command ID");
|
|
190
|
+
cancelSuccessorIds.push(id);
|
|
191
|
+
}
|
|
192
|
+
const result = module.reconcileMessageCommand(store, { commandId, decision: decisions[0], apply: args.includes("--apply"), confirmWithoutEvidence: value("--confirm-without-evidence"), cancelSuccessors: args.includes("--cancel-successors"), cancelSuccessorIds });
|
|
193
|
+
if (json)
|
|
194
|
+
console.log(JSON.stringify({ ...result, nextCommands: [result.nextAction] }, null, 2));
|
|
195
|
+
else
|
|
196
|
+
console.log(module.formatMessageQueueReconciliation(result));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`Unknown pibo debug message-queue command "${command}". Run pibo debug message-queue --help.`);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
store.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
106
205
|
async function runDebugIntegrity(args) {
|
|
107
206
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
108
207
|
printDebugIntegrityDiscovery();
|
|
@@ -212,6 +311,31 @@ async function runDebugRepair(args) {
|
|
|
212
311
|
printDebugRepairDiscovery();
|
|
213
312
|
return;
|
|
214
313
|
}
|
|
314
|
+
if (args[0] === "output-collision") {
|
|
315
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
316
|
+
printDebugRepairDiscovery();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const jobIndex = args.indexOf("--job");
|
|
320
|
+
const jobId = jobIndex >= 0 ? args[jobIndex + 1] : undefined;
|
|
321
|
+
if (!jobId || jobId.startsWith("-"))
|
|
322
|
+
throw new Error("pibo debug repair output-collision requires --job <dead-job-id>");
|
|
323
|
+
const allowed = new Set(["--job", jobId, "--json", "--dry-run", "--apply", "--keep-existing"]);
|
|
324
|
+
const unknown = args.slice(1).find((arg) => !allowed.has(arg));
|
|
325
|
+
if (unknown)
|
|
326
|
+
throw new Error(`Unknown output collision repair option "${unknown}"`);
|
|
327
|
+
if (args.includes("--apply") && args.includes("--dry-run"))
|
|
328
|
+
throw new Error("Choose either --dry-run or --apply");
|
|
329
|
+
const { repairOutputCollision } = await import("./output-collision-repair.js");
|
|
330
|
+
const result = repairOutputCollision({ dataStore: resolveDebugStore("pibo-data"), reliabilityStore: resolveDebugStore("reliability"), jobId, apply: args.includes("--apply"), keepExisting: args.includes("--keep-existing") });
|
|
331
|
+
console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : [
|
|
332
|
+
"pibo debug repair output-collision", `mode\t${result.mode}`, `job\t${result.jobId}`, `decision\t${result.decision}`,
|
|
333
|
+
`applied\t${result.applied}`, `idempotent\t${result.idempotent}`, `transcript\t${result.projections.transcript}`,
|
|
334
|
+
`trace\t${result.projections.trace}`, `navigation\t${result.projections.navigation}`, `command\t${result.projections.command}`,
|
|
335
|
+
...(result.auditStreamId ? [`audit\t${result.auditStreamId}`] : []), "", ...result.warnings.map((warning) => `warning\t${warning}`),
|
|
336
|
+
].join("\n"));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
215
339
|
if (args[0] !== "output") {
|
|
216
340
|
throw new Error(`Unknown pibo debug repair command "${args[0]}". Run pibo debug repair --help.`);
|
|
217
341
|
}
|
|
@@ -695,6 +819,29 @@ async function runDebugTelemetry(args) {
|
|
|
695
819
|
}
|
|
696
820
|
throw new Error(`Unknown pibo debug telemetry command "${command}". Run pibo debug telemetry --help.`);
|
|
697
821
|
}
|
|
822
|
+
async function runDebugCache(args) {
|
|
823
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
824
|
+
printDebugCacheDiscovery();
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const options = parseOptions(args);
|
|
828
|
+
const piboSessionId = options.positionals[0];
|
|
829
|
+
if (!piboSessionId)
|
|
830
|
+
throw new Error("pibo debug cache requires <pibo-session-id>");
|
|
831
|
+
if (options.positionals.length > 1)
|
|
832
|
+
throw new Error("pibo debug cache accepts one <pibo-session-id>. Run pibo debug cache --help.");
|
|
833
|
+
const limit = options.limit === undefined ? undefined : Number(options.limit);
|
|
834
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 200))
|
|
835
|
+
throw new Error("--limit must be an integer from 1 to 200");
|
|
836
|
+
const stores = { sessions: resolveDebugStore("sessions"), chat: resolveDebugStore("chat") };
|
|
837
|
+
const { formatJson } = await import("./sql.js");
|
|
838
|
+
const { formatDebugCache, inspectDebugCache } = await import("./cache.js");
|
|
839
|
+
const result = await inspectDebugCache(piboSessionId, stores, { limit });
|
|
840
|
+
if (options.json)
|
|
841
|
+
console.log(formatJson(result));
|
|
842
|
+
else
|
|
843
|
+
console.log(formatDebugCache(result));
|
|
844
|
+
}
|
|
698
845
|
async function runDebugTrace(args) {
|
|
699
846
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
700
847
|
printDebugTraceDiscovery();
|
|
@@ -907,7 +1054,7 @@ async function runDebugJobs(args) {
|
|
|
907
1054
|
const reliability = new PiboReliabilityStore(store.path);
|
|
908
1055
|
try {
|
|
909
1056
|
if (command === "list") {
|
|
910
|
-
const jobs = reliability.
|
|
1057
|
+
const jobs = reliability.inspectJobs({ queue: options.queue, limit: options.limit ? Number(options.limit) : undefined });
|
|
911
1058
|
if (options.json)
|
|
912
1059
|
console.log(formatJson({ jobs: jobs.map(observableJobRow) }));
|
|
913
1060
|
else
|
|
@@ -933,6 +1080,27 @@ async function runDebugJobs(args) {
|
|
|
933
1080
|
console.log(formatRows([compactJobRow(job)]));
|
|
934
1081
|
return;
|
|
935
1082
|
}
|
|
1083
|
+
if (command === "reconcile-runs") {
|
|
1084
|
+
if (options.apply === options.dryRun) {
|
|
1085
|
+
throw new Error("pibo debug jobs reconcile-runs requires exactly one of --dry-run or --apply");
|
|
1086
|
+
}
|
|
1087
|
+
const result = reliability.reconcileOrphanRunJobs({ apply: options.apply });
|
|
1088
|
+
const output = {
|
|
1089
|
+
checkedAt: result.checkedAt,
|
|
1090
|
+
mode: result.apply ? "apply" : "dry-run",
|
|
1091
|
+
candidateCount: result.candidates.length,
|
|
1092
|
+
moved: result.moved,
|
|
1093
|
+
jobs: result.candidates.map(observableJobRow),
|
|
1094
|
+
};
|
|
1095
|
+
if (options.json)
|
|
1096
|
+
console.log(formatJson(output));
|
|
1097
|
+
else {
|
|
1098
|
+
console.log(formatRows([{ checkedAt: output.checkedAt, mode: output.mode, candidateCount: output.candidateCount, moved: output.moved }]));
|
|
1099
|
+
if (result.candidates.length)
|
|
1100
|
+
console.log(formatRows(result.candidates.map(compactJobRow)));
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
936
1104
|
throw new Error(`Unknown pibo debug jobs command "${command}". Run pibo debug jobs --help.`);
|
|
937
1105
|
}
|
|
938
1106
|
finally {
|
|
@@ -1257,6 +1425,10 @@ function compactJobRow(job) {
|
|
|
1257
1425
|
eventId: correlation.eventId,
|
|
1258
1426
|
phase: correlation.phase,
|
|
1259
1427
|
workerId: job.workerId,
|
|
1428
|
+
claimExpiresAt: job.claimExpiresAt,
|
|
1429
|
+
claimExpired: job.claimExpired,
|
|
1430
|
+
missingRunRecord: job.missingRunRecord,
|
|
1431
|
+
effectiveLiveness: job.effectiveLiveness,
|
|
1260
1432
|
lastError: job.lastError,
|
|
1261
1433
|
};
|
|
1262
1434
|
}
|
|
@@ -1361,12 +1533,15 @@ function printDebugDiscovery() {
|
|
|
1361
1533
|
|
|
1362
1534
|
Commands:
|
|
1363
1535
|
backup Create, verify or restore an explicit SQLite and payload snapshot
|
|
1536
|
+
message-queue Inspect or reconcile interrupted durable message commands
|
|
1537
|
+
storage Bounded SQLite status, verification, checkpoint, and retention
|
|
1364
1538
|
db Inspect and query local SQLite stores
|
|
1365
1539
|
session Inspect one Pibo Session by id or Chat URL
|
|
1366
1540
|
summary Show compact session diagnosis and drill-down commands
|
|
1367
1541
|
messages List or show stored user/assistant messages
|
|
1368
1542
|
final Show the latest assistant message
|
|
1369
1543
|
trace Rebuild the Chat Web trace view for one Pibo Session
|
|
1544
|
+
cache Summarize provider-reported cache usage for one Pibo Session
|
|
1370
1545
|
events Inspect compact event payload fields for one Pibo Session
|
|
1371
1546
|
agents Inspect delegated child agents and their persisted activity
|
|
1372
1547
|
tool Inspect one grouped tool call
|
|
@@ -1383,11 +1558,13 @@ Commands:
|
|
|
1383
1558
|
pty Run and inspect interactive CLI/TUI commands under a PTY
|
|
1384
1559
|
|
|
1385
1560
|
Next:
|
|
1561
|
+
pibo debug storage status --json
|
|
1386
1562
|
pibo debug db
|
|
1387
1563
|
pibo debug summary <pibo-session-id>
|
|
1388
1564
|
pibo debug final <pibo-session-id>
|
|
1389
1565
|
pibo debug messages <pibo-session-id> list
|
|
1390
1566
|
pibo debug trace <pibo-session-id> --running-only
|
|
1567
|
+
pibo debug cache <pibo-session-id>
|
|
1391
1568
|
pibo debug events stream --topic pibo.output
|
|
1392
1569
|
pibo debug persistence
|
|
1393
1570
|
pibo debug repair output <pibo-session-id> <event-id> --dry-run
|
|
@@ -1456,6 +1633,8 @@ function printDebugRepairDiscovery() {
|
|
|
1456
1633
|
Usage:
|
|
1457
1634
|
pibo debug repair output <pibo-session-id> <event-id> [--dry-run|--apply] [--json]
|
|
1458
1635
|
pibo debug repair output --session <pibo-session-id> [--since <iso-date>] [--before <iso-date>] [--limit n] [--dry-run|--apply] [--json]
|
|
1636
|
+
pibo debug repair output-collision --job <dead-job-id> [--dry-run] [--json]
|
|
1637
|
+
pibo debug repair output-collision --job <dead-job-id> --apply --keep-existing [--json]
|
|
1459
1638
|
|
|
1460
1639
|
Behavior:
|
|
1461
1640
|
Dry-run is the default. --apply is required for every mutation.
|
|
@@ -1464,6 +1643,8 @@ Behavior:
|
|
|
1464
1643
|
Repair never invents assistant content. Without an exact Reliability terminal, only a completed assistant record can justify message_finished.
|
|
1465
1644
|
Every applied terminal writes a pibo.output.repair_applied audit event in the same transaction.
|
|
1466
1645
|
Repair does not delete or replay pending or dead output-persistence jobs.
|
|
1646
|
+
Collision dry-run reports transcript, trace, navigation, and command projection state.
|
|
1647
|
+
Collision apply requires --keep-existing, records an idempotent audit, and never compares or replays conflicting bodies or side effects.
|
|
1467
1648
|
|
|
1468
1649
|
Next:
|
|
1469
1650
|
pibo debug repair output ps_... <event-id> --dry-run --json
|
|
@@ -1655,6 +1836,23 @@ Next:
|
|
|
1655
1836
|
pibo debug tool ps_... <tool-call-id> --output
|
|
1656
1837
|
`);
|
|
1657
1838
|
}
|
|
1839
|
+
function printDebugCacheDiscovery() {
|
|
1840
|
+
console.log(`pibo debug cache - summarize provider-reported cache usage
|
|
1841
|
+
|
|
1842
|
+
Usage:
|
|
1843
|
+
pibo debug cache <pibo-session-id> [--limit n] [--json]
|
|
1844
|
+
|
|
1845
|
+
Reports:
|
|
1846
|
+
Per-inference input, cache-read, cache-write and uncached token counts.
|
|
1847
|
+
Warns when large consecutive requests move from mostly cached to mostly uncached input.
|
|
1848
|
+
Provider metrics cannot identify the cause of a cache-read drop.
|
|
1849
|
+
|
|
1850
|
+
Next:
|
|
1851
|
+
pibo debug cache ps_... --json
|
|
1852
|
+
pibo debug trace ps_... --medium
|
|
1853
|
+
pibo debug events ps_... --type assistant_usage --fields inputTokens,cacheReadTokens,cacheWriteTokens,totalTokens
|
|
1854
|
+
`);
|
|
1855
|
+
}
|
|
1658
1856
|
function printDebugTraceDiscovery() {
|
|
1659
1857
|
console.log(`pibo debug trace - rebuild one Chat Web trace view
|
|
1660
1858
|
|
|
@@ -1704,9 +1902,11 @@ Usage:
|
|
|
1704
1902
|
pibo debug jobs list [--queue queue] [--limit n] [--json]
|
|
1705
1903
|
pibo debug jobs dead [--queue queue] [--limit n] [--json]
|
|
1706
1904
|
pibo debug jobs replay <job-id> [--json]
|
|
1905
|
+
pibo debug jobs reconcile-runs (--dry-run|--apply) [--json]
|
|
1707
1906
|
|
|
1708
1907
|
Next:
|
|
1709
1908
|
pibo debug jobs list --queue runs
|
|
1909
|
+
pibo debug jobs reconcile-runs --dry-run
|
|
1710
1910
|
pibo debug jobs dead --queue runs
|
|
1711
1911
|
`);
|
|
1712
1912
|
}
|
|
@@ -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")
|