dsh-session-manager 0.4.9 → 0.4.10

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.
Files changed (5) hide show
  1. package/CHANGELOG.md +136 -167
  2. package/README.md +108 -107
  3. package/README.zh.md +1 -0
  4. package/lib/index.js +1247 -1207
  5. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -1,1207 +1,1247 @@
1
- /**
2
- * @dsh-session-manager — Host half.
3
- *
4
- * Cordis plugin that fills three gaps in DSH:
5
- * - delete / unarchive (DSH ships `workspace.archiveSession` but not its inverse);
6
- * - cross-workspace move (DSH indexes workspace membership off the session
7
- * header's immutable `cwd`, so a real move must re-home the artifact);
8
- * - per-conversation Agent-preset migration (rewrite the effective preset
9
- * in place so the next event fold sees the new value).
10
- *
11
- * All durable rewrites go through DSH's own persistence seam (readRaw /
12
- * create / append / flush on the jsonl backend); the JSONL codec chain
13
- * migrates v2 -> v3 transparently, so the emitted artifact is always valid
14
- * for the running DSH build. The plugin never reaches into private DSH
15
- * module paths.
16
- *
17
- * The move and migrate paths do NOT tear down the live agent or session.
18
- * The in-memory session stays alive, the file is rewritten in place under
19
- * a new cwd (move) or a new event is appended to the existing log
20
- * (migrate), and the workspace registry is updated. The api-gateway's
21
- * chat panel therefore remains "available" throughout; the user does not
22
- * have to refresh.
23
- *
24
- * Compat boundary (lib/compat/) is loaded only because the test suite
25
- * asserts a static import; the apply() body inlines the DSH services it
26
- * uses and never instantiates the adapter.
27
- */
28
- import { mkdir, open, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
29
- import { dirname, join } from "node:path";
30
- import { randomBytes } from "node:crypto";
31
- import { createDshAdapter } from "./compat/dsh-adapter.js";
32
- import { decompressAllZstdFrames } from "./compat/zstd-frames.js";
33
-
34
- export const name = "dsh-session-manager";
35
-
36
- export const inject = [
37
- "webServer",
38
- "workspaceRegistry",
39
- "sessions",
40
- "agents",
41
- "sessionPersistence",
42
- "agentPresets"
43
- ];
44
-
45
- /** Like JSON.stringify but swallows circular-reference/BigInt errors so the
46
- * logger never throws while reporting a throw. */
47
- function safeJson(value) { try { return JSON.stringify(value); } catch { return "[unserializable]"; } }
48
- /** Like safeJson but used as the user-facing error string in HTTP responses. */
49
- function safeErrorMessage(error) {
50
- if (error === null || error === undefined) return `<nullish:${typeof error}>`;
51
- if (typeof error === "string") return error;
52
- if (typeof error === "object") {
53
- if (typeof error.message === "string" && error.message.length > 0) return error.message;
54
- if (typeof error.code === "string" && error.code.length > 0) return `[code=${error.code}]`;
55
- }
56
- try { return JSON.stringify(error); } catch { return String(error); }
57
- }
58
- const API_PREFIX = "/session-manager/api";
59
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
60
-
61
- /**
62
- * Encode a session id the way DSH's persistence layer stores its project
63
- * directory: separators and unsafe code units become "--<encoded>--" markers.
64
- * This mirrors the projectKey helper inside the JSONL persistence backend.
65
- * (The name encodeSessionSegment is a leftover from an earlier refactor;
66
- * the function encodes a session id, which DSH derives from a workspace
67
- * path; "project" + "segment" both refer to that same mapping.)
68
- */
69
- const encodeSessionSegment = (id) => {
70
- let readable = "";
71
- let separatorRun = false;
72
- for (let i = 0; i < id.length; i++) {
73
- const ch = id[i];
74
- const code = id.charCodeAt(i);
75
- if (ch === "/" || ch === "\\" || ch === ":") {
76
- if (!separatorRun) readable += "-";
77
- separatorRun = true;
78
- } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
79
- readable += ch;
80
- separatorRun = false;
81
- } else {
82
- readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
83
- separatorRun = false;
84
- }
85
- }
86
- return "--" + (readable.replace(/^-+/, "") || "root").slice(0, 251) + "--";
87
- };
88
-
89
- /** List every persisted session header visible to the configured persistence
90
- * backend, supplemented by raw-id sessions written by older DSH builds.
91
- *
92
- * Performance (issue #6): the disk loop previously opened and zstd-decoded
93
- * every candidate file in <dshHome>/sessions/<proj>/<dir>/ even when the
94
- * persistence layer had already returned those ids. On a healthy index
95
- * that wasted O(N * file_size) read+inflate per call -- the reporter
96
- * measured +17s CPU / +254 MB read per DSH startup on an 80-session /
97
- * 249 MB library. We now pre-build a `knownDirs` set from the persisted
98
- * ids (both `[id]` and `[encodeSessionSegment(id)]`) and skip matching
99
- * directories before any fopen. Orphan raw-id directories that the
100
- * index does not know about still fall through to the file read so the
101
- * disk fallback semantics are preserved -- the orphan is the whole
102
- * reason this loop exists.
103
- *
104
- * @param {object|undefined} persistence DSH's sessionPersistence service
105
- * @param {((segment: string) => string)|undefined} dshHome Resolver for
106
- * DSH home sub-paths; only "sessions" is read.
107
- */
108
- const listSessionHeaders = async (persistence, dshHome) => {
109
- const seen = new Set();
110
- const out = [];
111
- if (persistence !== void 0 && typeof persistence.list === "function") {
112
- try {
113
- for (const header of await persistence.list()) {
114
- if (header === void 0 || typeof header.id !== "string") continue;
115
- seen.add(header.id);
116
- out.push(header);
117
- }
118
- } catch { /* fall through to disk */ }
119
- }
120
-
121
- // Early-skip set for the disk fallback. For every id already in `seen`,
122
- // both candidate directory names ([encodeSessionSegment(id), id]) are
123
- // guaranteed to map back to that header; opening those files would only
124
- // re-decode a payload we already have. Mark them up-front and skip with
125
- // an O(1) Set lookup instead of an fopen + inflate + JSON.parse
126
- // roundtrip. Genuine orphans (raw-id sessions the index does not
127
- // surface) are not in `knownDirs` and still fall through to the file
128
- // read below -- the disk fallback semantics are preserved.
129
- const knownDirs = new Set();
130
- for (const id of seen) {
131
- knownDirs.add(id);
132
- knownDirs.add(encodeSessionSegment(id));
133
- }
134
-
135
- if (typeof dshHome !== "function") return out;
136
- const root = dshHome("sessions");
137
- let projects;
138
- try { projects = await readdir(root, { withFileTypes: true }); }
139
- catch { return out; }
140
- for (const proj of projects) {
141
- if (!proj.isDirectory()) continue;
142
- let dirs;
143
- try { dirs = await readdir(join(root, proj.name), { withFileTypes: true }); }
144
- catch { continue; }
145
- for (const entry of dirs) {
146
- if (!entry.isDirectory()) continue;
147
- // Cheap directory-name skip -- the issue #6 hot path fix.
148
- if (knownDirs.has(entry.name)) continue;
149
- for (const filename of ["session.v3.jsonl.zstd", "session.v2.jsonl.zstd", "session.jsonl.zstd", "session.jsonl"]) {
150
- const filePath = join(root, proj.name, entry.name, filename);
151
- try {
152
- const buf = await readFile(filePath);
153
- let content;
154
- if (filename.endsWith(".zstd")) {
155
- // lib/compat/zstd-frames.js#decompressAllZstdFrames walks every
156
- // complete concatenated frame (one frame per DSH batch) and
157
- // concatenates the plaintexts. Throws on empty/header-less
158
- // buffers and on corrupt headers; the outer catch swallows
159
- // both so we move on to the next candidate filename.
160
- try {
161
- const { content: decoded } = await decompressAllZstdFrames(buf);
162
- content = decoded.toString("utf8");
163
- } catch {
164
- continue;
165
- }
166
- } else {
167
- content = buf.toString("utf8");
168
- }
169
- const headerLine = content.split("\n", 1)[0];
170
- const meta = JSON.parse(headerLine);
171
- if (typeof meta?.id !== "string" || seen.has(meta.id)) continue;
172
- seen.add(meta.id);
173
- out.push({ id: meta.id, cwd: meta.cwd, createdAt: meta.createdAt, version: meta.version, origin: meta.origin });
174
- } catch { /* skip */ }
175
- }
176
- }
177
- }
178
- return out;
179
- };
180
- export function apply(ctx) {
181
- const readBody = async (req) => {
182
- const chunks = [];
183
- for await (const chunk of req) chunks.push(Buffer.from(chunk));
184
- return Buffer.concat(chunks).toString("utf8");
185
- };
186
-
187
- const send = (res, code, obj) => {
188
- res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
189
- res.end(JSON.stringify(obj));
190
- };
191
-
192
- /** Remove one id from the registry-global archive set (durable, serialized). */
193
- const unarchiveSession = async (sessionId) => {
194
- const registry = ctx.workspaceRegistry;
195
- await registry.enqueueOperation(async () => {
196
- const state = registry.requireState();
197
- if (!state.archivedSessionIds.includes(sessionId)) return;
198
- await registry.setState({
199
- ...state,
200
- archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
201
- });
202
- });
203
- };
204
-
205
- /** Detach one session from every workspace's ordered accounting. */
206
- const detachFromWorkspaces = async (sessionId) => {
207
- for (const entity of ctx.workspaceRegistry.list()) {
208
- if (entity.sessionIds.includes(sessionId)) {
209
- await entity.detachSession(sessionId);
210
- }
211
- }
212
- };
213
-
214
- /** Ordered workspace projection for the client. */
215
- const listWorkspaces = () => {
216
- return ctx.workspaceRegistry.list().map((entity) => {
217
- const rawIds = Array.isArray(entity.record?.sessionIds)
218
- ? [...entity.record.sessionIds]
219
- : [...entity.sessionIds];
220
- return {
221
- id: entity.id,
222
- name: entity.title || entity.id,
223
- title: entity.title || entity.id,
224
- path: entity.path,
225
- sessionCount: rawIds.length,
226
- sessionIds: rawIds
227
- };
228
-
229
- });
230
- };
231
-
232
-
233
- /**
234
- * Durable temp-file write next to its final target. Returns the temp path;
235
- * the caller publishes it with a rename once the coast is clear.
236
- */
237
- const writeTempFile = async (finalPath, data) => {
238
- const temp = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
239
- const handle = await open(temp, "wx", 0o600);
240
- try {
241
- await handle.writeFile(data);
242
- await handle.sync();
243
- } finally {
244
- await handle.close();
245
- }
246
- return temp;
247
- };
248
-
249
- /**
250
- * Encode the moved artifact in the backend's own physical layout:
251
- * plain JSONL, or zstd frames whose FIRST frame is exactly the header
252
- * line (the reader's assertZstdHeaderFrame / readFirstZstdLine contract).
253
- */
254
- const encodeArtifact = async (headerLine, rest, isZstd) => {
255
- if (!isZstd) return Buffer.from(`${headerLine}
256
- ${rest}`, "utf8");
257
- const { zstdCompress } = await import("node:zlib");
258
- if (typeof zstdCompress !== "function") {
259
- throw new Error("当前 Node 运行时没有 zstd 支持,无法迁移 zstd 编码的会话工件");
260
- }
261
- const compress = (buffer) => new Promise((resolve, reject) => {
262
- zstdCompress(buffer, (error, output) => error == null ? resolve(output) : reject(error));
263
- });
264
- const headerFrame = await compress(Buffer.from(`${headerLine}\n`, "utf8"));
265
- if (rest === "") return headerFrame;
266
- const bodyFrame = await compress(Buffer.from(rest, "utf8"));
267
- return Buffer.concat([headerFrame, bodyFrame]);
268
- };
269
-
270
- /**
271
- * TRUE cross-workspace move. See the module docstring for the full design:
272
- * re-home the stored cwd, migrate the artifact, then swap accounting.
273
- * Throws an Error with a readable message on every failure path; the
274
- * artifact is restored (or its location reported) whenever a mid-move
275
- * step fails, so the session never ends up in a half-moved state.
276
- */
277
- const moveSession = async (sessionId, targetWorkspaceId) => {
278
- const registry = ctx.workspaceRegistry;
279
- const persistence = ctx.get("sessionPersistence");
280
- const dshHomePath = ctx.get("dshHomePath");
281
- if (persistence === void 0) throw new Error("sessionPersistence 服务不可用");
282
- if (typeof persistence.locate !== "function") {
283
- throw new Error("当前持久化后端无法定位会话工件,无法跨工作区移动");
284
- }
285
-
286
- const target = registry.list().find((entity) => entity.id === targetWorkspaceId);
287
- if (target === void 0) {
288
- const error = new Error(`目标工作区不存在: ${targetWorkspaceId}`);
289
- error.code = "workspace-not-found";
290
- throw error;
291
- }
292
- const targetPath = target.path;
293
-
294
- // Fast path: the common case -- the session is already in the
295
- // persistence index -- costs one .list() + .find(). The previous
296
- // code called listSessionHeaders() unconditionally, which (before
297
- // the issue #6 fix) re-read every file in the session library even
298
- // when the index already had the answer. We still fall through to
299
- // the disk fallback for raw-id orphans persistence.list() cannot
300
- // surface (older DSH builds wrote plain-id directories that the
301
- // index may not know about).
302
- let storedHeader;
303
- if (persistence !== void 0 && typeof persistence.list === "function") {
304
- try {
305
- const headers = await persistence.list();
306
- storedHeader = headers.find((header) =>
307
- header !== void 0 && typeof header.id === "string" && header.id === sessionId
308
- );
309
- } catch { /* fall through to disk fallback below */ }
310
- }
311
- if (storedHeader === void 0) {
312
- const storedHeaders = await listSessionHeaders(persistence, dshHomePath);
313
- storedHeader = storedHeaders.find((header) => header !== void 0 && header.id === sessionId);
314
- }
315
- if (storedHeader === void 0) {
316
- const error = new Error(
317
- `会话 ${sessionId} 没有磁盘记录(不存在,或是一个尚未发送任何消息的空白会话),无法移动`
318
- );
319
- error.code = "session-not-found";
320
- throw error;
321
- }
322
- if (storedHeader.origin === "subagent") {
323
- const error = new Error("子代理(subagent)会话不支持跨工作区移动");
324
- error.code = "subagent-unsupported";
325
- throw error;
326
- }
327
- if (storedHeader.cwd !== void 0) {
328
- let currentCanonical;
329
- try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
330
- if (currentCanonical === targetPath) {
331
- return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
332
- }
333
- }
334
-
335
- const liveSession = ctx.sessions.get(sessionId);
336
- let liveAgent;
337
- try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
338
- // The 0.4.6+ move path is no-teardown: the live agent/session stays
339
- // alive throughout. We only rewrite the on-disk artifact in place and
340
- // update the in-memory session header to point at the new cwd; the
341
- // api-gateway's chat panel keeps the in-memory session as active.
342
- // The persistence coordinator's per-id serialize() (if available) wraps
343
- // the rewrite so the write-behind cannot append to the hidden file.
344
- // client UI never has to re-init. Just flush pending events to
345
- // disk (still at OLD cwd), then atomically rename the artifact to
346
- // NEW cwd. The persistence coordinator's serialize() inside the
347
- // move block holds the per-id lock, so the write-behind can't append
348
- // to the hidden file. The agent's UI keeps showing the same in-memory
349
- // session -- no client state flip, no half-initialized resume, no
350
- // "session unavailable" symptoms.
351
- if (liveSession !== void 0) {
352
- try { await ctx.sessions.flush(liveSession); } catch { /* best-effort */ }
353
- }
354
-
355
- // No teardown -- the agent and session are still live and that's
356
- // intentional. Move straight to file re-home.
357
- const coordinator = persistence.coordinator;
358
- const serialize = typeof coordinator?.serialize === "function"
359
- ? (operation) => coordinator.serialize(sessionId, operation)
360
- : (operation) => operation();
361
-
362
- const result = await serialize(async () => {
363
- const raw = await readSessionArtifact(sessionId);
364
- if (raw === void 0) throw new Error("读取会话工件失败:未找到会话文件");
365
- const newlineAt = raw.content.indexOf("\n");
366
- if (newlineAt === -1) throw new Error("会话工件缺少头行,数据可能损坏");
367
- const rest = raw.content.slice(newlineAt + 1);
368
- let oldHeader;
369
- try { oldHeader = JSON.parse(raw.content.slice(0, newlineAt)); }
370
- catch (error) { throw new Error(`会话工件头行无法解析: ${String(error)}`); }
371
- if (oldHeader.id !== sessionId) throw new Error("会话工件头行 id 与请求不符,拒绝移动");
372
-
373
- // Keep the stored generation in sync with the filename selected by
374
- // persistence.locate(). Rewriting a v2 header to v0 while locating it
375
- // through the same v2 backend creates a v2 filename with v0 content,
376
- // which makes DSH reject the session on the next startup.
377
- const newHeader = { ...oldHeader, cwd: targetPath };
378
- const oldPath = raw.path ?? persistence.locate(raw.meta)?.path;
379
- const newLocation = persistence.locate(newHeader);
380
- if (typeof oldPath !== "string" || newLocation === void 0) {
381
- throw new Error("持久化后端无法定位会话工件路径");
382
- }
383
- const newPath = newLocation.path;
384
- if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
385
- const bytes = await encodeArtifact(JSON.stringify(newHeader), rest, newPath.endsWith(".zstd"));
386
- await mkdir(dirname(newPath), { recursive: true });
387
- const tempNew = await writeTempFile(newPath, bytes);
388
- const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
389
- try {
390
- await rename(oldPath, oldHidden);
391
- } catch (error) {
392
- await rm(tempNew, { force: true });
393
- throw new Error(`移动失败(无法隐藏旧会话工件,已取消,会话保持原状): ${String(error)}`);
394
- }
395
- try {
396
- await rename(tempNew, newPath);
397
- } catch (error) {
398
- try { await rename(oldHidden, oldPath); } catch { /* reported below */ }
399
- await rm(tempNew, { force: true });
400
- throw new Error(`移动失败(发布新会话工件失败,已尝试回滚): ${String(error)}`);
401
- }
402
-
403
- const state = coordinator?.states?.get?.(sessionId);
404
- if (state?.owner !== void 0 && liveSession !== void 0 && state.owner !== liveSession) {
405
- await rm(newPath, { force: true });
406
- await rename(oldHidden, oldPath);
407
- throw new Error(`会话 ${sessionId} 的持久化 owner 与当前 live session 不一致,已回滚`);
408
- }
409
- const oldStateMeta = state?.meta;
410
- const oldLiveHeader = liveSession?.header;
411
- const oldIndexedHeader = registry.headers?.get?.(sessionId);
412
- const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
413
- const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
414
- const fromWorkspaceIds = registry.list()
415
- .filter((entity) => entity.id !== target.id && (entity.record?.sessionIds ?? entity.sessionIds).includes(sessionId))
416
- .map((entity) => entity.id);
417
- let targetAttached = false;
418
- const detachedFrom = [];
419
-
420
- try {
421
- if (state !== void 0) {
422
- state.meta = { ...state.meta, ...newHeader, cwd: targetPath };
423
- state.materialized = true;
424
- }
425
- coordinator?.preparations?.invalidate?.(sessionId);
426
- if (liveSession !== void 0) liveSession.header = Object.freeze({ ...newHeader });
427
- registry.headers?.set?.(sessionId, { ...newHeader });
428
- registry.sessionPaths?.set?.(sessionId, targetPath);
429
- registry.invalidSessionPaths?.delete?.(sessionId);
430
-
431
- await registry.enqueueOperation(async () => {
432
- for (const id of fromWorkspaceIds) {
433
- const entity = registry.list().find((candidate) => candidate.id === id);
434
- if (entity === void 0) continue;
435
- await entity.detachSession(sessionId);
436
- detachedFrom.push(id);
437
- }
438
- await target.attachSession(sessionId);
439
- targetAttached = true;
440
- });
441
- } catch (error) {
442
- if (state !== void 0 && oldStateMeta !== void 0) state.meta = oldStateMeta;
443
- coordinator?.preparations?.invalidate?.(sessionId);
444
- if (liveSession !== void 0 && oldLiveHeader !== void 0) liveSession.header = oldLiveHeader;
445
- if (oldIndexedHeader === void 0) registry.headers?.delete?.(sessionId);
446
- else registry.headers?.set?.(sessionId, oldIndexedHeader);
447
- if (oldIndexedPath === void 0) registry.sessionPaths?.delete?.(sessionId);
448
- else registry.sessionPaths?.set?.(sessionId, oldIndexedPath);
449
- if (oldInvalidPath === void 0) registry.invalidSessionPaths?.delete?.(sessionId);
450
- else registry.invalidSessionPaths?.set?.(sessionId, oldInvalidPath);
451
-
452
- const rollbackErrors = [];
453
- try { if (targetAttached) await target.detachSession(sessionId); } catch (e) { rollbackErrors.push(e); }
454
- for (const id of detachedFrom) {
455
- try {
456
- const entity = registry.list().find((candidate) => candidate.id === id);
457
- if (entity !== void 0) await entity.attachSession(sessionId);
458
- } catch (e) { rollbackErrors.push(e); }
459
- }
460
- try { await rm(newPath, { force: true }); } catch (e) { rollbackErrors.push(e); }
461
- try { await rename(oldHidden, oldPath); } catch (e) { rollbackErrors.push(e); }
462
- if (rollbackErrors.length > 0) {
463
- throw new AggregateError([error, ...rollbackErrors], "移动失败且回滚不完整");
464
- }
465
- throw error;
466
- }
467
-
468
- try { await rm(oldHidden, { force: true }); } catch { /* best-effort */ }
469
- try { await rm(dirname(oldPath), { recursive: true, force: true }); } catch { /* best-effort */ }
470
- return {
471
- ok: true,
472
- sessionId,
473
- moved: true,
474
- fromWorkspaceIds,
475
- toWorkspaceId: target.id,
476
- toWorkspaceTitle: target.title || target.id,
477
- artifactFrom: oldPath,
478
- artifactTo: newPath,
479
- wasLive: liveSession !== void 0 || liveAgent !== void 0
480
- };
481
- });
482
-
483
- const cache = ctx.get("sessionProjectionCache");
484
- if (cache !== void 0 && typeof cache.coldSnapshot === "function") {
485
- try { await cache.coldSnapshot(sessionId); }
486
- catch (error) { ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`); }
487
- }
488
- // The agent is still live (no teardown). The session's in-memory
489
- // header was rewritten above and the workspace registry updated;
490
- // future writes go through persistence.locate() against the new
491
- // cwd, so the file at the new location accumulates new events.
492
- // Re-resuming the agent here re-creates a half-initialized
493
- // Session/agent pair and leaves the client UI in a "session
494
- // unavailable" state (agent/status is only emitted on PHASE
495
- // CHANGES, never on agent construction). Leaving the live
496
- // agent/session in place avoids the broken UI entirely.
497
- ctx.logger.info(`session-manager: moved "${sessionId}" to workspace "${target.id}" (in-place file rename, live agent retained)`);
498
- return result;
499
- };
500
- /* === preset-migration (session-manager v0.3.0) === */
501
- const NL = String.fromCharCode(10);
502
-
503
- const scanSelectedEvents = (content) => {
504
- const out = [];
505
- const newlineAt = content.indexOf(NL);
506
- const after = newlineAt === -1 ? content : content.slice(newlineAt + 1);
507
- for (const line of after.split(NL)) {
508
- if (line.length === 0) continue;
509
- let obj;
510
- try { obj = JSON.parse(line); } catch { continue; }
511
- if (obj !== null && typeof obj === "object" && obj.type === "agent-preset/selected" && obj.data !== void 0 && typeof obj.data.agentPreset === "string") {
512
- out.push({ agentPreset: obj.data.agentPreset, time: obj.time, seq: obj.seq });
513
- }
514
- }
515
- return out;
516
- };
517
-
518
- const scanPreset = async (filter) => {
519
- // Pull the full preset roster so the UI shows every available preset,
520
- // not just the ones currently used by some session.
521
- let allPresetIds = [];
522
- try {
523
- const ap = ctx.get("agentPresets");
524
- if (ap !== void 0 && typeof ap.list === "function") {
525
- const list = await ap.list();
526
- for (const p of (list || [])) {
527
- if (p && typeof p.id === "string" && p.id.length > 0) allPresetIds.push(p.id);
528
- }
529
- }
530
- } catch { /* ignore -- fall back to used presets only */ }
531
-
532
- const persistence = ctx.get("sessionPersistence");
533
- if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
534
- // Don't pre-flight check readRaw/list -- the actual JSONL backend
535
- // exposes both. listSessionHeaders is wrapped in try/catch below so
536
- // any missing-method error surfaces as a useful per-session "no
537
- // artifact" message instead of a hard "backend does not support"
538
- // failure that hides which session actually failed.
539
- let headers = [];
540
- try {
541
- headers = await listSessionHeaders(persistence, ctx.get("dshHomePath"));
542
- } catch (probeError) {
543
- throw new Error("failed to enumerate sessions: " + String(probeError.message ?? probeError));
544
- }
545
- let allowedIds = null;
546
- const workspaceId = filter !== void 0 && typeof filter === "object" ? filter.id : void 0;
547
- if (typeof workspaceId === "string" && workspaceId !== "") {
548
- const registry = ctx.get("workspaceRegistry");
549
- if (registry !== void 0 && typeof registry.list === "function") {
550
- const all = registry.list();
551
- const ws = all.find((entity) => entity.id === workspaceId);
552
- if (ws === void 0) {
553
- const error = new Error("workspace not found: " + workspaceId);
554
- error.code = "workspace-not-found";
555
- throw error;
556
- }
557
- allowedIds = new Set();
558
- const ids = typeof ws.sessionIds === "function" ? ws.sessionIds() : ws.sessionIds;
559
- if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
560
- }
561
- }
562
- const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
563
- ? filter.sessionId
564
- : "";
565
- const out = [];
566
- for (const header of headers) {
567
- if (requestedSessionId !== "" && header.id !== requestedSessionId) continue;
568
- if (allowedIds !== null && !allowedIds.has(header.id)) continue;
569
- if (header.origin === "subagent") continue;
570
- const raw = await readSessionArtifact(header.id);
571
- if (raw === void 0) continue;
572
- const newlineAt = raw.content.indexOf(NL);
573
- if (newlineAt === -1) continue;
574
- let headerObj;
575
- try { headerObj = JSON.parse(raw.content.slice(0, newlineAt)); } catch { continue; }
576
- let selected = scanSelectedEvents(raw.content);
577
- const live = ctx.sessions.get(header.id);
578
- if (live !== void 0) {
579
- const liveEvents = Array.isArray(live.events)
580
- ? live.events
581
- : Array.isArray(live.log) ? live.log : [];
582
- const liveSelected = liveEvents
583
- .filter((event) => event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string")
584
- .map((event) => ({ agentPreset: event.data.agentPreset, time: event.time, seq: event.seq }));
585
- if (liveSelected.length > selected.length) selected = liveSelected;
586
- }
587
- const finalPreset = selected.length > 0 ? selected[selected.length - 1].agentPreset : headerObj.agentPreset;
588
- out.push({ sessionId: header.id, cwd: header.cwd, origin: header.origin, headerPreset: headerObj.agentPreset, selectedEvents: selected, finalPreset });
589
- }
590
- // Targets come only from the live roster. A deleted preset can still be
591
- // displayed as this session's current value, but must not be offered as
592
- // a destination.
593
- return { rows: out, availablePresets: Array.from(new Set(allPresetIds)) };
594
- };
595
-
596
- /**
597
- * Rewrite the effective preset of one conversation in place on disk.
598
- * The 0.4.6+ flow is no-teardown: when a live session is in store we
599
- * append the new agent-preset/selected event via Session.append() and
600
- * flush through SessionStore.flush(); for cold sessions we rewrite
601
- * the last matching event directly on the existing artifact. In both
602
- * cases the live agent/session is left intact, the api-gateway chat
603
- * panel stays available, and the projection picks up the new value
604
- * on the next event fold.
605
- */
606
- /** Cold-path preset migration: read disk, rewrite, publish. Works whether
607
- * or not the session is currently in the coordinator, and does not depend on
608
- * `coordinator.states` carrying a fresh `meta.cwd`. */
609
- const applyMigrationToArtifact = async (args) => {
610
- const sessionId = typeof args.sessionId === "string" ? args.sessionId : "";
611
- const toPreset = typeof args.toPreset === "string" ? args.toPreset : "";
612
- const liveSession = (args !== void 0 && typeof args === "object") ? args.liveSession : void 0;
613
- const persistence = ctx.get("sessionPersistence");
614
- if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
615
- const coordinator = persistence.coordinator;
616
- const serialize = typeof coordinator?.serialize === "function"
617
- ? (operation) => coordinator.serialize(sessionId, operation)
618
- : (operation) => operation();
619
- return await serialize(async () => {
620
- const raw = await readSessionArtifact(sessionId);
621
- if (raw === void 0) throw new Error(`session "${sessionId}" has no artifact`);
622
- const lines = raw.content.split(NL);
623
- if (lines.length < 2) throw new Error("session artifact has no header line");
624
- let header;
625
- try { header = JSON.parse(lines[0]); } catch { throw new Error("session header parse failed"); }
626
- if (header.id !== sessionId) throw new Error("session header id mismatch");
627
-
628
- let selectedIndex = -1;
629
- let selectedPreset;
630
- for (let index = 1; index < lines.length; index += 1) {
631
- if (lines[index] === "") continue;
632
- let event;
633
- try { event = JSON.parse(lines[index]); } catch { continue; }
634
- if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
635
- selectedIndex = index;
636
- selectedPreset = event.data.agentPreset;
637
- }
638
- }
639
- const oldPreset = selectedIndex >= 0 ? selectedPreset : header.agentPreset;
640
- if (oldPreset === toPreset) {
641
- return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
642
- }
643
-
644
- if (selectedIndex >= 0) {
645
- const event = JSON.parse(lines[selectedIndex]);
646
- event.data = { ...event.data, agentPreset: toPreset };
647
- lines[selectedIndex] = JSON.stringify(event);
648
- } else {
649
- header.agentPreset = toPreset;
650
- lines[0] = JSON.stringify(header);
651
- }
652
-
653
- const artifactPath = raw.path ?? persistence.locate?.(raw.meta)?.path;
654
- if (typeof artifactPath !== "string") throw new Error("cannot locate session artifact");
655
- const replacementContent = lines.join(NL);
656
- const firstNewline = replacementContent.indexOf(NL);
657
- const bytes = await encodeArtifact(
658
- replacementContent.slice(0, firstNewline),
659
- replacementContent.slice(firstNewline + 1),
660
- artifactPath.endsWith(".zstd")
661
- );
662
- const tempPath = await writeTempFile(artifactPath, bytes);
663
- const hiddenPath = `${artifactPath}.${randomBytes(6).toString("hex")}.tmp`;
664
- try {
665
- await rename(artifactPath, hiddenPath);
666
- await rename(tempPath, artifactPath);
667
- } catch (error) {
668
- try { await rm(artifactPath, { force: true }); } catch { /* best-effort */ }
669
- try { await rename(hiddenPath, artifactPath); } catch { /* reported below */ }
670
- try { await rm(tempPath, { force: true }); } catch { /* best-effort */ }
671
- throw new Error(`preset migration publish failed: ${String(error)}`);
672
- }
673
- try { await rm(hiddenPath, { force: true }); } catch { /* best-effort */ }
674
-
675
- const state = coordinator?.states?.get?.(sessionId);
676
- if (state !== void 0 && selectedIndex < 0) state.meta = { ...state.meta, agentPreset: toPreset };
677
- coordinator?.preparations?.invalidate?.(sessionId);
678
- // Mirror the disk rewrite into the in-memory live session: the cold path
679
- // bypasses the persistence write controller (which is intentionally
680
- // skipped when its state is stale), so the session header, projection,
681
- // and downstream agent composition would otherwise keep reporting the old
682
- // preset. Update both the header and the in-memory event log so DSH's
683
- // `resolveSessionPreset` and any projection subscribers observe the new
684
- // preset from the same source.
685
- if (liveSession !== void 0) {
686
- try {
687
- const nextHeader = { ...(liveSession.header ?? {}), agentPreset: toPreset };
688
- try { liveSession.header = Object.freeze(nextHeader); }
689
- catch { liveSession.header = nextHeader; }
690
- } catch { /* live session may have been retired mid-flight */ }
691
- // Append the new selection to the live log so the session/store
692
- // observers (agent-presets, projections, ctx) fire their normal
693
- // `session/event` + `agent-preset/selected` pipeline. We append
694
- // unconditionally (not only when the disk had no prior selection)
695
- // because the in-memory projection reads the latest appended event
696
- // and the cold path deliberately skipped the persistence write
697
- // controller; the appended event will be persisted on the next
698
- // normal flush, keeping disk and memory consistent.
699
- if (typeof liveSession.append === "function") {
700
- try { liveSession.append("agent-preset/selected", { agentPreset: toPreset }); } catch { /* best-effort */ }
701
- }
702
- }
703
- return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
704
- });
705
- };
706
- /** Rewrite exactly one conversation's effective Agent preset. */
707
- const migratePreset = async (opts) => {
708
- const sessionId = typeof opts.sessionId === "string" ? opts.sessionId.trim() : "";
709
- const toPreset = typeof opts.toPreset === "string" ? opts.toPreset.trim() : "";
710
- if (sessionId === "") { const e = new Error("sessionId required"); e.code = "bad-request"; throw e; }
711
- if (toPreset === "") { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
712
-
713
- const presets = ctx.get("agentPresets");
714
- if (presets === void 0 || typeof presets.list !== "function") {
715
- throw new Error("agentPresets service unavailable");
716
- }
717
- const roster = await presets.list();
718
- const target = (roster || []).find((preset) => preset && preset.id === toPreset);
719
- if (target === void 0) {
720
- const available = (roster || []).map((preset) => preset?.id).filter(Boolean);
721
- throw new Error(`Agent 预设 "${toPreset}" 不存在(可用:${available.join(", ")})`);
722
- }
723
- if (target.broken !== void 0) throw new Error(`Agent 预设 "${toPreset}" 不可用:${target.broken}`);
724
-
725
- const liveSession = ctx.sessions.get(sessionId);
726
- let liveAgent;
727
- try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
728
- // Resolve persistence up front so the live path can read the coordinator
729
- // without tripping JavaScript's temporal dead zone on `const persistence`.
730
- const persistence = ctx.get("sessionPersistence");
731
- if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
732
-
733
- if (liveSession !== void 0) {
734
- const coordinator = persistence.coordinator;
735
- const states = coordinator?.states;
736
- const beforeState = states?.get?.(sessionId);
737
- const liveCwd = liveSession.header?.cwd;
738
- // A just-completed moveSession mutates `liveSession.header.cwd` in place,
739
- // but the persistence coordinator's tracked state may not see the new cwd
740
- // until its serialize chain drains. If we hand the migration to the
741
- // coordinator now, its `state.meta.cwd` may still be the pre-move cwd and
742
- // appendBatch will open the old (now deleted) artifact path, producing the
743
- // "ENOENT ... session.v2.jsonl.zstd" regression. Falling back to the cold
744
- // path is always correct: the disk copy already reflects the new cwd.
745
- const stateStale = beforeState === void 0
746
- || beforeState.meta?.cwd === void 0
747
- || (liveCwd !== void 0 && beforeState.meta.cwd !== liveCwd);
748
- if (stateStale) {
749
- const raw = await readSessionArtifact(sessionId);
750
- if (raw !== void 0) {
751
- const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
752
- if (result.migrated) { try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ } }
753
- ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" via cold path (state was stale, live cwd=${String(liveCwd)}, state cwd=${String(beforeState?.meta?.cwd)})`);
754
- return { sessionId, migrated: true, oldPreset: result.oldPreset, newPreset: toPreset };
755
- }
756
- // No artifact yet; fall through to the in-memory + flush path below,
757
- // which materializes the empty file using the live cwd.
758
- }
759
- let oldPreset = liveSession.header?.agentPreset;
760
- // SessionStore implementations from older DSH builds may expose the
761
- // history as `log` rather than the newer `events` snapshot getter.
762
- // Do not dereference `.length` on an absent compatibility surface.
763
- const liveEvents = Array.isArray(liveSession.events)
764
- ? liveSession.events
765
- : Array.isArray(liveSession.log) ? liveSession.log : [];
766
- for (let index = liveEvents.length - 1; index >= 0; index -= 1) {
767
- const event = liveEvents[index];
768
- if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
769
- oldPreset = event.data.agentPreset;
770
- break;
771
- }
772
- }
773
- if (oldPreset === toPreset) {
774
- return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
775
- }
776
- if (liveAgent !== void 0 && typeof presets?.recompose === "function" && liveAgent.ctx) {
777
- try { await presets.recompose(liveAgent.ctx, toPreset); } catch { /* best-effort */ }
778
- }
779
- liveSession.append("agent-preset/selected", { agentPreset: toPreset });
780
- await ctx.sessions.flush(liveSession);
781
- ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" from "${String(oldPreset)}" to "${toPreset}"`);
782
- return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
783
- }
784
-
785
- // A session absent from SessionStore is cold. If an older plugin left its
786
- // exact Session object in the coordinator, retire that proven orphan by
787
- // the coordinator's own drain path instead of deleting owner bookkeeping.
788
- const coordinator = persistence.coordinator;
789
- let tracked = coordinator?.states?.get?.(sessionId);
790
- if (tracked?.owner !== void 0 && ctx.sessions.get(sessionId) === void 0) {
791
- coordinator.retire?.(tracked.owner);
792
- const retirement = coordinator.retirements?.get?.(sessionId);
793
- if (retirement !== void 0) await retirement;
794
- tracked = coordinator.states?.get?.(sessionId);
795
- if (tracked?.owner !== void 0) {
796
- throw new Error(`会话 ${sessionId} 仍有未释放的持久化 owner,已停止迁移以保护数据`);
797
- }
798
- }
799
-
800
- const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
801
-
802
- if (result.migrated) {
803
- try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ }
804
- ctx.logger.info(`session-manager: migrated cold preset for "${sessionId}" from "${String(result.oldPreset)}" to "${toPreset}"`);
805
- }
806
- return result;
807
- };
808
-
809
- /** Read one session artifact verbatim, falling back to direct disk access when the
810
- * configured persistence backend cannot see the id (e.g. an older DSH build
811
- * wrote a "session.v2.jsonl.zstd" file under the raw-id directory).
812
- */
813
- const readSessionArtifact = async (sessionId, signal) => {
814
- const persistence = ctx.get("sessionPersistence");
815
- if (persistence !== void 0 && typeof persistence.readRaw === "function") {
816
- try {
817
- const raw = await persistence.readRaw(sessionId, signal);
818
- if (raw !== void 0) {
819
- const located = typeof persistence.locate === "function" ? persistence.locate(raw.meta) : void 0;
820
- // After a cross-workspace move, a backend may return a raw artifact
821
- // whose metadata cache still points at the pre-move cwd. Do not
822
- // hand that stale path to a mutator; verify it and fall through to
823
- // the id-based filesystem scan when it no longer exists.
824
- if (located?.path !== void 0) {
825
- try {
826
- if ((await stat(located.path)).isFile()) {
827
- return { ...raw, path: located.path };
828
- }
829
- } catch { /* stale locate result; scan the durable files below */ }
830
- } else {
831
- return { ...raw };
832
- }
833
- }
834
- } catch { /* fall through to direct disk */ }
835
- }
836
- const dshHomePath = ctx.get("dshHomePath");
837
- if (typeof dshHomePath !== "function") return void 0;
838
- const root = dshHomePath("sessions");
839
- let projects;
840
- try { projects = await readdir(root, { withFileTypes: true }); }
841
- catch { return void 0; }
842
- const encoded = encodeSessionSegment(sessionId);
843
- const { createRequire } = await import("node:module"); const require_ = createRequire(import.meta.url); const { zstdDecompressSync } = require_("node:zlib");
844
- for (const proj of projects) {
845
- if (!proj.isDirectory()) continue;
846
- for (const candidate of [encoded, sessionId]) {
847
- const dir = join(root, proj.name, candidate);
848
- for (const filename of ["session.jsonl.zstd", "session.v2.jsonl.zstd", "session.v3.jsonl.zstd", "session.jsonl"]) {
849
- const filePath = join(dir, filename);
850
- try {
851
- const buf = await readFile(filePath);
852
- let content;
853
- if (filename.endsWith(".zstd")) {
854
- // Node's zstdDecompressSync only decodes the FIRST frame in a
855
- // multi-frame file; DSH writes one frame per batch, so a real
856
- // artifact contains many concatenated frames and the rest is
857
- // otherwise silently lost. Walk every magic-prefixed frame and
858
- // concatenate their plaintexts.
859
- const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
860
- const plaintexts = [];
861
- let pos = 0;
862
- while (pos < buf.length) {
863
- const next = buf.indexOf(ZSTD_MAGIC, pos);
864
- if (next < 0) break;
865
- try {
866
- plaintexts.push(zstdDecompressSync(buf.subarray(next)));
867
- } catch {
868
- break;
869
- }
870
- pos = next + 4;
871
- }
872
- if (plaintexts.length === 0) continue;
873
- content = Buffer.concat(plaintexts).toString("utf8");
874
- } else {
875
- content = buf.toString("utf8");
876
- }
877
- const headerLine = content.split("\n", 1)[0];
878
- let meta;
879
- try { meta = JSON.parse(headerLine); } catch { continue; }
880
- if (typeof meta?.id !== "string" || meta.id !== sessionId) continue;
881
- return { meta, filename, content, path: filePath };
882
- } catch { /* keep scanning */ }
883
- }
884
- }
885
- }
886
- return void 0;
887
- };
888
- const sessionDirOf = async (sessionId) => {
889
- const persistence = ctx.get("sessionPersistence");
890
- if (persistence !== void 0) {
891
- try {
892
- const headers = await persistence.list();
893
- const meta = headers.find((header) => header.id === sessionId);
894
- if (meta !== void 0) {
895
- const location = persistence.locate(meta);
896
- if (location !== void 0) return dirname(location.path);
897
- }
898
- } catch { /* fall through to filesystem scan */ }
899
- }
900
- const dshHomePath = ctx.get("dshHomePath");
901
- if (typeof dshHomePath !== "function") return void 0;
902
- const root = dshHomePath("sessions");
903
- let projects;
904
- try { projects = await readdir(root, { withFileTypes: true }); }
905
- catch { return void 0; }
906
- const encoded = encodeSessionSegment(sessionId);
907
- const candidates = [encoded, sessionId];
908
- for (const proj of projects) {
909
- if (!proj.isDirectory()) continue;
910
- for (const candidate of candidates) {
911
- const dir = join(root, proj.name, candidate);
912
- try {
913
- if ((await stat(dir)).isDirectory()) return dir;
914
- } catch { /* keep scanning */ }
915
- }
916
- }
917
- return void 0;
918
- };
919
-
920
- /**
921
- * Delete one session end to end. Returns a summary of what was torn down.
922
- * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
923
- */
924
- const deleteSession = async (sessionId) => {
925
- const session = ctx.sessions.get(sessionId);
926
- // 0.1.1-rc.2 throws ApiRemoteSessionNotFound for cold sessions here.
927
- let agent;try{agent=ctx.agents.get(sessionId)}catch{agent=void 0}
928
-
929
- if (agent !== void 0) {
930
- // Stop any running turn (disposed-kind suppresses re-wake).
931
- agent.cancel({ kind: "disposed" });
932
- // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
933
- if (typeof agent.scope?.dispose === "function") {
934
- await Promise.race([agent.scope.dispose(), sleep(3000)]);
935
- }
936
- // Drop the zombie from the registry so a later session.create/open with
937
- // the same id cannot resurrect it.
938
- try {
939
- ctx.agents.store?.delete?.(sessionId);
940
- } catch { /* best-effort */ }
941
- }
942
-
943
- let detached = false;
944
- if (session !== void 0) {
945
- // Flush buffered events to disk first so the retirement drain is a no-op.
946
- try {
947
- await ctx.sessions.flush(session);
948
- } catch { /* best-effort */ }
949
- // Detach the session store entry: emits session/disposed, which the
950
- // persistence write-path answers with a final drain, and the API proxy
951
- // relays as host/session-removed so every connected client drops the row.
952
- try {
953
- const entry = ctx.sessions.store?.get?.(sessionId);
954
- if (entry !== void 0 && typeof entry.detach === "function") {
955
- entry.detach();
956
- await sleep(200); // let the write-behind retirement settle
957
- detached = true;
958
- }
959
- } catch { /* best-effort */ }
960
- }
961
- // Some sessions have a stored artifact but no live store row (e.g.
962
- // already-scoped scope torn down, or never attached). Without a live
963
- // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
964
- // entry.detach() never fires — so the apiproxy never queues a
965
- // host/session-removed frame, every connected client keeps showing the
966
- // session row, and the row collapses into the ungrouped section the
967
- // moment host/workspace-changed drops its workspace membership. Emit
968
- // session/disposed explicitly so every connected client drops the row.
969
- if (!detached) {
970
- try {
971
- ctx.emit("session/disposed", { id: sessionId });
972
- } catch (_) { /* best-effort */ }
973
- }
974
-
975
- // Workspace accounting + archive-set membership.
976
- await detachFromWorkspaces(sessionId);
977
- await unarchiveSession(sessionId);
978
-
979
- // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
980
- const dir = await sessionDirOf(sessionId);
981
- if (dir !== void 0) {
982
- await rm(dir, { recursive: true, force: true });
983
- }
984
-
985
- return {
986
- ok: true,
987
- sessionId,
988
- wasLive: session !== void 0 || agent !== void 0,
989
- filesRemoved: dir !== void 0
990
- };
991
- };
992
-
993
- /**
994
- * On plugin boot, refold every persisted session projection cache
995
- * row from disk. Sessions moved before coldSnapshot was wired into
996
- * moveSession would otherwise keep their pre-move {createdAt, cwd}
997
- * identity and let the cold list path fall back to basename(cwd),
998
- * surfacing the workspace title (for example "DSH") where the
999
- * session's own title was expected. The sweep is one-shot and
1000
- * idempotent: a row whose identity already matches is a cheap
1001
- * pass-through refold, and a stale one triggers the unrelated-identity
1002
- * full re-read path.
1003
- */
1004
- ctx.effect(async () => {
1005
- const cache = ctx.get("sessionProjectionCache");
1006
- const persistence = ctx.get("sessionPersistence");
1007
- const registry = ctx.get("workspaceRegistry");
1008
- if (cache === void 0 || typeof cache.coldSnapshot !== "function") return;
1009
- if (persistence === void 0 || typeof persistence.list !== "function") return;
1010
- if (registry === void 0 || typeof registry.list !== "function") return;
1011
- try {
1012
- // ---- step 1: re-fold every active session's projection cache row,
1013
- // so a session moved before coldSnapshot was wired in stops being
1014
- // identity-stuck under a stale {createdAt, cwd} key.
1015
- const headers = await listSessionHeaders(persistence, ctx.get("dshHomePath"));
1016
- let refolded = 0;
1017
- const headerById = new Map();
1018
- for (const header of headers) {
1019
- if (header === void 0 || header.cwd === void 0) continue;
1020
- headerById.set(header.id, header);
1021
- try {
1022
- await cache.coldSnapshot(header.id);
1023
- refolded += 1;
1024
- } catch (error) {
1025
- ctx.logger.warn(`session-manager: post-boot cache refold failed for "${header.id}": ${String(error)}`);
1026
- }
1027
- }
1028
- ctx.logger.info(`session-manager: post-boot re-folded ${refolded} session projection cache rows`);
1029
-
1030
- // ---- step 2: reconcile workspace accounting with disk. After a few
1031
- // pre-fix moves, a workspace record could keep a session id whose
1032
- // disk artifact is gone, OR miss a session whose disk artifact is on
1033
- // a matching cwd but not in any record. Both symptoms show up as
1034
- // "AI count higher than visible" / "another session in ungrouped".
1035
- // Walk the registry, drop missing-disk ids, and add missing-in-record
1036
- // ids back into the workspace whose path matches header.cwd.
1037
- const allWorkspaces = registry.list();
1038
- const recordByWs = new Map();
1039
- for (const ws of allWorkspaces) {
1040
- recordByWs.set(ws.id, ws);
1041
- }
1042
- const validIds = new Set(headerById.keys());
1043
- const pathToWs = new Map();
1044
- for (const ws of allWorkspaces) {
1045
- pathToWs.set(ws.path, ws);
1046
- }
1047
- // Pass 1: drop ids whose disk artifact no longer exists.
1048
- let removed = 0;
1049
- for (const ws of allWorkspaces) {
1050
- const filtered = ws.record?.sessionIds?.filter((id) => validIds.has(id));
1051
- const original = ws.record?.sessionIds ?? [];
1052
- if (filtered && filtered.length !== original.length) {
1053
- await registry.enqueueOperation(async () => {
1054
- const state = registry.requireState();
1055
- const w = state.workspaces?.find((s) => s.id === ws.id);
1056
- if (w !== undefined) {
1057
- w.sessionIds = filtered;
1058
- w.updatedAt = new Date().toISOString();
1059
- await registry.setState(state);
1060
- }
1061
- });
1062
- removed += original.length - filtered.length;
1063
- }
1064
- }
1065
- // Pass 2: attach disk-only sessions to the workspace whose path
1066
- // matches their cwd.
1067
- let added = 0;
1068
- for (const header of headers) {
1069
- if (header.cwd === void 0) continue;
1070
- const ws = pathToWs.get(header.cwd);
1071
- if (ws === undefined) continue;
1072
- const rec = ws.record;
1073
- if (rec === undefined || rec.sessionIds === undefined) continue;
1074
- if (rec.sessionIds.includes(header.id)) continue;
1075
- await registry.enqueueOperation(async () => {
1076
- const state = registry.requireState();
1077
- const w = state.workspaces?.find((s) => s.id === ws.id);
1078
- if (w !== undefined && w.sessionIds !== undefined && !w.sessionIds.includes(header.id)) {
1079
- w.sessionIds = [header.id, ...w.sessionIds];
1080
- w.updatedAt = new Date().toISOString();
1081
- await registry.setState(state);
1082
- added += 1;
1083
- }
1084
- });
1085
- }
1086
- if (removed > 0 || added > 0) {
1087
- ctx.logger.info(`session-manager: post-boot workspace reconcile removed ${removed} orphans, attached ${added} disk-only sessions`);
1088
- }
1089
- } catch (error) {
1090
- ctx.logger.warn(`session-manager: post-boot reconcile failed: ${String(error)}`);
1091
- }
1092
- }, "session-manager: post-boot re-fold stale projection cache rows");
1093
-
1094
- ctx.effect(() => ctx.webServer.register({
1095
- kind: "prefix",
1096
- path: API_PREFIX,
1097
- handler: async (req, res) => {
1098
- try {
1099
- const url = new URL(req.url ?? "/", "http://localhost");
1100
- const path = url.pathname.startsWith(API_PREFIX)
1101
- ? url.pathname.slice(API_PREFIX.length) || "/"
1102
- : "/";
1103
- let body = {};
1104
- if (req.method === "POST") {
1105
- const raw = await readBody(req);
1106
- if (raw.trim() !== "") {
1107
- try {
1108
- body = JSON.parse(raw);
1109
- } catch {
1110
- return send(res, 400, { ok: false, error: "请求体不是合法 JSON" });
1111
- }
1112
- }
1113
- }
1114
- const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() !== ""
1115
- ? body.sessionId.trim()
1116
- : null;
1117
-
1118
- if (req.method === "POST" && path === "/delete") {
1119
- if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1120
- try {
1121
- const result = await deleteSession(sessionId);
1122
- return send(res, 200, { ok: true, result });
1123
- } catch (error) {
1124
- ctx.logger.warn(`session-manager: delete "${sessionId}" failed: ${String(error)}`);
1125
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1126
- }
1127
- }
1128
-
1129
- if (req.method === "POST" && path === "/unarchive") {
1130
- if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1131
- try {
1132
- await unarchiveSession(sessionId);
1133
- return send(res, 200, { ok: true, result: { sessionId } });
1134
- } catch (error) {
1135
- ctx.logger.warn(`session-manager: unarchive "${sessionId}" failed: ${String(error)}`);
1136
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1137
- }
1138
- }
1139
-
1140
- if (req.method === "POST" && path === "/move") {
1141
- if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1142
- const targetWorkspaceId = typeof body.targetWorkspaceId === "string" && body.targetWorkspaceId.trim() !== ""
1143
- ? body.targetWorkspaceId.trim()
1144
- : null;
1145
- if (targetWorkspaceId === null) return send(res, 400, { ok: false, error: "targetWorkspaceId 必填" });
1146
- try {
1147
- const result = await moveSession(sessionId, targetWorkspaceId);
1148
- return send(res, 200, { ok: true, result });
1149
- } catch (error) {
1150
- ctx.logger.warn(`session-manager: api error: ${String(error)}; type=${error?.constructor?.name}; json=${safeJson(error)}`);
1151
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1152
- }
1153
- }
1154
-
1155
- if (req.method === "GET" && path === "/workspaces") {
1156
- try {
1157
- const workspaces = listWorkspaces();
1158
- return send(res, 200, { ok: true, result: { workspaces } });
1159
- } catch (error) {
1160
- ctx.logger.warn(`session-manager: listWorkspaces failed: ${String(error)}`);
1161
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1162
- }
1163
- }
1164
-
1165
-
1166
-
1167
- if (req.method === "GET" && path === "/preset-scan") {
1168
- try {
1169
- const workspaceId = url.searchParams.get("workspaceId") || "";
1170
- const requestedSessionId = url.searchParams.get("sessionId") || "";
1171
- const filter = {
1172
- ...(workspaceId !== "" ? { id: workspaceId } : {}),
1173
- ...(requestedSessionId !== "" ? { sessionId: requestedSessionId } : {})
1174
- };
1175
- const result = await scanPreset(filter);
1176
- return send(res, 200, { ok: true, result });
1177
- } catch (error) {
1178
- ctx.logger.warn("session-manager: preset-scan failed: " + String(error));
1179
- return send(res, 500, { ok: false, error: String(error.message || error) });
1180
- }
1181
- }
1182
- if (req.method === "POST" && path === "/preset-migrate") {
1183
- const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
1184
- const toPreset = typeof body.toPreset === "string" ? body.toPreset.trim() : "";
1185
- if (requestedSessionId === "" || toPreset === "") {
1186
- return send(res, 400, { ok: false, error: "sessionId and toPreset required" });
1187
- }
1188
- try {
1189
- const result = await migratePreset({ sessionId: requestedSessionId, toPreset });
1190
- return send(res, 200, { ok: true, result });
1191
- } catch (error) {
1192
- ctx.logger.warn("session-manager: preset-migrate failed: " + String(error));
1193
- return send(res, 500, { ok: false, error: String(error.message || error) });
1194
- }
1195
- }
1196
-
1197
- return send(res, 404, { ok: false, error: `not found: ${req.method} ${path}` });
1198
- } catch (error) {
1199
- ctx.logger.warn(`session-manager: api error: ${String(error)}; type=${error?.constructor?.name}; json=${safeJson(error)}`);
1200
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1201
- }
1202
- } }), "session-manager: http api");
1203
-
1204
-
1205
- }
1206
-
1207
- export { listSessionHeaders, encodeSessionSegment };
1
+ /**
2
+ * @dsh-session-manager — Host half.
3
+ *
4
+ * Cordis plugin that fills three gaps in DSH:
5
+ * - delete / unarchive (DSH ships `workspace.archiveSession` but not its inverse);
6
+ * - cross-workspace move (DSH indexes workspace membership off the session
7
+ * header's immutable `cwd`, so a real move must re-home the artifact);
8
+ * - per-conversation Agent-preset migration (rewrite the effective preset
9
+ * in place so the next event fold sees the new value).
10
+ *
11
+ * All durable rewrites go through DSH's own persistence seam (readRaw /
12
+ * create / append / flush on the jsonl backend); the JSONL codec chain
13
+ * migrates v2 -> v3 transparently, so the emitted artifact is always valid
14
+ * for the running DSH build. The plugin never reaches into private DSH
15
+ * module paths.
16
+ *
17
+ * The move and migrate paths do NOT tear down the live agent or session.
18
+ * The in-memory session stays alive, the file is rewritten in place under
19
+ * a new cwd (move) or a new event is appended to the existing log
20
+ * (migrate), and the workspace registry is updated. The api-gateway's
21
+ * chat panel therefore remains "available" throughout; the user does not
22
+ * have to refresh.
23
+ *
24
+ * Compat boundary (lib/compat/) is loaded only because the test suite
25
+ * asserts a static import; the apply() body inlines the DSH services it
26
+ * uses and never instantiates the adapter.
27
+ */
28
+ import { mkdir, open, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
29
+ import { dirname, join } from "node:path";
30
+ import { randomBytes } from "node:crypto";
31
+ import { createDshAdapter } from "./compat/dsh-adapter.js";
32
+ import { decompressAllZstdFrames } from "./compat/zstd-frames.js";
33
+
34
+ export const name = "dsh-session-manager";
35
+
36
+ export const inject = [
37
+ "webServer",
38
+ "workspaceRegistry",
39
+ "sessions",
40
+ "agents",
41
+ "sessionPersistence",
42
+ "agentPresets"
43
+ ];
44
+
45
+ /** Like JSON.stringify but swallows circular-reference/BigInt errors so the
46
+ * logger never throws while reporting a throw. */
47
+ function safeJson(value) { try { return JSON.stringify(value); } catch { return "[unserializable]"; } }
48
+ /** Like safeJson but used as the user-facing error string in HTTP responses. */
49
+ function safeErrorMessage(error) {
50
+ if (error === null || error === undefined) return `<nullish:${typeof error}>`;
51
+ if (typeof error === "string") return error;
52
+ if (typeof error === "object") {
53
+ if (typeof error.message === "string" && error.message.length > 0) return error.message;
54
+ if (typeof error.code === "string" && error.code.length > 0) return `[code=${error.code}]`;
55
+ }
56
+ try { return JSON.stringify(error); } catch { return String(error); }
57
+ }
58
+ const API_PREFIX = "/session-manager/api";
59
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
60
+ function liveWriterOf(persistence, sessionId) {
61
+ const writers = persistence?.tracker?.writers;
62
+ return typeof writers?.get === "function" ? writers.get(sessionId) : undefined;
63
+ }
64
+
65
+ /**
66
+ * Retarget the existing live JSONL writer after its artifact is relocated.
67
+ * The agent loop retains the original SessionHandle until the agent is
68
+ * disposed; replacing the tracker entry with a newly opened handle leaves
69
+ * that retained handle stale and can make later teardown delete the new
70
+ * tracker entry. The compiled DSH handle exposes a writable runtime
71
+ * header property even though the TypeScript declaration is readonly.
72
+ * Mutating that property keeps the same handle, queue, cursor, and lease.
73
+ */
74
+ function rebindLiveWriter(persistence, sessionId, newHeader) {
75
+ const writer = liveWriterOf(persistence, sessionId);
76
+ if (writer === undefined || writer === null) {
77
+ throw new Error("当前 DSH 运行时未暴露可重绑定的 live persistence writer,无法安全迁移运行中的会话;请先关闭会话后重试");
78
+ }
79
+ if (typeof writer.header !== "object" || writer.header === null) {
80
+ throw new Error("当前持久化后端的 live writer 不支持安全重绑定;请先关闭会话后重试");
81
+ }
82
+ const reboundHeader = Object.freeze({ ...newHeader });
83
+ writer.header = reboundHeader;
84
+ return writer;
85
+ }
86
+
87
+
88
+ /**
89
+ * Encode a session id the way DSH's persistence layer stores its project
90
+ * directory: separators and unsafe code units become "--<encoded>--" markers.
91
+ * This mirrors the projectKey helper inside the JSONL persistence backend.
92
+ * (The name encodeSessionSegment is a leftover from an earlier refactor;
93
+ * the function encodes a session id, which DSH derives from a workspace
94
+ * path; "project" + "segment" both refer to that same mapping.)
95
+ */
96
+ const encodeSessionSegment = (id) => {
97
+ let readable = "";
98
+ let separatorRun = false;
99
+ for (let i = 0; i < id.length; i++) {
100
+ const ch = id[i];
101
+ const code = id.charCodeAt(i);
102
+ if (ch === "/" || ch === "\\" || ch === ":") {
103
+ if (!separatorRun) readable += "-";
104
+ separatorRun = true;
105
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
106
+ readable += ch;
107
+ separatorRun = false;
108
+ } else {
109
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
110
+ separatorRun = false;
111
+ }
112
+ }
113
+ return "--" + (readable.replace(/^-+/, "") || "root").slice(0, 251) + "--";
114
+ };
115
+
116
+ /** List every persisted session header visible to the configured persistence
117
+ * backend, supplemented by raw-id sessions written by older DSH builds.
118
+ *
119
+ * Performance (issue #6): the disk loop previously opened and zstd-decoded
120
+ * every candidate file in <dshHome>/sessions/<proj>/<dir>/ even when the
121
+ * persistence layer had already returned those ids. On a healthy index
122
+ * that wasted O(N * file_size) read+inflate per call -- the reporter
123
+ * measured +17s CPU / +254 MB read per DSH startup on an 80-session /
124
+ * 249 MB library. We now pre-build a `knownDirs` set from the persisted
125
+ * ids (both `[id]` and `[encodeSessionSegment(id)]`) and skip matching
126
+ * directories before any fopen. Orphan raw-id directories that the
127
+ * index does not know about still fall through to the file read so the
128
+ * disk fallback semantics are preserved -- the orphan is the whole
129
+ * reason this loop exists.
130
+ *
131
+ * @param {object|undefined} persistence DSH's sessionPersistence service
132
+ * @param {((segment: string) => string)|undefined} dshHome Resolver for
133
+ * DSH home sub-paths; only "sessions" is read.
134
+ */
135
+ const listSessionHeaders = async (persistence, dshHome) => {
136
+ const seen = new Set();
137
+ const out = [];
138
+ if (persistence !== void 0 && typeof persistence.list === "function") {
139
+ try {
140
+ for (const header of await persistence.list()) {
141
+ if (header === void 0 || typeof header.id !== "string") continue;
142
+ seen.add(header.id);
143
+ out.push(header);
144
+ }
145
+ } catch { /* fall through to disk */ }
146
+ }
147
+
148
+ // Early-skip set for the disk fallback. For every id already in `seen`,
149
+ // both candidate directory names ([encodeSessionSegment(id), id]) are
150
+ // guaranteed to map back to that header; opening those files would only
151
+ // re-decode a payload we already have. Mark them up-front and skip with
152
+ // an O(1) Set lookup instead of an fopen + inflate + JSON.parse
153
+ // roundtrip. Genuine orphans (raw-id sessions the index does not
154
+ // surface) are not in `knownDirs` and still fall through to the file
155
+ // read below -- the disk fallback semantics are preserved.
156
+ const knownDirs = new Set();
157
+ for (const id of seen) {
158
+ knownDirs.add(id);
159
+ knownDirs.add(encodeSessionSegment(id));
160
+ }
161
+
162
+ if (typeof dshHome !== "function") return out;
163
+ const root = dshHome("sessions");
164
+ let projects;
165
+ try { projects = await readdir(root, { withFileTypes: true }); }
166
+ catch { return out; }
167
+ for (const proj of projects) {
168
+ if (!proj.isDirectory()) continue;
169
+ let dirs;
170
+ try { dirs = await readdir(join(root, proj.name), { withFileTypes: true }); }
171
+ catch { continue; }
172
+ for (const entry of dirs) {
173
+ if (!entry.isDirectory()) continue;
174
+ // Cheap directory-name skip -- the issue #6 hot path fix.
175
+ if (knownDirs.has(entry.name)) continue;
176
+ for (const filename of ["session.v3.jsonl.zstd", "session.v2.jsonl.zstd", "session.jsonl.zstd", "session.jsonl"]) {
177
+ const filePath = join(root, proj.name, entry.name, filename);
178
+ try {
179
+ const buf = await readFile(filePath);
180
+ let content;
181
+ if (filename.endsWith(".zstd")) {
182
+ // lib/compat/zstd-frames.js#decompressAllZstdFrames walks every
183
+ // complete concatenated frame (one frame per DSH batch) and
184
+ // concatenates the plaintexts. Throws on empty/header-less
185
+ // buffers and on corrupt headers; the outer catch swallows
186
+ // both so we move on to the next candidate filename.
187
+ try {
188
+ const { content: decoded } = await decompressAllZstdFrames(buf);
189
+ content = decoded.toString("utf8");
190
+ } catch {
191
+ continue;
192
+ }
193
+ } else {
194
+ content = buf.toString("utf8");
195
+ }
196
+ const headerLine = content.split("\n", 1)[0];
197
+ const meta = JSON.parse(headerLine);
198
+ if (typeof meta?.id !== "string" || seen.has(meta.id)) continue;
199
+ seen.add(meta.id);
200
+ out.push({ id: meta.id, cwd: meta.cwd, createdAt: meta.createdAt, version: meta.version, origin: meta.origin });
201
+ } catch { /* skip */ }
202
+ }
203
+ }
204
+ }
205
+ return out;
206
+ };
207
+ export function apply(ctx) {
208
+ const readBody = async (req) => {
209
+ const chunks = [];
210
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
211
+ return Buffer.concat(chunks).toString("utf8");
212
+ };
213
+
214
+ const send = (res, code, obj) => {
215
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
216
+ res.end(JSON.stringify(obj));
217
+ };
218
+
219
+ /** Remove one id from the registry-global archive set (durable, serialized). */
220
+ const unarchiveSession = async (sessionId) => {
221
+ const registry = ctx.workspaceRegistry;
222
+ await registry.enqueueOperation(async () => {
223
+ const state = registry.requireState();
224
+ if (!state.archivedSessionIds.includes(sessionId)) return;
225
+ await registry.setState({
226
+ ...state,
227
+ archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
228
+ });
229
+ });
230
+ };
231
+
232
+ /** Detach one session from every workspace's ordered accounting. */
233
+ const detachFromWorkspaces = async (sessionId) => {
234
+ for (const entity of ctx.workspaceRegistry.list()) {
235
+ if (entity.sessionIds.includes(sessionId)) {
236
+ await entity.detachSession(sessionId);
237
+ }
238
+ }
239
+ };
240
+
241
+ /** Ordered workspace projection for the client. */
242
+ const listWorkspaces = () => {
243
+ return ctx.workspaceRegistry.list().map((entity) => {
244
+ const rawIds = Array.isArray(entity.record?.sessionIds)
245
+ ? [...entity.record.sessionIds]
246
+ : [...entity.sessionIds];
247
+ return {
248
+ id: entity.id,
249
+ name: entity.title || entity.id,
250
+ title: entity.title || entity.id,
251
+ path: entity.path,
252
+ sessionCount: rawIds.length,
253
+ sessionIds: rawIds
254
+ };
255
+
256
+ });
257
+ };
258
+
259
+
260
+ /**
261
+ * Durable temp-file write next to its final target. Returns the temp path;
262
+ * the caller publishes it with a rename once the coast is clear.
263
+ */
264
+ const writeTempFile = async (finalPath, data) => {
265
+ const temp = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
266
+ const handle = await open(temp, "wx", 0o600);
267
+ try {
268
+ await handle.writeFile(data);
269
+ await handle.sync();
270
+ } finally {
271
+ await handle.close();
272
+ }
273
+ return temp;
274
+ };
275
+
276
+ /**
277
+ * Encode the moved artifact in the backend's own physical layout:
278
+ * plain JSONL, or zstd frames whose FIRST frame is exactly the header
279
+ * line (the reader's assertZstdHeaderFrame / readFirstZstdLine contract).
280
+ */
281
+ const encodeArtifact = async (headerLine, rest, isZstd) => {
282
+ if (!isZstd) return Buffer.from(`${headerLine}
283
+ ${rest}`, "utf8");
284
+ const { zstdCompress } = await import("node:zlib");
285
+ if (typeof zstdCompress !== "function") {
286
+ throw new Error("当前 Node 运行时没有 zstd 支持,无法迁移 zstd 编码的会话工件");
287
+ }
288
+ const compress = (buffer) => new Promise((resolve, reject) => {
289
+ zstdCompress(buffer, (error, output) => error == null ? resolve(output) : reject(error));
290
+ });
291
+ const headerFrame = await compress(Buffer.from(`${headerLine}\n`, "utf8"));
292
+ if (rest === "") return headerFrame;
293
+ const bodyFrame = await compress(Buffer.from(rest, "utf8"));
294
+ return Buffer.concat([headerFrame, bodyFrame]);
295
+ };
296
+
297
+ /**
298
+ * TRUE cross-workspace move. See the module docstring for the full design:
299
+ * re-home the stored cwd, migrate the artifact, then swap accounting.
300
+ * Throws an Error with a readable message on every failure path; the
301
+ * artifact is restored (or its location reported) whenever a mid-move
302
+ * step fails, so the session never ends up in a half-moved state.
303
+ */
304
+ const moveSession = async (sessionId, targetWorkspaceId) => {
305
+ const registry = ctx.workspaceRegistry;
306
+ const persistence = ctx.get("sessionPersistence");
307
+ const dshHomePath = ctx.get("dshHomePath");
308
+ if (persistence === void 0) throw new Error("sessionPersistence 服务不可用");
309
+ if (typeof persistence.locate !== "function") {
310
+ throw new Error("当前持久化后端无法定位会话工件,无法跨工作区移动");
311
+ }
312
+
313
+ const target = registry.list().find((entity) => entity.id === targetWorkspaceId);
314
+ if (target === void 0) {
315
+ const error = new Error(`目标工作区不存在: ${targetWorkspaceId}`);
316
+ error.code = "workspace-not-found";
317
+ throw error;
318
+ }
319
+ const targetPath = target.path;
320
+
321
+ // Fast path: the common case -- the session is already in the
322
+ // persistence index -- costs one .list() + .find(). The previous
323
+ // code called listSessionHeaders() unconditionally, which (before
324
+ // the issue #6 fix) re-read every file in the session library even
325
+ // when the index already had the answer. We still fall through to
326
+ // the disk fallback for raw-id orphans persistence.list() cannot
327
+ // surface (older DSH builds wrote plain-id directories that the
328
+ // index may not know about).
329
+ let storedHeader;
330
+ if (persistence !== void 0 && typeof persistence.list === "function") {
331
+ try {
332
+ const headers = await persistence.list();
333
+ storedHeader = headers.find((header) =>
334
+ header !== void 0 && typeof header.id === "string" && header.id === sessionId
335
+ );
336
+ } catch { /* fall through to disk fallback below */ }
337
+ }
338
+ if (storedHeader === void 0) {
339
+ const storedHeaders = await listSessionHeaders(persistence, dshHomePath);
340
+ storedHeader = storedHeaders.find((header) => header !== void 0 && header.id === sessionId);
341
+ }
342
+ if (storedHeader === void 0) {
343
+ const error = new Error(
344
+ `会话 ${sessionId} 没有磁盘记录(不存在,或是一个尚未发送任何消息的空白会话),无法移动`
345
+ );
346
+ error.code = "session-not-found";
347
+ throw error;
348
+ }
349
+ if (storedHeader.origin === "subagent") {
350
+ const error = new Error("子代理(subagent)会话不支持跨工作区移动");
351
+ error.code = "subagent-unsupported";
352
+ throw error;
353
+ }
354
+ if (storedHeader.cwd !== void 0) {
355
+ let currentCanonical;
356
+ try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
357
+ if (currentCanonical === targetPath) {
358
+ return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
359
+ }
360
+ }
361
+
362
+ const liveSession = ctx.sessions.get(sessionId);
363
+ if (liveSession !== void 0 && liveWriterOf(persistence, sessionId) === undefined) {
364
+ throw new Error("当前 DSH 运行时未暴露可重绑定的 live persistence writer,无法安全迁移运行中的会话;请先关闭会话后重试");
365
+ }
366
+ let liveAgent;
367
+ try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
368
+ // The 0.4.6+ move path is no-teardown: the live agent/session stays
369
+ // alive throughout. We only rewrite the on-disk artifact in place and
370
+ // update the in-memory session header to point at the new cwd; the
371
+ // api-gateway's chat panel keeps the in-memory session as active.
372
+ // The persistence coordinator's per-id serialize() (if available) wraps
373
+ // the rewrite so the write-behind cannot append to the hidden file.
374
+ // client UI never has to re-init. Just flush pending events to
375
+ // disk (still at OLD cwd), then atomically rename the artifact to
376
+ // NEW cwd. The persistence coordinator's serialize() inside the
377
+ // move block holds the per-id lock, so the write-behind can't append
378
+ // to the hidden file. The agent's UI keeps showing the same in-memory
379
+ // session -- no client state flip, no half-initialized resume, no
380
+ // "session unavailable" symptoms.
381
+ if (liveSession !== void 0) {
382
+ try { await ctx.sessions.flush(liveSession); } catch { /* best-effort */ }
383
+ }
384
+
385
+ // No teardown -- the agent and session are still live and that's
386
+ // intentional. Move straight to file re-home.
387
+ const coordinator = persistence.coordinator;
388
+ const serialize = typeof coordinator?.serialize === "function"
389
+ ? (operation) => coordinator.serialize(sessionId, operation)
390
+ : (operation) => operation();
391
+
392
+ const result = await serialize(async () => {
393
+ const raw = await readSessionArtifact(sessionId);
394
+ if (raw === void 0) throw new Error("读取会话工件失败:未找到会话文件");
395
+ const newlineAt = raw.content.indexOf("\n");
396
+ if (newlineAt === -1) throw new Error("会话工件缺少头行,数据可能损坏");
397
+ const rest = raw.content.slice(newlineAt + 1);
398
+ let oldHeader;
399
+ try { oldHeader = JSON.parse(raw.content.slice(0, newlineAt)); }
400
+ catch (error) { throw new Error(`会话工件头行无法解析: ${String(error)}`); }
401
+ if (oldHeader.id !== sessionId) throw new Error("会话工件头行 id 与请求不符,拒绝移动");
402
+
403
+ // Keep the stored generation in sync with the filename selected by
404
+ // persistence.locate(). Rewriting a v2 header to v0 while locating it
405
+ // through the same v2 backend creates a v2 filename with v0 content,
406
+ // which makes DSH reject the session on the next startup.
407
+ const newHeader = { ...oldHeader, cwd: targetPath };
408
+ const oldPath = raw.path ?? persistence.locate(raw.meta)?.path;
409
+ const newLocation = persistence.locate(newHeader);
410
+ if (typeof oldPath !== "string" || newLocation === void 0) {
411
+ throw new Error("持久化后端无法定位会话工件路径");
412
+ }
413
+ const newPath = newLocation.path;
414
+ if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
415
+ const bytes = await encodeArtifact(JSON.stringify(newHeader), rest, newPath.endsWith(".zstd"));
416
+ await mkdir(dirname(newPath), { recursive: true });
417
+ const tempNew = await writeTempFile(newPath, bytes);
418
+ const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
419
+ try {
420
+ await rename(oldPath, oldHidden);
421
+ } catch (error) {
422
+ await rm(tempNew, { force: true });
423
+ throw new Error(`移动失败(无法隐藏旧会话工件,已取消,会话保持原状): ${String(error)}`);
424
+ }
425
+ try {
426
+ await rename(tempNew, newPath);
427
+ } catch (error) {
428
+ try { await rename(oldHidden, oldPath); } catch { /* reported below */ }
429
+ await rm(tempNew, { force: true });
430
+ throw new Error(`移动失败(发布新会话工件失败,已尝试回滚): ${String(error)}`);
431
+ }
432
+
433
+ const state = coordinator?.states?.get?.(sessionId);
434
+ if (state?.owner !== void 0 && liveSession !== void 0 && state.owner !== liveSession) {
435
+ await rm(newPath, { force: true });
436
+ await rename(oldHidden, oldPath);
437
+ throw new Error(`会话 ${sessionId} 的持久化 owner 与当前 live session 不一致,已回滚`);
438
+ }
439
+ const oldStateMeta = state?.meta;
440
+ const oldLiveHeader = liveSession?.header;
441
+ const liveWriter = liveSession === void 0 ? void 0 : liveWriterOf(persistence, sessionId);
442
+ const oldWriterHeader = liveWriter?.header;
443
+ const oldIndexedHeader = registry.headers?.get?.(sessionId);
444
+ const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
445
+ const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
446
+ const fromWorkspaceIds = registry.list()
447
+ .filter((entity) => entity.id !== target.id && (entity.record?.sessionIds ?? entity.sessionIds).includes(sessionId))
448
+ .map((entity) => entity.id);
449
+ let targetAttached = false;
450
+ const detachedFrom = [];
451
+
452
+ try {
453
+ if (state !== void 0) {
454
+ state.meta = { ...state.meta, ...newHeader, cwd: targetPath };
455
+ state.materialized = true;
456
+ }
457
+ coordinator?.preparations?.invalidate?.(sessionId);
458
+ if (liveSession !== void 0) {
459
+ liveSession.header = Object.freeze({ ...newHeader });
460
+ rebindLiveWriter(persistence, sessionId, newHeader);
461
+ }
462
+ registry.headers?.set?.(sessionId, { ...newHeader });
463
+ registry.sessionPaths?.set?.(sessionId, targetPath);
464
+ registry.invalidSessionPaths?.delete?.(sessionId);
465
+
466
+ await registry.enqueueOperation(async () => {
467
+ for (const id of fromWorkspaceIds) {
468
+ const entity = registry.list().find((candidate) => candidate.id === id);
469
+ if (entity === void 0) continue;
470
+ await entity.detachSession(sessionId);
471
+ detachedFrom.push(id);
472
+ }
473
+ await target.attachSession(sessionId);
474
+ targetAttached = true;
475
+ });
476
+ } catch (error) {
477
+ if (state !== void 0 && oldStateMeta !== void 0) state.meta = oldStateMeta;
478
+ coordinator?.preparations?.invalidate?.(sessionId);
479
+ if (liveSession !== void 0 && oldLiveHeader !== void 0) liveSession.header = oldLiveHeader;
480
+ if (liveWriter !== void 0 && oldWriterHeader !== void 0) liveWriter.header = oldWriterHeader;
481
+ if (oldIndexedHeader === void 0) registry.headers?.delete?.(sessionId);
482
+ else registry.headers?.set?.(sessionId, oldIndexedHeader);
483
+ if (oldIndexedPath === void 0) registry.sessionPaths?.delete?.(sessionId);
484
+ else registry.sessionPaths?.set?.(sessionId, oldIndexedPath);
485
+ if (oldInvalidPath === void 0) registry.invalidSessionPaths?.delete?.(sessionId);
486
+ else registry.invalidSessionPaths?.set?.(sessionId, oldInvalidPath);
487
+
488
+ const rollbackErrors = [];
489
+ try { if (targetAttached) await target.detachSession(sessionId); } catch (e) { rollbackErrors.push(e); }
490
+ for (const id of detachedFrom) {
491
+ try {
492
+ const entity = registry.list().find((candidate) => candidate.id === id);
493
+ if (entity !== void 0) await entity.attachSession(sessionId);
494
+ } catch (e) { rollbackErrors.push(e); }
495
+ }
496
+ try { await rm(newPath, { force: true }); } catch (e) { rollbackErrors.push(e); }
497
+ try { await rename(oldHidden, oldPath); } catch (e) { rollbackErrors.push(e); }
498
+ if (rollbackErrors.length > 0) {
499
+ throw new AggregateError([error, ...rollbackErrors], "移动失败且回滚不完整");
500
+ }
501
+ throw error;
502
+ }
503
+
504
+ // rebindLiveWriter() above retargeted the existing writer before
505
+ // cleanup, so subsequent routed events use the target cwd without
506
+ // replacing the handle retained by the live agent.
507
+ try { await rm(oldHidden, { force: true }); } catch { /* best-effort */ }
508
+ try { await rm(dirname(oldPath), { recursive: true, force: true }); } catch { /* best-effort */ }
509
+
510
+ return {
511
+ ok: true,
512
+ sessionId,
513
+ moved: true,
514
+ fromWorkspaceIds,
515
+ toWorkspaceId: target.id,
516
+ toWorkspaceTitle: target.title || target.id,
517
+ artifactFrom: oldPath,
518
+ artifactTo: newPath,
519
+ wasLive: liveSession !== void 0 || liveAgent !== void 0
520
+ };
521
+ });
522
+
523
+ const cache = ctx.get("sessionProjectionCache");
524
+ if (cache !== void 0 && typeof cache.coldSnapshot === "function") {
525
+ try { await cache.coldSnapshot(sessionId); }
526
+ catch (error) { ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`); }
527
+ }
528
+ // The agent is still live (no teardown). The session's in-memory
529
+ // header was rewritten above and the workspace registry updated;
530
+ // future writes go through persistence.locate() against the new
531
+ // cwd, so the file at the new location accumulates new events.
532
+ // Re-resuming the agent here re-creates a half-initialized
533
+ // Session/agent pair and leaves the client UI in a "session
534
+ // unavailable" state (agent/status is only emitted on PHASE
535
+ // CHANGES, never on agent construction). Leaving the live
536
+ // agent/session in place avoids the broken UI entirely.
537
+ ctx.logger.info(`session-manager: moved "${sessionId}" to workspace "${target.id}" (in-place file rename, live agent retained)`);
538
+ return result;
539
+ };
540
+ /* === preset-migration (session-manager v0.3.0) === */
541
+ const NL = String.fromCharCode(10);
542
+
543
+ const scanSelectedEvents = (content) => {
544
+ const out = [];
545
+ const newlineAt = content.indexOf(NL);
546
+ const after = newlineAt === -1 ? content : content.slice(newlineAt + 1);
547
+ for (const line of after.split(NL)) {
548
+ if (line.length === 0) continue;
549
+ let obj;
550
+ try { obj = JSON.parse(line); } catch { continue; }
551
+ if (obj !== null && typeof obj === "object" && obj.type === "agent-preset/selected" && obj.data !== void 0 && typeof obj.data.agentPreset === "string") {
552
+ out.push({ agentPreset: obj.data.agentPreset, time: obj.time, seq: obj.seq });
553
+ }
554
+ }
555
+ return out;
556
+ };
557
+
558
+ const scanPreset = async (filter) => {
559
+ // Pull the full preset roster so the UI shows every available preset,
560
+ // not just the ones currently used by some session.
561
+ let allPresetIds = [];
562
+ try {
563
+ const ap = ctx.get("agentPresets");
564
+ if (ap !== void 0 && typeof ap.list === "function") {
565
+ const list = await ap.list();
566
+ for (const p of (list || [])) {
567
+ if (p && typeof p.id === "string" && p.id.length > 0) allPresetIds.push(p.id);
568
+ }
569
+ }
570
+ } catch { /* ignore -- fall back to used presets only */ }
571
+
572
+ const persistence = ctx.get("sessionPersistence");
573
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
574
+ // Don't pre-flight check readRaw/list -- the actual JSONL backend
575
+ // exposes both. listSessionHeaders is wrapped in try/catch below so
576
+ // any missing-method error surfaces as a useful per-session "no
577
+ // artifact" message instead of a hard "backend does not support"
578
+ // failure that hides which session actually failed.
579
+ let headers = [];
580
+ try {
581
+ headers = await listSessionHeaders(persistence, ctx.get("dshHomePath"));
582
+ } catch (probeError) {
583
+ throw new Error("failed to enumerate sessions: " + String(probeError.message ?? probeError));
584
+ }
585
+ let allowedIds = null;
586
+ const workspaceId = filter !== void 0 && typeof filter === "object" ? filter.id : void 0;
587
+ if (typeof workspaceId === "string" && workspaceId !== "") {
588
+ const registry = ctx.get("workspaceRegistry");
589
+ if (registry !== void 0 && typeof registry.list === "function") {
590
+ const all = registry.list();
591
+ const ws = all.find((entity) => entity.id === workspaceId);
592
+ if (ws === void 0) {
593
+ const error = new Error("workspace not found: " + workspaceId);
594
+ error.code = "workspace-not-found";
595
+ throw error;
596
+ }
597
+ allowedIds = new Set();
598
+ const ids = typeof ws.sessionIds === "function" ? ws.sessionIds() : ws.sessionIds;
599
+ if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
600
+ }
601
+ }
602
+ const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
603
+ ? filter.sessionId
604
+ : "";
605
+ const out = [];
606
+ for (const header of headers) {
607
+ if (requestedSessionId !== "" && header.id !== requestedSessionId) continue;
608
+ if (allowedIds !== null && !allowedIds.has(header.id)) continue;
609
+ if (header.origin === "subagent") continue;
610
+ const raw = await readSessionArtifact(header.id);
611
+ if (raw === void 0) continue;
612
+ const newlineAt = raw.content.indexOf(NL);
613
+ if (newlineAt === -1) continue;
614
+ let headerObj;
615
+ try { headerObj = JSON.parse(raw.content.slice(0, newlineAt)); } catch { continue; }
616
+ let selected = scanSelectedEvents(raw.content);
617
+ const live = ctx.sessions.get(header.id);
618
+ if (live !== void 0) {
619
+ const liveEvents = Array.isArray(live.events)
620
+ ? live.events
621
+ : Array.isArray(live.log) ? live.log : [];
622
+ const liveSelected = liveEvents
623
+ .filter((event) => event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string")
624
+ .map((event) => ({ agentPreset: event.data.agentPreset, time: event.time, seq: event.seq }));
625
+ if (liveSelected.length > selected.length) selected = liveSelected;
626
+ }
627
+ const finalPreset = selected.length > 0 ? selected[selected.length - 1].agentPreset : headerObj.agentPreset;
628
+ out.push({ sessionId: header.id, cwd: header.cwd, origin: header.origin, headerPreset: headerObj.agentPreset, selectedEvents: selected, finalPreset });
629
+ }
630
+ // Targets come only from the live roster. A deleted preset can still be
631
+ // displayed as this session's current value, but must not be offered as
632
+ // a destination.
633
+ return { rows: out, availablePresets: Array.from(new Set(allPresetIds)) };
634
+ };
635
+
636
+ /**
637
+ * Rewrite the effective preset of one conversation in place on disk.
638
+ * The 0.4.6+ flow is no-teardown: when a live session is in store we
639
+ * append the new agent-preset/selected event via Session.append() and
640
+ * flush through SessionStore.flush(); for cold sessions we rewrite
641
+ * the last matching event directly on the existing artifact. In both
642
+ * cases the live agent/session is left intact, the api-gateway chat
643
+ * panel stays available, and the projection picks up the new value
644
+ * on the next event fold.
645
+ */
646
+ /** Cold-path preset migration: read disk, rewrite, publish. Works whether
647
+ * or not the session is currently in the coordinator, and does not depend on
648
+ * `coordinator.states` carrying a fresh `meta.cwd`. */
649
+ const applyMigrationToArtifact = async (args) => {
650
+ const sessionId = typeof args.sessionId === "string" ? args.sessionId : "";
651
+ const toPreset = typeof args.toPreset === "string" ? args.toPreset : "";
652
+ const liveSession = (args !== void 0 && typeof args === "object") ? args.liveSession : void 0;
653
+ const persistence = ctx.get("sessionPersistence");
654
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
655
+ const coordinator = persistence.coordinator;
656
+ const serialize = typeof coordinator?.serialize === "function"
657
+ ? (operation) => coordinator.serialize(sessionId, operation)
658
+ : (operation) => operation();
659
+ return await serialize(async () => {
660
+ const raw = await readSessionArtifact(sessionId);
661
+ if (raw === void 0) throw new Error(`session "${sessionId}" has no artifact`);
662
+ const lines = raw.content.split(NL);
663
+ if (lines.length < 2) throw new Error("session artifact has no header line");
664
+ let header;
665
+ try { header = JSON.parse(lines[0]); } catch { throw new Error("session header parse failed"); }
666
+ if (header.id !== sessionId) throw new Error("session header id mismatch");
667
+
668
+ let selectedIndex = -1;
669
+ let selectedPreset;
670
+ for (let index = 1; index < lines.length; index += 1) {
671
+ if (lines[index] === "") continue;
672
+ let event;
673
+ try { event = JSON.parse(lines[index]); } catch { continue; }
674
+ if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
675
+ selectedIndex = index;
676
+ selectedPreset = event.data.agentPreset;
677
+ }
678
+ }
679
+ const oldPreset = selectedIndex >= 0 ? selectedPreset : header.agentPreset;
680
+ if (oldPreset === toPreset) {
681
+ return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
682
+ }
683
+
684
+ if (selectedIndex >= 0) {
685
+ const event = JSON.parse(lines[selectedIndex]);
686
+ event.data = { ...event.data, agentPreset: toPreset };
687
+ lines[selectedIndex] = JSON.stringify(event);
688
+ } else {
689
+ header.agentPreset = toPreset;
690
+ lines[0] = JSON.stringify(header);
691
+ }
692
+
693
+ const artifactPath = raw.path ?? persistence.locate?.(raw.meta)?.path;
694
+ if (typeof artifactPath !== "string") throw new Error("cannot locate session artifact");
695
+ const replacementContent = lines.join(NL);
696
+ const firstNewline = replacementContent.indexOf(NL);
697
+ const bytes = await encodeArtifact(
698
+ replacementContent.slice(0, firstNewline),
699
+ replacementContent.slice(firstNewline + 1),
700
+ artifactPath.endsWith(".zstd")
701
+ );
702
+ const tempPath = await writeTempFile(artifactPath, bytes);
703
+ const hiddenPath = `${artifactPath}.${randomBytes(6).toString("hex")}.tmp`;
704
+ try {
705
+ await rename(artifactPath, hiddenPath);
706
+ await rename(tempPath, artifactPath);
707
+ } catch (error) {
708
+ try { await rm(artifactPath, { force: true }); } catch { /* best-effort */ }
709
+ try { await rename(hiddenPath, artifactPath); } catch { /* reported below */ }
710
+ try { await rm(tempPath, { force: true }); } catch { /* best-effort */ }
711
+ throw new Error(`preset migration publish failed: ${String(error)}`);
712
+ }
713
+ try { await rm(hiddenPath, { force: true }); } catch { /* best-effort */ }
714
+
715
+ const state = coordinator?.states?.get?.(sessionId);
716
+ if (state !== void 0 && selectedIndex < 0) state.meta = { ...state.meta, agentPreset: toPreset };
717
+ coordinator?.preparations?.invalidate?.(sessionId);
718
+ // Mirror the disk rewrite into the in-memory live session: the cold path
719
+ // bypasses the persistence write controller (which is intentionally
720
+ // skipped when its state is stale), so the session header, projection,
721
+ // and downstream agent composition would otherwise keep reporting the old
722
+ // preset. Update both the header and the in-memory event log so DSH's
723
+ // `resolveSessionPreset` and any projection subscribers observe the new
724
+ // preset from the same source.
725
+ if (liveSession !== void 0) {
726
+ try {
727
+ const nextHeader = { ...(liveSession.header ?? {}), agentPreset: toPreset };
728
+ try { liveSession.header = Object.freeze(nextHeader); }
729
+ catch { liveSession.header = nextHeader; }
730
+ } catch { /* live session may have been retired mid-flight */ }
731
+ // Append the new selection to the live log so the session/store
732
+ // observers (agent-presets, projections, ctx) fire their normal
733
+ // `session/event` + `agent-preset/selected` pipeline. We append
734
+ // unconditionally (not only when the disk had no prior selection)
735
+ // because the in-memory projection reads the latest appended event
736
+ // and the cold path deliberately skipped the persistence write
737
+ // controller; the appended event will be persisted on the next
738
+ // normal flush, keeping disk and memory consistent.
739
+ if (typeof liveSession.append === "function") {
740
+ try { liveSession.append("agent-preset/selected", { agentPreset: toPreset }); } catch { /* best-effort */ }
741
+ }
742
+ }
743
+ return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
744
+ });
745
+ };
746
+ /** Rewrite exactly one conversation's effective Agent preset. */
747
+ const migratePreset = async (opts) => {
748
+ const sessionId = typeof opts.sessionId === "string" ? opts.sessionId.trim() : "";
749
+ const toPreset = typeof opts.toPreset === "string" ? opts.toPreset.trim() : "";
750
+ if (sessionId === "") { const e = new Error("sessionId required"); e.code = "bad-request"; throw e; }
751
+ if (toPreset === "") { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
752
+
753
+ const presets = ctx.get("agentPresets");
754
+ if (presets === void 0 || typeof presets.list !== "function") {
755
+ throw new Error("agentPresets service unavailable");
756
+ }
757
+ const roster = await presets.list();
758
+ const target = (roster || []).find((preset) => preset && preset.id === toPreset);
759
+ if (target === void 0) {
760
+ const available = (roster || []).map((preset) => preset?.id).filter(Boolean);
761
+ throw new Error(`Agent 预设 "${toPreset}" 不存在(可用:${available.join(", ")})`);
762
+ }
763
+ if (target.broken !== void 0) throw new Error(`Agent 预设 "${toPreset}" 不可用:${target.broken}`);
764
+
765
+ const liveSession = ctx.sessions.get(sessionId);
766
+ let liveAgent;
767
+ try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
768
+ // Resolve persistence up front so the live path can read the coordinator
769
+ // without tripping JavaScript's temporal dead zone on `const persistence`.
770
+ const persistence = ctx.get("sessionPersistence");
771
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
772
+
773
+ if (liveSession !== void 0) {
774
+ const coordinator = persistence.coordinator;
775
+ const states = coordinator?.states;
776
+ const beforeState = states?.get?.(sessionId);
777
+ const liveCwd = liveSession.header?.cwd;
778
+ // A just-completed moveSession mutates `liveSession.header.cwd` in place,
779
+ // but the persistence coordinator's tracked state may not see the new cwd
780
+ // until its serialize chain drains. If we hand the migration to the
781
+ // coordinator now, its `state.meta.cwd` may still be the pre-move cwd and
782
+ // appendBatch will open the old (now deleted) artifact path, producing the
783
+ // "ENOENT ... session.v2.jsonl.zstd" regression. Falling back to the cold
784
+ // path is always correct: the disk copy already reflects the new cwd.
785
+ const stateStale = beforeState === void 0
786
+ || beforeState.meta?.cwd === void 0
787
+ || (liveCwd !== void 0 && beforeState.meta.cwd !== liveCwd);
788
+ if (stateStale) {
789
+ const raw = await readSessionArtifact(sessionId);
790
+ if (raw !== void 0) {
791
+ const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
792
+ if (result.migrated) { try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ } }
793
+ ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" via cold path (state was stale, live cwd=${String(liveCwd)}, state cwd=${String(beforeState?.meta?.cwd)})`);
794
+ return { sessionId, migrated: true, oldPreset: result.oldPreset, newPreset: toPreset };
795
+ }
796
+ // No artifact yet; fall through to the in-memory + flush path below,
797
+ // which materializes the empty file using the live cwd.
798
+ }
799
+ let oldPreset = liveSession.header?.agentPreset;
800
+ // SessionStore implementations from older DSH builds may expose the
801
+ // history as `log` rather than the newer `events` snapshot getter.
802
+ // Do not dereference `.length` on an absent compatibility surface.
803
+ const liveEvents = Array.isArray(liveSession.events)
804
+ ? liveSession.events
805
+ : Array.isArray(liveSession.log) ? liveSession.log : [];
806
+ for (let index = liveEvents.length - 1; index >= 0; index -= 1) {
807
+ const event = liveEvents[index];
808
+ if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
809
+ oldPreset = event.data.agentPreset;
810
+ break;
811
+ }
812
+ }
813
+ if (oldPreset === toPreset) {
814
+ return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
815
+ }
816
+ if (liveAgent !== void 0 && typeof presets?.recompose === "function" && liveAgent.ctx) {
817
+ try { await presets.recompose(liveAgent.ctx, toPreset); } catch { /* best-effort */ }
818
+ }
819
+ liveSession.append("agent-preset/selected", { agentPreset: toPreset });
820
+ await ctx.sessions.flush(liveSession);
821
+ ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" from "${String(oldPreset)}" to "${toPreset}"`);
822
+ return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
823
+ }
824
+
825
+ // A session absent from SessionStore is cold. If an older plugin left its
826
+ // exact Session object in the coordinator, retire that proven orphan by
827
+ // the coordinator's own drain path instead of deleting owner bookkeeping.
828
+ const coordinator = persistence.coordinator;
829
+ let tracked = coordinator?.states?.get?.(sessionId);
830
+ if (tracked?.owner !== void 0 && ctx.sessions.get(sessionId) === void 0) {
831
+ coordinator.retire?.(tracked.owner);
832
+ const retirement = coordinator.retirements?.get?.(sessionId);
833
+ if (retirement !== void 0) await retirement;
834
+ tracked = coordinator.states?.get?.(sessionId);
835
+ if (tracked?.owner !== void 0) {
836
+ throw new Error(`会话 ${sessionId} 仍有未释放的持久化 owner,已停止迁移以保护数据`);
837
+ }
838
+ }
839
+
840
+ const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
841
+
842
+ if (result.migrated) {
843
+ try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ }
844
+ ctx.logger.info(`session-manager: migrated cold preset for "${sessionId}" from "${String(result.oldPreset)}" to "${toPreset}"`);
845
+ }
846
+ return result;
847
+ };
848
+
849
+ /** Read one session artifact verbatim, falling back to direct disk access when the
850
+ * configured persistence backend cannot see the id (e.g. an older DSH build
851
+ * wrote a "session.v2.jsonl.zstd" file under the raw-id directory).
852
+ */
853
+ const readSessionArtifact = async (sessionId, signal) => {
854
+ const persistence = ctx.get("sessionPersistence");
855
+ if (persistence !== void 0 && typeof persistence.readRaw === "function") {
856
+ try {
857
+ const raw = await persistence.readRaw(sessionId, signal);
858
+ if (raw !== void 0) {
859
+ const located = typeof persistence.locate === "function" ? persistence.locate(raw.meta) : void 0;
860
+ // After a cross-workspace move, a backend may return a raw artifact
861
+ // whose metadata cache still points at the pre-move cwd. Do not
862
+ // hand that stale path to a mutator; verify it and fall through to
863
+ // the id-based filesystem scan when it no longer exists.
864
+ if (located?.path !== void 0) {
865
+ try {
866
+ if ((await stat(located.path)).isFile()) {
867
+ return { ...raw, path: located.path };
868
+ }
869
+ } catch { /* stale locate result; scan the durable files below */ }
870
+ } else {
871
+ return { ...raw };
872
+ }
873
+ }
874
+ } catch { /* fall through to direct disk */ }
875
+ }
876
+ const dshHomePath = ctx.get("dshHomePath");
877
+ if (typeof dshHomePath !== "function") return void 0;
878
+ const root = dshHomePath("sessions");
879
+ let projects;
880
+ try { projects = await readdir(root, { withFileTypes: true }); }
881
+ catch { return void 0; }
882
+ const encoded = encodeSessionSegment(sessionId);
883
+ const { createRequire } = await import("node:module"); const require_ = createRequire(import.meta.url); const { zstdDecompressSync } = require_("node:zlib");
884
+ for (const proj of projects) {
885
+ if (!proj.isDirectory()) continue;
886
+ for (const candidate of [encoded, sessionId]) {
887
+ const dir = join(root, proj.name, candidate);
888
+ for (const filename of ["session.jsonl.zstd", "session.v2.jsonl.zstd", "session.v3.jsonl.zstd", "session.jsonl"]) {
889
+ const filePath = join(dir, filename);
890
+ try {
891
+ const buf = await readFile(filePath);
892
+ let content;
893
+ if (filename.endsWith(".zstd")) {
894
+ // Node's zstdDecompressSync only decodes the FIRST frame in a
895
+ // multi-frame file; DSH writes one frame per batch, so a real
896
+ // artifact contains many concatenated frames and the rest is
897
+ // otherwise silently lost. Walk every magic-prefixed frame and
898
+ // concatenate their plaintexts.
899
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
900
+ const plaintexts = [];
901
+ let pos = 0;
902
+ while (pos < buf.length) {
903
+ const next = buf.indexOf(ZSTD_MAGIC, pos);
904
+ if (next < 0) break;
905
+ try {
906
+ plaintexts.push(zstdDecompressSync(buf.subarray(next)));
907
+ } catch {
908
+ break;
909
+ }
910
+ pos = next + 4;
911
+ }
912
+ if (plaintexts.length === 0) continue;
913
+ content = Buffer.concat(plaintexts).toString("utf8");
914
+ } else {
915
+ content = buf.toString("utf8");
916
+ }
917
+ const headerLine = content.split("\n", 1)[0];
918
+ let meta;
919
+ try { meta = JSON.parse(headerLine); } catch { continue; }
920
+ if (typeof meta?.id !== "string" || meta.id !== sessionId) continue;
921
+ return { meta, filename, content, path: filePath };
922
+ } catch { /* keep scanning */ }
923
+ }
924
+ }
925
+ }
926
+ return void 0;
927
+ };
928
+ const sessionDirOf = async (sessionId) => {
929
+ const persistence = ctx.get("sessionPersistence");
930
+ if (persistence !== void 0) {
931
+ try {
932
+ const headers = await persistence.list();
933
+ const meta = headers.find((header) => header.id === sessionId);
934
+ if (meta !== void 0) {
935
+ const location = persistence.locate(meta);
936
+ if (location !== void 0) return dirname(location.path);
937
+ }
938
+ } catch { /* fall through to filesystem scan */ }
939
+ }
940
+ const dshHomePath = ctx.get("dshHomePath");
941
+ if (typeof dshHomePath !== "function") return void 0;
942
+ const root = dshHomePath("sessions");
943
+ let projects;
944
+ try { projects = await readdir(root, { withFileTypes: true }); }
945
+ catch { return void 0; }
946
+ const encoded = encodeSessionSegment(sessionId);
947
+ const candidates = [encoded, sessionId];
948
+ for (const proj of projects) {
949
+ if (!proj.isDirectory()) continue;
950
+ for (const candidate of candidates) {
951
+ const dir = join(root, proj.name, candidate);
952
+ try {
953
+ if ((await stat(dir)).isDirectory()) return dir;
954
+ } catch { /* keep scanning */ }
955
+ }
956
+ }
957
+ return void 0;
958
+ };
959
+
960
+ /**
961
+ * Delete one session end to end. Returns a summary of what was torn down.
962
+ * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
963
+ */
964
+ const deleteSession = async (sessionId) => {
965
+ const session = ctx.sessions.get(sessionId);
966
+ // 0.1.1-rc.2 throws ApiRemoteSessionNotFound for cold sessions here.
967
+ let agent;try{agent=ctx.agents.get(sessionId)}catch{agent=void 0}
968
+
969
+ if (agent !== void 0) {
970
+ // Stop any running turn (disposed-kind suppresses re-wake).
971
+ agent.cancel({ kind: "disposed" });
972
+ // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
973
+ if (typeof agent.scope?.dispose === "function") {
974
+ await Promise.race([agent.scope.dispose(), sleep(3000)]);
975
+ }
976
+ // Drop the zombie from the registry so a later session.create/open with
977
+ // the same id cannot resurrect it.
978
+ try {
979
+ ctx.agents.store?.delete?.(sessionId);
980
+ } catch { /* best-effort */ }
981
+ }
982
+
983
+ let detached = false;
984
+ if (session !== void 0) {
985
+ // Flush buffered events to disk first so the retirement drain is a no-op.
986
+ try {
987
+ await ctx.sessions.flush(session);
988
+ } catch { /* best-effort */ }
989
+ // Detach the session store entry: emits session/disposed, which the
990
+ // persistence write-path answers with a final drain, and the API proxy
991
+ // relays as host/session-removed so every connected client drops the row.
992
+ try {
993
+ const entry = ctx.sessions.store?.get?.(sessionId);
994
+ if (entry !== void 0 && typeof entry.detach === "function") {
995
+ entry.detach();
996
+ await sleep(200); // let the write-behind retirement settle
997
+ detached = true;
998
+ }
999
+ } catch { /* best-effort */ }
1000
+ }
1001
+ // Some sessions have a stored artifact but no live store row (e.g.
1002
+ // already-scoped scope torn down, or never attached). Without a live
1003
+ // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
1004
+ // entry.detach() never fires — so the apiproxy never queues a
1005
+ // host/session-removed frame, every connected client keeps showing the
1006
+ // session row, and the row collapses into the ungrouped section the
1007
+ // moment host/workspace-changed drops its workspace membership. Emit
1008
+ // session/disposed explicitly so every connected client drops the row.
1009
+ if (!detached) {
1010
+ try {
1011
+ ctx.emit("session/disposed", { id: sessionId });
1012
+ } catch (_) { /* best-effort */ }
1013
+ }
1014
+
1015
+ // Workspace accounting + archive-set membership.
1016
+ await detachFromWorkspaces(sessionId);
1017
+ await unarchiveSession(sessionId);
1018
+
1019
+ // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
1020
+ const dir = await sessionDirOf(sessionId);
1021
+ if (dir !== void 0) {
1022
+ await rm(dir, { recursive: true, force: true });
1023
+ }
1024
+
1025
+ return {
1026
+ ok: true,
1027
+ sessionId,
1028
+ wasLive: session !== void 0 || agent !== void 0,
1029
+ filesRemoved: dir !== void 0
1030
+ };
1031
+ };
1032
+
1033
+ /**
1034
+ * On plugin boot, refold every persisted session projection cache
1035
+ * row from disk. Sessions moved before coldSnapshot was wired into
1036
+ * moveSession would otherwise keep their pre-move {createdAt, cwd}
1037
+ * identity and let the cold list path fall back to basename(cwd),
1038
+ * surfacing the workspace title (for example "DSH") where the
1039
+ * session's own title was expected. The sweep is one-shot and
1040
+ * idempotent: a row whose identity already matches is a cheap
1041
+ * pass-through refold, and a stale one triggers the unrelated-identity
1042
+ * full re-read path.
1043
+ */
1044
+ ctx.effect(async () => {
1045
+ const cache = ctx.get("sessionProjectionCache");
1046
+ const persistence = ctx.get("sessionPersistence");
1047
+ const registry = ctx.get("workspaceRegistry");
1048
+ if (cache === void 0 || typeof cache.coldSnapshot !== "function") return;
1049
+ if (persistence === void 0 || typeof persistence.list !== "function") return;
1050
+ if (registry === void 0 || typeof registry.list !== "function") return;
1051
+ try {
1052
+ // ---- step 1: re-fold every active session's projection cache row,
1053
+ // so a session moved before coldSnapshot was wired in stops being
1054
+ // identity-stuck under a stale {createdAt, cwd} key.
1055
+ const headers = await listSessionHeaders(persistence, ctx.get("dshHomePath"));
1056
+ let refolded = 0;
1057
+ const headerById = new Map();
1058
+ for (const header of headers) {
1059
+ if (header === void 0 || header.cwd === void 0) continue;
1060
+ headerById.set(header.id, header);
1061
+ try {
1062
+ await cache.coldSnapshot(header.id);
1063
+ refolded += 1;
1064
+ } catch (error) {
1065
+ ctx.logger.warn(`session-manager: post-boot cache refold failed for "${header.id}": ${String(error)}`);
1066
+ }
1067
+ }
1068
+ ctx.logger.info(`session-manager: post-boot re-folded ${refolded} session projection cache rows`);
1069
+
1070
+ // ---- step 2: reconcile workspace accounting with disk. After a few
1071
+ // pre-fix moves, a workspace record could keep a session id whose
1072
+ // disk artifact is gone, OR miss a session whose disk artifact is on
1073
+ // a matching cwd but not in any record. Both symptoms show up as
1074
+ // "AI count higher than visible" / "another session in ungrouped".
1075
+ // Walk the registry, drop missing-disk ids, and add missing-in-record
1076
+ // ids back into the workspace whose path matches header.cwd.
1077
+ const allWorkspaces = registry.list();
1078
+ const recordByWs = new Map();
1079
+ for (const ws of allWorkspaces) {
1080
+ recordByWs.set(ws.id, ws);
1081
+ }
1082
+ const validIds = new Set(headerById.keys());
1083
+ const pathToWs = new Map();
1084
+ for (const ws of allWorkspaces) {
1085
+ pathToWs.set(ws.path, ws);
1086
+ }
1087
+ // Pass 1: drop ids whose disk artifact no longer exists.
1088
+ let removed = 0;
1089
+ for (const ws of allWorkspaces) {
1090
+ const filtered = ws.record?.sessionIds?.filter((id) => validIds.has(id));
1091
+ const original = ws.record?.sessionIds ?? [];
1092
+ if (filtered && filtered.length !== original.length) {
1093
+ await registry.enqueueOperation(async () => {
1094
+ const state = registry.requireState();
1095
+ const w = state.workspaces?.find((s) => s.id === ws.id);
1096
+ if (w !== undefined) {
1097
+ w.sessionIds = filtered;
1098
+ w.updatedAt = new Date().toISOString();
1099
+ await registry.setState(state);
1100
+ }
1101
+ });
1102
+ removed += original.length - filtered.length;
1103
+ }
1104
+ }
1105
+ // Pass 2: attach disk-only sessions to the workspace whose path
1106
+ // matches their cwd.
1107
+ let added = 0;
1108
+ for (const header of headers) {
1109
+ if (header.cwd === void 0) continue;
1110
+ const ws = pathToWs.get(header.cwd);
1111
+ if (ws === undefined) continue;
1112
+ const rec = ws.record;
1113
+ if (rec === undefined || rec.sessionIds === undefined) continue;
1114
+ if (rec.sessionIds.includes(header.id)) continue;
1115
+ await registry.enqueueOperation(async () => {
1116
+ const state = registry.requireState();
1117
+ const w = state.workspaces?.find((s) => s.id === ws.id);
1118
+ if (w !== undefined && w.sessionIds !== undefined && !w.sessionIds.includes(header.id)) {
1119
+ w.sessionIds = [header.id, ...w.sessionIds];
1120
+ w.updatedAt = new Date().toISOString();
1121
+ await registry.setState(state);
1122
+ added += 1;
1123
+ }
1124
+ });
1125
+ }
1126
+ if (removed > 0 || added > 0) {
1127
+ ctx.logger.info(`session-manager: post-boot workspace reconcile removed ${removed} orphans, attached ${added} disk-only sessions`);
1128
+ }
1129
+ } catch (error) {
1130
+ ctx.logger.warn(`session-manager: post-boot reconcile failed: ${String(error)}`);
1131
+ }
1132
+ }, "session-manager: post-boot re-fold stale projection cache rows");
1133
+
1134
+ ctx.effect(() => ctx.webServer.register({
1135
+ kind: "prefix",
1136
+ path: API_PREFIX,
1137
+ handler: async (req, res) => {
1138
+ try {
1139
+ const url = new URL(req.url ?? "/", "http://localhost");
1140
+ const path = url.pathname.startsWith(API_PREFIX)
1141
+ ? url.pathname.slice(API_PREFIX.length) || "/"
1142
+ : "/";
1143
+ let body = {};
1144
+ if (req.method === "POST") {
1145
+ const raw = await readBody(req);
1146
+ if (raw.trim() !== "") {
1147
+ try {
1148
+ body = JSON.parse(raw);
1149
+ } catch {
1150
+ return send(res, 400, { ok: false, error: "请求体不是合法 JSON" });
1151
+ }
1152
+ }
1153
+ }
1154
+ const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() !== ""
1155
+ ? body.sessionId.trim()
1156
+ : null;
1157
+
1158
+ if (req.method === "POST" && path === "/delete") {
1159
+ if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1160
+ try {
1161
+ const result = await deleteSession(sessionId);
1162
+ return send(res, 200, { ok: true, result });
1163
+ } catch (error) {
1164
+ ctx.logger.warn(`session-manager: delete "${sessionId}" failed: ${String(error)}`);
1165
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1166
+ }
1167
+ }
1168
+
1169
+ if (req.method === "POST" && path === "/unarchive") {
1170
+ if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1171
+ try {
1172
+ await unarchiveSession(sessionId);
1173
+ return send(res, 200, { ok: true, result: { sessionId } });
1174
+ } catch (error) {
1175
+ ctx.logger.warn(`session-manager: unarchive "${sessionId}" failed: ${String(error)}`);
1176
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1177
+ }
1178
+ }
1179
+
1180
+ if (req.method === "POST" && path === "/move") {
1181
+ if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1182
+ const targetWorkspaceId = typeof body.targetWorkspaceId === "string" && body.targetWorkspaceId.trim() !== ""
1183
+ ? body.targetWorkspaceId.trim()
1184
+ : null;
1185
+ if (targetWorkspaceId === null) return send(res, 400, { ok: false, error: "targetWorkspaceId 必填" });
1186
+ try {
1187
+ const result = await moveSession(sessionId, targetWorkspaceId);
1188
+ return send(res, 200, { ok: true, result });
1189
+ } catch (error) {
1190
+ ctx.logger.warn(`session-manager: api error: ${String(error)}; type=${error?.constructor?.name}; json=${safeJson(error)}`);
1191
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1192
+ }
1193
+ }
1194
+
1195
+ if (req.method === "GET" && path === "/workspaces") {
1196
+ try {
1197
+ const workspaces = listWorkspaces();
1198
+ return send(res, 200, { ok: true, result: { workspaces } });
1199
+ } catch (error) {
1200
+ ctx.logger.warn(`session-manager: listWorkspaces failed: ${String(error)}`);
1201
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1202
+ }
1203
+ }
1204
+
1205
+
1206
+
1207
+ if (req.method === "GET" && path === "/preset-scan") {
1208
+ try {
1209
+ const workspaceId = url.searchParams.get("workspaceId") || "";
1210
+ const requestedSessionId = url.searchParams.get("sessionId") || "";
1211
+ const filter = {
1212
+ ...(workspaceId !== "" ? { id: workspaceId } : {}),
1213
+ ...(requestedSessionId !== "" ? { sessionId: requestedSessionId } : {})
1214
+ };
1215
+ const result = await scanPreset(filter);
1216
+ return send(res, 200, { ok: true, result });
1217
+ } catch (error) {
1218
+ ctx.logger.warn("session-manager: preset-scan failed: " + String(error));
1219
+ return send(res, 500, { ok: false, error: String(error.message || error) });
1220
+ }
1221
+ }
1222
+ if (req.method === "POST" && path === "/preset-migrate") {
1223
+ const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
1224
+ const toPreset = typeof body.toPreset === "string" ? body.toPreset.trim() : "";
1225
+ if (requestedSessionId === "" || toPreset === "") {
1226
+ return send(res, 400, { ok: false, error: "sessionId and toPreset required" });
1227
+ }
1228
+ try {
1229
+ const result = await migratePreset({ sessionId: requestedSessionId, toPreset });
1230
+ return send(res, 200, { ok: true, result });
1231
+ } catch (error) {
1232
+ ctx.logger.warn("session-manager: preset-migrate failed: " + String(error));
1233
+ return send(res, 500, { ok: false, error: String(error.message || error) });
1234
+ }
1235
+ }
1236
+
1237
+ return send(res, 404, { ok: false, error: `not found: ${req.method} ${path}` });
1238
+ } catch (error) {
1239
+ ctx.logger.warn(`session-manager: api error: ${String(error)}; type=${error?.constructor?.name}; json=${safeJson(error)}`);
1240
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1241
+ }
1242
+ } }), "session-manager: http api");
1243
+
1244
+
1245
+ }
1246
+
1247
+ export { listSessionHeaders, encodeSessionSegment };