@pasko70/pibo 3.4.4 → 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 +111 -25
- package/dist/agent-runtimes/codex-native/turn.js +27 -3
- package/dist/agent-runtimes/pi/adapter.js +1 -0
- package/dist/agent-runtimes/pi/routed-session.js +2 -2
- package/dist/apps/chat/bounded-event-stream.js +98 -0
- package/dist/apps/chat/chat-settings-routes.js +3 -3
- package/dist/apps/chat/data/chat-data-mappers.js +40 -14
- package/dist/apps/chat/data/event-command-service.js +30 -21
- package/dist/apps/chat/data/history-query-service.js +34 -25
- package/dist/apps/chat/data/read-state-service.js +37 -3
- package/dist/apps/chat/data/session-query-service.js +19 -11
- package/dist/apps/chat/data/timeline-query-service.js +19 -7
- package/dist/apps/chat/message-command-dispatcher.js +149 -0
- package/dist/apps/chat/output-compactor.js +9 -0
- package/dist/apps/chat/output-event-policy.js +9 -1
- package/dist/apps/chat/stream.js +21 -3
- package/dist/apps/chat/telemetry-retention-service.js +113 -8
- package/dist/apps/chat/trace-response-cache.js +46 -0
- package/dist/apps/chat/trace-v2.js +12 -6
- package/dist/apps/chat/trace.js +1 -1
- package/dist/apps/chat/web-app.js +645 -297
- package/dist/apps/chat-ui/assets/{dist-V06sfuZa.js → dist-B-auLrzD.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-CH3SvpYV.js → dist-BA_dsINH.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-23lt_7qm.js → dist-eJZar_0-.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-Bf2KScPo.js → dist-wNNR2Bci.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DsgL8w-W.js → dist-zcmEsIEp.js} +1 -1
- package/dist/apps/chat-ui/assets/index-DEkbN5Vo.js +229 -0
- 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-DZgW1fCB.js +44 -0
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/cli-session/localSessionSource.js +3 -2
- package/dist/compute/pool/seeds.js +25 -3
- package/dist/core/events.js +4 -0
- package/dist/core/output-render-sequence.js +65 -6
- package/dist/core/provider-capacity.js +33 -0
- package/dist/core/provider-telemetry.js +21 -6
- package/dist/core/runtime-capacity.js +174 -0
- package/dist/core/runtime-telemetry.js +40 -12
- package/dist/core/session-router.js +178 -13
- package/dist/data/async-chat-reads.js +38 -0
- package/dist/data/async-chat-storage.js +96 -0
- package/dist/data/async-telemetry-maintenance.js +9 -0
- package/dist/data/bounded-worker-client.js +256 -0
- package/dist/data/chat-read-projections.js +159 -0
- package/dist/data/chat-read-worker.js +73 -0
- package/dist/data/chat-storage-worker.js +167 -0
- package/dist/data/ingest-service.js +152 -18
- package/dist/data/message-command-store.js +290 -0
- package/dist/data/payload-store.js +92 -11
- package/dist/data/pibo-store.js +10 -8
- package/dist/data/schema.js +39 -4
- package/dist/data/session-store.js +3 -1
- package/dist/data/storage-backup.js +278 -0
- package/dist/data/storage-maintenance.js +344 -0
- package/dist/data/storage-verification-worker.js +25 -0
- package/dist/data/telemetry-capture.js +188 -0
- package/dist/data/telemetry-command.js +3 -0
- package/dist/data/telemetry-maintenance-worker.js +40 -0
- package/dist/data/telemetry-maintenance.js +110 -0
- package/dist/data/telemetry-retention.js +16 -7
- package/dist/data/telemetry-worker.js +111 -0
- package/dist/data/telemetry-writer.js +150 -83
- package/dist/data/telemetry.js +5 -0
- package/dist/debug/index.js +207 -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 +41 -0
- package/dist/debug/storage-maintenance.js +78 -0
- package/dist/debug/telemetry-capture.js +66 -0
- package/dist/gateway/cli.js +71 -7
- package/dist/gateway/server.js +3 -0
- package/dist/providers/openai-gpt56.js +11 -6
- package/dist/reliability/store.js +119 -23
- package/dist/session-ui/terminalRows.js +54 -13
- package/dist/sessions/pibo-data-store.js +19 -14
- package/dist/shared/debug-features.js +4 -0
- package/dist/shared/model-inference-metrics.js +23 -0
- package/dist/shared/trace-event-projection.js +69 -2
- package/dist/shared/trace-history.js +9 -0
- package/dist/shared/trace-live-reducer.js +1 -0
- package/dist/shared/trace-patch-nodes.js +19 -0
- package/dist/web/channel.js +117 -12
- package/dist/web/http.js +135 -41
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-BOceJ0jM.css +0 -1
- package/dist/apps/chat-ui/assets/index-BOemYq-V.js +0 -228
- package/dist/apps/chat-vscode-web/assets/index-CMwTB8o8.js +0 -43
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { pipeline } from "node:stream";
|
|
1
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { gunzipSync, gzipSync } from "node:zlib";
|
|
3
|
-
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createGunzip, gunzipSync, gzipSync } from "node:zlib";
|
|
4
|
+
import { createReadStream, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { dirname, extname, join, resolve } from "node:path";
|
|
5
6
|
import { piboHomePath } from "../core/pibo-home.js";
|
|
6
7
|
const MAX_SYNC_PAYLOAD_GZIP_BYTES = 512 * 1024;
|
|
@@ -23,30 +24,55 @@ export class PiboPayloadMetadataConflictError extends Error {
|
|
|
23
24
|
export class PayloadStore {
|
|
24
25
|
db;
|
|
25
26
|
rootDir;
|
|
26
|
-
constructor(db, rootDir = piboHomePath("payloads")) {
|
|
27
|
+
constructor(db, rootDir = piboHomePath("payloads"), readOnly = false) {
|
|
27
28
|
this.db = db;
|
|
28
29
|
this.rootDir = rootDir === ":memory:" ? rootDir : resolve(rootDir);
|
|
29
|
-
if (this.rootDir !== ":memory:")
|
|
30
|
+
if (!readOnly && this.rootDir !== ":memory:")
|
|
30
31
|
mkdirSync(this.rootDir, { recursive: true });
|
|
31
32
|
}
|
|
32
33
|
writePayload(input) {
|
|
34
|
+
return this.commitPreparedPayload(this.preparePayload(input));
|
|
35
|
+
}
|
|
36
|
+
/** Performs hashing, compression and atomic file publication before a DB transaction begins. */
|
|
37
|
+
preparePayload(input) {
|
|
33
38
|
const contentType = input.contentType ?? defaultContentType(input.value);
|
|
34
39
|
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
35
40
|
const bytes = payloadToBytes(input.value, contentType);
|
|
36
41
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
37
42
|
const existing = this.findByIdentity(sha256, contentType, input.retentionClass);
|
|
38
43
|
if (existing) {
|
|
39
|
-
|
|
40
|
-
return this.getPayload(existing.id) ?? existing;
|
|
44
|
+
return { ...existing, refCount: 1, status: "staged" };
|
|
41
45
|
}
|
|
42
46
|
const shouldCompress = bytes.byteLength <= MAX_SYNC_PAYLOAD_GZIP_BYTES;
|
|
43
47
|
const encoding = shouldCompress ? "gzip" : "identity";
|
|
44
48
|
const bytesToStore = shouldCompress ? gzipSync(bytes) : bytes;
|
|
45
|
-
const compressedByteSize = shouldCompress ? bytesToStore.byteLength :
|
|
49
|
+
const compressedByteSize = shouldCompress ? bytesToStore.byteLength : undefined;
|
|
46
50
|
const relativePath = buildRelativePayloadPath(sha256, contentType, input.retentionClass, encoding);
|
|
47
51
|
const absolutePath = this.rootDir === ":memory:" ? relativePath : join(this.rootDir, relativePath);
|
|
48
52
|
writePayloadFile(absolutePath, bytesToStore);
|
|
49
|
-
|
|
53
|
+
return {
|
|
54
|
+
id: input.id ?? `payload_${randomUUID()}`,
|
|
55
|
+
sha256,
|
|
56
|
+
storageKind: "file",
|
|
57
|
+
storagePath: relativePath,
|
|
58
|
+
contentType,
|
|
59
|
+
encoding,
|
|
60
|
+
byteSize: bytes.byteLength,
|
|
61
|
+
compressedByteSize,
|
|
62
|
+
previewText: previewTextFromValue(input.value),
|
|
63
|
+
retentionClass: input.retentionClass,
|
|
64
|
+
refCount: 1,
|
|
65
|
+
status: "staged",
|
|
66
|
+
createdAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Links a previously published payload file with only bounded metadata SQL. */
|
|
70
|
+
commitPreparedPayload(prepared) {
|
|
71
|
+
const existing = this.findByIdentity(prepared.sha256, prepared.contentType, prepared.retentionClass);
|
|
72
|
+
if (existing) {
|
|
73
|
+
this.db.prepare("UPDATE payloads SET ref_count = ref_count + 1 WHERE id = ?").run(existing.id);
|
|
74
|
+
return this.getPayload(existing.id) ?? existing;
|
|
75
|
+
}
|
|
50
76
|
this.db.prepare(`
|
|
51
77
|
INSERT INTO payloads (
|
|
52
78
|
id,
|
|
@@ -64,10 +90,10 @@ export class PayloadStore {
|
|
|
64
90
|
created_at,
|
|
65
91
|
last_verified_at
|
|
66
92
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
67
|
-
`).run(id, sha256, "file",
|
|
68
|
-
const stored = this.getPayload(id);
|
|
93
|
+
`).run(prepared.id, prepared.sha256, "file", prepared.storagePath ?? null, prepared.contentType, prepared.encoding, prepared.byteSize, prepared.compressedByteSize ?? null, prepared.previewText ?? null, prepared.retentionClass, 1, "committed", prepared.createdAt, prepared.createdAt);
|
|
94
|
+
const stored = this.getPayload(prepared.id);
|
|
69
95
|
if (!stored)
|
|
70
|
-
throw new Error(`Failed to persist payload \"${id}\"`);
|
|
96
|
+
throw new Error(`Failed to persist payload \"${prepared.id}\"`);
|
|
71
97
|
return stored;
|
|
72
98
|
}
|
|
73
99
|
getPayload(id) {
|
|
@@ -88,6 +114,61 @@ export class PayloadStore {
|
|
|
88
114
|
return bytes;
|
|
89
115
|
throw new Error(`Unsupported payload encoding \"${payload.encoding}\"`);
|
|
90
116
|
}
|
|
117
|
+
openPayloadStream(id) {
|
|
118
|
+
const payload = this.getPayload(id);
|
|
119
|
+
if (!payload?.storagePath)
|
|
120
|
+
throw new Error("Payload not found");
|
|
121
|
+
if (payload.encoding !== "identity" && payload.encoding !== "gzip")
|
|
122
|
+
throw new Error("Unsupported payload encoding");
|
|
123
|
+
const source = createReadStream(this.rootDir === ":memory:" ? payload.storagePath : join(this.rootDir, payload.storagePath));
|
|
124
|
+
if (payload.encoding === "identity")
|
|
125
|
+
return source;
|
|
126
|
+
const decoded = createGunzip();
|
|
127
|
+
pipeline(source, decoded, () => { }); // Pipeline closes both ends on cancellation or read failure.
|
|
128
|
+
return decoded;
|
|
129
|
+
}
|
|
130
|
+
/** Read a bounded uncompressed range without materializing the entire payload. */
|
|
131
|
+
async readPayloadRange(id, offset, limit) {
|
|
132
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1)
|
|
133
|
+
throw new RangeError("Invalid payload range");
|
|
134
|
+
const payload = this.getPayload(id);
|
|
135
|
+
if (!payload?.storagePath)
|
|
136
|
+
throw new Error("Payload not found");
|
|
137
|
+
if (offset >= payload.byteSize)
|
|
138
|
+
return Buffer.alloc(0);
|
|
139
|
+
const path = this.rootDir === ":memory:" ? payload.storagePath : join(this.rootDir, payload.storagePath);
|
|
140
|
+
const end = Math.min(payload.byteSize, offset + limit);
|
|
141
|
+
const source = createReadStream(path, payload.encoding === "identity" ? { start: offset, end: end - 1 } : {});
|
|
142
|
+
if (payload.encoding !== "identity" && payload.encoding !== "gzip") {
|
|
143
|
+
source.destroy();
|
|
144
|
+
throw new Error("Unsupported payload encoding");
|
|
145
|
+
}
|
|
146
|
+
const stream = payload.encoding === "gzip" ? source.pipe(createGunzip()) : source;
|
|
147
|
+
const forwardError = (error) => stream.destroy(error);
|
|
148
|
+
if (stream !== source)
|
|
149
|
+
source.on("error", forwardError);
|
|
150
|
+
let position = payload.encoding === "identity" ? offset : 0;
|
|
151
|
+
const parts = [];
|
|
152
|
+
try {
|
|
153
|
+
for await (const value of stream) {
|
|
154
|
+
const bytes = Buffer.from(value);
|
|
155
|
+
const from = Math.max(0, offset - position), to = Math.min(bytes.length, end - position);
|
|
156
|
+
if (to > from)
|
|
157
|
+
parts.push(Buffer.from(bytes.subarray(from, to)));
|
|
158
|
+
position += bytes.length;
|
|
159
|
+
if (position >= end)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
stream.destroy();
|
|
165
|
+
source.destroy();
|
|
166
|
+
}
|
|
167
|
+
const result = Buffer.concat(parts);
|
|
168
|
+
if (result.length !== end - offset)
|
|
169
|
+
throw new Error("Incomplete payload range");
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
91
172
|
readPayloadText(id) {
|
|
92
173
|
return Buffer.from(this.readPayloadBytes(id)).toString("utf8");
|
|
93
174
|
}
|
package/dist/data/pibo-store.js
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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);
|
package/dist/data/schema.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
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 = 14;
|
|
2
5
|
const NATIVE_HISTORY_FALLBACK_SCHEMA_VERSION = 5;
|
|
6
|
+
const TERMINAL_MESSAGE_UNREAD_SCHEMA_VERSION = 14;
|
|
3
7
|
const retiredScopeColumn = ["owner", "scope"].join("_");
|
|
4
8
|
const payloadTableDefinition = `
|
|
5
9
|
id TEXT PRIMARY KEY,
|
|
@@ -198,6 +202,14 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
|
|
|
198
202
|
if (rebuildPayloadTable)
|
|
199
203
|
rebuildPayloadTableForMetadataIdentity(db);
|
|
200
204
|
hooks.afterStep?.("payload-metadata-identity");
|
|
205
|
+
if (previousVersion < TERMINAL_MESSAGE_UNREAD_SCHEMA_VERSION) {
|
|
206
|
+
db.exec(`
|
|
207
|
+
DROP TRIGGER IF EXISTS chat_unread_event_insert;
|
|
208
|
+
DROP TRIGGER IF EXISTS chat_unread_event_update;
|
|
209
|
+
DROP INDEX IF EXISTS idx_event_log_unread_stream;
|
|
210
|
+
DROP INDEX IF EXISTS idx_event_log_unread_session_stream;
|
|
211
|
+
`);
|
|
212
|
+
}
|
|
201
213
|
db.exec(`
|
|
202
214
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
203
215
|
id TEXT PRIMARY KEY,
|
|
@@ -660,8 +672,10 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
|
|
|
660
672
|
ON event_log(session_id, stream_id);
|
|
661
673
|
CREATE INDEX IF NOT EXISTS idx_event_log_unread_session_stream
|
|
662
674
|
ON event_log(session_id, stream_id)
|
|
663
|
-
WHERE
|
|
664
|
-
|
|
675
|
+
WHERE type = 'message_finished';
|
|
676
|
+
CREATE INDEX IF NOT EXISTS idx_event_log_unread_error_session_stream
|
|
677
|
+
ON event_log(session_id, stream_id)
|
|
678
|
+
WHERE type = 'session_error';
|
|
665
679
|
CREATE INDEX IF NOT EXISTS idx_event_log_session_sequence_stream
|
|
666
680
|
ON event_log(session_id, session_sequence DESC, stream_id DESC);
|
|
667
681
|
CREATE INDEX IF NOT EXISTS idx_event_log_session_type_sequence_stream
|
|
@@ -710,6 +724,12 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
|
|
|
710
724
|
ON telemetry_turns(retention_class, updated_at);
|
|
711
725
|
CREATE INDEX IF NOT EXISTS idx_telemetry_phases_turn_started
|
|
712
726
|
ON telemetry_phases(turn_id, started_at ASC);
|
|
727
|
+
CREATE INDEX IF NOT EXISTS idx_telemetry_phases_open_name
|
|
728
|
+
ON telemetry_phases(turn_id, name, COALESCE(last_progress_at, started_at) DESC, created_at DESC) WHERE status='open';
|
|
729
|
+
CREATE INDEX IF NOT EXISTS idx_telemetry_phases_open_turn
|
|
730
|
+
ON telemetry_phases(turn_id, started_at ASC, created_at ASC) WHERE status='open';
|
|
731
|
+
CREATE INDEX IF NOT EXISTS idx_telemetry_phases_turn_name
|
|
732
|
+
ON telemetry_phases(turn_id, name);
|
|
713
733
|
CREATE INDEX IF NOT EXISTS idx_telemetry_phases_session_status
|
|
714
734
|
ON telemetry_phases(pibo_session_id, status, last_progress_at);
|
|
715
735
|
CREATE INDEX IF NOT EXISTS idx_telemetry_phases_provider_request
|
|
@@ -759,6 +779,19 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
|
|
|
759
779
|
CREATE INDEX IF NOT EXISTS idx_telemetry_tool_calls_retention_updated
|
|
760
780
|
ON telemetry_tool_calls(retention_class, updated_at);
|
|
761
781
|
`);
|
|
782
|
+
db.exec(CHAT_READ_PROJECTION_SCHEMA);
|
|
783
|
+
if (previousVersion < TERMINAL_MESSAGE_UNREAD_SCHEMA_VERSION) {
|
|
784
|
+
db.exec(`
|
|
785
|
+
DELETE FROM chat_unread_counts;
|
|
786
|
+
DELETE FROM chat_unread_index;
|
|
787
|
+
UPDATE chat_read_backfill
|
|
788
|
+
SET event_cursor = 0,
|
|
789
|
+
event_target = (SELECT COALESCE(MAX(stream_id), 0) FROM event_log)
|
|
790
|
+
WHERE id = 1;
|
|
791
|
+
`);
|
|
792
|
+
}
|
|
793
|
+
db.exec(CHAT_NAVIGATION_REVISION_SCHEMA);
|
|
794
|
+
db.exec(TELEMETRY_MAINTENANCE_SCHEMA);
|
|
762
795
|
hooks.afterStep?.("schema");
|
|
763
796
|
db.exec(`
|
|
764
797
|
INSERT OR IGNORE INTO session_runtime_bindings (
|
|
@@ -798,6 +831,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
|
|
|
798
831
|
END;
|
|
799
832
|
`);
|
|
800
833
|
hooks.afterStep?.("render-high-water");
|
|
834
|
+
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
835
|
// Always inspect for interrupted pre-atomic v7 repairs. A previous process
|
|
802
836
|
// may have written negative temporary values before setting user_version.
|
|
803
837
|
db.exec(`
|
|
@@ -807,7 +841,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
|
|
|
807
841
|
);
|
|
808
842
|
INSERT INTO pibo_v7_sequence_repair_sessions (session_id)
|
|
809
843
|
SELECT DISTINCT session_id
|
|
810
|
-
FROM event_log
|
|
844
|
+
FROM event_log INDEXED BY idx_event_log_sequence_repair_candidates
|
|
811
845
|
WHERE session_id IS NOT NULL
|
|
812
846
|
AND (session_sequence IS NULL OR session_sequence <= 0);
|
|
813
847
|
`);
|
|
@@ -858,6 +892,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
|
|
|
858
892
|
}
|
|
859
893
|
}
|
|
860
894
|
hooks.afterStep?.("runtime-binding-metadata");
|
|
895
|
+
db.exec(MESSAGE_COMMAND_SCHEMA);
|
|
861
896
|
db.exec(`PRAGMA user_version = ${PIBO_DATA_SCHEMA_VERSION}`);
|
|
862
897
|
hooks.afterStep?.("user-version");
|
|
863
898
|
}
|
|
@@ -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
|
+
}
|