@pasko70/pibo 3.5.0 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-runtime/routed-session.js +80 -19
- package/dist/agent-runtimes/codex-native/turn.js +27 -3
- package/dist/apps/chat/data/chat-data-mappers.js +8 -1
- package/dist/apps/chat/data/read-state-service.js +24 -3
- package/dist/apps/chat/message-command-dispatcher.js +16 -1
- package/dist/apps/chat/web-app.js +40 -19
- package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-B-auLrzD.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-BA_dsINH.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-eJZar_0-.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-wNNR2Bci.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-zcmEsIEp.js} +1 -1
- package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-DEkbN5Vo.js} +43 -43
- package/dist/apps/chat-ui/assets/index-DZK6Tzil.css +1 -0
- package/dist/apps/chat-ui/index.html +2 -2
- package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DZgW1fCB.js} +11 -11
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/cli-session/localSessionSource.js +3 -2
- package/dist/core/output-render-sequence.js +63 -6
- package/dist/core/session-router.js +99 -11
- package/dist/data/async-chat-storage.js +7 -2
- package/dist/data/bounded-worker-client.js +1 -1
- package/dist/data/chat-read-projections.js +4 -4
- package/dist/data/chat-storage-worker.js +24 -3
- package/dist/data/ingest-service.js +73 -4
- package/dist/data/message-command-store.js +153 -11
- package/dist/data/schema.js +24 -3
- package/dist/data/storage-maintenance.js +344 -0
- package/dist/data/storage-verification-worker.js +25 -0
- package/dist/debug/index.js +155 -1
- package/dist/debug/message-queue.js +108 -0
- package/dist/debug/output-collision-repair.js +140 -0
- package/dist/debug/output-integrity.js +38 -2
- package/dist/debug/output-repair.js +1 -0
- package/dist/debug/storage-backup.js +12 -4
- package/dist/debug/storage-maintenance.js +78 -0
- package/dist/gateway/cli.js +71 -7
- package/dist/gateway/server.js +1 -0
- package/dist/reliability/store.js +119 -23
- package/dist/session-ui/terminalRows.js +7 -8
- package/dist/sessions/pibo-data-store.js +18 -14
- package/dist/shared/trace-event-projection.js +13 -3
- package/dist/web/channel.js +114 -12
- package/dist/web/http.js +105 -44
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-hEkrlRk-.css +0 -1
|
@@ -0,0 +1,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
|
+
}
|
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;
|
|
@@ -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.
|
|
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
|
}
|