@pasko70/pibo 3.4.3 → 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 (84) hide show
  1. package/dist/agent-runtime/routed-session.js +39 -12
  2. package/dist/agent-runtimes/codex-native/adapter.js +14 -1
  3. package/dist/agent-runtimes/codex-native/models.js +4 -4
  4. package/dist/agent-runtimes/codex-native/process.js +32 -13
  5. package/dist/agent-runtimes/codex-native/provider-usage.js +115 -0
  6. package/dist/agent-runtimes/pi/adapter.js +1 -0
  7. package/dist/agent-runtimes/pi/routed-session.js +4 -2
  8. package/dist/apps/chat/bounded-event-stream.js +98 -0
  9. package/dist/apps/chat/chat-settings-routes.js +3 -3
  10. package/dist/apps/chat/data/chat-data-mappers.js +32 -13
  11. package/dist/apps/chat/data/event-command-service.js +30 -21
  12. package/dist/apps/chat/data/history-query-service.js +34 -25
  13. package/dist/apps/chat/data/read-state-service.js +13 -0
  14. package/dist/apps/chat/data/session-query-service.js +19 -11
  15. package/dist/apps/chat/data/timeline-query-service.js +19 -7
  16. package/dist/apps/chat/message-command-dispatcher.js +134 -0
  17. package/dist/apps/chat/output-compactor.js +9 -0
  18. package/dist/apps/chat/output-event-policy.js +9 -1
  19. package/dist/apps/chat/stream.js +21 -3
  20. package/dist/apps/chat/telemetry-retention-service.js +113 -8
  21. package/dist/apps/chat/trace-response-cache.js +46 -0
  22. package/dist/apps/chat/trace-v2.js +12 -6
  23. package/dist/apps/chat/trace.js +1 -1
  24. package/dist/apps/chat/web-app.js +608 -281
  25. package/dist/apps/chat-ui/assets/{dist-DeMKnZR8.js → dist-BG0n7zLd.js} +1 -1
  26. package/dist/apps/chat-ui/assets/{dist-C8GzUMPk.js → dist-D6TjFhAm.js} +1 -1
  27. package/dist/apps/chat-ui/assets/{dist-BkUu8WPA.js → dist-D79vyxSX.js} +1 -1
  28. package/dist/apps/chat-ui/assets/{dist-B4YhOxh0.js → dist-DFZ8cwh0.js} +1 -1
  29. package/dist/apps/chat-ui/assets/{dist-Cyb4rVa6.js → dist-cOjokPrK.js} +1 -1
  30. package/dist/apps/chat-ui/assets/index-RMHUTJ62.js +229 -0
  31. package/dist/apps/chat-ui/assets/{index-BOceJ0jM.css → index-hEkrlRk-.css} +1 -1
  32. package/dist/apps/chat-ui/index.html +2 -2
  33. package/dist/apps/chat-vscode-web/assets/index-xacbCyTx.js +44 -0
  34. package/dist/apps/chat-vscode-web/index.html +1 -1
  35. package/dist/compute/pool/seeds.js +25 -3
  36. package/dist/core/events.js +4 -0
  37. package/dist/core/output-render-sequence.js +2 -0
  38. package/dist/core/provider-capacity.js +33 -0
  39. package/dist/core/provider-telemetry.js +21 -6
  40. package/dist/core/runtime-capacity.js +174 -0
  41. package/dist/core/runtime-telemetry.js +40 -12
  42. package/dist/core/session-router.js +79 -2
  43. package/dist/data/async-chat-reads.js +38 -0
  44. package/dist/data/async-chat-storage.js +91 -0
  45. package/dist/data/async-telemetry-maintenance.js +9 -0
  46. package/dist/data/bounded-worker-client.js +256 -0
  47. package/dist/data/chat-read-projections.js +159 -0
  48. package/dist/data/chat-read-worker.js +73 -0
  49. package/dist/data/chat-storage-worker.js +146 -0
  50. package/dist/data/ingest-service.js +79 -14
  51. package/dist/data/message-command-store.js +148 -0
  52. package/dist/data/payload-store.js +92 -11
  53. package/dist/data/pibo-store.js +10 -8
  54. package/dist/data/schema.js +16 -2
  55. package/dist/data/session-store.js +3 -1
  56. package/dist/data/storage-backup.js +278 -0
  57. package/dist/data/telemetry-capture.js +188 -0
  58. package/dist/data/telemetry-command.js +3 -0
  59. package/dist/data/telemetry-maintenance-worker.js +40 -0
  60. package/dist/data/telemetry-maintenance.js +110 -0
  61. package/dist/data/telemetry-retention.js +16 -7
  62. package/dist/data/telemetry-worker.js +111 -0
  63. package/dist/data/telemetry-writer.js +150 -83
  64. package/dist/data/telemetry.js +5 -0
  65. package/dist/debug/index.js +52 -0
  66. package/dist/debug/storage-backup.js +33 -0
  67. package/dist/debug/telemetry-capture.js +66 -0
  68. package/dist/gateway/cli.js +104 -16
  69. package/dist/gateway/server.js +2 -0
  70. package/dist/providers/openai-gpt56.js +11 -6
  71. package/dist/session-ui/terminalRows.js +55 -13
  72. package/dist/sessions/pibo-data-store.js +30 -10
  73. package/dist/shared/debug-features.js +4 -0
  74. package/dist/shared/model-inference-metrics.js +23 -0
  75. package/dist/shared/trace-event-projection.js +59 -2
  76. package/dist/shared/trace-history.js +9 -0
  77. package/dist/shared/trace-live-reducer.js +1 -0
  78. package/dist/shared/trace-patch-nodes.js +19 -0
  79. package/dist/web/channel.js +8 -2
  80. package/dist/web/http.js +36 -3
  81. package/npm-shrinkwrap.json +2 -2
  82. package/package.json +1 -1
  83. package/dist/apps/chat-ui/assets/index-Dk4mbXAB.js +0 -228
  84. package/dist/apps/chat-vscode-web/assets/index-0oTGFHni.js +0 -43
@@ -23,10 +23,12 @@ export class PiboDataStore {
23
23
  telemetry;
24
24
  constructor(path = piboHomePath("pibo.sqlite"), options = {}) {
25
25
  this.path = path === ":memory:" ? path : resolve(path);
26
- const insidePiboHome = ensurePrivatePiboHomeForPath(this.path);
27
- if (this.path !== ":memory:")
26
+ const insidePiboHome = !options.readOnly && ensurePrivatePiboHomeForPath(this.path);
27
+ if (!options.readOnly && this.path !== ":memory:")
28
28
  mkdirSync(dirname(this.path), { recursive: true });
29
- this.db = new DatabaseSync(this.path);
29
+ this.db = new DatabaseSync(this.path, { readOnly: options.readOnly === true });
30
+ // Connection-only configuration also protects the initial schema read.
31
+ this.db.exec("PRAGMA busy_timeout = 5000");
30
32
  try {
31
33
  assertSupportedPiboDataSchemaVersion(this.db);
32
34
  }
@@ -34,14 +36,14 @@ export class PiboDataStore {
34
36
  this.db.close();
35
37
  throw error;
36
38
  }
37
- if (insidePiboHome)
39
+ if (!options.readOnly && insidePiboHome)
38
40
  protectPrivateFileSync(this.path);
39
- this.db.exec("PRAGMA busy_timeout = 5000");
40
41
  this.db.exec("PRAGMA foreign_keys = ON");
41
- if (this.path !== ":memory:")
42
+ if (!options.readOnly && this.path !== ":memory:")
42
43
  this.db.exec("PRAGMA journal_mode = WAL");
43
- applyPiboDataSchema(this.db);
44
- this.payloads = new PayloadStore(this.db, options.payloadRootDir ?? piboHomePath("payloads"));
44
+ if (!options.readOnly)
45
+ applyPiboDataSchema(this.db);
46
+ this.payloads = new PayloadStore(this.db, options.payloadRootDir ?? piboHomePath("payloads"), options.readOnly);
45
47
  this.eventLog = new PiboEventLogStore(this.db);
46
48
  this.messages = new MessageStore(this.db);
47
49
  this.observations = new ObservationStore(this.db);
@@ -1,4 +1,7 @@
1
- export const PIBO_DATA_SCHEMA_VERSION = 9;
1
+ import { CHAT_READ_PROJECTION_SCHEMA, CHAT_NAVIGATION_REVISION_SCHEMA } from "./chat-read-projections.js";
2
+ import { TELEMETRY_MAINTENANCE_SCHEMA } from "./telemetry-maintenance.js";
3
+ import { MESSAGE_COMMAND_SCHEMA } from "./message-command-store.js";
4
+ export const PIBO_DATA_SCHEMA_VERSION = 13;
2
5
  const NATIVE_HISTORY_FALLBACK_SCHEMA_VERSION = 5;
3
6
  const retiredScopeColumn = ["owner", "scope"].join("_");
4
7
  const payloadTableDefinition = `
@@ -710,6 +713,12 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
710
713
  ON telemetry_turns(retention_class, updated_at);
711
714
  CREATE INDEX IF NOT EXISTS idx_telemetry_phases_turn_started
712
715
  ON telemetry_phases(turn_id, started_at ASC);
716
+ CREATE INDEX IF NOT EXISTS idx_telemetry_phases_open_name
717
+ ON telemetry_phases(turn_id, name, COALESCE(last_progress_at, started_at) DESC, created_at DESC) WHERE status='open';
718
+ CREATE INDEX IF NOT EXISTS idx_telemetry_phases_open_turn
719
+ ON telemetry_phases(turn_id, started_at ASC, created_at ASC) WHERE status='open';
720
+ CREATE INDEX IF NOT EXISTS idx_telemetry_phases_turn_name
721
+ ON telemetry_phases(turn_id, name);
713
722
  CREATE INDEX IF NOT EXISTS idx_telemetry_phases_session_status
714
723
  ON telemetry_phases(pibo_session_id, status, last_progress_at);
715
724
  CREATE INDEX IF NOT EXISTS idx_telemetry_phases_provider_request
@@ -759,6 +768,9 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
759
768
  CREATE INDEX IF NOT EXISTS idx_telemetry_tool_calls_retention_updated
760
769
  ON telemetry_tool_calls(retention_class, updated_at);
761
770
  `);
771
+ db.exec(CHAT_READ_PROJECTION_SCHEMA);
772
+ db.exec(CHAT_NAVIGATION_REVISION_SCHEMA);
773
+ db.exec(TELEMETRY_MAINTENANCE_SCHEMA);
762
774
  hooks.afterStep?.("schema");
763
775
  db.exec(`
764
776
  INSERT OR IGNORE INTO session_runtime_bindings (
@@ -798,6 +810,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
798
810
  END;
799
811
  `);
800
812
  hooks.afterStep?.("render-high-water");
813
+ db.exec("CREATE INDEX IF NOT EXISTS idx_event_log_sequence_repair_candidates ON event_log(session_id) WHERE session_id IS NOT NULL AND (session_sequence IS NULL OR session_sequence <= 0)");
801
814
  // Always inspect for interrupted pre-atomic v7 repairs. A previous process
802
815
  // may have written negative temporary values before setting user_version.
803
816
  db.exec(`
@@ -807,7 +820,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
807
820
  );
808
821
  INSERT INTO pibo_v7_sequence_repair_sessions (session_id)
809
822
  SELECT DISTINCT session_id
810
- FROM event_log
823
+ FROM event_log INDEXED BY idx_event_log_sequence_repair_candidates
811
824
  WHERE session_id IS NOT NULL
812
825
  AND (session_sequence IS NULL OR session_sequence <= 0);
813
826
  `);
@@ -858,6 +871,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
858
871
  }
859
872
  }
860
873
  hooks.afterStep?.("runtime-binding-metadata");
874
+ db.exec(MESSAGE_COMMAND_SCHEMA);
861
875
  db.exec(`PRAGMA user_version = ${PIBO_DATA_SCHEMA_VERSION}`);
862
876
  hooks.afterStep?.("user-version");
863
877
  }
@@ -31,7 +31,7 @@ export class SessionStore {
31
31
  now,
32
32
  ];
33
33
  const assignments = [
34
- "pi_session_id = excluded.pi_session_id",
34
+ ...(input.preserveRuntimeBinding ? [] : ["pi_session_id = excluded.pi_session_id"]),
35
35
  "room_id = excluded.room_id",
36
36
  "root_session_id = excluded.root_session_id",
37
37
  "parent_id = excluded.parent_id",
@@ -53,6 +53,8 @@ export class SessionStore {
53
53
  VALUES (${baseColumns.map(() => "?").join(", ")})
54
54
  ON CONFLICT(id) DO UPDATE SET ${assignments.join(", ")}
55
55
  `).run(...values);
56
+ if (input.preserveRuntimeBinding)
57
+ return;
56
58
  const binding = input.session.runtimeBinding
57
59
  ?? createLegacyPiRuntimeSessionBinding(input.session.id, input.session.piSessionId, input.session.createdAt);
58
60
  this.db.prepare(`
@@ -0,0 +1,278 @@
1
+ import { DatabaseSync, backup } from "node:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream, createWriteStream, statSync } from "node:fs";
4
+ import { mkdir, open, readFile, rename, stat, copyFile } from "node:fs/promises";
5
+ import { basename, dirname, resolve, relative, join, isAbsolute } from "node:path";
6
+ import { createGunzip } from "node:zlib";
7
+ import { pipeline } from "node:stream/promises";
8
+ const MANIFEST = "manifest.json", CATALOG = "payloads.jsonl", DATABASE = "snapshot.sqlite";
9
+ function ownedPath(root, path) { const target = resolve(root, path), rel = relative(resolve(root), target); if (isAbsolute(path) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\"))
10
+ throw Error("Backup path escapes its directory"); return target; }
11
+ async function syncFile(path) { const file = await open(path, "r"); try {
12
+ await file.sync();
13
+ }
14
+ finally {
15
+ await file.close();
16
+ } }
17
+ async function publishManifest(root, manifest) { const temp = join(root, MANIFEST + ".tmp"), file = await open(temp, "w", 0o600); try {
18
+ await file.writeFile(JSON.stringify(manifest) + "\n");
19
+ await file.sync();
20
+ }
21
+ finally {
22
+ await file.close();
23
+ } await rename(temp, join(root, MANIFEST)); await syncFile(root); }
24
+ export async function readStorageBackupManifest(root) { const path = join(root, MANIFEST); if ((await stat(path)).size > 16384)
25
+ throw Error("Backup manifest exceeds budget"); const m = JSON.parse(await readFile(path, "utf8")); if (m.format !== "pibo-storage-backup-v1" || !["snapshot", "payloads", "complete"].includes(m.status) || !Number.isSafeInteger(m.maxBytes) || !Number.isSafeInteger(m.maxPayloads))
26
+ throw Error("Unsupported backup manifest"); return m; }
27
+ async function digest(path, maximum, encoding, signal) { const input = createReadStream(path, { highWaterMark: 65536, signal }); const stream = encoding === "gzip" ? input.pipe(createGunzip()) : input; let bytes = 0; const hash = createHash("sha256"); try {
28
+ for await (const chunk of stream) {
29
+ signal?.throwIfAborted();
30
+ bytes += chunk.length;
31
+ if (bytes > maximum)
32
+ throw Error("Backup byte quota exceeded");
33
+ hash.update(chunk);
34
+ }
35
+ return { sha256: hash.digest("hex"), bytes };
36
+ }
37
+ finally {
38
+ input.destroy();
39
+ stream.destroy();
40
+ } }
41
+ /** Explicit offline operator operation. The SQLite online-backup API includes committed WAL data. */
42
+ export async function createStorageBackup(input) {
43
+ const milliseconds = input.maxMilliseconds ?? 60000;
44
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 1 || milliseconds > 3600000)
45
+ throw Error("Invalid backup time quota");
46
+ const timeout = AbortSignal.timeout(milliseconds);
47
+ input = { ...input, signal: input.signal ? AbortSignal.any([input.signal, timeout]) : timeout };
48
+ const root = resolve(input.destination), source = resolve(input.source), payloadRoot = resolve(input.payloadRoot), deadline = Date.now() + (input.maxMilliseconds ?? 60000);
49
+ const check = () => { input.signal?.throwIfAborted(); if (Date.now() > deadline)
50
+ throw Error("Backup time budget exhausted; resume explicitly"); };
51
+ let manifest;
52
+ let snapshotVerified = false;
53
+ if (input.resume) {
54
+ manifest = await readStorageBackupManifest(root);
55
+ if (manifest.source !== source || manifest.payloadRoot !== payloadRoot)
56
+ throw Error("Backup resume source does not match manifest");
57
+ }
58
+ else {
59
+ const maxBytes = input.maxBytes ?? 1024 * 1024 * 1024, maxPayloads = input.maxPayloads ?? 100000;
60
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 4096 || !Number.isSafeInteger(maxPayloads) || maxPayloads < 1 || maxPayloads > 1000000)
61
+ throw Error("Invalid backup quota");
62
+ await mkdir(root, { mode: 0o700 });
63
+ manifest = { format: "pibo-storage-backup-v1", status: "snapshot", source, payloadRoot, createdAt: new Date().toISOString(), payloadCount: 0, payloadBytes: 0, cursor: "", maxBytes, maxPayloads };
64
+ await publishManifest(root, manifest);
65
+ }
66
+ if (manifest.status === "complete") {
67
+ await verifyStorageBackup(root, input.signal);
68
+ return manifest;
69
+ }
70
+ if (manifest.status === "snapshot") {
71
+ check();
72
+ const walBytes = () => { try {
73
+ return statSync(source + "-wal").size;
74
+ }
75
+ catch {
76
+ return 0;
77
+ } };
78
+ const initialWalBytes = walBytes(), maxWalGrowth = input.maxWalGrowthBytes ?? 64 * 1024 * 1024;
79
+ if (!Number.isSafeInteger(maxWalGrowth) || maxWalGrowth < 0)
80
+ throw Error("Invalid WAL growth quota");
81
+ const db = new DatabaseSync(source, { readOnly: true });
82
+ const temporary = join(root, DATABASE + ".partial");
83
+ try {
84
+ db.exec("PRAGMA busy_timeout=10; BEGIN");
85
+ const pages = db.prepare("PRAGMA page_count").get().page_count, pageSize = db.prepare("PRAGMA page_size").get().page_size;
86
+ if (pages * pageSize > manifest.maxBytes)
87
+ throw Error("Database exceeds backup byte quota");
88
+ await backup(db, temporary, { rate: 128, progress: ({ totalPages, remainingPages }) => { check(); if (walBytes() - initialWalBytes > maxWalGrowth)
89
+ throw Error("Backup WAL growth quota exceeded; source snapshot released"); if (totalPages * pageSize > manifest.maxBytes)
90
+ throw Error("Database exceeds backup byte quota"); input.onProgress?.({ stage: "snapshot", copied: (totalPages - remainingPages) * pageSize }); } });
91
+ }
92
+ finally {
93
+ if (db.isTransaction)
94
+ db.exec("ROLLBACK");
95
+ db.close();
96
+ }
97
+ await syncFile(temporary);
98
+ await rename(temporary, join(root, DATABASE));
99
+ const hash = await digest(join(root, DATABASE), manifest.maxBytes, undefined, input.signal);
100
+ snapshotVerified = true;
101
+ manifest.databaseSha256 = hash.sha256;
102
+ manifest.databaseBytes = hash.bytes;
103
+ manifest.status = "payloads";
104
+ await publishManifest(root, manifest);
105
+ }
106
+ const database = join(root, DATABASE);
107
+ if (!snapshotVerified && (await digest(database, manifest.maxBytes, undefined, input.signal)).sha256 !== manifest.databaseSha256)
108
+ throw Error("Backup snapshot hash changed");
109
+ const db = new DatabaseSync(database, { readOnly: true });
110
+ try {
111
+ if (db.prepare("PRAGMA quick_check").get().quick_check !== "ok")
112
+ throw Error("Backup SQLite integrity check failed");
113
+ // Rebuild the small cursor from the fsynced catalog, including a file published before a crash.
114
+ let count = 0, bytes = 0, cursor = "";
115
+ const catalog = join(root, CATALOG);
116
+ const catalogFile = await open(catalog, "a+", 0o600);
117
+ try {
118
+ const size = (await catalogFile.stat()).size;
119
+ if (size) {
120
+ const tail = Buffer.alloc(Math.min(8192, size));
121
+ await catalogFile.read(tail, 0, tail.length, size - tail.length);
122
+ if (tail.at(-1) !== 10) {
123
+ const newline = tail.lastIndexOf(10);
124
+ if (newline < 0 && size > 8192)
125
+ throw Error("Invalid partial backup catalog");
126
+ await catalogFile.truncate(newline < 0 ? 0 : size - tail.length + newline + 1);
127
+ await catalogFile.sync();
128
+ }
129
+ }
130
+ }
131
+ finally {
132
+ await catalogFile.close();
133
+ }
134
+ for await (const row of catalogRows(catalog, manifest.maxPayloads)) {
135
+ check();
136
+ if (row.id <= cursor)
137
+ throw Error("Backup catalog order is invalid");
138
+ const hash = await digest(ownedPath(join(root, "payloads"), row.path), manifest.maxBytes, undefined, input.signal);
139
+ if (hash.sha256 !== row.storedSha256 || hash.bytes !== row.bytes)
140
+ throw Error("Backup payload changed");
141
+ count++;
142
+ bytes += row.bytes;
143
+ cursor = row.id;
144
+ }
145
+ manifest.payloadCount = count;
146
+ manifest.payloadBytes = bytes;
147
+ manifest.cursor = cursor;
148
+ const hasPayloads = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='payloads'").get());
149
+ while (hasPayloads) {
150
+ check();
151
+ const rows = db.prepare("SELECT id,storage_path,sha256,encoding,byte_size,status FROM payloads WHERE id>? ORDER BY id LIMIT 128").all(manifest.cursor);
152
+ if (!rows.length)
153
+ break;
154
+ for (const row of rows) {
155
+ check();
156
+ if (!row.storage_path || row.status !== "committed")
157
+ throw Error("Snapshot contains unavailable payload metadata");
158
+ if (manifest.payloadCount >= manifest.maxPayloads)
159
+ throw Error("Backup payload count quota exceeded");
160
+ const sourcePath = ownedPath(payloadRoot, row.storage_path), target = ownedPath(join(root, "payloads"), row.storage_path), size = (await stat(sourcePath)).size;
161
+ if ((manifest.databaseBytes ?? 0) + manifest.payloadBytes + size > manifest.maxBytes)
162
+ throw Error("Backup byte quota exceeded");
163
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
164
+ const temporary = target + ".partial";
165
+ await pipeline(createReadStream(sourcePath, { highWaterMark: 65536 }), createWriteStream(temporary, { mode: 0o600 }), { signal: input.signal });
166
+ const raw = await digest(temporary, manifest.maxBytes, undefined, input.signal), content = await digest(temporary, row.byte_size, row.encoding, input.signal);
167
+ if (content.sha256 !== row.sha256 || content.bytes !== row.byte_size)
168
+ throw Error("Source payload failed content verification");
169
+ await syncFile(temporary);
170
+ await rename(temporary, target);
171
+ await syncFile(dirname(target));
172
+ const entry = { id: row.id, path: row.storage_path, sha256: row.sha256, storedSha256: raw.sha256, bytes: raw.bytes, encoding: row.encoding, contentBytes: row.byte_size };
173
+ const file = await open(catalog, "a", 0o600);
174
+ try {
175
+ await file.writeFile(JSON.stringify(entry) + "\n");
176
+ await file.sync();
177
+ }
178
+ finally {
179
+ await file.close();
180
+ }
181
+ manifest.payloadCount++;
182
+ manifest.payloadBytes += raw.bytes;
183
+ manifest.cursor = row.id;
184
+ input.onProgress?.({ stage: "payloads", copied: manifest.payloadCount });
185
+ }
186
+ await publishManifest(root, manifest);
187
+ }
188
+ manifest.catalogSha256 = (await digest(catalog, Math.max(1024, manifest.maxPayloads * 4096), undefined, input.signal)).sha256;
189
+ manifest.status = "complete";
190
+ await publishManifest(root, manifest);
191
+ return manifest;
192
+ }
193
+ finally {
194
+ db.close();
195
+ }
196
+ }
197
+ async function* catalogRows(path, maximum) {
198
+ const stream = createReadStream(path, { highWaterMark: 65536, encoding: "utf8" });
199
+ let count = 0, pending = "";
200
+ const parse = (line) => { if (line.length > 4096 || ++count > maximum)
201
+ throw Error("Backup catalog exceeds quota"); const row = JSON.parse(line); if (typeof row.id !== "string" || typeof row.path !== "string" || !Number.isSafeInteger(row.bytes) || row.bytes < 0)
202
+ throw Error("Invalid backup catalog row"); return row; };
203
+ try {
204
+ for await (const chunk of stream) {
205
+ pending += chunk;
206
+ let newline;
207
+ while ((newline = pending.indexOf("\n")) >= 0) {
208
+ const line = pending.slice(0, newline);
209
+ pending = pending.slice(newline + 1);
210
+ yield parse(line);
211
+ }
212
+ if (pending.length > 4096)
213
+ throw Error("Backup catalog line exceeds quota");
214
+ }
215
+ if (pending)
216
+ throw Error("Incomplete backup catalog line");
217
+ }
218
+ finally {
219
+ stream.destroy();
220
+ }
221
+ }
222
+ export async function verifyStorageBackup(root, signal) {
223
+ const m = await readStorageBackupManifest(root);
224
+ if (m.status !== "complete")
225
+ throw Error("Backup is incomplete");
226
+ if ((await digest(join(root, DATABASE), m.maxBytes, undefined, signal)).sha256 !== m.databaseSha256 || (await digest(join(root, CATALOG), Math.max(1024, m.maxPayloads * 4096), undefined, signal)).sha256 !== m.catalogSha256)
227
+ throw Error("Backup manifest hash mismatch");
228
+ const db = new DatabaseSync(join(root, DATABASE), { readOnly: true });
229
+ let payloads = 0, bytes = m.databaseBytes ?? 0, previousId = "";
230
+ try {
231
+ if (db.prepare("PRAGMA quick_check").get().quick_check !== "ok")
232
+ throw Error("Backup database failed verification");
233
+ const hasPayloads = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='payloads'").get());
234
+ for await (const row of catalogRows(join(root, CATALOG), m.maxPayloads)) {
235
+ signal?.throwIfAborted();
236
+ if (row.id <= previousId)
237
+ throw Error("Backup catalog order is invalid");
238
+ previousId = row.id;
239
+ const metadata = hasPayloads ? db.prepare("SELECT sha256,storage_path,byte_size FROM payloads WHERE id=?").get(row.id) : undefined;
240
+ if (!metadata || metadata.sha256 !== row.sha256 || metadata.storage_path !== row.path || metadata.byte_size !== row.contentBytes)
241
+ throw Error("Catalog does not match snapshot");
242
+ const path = ownedPath(join(root, "payloads"), row.path);
243
+ const raw = await digest(path, m.maxBytes, undefined, signal), content = await digest(path, row.contentBytes, row.encoding, signal);
244
+ if (raw.sha256 !== row.storedSha256 || raw.bytes !== row.bytes || content.sha256 !== row.sha256 || content.bytes !== row.contentBytes)
245
+ throw Error("Backup payload hash mismatch");
246
+ payloads++;
247
+ bytes += raw.bytes;
248
+ if (bytes > m.maxBytes)
249
+ throw Error("Backup exceeds byte quota");
250
+ }
251
+ const expected = hasPayloads ? Number(db.prepare("SELECT COUNT(*) AS count FROM payloads").get().count) : 0;
252
+ if (payloads !== expected || payloads !== m.payloadCount)
253
+ throw Error("Backup payload coverage mismatch");
254
+ return { payloads, bytes };
255
+ }
256
+ finally {
257
+ db.close();
258
+ }
259
+ }
260
+ export async function restoreStorageBackup(root, destination, signal) {
261
+ await verifyStorageBackup(root, signal);
262
+ const m = await readStorageBackupManifest(root), name = basename(m.source);
263
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name))
264
+ throw Error("Unsafe restored database name");
265
+ await mkdir(destination, { mode: 0o700 });
266
+ const payloadRoot = join(destination, "payloads"), database = join(destination, name);
267
+ for await (const row of catalogRows(join(root, CATALOG), m.maxPayloads)) {
268
+ signal?.throwIfAborted();
269
+ const target = ownedPath(payloadRoot, row.path);
270
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
271
+ await copyFile(ownedPath(join(root, "payloads"), row.path), target);
272
+ await syncFile(target);
273
+ }
274
+ await copyFile(join(root, DATABASE), database);
275
+ await syncFile(database);
276
+ await syncFile(destination);
277
+ return { database, payloadRoot };
278
+ }
@@ -0,0 +1,188 @@
1
+ import { DatabaseSync, backup } from "node:sqlite";
2
+ import { randomUUID, createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync, openSync, closeSync, fsyncSync, createReadStream } from "node:fs";
4
+ import { join } from "node:path";
5
+ function validId(id) { if (!/^capture_[a-f0-9-]{36}$/.test(id))
6
+ throw Error("Invalid capture id"); return id; }
7
+ function readJson(path) { if (statSync(path).size > 16384)
8
+ throw Error("Capture manifest exceeds budget"); return JSON.parse(readFileSync(path, "utf8")); }
9
+ function publish(path, value) { const temp = path + ".tmp"; writeFileSync(temp, JSON.stringify(value) + "\n", { mode: 0o600 }); const fd = openSync(temp, "r"); try {
10
+ fsyncSync(fd);
11
+ }
12
+ finally {
13
+ closeSync(fd);
14
+ } renameSync(temp, path); }
15
+ export function inspectTelemetryCapture(root, id) { const m = readJson(join(root, validId(id), "manifest.json")); if (m.format !== "pibo-telemetry-capture-v1" || m.id !== id)
16
+ throw Error("Invalid capture manifest"); return m; }
17
+ export function startTelemetryCapture(root, input) {
18
+ if (!input.sessionId.startsWith("ps_") || input.sessionId.length > 128 || !input.owner.trim() || input.owner.length > 128 || !Number.isSafeInteger(input.durationMs) || input.durationMs < 1000 || input.durationMs > 3600000 || !Number.isSafeInteger(input.maxBytes) || input.maxBytes < 1024 || input.maxBytes > 64 * 1024 * 1024 || !Number.isSafeInteger(input.maxRows) || input.maxRows < 1 || input.maxRows > 100000)
19
+ throw Error("Capture requires explicit session, owner, duration, byte and row limits");
20
+ mkdirSync(root, { recursive: true, mode: 0o700 });
21
+ const active = join(root, "active.json");
22
+ const lock = openSync(active, "wx", 0o600);
23
+ const id = `capture_${randomUUID()}`, directory = join(root, id);
24
+ const m = { format: "pibo-telemetry-capture-v1", id, status: "active", sessionId: input.sessionId, owner: input.owner, detail: "provider_metadata", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + input.durationMs).toISOString(), maxBytes: input.maxBytes, maxRows: input.maxRows, rows: 0, bytes: 0 };
25
+ try {
26
+ mkdirSync(directory, { mode: 0o700 });
27
+ const db = new DatabaseSync(join(directory, "active.sqlite"));
28
+ try {
29
+ db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA max_page_count=${Math.ceil((input.maxBytes + 1048576) / 4096)}; CREATE TABLE state(id INTEGER PRIMARY KEY CHECK(id=1),status TEXT NOT NULL,rows INTEGER NOT NULL,bytes INTEGER NOT NULL);INSERT INTO state VALUES(1,'active',0,0);CREATE TABLE events(sequence INTEGER PRIMARY KEY,received_at TEXT NOT NULL,type TEXT NOT NULL,metadata_json TEXT NOT NULL);`);
30
+ }
31
+ finally {
32
+ db.close();
33
+ }
34
+ publish(join(directory, "manifest.json"), m);
35
+ writeFileSync(lock, JSON.stringify({ id }) + "\n");
36
+ fsyncSync(lock);
37
+ return m;
38
+ }
39
+ catch (error) {
40
+ try {
41
+ unlinkSync(active);
42
+ }
43
+ catch { }
44
+ throw error;
45
+ }
46
+ finally {
47
+ closeSync(lock);
48
+ }
49
+ }
50
+ /** One run-owned connection; scopes and limits are checked transactionally before every append. */
51
+ export class TelemetryCaptureWriter {
52
+ root;
53
+ db;
54
+ manifest;
55
+ refreshAfter = 0;
56
+ failures = 0;
57
+ dropped = 0;
58
+ constructor(root) {
59
+ this.root = root;
60
+ }
61
+ status() { return { id: this.manifest?.id, rows: this.manifest?.rows, bytes: this.manifest?.bytes, expiresAt: this.manifest?.expiresAt, failures: this.failures, dropped: this.dropped }; }
62
+ close() { this.db?.close(); this.db = undefined; this.manifest = undefined; }
63
+ append(commands) {
64
+ try {
65
+ if (Date.now() >= this.refreshAfter) {
66
+ this.refreshAfter = Date.now() + 1000;
67
+ const path = join(this.root, "active.json");
68
+ if (!existsSync(path)) {
69
+ this.close();
70
+ return;
71
+ }
72
+ const id = validId(readJson(path).id);
73
+ if (this.manifest?.id !== id) {
74
+ this.close();
75
+ const m = inspectTelemetryCapture(this.root, id);
76
+ if (m.status !== "active")
77
+ return;
78
+ this.db = new DatabaseSync(join(this.root, id, "active.sqlite"));
79
+ this.db.exec("PRAGMA busy_timeout=10; PRAGMA synchronous=FULL");
80
+ this.manifest = m;
81
+ }
82
+ }
83
+ const db = this.db, m = this.manifest;
84
+ if (!db || !m)
85
+ return;
86
+ db.exec("BEGIN IMMEDIATE");
87
+ try {
88
+ const state = db.prepare("SELECT status,rows,bytes FROM state WHERE id=1").get();
89
+ if (state.status !== "active") {
90
+ db.exec("COMMIT");
91
+ this.close();
92
+ return;
93
+ }
94
+ const insert = db.prepare("INSERT INTO events(received_at,type,metadata_json) VALUES(?,?,?)");
95
+ let stopped = Date.now() >= Date.parse(m.expiresAt);
96
+ for (const input of commands) {
97
+ if (input.recorder !== "runtime" || input.command.kind !== "pi" || input.command.piboSessionId !== m.sessionId)
98
+ continue;
99
+ const summary = input.command.summary;
100
+ const metadata = { piboSessionId: m.sessionId, eventType: summary.eventType.slice(0, 256), byteSize: summary.byteSize, parseStatus: summary.parseStatus, normalizedType: summary.normalizedType?.slice(0, 256), assistantEventType: summary.assistantEventType?.slice(0, 128), messageEnded: summary.messageEnded };
101
+ const json = JSON.stringify(metadata), bytes = Buffer.byteLength(json) + 64;
102
+ if (stopped || state.rows >= m.maxRows || state.bytes + bytes > m.maxBytes) {
103
+ stopped = true;
104
+ this.dropped++;
105
+ continue;
106
+ }
107
+ insert.run(new Date().toISOString(), summary.eventType.slice(0, 256), json);
108
+ state.rows++;
109
+ state.bytes += bytes;
110
+ }
111
+ if (state.rows >= m.maxRows || state.bytes >= m.maxBytes)
112
+ stopped = true;
113
+ db.prepare("UPDATE state SET rows=?,bytes=?,status=? WHERE id=1").run(state.rows, state.bytes, stopped ? "stopped" : "active");
114
+ db.exec("COMMIT");
115
+ m.rows = state.rows;
116
+ m.bytes = state.bytes;
117
+ if (stopped) {
118
+ publish(join(this.root, m.id, "manifest.json"), { ...m, status: "stopped", rows: state.rows, bytes: state.bytes });
119
+ this.close();
120
+ }
121
+ }
122
+ catch (error) {
123
+ if (db.isTransaction)
124
+ db.exec("ROLLBACK");
125
+ throw error;
126
+ }
127
+ }
128
+ catch {
129
+ this.failures++;
130
+ this.close();
131
+ }
132
+ }
133
+ }
134
+ export async function finalizeTelemetryCapture(root, id) {
135
+ const m = inspectTelemetryCapture(root, id);
136
+ if (m.status === "archived")
137
+ return m;
138
+ const directory = join(root, id), db = new DatabaseSync(join(directory, "active.sqlite"));
139
+ try {
140
+ db.exec("PRAGMA busy_timeout=1000; PRAGMA synchronous=FULL; BEGIN IMMEDIATE");
141
+ db.exec("UPDATE state SET status='stopped' WHERE id=1");
142
+ const state = db.prepare("SELECT rows,bytes FROM state WHERE id=1").get();
143
+ db.exec("COMMIT");
144
+ publish(join(directory, "manifest.json"), { ...m, ...state, status: "stopped" });
145
+ // The archive is a separate closed snapshot; stale active handles cannot mutate it.
146
+ await backup(db, join(directory, "archive.sqlite"), { rate: 128 });
147
+ const archiveFd = openSync(join(directory, "archive.sqlite"), "r");
148
+ try {
149
+ fsyncSync(archiveFd);
150
+ }
151
+ finally {
152
+ closeSync(archiveFd);
153
+ }
154
+ const hash = createHash("sha256");
155
+ let bytes = 0;
156
+ for await (const chunk of createReadStream(join(directory, "archive.sqlite"), { highWaterMark: 65536 })) {
157
+ bytes += chunk.length;
158
+ if (bytes > m.maxBytes + 1048576)
159
+ throw Error("Capture archive exceeds physical quota");
160
+ hash.update(chunk);
161
+ }
162
+ const archived = { ...m, ...state, status: "archived", sha256: hash.digest("hex"), archiveBytes: bytes };
163
+ publish(join(directory, "manifest.json"), archived);
164
+ const pointer = join(root, "active.json");
165
+ if (existsSync(pointer) && readJson(pointer).id === id)
166
+ unlinkSync(pointer);
167
+ return archived;
168
+ }
169
+ finally {
170
+ if (db.isTransaction)
171
+ db.exec("ROLLBACK");
172
+ db.close();
173
+ }
174
+ }
175
+ export async function readTelemetryCapturePage(root, id, after = 0, limit = 50) { const m = inspectTelemetryCapture(root, id); if (m.status !== "archived")
176
+ throw Error("Capture must be finalized before inspection"); if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
177
+ throw Error("Invalid capture page budget"); const path = join(root, id, "archive.sqlite"), hash = createHash("sha256"); let bytes = 0; for await (const chunk of createReadStream(path, { highWaterMark: 65536 })) {
178
+ bytes += chunk.length;
179
+ if (bytes > m.maxBytes + 1048576)
180
+ throw Error("Capture archive exceeds physical quota");
181
+ hash.update(chunk);
182
+ } if (hash.digest("hex") !== m.sha256 || bytes !== m.archiveBytes)
183
+ throw Error("Capture archive hash mismatch"); const db = new DatabaseSync(path, { readOnly: true }); try {
184
+ return db.prepare("SELECT * FROM events WHERE sequence>? ORDER BY sequence LIMIT ?").all(after, limit);
185
+ }
186
+ finally {
187
+ db.close();
188
+ } }
@@ -0,0 +1,3 @@
1
+ export function isTelemetryProgress(input) {
2
+ return input.recorder === "runtime" && (input.command.kind === "pi" || (input.command.kind === "output" && ["assistant_delta", "thinking_delta", "tool_execution_updated"].includes(input.command.event.type)));
3
+ }
@@ -0,0 +1,40 @@
1
+ import { statSync } from "node:fs";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ import { parentPort, workerData, threadId } from "node:worker_threads";
4
+ import { pruneTelemetryRetention } from "./telemetry-retention.js";
5
+ import { TelemetryMaintenance } from "./telemetry-maintenance.js";
6
+ if (!parentPort)
7
+ throw Error("Maintenance requires a worker");
8
+ const db = new DatabaseSync(workerData.path);
9
+ db.exec("PRAGMA busy_timeout=10; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON");
10
+ const maintenance = new TelemetryMaintenance(db);
11
+ let timer, failures = 0, delay = 25;
12
+ let checkpointAfter = 0, checkpoint;
13
+ function observeCheckpoint() { if (Date.now() < checkpointAfter)
14
+ return; checkpointAfter = Date.now() + 5000; const started = performance.now(); const row = db.prepare("PRAGMA wal_checkpoint(PASSIVE)").get(); let walBytes = 0; try {
15
+ walBytes = statSync(workerData.path + "-wal").size;
16
+ }
17
+ catch { } checkpoint = { durationMs: performance.now() - started, busy: row.busy, logPages: row.log, checkpointedPages: row.checkpointed, walBytes, pendingSince: row.log > row.checkpointed ? (checkpoint?.pendingSince ?? Date.now()) : undefined }; }
18
+ function schedule() { if (timer || maintenance.status()?.status !== "running")
19
+ return; timer = setTimeout(() => { timer = undefined; try {
20
+ maintenance.step();
21
+ observeCheckpoint();
22
+ delay = 25;
23
+ }
24
+ catch {
25
+ failures++;
26
+ delay = Math.min(1000, delay * 2);
27
+ } schedule(); }, delay); timer.unref(); }
28
+ parentPort.on("message", (request) => {
29
+ try {
30
+ const { action, cutoff } = request.command;
31
+ const value = action === "preview" ? ["live", "diagnostic", "provider_event", "payload_preview", "incident"].map(retentionClass => pruneTelemetryRetention(db, { retentionClass: retentionClass, before: cutoff, apply: false })) : action === "start" ? maintenance.start(cutoff) : action === "status" ? maintenance.status() : maintenance.control(action);
32
+ if (action === "start" || action === "resume")
33
+ schedule();
34
+ parentPort.postMessage({ id: request.id, value: value ?? null, worker: { pid: process.pid, threadId, failures, delay, checkpoint, status: value } });
35
+ }
36
+ catch {
37
+ parentPort.postMessage({ id: request.id, error: { code: "storage_maintenance_failed", message: "Telemetry maintenance command failed" } });
38
+ }
39
+ });
40
+ parentPort.postMessage({ ready: true, worker: { pid: process.pid, threadId, failures, delay, checkpoint, status: maintenance.status() } });