opencode-sessions-explorer 0.1.2 → 0.1.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 +16 -0
- package/README.md +7 -3
- package/dist/bin/bulk-export.js +827 -159
- package/dist/bin/check-deps.js +836 -167
- package/dist/bin/dedupe-export.js +825 -157
- package/dist/lib/export-reconcile-worker.js +1519 -0
- package/dist/plugin.js +1133 -208
- package/docs/getting-started.md +5 -4
- package/docs/guides/export-and-maintenance.md +6 -3
- package/docs/guides/search-and-grep.md +6 -3
- package/docs/reference/architecture.md +10 -5
- package/package.json +2 -2
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/bin/dedupe-export.ts
|
|
5
|
-
import { readdirSync as
|
|
6
|
-
import { join as
|
|
5
|
+
import { readdirSync as readdirSync3, statSync as statSync3, unlinkSync as unlinkSync4 } from "fs";
|
|
6
|
+
import { join as join6 } from "path";
|
|
7
7
|
|
|
8
8
|
// src/lib/db.ts
|
|
9
9
|
import { Database } from "bun:sqlite";
|
|
@@ -177,8 +177,8 @@ function decodeModel(modelStr) {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// src/lib/export.ts
|
|
180
|
-
import { mkdirSync, existsSync as
|
|
181
|
-
import { join as
|
|
180
|
+
import { mkdirSync, existsSync as existsSync5, renameSync as renameSync2, writeFileSync as writeFileSync3, unlinkSync as unlinkSync3, readdirSync as readdirSync2 } from "fs";
|
|
181
|
+
import { join as join5 } from "path";
|
|
182
182
|
import { homedir as homedir2 } from "os";
|
|
183
183
|
|
|
184
184
|
// src/lib/channel.ts
|
|
@@ -204,55 +204,511 @@ function compactPath(path, baseDir) {
|
|
|
204
204
|
return { path, rel_path: rel || "." };
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// src/lib/export-constants.ts
|
|
208
|
+
var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
|
|
209
|
+
|
|
210
|
+
// src/lib/export-lock.ts
|
|
211
|
+
import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
212
|
+
import { hostname } from "os";
|
|
213
|
+
import { join as join2 } from "path";
|
|
214
|
+
import { randomUUID } from "crypto";
|
|
215
|
+
var LOCK_FILE = ".export.lock";
|
|
216
|
+
var DEFAULT_STALE_MS = 2 * 60 * 1000;
|
|
217
|
+
var HEARTBEAT_MS = 15000;
|
|
218
|
+
function acquireExportLock(root, staleMs = DEFAULT_STALE_MS) {
|
|
219
|
+
const path = join2(root, LOCK_FILE);
|
|
220
|
+
const first = tryCreateLock(path);
|
|
221
|
+
if (first)
|
|
222
|
+
return first;
|
|
223
|
+
const stale = staleCandidate(path, staleMs);
|
|
224
|
+
if (!stale)
|
|
225
|
+
return null;
|
|
226
|
+
if (!removeStaleLock(path, stale, staleMs))
|
|
227
|
+
return tryCreateLock(path);
|
|
228
|
+
return tryCreateLock(path);
|
|
229
|
+
}
|
|
230
|
+
function tryCreateLock(path) {
|
|
231
|
+
let fd = null;
|
|
232
|
+
try {
|
|
233
|
+
const token = randomUUID();
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const record = { token, pid: process.pid, hostname: hostname(), created_at: now, updated_at: now };
|
|
236
|
+
fd = openSync(path, "wx");
|
|
237
|
+
writeFileSync(fd, JSON.stringify(record));
|
|
238
|
+
closeSync(fd);
|
|
239
|
+
fd = null;
|
|
240
|
+
let lastHeartbeat = now;
|
|
241
|
+
return {
|
|
242
|
+
token,
|
|
243
|
+
release: () => releaseLock(path, token),
|
|
244
|
+
heartbeat: () => {
|
|
245
|
+
const current = Date.now();
|
|
246
|
+
if (current - lastHeartbeat < HEARTBEAT_MS)
|
|
247
|
+
return;
|
|
248
|
+
lastHeartbeat = current;
|
|
249
|
+
heartbeatLock(path, token, current);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
} catch {
|
|
253
|
+
if (fd != null)
|
|
254
|
+
try {
|
|
255
|
+
closeSync(fd);
|
|
256
|
+
} catch {}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function staleCandidate(path, staleMs) {
|
|
261
|
+
try {
|
|
262
|
+
const stat = statSync(path);
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
if (now - stat.mtimeMs <= staleMs)
|
|
265
|
+
return null;
|
|
266
|
+
const parsed = readLockRecord(path);
|
|
267
|
+
if (!parsed)
|
|
268
|
+
return { token: null, updatedAt: null, mtimeMs: stat.mtimeMs };
|
|
269
|
+
if (now - parsed.updated_at <= staleMs)
|
|
270
|
+
return null;
|
|
271
|
+
if (isLiveLocalOwner(parsed))
|
|
272
|
+
return null;
|
|
273
|
+
return { token: parsed.token, updatedAt: parsed.updated_at, mtimeMs: stat.mtimeMs };
|
|
274
|
+
} catch {
|
|
275
|
+
return { token: null, updatedAt: null, mtimeMs: 0 };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function removeStaleLock(path, stale, staleMs) {
|
|
279
|
+
try {
|
|
280
|
+
const stat = statSync(path);
|
|
281
|
+
if (stat.mtimeMs !== stale.mtimeMs)
|
|
282
|
+
return false;
|
|
283
|
+
if (stale.token) {
|
|
284
|
+
const current = readLockRecord(path);
|
|
285
|
+
if (current?.token !== stale.token)
|
|
286
|
+
return false;
|
|
287
|
+
if (current.updated_at !== stale.updatedAt)
|
|
288
|
+
return false;
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
if (now - current.updated_at <= staleMs)
|
|
291
|
+
return false;
|
|
292
|
+
if (now - stat.mtimeMs <= staleMs)
|
|
293
|
+
return false;
|
|
294
|
+
if (isLiveLocalOwner(current))
|
|
295
|
+
return false;
|
|
296
|
+
} else {
|
|
297
|
+
if (Date.now() - stat.mtimeMs <= staleMs)
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
unlinkSync(path);
|
|
301
|
+
return true;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function releaseLock(path, token) {
|
|
307
|
+
try {
|
|
308
|
+
const current = readLockRecord(path);
|
|
309
|
+
if (current?.token === token)
|
|
310
|
+
unlinkSync(path);
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
function heartbeatLock(path, token, now) {
|
|
314
|
+
try {
|
|
315
|
+
const current = readLockRecord(path);
|
|
316
|
+
if (current?.token !== token)
|
|
317
|
+
return;
|
|
318
|
+
writeFileSync(path, JSON.stringify({ ...current, updated_at: now }));
|
|
319
|
+
} catch {}
|
|
320
|
+
}
|
|
321
|
+
function readLockRecord(path) {
|
|
322
|
+
try {
|
|
323
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
324
|
+
if (typeof parsed.token !== "string")
|
|
325
|
+
return null;
|
|
326
|
+
if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid))
|
|
327
|
+
return null;
|
|
328
|
+
if (typeof parsed.hostname !== "string")
|
|
329
|
+
return null;
|
|
330
|
+
if (typeof parsed.created_at !== "number" || !Number.isFinite(parsed.created_at))
|
|
331
|
+
return null;
|
|
332
|
+
const updated = typeof parsed.updated_at === "number" && Number.isFinite(parsed.updated_at) ? parsed.updated_at : parsed.created_at;
|
|
333
|
+
return { token: parsed.token, pid: parsed.pid, hostname: parsed.hostname, created_at: parsed.created_at, updated_at: updated };
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function isLiveLocalOwner(record) {
|
|
339
|
+
if (record.hostname !== hostname())
|
|
340
|
+
return false;
|
|
341
|
+
if (!Number.isSafeInteger(record.pid) || record.pid <= 0)
|
|
342
|
+
return false;
|
|
343
|
+
try {
|
|
344
|
+
process.kill(record.pid, 0);
|
|
345
|
+
return true;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
return error?.code === "EPERM";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/lib/export-tombstones.ts
|
|
352
|
+
import { existsSync as existsSync2, readdirSync, rmSync, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
353
|
+
import { join as join3 } from "path";
|
|
354
|
+
var PART_FILE_RE = /^(?:\d{5}-)?(prt_[A-Za-z0-9_-]+)\.txt$/;
|
|
355
|
+
function reconcileTombstones(root, heartbeat = () => {}) {
|
|
356
|
+
const progress = {
|
|
357
|
+
scanned_sessions: 0,
|
|
358
|
+
removed_sessions: 0,
|
|
359
|
+
removed_parts: 0,
|
|
360
|
+
removed_channel_sessions: 0
|
|
361
|
+
};
|
|
362
|
+
const bySession = join3(root, "by-session");
|
|
363
|
+
const sessions = loadSessionIds();
|
|
364
|
+
if (existsSync2(bySession)) {
|
|
365
|
+
for (const entry of safeReadDir(bySession)) {
|
|
366
|
+
const dir = join3(bySession, entry);
|
|
367
|
+
if (!isDirectory(dir))
|
|
368
|
+
continue;
|
|
369
|
+
if (!sessions.has(entry)) {
|
|
370
|
+
rmSync(dir, { recursive: true, force: true });
|
|
371
|
+
removeChannelSessionDirs(root, entry);
|
|
372
|
+
progress.removed_sessions++;
|
|
373
|
+
heartbeat();
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
progress.scanned_sessions++;
|
|
377
|
+
progress.removed_parts += removeOrphanPartFiles(root, entry, dir, heartbeat);
|
|
378
|
+
heartbeat();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
progress.removed_channel_sessions += removeOrphanChannelSessions(root, sessions, heartbeat);
|
|
382
|
+
return progress;
|
|
383
|
+
}
|
|
384
|
+
function removeOrphanPartFiles(root, sessionId, dir, heartbeat) {
|
|
385
|
+
const livePartIds = loadSearchablePartIds(sessionId);
|
|
386
|
+
let removed = 0;
|
|
387
|
+
for (const file of safeReadDir(dir)) {
|
|
388
|
+
heartbeat();
|
|
389
|
+
const partId = partIdFromFile(file);
|
|
390
|
+
if (!partId || livePartIds.has(partId))
|
|
391
|
+
continue;
|
|
392
|
+
try {
|
|
393
|
+
unlinkSync2(join3(dir, file));
|
|
394
|
+
removeChannelPartFiles(root, sessionId, partId);
|
|
395
|
+
removed++;
|
|
396
|
+
} catch {}
|
|
397
|
+
}
|
|
398
|
+
return removed;
|
|
399
|
+
}
|
|
400
|
+
function removeOrphanChannelSessions(root, sessions, heartbeat) {
|
|
401
|
+
let removed = 0;
|
|
402
|
+
for (const channel of CHANNELS) {
|
|
403
|
+
const base = join3(root, "by-channel", channel, "by-session");
|
|
404
|
+
if (!existsSync2(base))
|
|
405
|
+
continue;
|
|
406
|
+
for (const sessionId of safeReadDir(base)) {
|
|
407
|
+
heartbeat();
|
|
408
|
+
const dir = join3(base, sessionId);
|
|
409
|
+
if (!isDirectory(dir) || sessions.has(sessionId))
|
|
410
|
+
continue;
|
|
411
|
+
rmSync(dir, { recursive: true, force: true });
|
|
412
|
+
removed++;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return removed;
|
|
416
|
+
}
|
|
417
|
+
function removeChannelSessionDirs(root, sessionId) {
|
|
418
|
+
for (const channel of CHANNELS) {
|
|
419
|
+
rmSync(channelDir(root, channel, sessionId), { recursive: true, force: true });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function removeChannelPartFiles(root, sessionId, partId) {
|
|
423
|
+
for (const channel of CHANNELS) {
|
|
424
|
+
const dir = channelDir(root, channel, sessionId);
|
|
425
|
+
if (!existsSync2(dir))
|
|
426
|
+
continue;
|
|
427
|
+
for (const file of safeReadDir(dir)) {
|
|
428
|
+
if (file === `${partId}.txt` || file.endsWith(`-${partId}.txt`)) {
|
|
429
|
+
try {
|
|
430
|
+
unlinkSync2(join3(dir, file));
|
|
431
|
+
} catch {}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function channelDir(root, channel, sessionId) {
|
|
437
|
+
return join3(root, "by-channel", channel, "by-session", sessionId);
|
|
438
|
+
}
|
|
439
|
+
function loadSessionIds() {
|
|
440
|
+
const rows = stmt(`SELECT id FROM session`).all();
|
|
441
|
+
return new Set(rows.map((row) => row.id));
|
|
442
|
+
}
|
|
443
|
+
function loadSearchablePartIds(sessionId) {
|
|
444
|
+
const placeholders = SEARCHABLE_TYPES.map(() => "?").join(",");
|
|
445
|
+
const rows = stmt(`
|
|
446
|
+
SELECT id
|
|
447
|
+
FROM part
|
|
448
|
+
WHERE session_id = ?
|
|
449
|
+
AND json_extract(data,'$.type') IN (${placeholders})
|
|
450
|
+
ORDER BY id ASC`).all(sessionId, ...SEARCHABLE_TYPES);
|
|
451
|
+
return new Set(rows.map((row) => row.id));
|
|
452
|
+
}
|
|
453
|
+
function safeReadDir(dir) {
|
|
454
|
+
try {
|
|
455
|
+
return readdirSync(dir);
|
|
456
|
+
} catch {
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function isDirectory(path) {
|
|
461
|
+
try {
|
|
462
|
+
return statSync2(path).isDirectory();
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function partIdFromFile(file) {
|
|
468
|
+
const match = PART_FILE_RE.exec(file);
|
|
469
|
+
return match ? match[1] : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/lib/export-background.ts
|
|
473
|
+
import { existsSync as existsSync3 } from "fs";
|
|
474
|
+
import { fileURLToPath } from "url";
|
|
475
|
+
var DEFAULT_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
476
|
+
var inFlight = false;
|
|
477
|
+
var lastStartedAt = 0;
|
|
478
|
+
function scheduleBackgroundReconcile(opts) {
|
|
479
|
+
const now = Date.now();
|
|
480
|
+
const minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
481
|
+
if (inFlight)
|
|
482
|
+
return { scheduled: false, reason: "already_running" };
|
|
483
|
+
if (now - lastStartedAt < minIntervalMs)
|
|
484
|
+
return { scheduled: false, reason: "throttled" };
|
|
485
|
+
const url = resolveWorkerUrl();
|
|
486
|
+
try {
|
|
487
|
+
const worker = new Worker(url, { type: "module" });
|
|
488
|
+
inFlight = true;
|
|
489
|
+
lastStartedAt = now;
|
|
490
|
+
const cleanup = () => {
|
|
491
|
+
inFlight = false;
|
|
492
|
+
worker.terminate();
|
|
493
|
+
};
|
|
494
|
+
worker.addEventListener("message", cleanup, { once: true });
|
|
495
|
+
worker.addEventListener("error", cleanup, { once: true });
|
|
496
|
+
const maybeUnref = worker;
|
|
497
|
+
maybeUnref.unref?.();
|
|
498
|
+
const request = { root: opts.root, batchSize: opts.batchSize ?? 2000 };
|
|
499
|
+
worker.postMessage(request);
|
|
500
|
+
return { scheduled: true };
|
|
501
|
+
} catch {
|
|
502
|
+
inFlight = false;
|
|
503
|
+
return { scheduled: false, reason: "worker_unavailable" };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function resolveWorkerUrl() {
|
|
507
|
+
const js = new URL("./export-reconcile-worker.js", import.meta.url);
|
|
508
|
+
if (existsSync3(fileURLToPath(js)))
|
|
509
|
+
return js;
|
|
510
|
+
const ts = new URL("./export-reconcile-worker.ts", import.meta.url);
|
|
511
|
+
if (existsSync3(fileURLToPath(ts)))
|
|
512
|
+
return ts;
|
|
513
|
+
const bundled = new URL("./lib/export-reconcile-worker.js", import.meta.url);
|
|
514
|
+
if (existsSync3(fileURLToPath(bundled)))
|
|
515
|
+
return bundled;
|
|
516
|
+
return js;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/lib/export-state.ts
|
|
520
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
|
|
521
|
+
import { join as join4 } from "path";
|
|
522
|
+
var CURSOR_SCHEMA = "v3";
|
|
523
|
+
var LAST_SYNC_FILE = ".last_sync";
|
|
524
|
+
function freshSyncState(migratedFrom, legacyCursor = null) {
|
|
525
|
+
return {
|
|
526
|
+
schema: CURSOR_SCHEMA,
|
|
527
|
+
insert_cursor: { id: "" },
|
|
528
|
+
session_cursor: null,
|
|
529
|
+
session_dirty_hints: {},
|
|
530
|
+
reconcile_watermark: null,
|
|
531
|
+
failed_parts: {},
|
|
532
|
+
dead_letters: {},
|
|
533
|
+
last_reconcile_at: null,
|
|
534
|
+
legacy_cursor: legacyCursor,
|
|
535
|
+
migrated_from: migratedFrom
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function getSyncState(root) {
|
|
539
|
+
const p = join4(root, LAST_SYNC_FILE);
|
|
540
|
+
if (!existsSync4(p))
|
|
541
|
+
return freshSyncState();
|
|
542
|
+
try {
|
|
543
|
+
return parseSyncState(readFileSync2(p, "utf8"));
|
|
544
|
+
} catch {
|
|
545
|
+
return freshSyncState("unreadable");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function setSyncState(state, root) {
|
|
549
|
+
const p = join4(root, LAST_SYNC_FILE);
|
|
550
|
+
const tmp = p + ".tmp";
|
|
551
|
+
writeFileSync2(tmp, `${CURSOR_SCHEMA} ${JSON.stringify(normalizeSyncState(state))}`);
|
|
552
|
+
renameSync(tmp, p);
|
|
553
|
+
}
|
|
554
|
+
function getLastSync(root) {
|
|
555
|
+
const p = join4(root, LAST_SYNC_FILE);
|
|
556
|
+
if (!existsSync4(p))
|
|
557
|
+
return null;
|
|
558
|
+
const state = getSyncState(root);
|
|
559
|
+
if (state.legacy_cursor)
|
|
560
|
+
return state.legacy_cursor;
|
|
561
|
+
if (!state.insert_cursor.id)
|
|
562
|
+
return null;
|
|
563
|
+
return { ts: 0, id: state.insert_cursor.id };
|
|
564
|
+
}
|
|
565
|
+
function parseSyncState(rawInput) {
|
|
566
|
+
const raw = rawInput.trim();
|
|
567
|
+
if (!raw)
|
|
568
|
+
return freshSyncState("empty");
|
|
569
|
+
if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
|
|
570
|
+
const parsed = JSON.parse(raw.slice(CURSOR_SCHEMA.length + 1));
|
|
571
|
+
return normalizeSyncState(parsed);
|
|
572
|
+
}
|
|
573
|
+
if (raw.startsWith("{")) {
|
|
574
|
+
return normalizeSyncState(JSON.parse(raw));
|
|
575
|
+
}
|
|
576
|
+
if (raw.startsWith("v2 ")) {
|
|
577
|
+
return freshSyncState("v2", parseLegacyCursor(raw.slice(3)));
|
|
578
|
+
}
|
|
579
|
+
const legacy = parseLegacyCursor(raw);
|
|
580
|
+
return freshSyncState(legacy ? "v1" : "unknown", legacy);
|
|
581
|
+
}
|
|
582
|
+
function normalizeSyncState(input) {
|
|
583
|
+
if (!isRecord(input))
|
|
584
|
+
return freshSyncState("invalid");
|
|
585
|
+
const state = freshSyncState(asString(input.migrated_from) ?? undefined, cursorOrNull(input.legacy_cursor));
|
|
586
|
+
const insert = isRecord(input.insert_cursor) ? input.insert_cursor : null;
|
|
587
|
+
state.insert_cursor.id = asString(insert?.id) ?? "";
|
|
588
|
+
state.session_cursor = cursorOrNull(input.session_cursor);
|
|
589
|
+
state.session_dirty_hints = dirtyHints(input.session_dirty_hints);
|
|
590
|
+
state.reconcile_watermark = reconcileWatermark(input.reconcile_watermark);
|
|
591
|
+
state.failed_parts = failedParts(input.failed_parts);
|
|
592
|
+
state.dead_letters = failedParts(input.dead_letters);
|
|
593
|
+
state.last_reconcile_at = finiteOrNull(input.last_reconcile_at);
|
|
594
|
+
return state;
|
|
595
|
+
}
|
|
596
|
+
function parseLegacyCursor(raw) {
|
|
597
|
+
const idx = raw.indexOf(":");
|
|
598
|
+
if (idx <= 0)
|
|
599
|
+
return null;
|
|
600
|
+
const ts = Number(raw.slice(0, idx));
|
|
601
|
+
const id = raw.slice(idx + 1);
|
|
602
|
+
if (!Number.isFinite(ts) || !id)
|
|
603
|
+
return null;
|
|
604
|
+
return { ts, id };
|
|
605
|
+
}
|
|
606
|
+
function cursorOrNull(value) {
|
|
607
|
+
if (!isRecord(value))
|
|
608
|
+
return null;
|
|
609
|
+
const ts = finiteOrNull(value.ts);
|
|
610
|
+
const id = asString(value.id);
|
|
611
|
+
if (ts == null || !id)
|
|
612
|
+
return null;
|
|
613
|
+
return { ts, id };
|
|
614
|
+
}
|
|
615
|
+
function dirtyHints(value) {
|
|
616
|
+
if (!isRecord(value))
|
|
617
|
+
return {};
|
|
618
|
+
const out = {};
|
|
619
|
+
for (const [id, raw] of Object.entries(value)) {
|
|
620
|
+
if (!id)
|
|
621
|
+
continue;
|
|
622
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
623
|
+
out[id] = { time_updated: raw, part_cursor: null };
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (!isRecord(raw))
|
|
627
|
+
continue;
|
|
628
|
+
const timeUpdated = finiteOrNull(raw.time_updated);
|
|
629
|
+
if (timeUpdated == null)
|
|
630
|
+
continue;
|
|
631
|
+
out[id] = { time_updated: timeUpdated, part_cursor: asString(raw.part_cursor) };
|
|
632
|
+
}
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
635
|
+
function reconcileWatermark(value) {
|
|
636
|
+
if (!isRecord(value))
|
|
637
|
+
return null;
|
|
638
|
+
const at = finiteOrNull(value.at);
|
|
639
|
+
if (at == null)
|
|
640
|
+
return null;
|
|
641
|
+
return {
|
|
642
|
+
part_id: asString(value.part_id),
|
|
643
|
+
session_id: asString(value.session_id),
|
|
644
|
+
at
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function failedParts(value) {
|
|
648
|
+
if (!isRecord(value))
|
|
649
|
+
return {};
|
|
650
|
+
const out = {};
|
|
651
|
+
for (const [id, raw] of Object.entries(value)) {
|
|
652
|
+
if (!id || !isRecord(raw))
|
|
653
|
+
continue;
|
|
654
|
+
const attempts = finiteOrNull(raw.attempts);
|
|
655
|
+
const firstFailedAt = finiteOrNull(raw.first_failed_at);
|
|
656
|
+
const lastFailedAt = finiteOrNull(raw.last_failed_at);
|
|
657
|
+
if (attempts == null || firstFailedAt == null || lastFailedAt == null)
|
|
658
|
+
continue;
|
|
659
|
+
out[id] = {
|
|
660
|
+
id,
|
|
661
|
+
attempts,
|
|
662
|
+
first_failed_at: firstFailedAt,
|
|
663
|
+
last_failed_at: lastFailedAt,
|
|
664
|
+
last_error: asString(raw.last_error) ?? "unknown export failure"
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
return out;
|
|
668
|
+
}
|
|
669
|
+
function finiteOrNull(value) {
|
|
670
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
671
|
+
}
|
|
672
|
+
function asString(value) {
|
|
673
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
674
|
+
}
|
|
675
|
+
function isRecord(value) {
|
|
676
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
677
|
+
}
|
|
678
|
+
|
|
207
679
|
// src/lib/export.ts
|
|
208
|
-
var DEFAULT_EXPORT_ROOT =
|
|
680
|
+
var DEFAULT_EXPORT_ROOT = join5(homedir2(), ".local/share/opencode-sessions-explorer");
|
|
209
681
|
var BODY_CAP_BYTES = 256 * 1024;
|
|
210
682
|
var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
|
|
211
683
|
var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
|
|
212
|
-
var
|
|
684
|
+
var INSERT_REWIND_MS = 3 * 60 * 1000;
|
|
685
|
+
var INSERT_REWIND_MAX_ROWS = 512;
|
|
686
|
+
var MAX_FAILED_ATTEMPTS = 5;
|
|
213
687
|
var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
|
|
214
688
|
function exportRoot() {
|
|
215
689
|
return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
|
|
216
690
|
}
|
|
691
|
+
function getSyncState2(root = exportRoot()) {
|
|
692
|
+
return getSyncState(root);
|
|
693
|
+
}
|
|
694
|
+
function setSyncState2(state, root = exportRoot()) {
|
|
695
|
+
setSyncState(state, root);
|
|
696
|
+
}
|
|
697
|
+
function getLastSync2(root = exportRoot()) {
|
|
698
|
+
return getLastSync(root);
|
|
699
|
+
}
|
|
217
700
|
function ensureRoot(root = exportRoot()) {
|
|
218
|
-
mkdirSync(
|
|
701
|
+
mkdirSync(join5(root, "by-session"), { recursive: true });
|
|
219
702
|
return root;
|
|
220
703
|
}
|
|
221
704
|
function channelExportComplete(root = exportRoot()) {
|
|
222
|
-
return
|
|
705
|
+
return existsSync5(join5(root, CHANNEL_COMPLETE_MARKER));
|
|
223
706
|
}
|
|
224
707
|
function markChannelExportComplete(root = exportRoot()) {
|
|
225
|
-
const p =
|
|
708
|
+
const p = join5(root, CHANNEL_COMPLETE_MARKER);
|
|
226
709
|
const tmp = p + ".tmp";
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
var CURSOR_SCHEMA = "v2";
|
|
231
|
-
function getLastSync(root = exportRoot()) {
|
|
232
|
-
const p = join2(root, ".last_sync");
|
|
233
|
-
if (!existsSync2(p))
|
|
234
|
-
return null;
|
|
235
|
-
try {
|
|
236
|
-
const raw = readFileSync(p, "utf8").trim();
|
|
237
|
-
if (!raw)
|
|
238
|
-
return null;
|
|
239
|
-
if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
|
|
240
|
-
const [tsStr, id] = raw.slice(CURSOR_SCHEMA.length + 1).split(":");
|
|
241
|
-
const ts = Number(tsStr);
|
|
242
|
-
if (!Number.isFinite(ts) || !id)
|
|
243
|
-
return null;
|
|
244
|
-
return { ts, id };
|
|
245
|
-
}
|
|
246
|
-
return null;
|
|
247
|
-
} catch {
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
function setLastSync(c, root = exportRoot()) {
|
|
252
|
-
const p = join2(root, ".last_sync");
|
|
253
|
-
const tmp = p + ".tmp";
|
|
254
|
-
writeFileSync(tmp, `${CURSOR_SCHEMA} ${c.ts}:${c.id}`);
|
|
255
|
-
renameSync(tmp, p);
|
|
710
|
+
writeFileSync3(tmp, String(Date.now()));
|
|
711
|
+
renameSync2(tmp, p);
|
|
256
712
|
}
|
|
257
713
|
var sessionCache = new Map;
|
|
258
714
|
function getSession(id) {
|
|
@@ -495,46 +951,46 @@ function writeMeta(s, dir) {
|
|
|
495
951
|
time_updated: s.time_updated,
|
|
496
952
|
archived: s.time_archived != null
|
|
497
953
|
};
|
|
498
|
-
const p =
|
|
954
|
+
const p = join5(dir, "meta.json");
|
|
499
955
|
const tmp = p + ".tmp";
|
|
500
|
-
|
|
501
|
-
|
|
956
|
+
writeFileSync3(tmp, JSON.stringify(meta, null, 2));
|
|
957
|
+
renameSync2(tmp, p);
|
|
502
958
|
}
|
|
503
959
|
function writePartFile(dir, filename, content) {
|
|
504
|
-
const p =
|
|
505
|
-
const tmp =
|
|
506
|
-
|
|
507
|
-
|
|
960
|
+
const p = join5(dir, filename);
|
|
961
|
+
const tmp = join5(dir, "." + filename + ".tmp");
|
|
962
|
+
writeFileSync3(tmp, content);
|
|
963
|
+
renameSync2(tmp, p);
|
|
508
964
|
const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(filename);
|
|
509
965
|
if (!m)
|
|
510
966
|
return;
|
|
511
967
|
const myPartId = m[2];
|
|
512
968
|
try {
|
|
513
|
-
for (const f of
|
|
969
|
+
for (const f of readdirSync2(dir)) {
|
|
514
970
|
if (f === filename || !f.endsWith(".txt") || f.startsWith("."))
|
|
515
971
|
continue;
|
|
516
972
|
const fm = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
|
|
517
973
|
if (fm && fm[2] === myPartId) {
|
|
518
974
|
try {
|
|
519
|
-
|
|
975
|
+
unlinkSync3(join5(dir, f));
|
|
520
976
|
} catch {}
|
|
521
977
|
}
|
|
522
978
|
}
|
|
523
979
|
} catch {}
|
|
524
980
|
}
|
|
525
|
-
function
|
|
526
|
-
return
|
|
981
|
+
function channelDir2(root, channel, sessionId) {
|
|
982
|
+
return join5(root, "by-channel", channel, "by-session", sessionId);
|
|
527
983
|
}
|
|
528
984
|
function deleteChannelPartFiles(root, sessionId, partId) {
|
|
529
985
|
for (const ch of PART_CHANNELS) {
|
|
530
|
-
const dir =
|
|
531
|
-
if (!
|
|
986
|
+
const dir = channelDir2(root, ch, sessionId);
|
|
987
|
+
if (!existsSync5(dir))
|
|
532
988
|
continue;
|
|
533
989
|
try {
|
|
534
|
-
for (const f of
|
|
990
|
+
for (const f of readdirSync2(dir)) {
|
|
535
991
|
if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
|
|
536
992
|
try {
|
|
537
|
-
|
|
993
|
+
unlinkSync3(join5(dir, f));
|
|
538
994
|
} catch {}
|
|
539
995
|
}
|
|
540
996
|
}
|
|
@@ -544,20 +1000,20 @@ function deleteChannelPartFiles(root, sessionId, partId) {
|
|
|
544
1000
|
function writeChannelFiles(root, sessionId, filename, partId, docs) {
|
|
545
1001
|
deleteChannelPartFiles(root, sessionId, partId);
|
|
546
1002
|
for (const doc of docs) {
|
|
547
|
-
const dir =
|
|
548
|
-
if (!
|
|
1003
|
+
const dir = channelDir2(root, doc.channel, sessionId);
|
|
1004
|
+
if (!existsSync5(dir))
|
|
549
1005
|
mkdirSync(dir, { recursive: true });
|
|
550
1006
|
writePartFile(dir, filename, doc.content);
|
|
551
1007
|
}
|
|
552
1008
|
}
|
|
553
1009
|
function writeSessionSummaryChannel(s, dirRoot = exportRoot()) {
|
|
554
|
-
const dir =
|
|
555
|
-
if (!
|
|
1010
|
+
const dir = channelDir2(dirRoot, "session-summary", s.id);
|
|
1011
|
+
if (!existsSync5(dir))
|
|
556
1012
|
mkdirSync(dir, { recursive: true });
|
|
557
|
-
const p =
|
|
1013
|
+
const p = join5(dir, "summary.txt");
|
|
558
1014
|
const tmp = p + ".tmp";
|
|
559
|
-
|
|
560
|
-
|
|
1015
|
+
writeFileSync3(tmp, buildSessionSummaryDocument(s));
|
|
1016
|
+
renameSync2(tmp, p);
|
|
561
1017
|
}
|
|
562
1018
|
function buildSessionSummaryDocument(s) {
|
|
563
1019
|
const firstPrompt = firstUserPrompt(s.id, "ASC");
|
|
@@ -611,7 +1067,7 @@ function getFileIndex(sessionId, dir) {
|
|
|
611
1067
|
return idx;
|
|
612
1068
|
idx = { nextSeq: 1, byPartId: new Map };
|
|
613
1069
|
try {
|
|
614
|
-
const files =
|
|
1070
|
+
const files = readdirSync2(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
|
|
615
1071
|
let max = 0;
|
|
616
1072
|
for (const f of files) {
|
|
617
1073
|
const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
|
|
@@ -632,125 +1088,337 @@ function getFileIndex(sessionId, dir) {
|
|
|
632
1088
|
}
|
|
633
1089
|
async function runExport(opts = {}) {
|
|
634
1090
|
const root = ensureRoot(opts.root ?? exportRoot());
|
|
635
|
-
const cursor = opts.fromCursor !== undefined ? opts.fromCursor : getLastSync(root);
|
|
636
1091
|
const batchSize = opts.batchSize ?? 1000;
|
|
637
|
-
const
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1092
|
+
const progress = emptyProgress(getLastSync2(root));
|
|
1093
|
+
const lock = acquireExportLock(root);
|
|
1094
|
+
if (!lock) {
|
|
1095
|
+
progress.lock_skipped = true;
|
|
1096
|
+
return progress;
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
const state = getSyncState2(root);
|
|
1100
|
+
applyCursorOverride(state, opts.fromCursor);
|
|
1101
|
+
const start = Date.now();
|
|
1102
|
+
const touchedSessions = new Set;
|
|
1103
|
+
retryFailedParts(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
|
|
1104
|
+
runInsertFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
|
|
1105
|
+
runSessionDirtyFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
|
|
1106
|
+
refreshTouchedSessions(root, touchedSessions);
|
|
1107
|
+
if (!opts.budgetMs) {
|
|
1108
|
+
lock.heartbeat();
|
|
1109
|
+
const tombstones = reconcileTombstones(root, lock.heartbeat);
|
|
1110
|
+
applyTombstoneProgress(progress, tombstones);
|
|
1111
|
+
state.last_reconcile_at = Date.now();
|
|
1112
|
+
state.reconcile_watermark = {
|
|
1113
|
+
part_id: state.insert_cursor.id || null,
|
|
1114
|
+
session_id: state.session_cursor?.id ?? null,
|
|
1115
|
+
at: state.last_reconcile_at
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
progress.last_cursor = state.legacy_cursor;
|
|
1119
|
+
setSyncState2(state, root);
|
|
1120
|
+
} finally {
|
|
1121
|
+
lock.release();
|
|
1122
|
+
}
|
|
1123
|
+
if (opts.budgetMs && !opts.skipBackgroundReconcile) {
|
|
1124
|
+
scheduleBackgroundReconcile({ root });
|
|
1125
|
+
}
|
|
1126
|
+
return progress;
|
|
1127
|
+
}
|
|
1128
|
+
function emptyProgress(cursor) {
|
|
1129
|
+
return {
|
|
1130
|
+
exported: 0,
|
|
1131
|
+
inserts: 0,
|
|
1132
|
+
updates: 0,
|
|
1133
|
+
skipped_nontext: 0,
|
|
1134
|
+
skipped_oversize: 0,
|
|
1135
|
+
failed: 0,
|
|
1136
|
+
retried: 0,
|
|
1137
|
+
dead_lettered: 0,
|
|
1138
|
+
tombstones_removed_parts: 0,
|
|
1139
|
+
tombstones_removed_sessions: 0,
|
|
1140
|
+
lock_skipped: false,
|
|
1141
|
+
last_cursor: cursor
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
function applyCursorOverride(state, cursor) {
|
|
1145
|
+
if (cursor === undefined)
|
|
1146
|
+
return;
|
|
1147
|
+
state.legacy_cursor = cursor;
|
|
1148
|
+
state.insert_cursor.id = cursor?.id ?? "";
|
|
1149
|
+
state.session_cursor = cursor && cursor.ts > 0 ? cursor : null;
|
|
1150
|
+
state.session_dirty_hints = {};
|
|
1151
|
+
}
|
|
1152
|
+
function retryFailedParts(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
|
|
1153
|
+
const ids = Object.keys(state.failed_parts).sort().slice(0, batchSize);
|
|
1154
|
+
for (const id of ids) {
|
|
1155
|
+
if (timeExceeded(start, budgetMs))
|
|
650
1156
|
break;
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1157
|
+
progress.retried++;
|
|
1158
|
+
const row = loadPartById(id);
|
|
1159
|
+
if (!row) {
|
|
1160
|
+
clearPartFailure(state, id);
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
exportPartRow(root, state, row, progress, touchedSessions);
|
|
1164
|
+
reportProgress(progress, onProgress, heartbeat);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
function runInsertFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
|
|
1168
|
+
const recentSafeRows = [];
|
|
1169
|
+
let scanCursor = state.insert_cursor.id;
|
|
1170
|
+
while (!timeExceeded(start, budgetMs)) {
|
|
1171
|
+
const rows = loadPartRowsAfterId(scanCursor, batchSize);
|
|
659
1172
|
if (rows.length === 0)
|
|
660
1173
|
break;
|
|
661
|
-
for (const
|
|
662
|
-
if (
|
|
1174
|
+
for (const row of rows) {
|
|
1175
|
+
if (timeExceeded(start, budgetMs))
|
|
663
1176
|
break;
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
writePartFile(dir, filename2, built.content);
|
|
693
|
-
idx.byPartId.set(r.id, filename2);
|
|
694
|
-
inserts++;
|
|
695
|
-
}
|
|
696
|
-
const filename = idx.byPartId.get(r.id);
|
|
697
|
-
if (filename)
|
|
698
|
-
writeChannelFiles(root, r.session_id, filename, r.id, channelDocs);
|
|
699
|
-
touchedSessions.add(r.session_id);
|
|
700
|
-
progress.exported++;
|
|
701
|
-
} catch {
|
|
702
|
-
progress.failed++;
|
|
703
|
-
}
|
|
1177
|
+
scanCursor = row.id;
|
|
1178
|
+
const safe = exportPartRow(root, state, row, progress, touchedSessions);
|
|
1179
|
+
if (safe)
|
|
1180
|
+
rememberSafeRow(recentSafeRows, row);
|
|
1181
|
+
reportProgress(progress, onProgress, heartbeat);
|
|
1182
|
+
}
|
|
1183
|
+
if (rows.length < batchSize)
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
if (recentSafeRows.length > 0) {
|
|
1187
|
+
state.insert_cursor.id = chooseInsertCursor(state.insert_cursor.id, recentSafeRows, budgetMs !== undefined);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function runSessionDirtyFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
|
|
1191
|
+
scanDirtySessionHints(state, start, budgetMs, Math.min(batchSize, 500));
|
|
1192
|
+
for (const [sessionId, hint] of sortedDirtyHints(state.session_dirty_hints)) {
|
|
1193
|
+
while (!timeExceeded(start, budgetMs)) {
|
|
1194
|
+
const rows = loadSessionPartRows(sessionId, hint.part_cursor, batchSize);
|
|
1195
|
+
if (rows.length === 0) {
|
|
1196
|
+
delete state.session_dirty_hints[sessionId];
|
|
1197
|
+
break;
|
|
1198
|
+
}
|
|
1199
|
+
for (const row of rows) {
|
|
1200
|
+
if (timeExceeded(start, budgetMs))
|
|
1201
|
+
break;
|
|
1202
|
+
hint.part_cursor = row.id;
|
|
1203
|
+
exportPartRow(root, state, row, progress, touchedSessions);
|
|
1204
|
+
reportProgress(progress, onProgress, heartbeat);
|
|
704
1205
|
}
|
|
705
|
-
|
|
1206
|
+
if (rows.length < batchSize) {
|
|
1207
|
+
delete state.session_dirty_hints[sessionId];
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (timeExceeded(start, budgetMs))
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
function scanDirtySessionHints(state, start, budgetMs, limit) {
|
|
1216
|
+
while (!timeExceeded(start, budgetMs)) {
|
|
1217
|
+
const rows = loadDirtySessionsAfter(state.session_cursor, limit);
|
|
1218
|
+
if (rows.length === 0)
|
|
1219
|
+
break;
|
|
1220
|
+
for (const row of rows) {
|
|
1221
|
+
state.session_dirty_hints[row.id] = { time_updated: row.time_updated, part_cursor: null };
|
|
1222
|
+
state.session_cursor = { ts: row.time_updated, id: row.id };
|
|
1223
|
+
}
|
|
1224
|
+
if (rows.length < limit)
|
|
1225
|
+
break;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
function exportPartRow(root, state, row, progress, touchedSessions) {
|
|
1229
|
+
if (row.data_bytes > SAFETY_PART_CAP_BYTES) {
|
|
1230
|
+
removeExistingPartExport(root, row.session_id, row.id);
|
|
1231
|
+
progress.skipped_oversize++;
|
|
1232
|
+
markSafeCursor(state, progress, row);
|
|
1233
|
+
clearPartFailure(state, row.id);
|
|
1234
|
+
return true;
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
const session = getSession(row.session_id);
|
|
1238
|
+
if (!session)
|
|
1239
|
+
throw new Error(`missing session ${row.session_id}`);
|
|
1240
|
+
const archived = session.time_archived != null;
|
|
1241
|
+
const built = buildPartFile(row.id, row.session_id, row.message_id, row.data, archived);
|
|
1242
|
+
if (!built) {
|
|
1243
|
+
removeExistingPartExport(root, row.session_id, row.id);
|
|
1244
|
+
progress.skipped_nontext++;
|
|
1245
|
+
markSafeCursor(state, progress, row);
|
|
1246
|
+
clearPartFailure(state, row.id);
|
|
1247
|
+
return true;
|
|
706
1248
|
}
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1249
|
+
const channelDocs = buildChannelDocuments(row.id, row.session_id, row.message_id, row.data, archived, row.role, session.directory);
|
|
1250
|
+
const dir = join5(root, "by-session", row.session_id);
|
|
1251
|
+
if (!existsSync5(dir)) {
|
|
1252
|
+
mkdirSync(dir, { recursive: true });
|
|
1253
|
+
writeMeta(session, dir);
|
|
1254
|
+
}
|
|
1255
|
+
const idx = getFileIndex(row.session_id, dir);
|
|
1256
|
+
const existing = idx.byPartId.get(row.id);
|
|
1257
|
+
if (existing) {
|
|
1258
|
+
writePartFile(dir, existing, built.content);
|
|
1259
|
+
progress.updates++;
|
|
1260
|
+
} else {
|
|
1261
|
+
const filename2 = safePartFilename(idx.nextSeq++, row.id);
|
|
1262
|
+
writePartFile(dir, filename2, built.content);
|
|
1263
|
+
idx.byPartId.set(row.id, filename2);
|
|
1264
|
+
progress.inserts++;
|
|
715
1265
|
}
|
|
1266
|
+
const filename = idx.byPartId.get(row.id);
|
|
1267
|
+
if (filename)
|
|
1268
|
+
writeChannelFiles(root, row.session_id, filename, row.id, channelDocs);
|
|
1269
|
+
touchedSessions.add(row.session_id);
|
|
1270
|
+
progress.exported++;
|
|
1271
|
+
markSafeCursor(state, progress, row);
|
|
1272
|
+
clearPartFailure(state, row.id);
|
|
1273
|
+
return true;
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
progress.failed++;
|
|
1276
|
+
markPartFailure(state, row.id, errorMessage(error), progress);
|
|
1277
|
+
return false;
|
|
716
1278
|
}
|
|
1279
|
+
}
|
|
1280
|
+
function removeExistingPartExport(root, sessionId, partId) {
|
|
1281
|
+
const dir = join5(root, "by-session", sessionId);
|
|
1282
|
+
try {
|
|
1283
|
+
if (existsSync5(dir)) {
|
|
1284
|
+
const idx = getFileIndex(sessionId, dir);
|
|
1285
|
+
const existing = idx.byPartId.get(partId);
|
|
1286
|
+
if (existing)
|
|
1287
|
+
unlinkSync3(join5(dir, existing));
|
|
1288
|
+
idx.byPartId.delete(partId);
|
|
1289
|
+
}
|
|
1290
|
+
deleteChannelPartFiles(root, sessionId, partId);
|
|
1291
|
+
} catch {}
|
|
1292
|
+
}
|
|
1293
|
+
function markSafeCursor(state, progress, row) {
|
|
1294
|
+
const cursor = { ts: row.time_updated, id: row.id };
|
|
1295
|
+
state.legacy_cursor = cursor;
|
|
1296
|
+
progress.last_cursor = cursor;
|
|
1297
|
+
}
|
|
1298
|
+
function markPartFailure(state, partId, message, progress) {
|
|
1299
|
+
if (state.dead_letters[partId])
|
|
1300
|
+
return;
|
|
1301
|
+
const now = Date.now();
|
|
1302
|
+
const existing = state.failed_parts[partId];
|
|
1303
|
+
const failure = {
|
|
1304
|
+
id: partId,
|
|
1305
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
1306
|
+
first_failed_at: existing?.first_failed_at ?? now,
|
|
1307
|
+
last_failed_at: now,
|
|
1308
|
+
last_error: message
|
|
1309
|
+
};
|
|
1310
|
+
if (failure.attempts >= MAX_FAILED_ATTEMPTS) {
|
|
1311
|
+
state.dead_letters[partId] = failure;
|
|
1312
|
+
delete state.failed_parts[partId];
|
|
1313
|
+
progress.dead_lettered++;
|
|
1314
|
+
} else {
|
|
1315
|
+
state.failed_parts[partId] = failure;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
function clearPartFailure(state, partId) {
|
|
1319
|
+
delete state.failed_parts[partId];
|
|
1320
|
+
}
|
|
1321
|
+
function rememberSafeRow(rows, row) {
|
|
1322
|
+
rows.push(row);
|
|
1323
|
+
const maxRows = INSERT_REWIND_MAX_ROWS * 4;
|
|
1324
|
+
if (rows.length > maxRows)
|
|
1325
|
+
rows.splice(0, rows.length - maxRows);
|
|
1326
|
+
}
|
|
1327
|
+
function chooseInsertCursor(previousId, rows, useRewind) {
|
|
1328
|
+
const last = rows[rows.length - 1];
|
|
1329
|
+
if (!last || !useRewind || rows.length <= INSERT_REWIND_MAX_ROWS)
|
|
1330
|
+
return last?.id ?? previousId;
|
|
1331
|
+
const maxCreated = rows.reduce((max, row) => Math.max(max, row.time_created), 0);
|
|
1332
|
+
const cutoff = maxCreated - INSERT_REWIND_MS;
|
|
1333
|
+
const timeIndex = rows.findIndex((row) => row.time_created >= cutoff);
|
|
1334
|
+
const rewindIndex = Math.max(timeIndex <= 0 ? rows.length - INSERT_REWIND_MAX_ROWS : timeIndex - 1, rows.length - INSERT_REWIND_MAX_ROWS);
|
|
1335
|
+
return rows[Math.max(0, rewindIndex)]?.id ?? last.id;
|
|
1336
|
+
}
|
|
1337
|
+
function sortedDirtyHints(hints) {
|
|
1338
|
+
return Object.entries(hints).sort((a, b) => a[1].time_updated - b[1].time_updated || a[0].localeCompare(b[0]));
|
|
1339
|
+
}
|
|
1340
|
+
function refreshTouchedSessions(root, touchedSessions) {
|
|
717
1341
|
for (const sid of touchedSessions) {
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
writeMeta(fresh, dir);
|
|
727
|
-
if (fresh)
|
|
728
|
-
writeSessionSummaryChannel(fresh, root);
|
|
1342
|
+
const dir = join5(root, "by-session", sid);
|
|
1343
|
+
const fresh = stmt(`
|
|
1344
|
+
SELECT id, title, project_id, directory, agent, model, cost,
|
|
1345
|
+
time_created, time_updated, time_archived, parent_id
|
|
1346
|
+
FROM session WHERE id = ?`).get(sid);
|
|
1347
|
+
if (fresh) {
|
|
1348
|
+
writeMeta(fresh, dir);
|
|
1349
|
+
writeSessionSummaryChannel(fresh, root);
|
|
729
1350
|
}
|
|
730
1351
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
progress.
|
|
734
|
-
progress.
|
|
735
|
-
|
|
1352
|
+
}
|
|
1353
|
+
function applyTombstoneProgress(progress, tombstones) {
|
|
1354
|
+
progress.tombstones_removed_parts = tombstones.removed_parts;
|
|
1355
|
+
progress.tombstones_removed_sessions = tombstones.removed_sessions;
|
|
1356
|
+
}
|
|
1357
|
+
function loadPartById(partId) {
|
|
1358
|
+
return stmt(partSelectSql("WHERE p.id = ?")).get(partId);
|
|
1359
|
+
}
|
|
1360
|
+
function loadPartRowsAfterId(afterId, limit) {
|
|
1361
|
+
if (!afterId)
|
|
1362
|
+
return stmt(`${partSelectSql("")} ORDER BY p.id ASC LIMIT ?`).all(limit);
|
|
1363
|
+
return stmt(`${partSelectSql("WHERE p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(afterId, limit);
|
|
1364
|
+
}
|
|
1365
|
+
function loadSessionPartRows(sessionId, afterId, limit) {
|
|
1366
|
+
if (!afterId) {
|
|
1367
|
+
return stmt(`${partSelectSql("WHERE p.session_id = ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, limit);
|
|
1368
|
+
}
|
|
1369
|
+
return stmt(`${partSelectSql("WHERE p.session_id = ? AND p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, afterId, limit);
|
|
1370
|
+
}
|
|
1371
|
+
function loadDirtySessionsAfter(cursor, limit) {
|
|
1372
|
+
if (!cursor) {
|
|
1373
|
+
return stmt(`SELECT id, time_updated FROM session ORDER BY time_updated ASC, id ASC LIMIT ?`).all(limit);
|
|
1374
|
+
}
|
|
1375
|
+
return stmt(`
|
|
1376
|
+
SELECT id, time_updated
|
|
1377
|
+
FROM session
|
|
1378
|
+
WHERE (time_updated > ? OR (time_updated = ? AND id > ?))
|
|
1379
|
+
ORDER BY time_updated ASC, id ASC
|
|
1380
|
+
LIMIT ?`).all(cursor.ts, cursor.ts, cursor.id, limit);
|
|
1381
|
+
}
|
|
1382
|
+
function partSelectSql(where) {
|
|
1383
|
+
return `
|
|
1384
|
+
SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated,
|
|
1385
|
+
p.data, LENGTH(p.data) AS data_bytes,
|
|
1386
|
+
json_extract(m.data,'$.role') AS role
|
|
1387
|
+
FROM part p
|
|
1388
|
+
LEFT JOIN message m ON m.id = p.message_id
|
|
1389
|
+
${where}`;
|
|
1390
|
+
}
|
|
1391
|
+
function timeExceeded(start, budgetMs) {
|
|
1392
|
+
return budgetMs !== undefined && Date.now() - start > budgetMs;
|
|
1393
|
+
}
|
|
1394
|
+
function reportProgress(progress, onProgress, heartbeat) {
|
|
1395
|
+
heartbeat();
|
|
1396
|
+
if (!onProgress)
|
|
1397
|
+
return;
|
|
1398
|
+
const processed = progress.exported + progress.skipped_nontext + progress.skipped_oversize + progress.failed;
|
|
1399
|
+
if (processed > 0 && processed % 5000 === 0)
|
|
1400
|
+
onProgress(progress);
|
|
1401
|
+
}
|
|
1402
|
+
function errorMessage(error) {
|
|
1403
|
+
return error instanceof Error ? error.message : String(error);
|
|
736
1404
|
}
|
|
737
1405
|
|
|
738
1406
|
// src/bin/dedupe-export.ts
|
|
739
1407
|
var argv = process.argv.slice(2);
|
|
740
1408
|
var apply = argv.includes("--apply");
|
|
741
|
-
var root =
|
|
1409
|
+
var root = join6(exportRoot(), "by-session");
|
|
742
1410
|
console.log(`[dedupe-export] root: ${root}`);
|
|
743
1411
|
console.log(`[dedupe-export] mode: ${apply ? "APPLY (will delete)" : "DRY-RUN (just report)"}`);
|
|
744
|
-
var sessions =
|
|
1412
|
+
var sessions = readdirSync3(root).filter((f) => f.startsWith("ses_"));
|
|
745
1413
|
console.log(`[dedupe-export] scanning ${sessions.length} session dirs...`);
|
|
746
1414
|
var totalDups = 0;
|
|
747
1415
|
var totalBytesFreed = 0;
|
|
748
1416
|
var dirsAffected = 0;
|
|
749
1417
|
for (const ses of sessions) {
|
|
750
|
-
const dir =
|
|
1418
|
+
const dir = join6(root, ses);
|
|
751
1419
|
let files;
|
|
752
1420
|
try {
|
|
753
|
-
files =
|
|
1421
|
+
files = readdirSync3(dir);
|
|
754
1422
|
} catch {
|
|
755
1423
|
continue;
|
|
756
1424
|
}
|
|
@@ -773,14 +1441,14 @@ for (const ses of sessions) {
|
|
|
773
1441
|
const keep = variants[0];
|
|
774
1442
|
const deletables = variants.slice(1);
|
|
775
1443
|
for (const d of deletables) {
|
|
776
|
-
const path =
|
|
777
|
-
const sz =
|
|
1444
|
+
const path = join6(dir, d.filename);
|
|
1445
|
+
const sz = statSync3(path).size;
|
|
778
1446
|
totalBytesFreed += sz;
|
|
779
1447
|
totalDups++;
|
|
780
1448
|
dirDups++;
|
|
781
1449
|
if (apply) {
|
|
782
1450
|
try {
|
|
783
|
-
|
|
1451
|
+
unlinkSync4(path);
|
|
784
1452
|
} catch (e) {
|
|
785
1453
|
console.error(` failed to delete ${path}: ${e.message}`);
|
|
786
1454
|
}
|