@averyyy/pi-coding-agent 0.82.1-piclient.2 → 0.82.1-piclient.4
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/CHANGELOG.md +12 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +2 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/tool-effects.d.ts +6 -0
- package/dist/cli/tool-effects.d.ts.map +1 -0
- package/dist/cli/tool-effects.js +164 -0
- package/dist/cli/tool-effects.js.map +1 -0
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js +5 -0
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/agent-session.d.ts +13 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +340 -89
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/model-runtime.d.ts +7 -0
- package/dist/core/model-runtime.d.ts.map +1 -1
- package/dist/core/model-runtime.js +66 -0
- package/dist/core/model-runtime.js.map +1 -1
- package/dist/core/pi-server-client.d.ts +56 -2
- package/dist/core/pi-server-client.d.ts.map +1 -1
- package/dist/core/pi-server-client.js +2090 -288
- package/dist/core/pi-server-client.js.map +1 -1
- package/dist/core/pi-server-compact-state.d.ts +81 -0
- package/dist/core/pi-server-compact-state.d.ts.map +1 -0
- package/dist/core/pi-server-compact-state.js +438 -0
- package/dist/core/pi-server-compact-state.js.map +1 -0
- package/dist/core/pi-server-protocol.d.ts +2 -0
- package/dist/core/pi-server-protocol.d.ts.map +1 -1
- package/dist/core/pi-server-protocol.js +24 -1
- package/dist/core/pi-server-protocol.js.map +1 -1
- package/dist/core/pi-server-provider-execution.d.ts +19 -0
- package/dist/core/pi-server-provider-execution.d.ts.map +1 -0
- package/dist/core/pi-server-provider-execution.js +43 -0
- package/dist/core/pi-server-provider-execution.js.map +1 -0
- package/dist/core/pi-server-request.d.ts +28 -0
- package/dist/core/pi-server-request.d.ts.map +1 -1
- package/dist/core/pi-server-request.js +492 -75
- package/dist/core/pi-server-request.js.map +1 -1
- package/dist/core/pi-server-run-state.d.ts +41 -0
- package/dist/core/pi-server-run-state.d.ts.map +1 -0
- package/dist/core/pi-server-run-state.js +318 -0
- package/dist/core/pi-server-run-state.js.map +1 -0
- package/dist/core/provider-execution-contract.d.ts +36 -0
- package/dist/core/provider-execution-contract.d.ts.map +1 -0
- package/dist/core/provider-execution-contract.js +30 -0
- package/dist/core/provider-execution-contract.js.map +1 -0
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +74 -53
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +86 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +737 -144
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/tool-effect-journal.d.ts +103 -0
- package/dist/core/tool-effect-journal.d.ts.map +1 -0
- package/dist/core/tool-effect-journal.js +1442 -0
- package/dist/core/tool-effect-journal.js.map +1 -0
- package/dist/core/tools/output-accumulator.d.ts +3 -1
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +46 -26
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +13 -0
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts +15 -0
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +98 -29
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,14 +1,61 @@
|
|
|
1
1
|
import { uuidv7 } from "@earendil-works/pi-ai";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { closeSync, createReadStream, existsSync, constants as fsConstants, fsyncSync, mkdirSync, openSync, readdirSync, readSync, renameSync, statSync, unlinkSync, writeSync, } from "fs";
|
|
4
4
|
import { readdir, stat } from "fs/promises";
|
|
5
|
-
import { join, resolve } from "path";
|
|
5
|
+
import { basename, dirname, join, resolve } from "path";
|
|
6
6
|
import { createInterface } from "readline";
|
|
7
7
|
import { StringDecoder } from "string_decoder";
|
|
8
|
+
import { isDeepStrictEqual } from "util";
|
|
9
|
+
import { serialize as serializeV8 } from "v8";
|
|
8
10
|
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
|
|
9
11
|
import { normalizePath, resolvePath } from "../utils/paths.js";
|
|
10
12
|
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "./messages.js";
|
|
13
|
+
import { appendPiServerTreeHash, hashPiServerTreeEntry, PI_SERVER_EMPTY_TREE_HASH } from "./pi-server-protocol.js";
|
|
11
14
|
export const CURRENT_SESSION_VERSION = 3;
|
|
15
|
+
let sessionManagerPersistenceTestHooks;
|
|
16
|
+
export function setSessionManagerPersistenceTestHooks(hooks) {
|
|
17
|
+
sessionManagerPersistenceTestHooks = hooks;
|
|
18
|
+
}
|
|
19
|
+
export const DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS = Object.freeze({
|
|
20
|
+
maxEntries: 250_000,
|
|
21
|
+
maxLogicalBytes: 256 * 1024 * 1024,
|
|
22
|
+
});
|
|
23
|
+
export const ENV_SESSION_MAX_ENTRIES = "PI_SESSION_MAX_ENTRIES";
|
|
24
|
+
export const ENV_SESSION_MAX_LOGICAL_BYTES = "PI_SESSION_MAX_LOGICAL_BYTES";
|
|
25
|
+
export const ENV_SESSION_MAX_FILE_BYTES = "PI_SESSION_MAX_FILE_BYTES";
|
|
26
|
+
export class SessionManagerCapacityError extends Error {
|
|
27
|
+
code = "PI_SESSION_CAPACITY_EXCEEDED";
|
|
28
|
+
retryable = false;
|
|
29
|
+
resource;
|
|
30
|
+
sessionId;
|
|
31
|
+
path;
|
|
32
|
+
current;
|
|
33
|
+
requested;
|
|
34
|
+
limit;
|
|
35
|
+
constructor(input) {
|
|
36
|
+
super(`Session capacity exceeded: resource=${input.resource}, session=${input.sessionId}, path=${input.path ?? "<memory>"}, requested=${input.requested}, limit=${input.limit}`);
|
|
37
|
+
this.name = "SessionManagerCapacityError";
|
|
38
|
+
this.resource = input.resource;
|
|
39
|
+
this.sessionId = input.sessionId;
|
|
40
|
+
this.path = input.path;
|
|
41
|
+
this.current = input.current;
|
|
42
|
+
this.requested = input.requested;
|
|
43
|
+
this.limit = input.limit;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let sessionManagerCapacityOverrides = {};
|
|
47
|
+
export function configureSessionManagerCapacityLimits(overrides) {
|
|
48
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
49
|
+
if (!Number.isSafeInteger(value) || (value ?? 0) <= 0) {
|
|
50
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
sessionManagerCapacityOverrides = { ...sessionManagerCapacityOverrides, ...overrides };
|
|
54
|
+
return getSessionManagerCapacityLimits();
|
|
55
|
+
}
|
|
56
|
+
export function resetSessionManagerCapacityLimits() {
|
|
57
|
+
sessionManagerCapacityOverrides = {};
|
|
58
|
+
}
|
|
12
59
|
function createSessionId() {
|
|
13
60
|
return uuidv7();
|
|
14
61
|
}
|
|
@@ -121,6 +168,40 @@ function buildEntryIndex(entries, byId) {
|
|
|
121
168
|
}
|
|
122
169
|
return index;
|
|
123
170
|
}
|
|
171
|
+
function validateSessionTreeShape(entries) {
|
|
172
|
+
const index = new Map();
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (index.has(entry.id))
|
|
175
|
+
throw new Error(`Duplicate session entry id ${entry.id}`);
|
|
176
|
+
index.set(entry.id, entry);
|
|
177
|
+
}
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
if (entry.parentId !== null && !index.has(entry.parentId)) {
|
|
180
|
+
throw new Error(`Parent entry ${entry.parentId} does not exist`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const visitState = new Map();
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (visitState.get(entry.id) === "visited")
|
|
186
|
+
continue;
|
|
187
|
+
const path = [];
|
|
188
|
+
let current = entry;
|
|
189
|
+
while (current) {
|
|
190
|
+
const state = visitState.get(current.id);
|
|
191
|
+
if (state === "visiting") {
|
|
192
|
+
throw new Error(`Session tree contains a parent cycle at entry ${current.id}`);
|
|
193
|
+
}
|
|
194
|
+
if (state === "visited")
|
|
195
|
+
break;
|
|
196
|
+
visitState.set(current.id, "visiting");
|
|
197
|
+
path.push(current);
|
|
198
|
+
current = current.parentId === null ? undefined : index.get(current.parentId);
|
|
199
|
+
}
|
|
200
|
+
for (const pathEntry of path)
|
|
201
|
+
visitState.set(pathEntry.id, "visited");
|
|
202
|
+
}
|
|
203
|
+
return index;
|
|
204
|
+
}
|
|
124
205
|
function buildSessionPath(entries, leafId, byId) {
|
|
125
206
|
const index = buildEntryIndex(entries, byId);
|
|
126
207
|
let leaf;
|
|
@@ -136,7 +217,12 @@ function buildSessionPath(entries, leafId, byId) {
|
|
|
136
217
|
}
|
|
137
218
|
const path = [];
|
|
138
219
|
let current = leaf;
|
|
220
|
+
const seen = new Set();
|
|
139
221
|
while (current) {
|
|
222
|
+
if (seen.has(current.id)) {
|
|
223
|
+
throw new Error(`Session tree contains a parent cycle at entry ${current.id}`);
|
|
224
|
+
}
|
|
225
|
+
seen.add(current.id);
|
|
140
226
|
path.push(current);
|
|
141
227
|
current = current.parentId ? index.get(current.parentId) : undefined;
|
|
142
228
|
}
|
|
@@ -183,7 +269,10 @@ export function sessionEntryToContextMessages(entry) {
|
|
|
183
269
|
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
|
|
184
270
|
}
|
|
185
271
|
if (entry.type === "compaction") {
|
|
186
|
-
return [
|
|
272
|
+
return [
|
|
273
|
+
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
|
|
274
|
+
...(entry.retainedTail ?? []),
|
|
275
|
+
];
|
|
187
276
|
}
|
|
188
277
|
return [];
|
|
189
278
|
}
|
|
@@ -211,6 +300,10 @@ export function buildContextEntries(entries, leafId, byId) {
|
|
|
211
300
|
return path;
|
|
212
301
|
}
|
|
213
302
|
const contextEntries = [compaction];
|
|
303
|
+
if (compaction.retainedTail !== undefined) {
|
|
304
|
+
contextEntries.push(...path.slice(compactionIdx + 1));
|
|
305
|
+
return contextEntries;
|
|
306
|
+
}
|
|
214
307
|
let foundFirstKept = false;
|
|
215
308
|
for (let i = 0; i < compactionIdx; i++) {
|
|
216
309
|
const entry = path[i];
|
|
@@ -256,21 +349,238 @@ const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
|
|
|
256
349
|
const SESSION_HEADER_READ_BUFFER_SIZE = 4096;
|
|
257
350
|
/** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */
|
|
258
351
|
const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
|
|
352
|
+
const SESSION_FILE_ENVELOPE_OVERHEAD_BYTES = 1024 * 1024;
|
|
353
|
+
const WINDOWS_SESSION_RENAME_RETRIES = 4;
|
|
354
|
+
const MAX_WINDOWS_SESSION_RENAME_DELAY_MS = 80;
|
|
355
|
+
function readPositiveSafeIntegerEnvironment(name) {
|
|
356
|
+
const raw = process.env[name];
|
|
357
|
+
if (raw === undefined)
|
|
358
|
+
return undefined;
|
|
359
|
+
if (!/^[1-9][0-9]*$/u.test(raw)) {
|
|
360
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
361
|
+
}
|
|
362
|
+
const value = Number(raw);
|
|
363
|
+
if (!Number.isSafeInteger(value)) {
|
|
364
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
365
|
+
}
|
|
366
|
+
return value;
|
|
367
|
+
}
|
|
368
|
+
export function getSessionManagerCapacityLimits() {
|
|
369
|
+
const maxEntries = sessionManagerCapacityOverrides.maxEntries ??
|
|
370
|
+
readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_ENTRIES) ??
|
|
371
|
+
DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS.maxEntries;
|
|
372
|
+
const maxLogicalBytes = sessionManagerCapacityOverrides.maxLogicalBytes ??
|
|
373
|
+
readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_LOGICAL_BYTES) ??
|
|
374
|
+
DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS.maxLogicalBytes;
|
|
375
|
+
const derivedMaxFileBytes = maxLogicalBytes + maxEntries + SESSION_FILE_ENVELOPE_OVERHEAD_BYTES;
|
|
376
|
+
if (!Number.isSafeInteger(derivedMaxFileBytes)) {
|
|
377
|
+
throw new Error("Session capacity limits are too large to derive a safe session file limit");
|
|
378
|
+
}
|
|
379
|
+
const maxFileBytes = sessionManagerCapacityOverrides.maxFileBytes ??
|
|
380
|
+
readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_FILE_BYTES) ??
|
|
381
|
+
derivedMaxFileBytes;
|
|
382
|
+
return { maxEntries, maxLogicalBytes, maxFileBytes };
|
|
383
|
+
}
|
|
384
|
+
function sessionCapacityError(resource, sessionId, path, current, requested, limit) {
|
|
385
|
+
return new SessionManagerCapacityError({ resource, sessionId, path, current, requested, limit });
|
|
386
|
+
}
|
|
387
|
+
function serializeEntryJson(entry) {
|
|
388
|
+
const serialized = JSON.stringify(entry);
|
|
389
|
+
if (serialized === undefined) {
|
|
390
|
+
throw new Error(`Session entry ${entry.type} could not be serialized`);
|
|
391
|
+
}
|
|
392
|
+
return serialized;
|
|
393
|
+
}
|
|
394
|
+
function measureSessionEntryLogicalBytes(entry) {
|
|
395
|
+
try {
|
|
396
|
+
return Buffer.byteLength(serializeEntryJson(entry), "utf8");
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (entry.type !== "custom")
|
|
400
|
+
throw error;
|
|
401
|
+
// Native in-memory custom entries intentionally support cyclic structured
|
|
402
|
+
// data. They have no JSON representation, so account their exact V8
|
|
403
|
+
// structured-clone bytes; all transportable entries retain JSON byte parity.
|
|
404
|
+
return serializeV8(entry).byteLength;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function measureSessionEntriesLogicalBytes(entries) {
|
|
408
|
+
let logicalBytes = 0;
|
|
409
|
+
for (const entry of entries)
|
|
410
|
+
logicalBytes += measureSessionEntryLogicalBytes(entry);
|
|
411
|
+
return logicalBytes;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Measure durable tree content as the UTF-8 bytes of each independently
|
|
415
|
+
* serialized session entry. File headers and JSONL newlines are envelope bytes.
|
|
416
|
+
*/
|
|
417
|
+
export function calculateSessionManagerLogicalBytes(entries) {
|
|
418
|
+
return measureSessionEntriesLogicalBytes(entries);
|
|
419
|
+
}
|
|
420
|
+
function assertSessionTreeCapacity(sessionId, path, currentEntryCount, currentLogicalBytes, nextEntryCount, nextLogicalBytes) {
|
|
421
|
+
const limits = getSessionManagerCapacityLimits();
|
|
422
|
+
if (nextEntryCount > limits.maxEntries) {
|
|
423
|
+
throw sessionCapacityError("entries", sessionId, path, currentEntryCount, nextEntryCount, limits.maxEntries);
|
|
424
|
+
}
|
|
425
|
+
if (nextLogicalBytes > limits.maxLogicalBytes) {
|
|
426
|
+
throw sessionCapacityError("logical_bytes", sessionId, path, currentLogicalBytes, nextLogicalBytes, limits.maxLogicalBytes);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function measureSessionFileBytes(entries) {
|
|
430
|
+
let fileBytes = 0;
|
|
431
|
+
for (const entry of entries) {
|
|
432
|
+
fileBytes += Buffer.byteLength(serializeEntryJson(entry), "utf8") + 1;
|
|
433
|
+
}
|
|
434
|
+
return fileBytes;
|
|
435
|
+
}
|
|
436
|
+
function assertSessionFileSize(sessionId, path, current, requested) {
|
|
437
|
+
const limit = getSessionManagerCapacityLimits().maxFileBytes;
|
|
438
|
+
if (requested > limit) {
|
|
439
|
+
throw sessionCapacityError("file_bytes", sessionId, path, current, requested, limit);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function getFilesystemErrorCode(error) {
|
|
443
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
444
|
+
return undefined;
|
|
445
|
+
return typeof error.code === "string" ? error.code : undefined;
|
|
446
|
+
}
|
|
447
|
+
function waitForWindowsSessionRenameRetry(attempt) {
|
|
448
|
+
const delay = Math.min(2 ** attempt * 5, MAX_WINDOWS_SESSION_RENAME_DELAY_MS);
|
|
449
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
|
|
450
|
+
}
|
|
451
|
+
function replaceSessionFile(tempPath, targetPath) {
|
|
452
|
+
for (let attempt = 0;; attempt++) {
|
|
453
|
+
try {
|
|
454
|
+
sessionManagerPersistenceTestHooks?.beforeRenameAttempt?.(attempt);
|
|
455
|
+
renameSync(tempPath, targetPath);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
const useWindowsRetries = process.platform === "win32" || sessionManagerPersistenceTestHooks?.simulateWindowsRenameRetries === true;
|
|
460
|
+
if (!useWindowsRetries ||
|
|
461
|
+
getFilesystemErrorCode(error) !== "EPERM" ||
|
|
462
|
+
attempt >= WINDOWS_SESSION_RENAME_RETRIES) {
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
waitForWindowsSessionRenameRetry(attempt);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function fsyncSessionDirectory(targetPath) {
|
|
470
|
+
if (process.platform === "win32") {
|
|
471
|
+
// libuv cannot open a Windows directory for fsync. Re-open the replaced
|
|
472
|
+
// file and fsync it after rename so the replacement reaches stable storage.
|
|
473
|
+
const handle = openSync(targetPath, "r+");
|
|
474
|
+
try {
|
|
475
|
+
fsyncSync(handle);
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
closeSync(handle);
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const handle = openSync(dirname(targetPath), "r");
|
|
483
|
+
try {
|
|
484
|
+
fsyncSync(handle);
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
closeSync(handle);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function serializeFileEntry(entry) {
|
|
491
|
+
return Buffer.from(`${serializeEntryJson(entry)}\n`);
|
|
492
|
+
}
|
|
493
|
+
function writeBufferFully(handle, buffer) {
|
|
494
|
+
let written = 0;
|
|
495
|
+
while (written < buffer.byteLength) {
|
|
496
|
+
const bytesWritten = writeSync(handle, buffer, written, buffer.byteLength - written);
|
|
497
|
+
if (bytesWritten === 0) {
|
|
498
|
+
throw new Error("Session file write made no progress");
|
|
499
|
+
}
|
|
500
|
+
written += bytesWritten;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function writeSessionFileAtomically(targetPath, entries) {
|
|
504
|
+
const header = entries.find((entry) => entry.type === "session");
|
|
505
|
+
const currentBytes = existsSync(targetPath) ? statSync(targetPath).size : 0;
|
|
506
|
+
assertSessionFileSize(header?.id ?? "<unresolved>", targetPath, currentBytes, measureSessionFileBytes(entries));
|
|
507
|
+
const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.rewrite-${process.pid}-${randomUUID()}.tmp`);
|
|
508
|
+
let tempHandle;
|
|
509
|
+
let replaced = false;
|
|
510
|
+
let primaryError;
|
|
511
|
+
const cleanupErrors = [];
|
|
512
|
+
try {
|
|
513
|
+
tempHandle = openSync(tempPath, "wx", 0o600);
|
|
514
|
+
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_open");
|
|
515
|
+
for (const entry of entries) {
|
|
516
|
+
writeBufferFully(tempHandle, serializeFileEntry(entry));
|
|
517
|
+
}
|
|
518
|
+
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_write");
|
|
519
|
+
fsyncSync(tempHandle);
|
|
520
|
+
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_fsync");
|
|
521
|
+
closeSync(tempHandle);
|
|
522
|
+
tempHandle = undefined;
|
|
523
|
+
replaceSessionFile(tempPath, targetPath);
|
|
524
|
+
replaced = true;
|
|
525
|
+
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_replace");
|
|
526
|
+
fsyncSessionDirectory(targetPath);
|
|
527
|
+
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_directory_fsync");
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
primaryError = error;
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
if (tempHandle !== undefined) {
|
|
534
|
+
try {
|
|
535
|
+
closeSync(tempHandle);
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
cleanupErrors.push(error);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (!replaced && existsSync(tempPath)) {
|
|
542
|
+
try {
|
|
543
|
+
unlinkSync(tempPath);
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
cleanupErrors.push(error);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (primaryError !== undefined && cleanupErrors.length === 0) {
|
|
551
|
+
throw primaryError;
|
|
552
|
+
}
|
|
553
|
+
if (primaryError !== undefined || cleanupErrors.length > 0) {
|
|
554
|
+
throw new AggregateError(primaryError === undefined ? cleanupErrors : [primaryError, ...cleanupErrors], `Failed to atomically rewrite session file ${targetPath}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function persistedEntriesEqual(left, right) {
|
|
558
|
+
return isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
|
|
559
|
+
}
|
|
259
560
|
class SessionHeaderScanLimitError extends Error {
|
|
260
561
|
constructor(filePath) {
|
|
261
562
|
super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`);
|
|
262
563
|
this.name = "SessionHeaderScanLimitError";
|
|
263
564
|
}
|
|
264
565
|
}
|
|
265
|
-
|
|
566
|
+
export class SessionFileCorruptionError extends Error {
|
|
567
|
+
path;
|
|
568
|
+
line;
|
|
569
|
+
constructor(filePath, line, cause) {
|
|
570
|
+
super(`Malformed session JSONL at ${filePath}:${line}`, { cause });
|
|
571
|
+
this.name = "SessionFileCorruptionError";
|
|
572
|
+
this.path = filePath;
|
|
573
|
+
this.line = line;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function parseSessionEntryLineStrict(line, filePath, lineNumber) {
|
|
266
577
|
if (!line.trim())
|
|
267
578
|
return null;
|
|
268
579
|
try {
|
|
269
580
|
return JSON.parse(line);
|
|
270
581
|
}
|
|
271
|
-
catch {
|
|
272
|
-
|
|
273
|
-
return null;
|
|
582
|
+
catch (error) {
|
|
583
|
+
throw new SessionFileCorruptionError(filePath, lineNumber, error);
|
|
274
584
|
}
|
|
275
585
|
}
|
|
276
586
|
/** Exported for testing */
|
|
@@ -278,12 +588,17 @@ export function loadEntriesFromFile(filePath) {
|
|
|
278
588
|
const resolvedFilePath = normalizePath(filePath);
|
|
279
589
|
if (!existsSync(resolvedFilePath))
|
|
280
590
|
return [];
|
|
591
|
+
const fileBytes = statSync(resolvedFilePath).size;
|
|
592
|
+
assertSessionFileSize("<unresolved>", resolvedFilePath, 0, fileBytes);
|
|
281
593
|
const entries = [];
|
|
594
|
+
let treeEntryCount = 0;
|
|
595
|
+
let treeLogicalBytes = 0;
|
|
282
596
|
const fd = openSync(resolvedFilePath, "r");
|
|
283
597
|
try {
|
|
284
598
|
const decoder = new StringDecoder("utf8");
|
|
285
599
|
const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
|
|
286
600
|
let pending = "";
|
|
601
|
+
let lineNumber = 1;
|
|
287
602
|
while (true) {
|
|
288
603
|
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
|
|
289
604
|
if (bytesRead === 0)
|
|
@@ -292,18 +607,33 @@ export function loadEntriesFromFile(filePath) {
|
|
|
292
607
|
let lineStart = 0;
|
|
293
608
|
let newlineIndex = pending.indexOf("\n", lineStart);
|
|
294
609
|
while (newlineIndex !== -1) {
|
|
295
|
-
const entry =
|
|
296
|
-
if (entry)
|
|
610
|
+
const entry = parseSessionEntryLineStrict(pending.slice(lineStart, newlineIndex), resolvedFilePath, lineNumber);
|
|
611
|
+
if (entry) {
|
|
612
|
+
if (entry.type !== "session") {
|
|
613
|
+
const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
|
|
614
|
+
assertSessionTreeCapacity(entries[0]?.type === "session" ? entries[0].id : "<unresolved>", resolvedFilePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
|
|
615
|
+
treeEntryCount++;
|
|
616
|
+
treeLogicalBytes = nextLogicalBytes;
|
|
617
|
+
}
|
|
297
618
|
entries.push(entry);
|
|
619
|
+
}
|
|
298
620
|
lineStart = newlineIndex + 1;
|
|
621
|
+
lineNumber++;
|
|
299
622
|
newlineIndex = pending.indexOf("\n", lineStart);
|
|
300
623
|
}
|
|
301
624
|
pending = pending.slice(lineStart);
|
|
302
625
|
}
|
|
303
626
|
pending += decoder.end();
|
|
304
|
-
const finalEntry =
|
|
305
|
-
if (finalEntry)
|
|
627
|
+
const finalEntry = parseSessionEntryLineStrict(pending, resolvedFilePath, lineNumber);
|
|
628
|
+
if (finalEntry) {
|
|
629
|
+
if (finalEntry.type !== "session") {
|
|
630
|
+
const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(finalEntry);
|
|
631
|
+
assertSessionTreeCapacity(entries[0]?.type === "session" ? entries[0].id : "<unresolved>", resolvedFilePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
|
|
632
|
+
treeEntryCount++;
|
|
633
|
+
treeLogicalBytes = nextLogicalBytes;
|
|
634
|
+
}
|
|
306
635
|
entries.push(finalEntry);
|
|
636
|
+
}
|
|
307
637
|
}
|
|
308
638
|
finally {
|
|
309
639
|
closeSync(fd);
|
|
@@ -319,13 +649,13 @@ export function loadEntriesFromFile(filePath) {
|
|
|
319
649
|
}
|
|
320
650
|
/**
|
|
321
651
|
* Inspect a physical line while searching for the first parsed session entry.
|
|
322
|
-
* Blank
|
|
652
|
+
* Blank lines are skipped; malformed JSON fails closed.
|
|
323
653
|
* Returns undefined to keep scanning, null for a parsed non-header entry, or the header.
|
|
324
654
|
*/
|
|
325
|
-
function parseSessionHeaderCandidate(line) {
|
|
655
|
+
function parseSessionHeaderCandidate(line, filePath, lineNumber) {
|
|
326
656
|
if (!line.trim())
|
|
327
657
|
return undefined;
|
|
328
|
-
const entry =
|
|
658
|
+
const entry = parseSessionEntryLineStrict(line, filePath, lineNumber);
|
|
329
659
|
if (!entry)
|
|
330
660
|
return undefined;
|
|
331
661
|
if (entry.type !== "session" || typeof entry.id !== "string")
|
|
@@ -333,18 +663,21 @@ function parseSessionHeaderCandidate(line) {
|
|
|
333
663
|
return entry;
|
|
334
664
|
}
|
|
335
665
|
function readSessionHeader(filePath) {
|
|
666
|
+
const fileBytes = statSync(filePath).size;
|
|
667
|
+
assertSessionFileSize("<unresolved>", filePath, 0, fileBytes);
|
|
336
668
|
const fd = openSync(filePath, "r");
|
|
337
669
|
try {
|
|
338
670
|
const decoder = new StringDecoder("utf8");
|
|
339
671
|
const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE);
|
|
340
672
|
const lineChunks = [];
|
|
341
673
|
let scannedBytes = 0;
|
|
674
|
+
let lineNumber = 1;
|
|
342
675
|
while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {
|
|
343
676
|
const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);
|
|
344
677
|
const bytesRead = readSync(fd, buffer, 0, readLength, null);
|
|
345
678
|
if (bytesRead === 0) {
|
|
346
679
|
lineChunks.push(decoder.end());
|
|
347
|
-
return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
|
|
680
|
+
return parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber) ?? null;
|
|
348
681
|
}
|
|
349
682
|
scannedBytes += bytesRead;
|
|
350
683
|
const chunk = decoder.write(buffer.subarray(0, bytesRead));
|
|
@@ -352,11 +685,12 @@ function readSessionHeader(filePath) {
|
|
|
352
685
|
let newlineIndex = chunk.indexOf("\n", lineStart);
|
|
353
686
|
while (newlineIndex !== -1) {
|
|
354
687
|
lineChunks.push(chunk.slice(lineStart, newlineIndex));
|
|
355
|
-
const header = parseSessionHeaderCandidate(lineChunks.join(""));
|
|
688
|
+
const header = parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber);
|
|
356
689
|
if (header !== undefined)
|
|
357
690
|
return header;
|
|
358
691
|
lineChunks.length = 0;
|
|
359
692
|
lineStart = newlineIndex + 1;
|
|
693
|
+
lineNumber++;
|
|
360
694
|
newlineIndex = chunk.indexOf("\n", lineStart);
|
|
361
695
|
}
|
|
362
696
|
lineChunks.push(chunk.slice(lineStart));
|
|
@@ -366,7 +700,7 @@ function readSessionHeader(filePath) {
|
|
|
366
700
|
const probe = Buffer.allocUnsafe(1);
|
|
367
701
|
if (readSync(fd, probe, 0, probe.length, null) === 0) {
|
|
368
702
|
lineChunks.push(decoder.end());
|
|
369
|
-
return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
|
|
703
|
+
return parseSessionHeaderCandidate(lineChunks.join(""), filePath, lineNumber) ?? null;
|
|
370
704
|
}
|
|
371
705
|
throw new SessionHeaderScanLimitError(filePath);
|
|
372
706
|
}
|
|
@@ -378,9 +712,10 @@ function readSessionHeaderForDiscovery(filePath) {
|
|
|
378
712
|
try {
|
|
379
713
|
return readSessionHeader(filePath);
|
|
380
714
|
}
|
|
381
|
-
catch {
|
|
382
|
-
|
|
383
|
-
|
|
715
|
+
catch (error) {
|
|
716
|
+
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
717
|
+
throw error;
|
|
718
|
+
// Discovery remains best-effort for transient filesystem failures.
|
|
384
719
|
return null;
|
|
385
720
|
}
|
|
386
721
|
}
|
|
@@ -406,7 +741,9 @@ export function findMostRecentSession(sessionDir, cwd) {
|
|
|
406
741
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
407
742
|
return files[0]?.path || null;
|
|
408
743
|
}
|
|
409
|
-
catch {
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
746
|
+
throw error;
|
|
410
747
|
// Directory access and stat races make recent-session discovery unavailable.
|
|
411
748
|
return null;
|
|
412
749
|
}
|
|
@@ -440,18 +777,23 @@ function getMessageActivityTime(entry) {
|
|
|
440
777
|
async function buildSessionInfo(filePath) {
|
|
441
778
|
try {
|
|
442
779
|
const stats = await stat(filePath);
|
|
780
|
+
assertSessionFileSize("<unresolved>", filePath, 0, stats.size);
|
|
443
781
|
let header = null;
|
|
444
782
|
let messageCount = 0;
|
|
445
783
|
let firstMessage = "";
|
|
446
784
|
const allMessages = [];
|
|
447
785
|
let name;
|
|
448
786
|
let lastActivityTime;
|
|
787
|
+
let treeEntryCount = 0;
|
|
788
|
+
let treeLogicalBytes = 0;
|
|
789
|
+
let lineNumber = 0;
|
|
449
790
|
const rl = createInterface({
|
|
450
791
|
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
451
792
|
crlfDelay: Infinity,
|
|
452
793
|
});
|
|
453
794
|
for await (const line of rl) {
|
|
454
|
-
|
|
795
|
+
lineNumber++;
|
|
796
|
+
const entry = parseSessionEntryLineStrict(line, filePath, lineNumber);
|
|
455
797
|
if (!entry)
|
|
456
798
|
continue;
|
|
457
799
|
if (!header) {
|
|
@@ -460,6 +802,12 @@ async function buildSessionInfo(filePath) {
|
|
|
460
802
|
header = entry;
|
|
461
803
|
continue;
|
|
462
804
|
}
|
|
805
|
+
if (entry.type !== "session") {
|
|
806
|
+
const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
|
|
807
|
+
assertSessionTreeCapacity(header.id, filePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
|
|
808
|
+
treeEntryCount++;
|
|
809
|
+
treeLogicalBytes = nextLogicalBytes;
|
|
810
|
+
}
|
|
463
811
|
// Extract session name (use latest, including explicit clears)
|
|
464
812
|
if (entry.type === "session_info") {
|
|
465
813
|
name = entry.name?.trim() || undefined;
|
|
@@ -507,7 +855,9 @@ async function buildSessionInfo(filePath) {
|
|
|
507
855
|
allMessagesText: allMessages.join(" "),
|
|
508
856
|
};
|
|
509
857
|
}
|
|
510
|
-
catch {
|
|
858
|
+
catch (error) {
|
|
859
|
+
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
860
|
+
throw error;
|
|
511
861
|
return null;
|
|
512
862
|
}
|
|
513
863
|
}
|
|
@@ -526,7 +876,9 @@ async function buildSessionInfosWithConcurrency(files, onLoaded) {
|
|
|
526
876
|
.then((info) => {
|
|
527
877
|
results[index] = info;
|
|
528
878
|
})
|
|
529
|
-
.catch(() => {
|
|
879
|
+
.catch((error) => {
|
|
880
|
+
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
881
|
+
throw error;
|
|
530
882
|
results[index] = null;
|
|
531
883
|
})
|
|
532
884
|
.finally(() => {
|
|
@@ -540,7 +892,13 @@ async function buildSessionInfosWithConcurrency(files, onLoaded) {
|
|
|
540
892
|
startNext();
|
|
541
893
|
}
|
|
542
894
|
if (inFlight.size > 0) {
|
|
543
|
-
|
|
895
|
+
try {
|
|
896
|
+
await Promise.race(inFlight);
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
await Promise.allSettled([...inFlight]);
|
|
900
|
+
throw error;
|
|
901
|
+
}
|
|
544
902
|
}
|
|
545
903
|
}
|
|
546
904
|
return results;
|
|
@@ -565,7 +923,9 @@ async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progress
|
|
|
565
923
|
}
|
|
566
924
|
}
|
|
567
925
|
}
|
|
568
|
-
catch {
|
|
926
|
+
catch (error) {
|
|
927
|
+
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
928
|
+
throw error;
|
|
569
929
|
// Return empty list on error
|
|
570
930
|
}
|
|
571
931
|
return sessions;
|
|
@@ -593,6 +953,11 @@ export class SessionManager {
|
|
|
593
953
|
labelsById = new Map();
|
|
594
954
|
labelTimestampsById = new Map();
|
|
595
955
|
leafId = null;
|
|
956
|
+
treeEntryCount = 0;
|
|
957
|
+
treeLogicalBytes = 0;
|
|
958
|
+
treeHash = PI_SERVER_EMPTY_TREE_HASH;
|
|
959
|
+
treeHashError;
|
|
960
|
+
appendPersistenceFailure;
|
|
596
961
|
constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions, preloadedFileEntries) {
|
|
597
962
|
this.cwd = resolvePath(cwd);
|
|
598
963
|
this.sessionDir = normalizePath(sessionDir);
|
|
@@ -612,34 +977,51 @@ export class SessionManager {
|
|
|
612
977
|
this._setSessionFile(sessionFile);
|
|
613
978
|
}
|
|
614
979
|
_setSessionFile(sessionFile, preloadedFileEntries) {
|
|
615
|
-
|
|
616
|
-
if (existsSync(
|
|
617
|
-
|
|
980
|
+
const resolvedSessionFile = resolvePath(sessionFile);
|
|
981
|
+
if (existsSync(resolvedSessionFile)) {
|
|
982
|
+
const nextFileEntries = structuredClone(preloadedFileEntries ?? loadEntriesFromFile(resolvedSessionFile));
|
|
618
983
|
// If file was empty, initialize it with a valid session header. If it was
|
|
619
984
|
// non-empty but did not parse as a pi session, fail without modifying it.
|
|
620
|
-
if (
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
throw new Error(`Session file is not a valid pi session: ${explicitPath}`);
|
|
985
|
+
if (nextFileEntries.length === 0) {
|
|
986
|
+
if (statSync(resolvedSessionFile).size > 0) {
|
|
987
|
+
throw new Error(`Session file is not a valid pi session: ${resolvedSessionFile}`);
|
|
624
988
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
989
|
+
const nextSessionId = createSessionId();
|
|
990
|
+
const header = {
|
|
991
|
+
type: "session",
|
|
992
|
+
version: CURRENT_SESSION_VERSION,
|
|
993
|
+
id: nextSessionId,
|
|
994
|
+
timestamp: new Date().toISOString(),
|
|
995
|
+
cwd: this.cwd,
|
|
996
|
+
};
|
|
997
|
+
writeSessionFileAtomically(resolvedSessionFile, [header]);
|
|
998
|
+
this.sessionId = nextSessionId;
|
|
999
|
+
this.sessionFile = resolvedSessionFile;
|
|
1000
|
+
this.fileEntries = [header];
|
|
1001
|
+
this._buildIndex();
|
|
1002
|
+
this.appendPersistenceFailure = undefined;
|
|
628
1003
|
this.flushed = true;
|
|
629
1004
|
return;
|
|
630
1005
|
}
|
|
631
|
-
const header =
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1006
|
+
const header = nextFileEntries.find((e) => e.type === "session");
|
|
1007
|
+
const nextSessionId = header?.id ?? createSessionId();
|
|
1008
|
+
const migrated = migrateToCurrentVersion(nextFileEntries);
|
|
1009
|
+
const treeEntries = nextFileEntries.filter((entry) => entry.type !== "session");
|
|
1010
|
+
validateSessionTreeShape(treeEntries);
|
|
1011
|
+
assertSessionTreeCapacity(nextSessionId, resolvedSessionFile, 0, 0, treeEntries.length, measureSessionEntriesLogicalBytes(treeEntries));
|
|
1012
|
+
if (migrated) {
|
|
1013
|
+
writeSessionFileAtomically(resolvedSessionFile, nextFileEntries);
|
|
635
1014
|
}
|
|
1015
|
+
this.sessionId = nextSessionId;
|
|
1016
|
+
this.sessionFile = resolvedSessionFile;
|
|
1017
|
+
this.fileEntries = nextFileEntries;
|
|
636
1018
|
this._buildIndex();
|
|
1019
|
+
this.appendPersistenceFailure = undefined;
|
|
637
1020
|
this.flushed = true;
|
|
638
1021
|
}
|
|
639
1022
|
else {
|
|
640
|
-
const explicitPath = this.sessionFile;
|
|
641
1023
|
this.newSession();
|
|
642
|
-
this.sessionFile =
|
|
1024
|
+
this.sessionFile = resolvedSessionFile; // preserve explicit path from --session flag
|
|
643
1025
|
}
|
|
644
1026
|
}
|
|
645
1027
|
newSession(options) {
|
|
@@ -661,6 +1043,11 @@ export class SessionManager {
|
|
|
661
1043
|
this.labelsById.clear();
|
|
662
1044
|
this.labelTimestampsById.clear();
|
|
663
1045
|
this.leafId = null;
|
|
1046
|
+
this.treeEntryCount = 0;
|
|
1047
|
+
this.treeLogicalBytes = 0;
|
|
1048
|
+
this.treeHash = PI_SERVER_EMPTY_TREE_HASH;
|
|
1049
|
+
this.treeHashError = undefined;
|
|
1050
|
+
this.appendPersistenceFailure = undefined;
|
|
664
1051
|
this.flushed = false;
|
|
665
1052
|
if (this.persist) {
|
|
666
1053
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
@@ -669,15 +1056,26 @@ export class SessionManager {
|
|
|
669
1056
|
return this.sessionFile;
|
|
670
1057
|
}
|
|
671
1058
|
_buildIndex() {
|
|
1059
|
+
const entries = this.fileEntries.filter((entry) => entry.type !== "session");
|
|
1060
|
+
validateSessionTreeShape(entries);
|
|
1061
|
+
const logicalBytes = measureSessionEntriesLogicalBytes(entries);
|
|
1062
|
+
assertSessionTreeCapacity(this.sessionId, this.sessionFile, 0, 0, entries.length, logicalBytes);
|
|
672
1063
|
this.byId.clear();
|
|
673
1064
|
this.labelsById.clear();
|
|
674
1065
|
this.labelTimestampsById.clear();
|
|
675
1066
|
this.leafId = null;
|
|
1067
|
+
this.treeEntryCount = 0;
|
|
1068
|
+
this.treeLogicalBytes = 0;
|
|
1069
|
+
this.treeHash = PI_SERVER_EMPTY_TREE_HASH;
|
|
1070
|
+
this.treeHashError = undefined;
|
|
676
1071
|
for (const entry of this.fileEntries) {
|
|
677
1072
|
if (entry.type === "session")
|
|
678
1073
|
continue;
|
|
1074
|
+
this.treeEntryCount++;
|
|
1075
|
+
this.treeLogicalBytes += measureSessionEntryLogicalBytes(entry);
|
|
679
1076
|
this.byId.set(entry.id, entry);
|
|
680
1077
|
this.leafId = entry.id;
|
|
1078
|
+
this._updateTreeHash(entry);
|
|
681
1079
|
if (entry.type === "label") {
|
|
682
1080
|
if (entry.label) {
|
|
683
1081
|
this.labelsById.set(entry.targetId, entry.label);
|
|
@@ -690,17 +1088,32 @@ export class SessionManager {
|
|
|
690
1088
|
}
|
|
691
1089
|
}
|
|
692
1090
|
}
|
|
693
|
-
|
|
1091
|
+
_updateTreeHash(entry) {
|
|
1092
|
+
if (this.treeHash === undefined)
|
|
1093
|
+
return;
|
|
1094
|
+
try {
|
|
1095
|
+
this.treeHash = appendPiServerTreeHash(this.treeHash, hashPiServerTreeEntry(entry));
|
|
1096
|
+
}
|
|
1097
|
+
catch (error) {
|
|
1098
|
+
this.treeHash = undefined;
|
|
1099
|
+
this.treeHashError = error;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
_rewriteFile(entries = this.fileEntries) {
|
|
694
1103
|
if (!this.persist || !this.sessionFile)
|
|
695
1104
|
return;
|
|
696
|
-
|
|
1105
|
+
if (this.appendPersistenceFailure !== undefined) {
|
|
1106
|
+
throw new Error("Session persistence has an indeterminate prior failure; reopen the session before retrying", {
|
|
1107
|
+
cause: this.appendPersistenceFailure,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
697
1110
|
try {
|
|
698
|
-
|
|
699
|
-
writeFileSync(fd, `${JSON.stringify(entry)}\n`);
|
|
700
|
-
}
|
|
1111
|
+
writeSessionFileAtomically(this.sessionFile, entries);
|
|
701
1112
|
}
|
|
702
|
-
|
|
703
|
-
|
|
1113
|
+
catch (error) {
|
|
1114
|
+
this.appendPersistenceFailure = error;
|
|
1115
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
1116
|
+
throw new Error(`Session file rewrite may have replaced ${this.sessionFile} without a durable in-memory commit; reopen the session before retrying: ${details}`, { cause: error });
|
|
704
1117
|
}
|
|
705
1118
|
}
|
|
706
1119
|
isPersisted() {
|
|
@@ -721,41 +1134,118 @@ export class SessionManager {
|
|
|
721
1134
|
getSessionFile() {
|
|
722
1135
|
return this.sessionFile;
|
|
723
1136
|
}
|
|
724
|
-
|
|
725
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* Durably flush the persisted session file before an external acknowledgement.
|
|
1139
|
+
* In-memory sessions and sessions without a materialized file have nothing to flush.
|
|
1140
|
+
*/
|
|
1141
|
+
flushSessionFile() {
|
|
1142
|
+
if (!this.persist || !this.sessionFile || !existsSync(this.sessionFile))
|
|
726
1143
|
return;
|
|
727
|
-
const
|
|
1144
|
+
const handle = openSync(this.sessionFile, "r+");
|
|
1145
|
+
try {
|
|
1146
|
+
fsyncSync(handle);
|
|
1147
|
+
}
|
|
1148
|
+
finally {
|
|
1149
|
+
closeSync(handle);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
_appendMaterializedEntry(entry) {
|
|
1153
|
+
if (!this.sessionFile)
|
|
1154
|
+
throw new Error("Cannot append without a session file");
|
|
1155
|
+
const handle = openSync(this.sessionFile, fsConstants.O_WRONLY | fsConstants.O_APPEND);
|
|
1156
|
+
let appendError;
|
|
1157
|
+
try {
|
|
1158
|
+
writeBufferFully(handle, serializeFileEntry(entry));
|
|
1159
|
+
sessionManagerPersistenceTestHooks?.onAppendStage?.("after_append");
|
|
1160
|
+
fsyncSync(handle);
|
|
1161
|
+
sessionManagerPersistenceTestHooks?.onAppendStage?.("after_fsync");
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
appendError = error;
|
|
1165
|
+
}
|
|
1166
|
+
try {
|
|
1167
|
+
closeSync(handle);
|
|
1168
|
+
}
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
appendError =
|
|
1171
|
+
appendError === undefined
|
|
1172
|
+
? error
|
|
1173
|
+
: new AggregateError([appendError, error], `Failed to close session file ${this.sessionFile}`);
|
|
1174
|
+
}
|
|
1175
|
+
if (appendError !== undefined) {
|
|
1176
|
+
this.appendPersistenceFailure = appendError;
|
|
1177
|
+
throw new Error(`Session entry ${entry.id} may have reached disk but was not committed in memory; reopen the session before retrying`, { cause: appendError });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
_persistBeforeCommit(entry, nextFileEntries) {
|
|
1181
|
+
if (!this.persist || !this.sessionFile)
|
|
1182
|
+
return this.flushed;
|
|
1183
|
+
const hasAssistant = nextFileEntries.some((candidate) => {
|
|
1184
|
+
return candidate.type === "message" && candidate.message.role === "assistant";
|
|
1185
|
+
});
|
|
728
1186
|
if (!hasAssistant) {
|
|
729
|
-
if (this.flushed)
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
return;
|
|
1187
|
+
if (this.flushed)
|
|
1188
|
+
this._appendMaterializedEntry(entry);
|
|
1189
|
+
return this.flushed;
|
|
1190
|
+
}
|
|
1191
|
+
if (this.flushed) {
|
|
1192
|
+
this._appendMaterializedEntry(entry);
|
|
1193
|
+
return true;
|
|
737
1194
|
}
|
|
1195
|
+
try {
|
|
1196
|
+
writeSessionFileAtomically(this.sessionFile, nextFileEntries);
|
|
1197
|
+
}
|
|
1198
|
+
catch (error) {
|
|
1199
|
+
this.appendPersistenceFailure = error;
|
|
1200
|
+
throw new Error(`Session entry ${entry.id} persistence failed before the in-memory commit; reopen the session before retrying`, { cause: error });
|
|
1201
|
+
}
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
_preflightPersistEntry(entry) {
|
|
1205
|
+
if (!this.persist || !this.sessionFile)
|
|
1206
|
+
return;
|
|
1207
|
+
const entryBytes = serializeFileEntry(entry).byteLength;
|
|
738
1208
|
if (!this.flushed) {
|
|
739
|
-
|
|
1209
|
+
assertSessionFileSize(this.sessionId, this.sessionFile, existsSync(this.sessionFile) ? statSync(this.sessionFile).size : 0, measureSessionFileBytes([...this.fileEntries, entry]));
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const currentBytes = existsSync(this.sessionFile) ? statSync(this.sessionFile).size : 0;
|
|
1213
|
+
assertSessionFileSize(this.sessionId, this.sessionFile, currentBytes, currentBytes + entryBytes);
|
|
1214
|
+
}
|
|
1215
|
+
_preflightAppendEntry(entry) {
|
|
1216
|
+
const nextLogicalBytes = this.treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
|
|
1217
|
+
assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, this.treeEntryCount + 1, nextLogicalBytes);
|
|
1218
|
+
this._preflightPersistEntry(entry);
|
|
1219
|
+
return nextLogicalBytes;
|
|
1220
|
+
}
|
|
1221
|
+
_appendEntry(entry) {
|
|
1222
|
+
if (this.appendPersistenceFailure !== undefined) {
|
|
1223
|
+
throw new Error("Session append persistence has an indeterminate prior failure; reopen the session before retrying", {
|
|
1224
|
+
cause: this.appendPersistenceFailure,
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
const nextLogicalBytes = this._preflightAppendEntry(entry);
|
|
1228
|
+
const nextFileEntries = [...this.fileEntries, entry];
|
|
1229
|
+
let nextTreeHash = this.treeHash;
|
|
1230
|
+
let nextTreeHashError = this.treeHashError;
|
|
1231
|
+
if (nextTreeHash !== undefined) {
|
|
740
1232
|
try {
|
|
741
|
-
|
|
742
|
-
writeFileSync(fd, `${JSON.stringify(e)}\n`);
|
|
743
|
-
}
|
|
1233
|
+
nextTreeHash = appendPiServerTreeHash(nextTreeHash, hashPiServerTreeEntry(entry));
|
|
744
1234
|
}
|
|
745
|
-
|
|
746
|
-
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
nextTreeHash = undefined;
|
|
1237
|
+
nextTreeHashError = error;
|
|
747
1238
|
}
|
|
748
|
-
this.flushed = true;
|
|
749
|
-
}
|
|
750
|
-
else {
|
|
751
|
-
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
752
1239
|
}
|
|
753
|
-
|
|
754
|
-
_appendEntry(entry) {
|
|
1240
|
+
const nextFlushed = this._persistBeforeCommit(entry, nextFileEntries);
|
|
755
1241
|
this.fileEntries.push(entry);
|
|
1242
|
+
this.treeEntryCount++;
|
|
1243
|
+
this.treeLogicalBytes = nextLogicalBytes;
|
|
756
1244
|
this.byId.set(entry.id, entry);
|
|
757
1245
|
this.leafId = entry.id;
|
|
758
|
-
this.
|
|
1246
|
+
this.treeHash = nextTreeHash;
|
|
1247
|
+
this.treeHashError = nextTreeHashError;
|
|
1248
|
+
this.flushed = nextFlushed;
|
|
759
1249
|
}
|
|
760
1250
|
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
|
|
761
1251
|
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
|
|
@@ -794,9 +1284,12 @@ export class SessionManager {
|
|
|
794
1284
|
parentId = entry.id;
|
|
795
1285
|
return entry;
|
|
796
1286
|
});
|
|
797
|
-
|
|
1287
|
+
const nextFileEntries = [...retainedEntries, ...messageEntries];
|
|
1288
|
+
const nextTreeEntries = nextFileEntries.filter((entry) => entry.type !== "session");
|
|
1289
|
+
assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, nextTreeEntries.length, measureSessionEntriesLogicalBytes(nextTreeEntries));
|
|
1290
|
+
this._rewriteFile(nextFileEntries);
|
|
1291
|
+
this.fileEntries = nextFileEntries;
|
|
798
1292
|
this._buildIndex();
|
|
799
|
-
this._rewriteFile();
|
|
800
1293
|
this.flushed = this.persist;
|
|
801
1294
|
}
|
|
802
1295
|
replaceTree(entries, leafId) {
|
|
@@ -804,27 +1297,145 @@ export class SessionManager {
|
|
|
804
1297
|
if (!header) {
|
|
805
1298
|
throw new Error("Session header is missing");
|
|
806
1299
|
}
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
throw new Error(`Duplicate session entry id ${entry.id}`);
|
|
811
|
-
}
|
|
812
|
-
ids.add(entry.id);
|
|
813
|
-
}
|
|
814
|
-
for (const entry of entries) {
|
|
815
|
-
if (entry.parentId !== null && !ids.has(entry.parentId)) {
|
|
816
|
-
throw new Error(`Parent entry ${entry.parentId} does not exist`);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
if (leafId !== null && !ids.has(leafId)) {
|
|
1300
|
+
const savedEntries = structuredClone(entries);
|
|
1301
|
+
const nextEntryById = validateSessionTreeShape(savedEntries);
|
|
1302
|
+
if (leafId !== null && !nextEntryById.has(leafId)) {
|
|
820
1303
|
throw new Error(`Leaf entry ${leafId} does not exist`);
|
|
821
1304
|
}
|
|
822
|
-
|
|
1305
|
+
const nextLogicalBytes = measureSessionEntriesLogicalBytes(savedEntries);
|
|
1306
|
+
assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, savedEntries.length, nextLogicalBytes);
|
|
1307
|
+
const nextFileEntries = [header, ...savedEntries];
|
|
1308
|
+
this._rewriteFile(nextFileEntries);
|
|
1309
|
+
this.fileEntries = nextFileEntries;
|
|
823
1310
|
this._buildIndex();
|
|
824
1311
|
this.leafId = leafId;
|
|
825
|
-
this._rewriteFile();
|
|
826
1312
|
this.flushed = this.persist;
|
|
827
1313
|
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Durably append one authoritative compaction entry without rewriting the
|
|
1316
|
+
* existing session history. The caller must provide the exact local base
|
|
1317
|
+
* identity used by the compaction request.
|
|
1318
|
+
*
|
|
1319
|
+
* Re-applying the exact already-durable entry is an idempotent no-op. Any
|
|
1320
|
+
* other base, leaf, parent, or entry-id divergence fails closed.
|
|
1321
|
+
*/
|
|
1322
|
+
appendCompactionEntry(entry, base) {
|
|
1323
|
+
if (!Number.isSafeInteger(base.baseEntryCount) ||
|
|
1324
|
+
base.baseEntryCount < 0 ||
|
|
1325
|
+
(base.baseLeafId !== null && (typeof base.baseLeafId !== "string" || base.baseLeafId.length === 0)) ||
|
|
1326
|
+
typeof base.baseTreeHash !== "string" ||
|
|
1327
|
+
!/^[a-f0-9]{64}$/u.test(base.baseTreeHash)) {
|
|
1328
|
+
throw new Error("Compaction append base identity is invalid");
|
|
1329
|
+
}
|
|
1330
|
+
if (entry.type !== "compaction" ||
|
|
1331
|
+
typeof entry.id !== "string" ||
|
|
1332
|
+
entry.id.length === 0 ||
|
|
1333
|
+
typeof entry.timestamp !== "string" ||
|
|
1334
|
+
entry.timestamp.length === 0 ||
|
|
1335
|
+
typeof entry.summary !== "string" ||
|
|
1336
|
+
typeof entry.firstKeptEntryId !== "string" ||
|
|
1337
|
+
entry.firstKeptEntryId.length === 0 ||
|
|
1338
|
+
!Number.isFinite(entry.tokensBefore) ||
|
|
1339
|
+
entry.tokensBefore < 0 ||
|
|
1340
|
+
(entry.retainedTail !== undefined && !Array.isArray(entry.retainedTail))) {
|
|
1341
|
+
throw new Error("Compaction entry is invalid");
|
|
1342
|
+
}
|
|
1343
|
+
let entryHash;
|
|
1344
|
+
try {
|
|
1345
|
+
entryHash = hashPiServerTreeEntry(entry);
|
|
1346
|
+
}
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
throw new Error(`Compaction entry ${entry.id} is not serializable`, { cause: error });
|
|
1349
|
+
}
|
|
1350
|
+
const currentTreeHash = this.treeHash;
|
|
1351
|
+
if (currentTreeHash === undefined) {
|
|
1352
|
+
throw new Error("Cannot validate compaction base because the local tree contains a non-serializable entry", {
|
|
1353
|
+
cause: this.treeHashError,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
const currentEntryCount = this.treeEntryCount;
|
|
1357
|
+
if (currentEntryCount === base.baseEntryCount + 1) {
|
|
1358
|
+
const existing = this.fileEntries.at(-1);
|
|
1359
|
+
if (existing?.type === "compaction" &&
|
|
1360
|
+
existing.id === entry.id &&
|
|
1361
|
+
this.leafId === entry.id &&
|
|
1362
|
+
currentTreeHash === appendPiServerTreeHash(base.baseTreeHash, entryHash) &&
|
|
1363
|
+
persistedEntriesEqual(existing, entry)) {
|
|
1364
|
+
if (this.persist && (!this.sessionFile || !this.flushed || !existsSync(this.sessionFile))) {
|
|
1365
|
+
throw new Error("Cannot verify an already-applied compaction without its materialized session file");
|
|
1366
|
+
}
|
|
1367
|
+
this.flushSessionFile();
|
|
1368
|
+
return entry.id;
|
|
1369
|
+
}
|
|
1370
|
+
throw new Error(`Compaction entry ${entry.id} cannot be replayed because the local tree has a divergent entry after the requested base`);
|
|
1371
|
+
}
|
|
1372
|
+
if (currentEntryCount !== base.baseEntryCount) {
|
|
1373
|
+
throw new Error(`Compaction append base entry count mismatch: expected ${base.baseEntryCount}, current ${currentEntryCount}`);
|
|
1374
|
+
}
|
|
1375
|
+
if (currentTreeHash !== base.baseTreeHash) {
|
|
1376
|
+
throw new Error(`Compaction append base tree hash mismatch`);
|
|
1377
|
+
}
|
|
1378
|
+
if (this.leafId !== base.baseLeafId) {
|
|
1379
|
+
throw new Error(`Compaction append base leaf mismatch: expected ${String(base.baseLeafId)}, current ${String(this.leafId)}`);
|
|
1380
|
+
}
|
|
1381
|
+
if (entry.parentId !== base.baseLeafId) {
|
|
1382
|
+
throw new Error(`Compaction entry parent mismatch: expected ${String(base.baseLeafId)}, received ${String(entry.parentId)}`);
|
|
1383
|
+
}
|
|
1384
|
+
if (base.baseLeafId !== null && !this.byId.has(base.baseLeafId)) {
|
|
1385
|
+
throw new Error(`Compaction append base leaf ${base.baseLeafId} does not exist`);
|
|
1386
|
+
}
|
|
1387
|
+
if (!this.byId.has(entry.firstKeptEntryId)) {
|
|
1388
|
+
throw new Error(`Compaction first-kept entry ${entry.firstKeptEntryId} does not exist`);
|
|
1389
|
+
}
|
|
1390
|
+
if (this.byId.has(entry.id)) {
|
|
1391
|
+
throw new Error(`Compaction entry id ${entry.id} already exists`);
|
|
1392
|
+
}
|
|
1393
|
+
if (this.appendPersistenceFailure !== undefined) {
|
|
1394
|
+
throw new Error("Compaction append persistence has an indeterminate prior failure; reopen the session before retrying", { cause: this.appendPersistenceFailure });
|
|
1395
|
+
}
|
|
1396
|
+
const savedEntry = { ...entry };
|
|
1397
|
+
const nextLogicalBytes = this.treeLogicalBytes + measureSessionEntryLogicalBytes(savedEntry);
|
|
1398
|
+
assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, this.treeEntryCount + 1, nextLogicalBytes);
|
|
1399
|
+
const serializedEntry = serializeFileEntry(savedEntry);
|
|
1400
|
+
if (this.persist) {
|
|
1401
|
+
if (!this.sessionFile || !this.flushed || !existsSync(this.sessionFile)) {
|
|
1402
|
+
throw new Error("Cannot durably append compaction before the session file is materialized");
|
|
1403
|
+
}
|
|
1404
|
+
const currentFileBytes = statSync(this.sessionFile).size;
|
|
1405
|
+
assertSessionFileSize(this.sessionId, this.sessionFile, currentFileBytes, currentFileBytes + serializedEntry.byteLength);
|
|
1406
|
+
const handle = openSync(this.sessionFile, fsConstants.O_WRONLY | fsConstants.O_APPEND);
|
|
1407
|
+
let appendError;
|
|
1408
|
+
try {
|
|
1409
|
+
writeBufferFully(handle, serializedEntry);
|
|
1410
|
+
sessionManagerPersistenceTestHooks?.onCompactionAppendStage?.("after_append");
|
|
1411
|
+
fsyncSync(handle);
|
|
1412
|
+
sessionManagerPersistenceTestHooks?.onCompactionAppendStage?.("after_fsync");
|
|
1413
|
+
}
|
|
1414
|
+
catch (error) {
|
|
1415
|
+
appendError = error;
|
|
1416
|
+
}
|
|
1417
|
+
try {
|
|
1418
|
+
closeSync(handle);
|
|
1419
|
+
}
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
appendError =
|
|
1422
|
+
appendError === undefined
|
|
1423
|
+
? error
|
|
1424
|
+
: new AggregateError([appendError, error], `Failed to close session file ${this.sessionFile}`);
|
|
1425
|
+
}
|
|
1426
|
+
if (appendError !== undefined) {
|
|
1427
|
+
this.appendPersistenceFailure = appendError;
|
|
1428
|
+
throw new Error(`Compaction entry ${entry.id} may have reached disk but was not committed in memory; reopen the session before retrying`, { cause: appendError });
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
this.fileEntries.push(savedEntry);
|
|
1432
|
+
this.treeEntryCount++;
|
|
1433
|
+
this.treeLogicalBytes = nextLogicalBytes;
|
|
1434
|
+
this.byId.set(savedEntry.id, savedEntry);
|
|
1435
|
+
this.leafId = savedEntry.id;
|
|
1436
|
+
this.treeHash = appendPiServerTreeHash(currentTreeHash, entryHash);
|
|
1437
|
+
return savedEntry.id;
|
|
1438
|
+
}
|
|
828
1439
|
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
829
1440
|
appendThinkingLevelChange(thinkingLevel) {
|
|
830
1441
|
const entry = {
|
|
@@ -851,7 +1462,7 @@ export class SessionManager {
|
|
|
851
1462
|
return entry.id;
|
|
852
1463
|
}
|
|
853
1464
|
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
854
|
-
appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage) {
|
|
1465
|
+
appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage, retainedTail) {
|
|
855
1466
|
const entry = {
|
|
856
1467
|
type: "compaction",
|
|
857
1468
|
id: generateId(this.byId),
|
|
@@ -860,6 +1471,7 @@ export class SessionManager {
|
|
|
860
1471
|
summary,
|
|
861
1472
|
firstKeptEntryId,
|
|
862
1473
|
tokensBefore,
|
|
1474
|
+
retainedTail,
|
|
863
1475
|
details,
|
|
864
1476
|
usage,
|
|
865
1477
|
fromHook,
|
|
@@ -1105,7 +1717,6 @@ export class SessionManager {
|
|
|
1105
1717
|
if (branchFromId !== null && !this.byId.has(branchFromId)) {
|
|
1106
1718
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
1107
1719
|
}
|
|
1108
|
-
this.leafId = branchFromId;
|
|
1109
1720
|
const entry = {
|
|
1110
1721
|
type: "branch_summary",
|
|
1111
1722
|
id: generateId(this.byId),
|
|
@@ -1162,62 +1773,42 @@ export class SessionManager {
|
|
|
1162
1773
|
labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId) });
|
|
1163
1774
|
}
|
|
1164
1775
|
}
|
|
1165
|
-
if (this.persist) {
|
|
1166
|
-
// Build label entries
|
|
1167
|
-
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
1168
|
-
let parentId = lastEntryId;
|
|
1169
|
-
const labelEntries = [];
|
|
1170
|
-
for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
|
|
1171
|
-
const labelEntry = {
|
|
1172
|
-
type: "label",
|
|
1173
|
-
id: generateId(new Set(pathEntryIds)),
|
|
1174
|
-
parentId,
|
|
1175
|
-
timestamp: labelTimestamp,
|
|
1176
|
-
targetId,
|
|
1177
|
-
label,
|
|
1178
|
-
};
|
|
1179
|
-
pathEntryIds.add(labelEntry.id);
|
|
1180
|
-
labelEntries.push(labelEntry);
|
|
1181
|
-
parentId = labelEntry.id;
|
|
1182
|
-
}
|
|
1183
|
-
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
1184
|
-
this.sessionId = newSessionId;
|
|
1185
|
-
this.sessionFile = newSessionFile;
|
|
1186
|
-
this._buildIndex();
|
|
1187
|
-
// Only write the file now if it contains an assistant message.
|
|
1188
|
-
// Otherwise defer to _persist(), which creates the file on the
|
|
1189
|
-
// first assistant response, matching the newSession() contract
|
|
1190
|
-
// and avoiding the duplicate-header bug when _persist()'s
|
|
1191
|
-
// no-assistant guard later resets flushed to false.
|
|
1192
|
-
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
|
1193
|
-
if (hasAssistant) {
|
|
1194
|
-
this._rewriteFile();
|
|
1195
|
-
this.flushed = true;
|
|
1196
|
-
}
|
|
1197
|
-
else {
|
|
1198
|
-
this.flushed = false;
|
|
1199
|
-
}
|
|
1200
|
-
return newSessionFile;
|
|
1201
|
-
}
|
|
1202
|
-
// In-memory mode: replace current session with the path + labels
|
|
1203
1776
|
const labelEntries = [];
|
|
1777
|
+
const labelEntryIds = new Set(pathEntryIds);
|
|
1204
1778
|
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
1205
1779
|
for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
|
|
1206
1780
|
const labelEntry = {
|
|
1207
1781
|
type: "label",
|
|
1208
|
-
id: generateId(
|
|
1782
|
+
id: generateId(labelEntryIds),
|
|
1209
1783
|
parentId,
|
|
1210
1784
|
timestamp: labelTimestamp,
|
|
1211
1785
|
targetId,
|
|
1212
1786
|
label,
|
|
1213
1787
|
};
|
|
1788
|
+
labelEntryIds.add(labelEntry.id);
|
|
1214
1789
|
labelEntries.push(labelEntry);
|
|
1215
1790
|
parentId = labelEntry.id;
|
|
1216
1791
|
}
|
|
1217
|
-
|
|
1792
|
+
const nextTreeEntries = [...pathWithoutLabels, ...labelEntries];
|
|
1793
|
+
const nextFileEntries = [header, ...nextTreeEntries];
|
|
1794
|
+
assertSessionTreeCapacity(newSessionId, this.persist ? newSessionFile : undefined, this.treeEntryCount, this.treeLogicalBytes, nextTreeEntries.length, measureSessionEntriesLogicalBytes(nextTreeEntries));
|
|
1795
|
+
const hasAssistant = nextFileEntries.some((entry) => {
|
|
1796
|
+
return entry.type === "message" && entry.message.role === "assistant";
|
|
1797
|
+
});
|
|
1798
|
+
if (this.persist) {
|
|
1799
|
+
assertSessionFileSize(newSessionId, newSessionFile, existsSync(newSessionFile) ? statSync(newSessionFile).size : 0, measureSessionFileBytes(nextFileEntries));
|
|
1800
|
+
if (hasAssistant) {
|
|
1801
|
+
writeSessionFileAtomically(newSessionFile, nextFileEntries);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
this.fileEntries = nextFileEntries;
|
|
1218
1805
|
this.sessionId = newSessionId;
|
|
1806
|
+
if (this.persist)
|
|
1807
|
+
this.sessionFile = newSessionFile;
|
|
1219
1808
|
this._buildIndex();
|
|
1220
|
-
|
|
1809
|
+
this.appendPersistenceFailure = undefined;
|
|
1810
|
+
this.flushed = this.persist && hasAssistant;
|
|
1811
|
+
return this.persist ? newSessionFile : undefined;
|
|
1221
1812
|
}
|
|
1222
1813
|
/**
|
|
1223
1814
|
* Create a new session.
|
|
@@ -1293,10 +1884,6 @@ export class SessionManager {
|
|
|
1293
1884
|
if (!sourceHeader) {
|
|
1294
1885
|
throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
|
|
1295
1886
|
}
|
|
1296
|
-
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
|
|
1297
|
-
if (!existsSync(dir)) {
|
|
1298
|
-
mkdirSync(dir, { recursive: true });
|
|
1299
|
-
}
|
|
1300
1887
|
// Create new session file with new ID but forked content
|
|
1301
1888
|
if (options?.id !== undefined) {
|
|
1302
1889
|
assertValidSessionId(options.id);
|
|
@@ -1304,6 +1891,7 @@ export class SessionManager {
|
|
|
1304
1891
|
const newSessionId = options?.id ?? createSessionId();
|
|
1305
1892
|
const timestamp = new Date().toISOString();
|
|
1306
1893
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
1894
|
+
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDirPath(resolvedTargetCwd);
|
|
1307
1895
|
const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
|
|
1308
1896
|
// Write new header pointing to source as parent, with updated cwd
|
|
1309
1897
|
const newHeader = {
|
|
@@ -1314,13 +1902,16 @@ export class SessionManager {
|
|
|
1314
1902
|
cwd: resolvedTargetCwd,
|
|
1315
1903
|
parentSession: resolvedSourcePath,
|
|
1316
1904
|
};
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1905
|
+
const migratedSourceEntries = structuredClone(sourceEntries);
|
|
1906
|
+
migrateToCurrentVersion(migratedSourceEntries);
|
|
1907
|
+
const copiedEntries = migratedSourceEntries.filter((entry) => entry.type !== "session");
|
|
1908
|
+
assertSessionTreeCapacity(newSessionId, newSessionFile, 0, 0, copiedEntries.length, measureSessionEntriesLogicalBytes(copiedEntries));
|
|
1909
|
+
const nextFileEntries = [newHeader, ...copiedEntries];
|
|
1910
|
+
assertSessionFileSize(newSessionId, newSessionFile, 0, measureSessionFileBytes(nextFileEntries));
|
|
1911
|
+
if (!existsSync(dir)) {
|
|
1912
|
+
mkdirSync(dir, { recursive: true });
|
|
1323
1913
|
}
|
|
1914
|
+
writeSessionFileAtomically(newSessionFile, nextFileEntries);
|
|
1324
1915
|
return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
|
|
1325
1916
|
}
|
|
1326
1917
|
/**
|
|
@@ -1381,7 +1972,9 @@ export class SessionManager {
|
|
|
1381
1972
|
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
1382
1973
|
return sessions;
|
|
1383
1974
|
}
|
|
1384
|
-
catch {
|
|
1975
|
+
catch (error) {
|
|
1976
|
+
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
1977
|
+
throw error;
|
|
1385
1978
|
return [];
|
|
1386
1979
|
}
|
|
1387
1980
|
}
|