@xrn1997/dsh-session-delete 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/icon.svg +18 -18
- package/lib/client.js +23648 -517
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +667 -234
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +6 -2
package/lib/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
+
import zlib from "node:zlib";
|
|
3
4
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
5
6
|
//#region src/shared/wire.ts
|
|
6
7
|
/**
|
|
7
|
-
* 跨半 wire 契约:`/dsh-session-delete-api`
|
|
8
|
+
* 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、五个端点段、请求字段名**——
|
|
8
9
|
* Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
|
|
9
10
|
* 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
|
|
10
11
|
*
|
|
@@ -27,12 +28,15 @@ import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
|
27
28
|
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
28
29
|
*/
|
|
29
30
|
const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
|
|
30
|
-
/**
|
|
31
|
+
/** 五个端点的段名(相对前缀;`GET list` **对账并读**清单——`session-trash/` 里的目录是准,其余四个
|
|
32
|
+
* `POST` 按 id 取数或写)。 */
|
|
31
33
|
const ROUTES = {
|
|
32
34
|
list: "/list",
|
|
33
35
|
delete: "/delete",
|
|
34
36
|
restore: "/restore",
|
|
35
|
-
purge: "/purge"
|
|
37
|
+
purge: "/purge",
|
|
38
|
+
/** 单条详情(读回收站目录里那条会话自己的日志)。用 POST 表达一个读:见设计稿 2026-10-15 §3。 */
|
|
39
|
+
detail: "/detail"
|
|
36
40
|
};
|
|
37
41
|
/** 请求体字段名:浏览器半发、Node 半读,同名同义(`purge` 的 `entryId` 缺席 = 清空)。 */
|
|
38
42
|
const PARAMS = {
|
|
@@ -156,6 +160,639 @@ function trashDirOf(entryId, home) {
|
|
|
156
160
|
return `${home}/session-trash/${entryId}`;
|
|
157
161
|
}
|
|
158
162
|
//#endregion
|
|
163
|
+
//#region src/log/zstd-frames.ts
|
|
164
|
+
const MAGIC = [
|
|
165
|
+
40,
|
|
166
|
+
181,
|
|
167
|
+
47,
|
|
168
|
+
253
|
|
169
|
+
];
|
|
170
|
+
/** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
|
|
171
|
+
const AFTER_DESCRIPTOR = 5;
|
|
172
|
+
const BLOCK_HEADER_BYTES = 3;
|
|
173
|
+
const BLOCK_LAST = 1;
|
|
174
|
+
const BLOCK_TYPE_SHIFT = 1;
|
|
175
|
+
const BLOCK_SIZE_SHIFT = 3;
|
|
176
|
+
const BLOCK_SIZE_MASK = 2097151;
|
|
177
|
+
const BLOCK_RAW = 0;
|
|
178
|
+
const BLOCK_RLE = 1;
|
|
179
|
+
const BLOCK_COMRESSED = 2;
|
|
180
|
+
/** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
|
|
181
|
+
const BLOCK_RESERVED = 3;
|
|
182
|
+
/** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
|
|
183
|
+
const DID_SIZES = [
|
|
184
|
+
0,
|
|
185
|
+
1,
|
|
186
|
+
2,
|
|
187
|
+
4
|
|
188
|
+
];
|
|
189
|
+
const FCS_SIZES_SINGLE = [
|
|
190
|
+
1,
|
|
191
|
+
2,
|
|
192
|
+
4,
|
|
193
|
+
8
|
|
194
|
+
];
|
|
195
|
+
const FCS_SIZES_MULTI = [
|
|
196
|
+
0,
|
|
197
|
+
2,
|
|
198
|
+
4,
|
|
199
|
+
8
|
|
200
|
+
];
|
|
201
|
+
/** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
|
|
202
|
+
const FRAME_TAIL_BYTES = 4;
|
|
203
|
+
function isMagicAt(raw, at) {
|
|
204
|
+
return MAGIC.every((byte, i) => raw[at + i] === byte);
|
|
205
|
+
}
|
|
206
|
+
function readUint24LE(raw, at) {
|
|
207
|
+
return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
|
|
208
|
+
}
|
|
209
|
+
/** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
|
|
210
|
+
function withFrameTail(raw, at) {
|
|
211
|
+
const afterTail = at + FRAME_TAIL_BYTES;
|
|
212
|
+
if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
|
|
213
|
+
return at;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 把 `raw` 切成帧区间。
|
|
217
|
+
* @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
|
|
218
|
+
* 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
|
|
219
|
+
*/
|
|
220
|
+
function frameRanges(raw) {
|
|
221
|
+
const ranges = [];
|
|
222
|
+
let at = 0;
|
|
223
|
+
while (at < raw.length) {
|
|
224
|
+
if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
|
|
225
|
+
const descriptor = raw[at + 4];
|
|
226
|
+
if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
|
|
227
|
+
const fcsFlag = descriptor & 3;
|
|
228
|
+
const singleSegment = descriptor >> 2 & 1;
|
|
229
|
+
const didFlag = descriptor >> 6 & 3;
|
|
230
|
+
let cursor = at + AFTER_DESCRIPTOR;
|
|
231
|
+
if (!singleSegment) cursor += 1;
|
|
232
|
+
cursor += DID_SIZES[didFlag];
|
|
233
|
+
cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
|
|
234
|
+
for (;;) {
|
|
235
|
+
const blockHeader = readUint24LE(raw, cursor);
|
|
236
|
+
cursor += BLOCK_HEADER_BYTES;
|
|
237
|
+
if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
|
|
238
|
+
const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
|
|
239
|
+
const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
|
|
240
|
+
if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
|
|
241
|
+
if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
|
|
242
|
+
else if (blockType === BLOCK_RLE) cursor += 1;
|
|
243
|
+
if (blockHeader & BLOCK_LAST) break;
|
|
244
|
+
}
|
|
245
|
+
cursor = withFrameTail(raw, cursor);
|
|
246
|
+
if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
|
|
247
|
+
ranges.push({
|
|
248
|
+
start: at,
|
|
249
|
+
end: cursor
|
|
250
|
+
});
|
|
251
|
+
at = cursor;
|
|
252
|
+
}
|
|
253
|
+
return ranges;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/log/session-log.ts
|
|
257
|
+
/**
|
|
258
|
+
* 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
|
|
259
|
+
* 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
|
|
260
|
+
*
|
|
261
|
+
* **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
|
|
262
|
+
* 这一层只懂编解码,所以它能被单独验穿。
|
|
263
|
+
*
|
|
264
|
+
* 两条实测决定的口径:
|
|
265
|
+
* ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
|
|
266
|
+
* `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
|
|
267
|
+
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
268
|
+
* ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
|
|
269
|
+
* 实测本机 686 个 `.jsonl.zstd` / 92 421 帧**没有一帧切在行中间**(连最后一帧都整行收尾),
|
|
270
|
+
* 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
|
|
271
|
+
* 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
|
|
272
|
+
* 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
|
|
273
|
+
* 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
|
|
274
|
+
*/
|
|
275
|
+
const NEWLINE = 10;
|
|
276
|
+
/** 会话日志的文件名后缀(`session.vN.jsonl.zstd`)。详情与回收站对账都按它挑日志,名字只有这一处家。 */
|
|
277
|
+
const SESSION_LOG_SUFFIX = ".jsonl.zstd";
|
|
278
|
+
/**
|
|
279
|
+
* 逐帧走一遍日志,每条记录交给回调。
|
|
280
|
+
*
|
|
281
|
+
* 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
|
|
282
|
+
* 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
|
|
283
|
+
*/
|
|
284
|
+
function walkSessionLog(raw, onRecord) {
|
|
285
|
+
const stats = {
|
|
286
|
+
frames: 0,
|
|
287
|
+
records: 0,
|
|
288
|
+
unparsedLines: 0,
|
|
289
|
+
truncatedTailBytes: 0
|
|
290
|
+
};
|
|
291
|
+
let stopped = false;
|
|
292
|
+
const feed = (line) => {
|
|
293
|
+
if (line === "") return;
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(line);
|
|
297
|
+
} catch {
|
|
298
|
+
stats.unparsedLines += 1;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
stats.records += 1;
|
|
302
|
+
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
303
|
+
};
|
|
304
|
+
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
305
|
+
let carry = Buffer.alloc(0);
|
|
306
|
+
for (const { start, end } of frameRanges(raw)) {
|
|
307
|
+
if (stopped) break;
|
|
308
|
+
stats.frames += 1;
|
|
309
|
+
let plain;
|
|
310
|
+
try {
|
|
311
|
+
plain = zlib.zstdDecompressSync(raw.subarray(start, end));
|
|
312
|
+
} catch (error) {
|
|
313
|
+
throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
|
|
314
|
+
}
|
|
315
|
+
const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
|
|
316
|
+
const cut = merged.lastIndexOf(NEWLINE);
|
|
317
|
+
if (cut === -1) {
|
|
318
|
+
carry = merged;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
carry = merged.subarray(cut + 1);
|
|
322
|
+
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
323
|
+
feed(line);
|
|
324
|
+
if (stopped) break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
stats.truncatedTailBytes = carry.length;
|
|
328
|
+
if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
|
|
329
|
+
return stats;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
|
|
333
|
+
* AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
|
|
334
|
+
* (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
|
|
335
|
+
* 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
|
|
336
|
+
*/
|
|
337
|
+
function readSessionHeader(raw) {
|
|
338
|
+
let header;
|
|
339
|
+
walkSessionLog(raw, (record) => {
|
|
340
|
+
if (record.type !== "session") return;
|
|
341
|
+
header = record;
|
|
342
|
+
return false;
|
|
343
|
+
});
|
|
344
|
+
return header;
|
|
345
|
+
}
|
|
346
|
+
const DEFAULT_LIMITS = {
|
|
347
|
+
perMessageChars: 3e3,
|
|
348
|
+
totalChars: 5e5
|
|
349
|
+
};
|
|
350
|
+
/** 一条记录里那些 `content[]` 段的文字拼起来(`\n` 分隔;非 text 段一律不参与)。 */
|
|
351
|
+
function textOf(content) {
|
|
352
|
+
if (!Array.isArray(content)) return "";
|
|
353
|
+
return content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
|
|
354
|
+
}
|
|
355
|
+
function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
356
|
+
return async function readTrashDetail(entryId) {
|
|
357
|
+
const record = (await deps.manifest.list()).find((e) => e.id === entryId);
|
|
358
|
+
if (record === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
359
|
+
const dir = trashDirOf(record.id, deps.home);
|
|
360
|
+
if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
|
|
361
|
+
const names = (await deps.fs.listFiles(dir)).sort();
|
|
362
|
+
const logName = names.find((n) => n.endsWith(SESSION_LOG_SUFFIX));
|
|
363
|
+
if (logName === void 0) throw verbError("io", `回收站目录里没有会话日志:${dir}(里面有:${names.join(", ") || "空目录"})`);
|
|
364
|
+
let raw;
|
|
365
|
+
try {
|
|
366
|
+
raw = await deps.fs.readFile(`${dir}/${logName}`);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
throw verbError("io", `读会话日志失败:${String(error)}`, error);
|
|
369
|
+
}
|
|
370
|
+
const detail = {
|
|
371
|
+
entryId: record.id,
|
|
372
|
+
cwd: "",
|
|
373
|
+
createdAt: 0,
|
|
374
|
+
lastActivity: 0,
|
|
375
|
+
logTitle: "",
|
|
376
|
+
models: [],
|
|
377
|
+
turnCount: 0,
|
|
378
|
+
userMessageCount: 0,
|
|
379
|
+
assistantMessageCount: 0,
|
|
380
|
+
toolCallCount: 0,
|
|
381
|
+
injectedCount: 0,
|
|
382
|
+
messages: [],
|
|
383
|
+
omittedByPayloadCap: 0
|
|
384
|
+
};
|
|
385
|
+
const models = /* @__PURE__ */ new Set();
|
|
386
|
+
let usedChars = 0;
|
|
387
|
+
let capped = false;
|
|
388
|
+
/** 收进正文,顺带管两个闸:截到单条上限,累计到总量就停。 */
|
|
389
|
+
function push(role, time, text) {
|
|
390
|
+
if (capped) {
|
|
391
|
+
detail.omittedByPayloadCap += 1;
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const truncated = text.length > limits.perMessageChars;
|
|
395
|
+
const kept = truncated ? text.slice(0, limits.perMessageChars) : text;
|
|
396
|
+
if (usedChars + kept.length > limits.totalChars) {
|
|
397
|
+
capped = true;
|
|
398
|
+
detail.omittedByPayloadCap += 1;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
usedChars += kept.length;
|
|
402
|
+
detail.messages.push({
|
|
403
|
+
role,
|
|
404
|
+
time,
|
|
405
|
+
text: kept,
|
|
406
|
+
truncated
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
walkSessionLog(raw, (r) => {
|
|
411
|
+
if (typeof r.time === "number" && r.time > detail.lastActivity) detail.lastActivity = r.time;
|
|
412
|
+
const data = r.data ?? {};
|
|
413
|
+
switch (r.type) {
|
|
414
|
+
case "session":
|
|
415
|
+
if (typeof r.cwd === "string") detail.cwd = r.cwd;
|
|
416
|
+
if (typeof r.createdAt === "number") detail.createdAt = r.createdAt;
|
|
417
|
+
return;
|
|
418
|
+
case "session/title":
|
|
419
|
+
if (typeof data.title === "string") detail.logTitle = data.title;
|
|
420
|
+
return;
|
|
421
|
+
case "turn/start":
|
|
422
|
+
detail.turnCount += 1;
|
|
423
|
+
return;
|
|
424
|
+
case "tool/call":
|
|
425
|
+
detail.toolCallCount += 1;
|
|
426
|
+
return;
|
|
427
|
+
case "user/message": {
|
|
428
|
+
if ((data.source ?? {}).kind !== "user") {
|
|
429
|
+
detail.injectedCount += 1;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const text = textOf(data.content);
|
|
433
|
+
if (text === "") return;
|
|
434
|
+
detail.userMessageCount += 1;
|
|
435
|
+
push("user", r.time ?? 0, text);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case "assistant/message": {
|
|
439
|
+
const message = data.message ?? {};
|
|
440
|
+
const source = message.source ?? {};
|
|
441
|
+
if (typeof source.model === "string") models.add(source.model);
|
|
442
|
+
const text = textOf(message.content);
|
|
443
|
+
if (text === "") return;
|
|
444
|
+
detail.assistantMessageCount += 1;
|
|
445
|
+
push("assistant", r.time ?? 0, text);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
default: return;
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
throw verbError("io", error instanceof Error ? `读回收站日志失败:${error.message}` : `读回收站日志失败:${String(error)}`, error);
|
|
453
|
+
}
|
|
454
|
+
detail.models = [...models];
|
|
455
|
+
return detail;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/ports/host-port.ts
|
|
460
|
+
/** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
|
|
461
|
+
function liveSessionsOf(ctx) {
|
|
462
|
+
const store = ctx.get("sessions");
|
|
463
|
+
if (store === void 0 || store === null) return void 0;
|
|
464
|
+
if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
|
|
465
|
+
return store;
|
|
466
|
+
}
|
|
467
|
+
/** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
|
|
468
|
+
* 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。
|
|
469
|
+
* `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
|
|
470
|
+
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
471
|
+
/**
|
|
472
|
+
* 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
|
|
473
|
+
*
|
|
474
|
+
* 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
|
|
475
|
+
* 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
|
|
476
|
+
* (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
|
|
477
|
+
*
|
|
478
|
+
* 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
|
|
479
|
+
* `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
|
|
480
|
+
* else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
|
|
481
|
+
* else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
|
|
482
|
+
* return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
|
|
483
|
+
* 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
|
|
484
|
+
* 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
|
|
485
|
+
* (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
|
|
486
|
+
*
|
|
487
|
+
* **两个调用方共用这一份**:`projectDirOf`(删除时定位会话目录)与回收站对账的重建路径
|
|
488
|
+
* (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
|
|
489
|
+
*/
|
|
490
|
+
function projectKey(cwd) {
|
|
491
|
+
let readable = "";
|
|
492
|
+
let separatorRun = false;
|
|
493
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
494
|
+
const code = cwd.charCodeAt(i);
|
|
495
|
+
const ch = String.fromCharCode(code);
|
|
496
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
497
|
+
if (!separatorRun) readable += "-";
|
|
498
|
+
separatorRun = true;
|
|
499
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
500
|
+
readable += ch;
|
|
501
|
+
separatorRun = false;
|
|
502
|
+
} else {
|
|
503
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
504
|
+
separatorRun = false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
508
|
+
}
|
|
509
|
+
/** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
|
|
510
|
+
function projectionCacheOf(ctx) {
|
|
511
|
+
const cache = ctx.get("sessionProjectionCache");
|
|
512
|
+
if (cache === void 0 || cache === null) return void 0;
|
|
513
|
+
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
514
|
+
return cache;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* 「账本里没有这个会话」的判别串——宿主 `readSessionHeader(id)` 对未知会话抛的是**裸 `Error`**
|
|
518
|
+
* (没有类型、没有码),只认这一句。asar `dsh-workspace/lib/types/index.js` 原文(逐字):
|
|
519
|
+
* `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
|
|
520
|
+
*
|
|
521
|
+
* 为什么按消息判而不是全吞:同一个方法里 `listStoredHeaders()` / `indexHeaders()` 会抛**真存储故障**
|
|
522
|
+
* (读盘失败、账本不一致…),那是 `io` 该走的路,绝不能被当成「无此会话」。
|
|
523
|
+
* 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
|
|
524
|
+
* hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
|
|
525
|
+
* 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
|
|
526
|
+
*/
|
|
527
|
+
const NO_SUCH_SESSION = "session persistence holds no such session";
|
|
528
|
+
/** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
|
|
529
|
+
function isUnknownSession(error) {
|
|
530
|
+
return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* 宿主服务层的**真实绑定**:十二个方法各自把动词层的问题翻成宿主官方服务的调用。
|
|
534
|
+
*
|
|
535
|
+
* 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
|
|
536
|
+
* 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
|
|
537
|
+
* `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(设计稿 §6 的两行映射)。
|
|
538
|
+
*/
|
|
539
|
+
function createHostPort(ctx) {
|
|
540
|
+
const host = ctx;
|
|
541
|
+
const registry = host.workspaceRegistry;
|
|
542
|
+
return {
|
|
543
|
+
/** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
|
|
544
|
+
async workspaceOf(sessionId) {
|
|
545
|
+
return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
|
|
546
|
+
},
|
|
547
|
+
/**
|
|
548
|
+
* 原生 cwd → 宿主项目目录名(`projectKey`);没有 cwd 时按宿主约定落到 `_no-cwd`。
|
|
549
|
+
*
|
|
550
|
+
* 「账本里没有这个会话」= **未命中**,回 `undefined`(不是抛):这正是本方法声明的类型,
|
|
551
|
+
* 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
|
|
552
|
+
* verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ 设计稿 §6 的 `not-found`
|
|
553
|
+
* (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
|
|
554
|
+
*
|
|
555
|
+
* **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
|
|
556
|
+
*/
|
|
557
|
+
async projectDirOf(sessionId) {
|
|
558
|
+
let header;
|
|
559
|
+
try {
|
|
560
|
+
header = await registry.readSessionHeader(sessionId);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (isUnknownSession(error)) return void 0;
|
|
563
|
+
throw error;
|
|
564
|
+
}
|
|
565
|
+
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
566
|
+
},
|
|
567
|
+
/**
|
|
568
|
+
* 标题:走官方**投影缓存**读面(`title` 投影单元的 `wire.view` 值就是标题文本本身:
|
|
569
|
+
* 单元定义原文 `{ key: "title", stateVersion: 1, wire: { view: (state) => state } }`,
|
|
570
|
+
* 所以 `values.title` 是 `string | null`)。
|
|
571
|
+
* 契约:**尽力取、取不到返回空串、绝不因此让删除失败** ⇒ 这里吞掉一切异常。
|
|
572
|
+
*/
|
|
573
|
+
async titleOf(sessionId) {
|
|
574
|
+
try {
|
|
575
|
+
const header = await registry.readSessionHeader(sessionId);
|
|
576
|
+
const cache = projectionCacheOf(host);
|
|
577
|
+
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
578
|
+
return typeof title === "string" ? title : "";
|
|
579
|
+
} catch {
|
|
580
|
+
return "";
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
/**
|
|
584
|
+
* 活跃判据 = 宿主官方那一条(设计稿 §5):`workspace/session-activity` waterfall 问一次,
|
|
585
|
+
* **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
|
|
586
|
+
* asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
|
|
587
|
+
* if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
|
|
588
|
+
*/
|
|
589
|
+
async isLive(sessionId) {
|
|
590
|
+
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
591
|
+
},
|
|
592
|
+
/** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
|
|
593
|
+
async isArchived(sessionId) {
|
|
594
|
+
return registry.archivedSessionIds.includes(sessionId);
|
|
595
|
+
},
|
|
596
|
+
/**
|
|
597
|
+
* 从 `ctx.sessions`(live store)里摘掉一个会话。
|
|
598
|
+
*
|
|
599
|
+
* **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
|
|
600
|
+
* (见设计稿 §4 末)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来,
|
|
601
|
+
* 于是"删了还在"(真机实测:文件已进回收站、账本已摘,侧栏仍留着那一行)。
|
|
602
|
+
*
|
|
603
|
+
* **这是未声明为契约的内部入口**(设计稿 §4 末记了这次偏离):`liveEntryFor` / `entry.detach()` 是 store
|
|
604
|
+
* 给**会话 owner**(agent 的 effect 链)用的,宿主自己的控制器 `createOrAdopt` 只取 `agents.create()`
|
|
605
|
+
* 返回的 `.agent`、把带 `dispose` 的 handle 丢掉了,`ctx.agents` 也没有 `liveEntryFor` 那样的入口
|
|
606
|
+
* (会话 store 有、agent store 没有,逐成员面核过)⇒ 只能摘到"会话"这一层,上面那具 agent 摘不到,
|
|
607
|
+
* 它之后若走到 `flush(session)` 会撞 `liveEntryFor` 抛 `session "…" is not live in this store`。
|
|
608
|
+
*
|
|
609
|
+
* **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 抛 ⇒ `unknown`(**不通告**,
|
|
610
|
+
* 因为无法断言宿主列表已经干净);内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。
|
|
611
|
+
* 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
|
|
612
|
+
*/
|
|
613
|
+
async evictLive(sessionId) {
|
|
614
|
+
try {
|
|
615
|
+
const store = liveSessionsOf(host);
|
|
616
|
+
if (store === void 0) return "unknown";
|
|
617
|
+
const session = store.get(sessionId);
|
|
618
|
+
if (session === void 0 || session === null) return "not-live";
|
|
619
|
+
const entry = store.liveEntryFor(session);
|
|
620
|
+
if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
|
|
621
|
+
entry.detach();
|
|
622
|
+
return "evicted";
|
|
623
|
+
} catch {
|
|
624
|
+
return "unknown";
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
/**
|
|
628
|
+
* 通告客户端"这个会话没了"。
|
|
629
|
+
*
|
|
630
|
+
* **为什么需要**:客户端那一行只由两条路更新——重连时的整表重列(`handleConnected` → `refreshList`),
|
|
631
|
+
* 或宿主转发的 `api-session/*` 事件。删除一个**冷**会话时宿主不会发任何事件(`session/disposed` 只在摘掉
|
|
632
|
+
* 活体时才有),所以那一行会一直挂到下次重连为止(真机实测就是这个形状)。
|
|
633
|
+
*
|
|
634
|
+
* **为什么这样发是对的**:`api-session/removed` 是宿主**自己声明并转发**的事件
|
|
635
|
+
* (asar `dsh-api-remotes/lib/remote-events.js` 的 `API_REMOTE_FORWARDED_EVENTS` 里
|
|
636
|
+
* `{ event: "api-session/removed", mode: "emit" }`;转发器就是 `ctx.on(event, …)` 的普通监听者);
|
|
637
|
+
* 我们不带 carrier 地 emit ⇒ 全量派发到它(见 {@link HostCtxLike.emit})⇒ 客户端
|
|
638
|
+
* `ctx.remote.$on("api-session/removed", …)` 收到后 `recordMutation({kind:'remove'})` + `handleRemoved()`。
|
|
639
|
+
* **只在"宿主列表确实已经不含它"时调用**(调用方按 {@link EvictionOutcome} 判):这条通告在语义上是**如实**
|
|
640
|
+
* 的,不是障眼法。
|
|
641
|
+
*/
|
|
642
|
+
async announceRemoved(sessionId) {
|
|
643
|
+
try {
|
|
644
|
+
host.emit("api-session/removed", sessionId);
|
|
645
|
+
} catch {}
|
|
646
|
+
},
|
|
647
|
+
/**
|
|
648
|
+
* 通告客户端"这个会话回来了"(恢复用)。
|
|
649
|
+
*
|
|
650
|
+
* 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
|
|
651
|
+
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
652
|
+
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**——恢复本身已经完成,只是要等下次重连才上屏。
|
|
653
|
+
*/
|
|
654
|
+
async announceRestored(sessionId) {
|
|
655
|
+
try {
|
|
656
|
+
const controller = host.get("sessionController");
|
|
657
|
+
if (controller === void 0 || controller === null || typeof controller.list !== "function") return;
|
|
658
|
+
const listed = await controller.list();
|
|
659
|
+
const items = Array.isArray(listed) ? listed : listed?.items;
|
|
660
|
+
if (!Array.isArray(items)) return;
|
|
661
|
+
const summary = items.find((item) => item?.sessionId === sessionId);
|
|
662
|
+
if (summary === void 0) return;
|
|
663
|
+
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
664
|
+
} catch {}
|
|
665
|
+
},
|
|
666
|
+
/** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
|
|
667
|
+
async unarchive(sessionId) {
|
|
668
|
+
await registry.unarchiveSession(sessionId);
|
|
669
|
+
},
|
|
670
|
+
/** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
|
|
671
|
+
async detach(sessionId, workspaceId) {
|
|
672
|
+
const workspace = registry.get(workspaceId);
|
|
673
|
+
if (workspace === void 0) throw new Error(`workspace not found (detach): ${workspaceId}`);
|
|
674
|
+
await workspace.detachSession(sessionId);
|
|
675
|
+
},
|
|
676
|
+
/** 挂账本:实体方法 `attachSession`(顶层没有)。 */
|
|
677
|
+
async attach(sessionId, workspaceId) {
|
|
678
|
+
const workspace = registry.get(workspaceId);
|
|
679
|
+
if (workspace === void 0) throw new Error(`workspace not found (attach): ${workspaceId}`);
|
|
680
|
+
await workspace.attachSession(sessionId);
|
|
681
|
+
},
|
|
682
|
+
/** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
|
|
683
|
+
* (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant"),所以 `workspaceId` 不用
|
|
684
|
+
* ——未分组的会话没有工作区可传,签名里它是可选的。
|
|
685
|
+
* 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
|
|
686
|
+
async archive(sessionId) {
|
|
687
|
+
await registry.archiveSession(sessionId);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region src/trash/reconcile.ts
|
|
693
|
+
/**
|
|
694
|
+
* 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(设计稿 2026-10-15 §9)。
|
|
695
|
+
*
|
|
696
|
+
* 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
|
|
697
|
+
* `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
|
|
698
|
+
*
|
|
699
|
+
* 五条口径(都不是口味):
|
|
700
|
+
* ① **每次读清单都对账**(`GET /list` 的正文就是"对账 + 读"):挂载、刷新、别处动过后的自动重读、
|
|
701
|
+
* 客户端启动探测全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。代价 = 每次读多一次目录枚举
|
|
702
|
+
* (本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
|
|
703
|
+
* ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
|
|
704
|
+
* (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
|
|
705
|
+
* ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
|
|
706
|
+
* `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
|
|
707
|
+
* `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
|
|
708
|
+
* 落在「未分组」)、`wasArchived: false`(恢复按"可见"还原)、`title: ''`(详情有日志标题兜底)
|
|
709
|
+
* ——这是重建的固有代价,不是可以"再读读看"的东西。
|
|
710
|
+
* ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
|
|
711
|
+
* 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
|
|
712
|
+
* ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
|
|
713
|
+
*
|
|
714
|
+
* 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
|
|
715
|
+
* 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
|
|
716
|
+
* 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
|
|
717
|
+
*/
|
|
718
|
+
/** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`)。 */
|
|
719
|
+
function isEntryId(name) {
|
|
720
|
+
try {
|
|
721
|
+
parseEntryId(name);
|
|
722
|
+
return true;
|
|
723
|
+
} catch {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* 从一个手工放进回收站的目录重建清单行。**任一件事实拿不到就整体放弃这一条**(回 `undefined`):
|
|
729
|
+
* 半截行比"不列"更坏——恢复会照它把目录搬去一个错的地方(同 `detail.ts` 那条"半截正文"的口径)。
|
|
730
|
+
*
|
|
731
|
+
* `sessionId` 取日志 header 的 `id`(恢复之后宿主按日志 header 建索引,那才是会话身份);
|
|
732
|
+
* header 缺席这个字段才回落到目录名里那段原目录名。
|
|
733
|
+
*/
|
|
734
|
+
async function buildEntry(deps, id) {
|
|
735
|
+
const dir = trashDirOf(id, deps.home);
|
|
736
|
+
let logName;
|
|
737
|
+
try {
|
|
738
|
+
logName = (await deps.fs.listFiles(dir)).find((name) => name.endsWith(SESSION_LOG_SUFFIX));
|
|
739
|
+
} catch {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (logName === void 0) return void 0;
|
|
743
|
+
let raw;
|
|
744
|
+
try {
|
|
745
|
+
raw = await deps.fs.readFile(`${dir}/${logName}`);
|
|
746
|
+
} catch {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
let header;
|
|
750
|
+
try {
|
|
751
|
+
header = readSessionHeader(raw);
|
|
752
|
+
} catch {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (header === void 0) return void 0;
|
|
756
|
+
let sizeBytes;
|
|
757
|
+
try {
|
|
758
|
+
sizeBytes = await deps.fs.sizeOfDir(dir);
|
|
759
|
+
} catch {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const { deletedAt, sessionDirName } = parseEntryId(id);
|
|
763
|
+
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
764
|
+
return {
|
|
765
|
+
id,
|
|
766
|
+
sessionId: typeof header.id === "string" && header.id !== "" ? header.id : sessionDirName,
|
|
767
|
+
projectDir: cwd === "" ? NO_CWD_PROJECT_DIR : projectKey(cwd),
|
|
768
|
+
workspaceId: "",
|
|
769
|
+
wasArchived: false,
|
|
770
|
+
deletedAt,
|
|
771
|
+
title: "",
|
|
772
|
+
sizeBytes
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
/** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
|
|
776
|
+
async function reconcileTrash(deps) {
|
|
777
|
+
const rows = await deps.manifest.list();
|
|
778
|
+
const root = `${deps.home}/session-trash`;
|
|
779
|
+
if (!await deps.fs.exists(root)) return rows;
|
|
780
|
+
const dirIds = /* @__PURE__ */ new Set();
|
|
781
|
+
for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
|
|
782
|
+
const rowIds = new Set(rows.map((row) => row.id));
|
|
783
|
+
const drops = rows.filter((row) => !dirIds.has(row.id));
|
|
784
|
+
const rebuilds = [];
|
|
785
|
+
for (const id of dirIds) {
|
|
786
|
+
if (rowIds.has(id)) continue;
|
|
787
|
+
const entry = await buildEntry(deps, id);
|
|
788
|
+
if (entry !== void 0) rebuilds.push(entry);
|
|
789
|
+
}
|
|
790
|
+
if (drops.length === 0 && rebuilds.length === 0) return rows;
|
|
791
|
+
for (const row of drops) await deps.manifest.remove(row.id);
|
|
792
|
+
for (const entry of rebuilds) await deps.manifest.add(entry);
|
|
793
|
+
return await deps.manifest.list();
|
|
794
|
+
}
|
|
795
|
+
//#endregion
|
|
159
796
|
//#region src/verbs/delete-session.ts
|
|
160
797
|
function deleteSession(deps) {
|
|
161
798
|
return async function deleteSession(sessionId) {
|
|
@@ -409,7 +1046,7 @@ function requireMethod(method, want, route) {
|
|
|
409
1046
|
async function dispatch(method, rest, req, deps) {
|
|
410
1047
|
if (rest === ROUTES.list) {
|
|
411
1048
|
requireMethod(method, "GET", ROUTES.list);
|
|
412
|
-
return await deps
|
|
1049
|
+
return await reconcileTrash(deps);
|
|
413
1050
|
}
|
|
414
1051
|
if (rest === ROUTES.delete) {
|
|
415
1052
|
requireMethod(method, "POST", ROUTES.delete);
|
|
@@ -423,10 +1060,24 @@ async function dispatch(method, rest, req, deps) {
|
|
|
423
1060
|
requireMethod(method, "POST", ROUTES.purge);
|
|
424
1061
|
return await purgeTrash(deps)(optionalString(await readJsonBody(req), PARAMS.entryId));
|
|
425
1062
|
}
|
|
1063
|
+
if (rest === ROUTES.detail) {
|
|
1064
|
+
requireMethod(method, "POST", ROUTES.detail);
|
|
1065
|
+
return await readTrashDetail(deps)(requireString(await readJsonBody(req), PARAMS.entryId));
|
|
1066
|
+
}
|
|
426
1067
|
throw new Error(`未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
|
|
427
1068
|
}
|
|
428
1069
|
/** 宿主 `webServer.register({kind:'prefix', path: SESSION_DELETE_API_PREFIX, handler})` 的那个 handler。 */
|
|
429
1070
|
function createApiHandler(deps) {
|
|
1071
|
+
/**
|
|
1072
|
+
* 到达序串行链(文件头 ⑤):`list`(含对账)与三个写动词一个跑完再放下一个。`tail` 由构造保证
|
|
1073
|
+
* **永不 reject**(每个任务自己的失败留在交回给那次请求的那个 promise 上),所以一次失败不会卡死链。
|
|
1074
|
+
*/
|
|
1075
|
+
let tail = Promise.resolve();
|
|
1076
|
+
const throughChain = (task) => {
|
|
1077
|
+
const started = tail.then(task);
|
|
1078
|
+
tail = started.then(() => void 0, () => void 0);
|
|
1079
|
+
return started;
|
|
1080
|
+
};
|
|
430
1081
|
return async function handle(req, res) {
|
|
431
1082
|
if (!isTrustedRequest(req)) {
|
|
432
1083
|
writeFailure(res, /* @__PURE__ */ new Error("非受信来源"), 403);
|
|
@@ -435,7 +1086,9 @@ function createApiHandler(deps) {
|
|
|
435
1086
|
try {
|
|
436
1087
|
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
437
1088
|
if (!url.pathname.startsWith("/dsh-session-delete-api")) throw new Error(`未知路由:${url.pathname}`);
|
|
438
|
-
|
|
1089
|
+
const rest = url.pathname.slice(23);
|
|
1090
|
+
const task = () => dispatch(req.method ?? "GET", rest, req, deps);
|
|
1091
|
+
writeOk(res, await (rest === ROUTES.detail ? task() : throughChain(task)));
|
|
439
1092
|
} catch (error) {
|
|
440
1093
|
writeFailure(res, error);
|
|
441
1094
|
}
|
|
@@ -470,235 +1123,15 @@ function createFsPort() {
|
|
|
470
1123
|
force: true
|
|
471
1124
|
});
|
|
472
1125
|
},
|
|
473
|
-
sizeOfDir: (p) => sizeOfDir(p)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
//#endregion
|
|
477
|
-
//#region src/ports/host-port.ts
|
|
478
|
-
/** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
|
|
479
|
-
function liveSessionsOf(ctx) {
|
|
480
|
-
const store = ctx.get("sessions");
|
|
481
|
-
if (store === void 0 || store === null) return void 0;
|
|
482
|
-
if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
|
|
483
|
-
return store;
|
|
484
|
-
}
|
|
485
|
-
/** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
|
|
486
|
-
* 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
|
|
487
|
-
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
488
|
-
/**
|
|
489
|
-
* 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
|
|
490
|
-
*
|
|
491
|
-
* 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
|
|
492
|
-
* 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
|
|
493
|
-
* (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
|
|
494
|
-
*
|
|
495
|
-
* 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
|
|
496
|
-
* `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
|
|
497
|
-
* else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
|
|
498
|
-
* else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
|
|
499
|
-
* return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
|
|
500
|
-
* 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
|
|
501
|
-
* 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
|
|
502
|
-
* (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
|
|
503
|
-
*/
|
|
504
|
-
function projectKey(cwd) {
|
|
505
|
-
let readable = "";
|
|
506
|
-
let separatorRun = false;
|
|
507
|
-
for (let i = 0; i < cwd.length; i++) {
|
|
508
|
-
const code = cwd.charCodeAt(i);
|
|
509
|
-
const ch = String.fromCharCode(code);
|
|
510
|
-
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
511
|
-
if (!separatorRun) readable += "-";
|
|
512
|
-
separatorRun = true;
|
|
513
|
-
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
514
|
-
readable += ch;
|
|
515
|
-
separatorRun = false;
|
|
516
|
-
} else {
|
|
517
|
-
readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
518
|
-
separatorRun = false;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
522
|
-
}
|
|
523
|
-
/** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
|
|
524
|
-
function projectionCacheOf(ctx) {
|
|
525
|
-
const cache = ctx.get("sessionProjectionCache");
|
|
526
|
-
if (cache === void 0 || cache === null) return void 0;
|
|
527
|
-
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
528
|
-
return cache;
|
|
529
|
-
}
|
|
530
|
-
/**
|
|
531
|
-
* 「账本里没有这个会话」的判别串——宿主 `readSessionHeader(id)` 对未知会话抛的是**裸 `Error`**
|
|
532
|
-
* (没有类型、没有码),只认这一句。asar `dsh-workspace/lib/types/index.js` 原文(逐字):
|
|
533
|
-
* `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
|
|
534
|
-
*
|
|
535
|
-
* 为什么按消息判而不是全吞:同一个方法里 `listStoredHeaders()` / `indexHeaders()` 会抛**真存储故障**
|
|
536
|
-
* (读盘失败、账本不一致…),那是 `io` 该走的路,绝不能被当成「无此会话」。
|
|
537
|
-
* 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
|
|
538
|
-
* hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
|
|
539
|
-
* 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
|
|
540
|
-
*/
|
|
541
|
-
const NO_SUCH_SESSION = "session persistence holds no such session";
|
|
542
|
-
/** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
|
|
543
|
-
function isUnknownSession(error) {
|
|
544
|
-
return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* 宿主服务层的**真实绑定**:十二个方法各自把动词层的问题翻成宿主官方服务的调用。
|
|
548
|
-
*
|
|
549
|
-
* 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
|
|
550
|
-
* 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
|
|
551
|
-
* `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(设计稿 §6 的两行映射)。
|
|
552
|
-
*/
|
|
553
|
-
function createHostPort(ctx) {
|
|
554
|
-
const host = ctx;
|
|
555
|
-
const registry = host.workspaceRegistry;
|
|
556
|
-
return {
|
|
557
|
-
/** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
|
|
558
|
-
async workspaceOf(sessionId) {
|
|
559
|
-
return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
|
|
560
|
-
},
|
|
561
|
-
/**
|
|
562
|
-
* 原生 cwd → 宿主项目目录名(`projectKey`);没有 cwd 时按宿主约定落到 `_no-cwd`。
|
|
563
|
-
*
|
|
564
|
-
* 「账本里没有这个会话」= **未命中**,回 `undefined`(不是抛):这正是本方法声明的类型,
|
|
565
|
-
* 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
|
|
566
|
-
* verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ 设计稿 §6 的 `not-found`
|
|
567
|
-
* (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
|
|
568
|
-
*
|
|
569
|
-
* **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
|
|
570
|
-
*/
|
|
571
|
-
async projectDirOf(sessionId) {
|
|
572
|
-
let header;
|
|
573
|
-
try {
|
|
574
|
-
header = await registry.readSessionHeader(sessionId);
|
|
575
|
-
} catch (error) {
|
|
576
|
-
if (isUnknownSession(error)) return void 0;
|
|
577
|
-
throw error;
|
|
578
|
-
}
|
|
579
|
-
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
580
|
-
},
|
|
581
|
-
/**
|
|
582
|
-
* 标题:走官方**投影缓存**读面(`title` 投影单元的 `wire.view` 值就是标题文本本身:
|
|
583
|
-
* 单元定义原文 `{ key: "title", stateVersion: 1, wire: { view: (state) => state } }`,
|
|
584
|
-
* 所以 `values.title` 是 `string | null`)。
|
|
585
|
-
* 契约:**尽力取、取不到返回空串、绝不因此让删除失败** ⇒ 这里吞掉一切异常。
|
|
586
|
-
*/
|
|
587
|
-
async titleOf(sessionId) {
|
|
588
|
-
try {
|
|
589
|
-
const header = await registry.readSessionHeader(sessionId);
|
|
590
|
-
const cache = projectionCacheOf(host);
|
|
591
|
-
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
592
|
-
return typeof title === "string" ? title : "";
|
|
593
|
-
} catch {
|
|
594
|
-
return "";
|
|
595
|
-
}
|
|
596
|
-
},
|
|
597
|
-
/**
|
|
598
|
-
* 活跃判据 = 宿主官方那一条(设计稿 §5):`workspace/session-activity` waterfall 问一次,
|
|
599
|
-
* **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
|
|
600
|
-
* asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
|
|
601
|
-
* if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
|
|
602
|
-
*/
|
|
603
|
-
async isLive(sessionId) {
|
|
604
|
-
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
605
|
-
},
|
|
606
|
-
/** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
|
|
607
|
-
async isArchived(sessionId) {
|
|
608
|
-
return registry.archivedSessionIds.includes(sessionId);
|
|
609
|
-
},
|
|
610
|
-
/**
|
|
611
|
-
* 从 `ctx.sessions`(live store)里摘掉一个会话。
|
|
612
|
-
*
|
|
613
|
-
* **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
|
|
614
|
-
* (见设计稿 §4 末)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来,
|
|
615
|
-
* 于是"删了还在"(真机实测:文件已进回收站、账本已摘,侧栏仍留着那一行)。
|
|
616
|
-
*
|
|
617
|
-
* **这是未声明为契约的内部入口**(设计稿 §4 末记了这次偏离):`liveEntryFor` / `entry.detach()` 是 store
|
|
618
|
-
* 给**会话 owner**(agent 的 effect 链)用的,宿主自己的控制器 `createOrAdopt` 只取 `agents.create()`
|
|
619
|
-
* 返回的 `.agent`、把带 `dispose` 的 handle 丢掉了,`ctx.agents` 也没有 `liveEntryFor` 那样的入口
|
|
620
|
-
* (会话 store 有、agent store 没有,逐成员面核过)⇒ 只能摘到"会话"这一层,上面那具 agent 摘不到,
|
|
621
|
-
* 它之后若走到 `flush(session)` 会撞 `liveEntryFor` 抛 `session "…" is not live in this store`。
|
|
622
|
-
*
|
|
623
|
-
* **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 抛 ⇒ `unknown`(**不通告**,
|
|
624
|
-
* 因为无法断言宿主列表已经干净);内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。
|
|
625
|
-
* 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
|
|
626
|
-
*/
|
|
627
|
-
async evictLive(sessionId) {
|
|
628
|
-
try {
|
|
629
|
-
const store = liveSessionsOf(host);
|
|
630
|
-
if (store === void 0) return "unknown";
|
|
631
|
-
const session = store.get(sessionId);
|
|
632
|
-
if (session === void 0 || session === null) return "not-live";
|
|
633
|
-
const entry = store.liveEntryFor(session);
|
|
634
|
-
if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
|
|
635
|
-
entry.detach();
|
|
636
|
-
return "evicted";
|
|
637
|
-
} catch {
|
|
638
|
-
return "unknown";
|
|
639
|
-
}
|
|
640
|
-
},
|
|
641
|
-
/**
|
|
642
|
-
* 通告客户端"这个会话没了"。
|
|
643
|
-
*
|
|
644
|
-
* **为什么需要**:客户端那一行只由两条路更新——重连时的整表重列(`handleConnected` → `refreshList`),
|
|
645
|
-
* 或宿主转发的 `api-session/*` 事件。删除一个**冷**会话时宿主不会发任何事件(`session/disposed` 只在摘掉
|
|
646
|
-
* 活体时才有),所以那一行会一直挂到下次重连为止(真机实测就是这个形状)。
|
|
647
|
-
*
|
|
648
|
-
* **为什么这样发是对的**:`api-session/removed` 是宿主**自己声明并转发**的事件
|
|
649
|
-
* (asar `dsh-api-remotes/lib/remote-events.js` 的 `API_REMOTE_FORWARDED_EVENTS` 里
|
|
650
|
-
* `{ event: "api-session/removed", mode: "emit" }`;转发器就是 `ctx.on(event, …)` 的普通监听者);
|
|
651
|
-
* 我们不带 carrier 地 emit ⇒ 全量派发到它(见 {@link HostCtxLike.emit})⇒ 客户端
|
|
652
|
-
* `ctx.remote.$on("api-session/removed", …)` 收到后 `recordMutation({kind:'remove'})` + `handleRemoved()`。
|
|
653
|
-
* **只在"宿主列表确实已经不含它"时调用**(调用方按 {@link EvictionOutcome} 判):这条通告在语义上是**如实**
|
|
654
|
-
* 的,不是障眼法。
|
|
655
|
-
*/
|
|
656
|
-
async announceRemoved(sessionId) {
|
|
657
|
-
try {
|
|
658
|
-
host.emit("api-session/removed", sessionId);
|
|
659
|
-
} catch {}
|
|
660
|
-
},
|
|
661
|
-
/**
|
|
662
|
-
* 通告客户端"这个会话回来了"(恢复用)。
|
|
663
|
-
*
|
|
664
|
-
* 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
|
|
665
|
-
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
666
|
-
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**——恢复本身已经完成,只是要等下次重连才上屏。
|
|
667
|
-
*/
|
|
668
|
-
async announceRestored(sessionId) {
|
|
669
|
-
try {
|
|
670
|
-
const controller = host.get("sessionController");
|
|
671
|
-
if (controller === void 0 || controller === null || typeof controller.list !== "function") return;
|
|
672
|
-
const listed = await controller.list();
|
|
673
|
-
const items = Array.isArray(listed) ? listed : listed?.items;
|
|
674
|
-
if (!Array.isArray(items)) return;
|
|
675
|
-
const summary = items.find((item) => item?.sessionId === sessionId);
|
|
676
|
-
if (summary === void 0) return;
|
|
677
|
-
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
678
|
-
} catch {}
|
|
679
|
-
},
|
|
680
|
-
/** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
|
|
681
|
-
async unarchive(sessionId) {
|
|
682
|
-
await registry.unarchiveSession(sessionId);
|
|
683
|
-
},
|
|
684
|
-
/** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
|
|
685
|
-
async detach(sessionId, workspaceId) {
|
|
686
|
-
const workspace = registry.get(workspaceId);
|
|
687
|
-
if (workspace === void 0) throw new Error(`workspace not found (detach): ${workspaceId}`);
|
|
688
|
-
await workspace.detachSession(sessionId);
|
|
1126
|
+
sizeOfDir: (p) => sizeOfDir(p),
|
|
1127
|
+
async listFiles(p) {
|
|
1128
|
+
return readdir(p);
|
|
689
1129
|
},
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const workspace = registry.get(workspaceId);
|
|
693
|
-
if (workspace === void 0) throw new Error(`workspace not found (attach): ${workspaceId}`);
|
|
694
|
-
await workspace.attachSession(sessionId);
|
|
1130
|
+
async listDirs(p) {
|
|
1131
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
695
1132
|
},
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
* ——未分组的会话没有工作区可传,签名里它是可选的。
|
|
699
|
-
* 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
|
|
700
|
-
async archive(sessionId) {
|
|
701
|
-
await registry.archiveSession(sessionId);
|
|
1133
|
+
async readFile(p) {
|
|
1134
|
+
return readFile(p);
|
|
702
1135
|
}
|
|
703
1136
|
};
|
|
704
1137
|
}
|
|
@@ -732,7 +1165,7 @@ const name = "@xrn1997/dsh-session-delete";
|
|
|
732
1165
|
* 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
|
|
733
1166
|
*
|
|
734
1167
|
* `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
|
|
735
|
-
* 前缀路由,浏览器半同源 fetch
|
|
1168
|
+
* 前缀路由,浏览器半同源 fetch 那五个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
|
|
736
1169
|
* `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
|
|
737
1170
|
* `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
|
|
738
1171
|
*/
|