opencode-sessions-explorer 0.1.2 → 0.1.3
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 +9 -0
- package/README.md +2 -1
- package/dist/bin/bulk-export.js +827 -159
- package/dist/bin/check-deps.js +835 -166
- package/dist/bin/dedupe-export.js +825 -157
- package/dist/lib/export-reconcile-worker.js +1519 -0
- package/dist/plugin.js +1122 -203
- package/docs/guides/export-and-maintenance.md +4 -2
- package/docs/guides/search-and-grep.md +4 -2
- package/docs/reference/architecture.md +5 -3
- package/package.json +2 -2
package/dist/bin/check-deps.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
// src/bin/check-deps.ts
|
|
5
5
|
import { spawnSync } from "child_process";
|
|
6
|
-
import { existsSync as
|
|
7
|
-
import { join as
|
|
6
|
+
import { existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
|
|
7
|
+
import { join as join6 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/lib/db.ts
|
|
10
10
|
import { Database } from "bun:sqlite";
|
|
@@ -248,8 +248,8 @@ function decodeModel(modelStr) {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
// src/lib/export.ts
|
|
251
|
-
import { mkdirSync, existsSync as
|
|
252
|
-
import { join as
|
|
251
|
+
import { mkdirSync, existsSync as existsSync5, renameSync as renameSync2, writeFileSync as writeFileSync3, unlinkSync as unlinkSync3, readdirSync as readdirSync2 } from "fs";
|
|
252
|
+
import { join as join5 } from "path";
|
|
253
253
|
import { homedir as homedir2 } from "os";
|
|
254
254
|
|
|
255
255
|
// src/lib/channel.ts
|
|
@@ -275,55 +275,511 @@ function compactPath(path, baseDir) {
|
|
|
275
275
|
return { path, rel_path: rel || "." };
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
// src/lib/export-constants.ts
|
|
279
|
+
var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
|
|
280
|
+
|
|
281
|
+
// src/lib/export-lock.ts
|
|
282
|
+
import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
283
|
+
import { hostname } from "os";
|
|
284
|
+
import { join as join2 } from "path";
|
|
285
|
+
import { randomUUID } from "crypto";
|
|
286
|
+
var LOCK_FILE = ".export.lock";
|
|
287
|
+
var DEFAULT_STALE_MS = 2 * 60 * 1000;
|
|
288
|
+
var HEARTBEAT_MS = 15000;
|
|
289
|
+
function acquireExportLock(root, staleMs = DEFAULT_STALE_MS) {
|
|
290
|
+
const path = join2(root, LOCK_FILE);
|
|
291
|
+
const first = tryCreateLock(path);
|
|
292
|
+
if (first)
|
|
293
|
+
return first;
|
|
294
|
+
const stale = staleCandidate(path, staleMs);
|
|
295
|
+
if (!stale)
|
|
296
|
+
return null;
|
|
297
|
+
if (!removeStaleLock(path, stale, staleMs))
|
|
298
|
+
return tryCreateLock(path);
|
|
299
|
+
return tryCreateLock(path);
|
|
300
|
+
}
|
|
301
|
+
function tryCreateLock(path) {
|
|
302
|
+
let fd = null;
|
|
303
|
+
try {
|
|
304
|
+
const token = randomUUID();
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
const record = { token, pid: process.pid, hostname: hostname(), created_at: now, updated_at: now };
|
|
307
|
+
fd = openSync(path, "wx");
|
|
308
|
+
writeFileSync(fd, JSON.stringify(record));
|
|
309
|
+
closeSync(fd);
|
|
310
|
+
fd = null;
|
|
311
|
+
let lastHeartbeat = now;
|
|
312
|
+
return {
|
|
313
|
+
token,
|
|
314
|
+
release: () => releaseLock(path, token),
|
|
315
|
+
heartbeat: () => {
|
|
316
|
+
const current = Date.now();
|
|
317
|
+
if (current - lastHeartbeat < HEARTBEAT_MS)
|
|
318
|
+
return;
|
|
319
|
+
lastHeartbeat = current;
|
|
320
|
+
heartbeatLock(path, token, current);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
} catch {
|
|
324
|
+
if (fd != null)
|
|
325
|
+
try {
|
|
326
|
+
closeSync(fd);
|
|
327
|
+
} catch {}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function staleCandidate(path, staleMs) {
|
|
332
|
+
try {
|
|
333
|
+
const stat = statSync(path);
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
if (now - stat.mtimeMs <= staleMs)
|
|
336
|
+
return null;
|
|
337
|
+
const parsed = readLockRecord(path);
|
|
338
|
+
if (!parsed)
|
|
339
|
+
return { token: null, updatedAt: null, mtimeMs: stat.mtimeMs };
|
|
340
|
+
if (now - parsed.updated_at <= staleMs)
|
|
341
|
+
return null;
|
|
342
|
+
if (isLiveLocalOwner(parsed))
|
|
343
|
+
return null;
|
|
344
|
+
return { token: parsed.token, updatedAt: parsed.updated_at, mtimeMs: stat.mtimeMs };
|
|
345
|
+
} catch {
|
|
346
|
+
return { token: null, updatedAt: null, mtimeMs: 0 };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function removeStaleLock(path, stale, staleMs) {
|
|
350
|
+
try {
|
|
351
|
+
const stat = statSync(path);
|
|
352
|
+
if (stat.mtimeMs !== stale.mtimeMs)
|
|
353
|
+
return false;
|
|
354
|
+
if (stale.token) {
|
|
355
|
+
const current = readLockRecord(path);
|
|
356
|
+
if (current?.token !== stale.token)
|
|
357
|
+
return false;
|
|
358
|
+
if (current.updated_at !== stale.updatedAt)
|
|
359
|
+
return false;
|
|
360
|
+
const now = Date.now();
|
|
361
|
+
if (now - current.updated_at <= staleMs)
|
|
362
|
+
return false;
|
|
363
|
+
if (now - stat.mtimeMs <= staleMs)
|
|
364
|
+
return false;
|
|
365
|
+
if (isLiveLocalOwner(current))
|
|
366
|
+
return false;
|
|
367
|
+
} else {
|
|
368
|
+
if (Date.now() - stat.mtimeMs <= staleMs)
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
unlinkSync(path);
|
|
372
|
+
return true;
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function releaseLock(path, token) {
|
|
378
|
+
try {
|
|
379
|
+
const current = readLockRecord(path);
|
|
380
|
+
if (current?.token === token)
|
|
381
|
+
unlinkSync(path);
|
|
382
|
+
} catch {}
|
|
383
|
+
}
|
|
384
|
+
function heartbeatLock(path, token, now) {
|
|
385
|
+
try {
|
|
386
|
+
const current = readLockRecord(path);
|
|
387
|
+
if (current?.token !== token)
|
|
388
|
+
return;
|
|
389
|
+
writeFileSync(path, JSON.stringify({ ...current, updated_at: now }));
|
|
390
|
+
} catch {}
|
|
391
|
+
}
|
|
392
|
+
function readLockRecord(path) {
|
|
393
|
+
try {
|
|
394
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
395
|
+
if (typeof parsed.token !== "string")
|
|
396
|
+
return null;
|
|
397
|
+
if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid))
|
|
398
|
+
return null;
|
|
399
|
+
if (typeof parsed.hostname !== "string")
|
|
400
|
+
return null;
|
|
401
|
+
if (typeof parsed.created_at !== "number" || !Number.isFinite(parsed.created_at))
|
|
402
|
+
return null;
|
|
403
|
+
const updated = typeof parsed.updated_at === "number" && Number.isFinite(parsed.updated_at) ? parsed.updated_at : parsed.created_at;
|
|
404
|
+
return { token: parsed.token, pid: parsed.pid, hostname: parsed.hostname, created_at: parsed.created_at, updated_at: updated };
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function isLiveLocalOwner(record) {
|
|
410
|
+
if (record.hostname !== hostname())
|
|
411
|
+
return false;
|
|
412
|
+
if (!Number.isSafeInteger(record.pid) || record.pid <= 0)
|
|
413
|
+
return false;
|
|
414
|
+
try {
|
|
415
|
+
process.kill(record.pid, 0);
|
|
416
|
+
return true;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return error?.code === "EPERM";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/lib/export-tombstones.ts
|
|
423
|
+
import { existsSync as existsSync2, readdirSync, rmSync, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
424
|
+
import { join as join3 } from "path";
|
|
425
|
+
var PART_FILE_RE = /^(?:\d{5}-)?(prt_[A-Za-z0-9_-]+)\.txt$/;
|
|
426
|
+
function reconcileTombstones(root, heartbeat = () => {}) {
|
|
427
|
+
const progress = {
|
|
428
|
+
scanned_sessions: 0,
|
|
429
|
+
removed_sessions: 0,
|
|
430
|
+
removed_parts: 0,
|
|
431
|
+
removed_channel_sessions: 0
|
|
432
|
+
};
|
|
433
|
+
const bySession = join3(root, "by-session");
|
|
434
|
+
const sessions = loadSessionIds();
|
|
435
|
+
if (existsSync2(bySession)) {
|
|
436
|
+
for (const entry of safeReadDir(bySession)) {
|
|
437
|
+
const dir = join3(bySession, entry);
|
|
438
|
+
if (!isDirectory(dir))
|
|
439
|
+
continue;
|
|
440
|
+
if (!sessions.has(entry)) {
|
|
441
|
+
rmSync(dir, { recursive: true, force: true });
|
|
442
|
+
removeChannelSessionDirs(root, entry);
|
|
443
|
+
progress.removed_sessions++;
|
|
444
|
+
heartbeat();
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
progress.scanned_sessions++;
|
|
448
|
+
progress.removed_parts += removeOrphanPartFiles(root, entry, dir, heartbeat);
|
|
449
|
+
heartbeat();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
progress.removed_channel_sessions += removeOrphanChannelSessions(root, sessions, heartbeat);
|
|
453
|
+
return progress;
|
|
454
|
+
}
|
|
455
|
+
function removeOrphanPartFiles(root, sessionId, dir, heartbeat) {
|
|
456
|
+
const livePartIds = loadSearchablePartIds(sessionId);
|
|
457
|
+
let removed = 0;
|
|
458
|
+
for (const file of safeReadDir(dir)) {
|
|
459
|
+
heartbeat();
|
|
460
|
+
const partId = partIdFromFile(file);
|
|
461
|
+
if (!partId || livePartIds.has(partId))
|
|
462
|
+
continue;
|
|
463
|
+
try {
|
|
464
|
+
unlinkSync2(join3(dir, file));
|
|
465
|
+
removeChannelPartFiles(root, sessionId, partId);
|
|
466
|
+
removed++;
|
|
467
|
+
} catch {}
|
|
468
|
+
}
|
|
469
|
+
return removed;
|
|
470
|
+
}
|
|
471
|
+
function removeOrphanChannelSessions(root, sessions, heartbeat) {
|
|
472
|
+
let removed = 0;
|
|
473
|
+
for (const channel of CHANNELS) {
|
|
474
|
+
const base = join3(root, "by-channel", channel, "by-session");
|
|
475
|
+
if (!existsSync2(base))
|
|
476
|
+
continue;
|
|
477
|
+
for (const sessionId of safeReadDir(base)) {
|
|
478
|
+
heartbeat();
|
|
479
|
+
const dir = join3(base, sessionId);
|
|
480
|
+
if (!isDirectory(dir) || sessions.has(sessionId))
|
|
481
|
+
continue;
|
|
482
|
+
rmSync(dir, { recursive: true, force: true });
|
|
483
|
+
removed++;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return removed;
|
|
487
|
+
}
|
|
488
|
+
function removeChannelSessionDirs(root, sessionId) {
|
|
489
|
+
for (const channel of CHANNELS) {
|
|
490
|
+
rmSync(channelDir(root, channel, sessionId), { recursive: true, force: true });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function removeChannelPartFiles(root, sessionId, partId) {
|
|
494
|
+
for (const channel of CHANNELS) {
|
|
495
|
+
const dir = channelDir(root, channel, sessionId);
|
|
496
|
+
if (!existsSync2(dir))
|
|
497
|
+
continue;
|
|
498
|
+
for (const file of safeReadDir(dir)) {
|
|
499
|
+
if (file === `${partId}.txt` || file.endsWith(`-${partId}.txt`)) {
|
|
500
|
+
try {
|
|
501
|
+
unlinkSync2(join3(dir, file));
|
|
502
|
+
} catch {}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function channelDir(root, channel, sessionId) {
|
|
508
|
+
return join3(root, "by-channel", channel, "by-session", sessionId);
|
|
509
|
+
}
|
|
510
|
+
function loadSessionIds() {
|
|
511
|
+
const rows = stmt(`SELECT id FROM session`).all();
|
|
512
|
+
return new Set(rows.map((row) => row.id));
|
|
513
|
+
}
|
|
514
|
+
function loadSearchablePartIds(sessionId) {
|
|
515
|
+
const placeholders = SEARCHABLE_TYPES.map(() => "?").join(",");
|
|
516
|
+
const rows = stmt(`
|
|
517
|
+
SELECT id
|
|
518
|
+
FROM part
|
|
519
|
+
WHERE session_id = ?
|
|
520
|
+
AND json_extract(data,'$.type') IN (${placeholders})
|
|
521
|
+
ORDER BY id ASC`).all(sessionId, ...SEARCHABLE_TYPES);
|
|
522
|
+
return new Set(rows.map((row) => row.id));
|
|
523
|
+
}
|
|
524
|
+
function safeReadDir(dir) {
|
|
525
|
+
try {
|
|
526
|
+
return readdirSync(dir);
|
|
527
|
+
} catch {
|
|
528
|
+
return [];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function isDirectory(path) {
|
|
532
|
+
try {
|
|
533
|
+
return statSync2(path).isDirectory();
|
|
534
|
+
} catch {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function partIdFromFile(file) {
|
|
539
|
+
const match = PART_FILE_RE.exec(file);
|
|
540
|
+
return match ? match[1] : null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/lib/export-background.ts
|
|
544
|
+
import { existsSync as existsSync3 } from "fs";
|
|
545
|
+
import { fileURLToPath } from "url";
|
|
546
|
+
var DEFAULT_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
547
|
+
var inFlight = false;
|
|
548
|
+
var lastStartedAt = 0;
|
|
549
|
+
function scheduleBackgroundReconcile(opts) {
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
const minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
552
|
+
if (inFlight)
|
|
553
|
+
return { scheduled: false, reason: "already_running" };
|
|
554
|
+
if (now - lastStartedAt < minIntervalMs)
|
|
555
|
+
return { scheduled: false, reason: "throttled" };
|
|
556
|
+
const url = resolveWorkerUrl();
|
|
557
|
+
try {
|
|
558
|
+
const worker = new Worker(url, { type: "module" });
|
|
559
|
+
inFlight = true;
|
|
560
|
+
lastStartedAt = now;
|
|
561
|
+
const cleanup = () => {
|
|
562
|
+
inFlight = false;
|
|
563
|
+
worker.terminate();
|
|
564
|
+
};
|
|
565
|
+
worker.addEventListener("message", cleanup, { once: true });
|
|
566
|
+
worker.addEventListener("error", cleanup, { once: true });
|
|
567
|
+
const maybeUnref = worker;
|
|
568
|
+
maybeUnref.unref?.();
|
|
569
|
+
const request = { root: opts.root, batchSize: opts.batchSize ?? 2000 };
|
|
570
|
+
worker.postMessage(request);
|
|
571
|
+
return { scheduled: true };
|
|
572
|
+
} catch {
|
|
573
|
+
inFlight = false;
|
|
574
|
+
return { scheduled: false, reason: "worker_unavailable" };
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function resolveWorkerUrl() {
|
|
578
|
+
const js = new URL("./export-reconcile-worker.js", import.meta.url);
|
|
579
|
+
if (existsSync3(fileURLToPath(js)))
|
|
580
|
+
return js;
|
|
581
|
+
const ts = new URL("./export-reconcile-worker.ts", import.meta.url);
|
|
582
|
+
if (existsSync3(fileURLToPath(ts)))
|
|
583
|
+
return ts;
|
|
584
|
+
const bundled = new URL("./lib/export-reconcile-worker.js", import.meta.url);
|
|
585
|
+
if (existsSync3(fileURLToPath(bundled)))
|
|
586
|
+
return bundled;
|
|
587
|
+
return js;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/lib/export-state.ts
|
|
591
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
|
|
592
|
+
import { join as join4 } from "path";
|
|
593
|
+
var CURSOR_SCHEMA = "v3";
|
|
594
|
+
var LAST_SYNC_FILE = ".last_sync";
|
|
595
|
+
function freshSyncState(migratedFrom, legacyCursor = null) {
|
|
596
|
+
return {
|
|
597
|
+
schema: CURSOR_SCHEMA,
|
|
598
|
+
insert_cursor: { id: "" },
|
|
599
|
+
session_cursor: null,
|
|
600
|
+
session_dirty_hints: {},
|
|
601
|
+
reconcile_watermark: null,
|
|
602
|
+
failed_parts: {},
|
|
603
|
+
dead_letters: {},
|
|
604
|
+
last_reconcile_at: null,
|
|
605
|
+
legacy_cursor: legacyCursor,
|
|
606
|
+
migrated_from: migratedFrom
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function getSyncState(root) {
|
|
610
|
+
const p = join4(root, LAST_SYNC_FILE);
|
|
611
|
+
if (!existsSync4(p))
|
|
612
|
+
return freshSyncState();
|
|
613
|
+
try {
|
|
614
|
+
return parseSyncState(readFileSync2(p, "utf8"));
|
|
615
|
+
} catch {
|
|
616
|
+
return freshSyncState("unreadable");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function setSyncState(state, root) {
|
|
620
|
+
const p = join4(root, LAST_SYNC_FILE);
|
|
621
|
+
const tmp = p + ".tmp";
|
|
622
|
+
writeFileSync2(tmp, `${CURSOR_SCHEMA} ${JSON.stringify(normalizeSyncState(state))}`);
|
|
623
|
+
renameSync(tmp, p);
|
|
624
|
+
}
|
|
625
|
+
function getLastSync(root) {
|
|
626
|
+
const p = join4(root, LAST_SYNC_FILE);
|
|
627
|
+
if (!existsSync4(p))
|
|
628
|
+
return null;
|
|
629
|
+
const state = getSyncState(root);
|
|
630
|
+
if (state.legacy_cursor)
|
|
631
|
+
return state.legacy_cursor;
|
|
632
|
+
if (!state.insert_cursor.id)
|
|
633
|
+
return null;
|
|
634
|
+
return { ts: 0, id: state.insert_cursor.id };
|
|
635
|
+
}
|
|
636
|
+
function parseSyncState(rawInput) {
|
|
637
|
+
const raw = rawInput.trim();
|
|
638
|
+
if (!raw)
|
|
639
|
+
return freshSyncState("empty");
|
|
640
|
+
if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
|
|
641
|
+
const parsed = JSON.parse(raw.slice(CURSOR_SCHEMA.length + 1));
|
|
642
|
+
return normalizeSyncState(parsed);
|
|
643
|
+
}
|
|
644
|
+
if (raw.startsWith("{")) {
|
|
645
|
+
return normalizeSyncState(JSON.parse(raw));
|
|
646
|
+
}
|
|
647
|
+
if (raw.startsWith("v2 ")) {
|
|
648
|
+
return freshSyncState("v2", parseLegacyCursor(raw.slice(3)));
|
|
649
|
+
}
|
|
650
|
+
const legacy = parseLegacyCursor(raw);
|
|
651
|
+
return freshSyncState(legacy ? "v1" : "unknown", legacy);
|
|
652
|
+
}
|
|
653
|
+
function normalizeSyncState(input) {
|
|
654
|
+
if (!isRecord(input))
|
|
655
|
+
return freshSyncState("invalid");
|
|
656
|
+
const state = freshSyncState(asString(input.migrated_from) ?? undefined, cursorOrNull(input.legacy_cursor));
|
|
657
|
+
const insert = isRecord(input.insert_cursor) ? input.insert_cursor : null;
|
|
658
|
+
state.insert_cursor.id = asString(insert?.id) ?? "";
|
|
659
|
+
state.session_cursor = cursorOrNull(input.session_cursor);
|
|
660
|
+
state.session_dirty_hints = dirtyHints(input.session_dirty_hints);
|
|
661
|
+
state.reconcile_watermark = reconcileWatermark(input.reconcile_watermark);
|
|
662
|
+
state.failed_parts = failedParts(input.failed_parts);
|
|
663
|
+
state.dead_letters = failedParts(input.dead_letters);
|
|
664
|
+
state.last_reconcile_at = finiteOrNull(input.last_reconcile_at);
|
|
665
|
+
return state;
|
|
666
|
+
}
|
|
667
|
+
function parseLegacyCursor(raw) {
|
|
668
|
+
const idx = raw.indexOf(":");
|
|
669
|
+
if (idx <= 0)
|
|
670
|
+
return null;
|
|
671
|
+
const ts = Number(raw.slice(0, idx));
|
|
672
|
+
const id = raw.slice(idx + 1);
|
|
673
|
+
if (!Number.isFinite(ts) || !id)
|
|
674
|
+
return null;
|
|
675
|
+
return { ts, id };
|
|
676
|
+
}
|
|
677
|
+
function cursorOrNull(value) {
|
|
678
|
+
if (!isRecord(value))
|
|
679
|
+
return null;
|
|
680
|
+
const ts = finiteOrNull(value.ts);
|
|
681
|
+
const id = asString(value.id);
|
|
682
|
+
if (ts == null || !id)
|
|
683
|
+
return null;
|
|
684
|
+
return { ts, id };
|
|
685
|
+
}
|
|
686
|
+
function dirtyHints(value) {
|
|
687
|
+
if (!isRecord(value))
|
|
688
|
+
return {};
|
|
689
|
+
const out = {};
|
|
690
|
+
for (const [id, raw] of Object.entries(value)) {
|
|
691
|
+
if (!id)
|
|
692
|
+
continue;
|
|
693
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
694
|
+
out[id] = { time_updated: raw, part_cursor: null };
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (!isRecord(raw))
|
|
698
|
+
continue;
|
|
699
|
+
const timeUpdated = finiteOrNull(raw.time_updated);
|
|
700
|
+
if (timeUpdated == null)
|
|
701
|
+
continue;
|
|
702
|
+
out[id] = { time_updated: timeUpdated, part_cursor: asString(raw.part_cursor) };
|
|
703
|
+
}
|
|
704
|
+
return out;
|
|
705
|
+
}
|
|
706
|
+
function reconcileWatermark(value) {
|
|
707
|
+
if (!isRecord(value))
|
|
708
|
+
return null;
|
|
709
|
+
const at = finiteOrNull(value.at);
|
|
710
|
+
if (at == null)
|
|
711
|
+
return null;
|
|
712
|
+
return {
|
|
713
|
+
part_id: asString(value.part_id),
|
|
714
|
+
session_id: asString(value.session_id),
|
|
715
|
+
at
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
function failedParts(value) {
|
|
719
|
+
if (!isRecord(value))
|
|
720
|
+
return {};
|
|
721
|
+
const out = {};
|
|
722
|
+
for (const [id, raw] of Object.entries(value)) {
|
|
723
|
+
if (!id || !isRecord(raw))
|
|
724
|
+
continue;
|
|
725
|
+
const attempts = finiteOrNull(raw.attempts);
|
|
726
|
+
const firstFailedAt = finiteOrNull(raw.first_failed_at);
|
|
727
|
+
const lastFailedAt = finiteOrNull(raw.last_failed_at);
|
|
728
|
+
if (attempts == null || firstFailedAt == null || lastFailedAt == null)
|
|
729
|
+
continue;
|
|
730
|
+
out[id] = {
|
|
731
|
+
id,
|
|
732
|
+
attempts,
|
|
733
|
+
first_failed_at: firstFailedAt,
|
|
734
|
+
last_failed_at: lastFailedAt,
|
|
735
|
+
last_error: asString(raw.last_error) ?? "unknown export failure"
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
function finiteOrNull(value) {
|
|
741
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
742
|
+
}
|
|
743
|
+
function asString(value) {
|
|
744
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
745
|
+
}
|
|
746
|
+
function isRecord(value) {
|
|
747
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
748
|
+
}
|
|
749
|
+
|
|
278
750
|
// src/lib/export.ts
|
|
279
|
-
var DEFAULT_EXPORT_ROOT =
|
|
751
|
+
var DEFAULT_EXPORT_ROOT = join5(homedir2(), ".local/share/opencode-sessions-explorer");
|
|
280
752
|
var BODY_CAP_BYTES = 256 * 1024;
|
|
281
753
|
var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
|
|
282
754
|
var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
|
|
283
|
-
var
|
|
755
|
+
var INSERT_REWIND_MS = 3 * 60 * 1000;
|
|
756
|
+
var INSERT_REWIND_MAX_ROWS = 512;
|
|
757
|
+
var MAX_FAILED_ATTEMPTS = 5;
|
|
284
758
|
var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
|
|
285
759
|
function exportRoot() {
|
|
286
760
|
return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
|
|
287
761
|
}
|
|
762
|
+
function getSyncState2(root = exportRoot()) {
|
|
763
|
+
return getSyncState(root);
|
|
764
|
+
}
|
|
765
|
+
function setSyncState2(state, root = exportRoot()) {
|
|
766
|
+
setSyncState(state, root);
|
|
767
|
+
}
|
|
768
|
+
function getLastSync2(root = exportRoot()) {
|
|
769
|
+
return getLastSync(root);
|
|
770
|
+
}
|
|
288
771
|
function ensureRoot(root = exportRoot()) {
|
|
289
|
-
mkdirSync(
|
|
772
|
+
mkdirSync(join5(root, "by-session"), { recursive: true });
|
|
290
773
|
return root;
|
|
291
774
|
}
|
|
292
775
|
function channelExportComplete(root = exportRoot()) {
|
|
293
|
-
return
|
|
776
|
+
return existsSync5(join5(root, CHANNEL_COMPLETE_MARKER));
|
|
294
777
|
}
|
|
295
778
|
function markChannelExportComplete(root = exportRoot()) {
|
|
296
|
-
const p =
|
|
297
|
-
const tmp = p + ".tmp";
|
|
298
|
-
writeFileSync(tmp, String(Date.now()));
|
|
299
|
-
renameSync(tmp, p);
|
|
300
|
-
}
|
|
301
|
-
var CURSOR_SCHEMA = "v2";
|
|
302
|
-
function getLastSync(root = exportRoot()) {
|
|
303
|
-
const p = join2(root, ".last_sync");
|
|
304
|
-
if (!existsSync2(p))
|
|
305
|
-
return null;
|
|
306
|
-
try {
|
|
307
|
-
const raw = readFileSync(p, "utf8").trim();
|
|
308
|
-
if (!raw)
|
|
309
|
-
return null;
|
|
310
|
-
if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
|
|
311
|
-
const [tsStr, id] = raw.slice(CURSOR_SCHEMA.length + 1).split(":");
|
|
312
|
-
const ts = Number(tsStr);
|
|
313
|
-
if (!Number.isFinite(ts) || !id)
|
|
314
|
-
return null;
|
|
315
|
-
return { ts, id };
|
|
316
|
-
}
|
|
317
|
-
return null;
|
|
318
|
-
} catch {
|
|
319
|
-
return null;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
function setLastSync(c, root = exportRoot()) {
|
|
323
|
-
const p = join2(root, ".last_sync");
|
|
779
|
+
const p = join5(root, CHANNEL_COMPLETE_MARKER);
|
|
324
780
|
const tmp = p + ".tmp";
|
|
325
|
-
|
|
326
|
-
|
|
781
|
+
writeFileSync3(tmp, String(Date.now()));
|
|
782
|
+
renameSync2(tmp, p);
|
|
327
783
|
}
|
|
328
784
|
var sessionCache = new Map;
|
|
329
785
|
function getSession(id) {
|
|
@@ -566,46 +1022,46 @@ function writeMeta(s, dir) {
|
|
|
566
1022
|
time_updated: s.time_updated,
|
|
567
1023
|
archived: s.time_archived != null
|
|
568
1024
|
};
|
|
569
|
-
const p =
|
|
1025
|
+
const p = join5(dir, "meta.json");
|
|
570
1026
|
const tmp = p + ".tmp";
|
|
571
|
-
|
|
572
|
-
|
|
1027
|
+
writeFileSync3(tmp, JSON.stringify(meta, null, 2));
|
|
1028
|
+
renameSync2(tmp, p);
|
|
573
1029
|
}
|
|
574
1030
|
function writePartFile(dir, filename, content) {
|
|
575
|
-
const p =
|
|
576
|
-
const tmp =
|
|
577
|
-
|
|
578
|
-
|
|
1031
|
+
const p = join5(dir, filename);
|
|
1032
|
+
const tmp = join5(dir, "." + filename + ".tmp");
|
|
1033
|
+
writeFileSync3(tmp, content);
|
|
1034
|
+
renameSync2(tmp, p);
|
|
579
1035
|
const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(filename);
|
|
580
1036
|
if (!m)
|
|
581
1037
|
return;
|
|
582
1038
|
const myPartId = m[2];
|
|
583
1039
|
try {
|
|
584
|
-
for (const f of
|
|
1040
|
+
for (const f of readdirSync2(dir)) {
|
|
585
1041
|
if (f === filename || !f.endsWith(".txt") || f.startsWith("."))
|
|
586
1042
|
continue;
|
|
587
1043
|
const fm = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
|
|
588
1044
|
if (fm && fm[2] === myPartId) {
|
|
589
1045
|
try {
|
|
590
|
-
|
|
1046
|
+
unlinkSync3(join5(dir, f));
|
|
591
1047
|
} catch {}
|
|
592
1048
|
}
|
|
593
1049
|
}
|
|
594
1050
|
} catch {}
|
|
595
1051
|
}
|
|
596
|
-
function
|
|
597
|
-
return
|
|
1052
|
+
function channelDir2(root, channel, sessionId) {
|
|
1053
|
+
return join5(root, "by-channel", channel, "by-session", sessionId);
|
|
598
1054
|
}
|
|
599
1055
|
function deleteChannelPartFiles(root, sessionId, partId) {
|
|
600
1056
|
for (const ch of PART_CHANNELS) {
|
|
601
|
-
const dir =
|
|
602
|
-
if (!
|
|
1057
|
+
const dir = channelDir2(root, ch, sessionId);
|
|
1058
|
+
if (!existsSync5(dir))
|
|
603
1059
|
continue;
|
|
604
1060
|
try {
|
|
605
|
-
for (const f of
|
|
1061
|
+
for (const f of readdirSync2(dir)) {
|
|
606
1062
|
if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
|
|
607
1063
|
try {
|
|
608
|
-
|
|
1064
|
+
unlinkSync3(join5(dir, f));
|
|
609
1065
|
} catch {}
|
|
610
1066
|
}
|
|
611
1067
|
}
|
|
@@ -615,20 +1071,20 @@ function deleteChannelPartFiles(root, sessionId, partId) {
|
|
|
615
1071
|
function writeChannelFiles(root, sessionId, filename, partId, docs) {
|
|
616
1072
|
deleteChannelPartFiles(root, sessionId, partId);
|
|
617
1073
|
for (const doc of docs) {
|
|
618
|
-
const dir =
|
|
619
|
-
if (!
|
|
1074
|
+
const dir = channelDir2(root, doc.channel, sessionId);
|
|
1075
|
+
if (!existsSync5(dir))
|
|
620
1076
|
mkdirSync(dir, { recursive: true });
|
|
621
1077
|
writePartFile(dir, filename, doc.content);
|
|
622
1078
|
}
|
|
623
1079
|
}
|
|
624
1080
|
function writeSessionSummaryChannel(s, dirRoot = exportRoot()) {
|
|
625
|
-
const dir =
|
|
626
|
-
if (!
|
|
1081
|
+
const dir = channelDir2(dirRoot, "session-summary", s.id);
|
|
1082
|
+
if (!existsSync5(dir))
|
|
627
1083
|
mkdirSync(dir, { recursive: true });
|
|
628
|
-
const p =
|
|
1084
|
+
const p = join5(dir, "summary.txt");
|
|
629
1085
|
const tmp = p + ".tmp";
|
|
630
|
-
|
|
631
|
-
|
|
1086
|
+
writeFileSync3(tmp, buildSessionSummaryDocument(s));
|
|
1087
|
+
renameSync2(tmp, p);
|
|
632
1088
|
}
|
|
633
1089
|
function buildSessionSummaryDocument(s) {
|
|
634
1090
|
const firstPrompt = firstUserPrompt(s.id, "ASC");
|
|
@@ -682,7 +1138,7 @@ function getFileIndex(sessionId, dir) {
|
|
|
682
1138
|
return idx;
|
|
683
1139
|
idx = { nextSeq: 1, byPartId: new Map };
|
|
684
1140
|
try {
|
|
685
|
-
const files =
|
|
1141
|
+
const files = readdirSync2(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
|
|
686
1142
|
let max = 0;
|
|
687
1143
|
for (const f of files) {
|
|
688
1144
|
const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
|
|
@@ -703,115 +1159,326 @@ function getFileIndex(sessionId, dir) {
|
|
|
703
1159
|
}
|
|
704
1160
|
async function runExport(opts = {}) {
|
|
705
1161
|
const root = ensureRoot(opts.root ?? exportRoot());
|
|
706
|
-
const cursor = opts.fromCursor !== undefined ? opts.fromCursor : getLastSync(root);
|
|
707
1162
|
const batchSize = opts.batchSize ?? 1000;
|
|
708
|
-
const
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
1163
|
+
const progress = emptyProgress(getLastSync2(root));
|
|
1164
|
+
const lock = acquireExportLock(root);
|
|
1165
|
+
if (!lock) {
|
|
1166
|
+
progress.lock_skipped = true;
|
|
1167
|
+
return progress;
|
|
1168
|
+
}
|
|
1169
|
+
try {
|
|
1170
|
+
const state = getSyncState2(root);
|
|
1171
|
+
applyCursorOverride(state, opts.fromCursor);
|
|
1172
|
+
const start = Date.now();
|
|
1173
|
+
const touchedSessions = new Set;
|
|
1174
|
+
retryFailedParts(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
|
|
1175
|
+
runInsertFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
|
|
1176
|
+
runSessionDirtyFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
|
|
1177
|
+
refreshTouchedSessions(root, touchedSessions);
|
|
1178
|
+
if (!opts.budgetMs) {
|
|
1179
|
+
lock.heartbeat();
|
|
1180
|
+
const tombstones = reconcileTombstones(root, lock.heartbeat);
|
|
1181
|
+
applyTombstoneProgress(progress, tombstones);
|
|
1182
|
+
state.last_reconcile_at = Date.now();
|
|
1183
|
+
state.reconcile_watermark = {
|
|
1184
|
+
part_id: state.insert_cursor.id || null,
|
|
1185
|
+
session_id: state.session_cursor?.id ?? null,
|
|
1186
|
+
at: state.last_reconcile_at
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
progress.last_cursor = state.legacy_cursor;
|
|
1190
|
+
setSyncState2(state, root);
|
|
1191
|
+
} finally {
|
|
1192
|
+
lock.release();
|
|
1193
|
+
}
|
|
1194
|
+
if (opts.budgetMs && !opts.skipBackgroundReconcile) {
|
|
1195
|
+
scheduleBackgroundReconcile({ root });
|
|
1196
|
+
}
|
|
1197
|
+
return progress;
|
|
1198
|
+
}
|
|
1199
|
+
function emptyProgress(cursor) {
|
|
1200
|
+
return {
|
|
1201
|
+
exported: 0,
|
|
1202
|
+
inserts: 0,
|
|
1203
|
+
updates: 0,
|
|
1204
|
+
skipped_nontext: 0,
|
|
1205
|
+
skipped_oversize: 0,
|
|
1206
|
+
failed: 0,
|
|
1207
|
+
retried: 0,
|
|
1208
|
+
dead_lettered: 0,
|
|
1209
|
+
tombstones_removed_parts: 0,
|
|
1210
|
+
tombstones_removed_sessions: 0,
|
|
1211
|
+
lock_skipped: false,
|
|
1212
|
+
last_cursor: cursor
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
function applyCursorOverride(state, cursor) {
|
|
1216
|
+
if (cursor === undefined)
|
|
1217
|
+
return;
|
|
1218
|
+
state.legacy_cursor = cursor;
|
|
1219
|
+
state.insert_cursor.id = cursor?.id ?? "";
|
|
1220
|
+
state.session_cursor = cursor && cursor.ts > 0 ? cursor : null;
|
|
1221
|
+
state.session_dirty_hints = {};
|
|
1222
|
+
}
|
|
1223
|
+
function retryFailedParts(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
|
|
1224
|
+
const ids = Object.keys(state.failed_parts).sort().slice(0, batchSize);
|
|
1225
|
+
for (const id of ids) {
|
|
1226
|
+
if (timeExceeded(start, budgetMs))
|
|
721
1227
|
break;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1228
|
+
progress.retried++;
|
|
1229
|
+
const row = loadPartById(id);
|
|
1230
|
+
if (!row) {
|
|
1231
|
+
clearPartFailure(state, id);
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
exportPartRow(root, state, row, progress, touchedSessions);
|
|
1235
|
+
reportProgress(progress, onProgress, heartbeat);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
function runInsertFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
|
|
1239
|
+
const recentSafeRows = [];
|
|
1240
|
+
let scanCursor = state.insert_cursor.id;
|
|
1241
|
+
while (!timeExceeded(start, budgetMs)) {
|
|
1242
|
+
const rows = loadPartRowsAfterId(scanCursor, batchSize);
|
|
730
1243
|
if (rows.length === 0)
|
|
731
1244
|
break;
|
|
732
|
-
for (const
|
|
733
|
-
if (
|
|
1245
|
+
for (const row of rows) {
|
|
1246
|
+
if (timeExceeded(start, budgetMs))
|
|
1247
|
+
break;
|
|
1248
|
+
scanCursor = row.id;
|
|
1249
|
+
const safe = exportPartRow(root, state, row, progress, touchedSessions);
|
|
1250
|
+
if (safe)
|
|
1251
|
+
rememberSafeRow(recentSafeRows, row);
|
|
1252
|
+
reportProgress(progress, onProgress, heartbeat);
|
|
1253
|
+
}
|
|
1254
|
+
if (rows.length < batchSize)
|
|
1255
|
+
break;
|
|
1256
|
+
}
|
|
1257
|
+
if (recentSafeRows.length > 0) {
|
|
1258
|
+
state.insert_cursor.id = chooseInsertCursor(state.insert_cursor.id, recentSafeRows, budgetMs !== undefined);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
function runSessionDirtyFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
|
|
1262
|
+
scanDirtySessionHints(state, start, budgetMs, Math.min(batchSize, 500));
|
|
1263
|
+
for (const [sessionId, hint] of sortedDirtyHints(state.session_dirty_hints)) {
|
|
1264
|
+
while (!timeExceeded(start, budgetMs)) {
|
|
1265
|
+
const rows = loadSessionPartRows(sessionId, hint.part_cursor, batchSize);
|
|
1266
|
+
if (rows.length === 0) {
|
|
1267
|
+
delete state.session_dirty_hints[sessionId];
|
|
1268
|
+
break;
|
|
1269
|
+
}
|
|
1270
|
+
for (const row of rows) {
|
|
1271
|
+
if (timeExceeded(start, budgetMs))
|
|
1272
|
+
break;
|
|
1273
|
+
hint.part_cursor = row.id;
|
|
1274
|
+
exportPartRow(root, state, row, progress, touchedSessions);
|
|
1275
|
+
reportProgress(progress, onProgress, heartbeat);
|
|
1276
|
+
}
|
|
1277
|
+
if (rows.length < batchSize) {
|
|
1278
|
+
delete state.session_dirty_hints[sessionId];
|
|
734
1279
|
break;
|
|
735
|
-
if (r.data_bytes > SAFETY_PART_CAP_BYTES) {
|
|
736
|
-
progress.skipped_oversize++;
|
|
737
|
-
} else {
|
|
738
|
-
try {
|
|
739
|
-
const s = getSession(r.session_id);
|
|
740
|
-
if (!s) {
|
|
741
|
-
progress.failed++;
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
const built = buildPartFile(r.id, r.session_id, r.message_id, r.data, s.time_archived != null);
|
|
745
|
-
if (!built) {
|
|
746
|
-
progress.skipped_nontext++;
|
|
747
|
-
continue;
|
|
748
|
-
}
|
|
749
|
-
const channelDocs = buildChannelDocuments(r.id, r.session_id, r.message_id, r.data, s.time_archived != null, r.role ?? null, s.directory);
|
|
750
|
-
const dir = join2(root, "by-session", r.session_id);
|
|
751
|
-
if (!existsSync2(dir)) {
|
|
752
|
-
mkdirSync(dir, { recursive: true });
|
|
753
|
-
writeMeta(s, dir);
|
|
754
|
-
}
|
|
755
|
-
const idx = getFileIndex(r.session_id, dir);
|
|
756
|
-
const existing = idx.byPartId.get(r.id);
|
|
757
|
-
if (existing) {
|
|
758
|
-
writePartFile(dir, existing, built.content);
|
|
759
|
-
updates++;
|
|
760
|
-
} else {
|
|
761
|
-
const seq = idx.nextSeq++;
|
|
762
|
-
const filename2 = safePartFilename(seq, r.id);
|
|
763
|
-
writePartFile(dir, filename2, built.content);
|
|
764
|
-
idx.byPartId.set(r.id, filename2);
|
|
765
|
-
inserts++;
|
|
766
|
-
}
|
|
767
|
-
const filename = idx.byPartId.get(r.id);
|
|
768
|
-
if (filename)
|
|
769
|
-
writeChannelFiles(root, r.session_id, filename, r.id, channelDocs);
|
|
770
|
-
touchedSessions.add(r.session_id);
|
|
771
|
-
progress.exported++;
|
|
772
|
-
} catch {
|
|
773
|
-
progress.failed++;
|
|
774
|
-
}
|
|
775
1280
|
}
|
|
776
|
-
progress.last_cursor = { ts: r.time_updated, id: r.id };
|
|
777
1281
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1282
|
+
if (timeExceeded(start, budgetMs))
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
function scanDirtySessionHints(state, start, budgetMs, limit) {
|
|
1287
|
+
while (!timeExceeded(start, budgetMs)) {
|
|
1288
|
+
const rows = loadDirtySessionsAfter(state.session_cursor, limit);
|
|
1289
|
+
if (rows.length === 0)
|
|
1290
|
+
break;
|
|
1291
|
+
for (const row of rows) {
|
|
1292
|
+
state.session_dirty_hints[row.id] = { time_updated: row.time_updated, part_cursor: null };
|
|
1293
|
+
state.session_cursor = { ts: row.time_updated, id: row.id };
|
|
1294
|
+
}
|
|
1295
|
+
if (rows.length < limit)
|
|
1296
|
+
break;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
function exportPartRow(root, state, row, progress, touchedSessions) {
|
|
1300
|
+
if (row.data_bytes > SAFETY_PART_CAP_BYTES) {
|
|
1301
|
+
removeExistingPartExport(root, row.session_id, row.id);
|
|
1302
|
+
progress.skipped_oversize++;
|
|
1303
|
+
markSafeCursor(state, progress, row);
|
|
1304
|
+
clearPartFailure(state, row.id);
|
|
1305
|
+
return true;
|
|
1306
|
+
}
|
|
1307
|
+
try {
|
|
1308
|
+
const session = getSession(row.session_id);
|
|
1309
|
+
if (!session)
|
|
1310
|
+
throw new Error(`missing session ${row.session_id}`);
|
|
1311
|
+
const archived = session.time_archived != null;
|
|
1312
|
+
const built = buildPartFile(row.id, row.session_id, row.message_id, row.data, archived);
|
|
1313
|
+
if (!built) {
|
|
1314
|
+
removeExistingPartExport(root, row.session_id, row.id);
|
|
1315
|
+
progress.skipped_nontext++;
|
|
1316
|
+
markSafeCursor(state, progress, row);
|
|
1317
|
+
clearPartFailure(state, row.id);
|
|
1318
|
+
return true;
|
|
1319
|
+
}
|
|
1320
|
+
const channelDocs = buildChannelDocuments(row.id, row.session_id, row.message_id, row.data, archived, row.role, session.directory);
|
|
1321
|
+
const dir = join5(root, "by-session", row.session_id);
|
|
1322
|
+
if (!existsSync5(dir)) {
|
|
1323
|
+
mkdirSync(dir, { recursive: true });
|
|
1324
|
+
writeMeta(session, dir);
|
|
1325
|
+
}
|
|
1326
|
+
const idx = getFileIndex(row.session_id, dir);
|
|
1327
|
+
const existing = idx.byPartId.get(row.id);
|
|
1328
|
+
if (existing) {
|
|
1329
|
+
writePartFile(dir, existing, built.content);
|
|
1330
|
+
progress.updates++;
|
|
1331
|
+
} else {
|
|
1332
|
+
const filename2 = safePartFilename(idx.nextSeq++, row.id);
|
|
1333
|
+
writePartFile(dir, filename2, built.content);
|
|
1334
|
+
idx.byPartId.set(row.id, filename2);
|
|
1335
|
+
progress.inserts++;
|
|
786
1336
|
}
|
|
1337
|
+
const filename = idx.byPartId.get(row.id);
|
|
1338
|
+
if (filename)
|
|
1339
|
+
writeChannelFiles(root, row.session_id, filename, row.id, channelDocs);
|
|
1340
|
+
touchedSessions.add(row.session_id);
|
|
1341
|
+
progress.exported++;
|
|
1342
|
+
markSafeCursor(state, progress, row);
|
|
1343
|
+
clearPartFailure(state, row.id);
|
|
1344
|
+
return true;
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
progress.failed++;
|
|
1347
|
+
markPartFailure(state, row.id, errorMessage(error), progress);
|
|
1348
|
+
return false;
|
|
787
1349
|
}
|
|
1350
|
+
}
|
|
1351
|
+
function removeExistingPartExport(root, sessionId, partId) {
|
|
1352
|
+
const dir = join5(root, "by-session", sessionId);
|
|
1353
|
+
try {
|
|
1354
|
+
if (existsSync5(dir)) {
|
|
1355
|
+
const idx = getFileIndex(sessionId, dir);
|
|
1356
|
+
const existing = idx.byPartId.get(partId);
|
|
1357
|
+
if (existing)
|
|
1358
|
+
unlinkSync3(join5(dir, existing));
|
|
1359
|
+
idx.byPartId.delete(partId);
|
|
1360
|
+
}
|
|
1361
|
+
deleteChannelPartFiles(root, sessionId, partId);
|
|
1362
|
+
} catch {}
|
|
1363
|
+
}
|
|
1364
|
+
function markSafeCursor(state, progress, row) {
|
|
1365
|
+
const cursor = { ts: row.time_updated, id: row.id };
|
|
1366
|
+
state.legacy_cursor = cursor;
|
|
1367
|
+
progress.last_cursor = cursor;
|
|
1368
|
+
}
|
|
1369
|
+
function markPartFailure(state, partId, message, progress) {
|
|
1370
|
+
if (state.dead_letters[partId])
|
|
1371
|
+
return;
|
|
1372
|
+
const now = Date.now();
|
|
1373
|
+
const existing = state.failed_parts[partId];
|
|
1374
|
+
const failure = {
|
|
1375
|
+
id: partId,
|
|
1376
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
1377
|
+
first_failed_at: existing?.first_failed_at ?? now,
|
|
1378
|
+
last_failed_at: now,
|
|
1379
|
+
last_error: message
|
|
1380
|
+
};
|
|
1381
|
+
if (failure.attempts >= MAX_FAILED_ATTEMPTS) {
|
|
1382
|
+
state.dead_letters[partId] = failure;
|
|
1383
|
+
delete state.failed_parts[partId];
|
|
1384
|
+
progress.dead_lettered++;
|
|
1385
|
+
} else {
|
|
1386
|
+
state.failed_parts[partId] = failure;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
function clearPartFailure(state, partId) {
|
|
1390
|
+
delete state.failed_parts[partId];
|
|
1391
|
+
}
|
|
1392
|
+
function rememberSafeRow(rows, row) {
|
|
1393
|
+
rows.push(row);
|
|
1394
|
+
const maxRows = INSERT_REWIND_MAX_ROWS * 4;
|
|
1395
|
+
if (rows.length > maxRows)
|
|
1396
|
+
rows.splice(0, rows.length - maxRows);
|
|
1397
|
+
}
|
|
1398
|
+
function chooseInsertCursor(previousId, rows, useRewind) {
|
|
1399
|
+
const last = rows[rows.length - 1];
|
|
1400
|
+
if (!last || !useRewind || rows.length <= INSERT_REWIND_MAX_ROWS)
|
|
1401
|
+
return last?.id ?? previousId;
|
|
1402
|
+
const maxCreated = rows.reduce((max, row) => Math.max(max, row.time_created), 0);
|
|
1403
|
+
const cutoff = maxCreated - INSERT_REWIND_MS;
|
|
1404
|
+
const timeIndex = rows.findIndex((row) => row.time_created >= cutoff);
|
|
1405
|
+
const rewindIndex = Math.max(timeIndex <= 0 ? rows.length - INSERT_REWIND_MAX_ROWS : timeIndex - 1, rows.length - INSERT_REWIND_MAX_ROWS);
|
|
1406
|
+
return rows[Math.max(0, rewindIndex)]?.id ?? last.id;
|
|
1407
|
+
}
|
|
1408
|
+
function sortedDirtyHints(hints) {
|
|
1409
|
+
return Object.entries(hints).sort((a, b) => a[1].time_updated - b[1].time_updated || a[0].localeCompare(b[0]));
|
|
1410
|
+
}
|
|
1411
|
+
function refreshTouchedSessions(root, touchedSessions) {
|
|
788
1412
|
for (const sid of touchedSessions) {
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
writeMeta(fresh, dir);
|
|
798
|
-
if (fresh)
|
|
799
|
-
writeSessionSummaryChannel(fresh, root);
|
|
1413
|
+
const dir = join5(root, "by-session", sid);
|
|
1414
|
+
const fresh = stmt(`
|
|
1415
|
+
SELECT id, title, project_id, directory, agent, model, cost,
|
|
1416
|
+
time_created, time_updated, time_archived, parent_id
|
|
1417
|
+
FROM session WHERE id = ?`).get(sid);
|
|
1418
|
+
if (fresh) {
|
|
1419
|
+
writeMeta(fresh, dir);
|
|
1420
|
+
writeSessionSummaryChannel(fresh, root);
|
|
800
1421
|
}
|
|
801
1422
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
progress.
|
|
805
|
-
progress.
|
|
806
|
-
|
|
1423
|
+
}
|
|
1424
|
+
function applyTombstoneProgress(progress, tombstones) {
|
|
1425
|
+
progress.tombstones_removed_parts = tombstones.removed_parts;
|
|
1426
|
+
progress.tombstones_removed_sessions = tombstones.removed_sessions;
|
|
1427
|
+
}
|
|
1428
|
+
function loadPartById(partId) {
|
|
1429
|
+
return stmt(partSelectSql("WHERE p.id = ?")).get(partId);
|
|
1430
|
+
}
|
|
1431
|
+
function loadPartRowsAfterId(afterId, limit) {
|
|
1432
|
+
if (!afterId)
|
|
1433
|
+
return stmt(`${partSelectSql("")} ORDER BY p.id ASC LIMIT ?`).all(limit);
|
|
1434
|
+
return stmt(`${partSelectSql("WHERE p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(afterId, limit);
|
|
1435
|
+
}
|
|
1436
|
+
function loadSessionPartRows(sessionId, afterId, limit) {
|
|
1437
|
+
if (!afterId) {
|
|
1438
|
+
return stmt(`${partSelectSql("WHERE p.session_id = ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, limit);
|
|
1439
|
+
}
|
|
1440
|
+
return stmt(`${partSelectSql("WHERE p.session_id = ? AND p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, afterId, limit);
|
|
1441
|
+
}
|
|
1442
|
+
function loadDirtySessionsAfter(cursor, limit) {
|
|
1443
|
+
if (!cursor) {
|
|
1444
|
+
return stmt(`SELECT id, time_updated FROM session ORDER BY time_updated ASC, id ASC LIMIT ?`).all(limit);
|
|
1445
|
+
}
|
|
1446
|
+
return stmt(`
|
|
1447
|
+
SELECT id, time_updated
|
|
1448
|
+
FROM session
|
|
1449
|
+
WHERE (time_updated > ? OR (time_updated = ? AND id > ?))
|
|
1450
|
+
ORDER BY time_updated ASC, id ASC
|
|
1451
|
+
LIMIT ?`).all(cursor.ts, cursor.ts, cursor.id, limit);
|
|
1452
|
+
}
|
|
1453
|
+
function partSelectSql(where) {
|
|
1454
|
+
return `
|
|
1455
|
+
SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated,
|
|
1456
|
+
p.data, LENGTH(p.data) AS data_bytes,
|
|
1457
|
+
json_extract(m.data,'$.role') AS role
|
|
1458
|
+
FROM part p
|
|
1459
|
+
LEFT JOIN message m ON m.id = p.message_id
|
|
1460
|
+
${where}`;
|
|
1461
|
+
}
|
|
1462
|
+
function timeExceeded(start, budgetMs) {
|
|
1463
|
+
return budgetMs !== undefined && Date.now() - start > budgetMs;
|
|
1464
|
+
}
|
|
1465
|
+
function reportProgress(progress, onProgress, heartbeat) {
|
|
1466
|
+
heartbeat();
|
|
1467
|
+
if (!onProgress)
|
|
1468
|
+
return;
|
|
1469
|
+
const processed = progress.exported + progress.skipped_nontext + progress.skipped_oversize + progress.failed;
|
|
1470
|
+
if (processed > 0 && processed % 5000 === 0)
|
|
1471
|
+
onProgress(progress);
|
|
1472
|
+
}
|
|
1473
|
+
function errorMessage(error) {
|
|
1474
|
+
return error instanceof Error ? error.message : String(error);
|
|
807
1475
|
}
|
|
808
1476
|
|
|
809
1477
|
// src/lib/ck.ts
|
|
810
|
-
import { existsSync as
|
|
1478
|
+
import { existsSync as existsSync6, readFileSync as readFileSync3, statSync as statSync3 } from "fs";
|
|
811
1479
|
function defaultCkCandidates() {
|
|
812
1480
|
const home = process.env.HOME ?? "";
|
|
813
1481
|
const candidates = [
|
|
814
|
-
process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN,
|
|
815
1482
|
home ? `${home}/.cargo/bin/ck` : null,
|
|
816
1483
|
"/usr/local/bin/ck",
|
|
817
1484
|
"/opt/homebrew/bin/ck",
|
|
@@ -820,18 +1487,20 @@ function defaultCkCandidates() {
|
|
|
820
1487
|
return candidates;
|
|
821
1488
|
}
|
|
822
1489
|
function locateCk() {
|
|
1490
|
+
if (process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN)
|
|
1491
|
+
return process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN;
|
|
823
1492
|
for (const c of defaultCkCandidates()) {
|
|
824
|
-
if (c.startsWith("/") &&
|
|
1493
|
+
if (c.startsWith("/") && existsSync6(c))
|
|
825
1494
|
return c;
|
|
826
1495
|
}
|
|
827
1496
|
return "ck";
|
|
828
1497
|
}
|
|
829
1498
|
function ckIndexPresent(root = exportRoot()) {
|
|
830
1499
|
const manifestPath = `${root}/.ck/manifest.json`;
|
|
831
|
-
if (!
|
|
1500
|
+
if (!existsSync6(manifestPath))
|
|
832
1501
|
return { present: false, embedded_chunks: null };
|
|
833
1502
|
try {
|
|
834
|
-
const m = JSON.parse(
|
|
1503
|
+
const m = JSON.parse(readFileSync3(manifestPath, "utf8"));
|
|
835
1504
|
return { present: true, embedded_chunks: m?.totals?.embedded_chunks ?? null };
|
|
836
1505
|
} catch {
|
|
837
1506
|
return { present: true, embedded_chunks: null };
|
|
@@ -853,7 +1522,7 @@ function fail(name, detail, fix) {
|
|
|
853
1522
|
var dbPath = null;
|
|
854
1523
|
try {
|
|
855
1524
|
dbPath = locateDb();
|
|
856
|
-
pass("OpenCode DB", `${dbPath} (${(
|
|
1525
|
+
pass("OpenCode DB", `${dbPath} (${(statSync4(dbPath).size / 1024 / 1024).toFixed(1)} MB)`);
|
|
857
1526
|
} catch (e) {
|
|
858
1527
|
fail("OpenCode DB", e.message, "Set $OPENCODE_SESSIONS_EXPLORER_DB to the absolute path of opencode.db, or install OpenCode and run it at least once.");
|
|
859
1528
|
}
|
|
@@ -880,14 +1549,14 @@ if (dbPath) {
|
|
|
880
1549
|
}
|
|
881
1550
|
}
|
|
882
1551
|
var root = exportRoot();
|
|
883
|
-
if (
|
|
884
|
-
const bySession =
|
|
885
|
-
if (
|
|
886
|
-
const sessionDirs =
|
|
1552
|
+
if (existsSync7(root)) {
|
|
1553
|
+
const bySession = join6(root, "by-session");
|
|
1554
|
+
if (existsSync7(bySession)) {
|
|
1555
|
+
const sessionDirs = readdirSync3(bySession).filter((f) => f.startsWith("ses_")).length;
|
|
887
1556
|
pass("Export tree", `${root} (${sessionDirs} session dirs)`);
|
|
888
|
-
const byChannel =
|
|
889
|
-
if (
|
|
890
|
-
const channels =
|
|
1557
|
+
const byChannel = join6(root, "by-channel");
|
|
1558
|
+
if (existsSync7(byChannel)) {
|
|
1559
|
+
const channels = readdirSync3(byChannel).filter((f) => !f.startsWith(".")).length;
|
|
891
1560
|
if (channelExportComplete(root))
|
|
892
1561
|
pass("Channel views", `${channels} channel dirs (complete)`);
|
|
893
1562
|
else
|
|
@@ -907,7 +1576,7 @@ try {
|
|
|
907
1576
|
if (r.status === 0) {
|
|
908
1577
|
const ver = (r.stdout ?? "").trim();
|
|
909
1578
|
pass("ck CLI", `${ckBin} (${ver})`);
|
|
910
|
-
if (
|
|
1579
|
+
if (existsSync7(root)) {
|
|
911
1580
|
const idx = ckIndexPresent(root);
|
|
912
1581
|
if (idx.present)
|
|
913
1582
|
pass("ck index", idx.embedded_chunks != null ? `present (${idx.embedded_chunks} embedded chunks)` : "present");
|
|
@@ -924,9 +1593,9 @@ var toolOutputDir = (() => {
|
|
|
924
1593
|
if (process.env.OPENCODE_SESSIONS_EXPLORER_TOOL_OUTPUT_DIR)
|
|
925
1594
|
return process.env.OPENCODE_SESSIONS_EXPLORER_TOOL_OUTPUT_DIR;
|
|
926
1595
|
const home = process.env.HOME ?? "";
|
|
927
|
-
return
|
|
1596
|
+
return join6(home, ".local/share/opencode/tool-output");
|
|
928
1597
|
})();
|
|
929
|
-
if (
|
|
1598
|
+
if (existsSync7(toolOutputDir))
|
|
930
1599
|
pass("tool-output dir", toolOutputDir);
|
|
931
1600
|
else
|
|
932
1601
|
warn("tool-output dir", `${toolOutputDir} not yet created`, "Will be auto-created by OpenCode when needed.");
|