@pasko70/pibo 3.4.4 → 3.5.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.
Files changed (79) hide show
  1. package/dist/agent-runtime/routed-session.js +37 -12
  2. package/dist/agent-runtimes/pi/adapter.js +1 -0
  3. package/dist/agent-runtimes/pi/routed-session.js +2 -2
  4. package/dist/apps/chat/bounded-event-stream.js +98 -0
  5. package/dist/apps/chat/chat-settings-routes.js +3 -3
  6. package/dist/apps/chat/data/chat-data-mappers.js +32 -13
  7. package/dist/apps/chat/data/event-command-service.js +30 -21
  8. package/dist/apps/chat/data/history-query-service.js +34 -25
  9. package/dist/apps/chat/data/read-state-service.js +13 -0
  10. package/dist/apps/chat/data/session-query-service.js +19 -11
  11. package/dist/apps/chat/data/timeline-query-service.js +19 -7
  12. package/dist/apps/chat/message-command-dispatcher.js +134 -0
  13. package/dist/apps/chat/output-compactor.js +9 -0
  14. package/dist/apps/chat/output-event-policy.js +9 -1
  15. package/dist/apps/chat/stream.js +21 -3
  16. package/dist/apps/chat/telemetry-retention-service.js +113 -8
  17. package/dist/apps/chat/trace-response-cache.js +46 -0
  18. package/dist/apps/chat/trace-v2.js +12 -6
  19. package/dist/apps/chat/trace.js +1 -1
  20. package/dist/apps/chat/web-app.js +608 -281
  21. package/dist/apps/chat-ui/assets/{dist-Bf2KScPo.js → dist-BG0n7zLd.js} +1 -1
  22. package/dist/apps/chat-ui/assets/{dist-23lt_7qm.js → dist-D6TjFhAm.js} +1 -1
  23. package/dist/apps/chat-ui/assets/{dist-V06sfuZa.js → dist-D79vyxSX.js} +1 -1
  24. package/dist/apps/chat-ui/assets/{dist-CH3SvpYV.js → dist-DFZ8cwh0.js} +1 -1
  25. package/dist/apps/chat-ui/assets/{dist-DsgL8w-W.js → dist-cOjokPrK.js} +1 -1
  26. package/dist/apps/chat-ui/assets/index-RMHUTJ62.js +229 -0
  27. package/dist/apps/chat-ui/assets/{index-BOceJ0jM.css → index-hEkrlRk-.css} +1 -1
  28. package/dist/apps/chat-ui/index.html +2 -2
  29. package/dist/apps/chat-vscode-web/assets/index-xacbCyTx.js +44 -0
  30. package/dist/apps/chat-vscode-web/index.html +1 -1
  31. package/dist/compute/pool/seeds.js +25 -3
  32. package/dist/core/events.js +4 -0
  33. package/dist/core/output-render-sequence.js +2 -0
  34. package/dist/core/provider-capacity.js +33 -0
  35. package/dist/core/provider-telemetry.js +21 -6
  36. package/dist/core/runtime-capacity.js +174 -0
  37. package/dist/core/runtime-telemetry.js +40 -12
  38. package/dist/core/session-router.js +79 -2
  39. package/dist/data/async-chat-reads.js +38 -0
  40. package/dist/data/async-chat-storage.js +91 -0
  41. package/dist/data/async-telemetry-maintenance.js +9 -0
  42. package/dist/data/bounded-worker-client.js +256 -0
  43. package/dist/data/chat-read-projections.js +159 -0
  44. package/dist/data/chat-read-worker.js +73 -0
  45. package/dist/data/chat-storage-worker.js +146 -0
  46. package/dist/data/ingest-service.js +79 -14
  47. package/dist/data/message-command-store.js +148 -0
  48. package/dist/data/payload-store.js +92 -11
  49. package/dist/data/pibo-store.js +10 -8
  50. package/dist/data/schema.js +16 -2
  51. package/dist/data/session-store.js +3 -1
  52. package/dist/data/storage-backup.js +278 -0
  53. package/dist/data/telemetry-capture.js +188 -0
  54. package/dist/data/telemetry-command.js +3 -0
  55. package/dist/data/telemetry-maintenance-worker.js +40 -0
  56. package/dist/data/telemetry-maintenance.js +110 -0
  57. package/dist/data/telemetry-retention.js +16 -7
  58. package/dist/data/telemetry-worker.js +111 -0
  59. package/dist/data/telemetry-writer.js +150 -83
  60. package/dist/data/telemetry.js +5 -0
  61. package/dist/debug/index.js +52 -0
  62. package/dist/debug/storage-backup.js +33 -0
  63. package/dist/debug/telemetry-capture.js +66 -0
  64. package/dist/gateway/server.js +2 -0
  65. package/dist/providers/openai-gpt56.js +11 -6
  66. package/dist/session-ui/terminalRows.js +48 -6
  67. package/dist/sessions/pibo-data-store.js +1 -0
  68. package/dist/shared/debug-features.js +4 -0
  69. package/dist/shared/model-inference-metrics.js +23 -0
  70. package/dist/shared/trace-event-projection.js +59 -2
  71. package/dist/shared/trace-history.js +9 -0
  72. package/dist/shared/trace-live-reducer.js +1 -0
  73. package/dist/shared/trace-patch-nodes.js +19 -0
  74. package/dist/web/channel.js +3 -0
  75. package/dist/web/http.js +36 -3
  76. package/npm-shrinkwrap.json +2 -2
  77. package/package.json +1 -1
  78. package/dist/apps/chat-ui/assets/index-BOemYq-V.js +0 -228
  79. package/dist/apps/chat-vscode-web/assets/index-CMwTB8o8.js +0 -43
@@ -0,0 +1,110 @@
1
+ export const TELEMETRY_MAINTENANCE_SCHEMA = `CREATE TABLE IF NOT EXISTS telemetry_maintenance_job (
2
+ id INTEGER PRIMARY KEY CHECK(id=1),retention_scope TEXT,cutoff TEXT NOT NULL,status TEXT NOT NULL,table_index INTEGER NOT NULL DEFAULT 0,class_index INTEGER NOT NULL DEFAULT 0,cursor_time TEXT NOT NULL DEFAULT '',cursor_row INTEGER NOT NULL DEFAULT 0,
3
+ scanned INTEGER NOT NULL DEFAULT 0,deleted INTEGER NOT NULL DEFAULT 0,protected INTEGER NOT NULL DEFAULT 0,batches INTEGER NOT NULL DEFAULT 0,updated_at TEXT NOT NULL
4
+ );`;
5
+ const CLASSES = ["live", "diagnostic", "provider_event", "payload_preview", "incident"];
6
+ /** A live owner updates the job on every bounded batch, so a stale stamp proves it stopped. */
7
+ export const TELEMETRY_MAINTENANCE_OWNER_LEASE_MS = 30_000;
8
+ const TABLES = [
9
+ { name: "telemetry_provider_events", time: "received_at", status: false },
10
+ { name: "telemetry_tool_calls", time: "updated_at", status: true },
11
+ { name: "telemetry_provider_requests", time: "updated_at", status: true },
12
+ { name: "telemetry_phases", time: "updated_at", status: true },
13
+ { name: "telemetry_turns", time: "updated_at", status: true },
14
+ ];
15
+ /** Only optional telemetry rows are eligible. No product, receipt, retry or payload deletion. */
16
+ export class TelemetryMaintenance {
17
+ db;
18
+ constructor(db) {
19
+ this.db = db;
20
+ }
21
+ status() { return this.db.prepare("SELECT * FROM telemetry_maintenance_job WHERE id=1").get(); }
22
+ start(cutoff, retentionScope) {
23
+ if (retentionScope && !CLASSES.includes(retentionScope))
24
+ throw Error("Invalid maintenance retention scope");
25
+ if (!Number.isFinite(Date.parse(cutoff)))
26
+ throw Error("Invalid telemetry cutoff");
27
+ const existing = this.status();
28
+ if (existing && (existing.status === "running" || existing.status === "paused")) {
29
+ if (existing.retention_scope === (retentionScope ?? null))
30
+ return existing;
31
+ if (existing.status === "paused")
32
+ throw Error("Telemetry maintenance is paused; resume or cancel it explicitly before changing scope");
33
+ // A different scope only conflicts while its owner still steps the job. A persisted job whose
34
+ // owner stopped updating it is reclaimable, so one narrow manual prune that outlived its process
35
+ // cannot disable automatic retention permanently.
36
+ if (Date.now() - Date.parse(existing.updated_at) < TELEMETRY_MAINTENANCE_OWNER_LEASE_MS)
37
+ throw Error("Another retention scope is already running");
38
+ }
39
+ this.db.prepare(`INSERT INTO telemetry_maintenance_job(id,cutoff,status,updated_at,retention_scope,class_index) VALUES(1,?,'running',?,?,?)
40
+ ON CONFLICT(id) DO UPDATE SET cutoff=excluded.cutoff,status='running',table_index=0,retention_scope=excluded.retention_scope,class_index=excluded.class_index,cursor_time='',cursor_row=0,scanned=0,deleted=0,protected=0,batches=0,updated_at=excluded.updated_at`).run(cutoff, new Date().toISOString(), retentionScope ?? null, retentionScope ? CLASSES.indexOf(retentionScope) : 0);
41
+ return this.status();
42
+ }
43
+ control(action) {
44
+ const status = action === "pause" ? "paused" : action === "resume" ? "running" : "cancelled";
45
+ this.db.prepare("UPDATE telemetry_maintenance_job SET status=?,updated_at=? WHERE id=1 AND status IN ('running','paused')").run(status, new Date().toISOString());
46
+ return this.status();
47
+ }
48
+ step(options = {}) {
49
+ const limit = options.rows ?? 128, budget = options.milliseconds ?? 4;
50
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 512 || !Number.isFinite(budget) || budget <= 0 || budget > 20)
51
+ throw Error("Invalid maintenance batch budget");
52
+ if (this.status()?.status !== "running")
53
+ return this.status();
54
+ this.db.exec("BEGIN IMMEDIATE");
55
+ try {
56
+ const state = this.status();
57
+ if (state.status !== "running") {
58
+ this.db.exec("COMMIT");
59
+ return state;
60
+ }
61
+ const spec = TABLES[state.table_index];
62
+ const rows = this.db.prepare(`SELECT rowid AS cursor_row,${spec.time} AS cursor_time,pibo_session_id,turn_id ${spec.name !== "telemetry_turns" ? ",provider_request_id" : ""} ${spec.status ? ',status' : ''} FROM ${spec.name}
63
+ WHERE retention_class=? AND (${spec.time},rowid)>(?,?) AND ${spec.time}<? ORDER BY ${spec.time},rowid LIMIT ?`).all(CLASSES[state.class_index], state.cursor_time, state.cursor_row, state.cutoff, limit);
64
+ const activeSession = this.db.prepare("SELECT 1 FROM sessions WHERE id=? AND status='running'");
65
+ const activeTurn = this.db.prepare("SELECT 1 FROM telemetry_turns WHERE turn_id=? AND status IN ('queued','running')");
66
+ const activeProvider = this.db.prepare("SELECT 1 FROM telemetry_provider_requests p WHERE p.provider_request_id=? AND (p.status NOT IN ('completed','error','aborted','timeout') OR EXISTS (SELECT 1 FROM telemetry_turns t WHERE t.turn_id=p.turn_id AND t.status IN ('queued','running'))) ");
67
+ const remove = this.db.prepare(`DELETE FROM ${spec.name} WHERE rowid=? AND ${spec.time}=?`);
68
+ const started = performance.now();
69
+ let scanned = 0, deleted = 0, protectedRows = 0;
70
+ let cursorTime = state.cursor_time, cursorRow = state.cursor_row;
71
+ for (const row of rows) {
72
+ const terminal = !spec.status || ["ok", "completed", "error", "aborted", "timeout"].includes(row.status ?? "");
73
+ if (!terminal || (row.pibo_session_id && activeSession.get(row.pibo_session_id)) || (row.turn_id && activeTurn.get(row.turn_id)) || (row.provider_request_id && activeProvider.get(row.provider_request_id)))
74
+ protectedRows++;
75
+ else
76
+ deleted += Number(remove.run(row.cursor_row, row.cursor_time).changes);
77
+ scanned++;
78
+ cursorTime = row.cursor_time;
79
+ cursorRow = row.cursor_row;
80
+ if (performance.now() - started >= budget)
81
+ break;
82
+ }
83
+ let tableIndex = state.table_index, classIndex = state.class_index;
84
+ let status = state.status;
85
+ if (scanned === rows.length && rows.length < limit) {
86
+ cursorTime = "";
87
+ cursorRow = 0;
88
+ if (state.retention_scope) {
89
+ tableIndex++;
90
+ }
91
+ else {
92
+ classIndex++;
93
+ if (classIndex >= CLASSES.length) {
94
+ classIndex = 0;
95
+ tableIndex++;
96
+ }
97
+ }
98
+ if (tableIndex >= TABLES.length)
99
+ status = "completed";
100
+ }
101
+ this.db.prepare(`UPDATE telemetry_maintenance_job SET table_index=?,class_index=?,cursor_time=?,cursor_row=?,status=?,scanned=scanned+?,deleted=deleted+?,protected=protected+?,batches=batches+1,updated_at=? WHERE id=1`).run(tableIndex, classIndex, cursorTime, cursorRow, status, scanned, deleted, protectedRows, new Date().toISOString());
102
+ this.db.exec("COMMIT");
103
+ return this.status();
104
+ }
105
+ catch (error) {
106
+ this.db.exec("ROLLBACK");
107
+ throw error;
108
+ }
109
+ }
110
+ }
@@ -1,3 +1,4 @@
1
+ import { TelemetryMaintenance, TELEMETRY_MAINTENANCE_SCHEMA } from "./telemetry-maintenance.js";
1
2
  const RETENTION_CLASSES = ["live", "diagnostic", "provider_event", "incident", "payload_preview"];
2
3
  const PRUNE_TABLES = [
3
4
  { table: "telemetry_provider_events", cutoffColumn: "received_at", byteExpression: "byte_size" },
@@ -23,16 +24,24 @@ export function getTelemetryRetentionStats(db) {
23
24
  };
24
25
  }
25
26
  export function pruneTelemetryRetention(db, input) {
26
- const plan = telemetryPrunePlan(db, input.retentionClass, input.before);
27
27
  if (!input.apply) {
28
+ const plan = telemetryPrunePlan(db, input.retentionClass, input.before);
28
29
  return { retentionClass: input.retentionClass, before: input.before, applied: false, rowsMatched: plan.rows, bytesMatched: plan.bytes, rowsDeleted: 0 };
29
30
  }
30
- let rowsDeleted = 0;
31
- for (const spec of PRUNE_TABLES) {
32
- const result = db.prepare(`DELETE FROM ${spec.table} WHERE retention_class = ? AND ${spec.cutoffColumn} < ?`).run(input.retentionClass, input.before);
33
- rowsDeleted += Number(result.changes ?? 0);
34
- }
35
- return { retentionClass: input.retentionClass, before: input.before, applied: true, rowsMatched: plan.rows, bytesMatched: plan.bytes, rowsDeleted };
31
+ db.exec(TELEMETRY_MAINTENANCE_SCHEMA);
32
+ const maintenance = new TelemetryMaintenance(db);
33
+ let state = maintenance.start(input.before, input.retentionClass);
34
+ if (state.status === "paused")
35
+ state = maintenance.control("resume");
36
+ const previous = state.deleted, started = performance.now();
37
+ let scanned = 0;
38
+ do {
39
+ const before = state.scanned;
40
+ state = maintenance.step({ rows: Math.min(128 - scanned, 128), milliseconds: 4 });
41
+ scanned += state.scanned - before;
42
+ } while (state.status === "running" && scanned < 128 && performance.now() - started < 20);
43
+ const rowsDeleted = state.deleted - previous;
44
+ return { retentionClass: input.retentionClass, before: state.cutoff, applied: true, rowsMatched: scanned, bytesMatched: 0, rowsDeleted, completed: state.status === "completed" };
36
45
  }
37
46
  function statsForTable(db, table, sqlTable, byteExpression, retentionClass) {
38
47
  const row = db.prepare(`SELECT COUNT(*) AS row_count, COALESCE(SUM(${byteExpression}), 0) AS byte_count FROM ${sqlTable} WHERE retention_class = ?`).get(retentionClass);
@@ -0,0 +1,111 @@
1
+ import { dirname, join } from "node:path";
2
+ import { TelemetryCaptureWriter } from "./telemetry-capture.js";
3
+ import { statSync } from "node:fs";
4
+ import { parentPort, workerData, threadId } from "node:worker_threads";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { TelemetryStore } from "./telemetry.js";
7
+ import { PiboRuntimeTelemetryRecorder } from "../core/runtime-telemetry.js";
8
+ import { PiboProviderTelemetryRecorder } from "../core/provider-telemetry.js";
9
+ const db = new DatabaseSync(workerData.path);
10
+ db.exec("PRAGMA busy_timeout=10; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON");
11
+ const store = new TelemetryStore(db);
12
+ const capture = new TelemetryCaptureWriter(join(dirname(workerData.path), "telemetry-captures"));
13
+ const captureTimer = setInterval(() => capture.append([]), 1000);
14
+ captureTimer.unref();
15
+ const measured = Boolean(workerData.measure);
16
+ let currentKind = "startup";
17
+ const measurements = {};
18
+ if (measured) {
19
+ const prepare = db.prepare.bind(db);
20
+ db.prepare = (sql) => {
21
+ const statement = prepare(sql);
22
+ for (const method of ["run", "get", "all"]) {
23
+ const original = statement[method].bind(statement);
24
+ statement[method] = (...args) => {
25
+ const kind = measurements[currentKind];
26
+ const verb = sql.trim().split(/\s/, 1)[0].toLowerCase();
27
+ if (kind) {
28
+ const label = ["insert", "update", "delete", "select"].includes(verb) ? verb : "other";
29
+ kind.sql[label] = (kind.sql[label] ?? 0) + 1;
30
+ }
31
+ return original(...args);
32
+ };
33
+ }
34
+ return statement;
35
+ };
36
+ }
37
+ let errors = 0;
38
+ let transactions = 0;
39
+ let operations = 0;
40
+ let busy = 0;
41
+ const runtimes = new Map();
42
+ function runtimeFor(input) {
43
+ const key = `${input.providerEventMode}:${input.progressFlushIntervalMs}`;
44
+ let runtime = runtimes.get(key);
45
+ if (!runtime) {
46
+ if (runtimes.size >= 4)
47
+ runtimes.delete(runtimes.keys().next().value);
48
+ runtime = new PiboRuntimeTelemetryRecorder(store, () => { errors++; }, { providerEventMode: input.providerEventMode, progressFlushIntervalMs: input.progressFlushIntervalMs });
49
+ runtimes.set(key, runtime);
50
+ }
51
+ return runtime;
52
+ }
53
+ parentPort.on("message", (request) => {
54
+ const started = performance.now();
55
+ let processed = 0;
56
+ const initialErrors = errors;
57
+ try {
58
+ if (request.command.commands.length > 64)
59
+ throw Error("Telemetry batch count exceeded");
60
+ if (started > request.deadline)
61
+ throw Error("Telemetry batch expired");
62
+ try {
63
+ store.transaction(() => {
64
+ for (const input of request.command.commands) {
65
+ if (processed && performance.now() - started >= 8)
66
+ break;
67
+ currentKind = input.recorder === "runtime" && input.command.kind === "output" ? input.command.event.type : `${input.recorder}.${input.command.kind}`;
68
+ if (measured && !measurements[currentKind]) {
69
+ if (Object.keys(measurements).length >= 32)
70
+ currentKind = "other";
71
+ measurements[currentKind] ??= { operations: 0, sql: {}, executionMs: 0 };
72
+ }
73
+ const operationStart = performance.now();
74
+ if (input.recorder === "runtime")
75
+ runtimeFor(input).executeTelemetryCommand(input.command);
76
+ else
77
+ new PiboProviderTelemetryRecorder({ store, session: input.session, model: input.model, onError: () => { errors++; } }).executeTelemetryCommand(input.command);
78
+ if (measured) {
79
+ measurements[currentKind].operations++;
80
+ measurements[currentKind].executionMs += performance.now() - operationStart;
81
+ }
82
+ processed++;
83
+ }
84
+ });
85
+ transactions++;
86
+ operations += processed;
87
+ }
88
+ catch (error) {
89
+ runtimes.clear();
90
+ if (error instanceof Error && /busy|locked/i.test(error.message)) {
91
+ busy++;
92
+ processed = 0;
93
+ }
94
+ else
95
+ throw error;
96
+ }
97
+ if (processed)
98
+ capture.append(request.command.commands.slice(0, processed));
99
+ const result = { processed, errors: Math.min(processed, errors - initialErrors), ms: performance.now() - started, stats: { pid: process.pid, threadId, capture: capture.status(), transactions, operations, busy, rssBytes: process.memoryUsage.rss(), heapUsedBytes: process.memoryUsage().heapUsed, synchronous: "FULL", ...(measured ? { measurements, walBytes: (() => { try {
100
+ return statSync(workerData.path + "-wal").size;
101
+ }
102
+ catch {
103
+ return 0;
104
+ } })() } : {}) } };
105
+ parentPort.postMessage({ id: request.id, value: result });
106
+ }
107
+ catch {
108
+ parentPort.postMessage({ id: request.id, error: { code: "telemetry_failed", message: "Optional telemetry batch failed" } });
109
+ }
110
+ });
111
+ parentPort.postMessage({ ready: true, worker: { pid: process.pid, threadId, kind: "telemetry" } });
@@ -1,114 +1,181 @@
1
- const DEFAULT_FLUSH_INTERVAL_MS = 25;
2
- const DEFAULT_MAX_PENDING_OPERATIONS = 1_024;
3
- /**
4
- * Gateway-scoped, ordered telemetry writer.
5
- *
6
- * Normal writes are deferred briefly so telemetry from multiple routed sessions
7
- * shares one SQLite transaction. The queue never drops lifecycle events: when
8
- * the hard bound is reached, it drains immediately in the caller instead.
9
- */
1
+ import { BoundedWorkerClient, boundedMessageBytes } from "./bounded-worker-client.js";
2
+ import { isTelemetryProgress } from "./telemetry-command.js";
3
+ /** Optional diagnostic projection. Product lifecycle/final events remain owned by the durable output outbox. */
10
4
  export class AsyncTelemetryWriter {
11
5
  store;
12
6
  options;
13
- flushIntervalMs;
14
- maxPendingOperations;
7
+ client;
8
+ path;
9
+ restartAfter = 0;
10
+ restarts = 0;
15
11
  pending = [];
12
+ pendingBytes = 0;
13
+ inFlight = 0;
16
14
  flushTimer;
17
- flushing = false;
15
+ flushing;
18
16
  closed = false;
17
+ limit;
18
+ byteLimit;
19
+ ageLimit;
20
+ interval;
21
+ accepted = 0;
22
+ completed = 0;
23
+ rejected = 0;
24
+ failed = 0;
25
+ expired = 0;
26
+ batches = 0;
27
+ maxBatchMs = 0;
28
+ workerStats;
19
29
  constructor(store, options = {}) {
20
30
  this.store = store;
21
31
  this.options = options;
22
- this.flushIntervalMs = nonNegativeFinite(options.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS);
23
- this.maxPendingOperations = positiveInteger(options.maxPendingOperations, DEFAULT_MAX_PENDING_OPERATIONS);
32
+ this.limit = options.maxPendingOperations ?? 1024;
33
+ this.byteLimit = options.maxPendingBytes ?? 4 * 1024 * 1024;
34
+ this.ageLimit = options.maxAgeMs ?? 2000;
35
+ this.interval = options.flushIntervalMs ?? 25;
36
+ for (const n of [this.limit, this.byteLimit, this.ageLimit])
37
+ if (!Number.isSafeInteger(n) || n <= 0)
38
+ throw Error("Telemetry budgets must be positive integers");
39
+ if (!Number.isFinite(this.interval) || this.interval < 0)
40
+ throw Error("Invalid telemetry batching interval");
41
+ this.path = store.databasePath;
42
+ if (this.path)
43
+ this.startWorker();
24
44
  }
45
+ startWorker() {
46
+ this.restartAfter = Date.now() + 1000;
47
+ this.client = new BoundedWorkerClient(new URL("./telemetry-worker.js", import.meta.url), { maxPending: 1, maxPendingBytes: 512 * 1024, maxMessageBytes: 512 * 1024, maxAgeMs: this.ageLimit, workerOptions: { workerData: { path: this.path, measure: this.options.measure === true } } });
48
+ }
49
+ /** Compatibility for the local in-memory adapter only; closures never execute on a file-backed producer. */
25
50
  enqueue(write, onError) {
26
- if (this.closed) {
27
- this.reportError(new Error("Telemetry writer is closed."), onError);
51
+ if (this.client) {
52
+ this.reject(Error("File telemetry requires a structured command"), onError);
28
53
  return false;
29
54
  }
30
- this.pending.push({ write, onError });
31
- if (this.pending.length >= this.maxPendingOperations) {
32
- this.flushNow();
55
+ return this.append({ write, onError, bytes: 64, at: performance.now() }, false);
56
+ }
57
+ record(command, fallback, onError) {
58
+ try {
59
+ const bytes = boundedMessageBytes(command, 64 * 1024);
60
+ return this.append({ ...(this.client ? { command: structuredClone(command) } : { write: fallback }), bytes, onError, at: performance.now() }, isTelemetryProgress(command));
33
61
  }
34
- else {
35
- this.scheduleFlush();
62
+ catch (error) {
63
+ this.reject(error, onError);
64
+ return false;
36
65
  }
37
- return true;
38
66
  }
39
- async flush() {
40
- this.flushNow();
41
- }
42
- async dispose() {
43
- if (this.closed)
44
- return;
45
- this.flushNow();
46
- this.closed = true;
67
+ append(item, progress) {
68
+ const reserve = progress ? Math.min(128, Math.floor(this.limit / 4)) : 0;
69
+ const reserveBytes = progress ? Math.min(512 * 1024, Math.floor(this.byteLimit / 4)) : 0;
70
+ if (this.closed || this.pending.length + this.inFlight >= this.limit - reserve || this.pendingBytes + item.bytes > this.byteLimit - reserveBytes) {
71
+ this.reject(Error(this.closed ? "Telemetry writer is closed" : "Optional telemetry capacity exhausted"), item.onError);
72
+ return false;
73
+ }
74
+ item.sequence = ++this.accepted;
75
+ this.pending.push(item);
76
+ this.pendingBytes += item.bytes;
77
+ // Pressure only schedules asynchronous work; it never drains SQLite in the producer.
78
+ this.schedule(this.pending.length >= 64 ? 0 : this.interval);
79
+ return true;
47
80
  }
48
- scheduleFlush() {
49
- if (this.flushTimer)
81
+ schedule(delay) {
82
+ if (this.flushTimer || this.flushing || this.closed)
50
83
  return;
51
- this.flushTimer = setTimeout(() => {
52
- this.flushTimer = undefined;
53
- this.flushNow();
54
- }, this.flushIntervalMs);
55
- this.flushTimer.unref();
84
+ this.flushTimer = setTimeout(() => { this.flushTimer = undefined; void this.flush(); }, delay);
85
+ this.flushTimer.unref?.();
56
86
  }
57
- flushNow() {
58
- if (this.flushing)
59
- return;
87
+ async flush() {
88
+ const target = this.accepted;
60
89
  if (this.flushTimer)
61
90
  clearTimeout(this.flushTimer);
62
91
  this.flushTimer = undefined;
63
- this.flushing = true;
64
- try {
65
- while (this.pending.length > 0) {
66
- const batch = this.pending;
67
- this.pending = [];
68
- try {
69
- this.store.transaction(() => {
70
- for (const operation of batch) {
71
- try {
72
- operation.write();
73
- }
74
- catch (error) {
75
- this.reportError(error, operation.onError);
76
- }
77
- }
78
- });
92
+ for (;;) {
93
+ if (this.flushing) {
94
+ await this.flushing;
95
+ continue;
96
+ }
97
+ if (!this.pending.length || this.pending[0].sequence > target)
98
+ return;
99
+ this.flushing = this.drain(target).finally(() => { this.flushing = undefined; if (this.pending.length && !this.closed)
100
+ this.schedule(0); });
101
+ await this.flushing;
102
+ }
103
+ }
104
+ async drain(target) {
105
+ while (this.pending.length && this.pending[0].sequence <= target) {
106
+ const batch = [];
107
+ let bytes = 0;
108
+ while (this.pending.length && this.pending[0].sequence <= target && batch.length < 64 && bytes + this.pending[0].bytes <= 256 * 1024) {
109
+ const item = this.pending.shift();
110
+ bytes += item.bytes;
111
+ batch.push(item);
112
+ }
113
+ this.inFlight = batch.length;
114
+ const live = batch.filter(item => { if (performance.now() - item.at <= this.ageLimit)
115
+ return true; this.expired++; return false; });
116
+ let processed = live.length;
117
+ try {
118
+ if (live.length && this.client) {
119
+ if (this.client.status().closed && this.client.status().exited && Date.now() >= this.restartAfter) {
120
+ this.restarts++;
121
+ this.startWorker();
122
+ }
123
+ // Startup consumes the same bounded queue age, without loading a second schema owner on the gateway thread.
124
+ while (!this.client.status().ready && !this.client.status().closed && performance.now() - live[0].at < this.ageLimit)
125
+ await new Promise(resolve => setTimeout(resolve, 5));
126
+ const remainingAge = this.ageLimit - (performance.now() - live[0].at);
127
+ if (remainingAge < Math.min(50, this.ageLimit / 4)) {
128
+ this.expired += live.length;
129
+ }
130
+ else {
131
+ const result = await this.client.request({ commands: live.map(item => item.command) }, { priority: "background", timeoutMs: remainingAge });
132
+ processed = result.processed;
133
+ this.failed += result.errors;
134
+ this.completed += processed - result.errors;
135
+ this.maxBatchMs = Math.max(this.maxBatchMs, result.ms);
136
+ this.workerStats = result.stats;
137
+ this.batches++;
138
+ }
79
139
  }
80
- catch (error) {
81
- for (const operation of batch)
82
- this.reportError(error, operation.onError);
140
+ else if (live.length) {
141
+ // Explicit in-memory test adapter uses the same bounded batches and asynchronous entry boundary.
142
+ await new Promise(resolve => setImmediate(resolve));
143
+ this.store.transaction(() => { for (const item of live) {
144
+ try {
145
+ item.write?.();
146
+ this.completed++;
147
+ }
148
+ catch (error) {
149
+ this.failed++;
150
+ this.report(error, item.onError);
151
+ }
152
+ } });
153
+ this.batches++;
83
154
  }
84
155
  }
85
- }
86
- finally {
87
- this.flushing = false;
88
- if (!this.closed && this.pending.length > 0)
89
- this.scheduleFlush();
156
+ catch (error) {
157
+ this.failed += live.length;
158
+ for (const item of live)
159
+ this.report(error, item.onError);
160
+ }
161
+ const remaining = live.slice(processed);
162
+ this.pending.unshift(...remaining);
163
+ this.pendingBytes -= bytes - remaining.reduce((sum, item) => sum + item.bytes, 0);
164
+ this.inFlight = 0;
165
+ if (remaining.length)
166
+ await new Promise(resolve => setTimeout(resolve, 10));
90
167
  }
91
168
  }
92
- reportError(error, operationHandler) {
93
- try {
94
- operationHandler?.(error);
95
- }
96
- catch {
97
- // Telemetry error reporting must not affect runtime work.
98
- }
99
- if (operationHandler === this.options.onError)
100
- return;
169
+ status() { return { mode: this.client ? "worker" : "in-memory", closed: this.closed, queued: this.pending.length, inFlight: this.inFlight, pendingBytes: this.pendingBytes, oldestAgeMs: this.pending.length ? performance.now() - this.pending[0].at : 0, accepted: this.accepted, restarts: this.restarts, completed: this.completed, rejected: this.rejected, failed: this.failed, expired: this.expired, batches: this.batches, maxBatchMs: this.maxBatchMs, worker: this.workerStats, transport: this.client ? { ready: this.client.status().ready, closed: this.client.status().closed, exited: this.client.status().exited } : undefined, limits: { count: this.limit, bytes: this.byteLimit, ageMs: this.ageLimit, batchCount: 64, batchBytes: 256 * 1024, batchTimeMs: 8 } }; }
170
+ async dispose() { if (this.closed)
171
+ return; this.closed = true; await this.flush(); await this.client?.close(); }
172
+ reject(error, handler) { this.rejected++; this.report(error, handler); }
173
+ report(error, handler) { try {
174
+ handler?.(error);
175
+ }
176
+ catch { } if (handler !== this.options.onError)
101
177
  try {
102
178
  this.options.onError?.(error);
103
179
  }
104
- catch {
105
- // Telemetry error reporting must not affect runtime work.
106
- }
107
- }
108
- }
109
- function nonNegativeFinite(value, fallback) {
110
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
111
- }
112
- function positiveInteger(value, fallback) {
113
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
180
+ catch { } }
114
181
  }
@@ -8,6 +8,11 @@ export class TelemetryStore {
8
8
  constructor(db) {
9
9
  this.db = db;
10
10
  }
11
+ /** SQLite-owned path; empty for the explicitly local in-memory test adapter. */
12
+ get databasePath() {
13
+ const row = this.db.prepare("PRAGMA database_list").all().find(row => row.name === "main");
14
+ return typeof row?.file === "string" && row.file ? row.file : undefined;
15
+ }
11
16
  transaction(action) {
12
17
  if (this.db.isTransaction)
13
18
  return action();
@@ -8,6 +8,11 @@ export async function runDebugCli(argv = process.argv) {
8
8
  printDebugDiscovery();
9
9
  return;
10
10
  }
11
+ if (args[0] === "backup") {
12
+ const { runStorageBackupCli } = await import("./storage-backup.js");
13
+ await runStorageBackupCli(args.slice(1));
14
+ return;
15
+ }
11
16
  if (args[0] === "db") {
12
17
  await runDebugDb(args.slice(1));
13
18
  return;
@@ -536,11 +541,55 @@ async function runDebugSignals(args) {
536
541
  console.log(formatSignalSnapshotText(payload));
537
542
  }
538
543
  }
544
+ async function runDebugTelemetryMaintenance(args) {
545
+ if (!args.length || args.includes("--help") || args.includes("-h")) {
546
+ console.log(`pibo debug telemetry maintenance - bounded optional diagnostic cleanup
547
+ Commands:
548
+ status Read the durable job and progress
549
+ start --before <iso> --apply Start a job; no deletion until step or gateway worker
550
+ step --apply Execute at most 128 rows / 4 ms cooperatively
551
+ pause --apply Pause between batches
552
+ resume --apply Mark the paused job ready
553
+ cancel --apply Cancel without undoing committed batches
554
+ Use --json for structured output. Product history, receipts and payload files are excluded.`);
555
+ return;
556
+ }
557
+ const action = args[0];
558
+ if (!["status", "start", "step", "pause", "resume", "cancel"].includes(action))
559
+ throw Error("Unknown maintenance action; use maintenance --help");
560
+ const options = parseOptions(args.slice(1));
561
+ if (action !== "status" && !options.apply)
562
+ throw Error("Maintenance mutations require --apply; inspect status first");
563
+ const store = resolveDebugStore("pibo-data");
564
+ if (!store.exists)
565
+ throw Error("Pibo data store is missing");
566
+ const { DatabaseSync } = await import("node:sqlite");
567
+ const { TelemetryMaintenance } = await import("../data/telemetry-maintenance.js");
568
+ const db = new DatabaseSync(store.path, { readOnly: action === "status" });
569
+ db.exec("PRAGMA busy_timeout=10; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON");
570
+ try {
571
+ const maintenance = new TelemetryMaintenance(db);
572
+ const result = action === "status" ? maintenance.status() : action === "start" ? maintenance.start(options.before ?? "") : action === "step" ? maintenance.step() : maintenance.control(action);
573
+ console.log(JSON.stringify(result ?? { status: "not_started" }, null, options.json ? 2 : undefined));
574
+ }
575
+ finally {
576
+ db.close();
577
+ }
578
+ }
539
579
  async function runDebugTelemetry(args) {
540
580
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
541
581
  printDebugTelemetryDiscovery();
542
582
  return;
543
583
  }
584
+ if (args[0] === "maintenance") {
585
+ await runDebugTelemetryMaintenance(args.slice(1));
586
+ return;
587
+ }
588
+ if (args[0] === "capture") {
589
+ const { runTelemetryCaptureCli } = await import("./telemetry-capture.js");
590
+ await runTelemetryCaptureCli(args.slice(1));
591
+ return;
592
+ }
544
593
  const command = args[0];
545
594
  const options = parseOptions(args.slice(1));
546
595
  const { formatJson } = await import("./sql.js");
@@ -1311,6 +1360,7 @@ function printDebugDiscovery() {
1311
1360
  console.log(`pibo debug - inspect local Pibo data
1312
1361
 
1313
1362
  Commands:
1363
+ backup Create, verify or restore an explicit SQLite and payload snapshot
1314
1364
  db Inspect and query local SQLite stores
1315
1365
  session Inspect one Pibo Session by id or Chat URL
1316
1366
  summary Show compact session diagnosis and drill-down commands
@@ -1491,6 +1541,8 @@ Commands:
1491
1541
  stale List read-only stale active work
1492
1542
  stats Show telemetry retention counts and byte estimates
1493
1543
  prune Dry-run telemetry retention cleanup unless --apply is explicit
1544
+ maintenance Inspect or control bounded cleanup; use maintenance --help
1545
+ capture Scope provider detail and inspect inert archives; use capture --help
1494
1546
 
1495
1547
  Next:
1496
1548
  pibo debug telemetry sessions --active