@moxxy/core 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +4 -1
- package/dist/run-turn.js.map +1 -1
- package/dist/sessions/persistence.d.ts +94 -10
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +356 -70
- package/dist/sessions/persistence.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +6 -0
- package/src/run-turn.test.ts +46 -0
- package/src/run-turn.ts +4 -1
- package/src/sessions/persistence.flush.test.ts +1 -1
- package/src/sessions/persistence.ready.test.ts +1 -1
- package/src/sessions/persistence.test.ts +312 -6
- package/src/sessions/persistence.ts +412 -67
|
@@ -16,24 +16,61 @@
|
|
|
16
16
|
* best-effort (lose at most the last in-flight event on a crash).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { promises as fs } from 'node:fs';
|
|
20
|
-
import * as os from 'node:os';
|
|
19
|
+
import { constants as fsConstants, promises as fs } from 'node:fs';
|
|
21
20
|
import * as path from 'node:path';
|
|
22
21
|
import { createMutex, type Mutex, type MoxxyEvent, type SessionId } from '@moxxy/sdk';
|
|
23
|
-
import { writeFileAtomic } from '@moxxy/sdk/server';
|
|
22
|
+
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
24
23
|
import type { EventLog } from '../events/log.js';
|
|
25
24
|
import { createLogger, type Logger } from '../logger.js';
|
|
26
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The channel that originated a session. Persisted into the sidecar by the
|
|
28
|
+
* runner so every surface (desktop/TUI/mobile) derives the session list from a
|
|
29
|
+
* single source instead of each keeping its own copy. `desktop`/`mobile`
|
|
30
|
+
* sessions are kept in the derived workspace list even before they have a first
|
|
31
|
+
* prompt (a brand-new chat the user just opened); empty `cli`/`tui` sidecars are
|
|
32
|
+
* dropped as noise.
|
|
33
|
+
*/
|
|
34
|
+
export type SessionSource = 'cli' | 'tui' | 'desktop' | 'mobile';
|
|
35
|
+
|
|
36
|
+
/** Schema version of the per-session metadata file (`<id>.json`). Bump when the
|
|
37
|
+
* shape changes incompatibly; readers tolerate a missing/older version. */
|
|
38
|
+
export const SESSION_META_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The single per-session metadata file: `~/.moxxy/sessions/<id>.json`.
|
|
42
|
+
*
|
|
43
|
+
* ONE file per session, the unit every surface (TUI/desktop/mobile) lists,
|
|
44
|
+
* searches and caches. The conversation itself lives in the append-only
|
|
45
|
+
* `<id>.jsonl`. Fields split by owner:
|
|
46
|
+
* - the RUNNER owns the content fields (`firstPrompt`, `eventCount`,
|
|
47
|
+
* `lastActivity`, `provider`, `model`, `startedAt`, `source`) and rewrites
|
|
48
|
+
* them on a debounce;
|
|
49
|
+
* - the UI owns `title` (a rename) and `groupId` (which desk it belongs to).
|
|
50
|
+
* Because both write the same file, the runner ADOPTS the UI fields on attach
|
|
51
|
+
* and re-merges them just before each write, so a live session never clobbers a
|
|
52
|
+
* rename or a move.
|
|
53
|
+
*/
|
|
27
54
|
export interface SessionMeta {
|
|
55
|
+
/** Schema version; absent on older files (treated as v0). */
|
|
56
|
+
readonly version?: number;
|
|
28
57
|
readonly id: string;
|
|
29
58
|
readonly cwd: string;
|
|
30
59
|
readonly startedAt: string;
|
|
31
60
|
readonly lastActivity: string;
|
|
32
61
|
readonly eventCount: number;
|
|
33
|
-
/** First 80 chars of the first user_prompt.
|
|
62
|
+
/** First 80 chars of the first user_prompt. The list/search label. */
|
|
34
63
|
readonly firstPrompt: string | null;
|
|
35
64
|
readonly provider: string | null;
|
|
36
65
|
readonly model: string | null;
|
|
66
|
+
/** Originating channel, when known. Written by the runner via `opts.source`. */
|
|
67
|
+
readonly source?: SessionSource;
|
|
68
|
+
/** Explicit workspace/desk membership (UI-owned). `null`/absent → grouped by
|
|
69
|
+
* cwd containment (CLI/TUI sessions that don't know about desks). */
|
|
70
|
+
readonly groupId?: string | null;
|
|
71
|
+
/** User-set display name, the rename (UI-owned). `null`/absent → the name is
|
|
72
|
+
* derived from `firstPrompt`. */
|
|
73
|
+
readonly title?: string | null;
|
|
37
74
|
}
|
|
38
75
|
|
|
39
76
|
export interface SessionPersistenceOpts {
|
|
@@ -45,6 +82,9 @@ export interface SessionPersistenceOpts {
|
|
|
45
82
|
readonly providerName?: string;
|
|
46
83
|
/** Currently-active model id — captured into the index for the picker. */
|
|
47
84
|
readonly modelId?: string;
|
|
85
|
+
/** Originating channel — persisted into the sidecar so the workspace list can
|
|
86
|
+
* be derived from a single source (see {@link SessionSource}). */
|
|
87
|
+
readonly source?: SessionSource;
|
|
48
88
|
/**
|
|
49
89
|
* Structured logger for persistence-degradation warnings. Defaults to a
|
|
50
90
|
* stderr JSON logger — event-log write failures are the session's source
|
|
@@ -54,7 +94,7 @@ export interface SessionPersistenceOpts {
|
|
|
54
94
|
}
|
|
55
95
|
|
|
56
96
|
export function defaultSessionsDir(): string {
|
|
57
|
-
return
|
|
97
|
+
return moxxyPath('sessions');
|
|
58
98
|
}
|
|
59
99
|
|
|
60
100
|
/**
|
|
@@ -152,6 +192,7 @@ export class SessionPersistence {
|
|
|
152
192
|
this.logPath = path.join(this.dir, `${this.id}.jsonl`);
|
|
153
193
|
const now = new Date().toISOString();
|
|
154
194
|
this.meta = {
|
|
195
|
+
version: SESSION_META_VERSION,
|
|
155
196
|
id: this.id,
|
|
156
197
|
cwd: opts.cwd,
|
|
157
198
|
startedAt: now,
|
|
@@ -160,6 +201,7 @@ export class SessionPersistence {
|
|
|
160
201
|
firstPrompt: null,
|
|
161
202
|
provider: opts.providerName ?? null,
|
|
162
203
|
model: opts.modelId ?? null,
|
|
204
|
+
...(opts.source ? { source: opts.source } : {}),
|
|
163
205
|
};
|
|
164
206
|
}
|
|
165
207
|
|
|
@@ -245,6 +287,16 @@ export class SessionPersistence {
|
|
|
245
287
|
}
|
|
246
288
|
|
|
247
289
|
private enqueueAppend(event: MoxxyEvent): void {
|
|
290
|
+
const ownedEvent = eventForSession(event, this.id);
|
|
291
|
+
if (!ownedEvent) {
|
|
292
|
+
this.logger.warn('session persistence ignored foreign-session event', {
|
|
293
|
+
path: this.logPath,
|
|
294
|
+
sessionId: this.id,
|
|
295
|
+
eventId: event.id,
|
|
296
|
+
eventSessionId: event.sessionId,
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
248
300
|
// Update in-memory meta synchronously so multiple events in the
|
|
249
301
|
// same tick share one debounced index write.
|
|
250
302
|
this.meta = {
|
|
@@ -253,10 +305,11 @@ export class SessionPersistence {
|
|
|
253
305
|
lastActivity: new Date().toISOString(),
|
|
254
306
|
firstPrompt:
|
|
255
307
|
this.meta.firstPrompt ??
|
|
256
|
-
(
|
|
308
|
+
(ownedEvent.type === 'user_prompt' ? firstPromptLabel(ownedEvent.text) : null),
|
|
309
|
+
...providerHeaderFromEvent(ownedEvent),
|
|
257
310
|
};
|
|
258
311
|
this.scheduleIndexWrite();
|
|
259
|
-
const line = JSON.stringify(
|
|
312
|
+
const line = JSON.stringify(ownedEvent) + '\n';
|
|
260
313
|
// Never propagate a write error into the listener chain — but never
|
|
261
314
|
// swallow it silently either: the JSONL is the session's source of
|
|
262
315
|
// truth, so a failing disk must at least be loud.
|
|
@@ -361,28 +414,50 @@ export class SessionPersistence {
|
|
|
361
414
|
if (!this.closed) {
|
|
362
415
|
await this.ensureReady();
|
|
363
416
|
}
|
|
364
|
-
// Write ONLY this session's
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
417
|
+
// Write ONLY this session's file (`<id>.json`), never a read-modify-write
|
|
418
|
+
// of a shared index — that loses rows when two moxxy processes update
|
|
419
|
+
// "their" row concurrently. `writeJsonAtomic` already `mkdir -p`s the dir,
|
|
420
|
+
// so this owns no ensureLogFile — the `.jsonl` is the append path's job.
|
|
421
|
+
//
|
|
422
|
+
// The UI fields (`title`, `groupId`) are owned by the rename/move path,
|
|
423
|
+
// not the runner. Re-read them just before writing so an external rename
|
|
424
|
+
// or move that landed mid-session is preserved rather than clobbered by
|
|
425
|
+
// our (possibly stale) in-memory copy. The runner owns every other field.
|
|
426
|
+
const onDisk = await readMetaSidecar(this.dir, this.meta.id);
|
|
427
|
+
const merged: SessionMeta = {
|
|
428
|
+
...this.meta,
|
|
429
|
+
title: onDisk?.title ?? this.meta.title ?? null,
|
|
430
|
+
groupId: onDisk?.groupId ?? this.meta.groupId ?? null,
|
|
431
|
+
};
|
|
432
|
+
await writeJsonAtomic(metaPath(this.dir, this.meta.id), merged);
|
|
371
433
|
} catch {
|
|
372
434
|
// Index write failures shouldn't bring down a session; the
|
|
373
435
|
// user can always re-resume by id from the filename.
|
|
374
436
|
}
|
|
375
437
|
}
|
|
376
438
|
|
|
377
|
-
/** One-time, memoized: `mkdir -p` the dir + create the empty `.jsonl
|
|
378
|
-
*
|
|
379
|
-
*
|
|
439
|
+
/** One-time, memoized: `mkdir -p` the dir + create the empty `.jsonl`, then
|
|
440
|
+
* ADOPT a pre-existing file's stable identity: `startedAt` (so the derived
|
|
441
|
+
* "created" time doesn't jump on every resume), `source` (when the caller
|
|
442
|
+
* didn't pass one), and the UI-owned `title`/`groupId` (so the first runner
|
|
443
|
+
* write doesn't drop a rename/move made while no runner was attached). On
|
|
444
|
+
* failure the latch is cleared so a later flush retries. */
|
|
380
445
|
private ensureReady(): Promise<void> {
|
|
381
446
|
if (!this.ready) {
|
|
382
447
|
this.ready = (async () => {
|
|
383
448
|
await fs.mkdir(this.dir, { recursive: true });
|
|
384
449
|
const handle = await fs.open(this.logPath, 'a');
|
|
385
450
|
await handle.close();
|
|
451
|
+
const existing = await readMetaSidecar(this.dir, this.id);
|
|
452
|
+
if (existing) {
|
|
453
|
+
this.meta = {
|
|
454
|
+
...this.meta,
|
|
455
|
+
startedAt: existing.startedAt,
|
|
456
|
+
source: this.meta.source ?? existing.source,
|
|
457
|
+
title: existing.title ?? null,
|
|
458
|
+
groupId: existing.groupId ?? null,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
386
461
|
})().catch((err) => {
|
|
387
462
|
this.ready = null;
|
|
388
463
|
throw err;
|
|
@@ -393,56 +468,85 @@ export class SessionPersistence {
|
|
|
393
468
|
}
|
|
394
469
|
|
|
395
470
|
/**
|
|
396
|
-
* Read the session index by assembling per-session
|
|
397
|
-
*
|
|
398
|
-
* `.jsonl` is missing are dropped. Sorted most-recent-activity first.
|
|
471
|
+
* Read the session index by assembling per-session metadata files (`<id>.json`),
|
|
472
|
+
* then hydrating each one's first prompt from its `.jsonl`. Sessions whose
|
|
473
|
+
* `.jsonl` is missing are dropped. Sorted most-recent-activity first. This is the
|
|
474
|
+
* heavier, jsonl-hydrated read used by `moxxy resume`; the workspace list uses
|
|
475
|
+
* the cheap {@link listSessionMetas} instead.
|
|
399
476
|
*/
|
|
400
477
|
export async function readIndex(dir = defaultSessionsDir()): Promise<SessionMeta[]> {
|
|
401
|
-
const
|
|
478
|
+
const { metas, logs } = await readSessionsDir(dir);
|
|
479
|
+
const present = metas.filter((meta) => logs.has(meta.id));
|
|
480
|
+
const hydrated = await mapWithConcurrency(present, READ_INDEX_CONCURRENCY, (meta) =>
|
|
481
|
+
hydrateMetaFirstPrompt(meta, dir),
|
|
482
|
+
);
|
|
483
|
+
return hydrated.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
484
|
+
}
|
|
402
485
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
486
|
+
async function hydrateMetaFirstPrompt(meta: SessionMeta, dir: string): Promise<SessionMeta> {
|
|
487
|
+
const stats = await matchingSessionStatsFromLog(meta.id, path.join(dir, `${meta.id}.jsonl`));
|
|
488
|
+
if (!stats) return meta;
|
|
489
|
+
if (stats.parsedEvents === 0) return meta;
|
|
490
|
+
return {
|
|
491
|
+
...meta,
|
|
492
|
+
eventCount: stats.eventCount,
|
|
493
|
+
firstPrompt: stats.firstPrompt,
|
|
494
|
+
provider: stats.provider ?? meta.provider,
|
|
495
|
+
model: stats.model ?? meta.model,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
413
498
|
|
|
414
|
-
|
|
499
|
+
async function matchingSessionStatsFromLog(
|
|
500
|
+
sessionId: string,
|
|
501
|
+
logPath: string,
|
|
502
|
+
): Promise<{
|
|
503
|
+
eventCount: number;
|
|
504
|
+
firstPrompt: string | null;
|
|
505
|
+
parsedEvents: number;
|
|
506
|
+
provider: string | null;
|
|
507
|
+
model: string | null;
|
|
508
|
+
} | null> {
|
|
509
|
+
let raw: string;
|
|
415
510
|
try {
|
|
416
|
-
|
|
511
|
+
raw = await fs.readFile(logPath, 'utf8');
|
|
417
512
|
} catch {
|
|
418
|
-
|
|
513
|
+
return null;
|
|
419
514
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
515
|
+
let eventCount = 0;
|
|
516
|
+
let parsedEvents = 0;
|
|
517
|
+
let firstPrompt: string | null = null;
|
|
518
|
+
let provider: string | null = null;
|
|
519
|
+
let model: string | null = null;
|
|
520
|
+
for (const line of raw.split('\n')) {
|
|
521
|
+
if (!line.trim()) continue;
|
|
425
522
|
try {
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
if (
|
|
523
|
+
const event = JSON.parse(line) as MoxxyEvent;
|
|
524
|
+
parsedEvents += 1;
|
|
525
|
+
if (!eventBelongsToSession(event, sessionId)) continue;
|
|
526
|
+
eventCount += 1;
|
|
527
|
+
if (firstPrompt === null && event.type === 'user_prompt') {
|
|
528
|
+
// Mirror the write-time label (SessionPersistence.enqueueAppend):
|
|
529
|
+
// code-point-aware slice + non-string coercion, so a hydrated index row
|
|
530
|
+
// matches the meta the live append wrote (no surrogate split, never null
|
|
531
|
+
// for a present prompt).
|
|
532
|
+
firstPrompt = firstPromptLabel(event.text);
|
|
533
|
+
}
|
|
534
|
+
const header = providerHeaderFromEvent(event);
|
|
535
|
+
if (header.provider !== undefined) provider = header.provider;
|
|
536
|
+
if (header.model !== undefined) model = header.model;
|
|
429
537
|
} catch {
|
|
430
|
-
//
|
|
538
|
+
// A corrupt line should not hide a later valid prompt.
|
|
431
539
|
}
|
|
432
|
-
}
|
|
540
|
+
}
|
|
541
|
+
return { eventCount, firstPrompt, parsedEvents, provider, model };
|
|
542
|
+
}
|
|
433
543
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
return false;
|
|
441
|
-
}
|
|
442
|
-
});
|
|
443
|
-
return metas
|
|
444
|
-
.filter((_, index) => checks[index])
|
|
445
|
-
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
544
|
+
function providerHeaderFromEvent(event: MoxxyEvent): { provider?: string | null; model?: string | null } {
|
|
545
|
+
if (event.type !== 'provider_request' && event.type !== 'provider_response') return {};
|
|
546
|
+
return {
|
|
547
|
+
provider: event.provider,
|
|
548
|
+
model: event.model,
|
|
549
|
+
};
|
|
446
550
|
}
|
|
447
551
|
|
|
448
552
|
/**
|
|
@@ -484,10 +588,19 @@ export async function restoreEvents(
|
|
|
484
588
|
}
|
|
485
589
|
const events: MoxxyEvent[] = [];
|
|
486
590
|
let corruptLines = 0;
|
|
591
|
+
let foreignEvents = 0;
|
|
592
|
+
let normalizedSessionIds = 0;
|
|
487
593
|
for (const line of raw.split('\n')) {
|
|
488
594
|
if (!line.trim()) continue;
|
|
489
595
|
try {
|
|
490
|
-
|
|
596
|
+
const event = JSON.parse(line) as MoxxyEvent;
|
|
597
|
+
const ownedEvent = eventForSession(event, sessionId);
|
|
598
|
+
if (ownedEvent) {
|
|
599
|
+
if (ownedEvent !== event) normalizedSessionIds += 1;
|
|
600
|
+
events.push(ownedEvent);
|
|
601
|
+
} else {
|
|
602
|
+
foreignEvents += 1;
|
|
603
|
+
}
|
|
491
604
|
} catch {
|
|
492
605
|
corruptLines += 1;
|
|
493
606
|
}
|
|
@@ -503,17 +616,37 @@ export async function restoreEvents(
|
|
|
503
616
|
}
|
|
504
617
|
}
|
|
505
618
|
|
|
506
|
-
if (corruptLines > 0 || resequenced > 0) {
|
|
507
|
-
|
|
619
|
+
if (corruptLines > 0 || resequenced > 0 || foreignEvents > 0 || normalizedSessionIds > 0) {
|
|
620
|
+
const message =
|
|
621
|
+
foreignEvents > 0
|
|
622
|
+
? 'session log restored with foreign-session events removed — re-sequenced to keep full history replayable'
|
|
623
|
+
: 'session log restored with gaps — re-sequenced to keep full history replayable';
|
|
624
|
+
logger.warn(message, {
|
|
508
625
|
sessionId,
|
|
509
626
|
path: logPath,
|
|
510
627
|
corruptLines,
|
|
628
|
+
foreignEvents,
|
|
629
|
+
normalizedSessionIds,
|
|
511
630
|
resequencedEvents: resequenced,
|
|
512
631
|
restoredEvents: events.length,
|
|
513
632
|
});
|
|
633
|
+
let canRewrite = true;
|
|
634
|
+
if (foreignEvents > 0) {
|
|
635
|
+
try {
|
|
636
|
+
await backupForeignSessionLog(logPath);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
canRewrite = false;
|
|
639
|
+
logger.warn('failed to backup foreign-session log before repair', {
|
|
640
|
+
sessionId,
|
|
641
|
+
path: logPath,
|
|
642
|
+
error: err instanceof Error ? err.message : String(err),
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
514
646
|
try {
|
|
515
|
-
// Conflict guard: only rewrite if
|
|
516
|
-
//
|
|
647
|
+
// Conflict guard: only rewrite if (a) a foreign-session backup didn't
|
|
648
|
+
// fail (`canRewrite`) and (b) the on-disk content still matches what we
|
|
649
|
+
// read. Another process (a desktop runner already attached + a CLI
|
|
517
650
|
// `resume` of the SAME id) may have appended/repaired between our read and
|
|
518
651
|
// this write; its appends are a newer snapshot than our re-sequenced one,
|
|
519
652
|
// and a blind `writeFileAtomic` would clobber them (silent history loss).
|
|
@@ -522,7 +655,9 @@ export async function restoreEvents(
|
|
|
522
655
|
// rewrite. Restore still succeeds either way — the in-memory log is
|
|
523
656
|
// already repaired; only the next clean resume would re-run the repair.
|
|
524
657
|
const current = await fs.readFile(logPath, 'utf8').catch(() => null);
|
|
525
|
-
if (
|
|
658
|
+
if (!canRewrite) {
|
|
659
|
+
/* foreign-session backup failed — skip the destructive rewrite */
|
|
660
|
+
} else if (current !== raw) {
|
|
526
661
|
logger.warn('skipped repaired-log rewrite — file changed under us (another process attached?)', {
|
|
527
662
|
sessionId,
|
|
528
663
|
path: logPath,
|
|
@@ -665,9 +800,68 @@ export function pageEvents(
|
|
|
665
800
|
}
|
|
666
801
|
|
|
667
802
|
/**
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
803
|
+
* Seed a session's event log from an external event list IFF the session has no
|
|
804
|
+
* log on disk yet. This is the migration that makes the runner's authoritative
|
|
805
|
+
* log the home of a chat whose history previously lived ONLY in a thin client's
|
|
806
|
+
* own mirror (the desktop's NDJSON chat store) — after it runs, `loadHistory`
|
|
807
|
+
* serves that chat from the runner like any other.
|
|
808
|
+
*
|
|
809
|
+
* Idempotent and NON-destructive: if `<sessionId>.jsonl` already exists AND is
|
|
810
|
+
* NON-EMPTY this is a no-op returning `false`, so a session the runner already
|
|
811
|
+
* owns is NEVER overwritten. A 0-byte log IS seeded: `persistence.attach`
|
|
812
|
+
* creates an empty `<id>.jsonl` on every spawn (even a session with zero
|
|
813
|
+
* events), so an existence-only guard would skip exactly the legacy chats this
|
|
814
|
+
* migration targets — and an empty file holds no history, so seeding over it
|
|
815
|
+
* loses nothing. Events are re-sequenced to contiguous `seq` 0..n-1 (order + ids
|
|
816
|
+
* preserved) so the seeded log satisfies {@link EventLog}'s seq invariants when
|
|
817
|
+
* it is later restored. Written temp+rename so a crash mid-seed can't leave a
|
|
818
|
+
* half-written log. Returns `true` iff it wrote the log.
|
|
819
|
+
*/
|
|
820
|
+
export async function seedSessionLog(
|
|
821
|
+
sessionId: string,
|
|
822
|
+
events: ReadonlyArray<MoxxyEvent>,
|
|
823
|
+
dir = defaultSessionsDir(),
|
|
824
|
+
): Promise<boolean> {
|
|
825
|
+
if (events.length === 0) return false;
|
|
826
|
+
const logPath = path.join(dir, `${sessionId}.jsonl`);
|
|
827
|
+
try {
|
|
828
|
+
// A non-empty log is a session the runner already owns → never overwrite.
|
|
829
|
+
// A 0-byte log is the empty file attach() left behind → seed over it.
|
|
830
|
+
if ((await fs.stat(logPath)).size > 0) return false;
|
|
831
|
+
} catch {
|
|
832
|
+
/* no log yet → seed below */
|
|
833
|
+
}
|
|
834
|
+
await fs.mkdir(dir, { recursive: true });
|
|
835
|
+
const reseq = events.map((e, i) => (e.seq === i ? e : ({ ...e, seq: i } as MoxxyEvent)));
|
|
836
|
+
await writeFileAtomic(logPath, reseq.map((e) => JSON.stringify(e) + '\n').join(''));
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function eventBelongsToSession(event: MoxxyEvent, sessionId: string): boolean {
|
|
841
|
+
const eventSessionId = (event as { sessionId?: unknown }).sessionId;
|
|
842
|
+
return eventSessionId == null || eventSessionId === sessionId;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function eventForSession(event: MoxxyEvent, sessionId: string): MoxxyEvent | null {
|
|
846
|
+
if (!eventBelongsToSession(event, sessionId)) return null;
|
|
847
|
+
if ((event as { sessionId?: unknown }).sessionId === sessionId) return event;
|
|
848
|
+
return { ...event, sessionId: sessionId as SessionId } as MoxxyEvent;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function backupForeignSessionLog(logPath: string): Promise<void> {
|
|
852
|
+
try {
|
|
853
|
+
await fs.copyFile(logPath, `${logPath}.foreign-session.bak`, fsConstants.COPYFILE_EXCL);
|
|
854
|
+
} catch (err) {
|
|
855
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return;
|
|
856
|
+
throw err;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Remove a session entirely: its event log (`<id>.jsonl`) and its single
|
|
862
|
+
* metadata file (`<id>.json`). This is the SINGLE deletion mechanism — a session
|
|
863
|
+
* exists iff its `<id>.json` does, so erasing it removes the session from every
|
|
864
|
+
* surface's derived list with no second copy to resurrect it.
|
|
671
865
|
*/
|
|
672
866
|
export async function deleteSession(
|
|
673
867
|
sessionId: string,
|
|
@@ -676,11 +870,162 @@ export async function deleteSession(
|
|
|
676
870
|
assertSafeSessionId(sessionId);
|
|
677
871
|
await fs.rm(path.join(dir, `${sessionId}.jsonl`), { force: true });
|
|
678
872
|
await fs.rm(metaPath(dir, sessionId), { force: true });
|
|
873
|
+
metaCache.delete(metaPath(dir, sessionId));
|
|
679
874
|
}
|
|
680
875
|
|
|
681
|
-
/** Per-session metadata
|
|
876
|
+
/** Per-session metadata file path: `<id>.json` (one file per session). */
|
|
682
877
|
function metaPath(dir: string, id: string): string {
|
|
683
|
-
return path.join(dir, `${id}.
|
|
878
|
+
return path.join(dir, `${id}.json`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Read one session's metadata file, or null if absent/corrupt. */
|
|
882
|
+
async function readMetaSidecar(dir: string, id: string): Promise<SessionMeta | null> {
|
|
883
|
+
try {
|
|
884
|
+
const parsed = JSON.parse(await fs.readFile(metaPath(dir, id), 'utf8')) as unknown;
|
|
885
|
+
return isSessionMeta(parsed) ? parsed : null;
|
|
886
|
+
} catch {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Patch the UI-owned fields of a session's `<id>.json` in place (read-modify-
|
|
893
|
+
* write): the rename (`title`) and move (`groupId`) paths. A live runner adopts
|
|
894
|
+
* and re-merges these on its next write, so this is safe whether or not a runner
|
|
895
|
+
* is attached. No-op if the session has no metadata file yet.
|
|
896
|
+
*/
|
|
897
|
+
async function patchSessionMeta(
|
|
898
|
+
sessionId: string,
|
|
899
|
+
patch: Partial<Pick<SessionMeta, 'title' | 'groupId'>>,
|
|
900
|
+
dir: string,
|
|
901
|
+
): Promise<void> {
|
|
902
|
+
assertSafeSessionId(sessionId);
|
|
903
|
+
const existing = await readMetaSidecar(dir, sessionId);
|
|
904
|
+
if (!existing) return;
|
|
905
|
+
await writeJsonAtomic(metaPath(dir, sessionId), { ...existing, ...patch });
|
|
906
|
+
metaCache.delete(metaPath(dir, sessionId));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Persist (or clear, when blank) a user-set session title — the rename path. */
|
|
910
|
+
export async function setSessionTitle(
|
|
911
|
+
sessionId: string,
|
|
912
|
+
title: string,
|
|
913
|
+
dir = defaultSessionsDir(),
|
|
914
|
+
): Promise<void> {
|
|
915
|
+
await patchSessionMeta(sessionId, { title: title.trim() || null }, dir);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Persist (or clear, when null) a session's explicit desk/workspace membership. */
|
|
919
|
+
export async function setSessionGroup(
|
|
920
|
+
sessionId: string,
|
|
921
|
+
groupId: string | null,
|
|
922
|
+
dir = defaultSessionsDir(),
|
|
923
|
+
): Promise<void> {
|
|
924
|
+
await patchSessionMeta(sessionId, { groupId: groupId ?? null }, dir);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Seed a session's `<id>.json` (+ empty `.jsonl`) so a freshly-created session
|
|
929
|
+
* appears in the derived workspace list IMMEDIATELY, before its runner spawns
|
|
930
|
+
* and attaches. Idempotent: if a metadata file already exists this is a no-op,
|
|
931
|
+
* so it never clobbers a live session. The runner adopts the seeded
|
|
932
|
+
* `startedAt`/`source`/`groupId`/`title` on attach.
|
|
933
|
+
*/
|
|
934
|
+
export async function seedSessionMeta(
|
|
935
|
+
sessionId: string,
|
|
936
|
+
cwd: string,
|
|
937
|
+
source: SessionSource,
|
|
938
|
+
dir = defaultSessionsDir(),
|
|
939
|
+
groupId: string | null = null,
|
|
940
|
+
): Promise<void> {
|
|
941
|
+
assertSafeSessionId(sessionId);
|
|
942
|
+
if (await readMetaSidecar(dir, sessionId)) return;
|
|
943
|
+
await fs.mkdir(dir, { recursive: true });
|
|
944
|
+
const handle = await fs.open(path.join(dir, `${sessionId}.jsonl`), 'a');
|
|
945
|
+
await handle.close();
|
|
946
|
+
const now = new Date().toISOString();
|
|
947
|
+
const meta: SessionMeta = {
|
|
948
|
+
version: SESSION_META_VERSION,
|
|
949
|
+
id: sessionId,
|
|
950
|
+
cwd,
|
|
951
|
+
startedAt: now,
|
|
952
|
+
lastActivity: now,
|
|
953
|
+
eventCount: 0,
|
|
954
|
+
firstPrompt: null,
|
|
955
|
+
provider: null,
|
|
956
|
+
model: null,
|
|
957
|
+
source,
|
|
958
|
+
groupId,
|
|
959
|
+
title: null,
|
|
960
|
+
};
|
|
961
|
+
await writeJsonAtomic(metaPath(dir, sessionId), meta);
|
|
962
|
+
metaCache.delete(metaPath(dir, sessionId));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Parse-cache for `<id>.json` files, keyed by absolute path. A file is re-parsed
|
|
967
|
+
* only when its mtime/size changes, so listing thousands of sessions on every
|
|
968
|
+
* `desks.list` costs N cheap stats plus reparses of just the CHANGED files — not
|
|
969
|
+
* N JSON parses. Cross-process safe: another process's write bumps the mtime,
|
|
970
|
+
* observed on the next stat. Per-process, bounded by the number of sessions.
|
|
971
|
+
*/
|
|
972
|
+
const metaCache = new Map<string, { mtimeMs: number; size: number; meta: SessionMeta }>();
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* One pass over the sessions dir: a single `readdir` yields BOTH the `<id>.json`
|
|
976
|
+
* metadata files and the set of `<id>` ids that have an event log, so callers
|
|
977
|
+
* test log-existence with an O(1) Set lookup instead of an `fs.access` per
|
|
978
|
+
* session. Each `<id>.json` is `stat`-checked and re-parsed only when its
|
|
979
|
+
* mtime/size changed (the parse cache), so steady-state cost is one `readdir` +
|
|
980
|
+
* N cheap `stat`s + reparses of only the CHANGED files.
|
|
981
|
+
*/
|
|
982
|
+
async function readSessionsDir(dir: string): Promise<{ metas: SessionMeta[]; logs: Set<string> }> {
|
|
983
|
+
let dirents: import('node:fs').Dirent[];
|
|
984
|
+
try {
|
|
985
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
986
|
+
} catch {
|
|
987
|
+
return { metas: [], logs: new Set() };
|
|
988
|
+
}
|
|
989
|
+
const logs = new Set<string>();
|
|
990
|
+
const files: import('node:fs').Dirent[] = [];
|
|
991
|
+
for (const d of dirents) {
|
|
992
|
+
if (!d.isFile()) continue;
|
|
993
|
+
if (d.name.endsWith('.jsonl')) logs.add(d.name.slice(0, -'.jsonl'.length));
|
|
994
|
+
else if (d.name.endsWith('.json') && d.name !== 'index.json') files.push(d);
|
|
995
|
+
}
|
|
996
|
+
const live = new Set(files.map((d) => path.join(dir, d.name)));
|
|
997
|
+
for (const key of metaCache.keys()) if (!live.has(key)) metaCache.delete(key);
|
|
998
|
+
const metas = await mapWithConcurrency(files, READ_INDEX_CONCURRENCY, async (d) => {
|
|
999
|
+
const file = path.join(dir, d.name);
|
|
1000
|
+
try {
|
|
1001
|
+
const stat = await fs.stat(file);
|
|
1002
|
+
const cached = metaCache.get(file);
|
|
1003
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
1004
|
+
return cached.meta;
|
|
1005
|
+
}
|
|
1006
|
+
const parsed = JSON.parse(await fs.readFile(file, 'utf8')) as unknown;
|
|
1007
|
+
if (!isSessionMeta(parsed)) return null;
|
|
1008
|
+
metaCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, meta: parsed });
|
|
1009
|
+
return parsed;
|
|
1010
|
+
} catch {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
return { metas: metas.filter((m): m is SessionMeta => m !== null), logs };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* List every session from its `<id>.json` (mtime-cached, no `.jsonl` re-read) —
|
|
1019
|
+
* the cheap read the workspace list/search uses on every `desks.list`. Sessions
|
|
1020
|
+
* whose `.jsonl` is gone are dropped (a deleted session never lingers). Use
|
|
1021
|
+
* {@link readIndex} when you need the jsonl-hydrated picker view. Sorted
|
|
1022
|
+
* most-recent-activity first.
|
|
1023
|
+
*/
|
|
1024
|
+
export async function listSessionMetas(dir = defaultSessionsDir()): Promise<SessionMeta[]> {
|
|
1025
|
+
const { metas, logs } = await readSessionsDir(dir);
|
|
1026
|
+
return metas
|
|
1027
|
+
.filter((meta) => logs.has(meta.id))
|
|
1028
|
+
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
684
1029
|
}
|
|
685
1030
|
|
|
686
1031
|
/**
|