@pasko70/pibo 3.5.0 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/agent-runtime/routed-session.js +80 -19
  2. package/dist/agent-runtimes/codex-native/turn.js +27 -3
  3. package/dist/apps/chat/data/chat-data-mappers.js +8 -1
  4. package/dist/apps/chat/data/read-state-service.js +24 -3
  5. package/dist/apps/chat/message-command-dispatcher.js +16 -1
  6. package/dist/apps/chat/web-app.js +40 -19
  7. package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-B-auLrzD.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-BA_dsINH.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-eJZar_0-.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-wNNR2Bci.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-zcmEsIEp.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-DEkbN5Vo.js} +43 -43
  13. package/dist/apps/chat-ui/assets/index-DZK6Tzil.css +1 -0
  14. package/dist/apps/chat-ui/index.html +2 -2
  15. package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DZgW1fCB.js} +11 -11
  16. package/dist/apps/chat-vscode-web/index.html +1 -1
  17. package/dist/cli-session/localSessionSource.js +3 -2
  18. package/dist/core/output-render-sequence.js +63 -6
  19. package/dist/core/session-router.js +99 -11
  20. package/dist/data/async-chat-storage.js +7 -2
  21. package/dist/data/bounded-worker-client.js +1 -1
  22. package/dist/data/chat-read-projections.js +4 -4
  23. package/dist/data/chat-storage-worker.js +24 -3
  24. package/dist/data/ingest-service.js +73 -4
  25. package/dist/data/message-command-store.js +153 -11
  26. package/dist/data/schema.js +24 -3
  27. package/dist/data/storage-maintenance.js +344 -0
  28. package/dist/data/storage-verification-worker.js +25 -0
  29. package/dist/debug/index.js +155 -1
  30. package/dist/debug/message-queue.js +108 -0
  31. package/dist/debug/output-collision-repair.js +140 -0
  32. package/dist/debug/output-integrity.js +38 -2
  33. package/dist/debug/output-repair.js +1 -0
  34. package/dist/debug/storage-backup.js +12 -4
  35. package/dist/debug/storage-maintenance.js +78 -0
  36. package/dist/gateway/cli.js +71 -7
  37. package/dist/gateway/server.js +1 -0
  38. package/dist/reliability/store.js +119 -23
  39. package/dist/session-ui/terminalRows.js +7 -8
  40. package/dist/sessions/pibo-data-store.js +18 -14
  41. package/dist/shared/trace-event-projection.js +13 -3
  42. package/dist/web/channel.js +114 -12
  43. package/dist/web/http.js +105 -44
  44. package/npm-shrinkwrap.json +2 -2
  45. package/package.json +1 -1
  46. package/dist/apps/chat-ui/assets/index-hEkrlRk-.css +0 -1
@@ -0,0 +1,344 @@
1
+ import { fork } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { appendFileSync, existsSync, readFileSync, statSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { fileURLToPath } from "node:url";
7
+ const ROW_SAMPLE_LIMIT = 10_000;
8
+ const TABLE_LIMIT = 64;
9
+ const DEFAULT_DB_WARN_BYTES = 8 * 1024 ** 3;
10
+ const DEFAULT_WAL_WARN_BYTES = 256 * 1024 ** 2;
11
+ const DEFAULT_PAYLOAD_WARN_BYTES = 8 * 1024 ** 3;
12
+ const MAINTENANCE_LOG_SUFFIX = ".maintenance.jsonl";
13
+ export function inspectStorageStatus(input) {
14
+ const path = resolve(input.path);
15
+ const thresholds = {
16
+ databaseBytes: validThreshold(input.databaseWarnBytes, DEFAULT_DB_WARN_BYTES),
17
+ walBytes: validThreshold(input.walWarnBytes, DEFAULT_WAL_WARN_BYTES),
18
+ payloadBytes: validThreshold(input.payloadWarnBytes, DEFAULT_PAYLOAD_WARN_BYTES),
19
+ };
20
+ if (!existsSync(path))
21
+ return { resultType: "storage.status", readOnly: true, path, exists: false, health: "degraded", sizes: { database: 0, wal: 0, shm: 0, payloadStoreMetadataSample: 0, payloadStoreSampleComplete: true }, pages: { pageSize: 0, pageCount: 0, freelistCount: 0, freelistRatio: 0 }, wal: { busy: 0, logPages: 0, checkpointedPages: 0, pressure: false }, rows: [], payloads: { rows: 0, rowsComplete: true, sampledRows: 0, referencedRows: 0, metadataOrphans: 0, brokenReferences: 0, integrityComplete: true }, thresholds, last: {}, warnings: ["Database does not exist"] };
22
+ const db = new DatabaseSync(path, { readOnly: true });
23
+ try {
24
+ db.exec("PRAGMA busy_timeout = 50");
25
+ const pageSize = pragmaNumber(db, "page_size"), pageCount = pragmaNumber(db, "page_count"), freelistCount = pragmaNumber(db, "freelist_count");
26
+ const walRow = { busy: 0, logPages: pageSize ? Math.ceil(fileSize(`${path}-wal`) / pageSize) : 0, checkpointedPages: 0 };
27
+ const schemas = db.prepare("SELECT name, type FROM sqlite_schema WHERE type IN ('table','index') AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name LIMIT ?").all(TABLE_LIMIT);
28
+ const estimates = statEstimates(db);
29
+ const rows = schemas.map((schema) => {
30
+ if (schema.type === "index")
31
+ return { name: schema.name, kind: "index", ...(estimates.get(schema.name) !== undefined ? { estimatedRows: estimates.get(schema.name) } : {}) };
32
+ const count = boundedCount(db, schema.name);
33
+ return { name: schema.name, kind: "table", boundedCount: count.count, countComplete: count.complete, ...(estimates.get(schema.name) !== undefined ? { estimatedRows: estimates.get(schema.name) } : {}) };
34
+ });
35
+ const payload = inspectPayloadReferencesBounded(db);
36
+ const sizes = {
37
+ database: fileSize(path),
38
+ wal: fileSize(`${path}-wal`),
39
+ shm: fileSize(`${path}-shm`),
40
+ payloadStoreMetadataSample: payload.metadataBytes,
41
+ payloadStoreSampleComplete: payload.rowsComplete && payload.sampledRows === payload.rows,
42
+ };
43
+ const walPressure = sizes.wal >= thresholds.walBytes;
44
+ const warnings = [
45
+ ...(sizes.database >= thresholds.databaseBytes ? ["database_size_threshold"] : []),
46
+ ...(sizes.wal >= thresholds.walBytes ? ["wal_size_threshold"] : []),
47
+ ...(sizes.payloadStoreSampleComplete && sizes.payloadStoreMetadataSample >= thresholds.payloadBytes ? ["payload_size_threshold"] : []),
48
+ ...(!sizes.payloadStoreSampleComplete ? ["payload_size_threshold_indeterminate"] : []),
49
+ ...(walPressure ? ["wal_checkpoint_pressure"] : []),
50
+ ...(payload.metadataOrphans ? ["payload_metadata_orphans_sample"] : []),
51
+ ...(payload.brokenReferences ? ["broken_payload_references_sample"] : []),
52
+ ];
53
+ return {
54
+ resultType: "storage.status", readOnly: true, path, exists: true, health: warnings.length ? "degraded" : "healthy", sizes,
55
+ pages: { pageSize, pageCount, freelistCount, freelistRatio: pageCount ? freelistCount / pageCount : 0 },
56
+ wal: { ...walRow, pressure: walPressure }, rows,
57
+ payloads: { rows: payload.rows, rowsComplete: payload.rowsComplete, sampledRows: payload.sampledRows, referencedRows: payload.referencedRows, metadataOrphans: payload.metadataOrphans, brokenReferences: payload.brokenReferences, integrityComplete: payload.integrityComplete },
58
+ thresholds, last: readMaintenanceMetadata(path), warnings,
59
+ };
60
+ }
61
+ finally {
62
+ db.close();
63
+ }
64
+ }
65
+ export async function verifyStorage(input) {
66
+ const path = resolve(input.path), mode = input.mode ?? "quick", timeoutMs = input.timeoutMs ?? 60_000;
67
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 3_600_000)
68
+ throw new Error("Verification timeout must be between 1 and 3600000 ms");
69
+ const started = Date.now(), progress = [];
70
+ const record = (stage, elapsedMs = Date.now() - started) => { const item = { stage, elapsedMs }; if (progress.length < 32)
71
+ progress.push(item); input.onProgress?.(item); };
72
+ const resultFor = (status, healthy, reason, messages = []) => ({ resultType: "storage.verification", path, mode, status, healthy, elapsedMs: Date.now() - started, progress, messages, ...(reason ? { reason } : {}) });
73
+ record("starting", 0);
74
+ if (input.signal?.aborted)
75
+ return resultFor("partial", false, "cancelled");
76
+ const result = await new Promise((resolveResult) => {
77
+ let child;
78
+ let settled = false;
79
+ let operationTimer;
80
+ let pendingPartialReason;
81
+ const startupTimer = setTimeout(() => requestStop("startup_timeout"), Math.min(5_000, Math.max(1_000, timeoutMs)));
82
+ const finish = (value) => {
83
+ if (settled)
84
+ return;
85
+ settled = true;
86
+ clearTimeout(startupTimer);
87
+ if (operationTimer)
88
+ clearTimeout(operationTimer);
89
+ input.signal?.removeEventListener("abort", cancel);
90
+ resolveResult(value);
91
+ };
92
+ const requestStop = (reason) => {
93
+ if (settled || pendingPartialReason)
94
+ return;
95
+ pendingPartialReason = reason;
96
+ record("terminating");
97
+ if (!child?.kill("SIGKILL"))
98
+ finish(resultFor("failed", false, "verification_process_not_terminated"));
99
+ };
100
+ const cancel = () => requestStop("cancelled");
101
+ input.signal?.addEventListener("abort", cancel, { once: true });
102
+ child = fork(fileURLToPath(new URL("./storage-verification-worker.js", import.meta.url)), [path, mode, ...(input.testNativeLongRunning ? ["native-long"] : [])], { stdio: ["ignore", "ignore", "ignore", "ipc"] });
103
+ child.on("message", (message) => {
104
+ if (!message || typeof message !== "object")
105
+ return;
106
+ const item = message;
107
+ if (item.type === "progress") {
108
+ const stage = String(item.stage);
109
+ record(stage, Number(item.elapsedMs));
110
+ if (stage.endsWith("_check") && !operationTimer) {
111
+ clearTimeout(startupTimer);
112
+ operationTimer = setTimeout(() => requestStop("timeout"), timeoutMs);
113
+ }
114
+ return;
115
+ }
116
+ if (item.type === "result") {
117
+ record("complete", Number(item.elapsedMs));
118
+ const messages = Array.isArray(item.messages) ? item.messages.map(String).slice(0, 100) : [];
119
+ finish(item.ok === true ? resultFor("complete", true, undefined, messages) : resultFor("failed", false, "integrity_errors", messages));
120
+ }
121
+ else if (item.type === "error")
122
+ finish(resultFor("failed", false, String(item.message ?? "verification_failed")));
123
+ });
124
+ child.on("error", (error) => finish(resultFor("failed", false, (error instanceof Error ? error.message : String(error)).slice(0, 500))));
125
+ child.on("exit", (code, signal) => {
126
+ if (pendingPartialReason) {
127
+ record("terminated");
128
+ finish(resultFor("partial", false, pendingPartialReason));
129
+ }
130
+ else if (!settled && code !== 0)
131
+ finish(resultFor("failed", false, `verification_process_exit_${code ?? signal ?? "unknown"}`));
132
+ });
133
+ });
134
+ if (result.status !== "partial") {
135
+ try {
136
+ recordMaintenance(path, { operation: "verification", at: new Date().toISOString(), mode, status: result.status, healthy: result.healthy, elapsedMs: result.elapsedMs });
137
+ }
138
+ catch { /* A read-only verification result remains valid when metadata storage is unavailable. */ }
139
+ }
140
+ return result;
141
+ }
142
+ export function checkpointStorage(input) {
143
+ const path = resolve(input.path), mode = input.mode ?? "passive";
144
+ if (!existsSync(path))
145
+ throw new Error(`Checkpoint database does not exist: ${path}`);
146
+ if (mode !== "passive" && mode !== "restart" && mode !== "truncate")
147
+ throw new Error("Checkpoint mode must be passive, restart, or truncate");
148
+ if (!input.apply)
149
+ return { resultType: "storage.checkpoint", mode: "dry-run", path, checkpointMode: mode, mutation: false };
150
+ const db = new DatabaseSync(path);
151
+ try {
152
+ db.exec("PRAGMA busy_timeout = 100");
153
+ const row = db.prepare(`PRAGMA wal_checkpoint(${mode.toUpperCase()})`).get();
154
+ const result = { resultType: "storage.checkpoint", mode: "apply", path, checkpointMode: mode, mutation: true, busy: Number(row.busy ?? 0), logPages: Number(row.log ?? 0), checkpointedPages: Number(row.checkpointed ?? 0), at: new Date().toISOString() };
155
+ recordMaintenance(path, { operation: "checkpoint", ...result });
156
+ return result;
157
+ }
158
+ finally {
159
+ db.close();
160
+ }
161
+ }
162
+ export async function maintainStorageRetention(input) {
163
+ const path = resolve(input.path), limit = input.limit ?? 1000;
164
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
165
+ throw new Error("Retention limit must be between 1 and 10000");
166
+ if (!Number.isFinite(Date.parse(input.before)))
167
+ throw new Error("Retention --before must be an ISO date");
168
+ const db = new DatabaseSync(path, { readOnly: !input.apply });
169
+ const target = tableExists(db, "event_log") ? { table: "event_log", time: "created_at" } : tableExists(db, "pibo_event_stream") ? { table: "pibo_event_stream", time: "created_at" } : undefined;
170
+ try {
171
+ if (!target)
172
+ return { resultType: "storage.retention", mode: input.apply ? "apply" : "dry-run", path, eligible: 0, deleted: 0, policy: "live_delta_only", preserved: ["chat_message", "audit_event", "idempotency_evidence", "referenced_payloads"] };
173
+ const eligibility = retentionEligibilitySql(target.table);
174
+ const eligible = Number(db.prepare(`SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${target.table} WHERE ${eligibility} AND ${target.time} < ? ORDER BY ${target.time} LIMIT ?)`).get(input.before, limit).count);
175
+ const plan = retentionPlan(db, target.table, target.time, input.before, limit);
176
+ if (!input.apply)
177
+ return { resultType: "storage.retention", mode: "dry-run", path, eligible, deleteLimit: limit, policy: "live_delta_only", plan, preserved: ["chat_message", "audit_event", "idempotency_evidence", "referenced_payloads"] };
178
+ const auditId = `storage_retention_${randomUUID()}`;
179
+ const at = new Date().toISOString();
180
+ db.exec("PRAGMA busy_timeout = 100; BEGIN IMMEDIATE");
181
+ try {
182
+ db.exec(`CREATE TABLE IF NOT EXISTS storage_maintenance_audit (
183
+ id TEXT PRIMARY KEY,
184
+ operation TEXT NOT NULL,
185
+ status TEXT NOT NULL,
186
+ details_json TEXT NOT NULL,
187
+ created_at TEXT NOT NULL
188
+ )`);
189
+ const candidates = db.prepare(`SELECT rowid, ${target.table === "event_log" ? "payload_ref AS payloadRef" : "NULL AS payloadRef"} FROM ${target.table} WHERE ${eligibility} AND ${target.time} < ? ORDER BY ${target.time}, rowid LIMIT ?`).all(input.before, limit);
190
+ const deletion = db.prepare(`DELETE FROM ${target.table} WHERE rowid IN (SELECT rowid FROM ${target.table} WHERE ${eligibility} AND ${target.time} < ? ORDER BY ${target.time}, rowid LIMIT ?)`).run(input.before, limit);
191
+ const deleted = Number(deletion.changes);
192
+ if (deleted !== candidates.length)
193
+ throw new Error("Retention candidate/delete count changed while holding the write transaction");
194
+ const releasedPayloadIds = [...new Set(candidates.flatMap((row) => row.payloadRef ? [row.payloadRef] : []))];
195
+ const payloads = inspectReleasedPayloadsBounded(db, releasedPayloadIds);
196
+ const result = { resultType: "storage.retention", mode: "apply", path, eligible, deleted, deleteLimit: limit, policy: "live_delta_only", plan, payloads, auditId, preserved: ["chat_message", "audit_event", "idempotency_evidence", "referenced_payloads", "orphan_payload_files"], at };
197
+ db.prepare("INSERT INTO storage_maintenance_audit (id, operation, status, details_json, created_at) VALUES (?, 'retention', 'complete', ?, ?)").run(auditId, JSON.stringify(result), at);
198
+ db.exec("COMMIT");
199
+ try {
200
+ recordMaintenance(path, { operation: "retention", ...result });
201
+ }
202
+ catch { /* Transactional audit is authoritative. */ }
203
+ return result;
204
+ }
205
+ catch (error) {
206
+ if (db.isTransaction)
207
+ db.exec("ROLLBACK");
208
+ throw error;
209
+ }
210
+ }
211
+ finally {
212
+ db.close();
213
+ }
214
+ }
215
+ function retentionEligibilitySql(table) {
216
+ return table === "event_log"
217
+ ? "retention_class = 'live_delta' AND idempotency_key IS NULL"
218
+ : "retention_class = 'live_delta' AND idempotency_key IS NULL AND event_id IS NULL";
219
+ }
220
+ function retentionPlan(db, table, time, before, limit) {
221
+ const classes = ["live_delta", "trace_event", "chat_message", "audit_event"];
222
+ return classes.map((retentionClass) => {
223
+ const rows = Number(db.prepare(`SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${table} WHERE retention_class=? AND ${time} < ? LIMIT ?)`).get(retentionClass, before, limit + 1).count);
224
+ return {
225
+ retentionClass,
226
+ rows: Math.min(rows, limit),
227
+ bounded: rows > limit,
228
+ disposition: retentionClass === "live_delta" ? "eligible" : retentionClass === "trace_event" ? "deferred_requires_policy" : "preserve",
229
+ };
230
+ });
231
+ }
232
+ function inspectPayloadReferencesBounded(db) {
233
+ if (!tableExists(db, "payloads"))
234
+ return { rows: 0, rowsComplete: true, sampledRows: 0, metadataBytes: 0, referencedRows: 0, metadataOrphans: 0, brokenReferences: 0, integrityComplete: true };
235
+ const rowCount = boundedCount(db, "payloads");
236
+ const payloadRows = db.prepare("SELECT id, COALESCE(compressed_byte_size, byte_size) AS bytes FROM payloads ORDER BY id LIMIT 1001").all();
237
+ const sample = payloadRows.slice(0, 1000);
238
+ const references = discoverPayloadReferenceColumns(db);
239
+ const sampledReferenceIds = new Set();
240
+ let brokenReferences = 0;
241
+ let referenceSamplesComplete = true;
242
+ for (const reference of references) {
243
+ const table = quoteIdentifier(reference.table), column = quoteIdentifier(reference.column);
244
+ // Do not filter here: LIMIT bounds rows examined even when references are sparse.
245
+ const rows = db.prepare(`SELECT ${column} AS id FROM ${table} LIMIT 1001`).all();
246
+ if (rows.length > 1000)
247
+ referenceSamplesComplete = false;
248
+ for (const row of rows.slice(0, 1000)) {
249
+ if (!row.id)
250
+ continue;
251
+ sampledReferenceIds.add(row.id);
252
+ if (!db.prepare("SELECT 1 FROM payloads WHERE id = ?").get(row.id))
253
+ brokenReferences += 1;
254
+ }
255
+ }
256
+ const referencedRows = sample.filter((payload) => sampledReferenceIds.has(payload.id)).length;
257
+ const integrityComplete = rowCount.complete && payloadRows.length <= 1000 && referenceSamplesComplete;
258
+ return {
259
+ rows: rowCount.count,
260
+ rowsComplete: rowCount.complete,
261
+ sampledRows: sample.length,
262
+ metadataBytes: sample.reduce((total, row) => total + Number(row.bytes ?? 0), 0),
263
+ referencedRows,
264
+ metadataOrphans: integrityComplete ? sample.length - referencedRows : 0,
265
+ brokenReferences,
266
+ integrityComplete,
267
+ };
268
+ }
269
+ function inspectReleasedPayloadsBounded(_db, candidateIds) {
270
+ const bounded = [...new Set(candidateIds)].slice(0, 1000);
271
+ return {
272
+ releasedReferenceCandidates: candidateIds.length,
273
+ candidatesReported: bounded.length,
274
+ candidatesTruncated: candidateIds.length > bounded.length,
275
+ referenceState: "not_scanned_online",
276
+ retainedMetadata: bounded.length,
277
+ retainedFiles: bounded.length,
278
+ action: "report_only",
279
+ };
280
+ }
281
+ function discoverPayloadReferenceColumns(db) {
282
+ const tables = db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT 128").all();
283
+ const references = [];
284
+ for (const { name } of tables) {
285
+ const columns = db.prepare("SELECT name FROM pragma_table_info(?) LIMIT 128").all(name);
286
+ for (const column of columns) {
287
+ if (column.name === "payload_ref" || column.name === "content_payload_ref" || column.name === "payload_preview_ref")
288
+ references.push({ table: name, column: column.name });
289
+ }
290
+ }
291
+ return references.slice(0, 64);
292
+ }
293
+ function quoteIdentifier(value) { return `"${value.replaceAll('"', '""')}"`; }
294
+ function statEstimates(db) { const result = new Map(); if (!tableExists(db, "sqlite_stat1"))
295
+ return result; try {
296
+ for (const row of db.prepare("SELECT tbl, idx, stat FROM sqlite_stat1 LIMIT 256").all()) {
297
+ const estimate = Number.parseInt(row.stat.split(" ")[0] ?? "", 10);
298
+ if (Number.isFinite(estimate)) {
299
+ result.set(row.tbl, estimate);
300
+ if (row.idx)
301
+ result.set(row.idx, estimate);
302
+ }
303
+ }
304
+ }
305
+ catch { } return result; }
306
+ function boundedCount(db, name) { const quoted = `"${name.replaceAll('"', '""')}"`; try {
307
+ const count = Number(db.prepare(`SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${quoted} LIMIT ?)`).get(ROW_SAMPLE_LIMIT + 1).count);
308
+ return { count: Math.min(count, ROW_SAMPLE_LIMIT), complete: count <= ROW_SAMPLE_LIMIT };
309
+ }
310
+ catch {
311
+ return { count: 0, complete: false };
312
+ } }
313
+ function pragmaNumber(db, name) { return Number(Object.values(db.prepare(`PRAGMA ${name}`).get() ?? { value: 0 })[0] ?? 0); }
314
+ function tableExists(db, table) { return Boolean(db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?").get(table)); }
315
+ function columnExists(db, table, column) { return Boolean(db.prepare(`SELECT 1 FROM pragma_table_info(?) WHERE name=?`).get(table, column)); }
316
+ function fileSize(path) { try {
317
+ return statSync(path).size;
318
+ }
319
+ catch {
320
+ return 0;
321
+ } }
322
+ function validThreshold(value, fallback) { return Number.isSafeInteger(value) && value > 0 ? value : fallback; }
323
+ export function recordStorageMaintenance(path, value) { appendFileSync(`${resolve(path)}${MAINTENANCE_LOG_SUFFIX}`, `${JSON.stringify(value)}\n`, { mode: 0o600 }); }
324
+ function recordMaintenance(path, value) { recordStorageMaintenance(path, value); }
325
+ function readMaintenanceMetadata(path) { const result = {}; try {
326
+ const metadataPath = `${path}${MAINTENANCE_LOG_SUFFIX}`;
327
+ const bytes = statSync(metadataPath).size;
328
+ if (bytes > 1024 * 1024)
329
+ return result;
330
+ const lines = readFileSync(metadataPath, "utf8").trim().split("\n").slice(-100);
331
+ for (const line of lines) {
332
+ const row = JSON.parse(line);
333
+ const operation = row.operation;
334
+ if (operation === "checkpoint")
335
+ result.checkpoint = row;
336
+ else if (operation === "backup")
337
+ result.backup = row;
338
+ else if (operation === "verification")
339
+ result.verification = row;
340
+ else if (operation === "retention")
341
+ result.retention = row;
342
+ }
343
+ }
344
+ catch { } return result; }
@@ -0,0 +1,25 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ const [path, mode, fixture] = process.argv.slice(2);
3
+ const started = Date.now();
4
+ const send = (message) => process.send?.(message);
5
+ let db;
6
+ try {
7
+ send({ type: "progress", stage: "opened", elapsedMs: 0 });
8
+ db = new DatabaseSync(path, { readOnly: true });
9
+ db.exec("PRAGMA busy_timeout = 50");
10
+ const pragma = mode === "full" ? "integrity_check" : "quick_check";
11
+ send({ type: "progress", stage: pragma, elapsedMs: Date.now() - started });
12
+ if (fixture === "native-long") {
13
+ db.prepare("WITH RECURSIVE counter(value) AS (VALUES(0) UNION ALL SELECT value + 1 FROM counter WHERE value < 1000000000) SELECT SUM(value) FROM counter").get();
14
+ }
15
+ const rows = db.prepare(`PRAGMA ${pragma}`).all();
16
+ const messages = rows.slice(0, 100).flatMap((row) => Object.values(row).map(String));
17
+ send({ type: "result", ok: messages.length === 1 && messages[0] === "ok", messages, elapsedMs: Date.now() - started });
18
+ }
19
+ catch (error) {
20
+ send({ type: "error", message: error instanceof Error ? error.message.slice(0, 500) : "Verification failed", elapsedMs: Date.now() - started });
21
+ }
22
+ finally {
23
+ db?.close();
24
+ process.disconnect?.();
25
+ }
@@ -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;
@@ -103,6 +112,92 @@ export async function runDebugCli(argv = process.argv) {
103
112
  process.exitCode = 1;
104
113
  }
105
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
+ }
106
201
  async function runDebugIntegrity(args) {
107
202
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
108
203
  printDebugIntegrityDiscovery();
@@ -212,6 +307,31 @@ async function runDebugRepair(args) {
212
307
  printDebugRepairDiscovery();
213
308
  return;
214
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
+ }
215
335
  if (args[0] !== "output") {
216
336
  throw new Error(`Unknown pibo debug repair command "${args[0]}". Run pibo debug repair --help.`);
217
337
  }
@@ -907,7 +1027,7 @@ async function runDebugJobs(args) {
907
1027
  const reliability = new PiboReliabilityStore(store.path);
908
1028
  try {
909
1029
  if (command === "list") {
910
- const jobs = reliability.listJobs({ queue: options.queue, limit: options.limit ? Number(options.limit) : undefined });
1030
+ const jobs = reliability.inspectJobs({ queue: options.queue, limit: options.limit ? Number(options.limit) : undefined });
911
1031
  if (options.json)
912
1032
  console.log(formatJson({ jobs: jobs.map(observableJobRow) }));
913
1033
  else
@@ -933,6 +1053,27 @@ async function runDebugJobs(args) {
933
1053
  console.log(formatRows([compactJobRow(job)]));
934
1054
  return;
935
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
+ }
936
1077
  throw new Error(`Unknown pibo debug jobs command "${command}". Run pibo debug jobs --help.`);
937
1078
  }
938
1079
  finally {
@@ -1257,6 +1398,10 @@ function compactJobRow(job) {
1257
1398
  eventId: correlation.eventId,
1258
1399
  phase: correlation.phase,
1259
1400
  workerId: job.workerId,
1401
+ claimExpiresAt: job.claimExpiresAt,
1402
+ claimExpired: job.claimExpired,
1403
+ missingRunRecord: job.missingRunRecord,
1404
+ effectiveLiveness: job.effectiveLiveness,
1260
1405
  lastError: job.lastError,
1261
1406
  };
1262
1407
  }
@@ -1361,6 +1506,8 @@ function printDebugDiscovery() {
1361
1506
 
1362
1507
  Commands:
1363
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
1364
1511
  db Inspect and query local SQLite stores
1365
1512
  session Inspect one Pibo Session by id or Chat URL
1366
1513
  summary Show compact session diagnosis and drill-down commands
@@ -1383,6 +1530,7 @@ Commands:
1383
1530
  pty Run and inspect interactive CLI/TUI commands under a PTY
1384
1531
 
1385
1532
  Next:
1533
+ pibo debug storage status --json
1386
1534
  pibo debug db
1387
1535
  pibo debug summary <pibo-session-id>
1388
1536
  pibo debug final <pibo-session-id>
@@ -1456,6 +1604,8 @@ function printDebugRepairDiscovery() {
1456
1604
  Usage:
1457
1605
  pibo debug repair output <pibo-session-id> <event-id> [--dry-run|--apply] [--json]
1458
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]
1459
1609
 
1460
1610
  Behavior:
1461
1611
  Dry-run is the default. --apply is required for every mutation.
@@ -1464,6 +1614,8 @@ Behavior:
1464
1614
  Repair never invents assistant content. Without an exact Reliability terminal, only a completed assistant record can justify message_finished.
1465
1615
  Every applied terminal writes a pibo.output.repair_applied audit event in the same transaction.
1466
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.
1467
1619
 
1468
1620
  Next:
1469
1621
  pibo debug repair output ps_... <event-id> --dry-run --json
@@ -1704,9 +1856,11 @@ Usage:
1704
1856
  pibo debug jobs list [--queue queue] [--limit n] [--json]
1705
1857
  pibo debug jobs dead [--queue queue] [--limit n] [--json]
1706
1858
  pibo debug jobs replay <job-id> [--json]
1859
+ pibo debug jobs reconcile-runs (--dry-run|--apply) [--json]
1707
1860
 
1708
1861
  Next:
1709
1862
  pibo debug jobs list --queue runs
1863
+ pibo debug jobs reconcile-runs --dry-run
1710
1864
  pibo debug jobs dead --queue runs
1711
1865
  `);
1712
1866
  }