@pentoshi/clai 3.8.36 → 3.8.39
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/app/adapters/current-store-adapter.js +2 -2
- package/dist/app/adapters/current-store-adapter.js.map +1 -1
- package/dist/app/controllers/session-controller.d.ts +6 -2
- package/dist/app/controllers/session-controller.js +19 -21
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/controllers/session-persistence.d.ts +16 -0
- package/dist/app/controllers/session-persistence.js +55 -0
- package/dist/app/controllers/session-persistence.js.map +1 -0
- package/dist/app/ports/persistence-port.d.ts +7 -0
- package/dist/store/history.d.ts +15 -8
- package/dist/store/history.js +357 -159
- package/dist/store/history.js.map +1 -1
- package/dist/tools/jobs.js +32 -2
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +1 -0
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/app/plan-lifecycle.js +10 -4
- package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
- package/dist/tui-v2/bootstrap/lifecycle.d.ts +13 -1
- package/dist/tui-v2/bootstrap/lifecycle.js +18 -3
- package/dist/tui-v2/bootstrap/lifecycle.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/package.json +1 -1
package/dist/store/history.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFile, copyFile, mkdir, readdir, readFile, rm, writeFile, rename, } from "node:fs/promises";
|
|
1
|
+
import { appendFile, copyFile, mkdir, readdir, open, readFile, rm, stat, utimes, writeFile, rename, } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { isInternalChatMessage, } from "../types.js";
|
|
4
4
|
import { redactSecrets } from "../llm/provider.js";
|
|
@@ -17,6 +17,12 @@ function dbFilePath() {
|
|
|
17
17
|
function jsonlFilePath() {
|
|
18
18
|
return join(historyDirPath(), "history.jsonl");
|
|
19
19
|
}
|
|
20
|
+
function jsonlLockFilePath() {
|
|
21
|
+
return join(historyDirPath(), "history.jsonl.lock");
|
|
22
|
+
}
|
|
23
|
+
function jsonlLockReaperPath() {
|
|
24
|
+
return join(historyDirPath(), "history.jsonl.lock.reaper");
|
|
25
|
+
}
|
|
20
26
|
/** Sessions pruned by retention land here — never hard-deleted on autosave. */
|
|
21
27
|
function archiveFilePath() {
|
|
22
28
|
return join(historyDirPath(), "history-archive.jsonl");
|
|
@@ -27,14 +33,6 @@ function backupDirPath() {
|
|
|
27
33
|
}
|
|
28
34
|
/** Max rolling backups kept under history-backups/. */
|
|
29
35
|
const MAX_HISTORY_BACKUPS = 12;
|
|
30
|
-
// We keep this string here (not as a literal) so the bundler doesn't try
|
|
31
|
-
// to statically resolve a module that may not be installed. If a user has
|
|
32
|
-
// `better-sqlite3` available (eg they explicitly added it for a richer
|
|
33
|
-
// history experience) we'll happily use it; otherwise we transparently
|
|
34
|
-
// fall back to the always-available JSONL log. This lets us drop
|
|
35
|
-
// `better-sqlite3` from our optional dependencies — and with it the
|
|
36
|
-
// deprecated `prebuild-install` warning — without losing functionality
|
|
37
|
-
// for users who already had a SQLite-backed history.
|
|
38
36
|
const sqliteModuleName = "better-sqlite3";
|
|
39
37
|
/** Snapshot the active session workspace for history persistence. */
|
|
40
38
|
function workspaceFieldsFromActive(existing) {
|
|
@@ -83,6 +81,8 @@ async function loadDatabase() {
|
|
|
83
81
|
name TEXT,
|
|
84
82
|
created_at TEXT NOT NULL,
|
|
85
83
|
updated_at TEXT NOT NULL,
|
|
84
|
+
writer_generation TEXT,
|
|
85
|
+
revision INTEGER NOT NULL DEFAULT 0,
|
|
86
86
|
cwd TEXT NOT NULL,
|
|
87
87
|
messages_json TEXT NOT NULL
|
|
88
88
|
);
|
|
@@ -100,6 +100,15 @@ async function loadDatabase() {
|
|
|
100
100
|
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at);
|
|
101
101
|
CREATE INDEX IF NOT EXISTS idx_tool_calls_session_id ON tool_calls(session_id);
|
|
102
102
|
`);
|
|
103
|
+
const sessionColumns = cachedDb
|
|
104
|
+
.prepare("PRAGMA table_info(sessions)")
|
|
105
|
+
.all();
|
|
106
|
+
if (!sessionColumns.some((column) => column.name === "writer_generation")) {
|
|
107
|
+
cachedDb.exec("ALTER TABLE sessions ADD COLUMN writer_generation TEXT;");
|
|
108
|
+
}
|
|
109
|
+
if (!sessionColumns.some((column) => column.name === "revision")) {
|
|
110
|
+
cachedDb.exec("ALTER TABLE sessions ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;");
|
|
111
|
+
}
|
|
103
112
|
return cachedDb;
|
|
104
113
|
}
|
|
105
114
|
catch (err) {
|
|
@@ -160,11 +169,101 @@ function scrubTranscript(items) {
|
|
|
160
169
|
}
|
|
161
170
|
});
|
|
162
171
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
const JSONL_LOCK_STALE_MS = 60_000;
|
|
173
|
+
const JSONL_LOCK_RETRIES = 200;
|
|
174
|
+
/** Serialize stale-lock reclamation and recheck the owner while holding it. */
|
|
175
|
+
async function reapStaleJsonlLock() {
|
|
176
|
+
let reaper;
|
|
177
|
+
const reaperToken = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
178
|
+
try {
|
|
179
|
+
reaper = await open(jsonlLockReaperPath(), "wx", 0o600);
|
|
180
|
+
try {
|
|
181
|
+
await reaper.writeFile(reaperToken);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
await reaper.close().catch(() => undefined);
|
|
185
|
+
await rm(jsonlLockReaperPath(), { force: true }).catch(() => undefined);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error?.code !== "EEXIST")
|
|
191
|
+
throw error;
|
|
192
|
+
const existingToken = await readFile(jsonlLockReaperPath(), "utf8").catch(() => undefined);
|
|
193
|
+
const reaperStat = await stat(jsonlLockReaperPath()).catch(() => undefined);
|
|
194
|
+
if (!reaperStat ||
|
|
195
|
+
Date.now() - reaperStat.mtimeMs <= JSONL_LOCK_STALE_MS) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const confirmed = await readFile(jsonlLockReaperPath(), "utf8").catch(() => undefined);
|
|
199
|
+
if (confirmed !== undefined && confirmed === existingToken) {
|
|
200
|
+
await rm(jsonlLockReaperPath(), { force: true }).catch(() => undefined);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const token = await readFile(jsonlLockFilePath(), "utf8").catch(() => undefined);
|
|
206
|
+
const lockStat = await stat(jsonlLockFilePath()).catch(() => undefined);
|
|
207
|
+
if (!lockStat || Date.now() - lockStat.mtimeMs <= JSONL_LOCK_STALE_MS) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// The live owner refreshes mtime periodically. A stale marker—including
|
|
211
|
+
// an empty marker left between open/write—is therefore safe to reclaim.
|
|
212
|
+
const confirmed = await readFile(jsonlLockFilePath(), "utf8").catch(() => undefined);
|
|
213
|
+
if (confirmed !== undefined && confirmed === token) {
|
|
214
|
+
await rm(jsonlLockFilePath(), { force: true });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await reaper.close().catch(() => undefined);
|
|
219
|
+
const currentReaper = await readFile(jsonlLockReaperPath(), "utf8").catch(() => undefined);
|
|
220
|
+
if (currentReaper === reaperToken) {
|
|
221
|
+
await rm(jsonlLockReaperPath(), { force: true }).catch(() => undefined);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Cross-process lock around JSONL read/modify/rename. Atomic rename protects
|
|
227
|
+
* readers, but without this lock two clai processes can both read the same
|
|
228
|
+
* base file and then each replace it, dropping whichever session they did not
|
|
229
|
+
* observe. The lock is transient and stale crash leftovers self-heal.
|
|
230
|
+
*/
|
|
231
|
+
async function acquireJsonlWriteLock() {
|
|
232
|
+
await mkdir(historyDirPath(), { recursive: true });
|
|
233
|
+
for (let attempt = 0; attempt < JSONL_LOCK_RETRIES; attempt += 1) {
|
|
234
|
+
try {
|
|
235
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
236
|
+
const handle = await open(jsonlLockFilePath(), "wx", 0o600);
|
|
237
|
+
try {
|
|
238
|
+
await handle.writeFile(token);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
await handle.close().catch(() => undefined);
|
|
242
|
+
await rm(jsonlLockFilePath(), { force: true }).catch(() => undefined);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
const heartbeat = setInterval(() => {
|
|
246
|
+
const now = new Date();
|
|
247
|
+
void utimes(jsonlLockFilePath(), now, now).catch(() => undefined);
|
|
248
|
+
}, JSONL_LOCK_STALE_MS / 3);
|
|
249
|
+
heartbeat.unref();
|
|
250
|
+
return async () => {
|
|
251
|
+
clearInterval(heartbeat);
|
|
252
|
+
await handle.close().catch(() => undefined);
|
|
253
|
+
const currentToken = await readFile(jsonlLockFilePath(), "utf8").catch(() => undefined);
|
|
254
|
+
if (currentToken === token) {
|
|
255
|
+
await rm(jsonlLockFilePath(), { force: true }).catch(() => undefined);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (error?.code !== "EEXIST")
|
|
261
|
+
throw error;
|
|
262
|
+
await reapStaleJsonlLock();
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
throw new Error("timed out waiting for history write lock");
|
|
168
267
|
}
|
|
169
268
|
/**
|
|
170
269
|
* Serializes every JSONL mutation through a single promise chain so concurrent
|
|
@@ -180,12 +279,18 @@ function mutateJsonl(update) {
|
|
|
180
279
|
const run = jsonlWriteChain.then(async () => {
|
|
181
280
|
try {
|
|
182
281
|
await ensureHistoryRecovered();
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
282
|
+
const releaseLock = await acquireJsonlWriteLock();
|
|
283
|
+
try {
|
|
284
|
+
const current = await readJsonlRecordsFrom(jsonlFilePath());
|
|
285
|
+
const next = update(current);
|
|
286
|
+
await writeJsonlAtomic(next);
|
|
287
|
+
// A list may have been loaded after the pre-write invalidation but
|
|
288
|
+
// before the atomic rename completed. Never leave that snapshot cached.
|
|
289
|
+
invalidateSessionListCache();
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
await releaseLock();
|
|
293
|
+
}
|
|
189
294
|
}
|
|
190
295
|
catch (err) {
|
|
191
296
|
handlePermissionError(err);
|
|
@@ -199,14 +304,62 @@ function updatedAtMs(record) {
|
|
|
199
304
|
const t = Date.parse(record.updatedAt || record.createdAt || "");
|
|
200
305
|
return Number.isFinite(t) ? t : 0;
|
|
201
306
|
}
|
|
202
|
-
|
|
307
|
+
function historyRevision(record) {
|
|
308
|
+
const revision = record?.revision;
|
|
309
|
+
return typeof revision === "number" &&
|
|
310
|
+
Number.isSafeInteger(revision) &&
|
|
311
|
+
revision > 0
|
|
312
|
+
? revision
|
|
313
|
+
: 0;
|
|
314
|
+
}
|
|
315
|
+
function historyWriterGeneration(record) {
|
|
316
|
+
const generation = record?.writerGeneration;
|
|
317
|
+
return typeof generation === "string" && generation.length > 0
|
|
318
|
+
? generation
|
|
319
|
+
: undefined;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Compare snapshots by writer generation, then capture revision. Legacy rows
|
|
323
|
+
* without a generation retain revision/timestamp fallback for compatibility.
|
|
324
|
+
*/
|
|
325
|
+
export function compareHistoryFreshness(left, right) {
|
|
326
|
+
const leftGeneration = historyWriterGeneration(left);
|
|
327
|
+
const rightGeneration = historyWriterGeneration(right);
|
|
328
|
+
if (leftGeneration || rightGeneration) {
|
|
329
|
+
if (!leftGeneration)
|
|
330
|
+
return -1;
|
|
331
|
+
if (!rightGeneration)
|
|
332
|
+
return 1;
|
|
333
|
+
const generationDelta = leftGeneration.localeCompare(rightGeneration);
|
|
334
|
+
if (generationDelta !== 0)
|
|
335
|
+
return generationDelta;
|
|
336
|
+
}
|
|
337
|
+
const revisionDelta = historyRevision(left) - historyRevision(right);
|
|
338
|
+
if (revisionDelta !== 0)
|
|
339
|
+
return revisionDelta;
|
|
340
|
+
if (historyRevision(left) > 0)
|
|
341
|
+
return 0;
|
|
342
|
+
return updatedAtMs(left) - updatedAtMs(right);
|
|
343
|
+
}
|
|
344
|
+
function freshestHistoryRecord(records) {
|
|
345
|
+
let freshest;
|
|
346
|
+
for (const record of records) {
|
|
347
|
+
if (!freshest || compareHistoryFreshness(record, freshest) > 0) {
|
|
348
|
+
freshest = record;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return freshest;
|
|
352
|
+
}
|
|
353
|
+
/** Keep the newest captured version of each session id. */
|
|
203
354
|
export function dedupeHistoryById(records) {
|
|
204
355
|
const byId = new Map();
|
|
205
356
|
for (const record of records) {
|
|
206
357
|
if (!record?.id)
|
|
207
358
|
continue;
|
|
208
359
|
const prev = byId.get(record.id);
|
|
209
|
-
|
|
360
|
+
// Preserve the first source on an exact tie. JSONL is passed first during
|
|
361
|
+
// cross-backend merge and is the durable canonical copy.
|
|
362
|
+
if (!prev || compareHistoryFreshness(record, prev) > 0) {
|
|
210
363
|
byId.set(record.id, record);
|
|
211
364
|
}
|
|
212
365
|
}
|
|
@@ -297,92 +450,101 @@ async function backupActiveHistory() {
|
|
|
297
450
|
export async function recoverOrphanedHistory() {
|
|
298
451
|
const sources = [];
|
|
299
452
|
const extras = [];
|
|
453
|
+
const releaseLock = await acquireJsonlWriteLock();
|
|
300
454
|
try {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
name.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
455
|
+
try {
|
|
456
|
+
const names = await readdir(historyDirPath());
|
|
457
|
+
for (const name of names) {
|
|
458
|
+
// Live write temps: history.jsonl.<pid>.<stamp>.tmp
|
|
459
|
+
if (name.startsWith("history.jsonl.") &&
|
|
460
|
+
name.endsWith(".tmp")) {
|
|
461
|
+
const path = join(historyDirPath(), name);
|
|
462
|
+
const rows = await readJsonlRecordsFrom(path);
|
|
463
|
+
if (rows.length === 0) {
|
|
464
|
+
// Empty crash leftovers — safe to remove.
|
|
465
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
extras.push(...rows);
|
|
469
|
+
sources.push(name);
|
|
312
470
|
}
|
|
313
|
-
extras.push(...rows);
|
|
314
|
-
sources.push(name);
|
|
315
471
|
}
|
|
316
472
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
/* dir may not exist yet */
|
|
320
|
-
}
|
|
321
|
-
// Also fold in archive (sessions previously pruned).
|
|
322
|
-
if (await safeExists(archiveFilePath())) {
|
|
323
|
-
const archived = await readJsonlRecordsFrom(archiveFilePath());
|
|
324
|
-
if (archived.length > 0) {
|
|
325
|
-
extras.push(...archived);
|
|
326
|
-
sources.push("history-archive.jsonl");
|
|
473
|
+
catch {
|
|
474
|
+
/* dir may not exist yet */
|
|
327
475
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
476
|
+
// Also fold in archive (sessions previously pruned).
|
|
477
|
+
if (await safeExists(archiveFilePath())) {
|
|
478
|
+
const archived = await readJsonlRecordsFrom(archiveFilePath());
|
|
479
|
+
if (archived.length > 0) {
|
|
480
|
+
extras.push(...archived);
|
|
481
|
+
sources.push("history-archive.jsonl");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Rolling backups (last-resort recovery of wiped active files).
|
|
485
|
+
try {
|
|
486
|
+
if (await safeExists(backupDirPath())) {
|
|
487
|
+
const backups = (await readdir(backupDirPath()))
|
|
488
|
+
.filter((n) => n.startsWith("history-") && n.endsWith(".jsonl"))
|
|
489
|
+
.sort()
|
|
490
|
+
.reverse()
|
|
491
|
+
.slice(0, 3);
|
|
492
|
+
for (const name of backups) {
|
|
493
|
+
const rows = await readJsonlRecordsFrom(join(backupDirPath(), name));
|
|
494
|
+
if (rows.length > 0) {
|
|
495
|
+
extras.push(...rows);
|
|
496
|
+
sources.push(`history-backups/${name}`);
|
|
497
|
+
}
|
|
342
498
|
}
|
|
343
499
|
}
|
|
344
500
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
/* ignore */
|
|
348
|
-
}
|
|
349
|
-
if (extras.length === 0)
|
|
350
|
-
return { recovered: 0, sources: [] };
|
|
351
|
-
const active = await readJsonlRecordsFrom(jsonlFilePath());
|
|
352
|
-
const beforeIds = new Set(active.map((r) => r.id));
|
|
353
|
-
const merged = dedupeHistoryById([...active, ...extras]);
|
|
354
|
-
const newCount = merged.filter((r) => !beforeIds.has(r.id)).length;
|
|
355
|
-
if (newCount === 0 && merged.length <= active.length) {
|
|
356
|
-
return { recovered: 0, sources };
|
|
357
|
-
}
|
|
358
|
-
// Write WITHOUT applying retention so recovery cannot re-prune.
|
|
359
|
-
await mkdir(historyDirPath(), { recursive: true });
|
|
360
|
-
await fixOwner(historyDirPath());
|
|
361
|
-
if (await safeExists(jsonlFilePath()))
|
|
362
|
-
await backupActiveHistory();
|
|
363
|
-
const sorted = sortHistoryByUpdatedDesc(merged);
|
|
364
|
-
// Stable chronological file order (oldest first) for append-friendly diffs.
|
|
365
|
-
sorted.reverse();
|
|
366
|
-
const body = sorted.length
|
|
367
|
-
? `${sorted.map((item) => JSON.stringify(item)).join("\n")}\n`
|
|
368
|
-
: "";
|
|
369
|
-
const tmpFile = `${jsonlFilePath()}.recover.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
370
|
-
await writeFile(tmpFile, body, { mode: 0o600 });
|
|
371
|
-
try {
|
|
372
|
-
await rename(tmpFile, jsonlFilePath());
|
|
373
|
-
}
|
|
374
|
-
catch (err) {
|
|
375
|
-
await rm(tmpFile, { force: true }).catch(() => undefined);
|
|
376
|
-
throw err;
|
|
377
|
-
}
|
|
378
|
-
await fixOwner(jsonlFilePath());
|
|
379
|
-
// Successful recovery: drop non-empty orphan temps we already merged.
|
|
380
|
-
for (const name of sources) {
|
|
381
|
-
if (name.startsWith("history.jsonl.") && name.endsWith(".tmp")) {
|
|
382
|
-
await rm(join(historyDirPath(), name), { force: true }).catch(() => undefined);
|
|
501
|
+
catch {
|
|
502
|
+
/* ignore */
|
|
383
503
|
}
|
|
504
|
+
if (extras.length === 0)
|
|
505
|
+
return { recovered: 0, sources: [] };
|
|
506
|
+
const active = await readJsonlRecordsFrom(jsonlFilePath());
|
|
507
|
+
const activeById = new Map(active.map((record) => [record.id, record]));
|
|
508
|
+
const merged = dedupeHistoryById([...active, ...extras]);
|
|
509
|
+
const recoveredCount = merged.filter((record) => {
|
|
510
|
+
const previous = activeById.get(record.id);
|
|
511
|
+
return !previous || compareHistoryFreshness(record, previous) > 0;
|
|
512
|
+
}).length;
|
|
513
|
+
if (recoveredCount === 0) {
|
|
514
|
+
return { recovered: 0, sources };
|
|
515
|
+
}
|
|
516
|
+
// Write WITHOUT applying retention so recovery cannot re-prune.
|
|
517
|
+
await mkdir(historyDirPath(), { recursive: true });
|
|
518
|
+
await fixOwner(historyDirPath());
|
|
519
|
+
if (await safeExists(jsonlFilePath()))
|
|
520
|
+
await backupActiveHistory();
|
|
521
|
+
const sorted = sortHistoryByUpdatedDesc(merged);
|
|
522
|
+
// Stable chronological file order (oldest first) for append-friendly diffs.
|
|
523
|
+
sorted.reverse();
|
|
524
|
+
const body = sorted.length
|
|
525
|
+
? `${sorted.map((item) => JSON.stringify(item)).join("\n")}\n`
|
|
526
|
+
: "";
|
|
527
|
+
const tmpFile = `${jsonlFilePath()}.recover.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
528
|
+
await writeFile(tmpFile, body, { mode: 0o600 });
|
|
529
|
+
try {
|
|
530
|
+
await rename(tmpFile, jsonlFilePath());
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
await rm(tmpFile, { force: true }).catch(() => undefined);
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
await fixOwner(jsonlFilePath());
|
|
537
|
+
// Successful recovery: drop non-empty orphan temps we already merged.
|
|
538
|
+
for (const name of sources) {
|
|
539
|
+
if (name.startsWith("history.jsonl.") && name.endsWith(".tmp")) {
|
|
540
|
+
await rm(join(historyDirPath(), name), { force: true }).catch(() => undefined);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return { recovered: recoveredCount, sources };
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
await releaseLock();
|
|
384
547
|
}
|
|
385
|
-
return { recovered: newCount, sources };
|
|
386
548
|
}
|
|
387
549
|
function startHistoryRecovery() {
|
|
388
550
|
if (!recoveryPromise) {
|
|
@@ -471,7 +633,40 @@ async function writeJsonlAtomic(records) {
|
|
|
471
633
|
}
|
|
472
634
|
await fixOwner(jsonlFilePath());
|
|
473
635
|
}
|
|
474
|
-
|
|
636
|
+
function serializeSessionPayload(record) {
|
|
637
|
+
return JSON.stringify({
|
|
638
|
+
messages: record.messages,
|
|
639
|
+
transcript: record.transcript,
|
|
640
|
+
...(record.contextUsage ? { contextUsage: record.contextUsage } : {}),
|
|
641
|
+
...(record.workspaceFolder
|
|
642
|
+
? {
|
|
643
|
+
workspaceFolder: record.workspaceFolder,
|
|
644
|
+
workspaceCode: record.workspaceCode,
|
|
645
|
+
}
|
|
646
|
+
: {}),
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
/** SQLite mirror write with atomic generation/revision rejection. */
|
|
650
|
+
function upsertSqlite(db, record) {
|
|
651
|
+
db.prepare(`INSERT INTO sessions
|
|
652
|
+
(id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json)
|
|
653
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
654
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
655
|
+
name = excluded.name,
|
|
656
|
+
created_at = excluded.created_at,
|
|
657
|
+
updated_at = excluded.updated_at,
|
|
658
|
+
writer_generation = excluded.writer_generation,
|
|
659
|
+
revision = excluded.revision,
|
|
660
|
+
cwd = excluded.cwd,
|
|
661
|
+
messages_json = excluded.messages_json
|
|
662
|
+
WHERE (sessions.writer_generation IS NULL AND excluded.writer_generation IS NOT NULL)
|
|
663
|
+
OR excluded.writer_generation > sessions.writer_generation
|
|
664
|
+
OR (excluded.writer_generation = sessions.writer_generation
|
|
665
|
+
AND excluded.revision >= sessions.revision)
|
|
666
|
+
OR (sessions.writer_generation IS NULL AND excluded.writer_generation IS NULL
|
|
667
|
+
AND excluded.revision >= sessions.revision)`).run(record.id, record.name ?? null, record.createdAt, record.updatedAt, historyWriterGeneration(record) ?? null, historyRevision(record), record.cwd, serializeSessionPayload(record));
|
|
668
|
+
}
|
|
669
|
+
export async function saveSession(messages, name, transcript, contextUsage, revision, writerGeneration) {
|
|
475
670
|
// Auto-derive a readable name from the first real user message if none provided
|
|
476
671
|
if (!name) {
|
|
477
672
|
const firstUser = messages.find((m) => m.role === "user" && !isInternalChatMessage(m));
|
|
@@ -484,6 +679,10 @@ export async function saveSession(messages, name, transcript, contextUsage) {
|
|
|
484
679
|
const workspace = workspaceFieldsFromActive();
|
|
485
680
|
const record = {
|
|
486
681
|
id: newId(),
|
|
682
|
+
...(writerGeneration ? { writerGeneration } : {}),
|
|
683
|
+
revision: typeof revision === "number" && Number.isSafeInteger(revision) && revision > 0
|
|
684
|
+
? revision
|
|
685
|
+
: 1,
|
|
487
686
|
name,
|
|
488
687
|
createdAt: now,
|
|
489
688
|
updatedAt: now,
|
|
@@ -495,41 +694,38 @@ export async function saveSession(messages, name, transcript, contextUsage) {
|
|
|
495
694
|
};
|
|
496
695
|
// Private mode: never persist chat content. Caller still gets a record
|
|
497
696
|
// back (so /save echoes a usable id) but nothing hits disk.
|
|
498
|
-
if (getConfig().privateMode)
|
|
697
|
+
if (getConfig().privateMode)
|
|
499
698
|
return record;
|
|
500
|
-
}
|
|
501
699
|
invalidateSessionListCache();
|
|
502
700
|
const db = await loadDatabase();
|
|
701
|
+
const canonical = await upsertJsonl(record);
|
|
503
702
|
if (db) {
|
|
504
|
-
db
|
|
505
|
-
messages: record.messages,
|
|
506
|
-
transcript: record.transcript,
|
|
507
|
-
...(contextUsage ? { contextUsage } : {}),
|
|
508
|
-
...(record.workspaceFolder
|
|
509
|
-
? {
|
|
510
|
-
workspaceFolder: record.workspaceFolder,
|
|
511
|
-
workspaceCode: record.workspaceCode,
|
|
512
|
-
}
|
|
513
|
-
: {}),
|
|
514
|
-
}));
|
|
703
|
+
upsertSqlite(db, canonical);
|
|
515
704
|
await enforceSqliteRetention(db);
|
|
516
705
|
invalidateSessionListCache();
|
|
517
706
|
}
|
|
518
|
-
|
|
519
|
-
await appendJsonl(record);
|
|
520
|
-
}
|
|
521
|
-
return record;
|
|
707
|
+
return canonical;
|
|
522
708
|
}
|
|
523
|
-
export async function upsertSession(id, messages, name, transcript, contextUsage) {
|
|
709
|
+
export async function upsertSession(id, messages, name, transcript, contextUsage, revision, writerGeneration) {
|
|
524
710
|
const existing = await getSession(id);
|
|
525
|
-
const
|
|
711
|
+
const requestedRevision = typeof revision === "number" && Number.isSafeInteger(revision) && revision > 0
|
|
712
|
+
? revision
|
|
713
|
+
: undefined;
|
|
714
|
+
const firstUser = messages.find((message) => message.role === "user" && !isInternalChatMessage(message));
|
|
526
715
|
const derivedName = firstUser
|
|
527
|
-
? firstUser.content.slice(0, 60).replace(/\n/g, " ").trim() +
|
|
716
|
+
? firstUser.content.slice(0, 60).replace(/\n/g, " ").trim() +
|
|
717
|
+
(firstUser.content.length > 60 ? "…" : "")
|
|
528
718
|
: undefined;
|
|
529
719
|
const now = new Date().toISOString();
|
|
530
720
|
const workspace = workspaceFieldsFromActive(existing);
|
|
721
|
+
const effectiveWriterGeneration = writerGeneration ?? existing?.writerGeneration;
|
|
531
722
|
const record = {
|
|
532
723
|
id,
|
|
724
|
+
...(effectiveWriterGeneration
|
|
725
|
+
? { writerGeneration: effectiveWriterGeneration }
|
|
726
|
+
: {}),
|
|
727
|
+
revision: requestedRevision ??
|
|
728
|
+
(writerGeneration ? 1 : historyRevision(existing) + 1),
|
|
533
729
|
name: name ?? existing?.name ?? derivedName,
|
|
534
730
|
createdAt: existing?.createdAt ?? now,
|
|
535
731
|
updatedAt: now,
|
|
@@ -543,29 +739,22 @@ export async function upsertSession(id, messages, name, transcript, contextUsage
|
|
|
543
739
|
: {}),
|
|
544
740
|
...workspace,
|
|
545
741
|
};
|
|
742
|
+
if (existing &&
|
|
743
|
+
requestedRevision !== undefined &&
|
|
744
|
+
compareHistoryFreshness(record, existing) <= 0) {
|
|
745
|
+
return existing;
|
|
746
|
+
}
|
|
546
747
|
if (getConfig().privateMode)
|
|
547
748
|
return record;
|
|
548
749
|
invalidateSessionListCache();
|
|
549
750
|
const db = await loadDatabase();
|
|
751
|
+
const canonical = await upsertJsonl(record);
|
|
550
752
|
if (db) {
|
|
551
|
-
db
|
|
552
|
-
messages: record.messages,
|
|
553
|
-
transcript: record.transcript,
|
|
554
|
-
...(record.contextUsage ? { contextUsage: record.contextUsage } : {}),
|
|
555
|
-
...(record.workspaceFolder
|
|
556
|
-
? {
|
|
557
|
-
workspaceFolder: record.workspaceFolder,
|
|
558
|
-
workspaceCode: record.workspaceCode,
|
|
559
|
-
}
|
|
560
|
-
: {}),
|
|
561
|
-
}));
|
|
753
|
+
upsertSqlite(db, canonical);
|
|
562
754
|
await enforceSqliteRetention(db);
|
|
563
755
|
invalidateSessionListCache();
|
|
564
756
|
}
|
|
565
|
-
|
|
566
|
-
await upsertJsonl(record);
|
|
567
|
-
}
|
|
568
|
-
return record;
|
|
757
|
+
return canonical;
|
|
569
758
|
}
|
|
570
759
|
async function enforceSqliteRetention(db) {
|
|
571
760
|
const limit = getConfig().historyRetentionLimit;
|
|
@@ -575,7 +764,7 @@ async function enforceSqliteRetention(db) {
|
|
|
575
764
|
// a JSONL archive copy.
|
|
576
765
|
try {
|
|
577
766
|
const doomed = db
|
|
578
|
-
.prepare(`SELECT id, name, created_at, updated_at, cwd, messages_json FROM sessions
|
|
767
|
+
.prepare(`SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json FROM sessions
|
|
579
768
|
WHERE id NOT IN (SELECT id FROM sessions ORDER BY updated_at DESC LIMIT ?)`)
|
|
580
769
|
.all(Math.floor(limit));
|
|
581
770
|
const records = doomed.map(rowToSession);
|
|
@@ -588,14 +777,24 @@ async function enforceSqliteRetention(db) {
|
|
|
588
777
|
db.exec(`DELETE FROM sessions WHERE id NOT IN (SELECT id FROM sessions ORDER BY updated_at DESC LIMIT ${Math.floor(limit)});`);
|
|
589
778
|
}
|
|
590
779
|
async function upsertJsonl(record) {
|
|
780
|
+
let canonical = record;
|
|
591
781
|
await mutateJsonl((records) => {
|
|
592
782
|
const idx = records.findIndex((item) => item.id === record.id);
|
|
593
|
-
if (idx >= 0)
|
|
594
|
-
records[idx]
|
|
595
|
-
|
|
783
|
+
if (idx >= 0) {
|
|
784
|
+
const current = records[idx];
|
|
785
|
+
if (current && compareHistoryFreshness(record, current) > 0) {
|
|
786
|
+
records[idx] = record;
|
|
787
|
+
}
|
|
788
|
+
else if (current) {
|
|
789
|
+
canonical = current;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
596
793
|
records.push(record);
|
|
794
|
+
}
|
|
597
795
|
return records;
|
|
598
796
|
});
|
|
797
|
+
return canonical;
|
|
599
798
|
}
|
|
600
799
|
export async function saveToolCall(sessionId, call, result) {
|
|
601
800
|
const record = {
|
|
@@ -621,6 +820,10 @@ function rowToSession(row) {
|
|
|
621
820
|
const messages = Array.isArray(parsed) ? parsed : parsed.messages ?? [];
|
|
622
821
|
return {
|
|
623
822
|
id: data.id,
|
|
823
|
+
writerGeneration: data.writer_generation ?? undefined,
|
|
824
|
+
revision: typeof data.revision === "number" && data.revision > 0
|
|
825
|
+
? data.revision
|
|
826
|
+
: undefined,
|
|
624
827
|
name: data.name ?? undefined,
|
|
625
828
|
createdAt: data.created_at,
|
|
626
829
|
updatedAt: data.updated_at,
|
|
@@ -646,10 +849,6 @@ async function listJsonlSessions(limit) {
|
|
|
646
849
|
return [];
|
|
647
850
|
}
|
|
648
851
|
}
|
|
649
|
-
/**
|
|
650
|
-
* Merge SQLite + JSONL sources so enabling better-sqlite3 never hides an
|
|
651
|
-
* existing JSONL history (or vice versa). Prefer the newer updatedAt per id.
|
|
652
|
-
*/
|
|
653
852
|
function mergeSessionLists(...lists) {
|
|
654
853
|
return sortHistoryByUpdatedDesc(dedupeHistoryById(lists.flat()));
|
|
655
854
|
}
|
|
@@ -670,9 +869,6 @@ export async function listSessions(limit = 20, options = {}) {
|
|
|
670
869
|
// Do not let a slow pre-write/pre-recovery read overwrite a newer cache.
|
|
671
870
|
// Every mutation increments this generation before and after persistence.
|
|
672
871
|
const loadGeneration = sessionListGeneration;
|
|
673
|
-
// JSONL parsing and optional SQLite startup are independent. Running them
|
|
674
|
-
// concurrently removes dynamic SQLite import latency from /history's
|
|
675
|
-
// critical path instead of paying both costs serially.
|
|
676
872
|
const [fromJsonl, db] = await Promise.all([
|
|
677
873
|
listJsonlSessions(0),
|
|
678
874
|
loadDatabase(),
|
|
@@ -681,7 +877,7 @@ export async function listSessions(limit = 20, options = {}) {
|
|
|
681
877
|
if (db) {
|
|
682
878
|
try {
|
|
683
879
|
const rows = db
|
|
684
|
-
.prepare("SELECT id, name, created_at, updated_at, cwd, messages_json FROM sessions ORDER BY updated_at DESC")
|
|
880
|
+
.prepare("SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json FROM sessions ORDER BY updated_at DESC")
|
|
685
881
|
.all();
|
|
686
882
|
fromDb = rows.map(rowToSession);
|
|
687
883
|
}
|
|
@@ -710,7 +906,7 @@ export async function getSession(sessionId) {
|
|
|
710
906
|
let fromDb;
|
|
711
907
|
if (db) {
|
|
712
908
|
const row = db
|
|
713
|
-
.prepare("SELECT id, name, created_at, updated_at, cwd, messages_json FROM sessions WHERE id = ?")
|
|
909
|
+
.prepare("SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json FROM sessions WHERE id = ?")
|
|
714
910
|
.get(sessionId);
|
|
715
911
|
if (row)
|
|
716
912
|
fromDb = rowToSession(row);
|
|
@@ -720,28 +916,30 @@ export async function getSession(sessionId) {
|
|
|
720
916
|
const fromArchive = fromJsonl
|
|
721
917
|
? undefined
|
|
722
918
|
: (await readJsonlRecordsFrom(archiveFilePath())).find((s) => s.id === sessionId);
|
|
723
|
-
const candidates = [
|
|
724
|
-
|
|
919
|
+
const candidates = [fromJsonl, fromDb, fromArchive].filter((record) => Boolean(record));
|
|
920
|
+
const freshest = freshestHistoryRecord(candidates);
|
|
921
|
+
if (!freshest)
|
|
725
922
|
return undefined;
|
|
726
|
-
|
|
923
|
+
// Heal split-brain stores on selection. This is especially important across
|
|
924
|
+
// upgrades where optional SQLite availability changes between launches.
|
|
925
|
+
if (!fromJsonl || compareHistoryFreshness(freshest, fromJsonl) > 0) {
|
|
926
|
+
await upsertJsonl(freshest);
|
|
927
|
+
}
|
|
928
|
+
if (db && (!fromDb || compareHistoryFreshness(freshest, fromDb) > 0)) {
|
|
929
|
+
upsertSqlite(db, freshest);
|
|
930
|
+
await enforceSqliteRetention(db);
|
|
931
|
+
invalidateSessionListCache();
|
|
932
|
+
}
|
|
933
|
+
return freshest;
|
|
727
934
|
}
|
|
728
935
|
export function getHistoryPath() {
|
|
729
936
|
// Prefer JSONL as the durable path users can inspect/backup; SQLite is
|
|
730
937
|
// optional acceleration when better-sqlite3 is installed.
|
|
731
938
|
return jsonlFilePath();
|
|
732
939
|
}
|
|
733
|
-
/**
|
|
734
|
-
* Clear active history after archiving a full snapshot. Never unrecoverably
|
|
735
|
-
* destroys chats — a timestamped copy is written under history-backups/ and
|
|
736
|
-
* the previous active file is moved into history-archive.jsonl.
|
|
737
|
-
*/
|
|
738
940
|
export async function clearAllHistory() {
|
|
739
941
|
let detail = "";
|
|
740
942
|
await ensureHistoryRecovered();
|
|
741
|
-
// Snapshot + move aside. Do NOT write into history-archive.jsonl here —
|
|
742
|
-
// that file is auto-reimported by recoverOrphanedHistory, which would
|
|
743
|
-
// immediately undo a clear. Intentional clears go to history-cleared-*.jsonl
|
|
744
|
-
// (and rolling backups) only.
|
|
745
943
|
try {
|
|
746
944
|
const snapshot = await readJsonlRecordsFrom(jsonlFilePath());
|
|
747
945
|
if (snapshot.length > 0) {
|