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