dsh-session-manager 0.4.6 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +216 -119
- package/README.md +107 -112
- package/README.zh.md +28 -35
- package/lib/client.js +143 -62
- package/lib/compat/dsh-adapter.js +200 -0
- package/lib/compat/zstd-frames.js +93 -0
- package/lib/index.js +185 -211
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,58 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @dsh-session-manager — Host half.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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).
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - POST /session-manager/api/unarchive { sessionId }
|
|
14
|
-
* Remove one session from the registry-global archive set. The registry's
|
|
15
|
-
* own `domain/changed` -> `host/archived-sessions-changed` frame then
|
|
16
|
-
* refreshes every connected client.
|
|
17
|
-
* - GET /session-manager/api/workspaces
|
|
18
|
-
* Ordered workspace projection: id / title / path / sessionCount /
|
|
19
|
-
* sessionIds (raw record order). Used by the client move dialog to pick a
|
|
20
|
-
* target and to derive the session's current workspace.
|
|
21
|
-
* - POST /session-manager/api/move { sessionId, targetWorkspaceId }
|
|
22
|
-
* TRUE cross-workspace move. DSH's workspace model keys membership off
|
|
23
|
-
* the session header's immutable `cwd`: `WorkspaceEntity.attachSession`
|
|
24
|
-
* validates `realpath(header.cwd) === workspace.path`, and every record
|
|
25
|
-
* write prunes ids whose indexed cwd no longer matches. So a real move
|
|
26
|
-
* must RE-HOME the session:
|
|
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.
|
|
27
16
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* an exact one-header-line first frame, or plain JSONL);
|
|
35
|
-
* 3. publish the new artifact at `locate({cwd: target})` and remove the
|
|
36
|
-
* old one, hiding-then-restoring on failure (never leaves the
|
|
37
|
-
* duplicate-id state that would make list()/findLog() throw);
|
|
38
|
-
* 4. enter a short-lived restored session (prepare + enter, never
|
|
39
|
-
* announced) whose fresh header makes attachSession's validation
|
|
40
|
-
* pass and refreshes the registry's canonical-cwd index, then swap
|
|
41
|
-
* accounting: detach from every other workspace's raw record,
|
|
42
|
-
* attach to the target — through the entity's own serialized
|
|
43
|
-
* durability path, so `host/workspace-changed` frames re-group every
|
|
44
|
-
* client sidebar immediately.
|
|
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.
|
|
45
23
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* All mutations go through the workspace registry's own serialized
|
|
50
|
-
* `enqueueOperation` + `setState` durability path (the same one the built-in
|
|
51
|
-
* archiveSession uses), so a restart cannot resurrect a removed session.
|
|
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.
|
|
52
27
|
*/
|
|
53
28
|
import { mkdir, open, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
54
29
|
import { dirname, join } from "node:path";
|
|
55
30
|
import { randomBytes } from "node:crypto";
|
|
31
|
+
import { createDshAdapter } from "./compat/dsh-adapter.js";
|
|
32
|
+
import { decompressAllZstdFrames } from "./compat/zstd-frames.js";
|
|
56
33
|
|
|
57
34
|
export const name = "dsh-session-manager";
|
|
58
35
|
|
|
@@ -81,6 +58,125 @@ function safeErrorMessage(error) {
|
|
|
81
58
|
const API_PREFIX = "/session-manager/api";
|
|
82
59
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
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
|
+
};
|
|
84
180
|
export function apply(ctx) {
|
|
85
181
|
const readBody = async (req) => {
|
|
86
182
|
const chunks = [];
|
|
@@ -181,6 +277,7 @@ ${rest}`, "utf8");
|
|
|
181
277
|
const moveSession = async (sessionId, targetWorkspaceId) => {
|
|
182
278
|
const registry = ctx.workspaceRegistry;
|
|
183
279
|
const persistence = ctx.get("sessionPersistence");
|
|
280
|
+
const dshHomePath = ctx.get("dshHomePath");
|
|
184
281
|
if (persistence === void 0) throw new Error("sessionPersistence 服务不可用");
|
|
185
282
|
if (typeof persistence.locate !== "function") {
|
|
186
283
|
throw new Error("当前持久化后端无法定位会话工件,无法跨工作区移动");
|
|
@@ -194,8 +291,27 @@ ${rest}`, "utf8");
|
|
|
194
291
|
}
|
|
195
292
|
const targetPath = target.path;
|
|
196
293
|
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
}
|
|
199
315
|
if (storedHeader === void 0) {
|
|
200
316
|
const error = new Error(
|
|
201
317
|
`会话 ${sessionId} 没有磁盘记录(不存在,或是一个尚未发送任何消息的空白会话),无法移动`
|
|
@@ -219,12 +335,12 @@ ${rest}`, "utf8");
|
|
|
219
335
|
const liveSession = ctx.sessions.get(sessionId);
|
|
220
336
|
let liveAgent;
|
|
221
337
|
try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
|
|
222
|
-
// The
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
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.
|
|
228
344
|
// client UI never has to re-init. Just flush pending events to
|
|
229
345
|
// disk (still at OLD cwd), then atomically rename the artifact to
|
|
230
346
|
// NEW cwd. The persistence coordinator's serialize() inside the
|
|
@@ -360,7 +476,7 @@ ${rest}`, "utf8");
|
|
|
360
476
|
toWorkspaceTitle: target.title || target.id,
|
|
361
477
|
artifactFrom: oldPath,
|
|
362
478
|
artifactTo: newPath,
|
|
363
|
-
wasLive: liveSession !== void 0 ||
|
|
479
|
+
wasLive: liveSession !== void 0 || liveAgent !== void 0
|
|
364
480
|
};
|
|
365
481
|
});
|
|
366
482
|
|
|
@@ -378,7 +494,7 @@ ${rest}`, "utf8");
|
|
|
378
494
|
// unavailable" state (agent/status is only emitted on PHASE
|
|
379
495
|
// CHANGES, never on agent construction). Leaving the live
|
|
380
496
|
// agent/session in place avoids the broken UI entirely.
|
|
381
|
-
ctx.logger.info(`session-manager: moved "${sessionId}" to workspace "${target.id}"
|
|
497
|
+
ctx.logger.info(`session-manager: moved "${sessionId}" to workspace "${target.id}" (in-place file rename, live agent retained)`);
|
|
382
498
|
return result;
|
|
383
499
|
};
|
|
384
500
|
/* === preset-migration (session-manager v0.3.0) === */
|
|
@@ -422,7 +538,7 @@ ${rest}`, "utf8");
|
|
|
422
538
|
// failure that hides which session actually failed.
|
|
423
539
|
let headers = [];
|
|
424
540
|
try {
|
|
425
|
-
headers = await listSessionHeaders(persistence);
|
|
541
|
+
headers = await listSessionHeaders(persistence, ctx.get("dshHomePath"));
|
|
426
542
|
} catch (probeError) {
|
|
427
543
|
throw new Error("failed to enumerate sessions: " + String(probeError.message ?? probeError));
|
|
428
544
|
}
|
|
@@ -478,12 +594,14 @@ ${rest}`, "utf8");
|
|
|
478
594
|
};
|
|
479
595
|
|
|
480
596
|
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
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.
|
|
487
605
|
*/
|
|
488
606
|
/** Cold-path preset migration: read disk, rewrite, publish. Works whether
|
|
489
607
|
* or not the session is currently in the coordinator, and does not depend on
|
|
@@ -688,93 +806,7 @@ ${rest}`, "utf8");
|
|
|
688
806
|
return result;
|
|
689
807
|
};
|
|
690
808
|
|
|
691
|
-
|
|
692
|
-
It never actually worked: DSH's WorkspaceEntity.attachSession validates
|
|
693
|
-
realpath(header.cwd) === workspace.path, so a cross-workspace attach always
|
|
694
|
-
threw "its cwd resolves to ..."; the registry-state fallback below also
|
|
695
|
-
mutated a state object that has no `workspaces` field. Replaced by the
|
|
696
|
-
re-home implementation above (cwd rewrite + artifact migration + fresh
|
|
697
|
-
placeholder header for index refresh).
|
|
698
|
-
/** Move one session from its current workspace to a target workspace. (legacy)
|
|
699
|
-
const moveSession = async (sessionId, targetWorkspaceId) => {
|
|
700
|
-
const registry = ctx.workspaceRegistry;
|
|
701
|
-
const allWorkspaces = registry.list();
|
|
702
|
-
|
|
703
|
-
// Find target workspace
|
|
704
|
-
const targetWorkspace = allWorkspaces.find((e) => e.id === targetWorkspaceId);
|
|
705
|
-
if (!targetWorkspace) {
|
|
706
|
-
throw new Error(`目标工作区不存在: ${targetWorkspaceId}`);
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Find current workspace(s) containing this session
|
|
710
|
-
const currentWorkspaces = allWorkspaces.filter((e) => e.sessionIds.includes(sessionId));
|
|
711
|
-
|
|
712
|
-
// Check if already in target workspace
|
|
713
|
-
if (currentWorkspaces.length === 1 && currentWorkspaces[0].id === targetWorkspaceId) {
|
|
714
|
-
return { ok: true, sessionId, moved: false, message: "会话已在目标工作区中" };
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// Detach from all current workspaces
|
|
718
|
-
for (const entity of currentWorkspaces) {
|
|
719
|
-
await entity.detachSession(sessionId);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// Attach to target workspace
|
|
723
|
-
if (typeof targetWorkspace.attachSession === "function") {
|
|
724
|
-
await targetWorkspace.attachSession(sessionId);
|
|
725
|
-
} else if (typeof targetWorkspace.prependSession === "function") {
|
|
726
|
-
await targetWorkspace.prependSession(sessionId);
|
|
727
|
-
} else {
|
|
728
|
-
// Fallback: try to use the registry's enqueueOperation
|
|
729
|
-
await registry.enqueueOperation(async () => {
|
|
730
|
-
const state = registry.requireState();
|
|
731
|
-
// Find target workspace in state and add session
|
|
732
|
-
const targetState = state.workspaces?.find((w) => w.id === targetWorkspaceId);
|
|
733
|
-
if (targetState) {
|
|
734
|
-
if (!targetState.sessionIds) targetState.sessionIds = [];
|
|
735
|
-
if (!targetState.sessionIds.includes(sessionId)) {
|
|
736
|
-
targetState.sessionIds.unshift(sessionId);
|
|
737
|
-
}
|
|
738
|
-
await registry.setState(state);
|
|
739
|
-
}
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
return {
|
|
744
|
-
ok: true,
|
|
745
|
-
sessionId,
|
|
746
|
-
moved: true,
|
|
747
|
-
fromWorkspace: currentWorkspaces.map((e) => e.id),
|
|
748
|
-
toWorkspace: targetWorkspaceId
|
|
749
|
-
};
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
==== end legacy v0.2.0 move implementation ==== */
|
|
753
|
-
/** Resolve the on-disk session directory (parent of its artifact), if any. */
|
|
754
|
-
/**
|
|
755
|
-
* Encode a session id the way DSH stores it on disk: "--<encoded>--".
|
|
756
|
-
* Mirrors @deepseek-ai/dsh-session-persistence-jsonl encodeSegment.
|
|
757
|
-
*/
|
|
758
|
-
const encodeSessionSegment = (id) => {
|
|
759
|
-
let readable = "";
|
|
760
|
-
let separatorRun = false;
|
|
761
|
-
for (let i = 0; i < id.length; i++) {
|
|
762
|
-
const ch = id[i];
|
|
763
|
-
const code = id.charCodeAt(i);
|
|
764
|
-
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
765
|
-
if (!separatorRun) readable += "-";
|
|
766
|
-
separatorRun = true;
|
|
767
|
-
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
768
|
-
readable += ch;
|
|
769
|
-
separatorRun = false;
|
|
770
|
-
} else {
|
|
771
|
-
readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
772
|
-
separatorRun = false;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
return "--" + (readable.replace(/^-+/, "") || "root").slice(0, 251) + "--";
|
|
776
|
-
};
|
|
777
|
-
/** Read one session artifact verbatim, falling back to direct disk access when the
|
|
809
|
+
/** Read one session artifact verbatim, falling back to direct disk access when the
|
|
778
810
|
* configured persistence backend cannot see the id (e.g. an older DSH build
|
|
779
811
|
* wrote a "session.v2.jsonl.zstd" file under the raw-id directory).
|
|
780
812
|
*/
|
|
@@ -813,7 +845,7 @@ ${rest}`, "utf8");
|
|
|
813
845
|
if (!proj.isDirectory()) continue;
|
|
814
846
|
for (const candidate of [encoded, sessionId]) {
|
|
815
847
|
const dir = join(root, proj.name, candidate);
|
|
816
|
-
for (const filename of ["session.jsonl.zstd", "session.v2.jsonl.zstd", "session.jsonl"]) {
|
|
848
|
+
for (const filename of ["session.jsonl.zstd", "session.v2.jsonl.zstd", "session.v3.jsonl.zstd", "session.jsonl"]) {
|
|
817
849
|
const filePath = join(dir, filename);
|
|
818
850
|
try {
|
|
819
851
|
const buf = await readFile(filePath);
|
|
@@ -852,66 +884,6 @@ ${rest}`, "utf8");
|
|
|
852
884
|
}
|
|
853
885
|
}
|
|
854
886
|
return void 0;
|
|
855
|
-
};
|
|
856
|
-
/** List every persisted session header visible to the configured persistence
|
|
857
|
-
* backend, supplemented by raw-id sessions written by older DSH builds. */
|
|
858
|
-
const listSessionHeaders = async (persistence) => {
|
|
859
|
-
const seen = new Set();
|
|
860
|
-
const out = [];
|
|
861
|
-
if (persistence !== void 0 && typeof persistence.list === "function") {
|
|
862
|
-
try {
|
|
863
|
-
for (const header of await persistence.list()) {
|
|
864
|
-
if (header === void 0 || typeof header.id !== "string") continue;
|
|
865
|
-
seen.add(header.id);
|
|
866
|
-
out.push(header);
|
|
867
|
-
}
|
|
868
|
-
} catch { /* fall through to disk */ }
|
|
869
|
-
}
|
|
870
|
-
const dshHomePath = ctx.get("dshHomePath");
|
|
871
|
-
if (typeof dshHomePath !== "function") return out;
|
|
872
|
-
const root = dshHomePath("sessions");
|
|
873
|
-
let projects;
|
|
874
|
-
try { projects = await readdir(root, { withFileTypes: true }); }
|
|
875
|
-
catch { return out; }
|
|
876
|
-
const { createRequire } = await import("node:module"); const require_ = createRequire(import.meta.url); const { zstdDecompressSync } = require_("node:zlib");
|
|
877
|
-
for (const proj of projects) {
|
|
878
|
-
if (!proj.isDirectory()) continue;
|
|
879
|
-
let dirs;
|
|
880
|
-
try { dirs = await readdir(join(root, proj.name), { withFileTypes: true }); }
|
|
881
|
-
catch { continue; }
|
|
882
|
-
for (const entry of dirs) {
|
|
883
|
-
if (!entry.isDirectory()) continue;
|
|
884
|
-
for (const filename of ["session.v2.jsonl.zstd", "session.jsonl.zstd", "session.jsonl"]) {
|
|
885
|
-
const filePath = join(root, proj.name, entry.name, filename);
|
|
886
|
-
try {
|
|
887
|
-
const buf = await readFile(filePath);
|
|
888
|
-
let content;
|
|
889
|
-
if (filename.endsWith(".zstd")) {
|
|
890
|
-
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
891
|
-
const plaintexts = [];
|
|
892
|
-
let pos = 0;
|
|
893
|
-
while (pos < buf.length) {
|
|
894
|
-
const next = buf.indexOf(ZSTD_MAGIC, pos);
|
|
895
|
-
if (next < 0) break;
|
|
896
|
-
try { plaintexts.push(zstdDecompressSync(buf.subarray(next))); }
|
|
897
|
-
catch { break; }
|
|
898
|
-
pos = next + 4;
|
|
899
|
-
}
|
|
900
|
-
if (plaintexts.length === 0) continue;
|
|
901
|
-
content = Buffer.concat(plaintexts).toString("utf8");
|
|
902
|
-
} else {
|
|
903
|
-
content = buf.toString("utf8");
|
|
904
|
-
}
|
|
905
|
-
const headerLine = content.split("\n", 1)[0];
|
|
906
|
-
const meta = JSON.parse(headerLine);
|
|
907
|
-
if (typeof meta?.id !== "string" || seen.has(meta.id)) continue;
|
|
908
|
-
seen.add(meta.id);
|
|
909
|
-
out.push({ id: meta.id, cwd: meta.cwd, createdAt: meta.createdAt, version: meta.version, origin: meta.origin });
|
|
910
|
-
} catch { /* skip */ }
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
return out;
|
|
915
887
|
};
|
|
916
888
|
const sessionDirOf = async (sessionId) => {
|
|
917
889
|
const persistence = ctx.get("sessionPersistence");
|
|
@@ -1040,7 +1012,7 @@ ${rest}`, "utf8");
|
|
|
1040
1012
|
// ---- step 1: re-fold every active session's projection cache row,
|
|
1041
1013
|
// so a session moved before coldSnapshot was wired in stops being
|
|
1042
1014
|
// identity-stuck under a stale {createdAt, cwd} key.
|
|
1043
|
-
const headers = await listSessionHeaders(persistence);
|
|
1015
|
+
const headers = await listSessionHeaders(persistence, ctx.get("dshHomePath"));
|
|
1044
1016
|
let refolded = 0;
|
|
1045
1017
|
const headerById = new Map();
|
|
1046
1018
|
for (const header of headers) {
|
|
@@ -1231,3 +1203,5 @@ if (req.method === "GET" && path === "/preset-scan") {
|
|
|
1231
1203
|
|
|
1232
1204
|
|
|
1233
1205
|
}
|
|
1206
|
+
|
|
1207
|
+
export { listSessionHeaders, encodeSessionSegment };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-manager",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Session manager for the DeepSeek Harness Web UI: delete sessions, archive sessions, move sessions across workspaces, and migrate a session’s Agent preset. Suggestions are welcome on GitHub. 在 DeepSeek Harness Web 中进行会话管理,包括:删除会话、归档会话、跨工作区移动会话、迁移会话的 Agent 预设。欢迎至GitHub提意见。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|