@sunshaobei/dsh-session-manager 0.4.3-fork.1 → 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/README.md +30 -9
- package/README.zh.md +30 -9
- package/client.css +532 -97
- package/client.js +949 -84
- package/cordis.patch.yml +1 -1
- package/package.json +10 -10
- package/packages/session-trash-host/lib/index.js +842 -254
- package/packages/session-trash-host/package.json +1 -1
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* This plugin patches the existing services at init time. It is designed
|
|
16
16
|
* to be loaded via cordis.patch.yml and works with the compiled DSH packages.
|
|
17
17
|
*/
|
|
18
|
-
import { rm, readdir, rmdir, stat, readFile } from 'node:fs/promises';
|
|
18
|
+
import { rm, readdir, rmdir, stat, readFile, open as openFile } from 'node:fs/promises';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
|
|
@@ -68,6 +68,104 @@ function normalizeStoredHeader(item) {
|
|
|
68
68
|
return undefined;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Normalize one persistence.list() item to `{ header, sizeBytes }` across
|
|
73
|
+
* cohorts. `sizeBytes` is a free field of the 0.1.5+ listing snapshot;
|
|
74
|
+
* normalizeStoredHeader unwrapped it away, which is why the recycle bin used
|
|
75
|
+
* to `stat()` every log just to recover a number the header listing already
|
|
76
|
+
* carried.
|
|
77
|
+
* @param {object|undefined} item
|
|
78
|
+
* @returns {{header: object, sizeBytes: number|undefined}|undefined}
|
|
79
|
+
*/
|
|
80
|
+
function normalizeStoredSnapshot(item) {
|
|
81
|
+
if (!item) return undefined;
|
|
82
|
+
if (item.header && typeof item.header.id === 'string') {
|
|
83
|
+
const size = item.sizeBytes;
|
|
84
|
+
return { header: item.header, sizeBytes: typeof size === 'number' ? size : undefined };
|
|
85
|
+
}
|
|
86
|
+
if (typeof item.id === 'string') return { header: item, sizeBytes: undefined };
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the listing facts the recycle bin needs straight out of the Session
|
|
92
|
+
* projection cache — DSH's documented ZERO-I/O listing read
|
|
93
|
+
* (`SessionProjectionCache.cachedSnapshot`, "the zero-I/O listing read").
|
|
94
|
+
* The stock session list (`api-session-controller.projectionsFor`) reads the
|
|
95
|
+
* same faces; this is not a private API.
|
|
96
|
+
*
|
|
97
|
+
* WHY THIS REPLACES THE LOG READ (v0.4.4):
|
|
98
|
+
*
|
|
99
|
+
* Until v0.4.3 the list derived `turnCount` and a fallback title by opening
|
|
100
|
+
* every archived session's log and decoding its whole JSONL/Zstd frame
|
|
101
|
+
* chain (`readSessionEvents`). Measured on the reference store (569 MB, 474
|
|
102
|
+
* sessions, p50 416 KB compressed, max 30 MB): a 6.7 MB log (3728 frames,
|
|
103
|
+
* 21.4 MB plaintext, 6493 events) cost ~198 ms of decompress+parse, and the
|
|
104
|
+
* median band 10-19 ms — per session, per request, on the web server's own
|
|
105
|
+
* thread. Decoding the first frame alone (the header) measured ~1 ms, which
|
|
106
|
+
* is what the stock listing pays.
|
|
107
|
+
*
|
|
108
|
+
* The header carries NO title — SessionHeader has had no `title` field since
|
|
109
|
+
* 0.2 — so the projection cache is also the only correct title source: the
|
|
110
|
+
* same `title` unit DSH renders in the sidebar.
|
|
111
|
+
*
|
|
112
|
+
* TWO FACES, AND WHY BOTH ARE NEEDED:
|
|
113
|
+
*
|
|
114
|
+
* A checkpoint is bound to the Session FORMAT GENERATION that folded it.
|
|
115
|
+
* `cachedSnapshot` accepts only a record at the host's CURRENT generation
|
|
116
|
+
* (`currentLifecycleMatches`: stored formatVersion === header.version), while
|
|
117
|
+
* `cachedPredecessorTitle` accepts a strictly older one. That is not
|
|
118
|
+
* academic: on the reference store the host moved from format 3 to 4 and the
|
|
119
|
+
* cache still held 481 v3 records plus 39 with no generation, against only 4
|
|
120
|
+
* v4 ones. Reading `cachedSnapshot` alone would have rejected ~99% of the
|
|
121
|
+
* corpus and silently blanked nearly every title. The predecessor face is
|
|
122
|
+
* the one DSH ships for exactly this upgrade window.
|
|
123
|
+
*
|
|
124
|
+
* It exposes the TITLE ONLY, by design — "format normalization can change
|
|
125
|
+
* their current meaning" is the cache's own note about other predecessor
|
|
126
|
+
* projections — so a pre-upgrade `turns` count is deliberately NOT readable.
|
|
127
|
+
* A missing `turns` therefore means UNKNOWN, never zero: callers must not
|
|
128
|
+
* render it as 0.
|
|
129
|
+
*
|
|
130
|
+
* @param {object|undefined} cache ctx.sessionProjectionCache
|
|
131
|
+
* @param {object} header stored SessionHeader (the lifecycle identity witness)
|
|
132
|
+
* @returns {{title: string|undefined, turns: number|undefined}}
|
|
133
|
+
*/
|
|
134
|
+
function readCachedSessionFacts(cache, header) {
|
|
135
|
+
const facts = { title: undefined, turns: undefined };
|
|
136
|
+
if (!header || !cache) return facts;
|
|
137
|
+
|
|
138
|
+
/** Fold one viewed block in, never overwriting an already-known fact. */
|
|
139
|
+
const take = (block) => {
|
|
140
|
+
const values = block?.values;
|
|
141
|
+
if (!values || typeof values !== 'object') return;
|
|
142
|
+
if (facts.title === undefined) {
|
|
143
|
+
const title = values.title;
|
|
144
|
+
if (typeof title === 'string' && title.length > 0) facts.title = title;
|
|
145
|
+
}
|
|
146
|
+
if (facts.turns === undefined) {
|
|
147
|
+
const turns = values.sessionStats?.turns;
|
|
148
|
+
if (typeof turns === 'number' && Number.isFinite(turns)) facts.turns = turns;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (typeof cache.cachedSnapshot === 'function') {
|
|
154
|
+
take(cache.cachedSnapshot(header, ['title', 'sessionStats']));
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// A cache miss or contract drift must never fail the list.
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
if (typeof cache.cachedPredecessorTitle === 'function') {
|
|
161
|
+
take(cache.cachedPredecessorTitle(header));
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Same: the row renders without the cached facts.
|
|
165
|
+
}
|
|
166
|
+
return facts;
|
|
167
|
+
}
|
|
168
|
+
|
|
71
169
|
/**
|
|
72
170
|
* Resolve one session's physical log path across host cohorts.
|
|
73
171
|
*
|
|
@@ -339,6 +437,420 @@ function patchProjectionCacheRemove(cache) {
|
|
|
339
437
|
};
|
|
340
438
|
}
|
|
341
439
|
|
|
440
|
+
/* ------------------------------------------------------------------ */
|
|
441
|
+
/* Sub-session lineage: child enumeration and cascade deletion */
|
|
442
|
+
/* ------------------------------------------------------------------ */
|
|
443
|
+
/*
|
|
444
|
+
* A subagent session is a SIBLING directory of the session that spawned it
|
|
445
|
+
* (~/.dsh/sessions/<project>/<childId>/) and its id NEVER enters the workspace
|
|
446
|
+
* registry — record.sessionIds holds root sessions only. The parent link lives
|
|
447
|
+
* in the child's own header (`origin: "subagent"`, `parentSession`,
|
|
448
|
+
* `delegationDepth`) and nowhere else, so every fact below is derived from that
|
|
449
|
+
* header. A store that cannot be enumerated degrades to "no children" instead
|
|
450
|
+
* of failing the request: a missing badge beats a wrong one.
|
|
451
|
+
*/
|
|
452
|
+
|
|
453
|
+
/** Log names inside one session directory (legacy plain + 0.1.5 generations). */
|
|
454
|
+
const SESSION_LOG_FILE = /^session(?:\.v\d+)?\.jsonl(?:\.zstd)?$/;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Is this session's agent working right now? The ONLY liveness witness this
|
|
458
|
+
* bundle trusts: `session.lock` outlives the session that wrote it, so its
|
|
459
|
+
* presence proves nothing, while the agents registry drops an entry once the
|
|
460
|
+
* session unloads. An absent entry is a session that is simply not loaded.
|
|
461
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
462
|
+
* @param {string} sessionId
|
|
463
|
+
* @returns {boolean}
|
|
464
|
+
*/
|
|
465
|
+
function isSessionLive(ctx, sessionId) {
|
|
466
|
+
const agents = ctx.get('agents');
|
|
467
|
+
const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
|
|
468
|
+
return agent !== undefined && agent.status !== 'idle';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Keep the non-empty string ids a request body may carry. */
|
|
472
|
+
function normalizeSessionIds(value) {
|
|
473
|
+
return Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : [];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* One lineage index entry: the parent link the cascade walks, plus the header
|
|
478
|
+
* the display facts are read from.
|
|
479
|
+
* @param {object|undefined} item a listing record — the header itself, a
|
|
480
|
+
* 0.1.5 snapshot wrapping it, or a sessionQuery record whose `header` holds it.
|
|
481
|
+
* @returns {{id: string, parentSession: string|undefined, delegationDepth: number,
|
|
482
|
+
* header: object, sizeBytes: number|undefined, logPath: string|undefined}|undefined}
|
|
483
|
+
*/
|
|
484
|
+
function toLineageEntry(item) {
|
|
485
|
+
const header = item?.header && typeof item.header.id === 'string' ? item.header : item;
|
|
486
|
+
if (!header || typeof header.id !== 'string') return undefined;
|
|
487
|
+
const size = typeof item.sizeBytes === 'number' ? item.sizeBytes
|
|
488
|
+
: (typeof header.sizeBytes === 'number' ? header.sizeBytes : undefined);
|
|
489
|
+
const parentSession = typeof header.parentSession === 'string' && header.parentSession
|
|
490
|
+
? header.parentSession
|
|
491
|
+
: undefined;
|
|
492
|
+
// A session that names a parent is at least depth 1; 0 would claim it is a
|
|
493
|
+
// root session, and the panel renders this number.
|
|
494
|
+
const delegationDepth = Number.isFinite(header.delegationDepth) ? header.delegationDepth : (parentSession ? 1 : 0);
|
|
495
|
+
return {
|
|
496
|
+
id: header.id,
|
|
497
|
+
parentSession,
|
|
498
|
+
delegationDepth,
|
|
499
|
+
header,
|
|
500
|
+
sizeBytes: size,
|
|
501
|
+
// Only the physical rung knows the path up front; carrying it saves the
|
|
502
|
+
// size fallback a second resolution round.
|
|
503
|
+
logPath: typeof item.logPath === 'string' ? item.logPath : undefined,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function indexLineage(items) {
|
|
508
|
+
const entries = new Map();
|
|
509
|
+
for (const item of items) {
|
|
510
|
+
const entry = toLineageEntry(item);
|
|
511
|
+
if (entry) entries.set(entry.id, entry);
|
|
512
|
+
}
|
|
513
|
+
return entries;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The header inside an inspect()/read() result, across cohort shapes. */
|
|
517
|
+
function firstHeader(result) {
|
|
518
|
+
const candidate = result?.header ?? (Array.isArray(result?.events) ? result.events[0] : undefined);
|
|
519
|
+
return candidate && typeof candidate.id === 'string' ? candidate : undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Read one session's header record. The decode rides the existing header
|
|
524
|
+
* convention (persistence.inspect → open(id, 'read')) because a .zstd log
|
|
525
|
+
* cannot be read as text; an uncompressed log is read directly, bounded to its
|
|
526
|
+
* first line.
|
|
527
|
+
* @returns {Promise<object|undefined>} the stored header record.
|
|
528
|
+
*/
|
|
529
|
+
async function readSessionHeaderRecord(persistence, sessionId, logPath) {
|
|
530
|
+
try {
|
|
531
|
+
if (typeof persistence?.inspect === 'function') {
|
|
532
|
+
const header = firstHeader(await persistence.inspect(sessionId));
|
|
533
|
+
if (header) return header;
|
|
534
|
+
}
|
|
535
|
+
} catch { /* the handle API is the next rung */ }
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
if (typeof persistence?.open === 'function') {
|
|
539
|
+
const handle = await persistence.open(sessionId, 'read');
|
|
540
|
+
try {
|
|
541
|
+
const header = firstHeader(await handle.read());
|
|
542
|
+
if (header) return header;
|
|
543
|
+
} finally {
|
|
544
|
+
if (typeof handle?.close === 'function') await handle.close().catch(() => {});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch { /* an uncompressed log is still readable below */ }
|
|
548
|
+
|
|
549
|
+
if (logPath.endsWith('.zstd')) return undefined;
|
|
550
|
+
try {
|
|
551
|
+
const handle = await openFile(logPath, 'r');
|
|
552
|
+
try {
|
|
553
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
554
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
555
|
+
const parsed = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8').split('\n', 1)[0]);
|
|
556
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
557
|
+
} finally {
|
|
558
|
+
await handle.close();
|
|
559
|
+
}
|
|
560
|
+
} catch {
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Physical fallback rung: enumerate ~/.dsh/sessions/<project>/<sessionId>/ and
|
|
567
|
+
* read each session's first record. A directory whose header stays unreadable
|
|
568
|
+
* still ENTERS the index under its directory name — it is a session with a log
|
|
569
|
+
* on disk, and that is exactly what decides whether a child's parent is a live
|
|
570
|
+
* row or an orphan.
|
|
571
|
+
* @returns {Promise<Array|undefined>} listing items, or undefined when the
|
|
572
|
+
* session store itself cannot be listed.
|
|
573
|
+
*/
|
|
574
|
+
async function scanSessionDirectories(persistence) {
|
|
575
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
576
|
+
if (!home) return undefined;
|
|
577
|
+
const root = join(home, '.dsh', 'sessions');
|
|
578
|
+
let projects;
|
|
579
|
+
try {
|
|
580
|
+
projects = await readdir(root);
|
|
581
|
+
} catch {
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
const items = [];
|
|
585
|
+
for (const project of projects) {
|
|
586
|
+
const projectDir = join(root, project);
|
|
587
|
+
for (const sessionId of await readdir(projectDir).catch(() => [])) {
|
|
588
|
+
const sessionDir = join(projectDir, sessionId);
|
|
589
|
+
const names = await readdir(sessionDir).catch(() => []);
|
|
590
|
+
const logName = names.find((name) => SESSION_LOG_FILE.test(name));
|
|
591
|
+
if (!logName) continue;
|
|
592
|
+
const logPath = join(sessionDir, logName);
|
|
593
|
+
const read = await readSessionHeaderRecord(persistence, sessionId, logPath);
|
|
594
|
+
const header = read && typeof read.id === 'string' ? read : { id: sessionId };
|
|
595
|
+
const sizeBytes = await stat(logPath).then((s) => s.size).catch(() => undefined);
|
|
596
|
+
items.push({ header, sizeBytes, logPath });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return items;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Index every stored session by id, walking the cohort ladder:
|
|
604
|
+
* sessionQuery.listSessions() → persistence.list() → physical scan. Only a rung
|
|
605
|
+
* that THROWS is skipped; `degraded` therefore means every rung threw, and the
|
|
606
|
+
* caller reports empty groups rather than guessing.
|
|
607
|
+
* @returns {Promise<{entries: Map<string, object>, degraded: boolean}>}
|
|
608
|
+
*/
|
|
609
|
+
async function collectLineage(ctx, persistence) {
|
|
610
|
+
const query = ctx.get('sessionQuery');
|
|
611
|
+
if (typeof query?.listSessions === 'function') {
|
|
612
|
+
try {
|
|
613
|
+
const records = await query.listSessions();
|
|
614
|
+
if (Array.isArray(records)) return { entries: indexLineage(records), degraded: false };
|
|
615
|
+
} catch { /* the listing below is the next rung */ }
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const listed = await persistence?.list?.();
|
|
619
|
+
if (Array.isArray(listed)) return { entries: indexLineage(listed), degraded: false };
|
|
620
|
+
} catch { /* the physical scan below is the last rung */ }
|
|
621
|
+
const scanned = await scanSessionDirectories(persistence);
|
|
622
|
+
if (scanned) return { entries: indexLineage(scanned), degraded: false };
|
|
623
|
+
return { entries: new Map(), degraded: true };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function descendantId(value) {
|
|
627
|
+
if (typeof value === 'string') return value;
|
|
628
|
+
if (typeof value?.id === 'string') return value.id;
|
|
629
|
+
if (typeof value?.header?.id === 'string') return value.header.id;
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Every descendant id of one session, at FULL depth, excluding the session
|
|
635
|
+
* itself. sessionQuery.traceSession owns the walk when it exists (it also
|
|
636
|
+
* answers for a missing ancestor); otherwise the header index is walked
|
|
637
|
+
* recursively.
|
|
638
|
+
* @returns {Promise<string[]>}
|
|
639
|
+
*/
|
|
640
|
+
async function collectDescendantIds(ctx, persistence, sessionId) {
|
|
641
|
+
const query = ctx.get('sessionQuery');
|
|
642
|
+
if (typeof query?.traceSession === 'function') {
|
|
643
|
+
try {
|
|
644
|
+
const trace = await query.traceSession(sessionId);
|
|
645
|
+
if (Array.isArray(trace?.descendants)) {
|
|
646
|
+
const ids = trace.descendants.map(descendantId).filter((id) => id && id !== sessionId);
|
|
647
|
+
return [...new Set(ids)];
|
|
648
|
+
}
|
|
649
|
+
} catch { /* a partial lineage must not fail the delete: derive it below */ }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const { entries } = await collectLineage(ctx, persistence);
|
|
653
|
+
const childrenOf = new Map();
|
|
654
|
+
for (const entry of entries.values()) {
|
|
655
|
+
if (!entry.parentSession) continue;
|
|
656
|
+
const bucket = childrenOf.get(entry.parentSession);
|
|
657
|
+
if (bucket) bucket.push(entry.id); else childrenOf.set(entry.parentSession, [entry.id]);
|
|
658
|
+
}
|
|
659
|
+
const ids = [];
|
|
660
|
+
const seen = new Set([sessionId]);
|
|
661
|
+
const queue = [...(childrenOf.get(sessionId) ?? [])];
|
|
662
|
+
while (queue.length > 0) {
|
|
663
|
+
const id = queue.shift();
|
|
664
|
+
// A corrupt parent link that cycles back must not loop forever.
|
|
665
|
+
if (seen.has(id)) continue;
|
|
666
|
+
seen.add(id);
|
|
667
|
+
ids.push(id);
|
|
668
|
+
queue.push(...(childrenOf.get(id) ?? []));
|
|
669
|
+
}
|
|
670
|
+
return ids;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Workspace-record session ids: the second way a child's parent can resolve. */
|
|
674
|
+
function collectRegistrySessionIds(registry) {
|
|
675
|
+
const ids = new Set();
|
|
676
|
+
const entities = registry?.entities;
|
|
677
|
+
if (entities) {
|
|
678
|
+
for (const entity of entities.values()) {
|
|
679
|
+
for (const sid of entity.record?.sessionIds ?? []) ids.add(sid);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return ids;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Byte size of a session log, or undefined when no such file exists. */
|
|
686
|
+
async function logFileSize(path) {
|
|
687
|
+
if (!path) return undefined;
|
|
688
|
+
try {
|
|
689
|
+
const info = await stat(path);
|
|
690
|
+
return info.isFile() ? info.size : undefined;
|
|
691
|
+
} catch {
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The display row for one child session.
|
|
698
|
+
*
|
|
699
|
+
* WHY A SIZE FALLBACK EXISTS: `sizeBytes` is a field of the persistence
|
|
700
|
+
* listing only — the kernel's `sessionQuery.listSessions()` projects
|
|
701
|
+
* `{ header, live, persisted }` and carries no size at all — so on a host that
|
|
702
|
+
* HAS sessionQuery (the normal case) every row would otherwise report
|
|
703
|
+
* `fileSize: null` and the panel could not show a single size. The fallback
|
|
704
|
+
* measures ONLY the rows this report is about to return (never the whole
|
|
705
|
+
* store), and a session with no log stays `null` rather than claiming 0 bytes.
|
|
706
|
+
*/
|
|
707
|
+
async function childInfo(ctx, persistence, projectionCache, entry) {
|
|
708
|
+
const cached = readCachedSessionFacts(projectionCache, entry.header);
|
|
709
|
+
const headerTitle = typeof entry.header.title === 'string' && entry.header.title && entry.header.title !== entry.id
|
|
710
|
+
? entry.header.title
|
|
711
|
+
: undefined;
|
|
712
|
+
const sizeBytes = typeof entry.sizeBytes === 'number'
|
|
713
|
+
? entry.sizeBytes
|
|
714
|
+
: await logFileSize(entry.logPath ?? await resolveSessionLogPath(persistence, entry.id));
|
|
715
|
+
return {
|
|
716
|
+
id: entry.id,
|
|
717
|
+
title: headerTitle ?? cached.title ?? null,
|
|
718
|
+
fileSize: typeof sizeBytes === 'number' ? sizeBytes : null,
|
|
719
|
+
createdAt: typeof entry.header.createdAt === 'number' ? entry.header.createdAt : null,
|
|
720
|
+
delegationDepth: entry.delegationDepth,
|
|
721
|
+
live: isSessionLive(ctx, entry.id),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Build the /children report: sub-sessions grouped under the parent they name,
|
|
727
|
+
* and orphans grouped under a parent id that resolves to nothing (neither a log
|
|
728
|
+
* nor a workspace-registry session id) so the panel can render its "残壳" rows.
|
|
729
|
+
* `sessionIds` narrows the report to those parents; omitting it reports the
|
|
730
|
+
* whole store.
|
|
731
|
+
* @returns {Promise<{groups: object, orphans: object, scannedAt: number,
|
|
732
|
+
* sessionCount: number, degraded?: true}>}
|
|
733
|
+
*/
|
|
734
|
+
async function buildChildrenReport(ctx, persistence, sessionIds) {
|
|
735
|
+
const { entries, degraded } = await collectLineage(ctx, persistence);
|
|
736
|
+
const report = { groups: {}, orphans: {}, scannedAt: Date.now(), sessionCount: entries.size };
|
|
737
|
+
if (degraded) return { ...report, degraded: true };
|
|
738
|
+
|
|
739
|
+
const filter = Array.isArray(sessionIds) ? new Set(normalizeSessionIds(sessionIds)) : undefined;
|
|
740
|
+
const registryIds = collectRegistrySessionIds(ctx.workspaceRegistry);
|
|
741
|
+
const projectionCache = ctx.get('sessionProjectionCache');
|
|
742
|
+
for (const entry of entries.values()) {
|
|
743
|
+
const parentId = entry.parentSession;
|
|
744
|
+
if (!parentId) continue;
|
|
745
|
+
if (filter && !filter.has(parentId)) continue;
|
|
746
|
+
const bucket = entries.has(parentId) || registryIds.has(parentId) ? report.groups : report.orphans;
|
|
747
|
+
(bucket[parentId] ??= []).push(await childInfo(ctx, persistence, projectionCache, entry));
|
|
748
|
+
}
|
|
749
|
+
for (const bucket of [report.groups, report.orphans]) {
|
|
750
|
+
for (const list of Object.values(bucket)) {
|
|
751
|
+
list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || a.id.localeCompare(b.id));
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return report;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Drop one session's live entry from the sessions store, when it has one. */
|
|
758
|
+
function detachLiveSession(ctx, sessionId) {
|
|
759
|
+
try {
|
|
760
|
+
const sessions = ctx.get('sessions');
|
|
761
|
+
const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
|
|
762
|
+
if (session !== undefined && typeof sessions.liveEntryFor === 'function') {
|
|
763
|
+
const entry = sessions.liveEntryFor(session);
|
|
764
|
+
if (entry && typeof entry.detach === 'function') entry.detach();
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
// not live in the store — nothing to detach
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* GHOST cleanup: a session with NO log on disk must DISAPPEAR, not come back as
|
|
773
|
+
* an ungrouped row, so it is detached from every workspace record that lists it.
|
|
774
|
+
*/
|
|
775
|
+
async function detachFromWorkspaces(registry, sessionId) {
|
|
776
|
+
const entities = registry?.entities;
|
|
777
|
+
if (!entities) return;
|
|
778
|
+
for (const entity of entities.values()) {
|
|
779
|
+
if (entity.record?.sessionIds?.includes(sessionId)) {
|
|
780
|
+
try {
|
|
781
|
+
await entity.detachSession(sessionId);
|
|
782
|
+
} catch {
|
|
783
|
+
// best effort
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Remove one session's durable artifacts: its log directory, its projection
|
|
791
|
+
* cache record, its live-store entry, and — when no log existed at all — its
|
|
792
|
+
* workspace slots. Failures propagate: the cascade reports them per id instead
|
|
793
|
+
* of rolling back.
|
|
794
|
+
*/
|
|
795
|
+
async function removeSessionArtifacts(ctx, persistence, projectionCache, registry, sessionId) {
|
|
796
|
+
const logPath = await resolveSessionLogPath(persistence, sessionId);
|
|
797
|
+
if (logPath) {
|
|
798
|
+
try {
|
|
799
|
+
const sessionDir = dirname(logPath);
|
|
800
|
+
await rm(sessionDir, { recursive: true, force: true });
|
|
801
|
+
const projectDir = dirname(sessionDir);
|
|
802
|
+
try {
|
|
803
|
+
const entries = await readdir(projectDir);
|
|
804
|
+
if (entries.length === 0) await rmdir(projectDir);
|
|
805
|
+
} catch { /* best effort */ }
|
|
806
|
+
} catch (error) {
|
|
807
|
+
throw new Error(`failed to delete session log for '${sessionId}'`, { cause: error });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (typeof projectionCache?.remove === 'function') await projectionCache.remove(sessionId);
|
|
812
|
+
|
|
813
|
+
detachLiveSession(ctx, sessionId);
|
|
814
|
+
if (!logPath) await detachFromWorkspaces(registry, sessionId);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Purge rows of sessions through the registry's row-aware permanent delete.
|
|
819
|
+
* Every failure is reported per ROW and never rolled back, and a row that
|
|
820
|
+
* failed midway contributes the ids it did remove so the counts stay true.
|
|
821
|
+
* `errors` is an alias of `failed` kept for the released client builds (they
|
|
822
|
+
* read `.sessionId` and the SESSION_RUNNING `code`).
|
|
823
|
+
*/
|
|
824
|
+
async function purgeSessionRows(registry, sessionIds, excludeIds) {
|
|
825
|
+
const rows = normalizeSessionIds(sessionIds);
|
|
826
|
+
const exclude = normalizeSessionIds(excludeIds);
|
|
827
|
+
const deleted = [];
|
|
828
|
+
const kept = [];
|
|
829
|
+
const failed = [];
|
|
830
|
+
const errors = [];
|
|
831
|
+
for (const sessionId of rows) {
|
|
832
|
+
try {
|
|
833
|
+
const result = await registry.permanentlyDeleteSession(sessionId, { excludeIds: exclude });
|
|
834
|
+
deleted.push(...result.deleted);
|
|
835
|
+
kept.push(...result.kept);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
deleted.push(...(Array.isArray(error?.deleted) ? error.deleted : []));
|
|
838
|
+
const message = String(error?.message ?? error);
|
|
839
|
+
failed.push({ sessionId, message });
|
|
840
|
+
errors.push({ sessionId, code: error?.code ?? 'ERROR', message });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
deletedCount: deleted.length,
|
|
845
|
+
keptCount: kept.length,
|
|
846
|
+
deleted,
|
|
847
|
+
kept,
|
|
848
|
+
failed,
|
|
849
|
+
errors,
|
|
850
|
+
total: rows.length,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
342
854
|
/* ------------------------------------------------------------------ */
|
|
343
855
|
/* Patch: WorkspaceRegistry — unarchive / permanentlyDelete / empty */
|
|
344
856
|
/* ------------------------------------------------------------------ */
|
|
@@ -370,6 +882,13 @@ function patchWorkspaceRegistry(registry, ctx) {
|
|
|
370
882
|
// zero the turn counts. ctx.get(...) is a safe non-throwing accessor, so
|
|
371
883
|
// the remaining per-call lookups below stay dynamic on purpose.
|
|
372
884
|
const persistence = ctx.sessionPersistence;
|
|
885
|
+
// Snapshotted exactly like persistence, and for the same reason: these
|
|
886
|
+
// methods live on the SHARED registry and can outlive this fiber, so a
|
|
887
|
+
// call-time `ctx.get('sessionProjectionCache')` would degrade to
|
|
888
|
+
// `undefined` on a dead context and silently zero every turn count — the
|
|
889
|
+
// v0.3.2 production symptom, just moved into the projection read. The
|
|
890
|
+
// service is in `static inject`, so it is present while this runs.
|
|
891
|
+
const projectionCache = ctx.get('sessionProjectionCache');
|
|
373
892
|
const runInQueue = (fn) => {
|
|
374
893
|
if (typeof registry.enqueueOperation === 'function') {
|
|
375
894
|
return registry.enqueueOperation(fn);
|
|
@@ -377,165 +896,228 @@ function patchWorkspaceRegistry(registry, ctx) {
|
|
|
377
896
|
return fn();
|
|
378
897
|
};
|
|
379
898
|
|
|
899
|
+
// The registry's OWN methods, captured before this patch replaces them.
|
|
900
|
+
// Unwrapping TRASH_PATCH_ORIGINAL matters on an in-process re-mount
|
|
901
|
+
// (loader update, uninstall → install): the registry is shared, so the
|
|
902
|
+
// previous install's patch would otherwise be read back as "the original"
|
|
903
|
+
// and the patch would recurse into itself forever.
|
|
904
|
+
const currentArchive = registry.archiveSession;
|
|
905
|
+
const currentUnarchive = registry.unarchiveSession;
|
|
906
|
+
const originalArchive = currentArchive?.[TRASH_PATCH_ORIGINAL] ?? currentArchive;
|
|
907
|
+
const originalUnarchive = currentUnarchive?.[TRASH_PATCH_ORIGINAL] ?? currentUnarchive;
|
|
908
|
+
|
|
380
909
|
// ── archiveSession ────────────────────────────────────────────────
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
910
|
+
//
|
|
911
|
+
// TWO CALLERS, ONE ARGUMENT SLOT (v0.4.4):
|
|
912
|
+
//
|
|
913
|
+
// DSH calls `WorkspaceRegistry.archiveSession(sessionId, options)` — its
|
|
914
|
+
// api-workspace-controller passes `{ stopActivity: true }` or `{}`. This
|
|
915
|
+
// plugin's own /archive route passes a display TITLE string instead. The
|
|
916
|
+
// pre-0.4.4 patch treated the second argument as a title, so DSH's `{}`
|
|
917
|
+
// was truthy and got stored as one: the recycle bin then read that object
|
|
918
|
+
// back as `savedTitle`, and rendering an object as a React child throws.
|
|
919
|
+
// Any session archived from DSH's own hover button therefore broke the
|
|
920
|
+
// panel, and the `{}` entries still sit in workspace.json. Split the two
|
|
921
|
+
// shapes explicitly and never store a non-string.
|
|
922
|
+
//
|
|
923
|
+
// The pre-0.4.4 patch also REPLACED the registry method outright, silently
|
|
924
|
+
// dropping four invariants DSH maintains there: the session-existence
|
|
925
|
+
// check, the `workspace/session-activity` gate that refuses to archive a
|
|
926
|
+
// session that is still working, `stopActivity` itself (so the "stop and
|
|
927
|
+
// archive" confirmation stopped nothing), and the pin drop that keeps
|
|
928
|
+
// pinning and archival mutually exclusive. Delegate to the registry's own
|
|
929
|
+
// method and layer only the display facts on top.
|
|
930
|
+
const parseArchiveArgs = (options) => {
|
|
931
|
+
if (typeof options === 'string') return { title: options || undefined, dshOptions: {} };
|
|
932
|
+
if (options && typeof options === 'object') return { title: undefined, dshOptions: options };
|
|
933
|
+
return { title: undefined, dshOptions: {} };
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Pre-0.2 cohorts ship no registry archive API at all — this plugin is what
|
|
938
|
+
* provides one there. The write mirrors the durable shape of
|
|
939
|
+
* WorkspaceRegistry.archiveSession (archive set + dropped pin) minus the
|
|
940
|
+
* activity gate those cohorts do not expose.
|
|
941
|
+
*/
|
|
942
|
+
const archiveWithoutRegistryApi = (sessionId) => runInQueue(async () => {
|
|
943
|
+
const state = registry.requireState?.();
|
|
944
|
+
if (!state) return;
|
|
945
|
+
const currentArchived = state.archivedSessionIds ?? [];
|
|
946
|
+
if (currentArchived.includes(sessionId)) return;
|
|
947
|
+
const next = { ...state, archivedSessionIds: [...currentArchived, sessionId] };
|
|
948
|
+
if (Array.isArray(state.pinnedSessionIds)) {
|
|
949
|
+
next.pinnedSessionIds = state.pinnedSessionIds.filter((id) => id !== sessionId);
|
|
950
|
+
}
|
|
951
|
+
await registry.setState(next);
|
|
952
|
+
notifyArchivedSessionsChanged(ctx, next.archivedSessionIds);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
/** Persist the recycle bin's display facts for one archived session. */
|
|
956
|
+
const writeArchivedMeta = (sessionId, title) => runInQueue(async () => {
|
|
957
|
+
const state = registry.requireState?.();
|
|
958
|
+
if (!state) return;
|
|
959
|
+
if (!(state.archivedSessionIds ?? []).includes(sessionId)) return;
|
|
960
|
+
const current = state.archivedMeta ?? {};
|
|
961
|
+
const previous = current[sessionId];
|
|
962
|
+
const resolved = title ?? (typeof previous?.title === 'string' ? previous.title : undefined);
|
|
963
|
+
const at = typeof previous?.at === 'number' ? previous.at : Date.now();
|
|
964
|
+
const next = { ...current, [sessionId]: resolved === undefined ? { at } : { at, title: resolved } };
|
|
965
|
+
await registry.setState({ ...state, archivedMeta: next });
|
|
966
|
+
notifyArchivedSessionsChanged(ctx, state.archivedSessionIds);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
/** Drop one archived session's display facts (unarchive or purge). */
|
|
970
|
+
const dropArchivedMeta = (sessionId) => runInQueue(async () => {
|
|
971
|
+
const state = registry.requireState?.();
|
|
972
|
+
if (!state) return;
|
|
973
|
+
const current = state.archivedMeta ?? {};
|
|
974
|
+
if (!(sessionId in current)) return;
|
|
975
|
+
const next = { ...current };
|
|
976
|
+
delete next[sessionId];
|
|
977
|
+
await registry.setState({ ...state, archivedMeta: next });
|
|
978
|
+
notifyArchivedSessionsChanged(ctx, state.archivedSessionIds ?? []);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
registry.archiveSession = function archiveSession(sessionId, options) {
|
|
982
|
+
const { title, dshOptions } = parseArchiveArgs(options);
|
|
983
|
+
// The registry's own method opens its own mutation-queue slot, so this
|
|
984
|
+
// call must stay OUTSIDE runInQueue: enqueueOperation chains on the
|
|
985
|
+
// current tail, and wrapping a queued call in another queued call
|
|
986
|
+
// deadlocks the registry.
|
|
987
|
+
const archive = typeof originalArchive === 'function'
|
|
988
|
+
? originalArchive.call(registry, sessionId, dshOptions)
|
|
989
|
+
: archiveWithoutRegistryApi(sessionId);
|
|
990
|
+
return archive.then(() => writeArchivedMeta(sessionId, title));
|
|
399
991
|
};
|
|
400
992
|
|
|
401
993
|
// ── unarchiveSession ──────────────────────────────────────────────
|
|
402
994
|
registry.unarchiveSession = function unarchiveSession(sessionId) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
...state,
|
|
410
|
-
|
|
995
|
+
const unarchive = typeof originalUnarchive === 'function'
|
|
996
|
+
? originalUnarchive.call(registry, sessionId)
|
|
997
|
+
: runInQueue(async () => {
|
|
998
|
+
const state = registry.requireState?.();
|
|
999
|
+
if (!state || !(state.archivedSessionIds ?? []).includes(sessionId)) return;
|
|
1000
|
+
const nextArchived = state.archivedSessionIds.filter((id) => id !== sessionId);
|
|
1001
|
+
await registry.setState({ ...state, archivedSessionIds: nextArchived });
|
|
1002
|
+
notifyArchivedSessionsChanged(ctx, nextArchived);
|
|
411
1003
|
});
|
|
1004
|
+
// Restoring used to leave the session's stored title behind forever.
|
|
1005
|
+
return unarchive.then(() => dropArchivedMeta(sessionId));
|
|
1006
|
+
};
|
|
412
1007
|
|
|
413
|
-
|
|
1008
|
+
/** Drop the deleted ids from the archive set and its display metadata. */
|
|
1009
|
+
const purgeArchivedSlots = (sessionIds) => runInQueue(async () => {
|
|
1010
|
+
const state = registry.requireState?.();
|
|
1011
|
+
if (!state) return false;
|
|
1012
|
+
const archived = state.archivedSessionIds ?? [];
|
|
1013
|
+
const removing = sessionIds.filter((id) => archived.includes(id));
|
|
1014
|
+
if (removing.length === 0) return false;
|
|
1015
|
+
// Drop the display facts in the SAME write: a purged session leaves no row
|
|
1016
|
+
// to render, and its archivedMeta entry would otherwise sit in the durable
|
|
1017
|
+
// record forever.
|
|
1018
|
+
const nextMeta = { ...(state.archivedMeta ?? {}) };
|
|
1019
|
+
for (const id of removing) delete nextMeta[id];
|
|
1020
|
+
await registry.setState({
|
|
1021
|
+
...state,
|
|
1022
|
+
archivedSessionIds: archived.filter((id) => !removing.includes(id)),
|
|
1023
|
+
archivedMeta: nextMeta,
|
|
414
1024
|
});
|
|
415
|
-
|
|
1025
|
+
return true;
|
|
1026
|
+
});
|
|
416
1027
|
|
|
417
1028
|
// ── permanentlyDeleteSession ──────────────────────────────────────
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
1029
|
+
//
|
|
1030
|
+
// A ROW, NOT AN ID (v0.5.0): a subagent session is a SIBLING directory of
|
|
1031
|
+
// the session that spawned it and never appears in the workspace registry
|
|
1032
|
+
// (record.sessionIds holds root sessions only), so deleting the parent alone
|
|
1033
|
+
// used to leave its whole child tree behind on disk. One row is therefore
|
|
1034
|
+
// "the session + ALL its descendants, minus excludeIds".
|
|
1035
|
+
//
|
|
1036
|
+
// A live descendant refuses the WHOLE row before a single file is removed,
|
|
1037
|
+
// and excluding a running child does NOT rescue the row: the store must
|
|
1038
|
+
// never be mutated underneath a running agent.
|
|
1039
|
+
//
|
|
1040
|
+
// @param {string} sessionId the row's primary session.
|
|
1041
|
+
// @param {{excludeIds?: string[]}} [options] descendants to spare.
|
|
1042
|
+
// @returns {Promise<{deleted: string[], kept: string[]}>}
|
|
1043
|
+
registry.permanentlyDeleteSession = async function permanentlyDeleteSession(sessionId, options) {
|
|
1044
|
+
// The proven published-plugin liveness check: only an ACTIVE agent blocks
|
|
1045
|
+
// deletion, and it names the offending session.
|
|
1046
|
+
if (isSessionLive(ctx, sessionId)) {
|
|
424
1047
|
const err = new Error(`会话正在运行中,请等待结束后再删除`);
|
|
425
1048
|
err.code = 'SESSION_RUNNING';
|
|
426
1049
|
throw err;
|
|
427
1050
|
}
|
|
428
1051
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
// the workspace registry whose log was never materialized or already
|
|
432
|
-
// removed), `logFound` stays false.
|
|
433
|
-
let logFound = false;
|
|
434
|
-
try {
|
|
435
|
-
const logPath = await resolveSessionLogPath(persistence, sessionId);
|
|
436
|
-
if (logPath) {
|
|
437
|
-
logFound = true;
|
|
438
|
-
const sessionDir = dirname(logPath);
|
|
439
|
-
await rm(sessionDir, { recursive: true, force: true });
|
|
440
|
-
const projectDir = dirname(sessionDir);
|
|
441
|
-
try {
|
|
442
|
-
const entries = await readdir(projectDir);
|
|
443
|
-
if (entries.length === 0) await rmdir(projectDir);
|
|
444
|
-
} catch { /* best effort */ }
|
|
445
|
-
}
|
|
446
|
-
} catch (error) {
|
|
447
|
-
throw new Error(`failed to delete session log for '${sessionId}'`, { cause: error });
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const cache = ctx.get('sessionProjectionCache');
|
|
451
|
-
if (cache && typeof cache.remove === 'function') {
|
|
452
|
-
await cache.remove(sessionId);
|
|
453
|
-
}
|
|
1052
|
+
const exclude = new Set(normalizeSessionIds(options?.excludeIds));
|
|
1053
|
+
const descendants = await collectDescendantIds(ctx, persistence, sessionId);
|
|
454
1054
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
|
|
462
|
-
if (session !== undefined && typeof sessions.liveEntryFor === 'function') {
|
|
463
|
-
const entry = sessions.liveEntryFor(session);
|
|
464
|
-
if (entry && typeof entry.detach === 'function') {
|
|
465
|
-
entry.detach();
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
} catch {
|
|
469
|
-
// not live in the store — nothing to detach
|
|
1055
|
+
const running = descendants.filter((id) => isSessionLive(ctx, id)).sort();
|
|
1056
|
+
if (running.length > 0) {
|
|
1057
|
+
const err = new Error(`会话正在运行中,请等待结束后再删除(运行中的子会话:${running.join('、')})`);
|
|
1058
|
+
err.code = 'SESSION_RUNNING';
|
|
1059
|
+
err.runningSessionIds = running;
|
|
1060
|
+
throw err;
|
|
470
1061
|
}
|
|
471
1062
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (!state) return;
|
|
475
|
-
|
|
476
|
-
let changed = false;
|
|
477
|
-
if (state.archivedSessionIds.includes(sessionId)) {
|
|
478
|
-
const newArchived = state.archivedSessionIds.filter((id) => id !== sessionId);
|
|
479
|
-
await registry.setState({
|
|
480
|
-
...state,
|
|
481
|
-
archivedSessionIds: newArchived,
|
|
482
|
-
});
|
|
483
|
-
changed = true;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// GHOST-session cleanup: only when no physical log exists should the
|
|
487
|
-
// session be detached from every workspace record — it must DISAPPEAR,
|
|
488
|
-
// not be "restored". A session WITH a log keeps its workspace slot (the
|
|
489
|
-
// deleted log makes it invisible anyway, and detaching a still-listed
|
|
490
|
-
// session would surface it under an ungrouped section).
|
|
491
|
-
if (!logFound) {
|
|
492
|
-
const entities = registry.entities;
|
|
493
|
-
if (entities) {
|
|
494
|
-
for (const entity of entities.values()) {
|
|
495
|
-
if (entity.record?.sessionIds?.includes(sessionId)) {
|
|
496
|
-
try {
|
|
497
|
-
await entity.detachSession(sessionId);
|
|
498
|
-
} catch {
|
|
499
|
-
// best effort
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (changed) {
|
|
507
|
-
const archived = registry.requireState?.()?.archivedSessionIds ?? [];
|
|
508
|
-
notifyArchivedSessionsChanged(ctx, archived);
|
|
509
|
-
}
|
|
510
|
-
});
|
|
511
|
-
};
|
|
512
|
-
|
|
513
|
-
// ── emptyArchivedSessions ─────────────────────────────────────────
|
|
514
|
-
registry.emptyArchivedSessions = async function emptyArchivedSessions() {
|
|
515
|
-
const state = registry.requireState?.();
|
|
516
|
-
if (!state || state.archivedSessionIds.length === 0) return { deletedCount: 0 };
|
|
1063
|
+
const targets = [sessionId, ...descendants.filter((id) => !exclude.has(id))];
|
|
1064
|
+
const kept = descendants.filter((id) => exclude.has(id));
|
|
517
1065
|
|
|
518
|
-
const
|
|
519
|
-
let
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
for (const sessionId of archivedIds) {
|
|
1066
|
+
const deleted = [];
|
|
1067
|
+
let failure;
|
|
1068
|
+
for (const id of targets) {
|
|
523
1069
|
try {
|
|
524
|
-
await registry
|
|
525
|
-
deletedCount++;
|
|
1070
|
+
await removeSessionArtifacts(ctx, persistence, projectionCache, registry, id);
|
|
526
1071
|
} catch (error) {
|
|
527
|
-
|
|
1072
|
+
// NO ROLLBACK: the ids already removed stay removed, and the caller is
|
|
1073
|
+
// handed exactly which ones they were so its counts stay true.
|
|
1074
|
+
error.deleted = deleted;
|
|
1075
|
+
failure = error;
|
|
1076
|
+
break;
|
|
528
1077
|
}
|
|
1078
|
+
deleted.push(id);
|
|
529
1079
|
}
|
|
530
1080
|
|
|
531
|
-
if (
|
|
532
|
-
|
|
1081
|
+
if (await purgeArchivedSlots(deleted)) {
|
|
1082
|
+
notifyArchivedSessionsChanged(ctx, registry.requireState?.()?.archivedSessionIds ?? []);
|
|
533
1083
|
}
|
|
1084
|
+
if (failure) throw failure;
|
|
1085
|
+
return { deleted, kept };
|
|
1086
|
+
};
|
|
534
1087
|
|
|
535
|
-
|
|
1088
|
+
// ── emptyArchivedSessions ─────────────────────────────────────────
|
|
1089
|
+
registry.emptyArchivedSessions = async function emptyArchivedSessions(options) {
|
|
1090
|
+
const archivedIds = [...(registry.requireState?.()?.archivedSessionIds ?? [])];
|
|
1091
|
+
// Every row cascades to its sub-sessions exactly like /purge: emptying the
|
|
1092
|
+
// recycle bin must not leave orphaned child directories behind.
|
|
1093
|
+
const result = await purgeSessionRows(registry, archivedIds, normalizeSessionIds(options?.excludeIds));
|
|
1094
|
+
|
|
1095
|
+
if (result.deletedCount > 0) {
|
|
1096
|
+
notifyArchivedSessionsChanged(ctx, registry.requireState?.()?.archivedSessionIds ?? []);
|
|
1097
|
+
}
|
|
1098
|
+
if (result.failed.length > 0 && result.deletedCount === 0) {
|
|
1099
|
+
throw new Error(`failed to empty recycle bin: ${result.failed[0].message}`);
|
|
1100
|
+
}
|
|
1101
|
+
return result;
|
|
536
1102
|
};
|
|
537
1103
|
|
|
538
1104
|
// ── listArchivedSessions ──────────────────────────────────────────
|
|
1105
|
+
//
|
|
1106
|
+
// ZERO LOG I/O (v0.4.4). Until v0.4.3 every row opened its session log and
|
|
1107
|
+
// decoded the entire Zstd frame chain just to count `turn/start` events and
|
|
1108
|
+
// to derive a title from the first user message. On the reference store
|
|
1109
|
+
// that meant ~0.2 s of header listing plus 10-198 ms PER archived session,
|
|
1110
|
+
// on the web server's own thread, on every open and on every archive /
|
|
1111
|
+
// unarchive / purge broadcast. Every fact the panel shows now comes from
|
|
1112
|
+
// durable state or the projection cache:
|
|
1113
|
+
//
|
|
1114
|
+
// title archivedMeta.title → projection-cache `title` → legacy
|
|
1115
|
+
// archivedTitles (string entries only) → '未命名会话'
|
|
1116
|
+
// turnCount projection-cache `sessionStats.turns`
|
|
1117
|
+
// fileSize the header listing's own sizeBytes
|
|
1118
|
+
// archivedAt archivedMeta.at — the REAL archive time. The old value was
|
|
1119
|
+
// the log's mtime, i.e. last ACTIVITY, which the panel
|
|
1120
|
+
// labelled "最近删除" and sorted by.
|
|
539
1121
|
registry.listArchivedSessions = async function listArchivedSessions() {
|
|
540
1122
|
const state = registry.requireState?.();
|
|
541
1123
|
if (!state) return [];
|
|
@@ -543,42 +1125,18 @@ function patchWorkspaceRegistry(registry, ctx) {
|
|
|
543
1125
|
const archivedIds = state.archivedSessionIds ?? [];
|
|
544
1126
|
if (archivedIds.length === 0) return [];
|
|
545
1127
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (!raw) return '';
|
|
549
|
-
// content 是 ContentBlock[],每个 block 有 type 和 text 字段
|
|
550
|
-
if (Array.isArray(raw)) {
|
|
551
|
-
return raw
|
|
552
|
-
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
553
|
-
.map((part) => part.text)
|
|
554
|
-
.filter(Boolean)
|
|
555
|
-
.join('\n')
|
|
556
|
-
.trim();
|
|
557
|
-
}
|
|
558
|
-
if (typeof raw !== 'string') return '';
|
|
559
|
-
let str = raw;
|
|
560
|
-
// 提取 <USER_REQUEST> 内的内容(用户真实输入)
|
|
561
|
-
const userReqMatch = str.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/i);
|
|
562
|
-
if (userReqMatch) return userReqMatch[1].trim();
|
|
563
|
-
// 去掉已知的 XML 标签
|
|
564
|
-
str = str.replace(/<ADDITIONAL_METADATA>[\s\S]*?<\/ADDITIONAL_METADATA>/gi, '');
|
|
565
|
-
str = str.replace(/<USER_SETTINGS_CHANGE>[\s\S]*?<\/USER_SETTINGS_CHANGE>/gi, '');
|
|
566
|
-
str = str.replace(/<SKILL>[\s\S]*?<\/SKILL>/gi, '');
|
|
567
|
-
str = str.replace(/<SYSTEM_CONTEXT>[\s\S]*?<\/SYSTEM_CONTEXT>/gi, '');
|
|
568
|
-
str = str.replace(/<WORKSPACE>[\s\S]*?<\/WORKSPACE>/gi, '');
|
|
569
|
-
// 去掉所有剩余的 HTML/XML 标签
|
|
570
|
-
str = str.replace(/<[^>]+>/g, '');
|
|
571
|
-
return str.trim();
|
|
572
|
-
}
|
|
1128
|
+
const archivedMeta = state.archivedMeta ?? {};
|
|
1129
|
+
const legacyTitles = state.archivedTitles ?? {};
|
|
573
1130
|
|
|
574
|
-
|
|
1131
|
+
// ONE listing for the whole set. Its snapshot also carries sizeBytes —
|
|
1132
|
+
// the field the panel used to stat() every log to recover.
|
|
1133
|
+
let snapshots = [];
|
|
575
1134
|
try {
|
|
576
|
-
|
|
1135
|
+
snapshots = ((await persistence?.list?.()) ?? []).map(normalizeStoredSnapshot).filter(Boolean);
|
|
577
1136
|
} catch {
|
|
578
|
-
//
|
|
1137
|
+
// Listing failed: every row still renders from state alone.
|
|
579
1138
|
}
|
|
580
|
-
|
|
581
|
-
const byId = new Map(headers.map((h) => [h.id, h]));
|
|
1139
|
+
const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
|
|
582
1140
|
|
|
583
1141
|
const workspaceBySession = new Map();
|
|
584
1142
|
const entities = registry.entities;
|
|
@@ -595,62 +1153,56 @@ function patchWorkspaceRegistry(registry, ctx) {
|
|
|
595
1153
|
}
|
|
596
1154
|
}
|
|
597
1155
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
archivedAt,
|
|
649
|
-
fileSize,
|
|
650
|
-
turnCount,
|
|
651
|
-
};
|
|
652
|
-
})
|
|
653
|
-
);
|
|
1156
|
+
// WHY ONE PATH RESOLUTION PER ROW IS BOUNDED: the hover card shows where a
|
|
1157
|
+
// session actually LIVES, and that path is only knowable through the same
|
|
1158
|
+
// resolver ladder permanentlyDeleteSession already walks per session —
|
|
1159
|
+
// findLog (a service query), locate() (pure path computation, no I/O) and,
|
|
1160
|
+
// only when neither answers, a directory scan. It never decodes a log, so
|
|
1161
|
+
// the listing still OPENS nothing (inspect/open stay at zero) and the
|
|
1162
|
+
// store-wide header listing above is still the ONE listing.
|
|
1163
|
+
//
|
|
1164
|
+
// findLog may name a location whose file is already gone (documented cohort
|
|
1165
|
+
// behaviour), while this field promises a path ON DISK: one fileExists
|
|
1166
|
+
// stat — the predicate locate() itself goes through — settles that, and a
|
|
1167
|
+
// session with no log at all reports null instead of a stale location.
|
|
1168
|
+
const items = await Promise.all(archivedIds.map(async (sessionId) => {
|
|
1169
|
+
const snapshot = byId.get(sessionId);
|
|
1170
|
+
const header = snapshot?.header;
|
|
1171
|
+
const ws = workspaceBySession.get(sessionId);
|
|
1172
|
+
const meta = archivedMeta[sessionId];
|
|
1173
|
+
const cached = readCachedSessionFacts(projectionCache, header);
|
|
1174
|
+
// `archivedTitles` predates archivedMeta and is also where the pre-0.4.4
|
|
1175
|
+
// options-object bug parked its `{}` values: only strings are titles.
|
|
1176
|
+
const legacyTitle = typeof legacyTitles[sessionId] === 'string' ? legacyTitles[sessionId] : undefined;
|
|
1177
|
+
|
|
1178
|
+
const title = meta?.title
|
|
1179
|
+
|| cached.title
|
|
1180
|
+
|| legacyTitle
|
|
1181
|
+
|| (header?.title && header.title !== sessionId ? header.title : undefined)
|
|
1182
|
+
|| '未命名会话';
|
|
1183
|
+
|
|
1184
|
+
const resolvedLogPath = await resolveSessionLogPath(persistence, sessionId);
|
|
1185
|
+
const logPath = resolvedLogPath && (await fileExists(resolvedLogPath)) ? resolvedLogPath : null;
|
|
1186
|
+
|
|
1187
|
+
return {
|
|
1188
|
+
sessionId,
|
|
1189
|
+
title,
|
|
1190
|
+
cwd: header?.cwd,
|
|
1191
|
+
workspacePath: ws?.workspacePath || ws?.cwd,
|
|
1192
|
+
workspaceTitle: ws?.workspaceTitle,
|
|
1193
|
+
// Rows archived before archivedMeta existed fall back to the header's
|
|
1194
|
+
// createdAt: stable and monotonic without touching the disk.
|
|
1195
|
+
archivedAt: typeof meta?.at === 'number' ? meta.at : (header?.createdAt ?? 0),
|
|
1196
|
+
fileSize: snapshot?.sizeBytes ?? 0,
|
|
1197
|
+
// null, NOT 0, when the cache cannot serve a current-generation
|
|
1198
|
+
// `sessionStats` row: an unknown count and a zero count are different
|
|
1199
|
+
// facts, and the panel must not print "0 轮对话" over a long session
|
|
1200
|
+
// whose checkpoint predates a format upgrade. The client omits the
|
|
1201
|
+
// chip for null.
|
|
1202
|
+
turnCount: typeof cached.turns === 'number' ? cached.turns : null,
|
|
1203
|
+
logPath,
|
|
1204
|
+
};
|
|
1205
|
+
}));
|
|
654
1206
|
|
|
655
1207
|
// 按工作区顺序排列(与左侧 sidebar 一致),每个工作区内按时间降序排列
|
|
656
1208
|
const workspaceOrder = [];
|
|
@@ -690,9 +1242,18 @@ function patchWorkspaceRegistry(registry, ctx) {
|
|
|
690
1242
|
'emptyArchivedSessions',
|
|
691
1243
|
'listArchivedSessions',
|
|
692
1244
|
];
|
|
1245
|
+
// The two patched registry methods ride ON TOP of the registry's own
|
|
1246
|
+
// implementations, so a later install must be able to unwrap them.
|
|
1247
|
+
const ORIGINALS = {
|
|
1248
|
+
archiveSession: originalArchive,
|
|
1249
|
+
unarchiveSession: originalUnarchive,
|
|
1250
|
+
};
|
|
693
1251
|
for (const name of INSTALLED) {
|
|
694
1252
|
if (typeof registry[name] === 'function') {
|
|
695
1253
|
Object.defineProperty(registry[name], TRASH_PATCH, { value: token });
|
|
1254
|
+
if (typeof ORIGINALS[name] === 'function') {
|
|
1255
|
+
Object.defineProperty(registry[name], TRASH_PATCH_ORIGINAL, { value: ORIGINALS[name] });
|
|
1256
|
+
}
|
|
696
1257
|
}
|
|
697
1258
|
}
|
|
698
1259
|
return () => {
|
|
@@ -733,6 +1294,31 @@ function httpError(statusCode, code, message) {
|
|
|
733
1294
|
return Object.assign(new Error(message), { statusCode, code });
|
|
734
1295
|
}
|
|
735
1296
|
|
|
1297
|
+
/**
|
|
1298
|
+
* Translate a `WorkspaceRegistry.archiveSession` refusal into an HTTP error.
|
|
1299
|
+
*
|
|
1300
|
+
* The registry owns two checks this plugin no longer shadows: the
|
|
1301
|
+
* session-existence check and the `workspace/session-activity` gate, which
|
|
1302
|
+
* refuses to archive a session that any provider still reports as working.
|
|
1303
|
+
* Both are ordinary refusals, so without this mapping they would reach the
|
|
1304
|
+
* browser as an opaque 500.
|
|
1305
|
+
*
|
|
1306
|
+
* Matching on `name` (not `instanceof`) is deliberate: the registry classes
|
|
1307
|
+
* are not a dependency of this bundle, and both set `this.name` explicitly.
|
|
1308
|
+
*
|
|
1309
|
+
* @param {unknown} error - the thrown registry refusal.
|
|
1310
|
+
* @returns {Error} an httpError, or the original error when unrecognized.
|
|
1311
|
+
*/
|
|
1312
|
+
function mapArchiveRefusal(error) {
|
|
1313
|
+
if (error?.name === 'WorkspaceActiveSessionError') {
|
|
1314
|
+
return httpError(409, 'SESSION_RUNNING', '会话正在运行中,请先停止后再移入回收站');
|
|
1315
|
+
}
|
|
1316
|
+
if (error?.name === 'WorkspaceUnknownSessionError') {
|
|
1317
|
+
return httpError(404, 'UNKNOWN_SESSION', String(error?.message ?? error));
|
|
1318
|
+
}
|
|
1319
|
+
return error;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
736
1322
|
function sendJson(res, status, body) {
|
|
737
1323
|
res.writeHead(status, {
|
|
738
1324
|
'content-type': 'application/json; charset=utf-8',
|
|
@@ -877,7 +1463,14 @@ function registerRoutes(ctx) {
|
|
|
877
1463
|
if (agent !== undefined && agent.status !== 'idle') {
|
|
878
1464
|
throw httpError(400, 'SESSION_RUNNING', '会话正在运行中,无法删除');
|
|
879
1465
|
}
|
|
880
|
-
|
|
1466
|
+
try {
|
|
1467
|
+
// Only a string may become the durable display title: the registry
|
|
1468
|
+
// itself passes an OPTIONS object in this argument slot, and storing
|
|
1469
|
+
// that object is what corrupted archivedTitles before v0.4.4.
|
|
1470
|
+
await registry?.archiveSession?.(sessionId, typeof title === 'string' ? title : undefined);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
throw mapArchiveRefusal(error);
|
|
1473
|
+
}
|
|
881
1474
|
ok(res, { sessionId, archivedSessionIds: broadcastArchived() });
|
|
882
1475
|
}),
|
|
883
1476
|
},
|
|
@@ -893,27 +1486,31 @@ function registerRoutes(ctx) {
|
|
|
893
1486
|
ok(res, { sessionId, archivedSessionIds: broadcastArchived() });
|
|
894
1487
|
}),
|
|
895
1488
|
},
|
|
896
|
-
// POST
|
|
1489
|
+
// POST children { sessionIds? } — the sub-session rows of the given parent
|
|
1490
|
+
// sessions (the whole store when the list is omitted): children whose
|
|
1491
|
+
// parent resolves, plus the orphans whose parent id resolves to nothing.
|
|
897
1492
|
{
|
|
898
1493
|
kind: 'exact',
|
|
899
|
-
path: `${ROUTE_PREFIX}/
|
|
1494
|
+
path: `${ROUTE_PREFIX}/children`,
|
|
900
1495
|
handler: guard(async (req, res) => {
|
|
901
1496
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
902
1497
|
const { sessionIds } = await readBody(req);
|
|
903
|
-
|
|
1498
|
+
ok(res, await buildChildrenReport(ctx, persistence, sessionIds));
|
|
1499
|
+
}),
|
|
1500
|
+
},
|
|
1501
|
+
// POST purge { sessionIds: [], excludeIds?: [] } — permanently delete the
|
|
1502
|
+
// given sessions AND their full-depth sub-session trees.
|
|
1503
|
+
{
|
|
1504
|
+
kind: 'exact',
|
|
1505
|
+
path: `${ROUTE_PREFIX}/purge`,
|
|
1506
|
+
handler: guard(async (req, res) => {
|
|
1507
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1508
|
+
const { sessionIds, excludeIds } = await readBody(req);
|
|
1509
|
+
const ids = normalizeSessionIds(sessionIds);
|
|
904
1510
|
if (ids.length === 0) throw httpError(400, 'BAD_REQUEST', 'sessionIds[] is required');
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
try {
|
|
909
|
-
await registry?.permanentlyDeleteSession?.(sessionId);
|
|
910
|
-
deleted.push(sessionId);
|
|
911
|
-
} catch (error) {
|
|
912
|
-
errors.push({ sessionId, code: error?.code ?? 'ERROR', message: error?.message ?? String(error) });
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
if (deleted.length > 0) broadcastArchived();
|
|
916
|
-
ok(res, { deletedCount: deleted.length, total: ids.length, errors });
|
|
1511
|
+
const result = await purgeSessionRows(registry, ids, excludeIds);
|
|
1512
|
+
if (result.deletedCount > 0) broadcastArchived();
|
|
1513
|
+
ok(res, result);
|
|
917
1514
|
}),
|
|
918
1515
|
},
|
|
919
1516
|
// GET messages — read and parse messages for session preview modal.
|
|
@@ -1143,14 +1740,14 @@ function registerRoutes(ctx) {
|
|
|
1143
1740
|
ok(res, { sessionId, messages });
|
|
1144
1741
|
}),
|
|
1145
1742
|
},
|
|
1146
|
-
// POST purge-workspace { workspacePath, sessionIds? } — permanently delete all archived sessions in a workspace.
|
|
1743
|
+
// POST purge-workspace { workspacePath, sessionIds?, excludeIds? } — permanently delete all archived sessions in a workspace.
|
|
1147
1744
|
{
|
|
1148
1745
|
kind: 'exact',
|
|
1149
1746
|
path: `${ROUTE_PREFIX}/purge-workspace`,
|
|
1150
1747
|
handler: guard(async (req, res) => {
|
|
1151
1748
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1152
|
-
const { workspacePath, sessionIds } = await readBody(req);
|
|
1153
|
-
let targetIds =
|
|
1749
|
+
const { workspacePath, sessionIds, excludeIds } = await readBody(req);
|
|
1750
|
+
let targetIds = normalizeSessionIds(sessionIds);
|
|
1154
1751
|
|
|
1155
1752
|
if (targetIds.length === 0 && workspacePath) {
|
|
1156
1753
|
const archived = (await registry?.listArchivedSessions?.()) ?? [];
|
|
@@ -1158,31 +1755,22 @@ function registerRoutes(ctx) {
|
|
|
1158
1755
|
}
|
|
1159
1756
|
|
|
1160
1757
|
if (targetIds.length === 0) {
|
|
1161
|
-
return ok(res, { deletedCount: 0,
|
|
1758
|
+
return ok(res, { deletedCount: 0, keptCount: 0, deleted: [], kept: [], failed: [], errors: [], total: 0 });
|
|
1162
1759
|
}
|
|
1163
1760
|
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
try {
|
|
1168
|
-
await registry?.permanentlyDeleteSession?.(sessionId);
|
|
1169
|
-
deleted.push(sessionId);
|
|
1170
|
-
} catch (error) {
|
|
1171
|
-
errors.push({ sessionId, code: error?.code ?? 'ERROR', message: error?.message ?? String(error) });
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
if (deleted.length > 0) broadcastArchived();
|
|
1176
|
-
ok(res, { deletedCount: deleted.length, total: targetIds.length, errors });
|
|
1761
|
+
const result = await purgeSessionRows(registry, targetIds, excludeIds);
|
|
1762
|
+
if (result.deletedCount > 0) broadcastArchived();
|
|
1763
|
+
ok(res, result);
|
|
1177
1764
|
}),
|
|
1178
1765
|
},
|
|
1179
|
-
// POST empty — permanently delete every archived session.
|
|
1766
|
+
// POST empty { excludeIds? } — permanently delete every archived session.
|
|
1180
1767
|
{
|
|
1181
1768
|
kind: 'exact',
|
|
1182
1769
|
path: `${ROUTE_PREFIX}/empty`,
|
|
1183
1770
|
handler: guard(async (req, res) => {
|
|
1184
1771
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { code: 'METHOD', message: 'POST only' } });
|
|
1185
|
-
const
|
|
1772
|
+
const { excludeIds } = await readBody(req);
|
|
1773
|
+
const result = await registry.emptyArchivedSessions({ excludeIds: normalizeSessionIds(excludeIds) });
|
|
1186
1774
|
broadcastArchived();
|
|
1187
1775
|
ok(res, result);
|
|
1188
1776
|
}),
|