@xrn1997/dsh-session-delete 0.1.0 → 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 +1 -1
- package/lib/client.js +43 -62
- package/lib/client.js.map +1 -1
- package/lib/index.js +388 -261
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -28,7 +28,8 @@ import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
|
28
28
|
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
29
29
|
*/
|
|
30
30
|
const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
|
|
31
|
-
/** 五个端点的段名(相对前缀;`GET list`
|
|
31
|
+
/** 五个端点的段名(相对前缀;`GET list` **对账并读**清单——`session-trash/` 里的目录是准,其余四个
|
|
32
|
+
* `POST` 按 id 取数或写)。 */
|
|
32
33
|
const ROUTES = {
|
|
33
34
|
list: "/list",
|
|
34
35
|
delete: "/delete",
|
|
@@ -272,6 +273,14 @@ function frameRanges(raw) {
|
|
|
272
273
|
* 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
|
|
273
274
|
*/
|
|
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
|
+
*/
|
|
275
284
|
function walkSessionLog(raw, onRecord) {
|
|
276
285
|
const stats = {
|
|
277
286
|
frames: 0,
|
|
@@ -279,6 +288,7 @@ function walkSessionLog(raw, onRecord) {
|
|
|
279
288
|
unparsedLines: 0,
|
|
280
289
|
truncatedTailBytes: 0
|
|
281
290
|
};
|
|
291
|
+
let stopped = false;
|
|
282
292
|
const feed = (line) => {
|
|
283
293
|
if (line === "") return;
|
|
284
294
|
let parsed;
|
|
@@ -289,11 +299,12 @@ function walkSessionLog(raw, onRecord) {
|
|
|
289
299
|
return;
|
|
290
300
|
}
|
|
291
301
|
stats.records += 1;
|
|
292
|
-
onRecord(parsed !== null && typeof parsed === "object" ? parsed : {});
|
|
302
|
+
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
293
303
|
};
|
|
294
304
|
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
295
305
|
let carry = Buffer.alloc(0);
|
|
296
306
|
for (const { start, end } of frameRanges(raw)) {
|
|
307
|
+
if (stopped) break;
|
|
297
308
|
stats.frames += 1;
|
|
298
309
|
let plain;
|
|
299
310
|
try {
|
|
@@ -308,40 +319,33 @@ function walkSessionLog(raw, onRecord) {
|
|
|
308
319
|
continue;
|
|
309
320
|
}
|
|
310
321
|
carry = merged.subarray(cut + 1);
|
|
311
|
-
for (const line of merged.subarray(0, cut).toString("utf8").split("\n"))
|
|
322
|
+
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
323
|
+
feed(line);
|
|
324
|
+
if (stopped) break;
|
|
325
|
+
}
|
|
312
326
|
}
|
|
313
327
|
stats.truncatedTailBytes = carry.length;
|
|
314
|
-
if (carry.length > 0) feed(carry.toString("utf8"));
|
|
328
|
+
if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
|
|
315
329
|
return stats;
|
|
316
330
|
}
|
|
317
|
-
//#endregion
|
|
318
|
-
//#region src/trash/detail.ts
|
|
319
331
|
/**
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* 三条硬口径,都不是口味:
|
|
325
|
-
* ① **寻址只认清册**:`entryId` 先在清单里查,目录一律由 `trashDirOf(record.id, home)` 推 ⇒ 外部
|
|
326
|
-
* 输入进不了文件系统寻址(不给"传个路径就读任意目录"留缝)。
|
|
327
|
-
* ② **正文只认 `source.kind === "user"`**:`user/message` 里绝大多数是宿主注入的上下文
|
|
328
|
-
* (本机实测一条会话:真实提问 21 条、注入 241 条),不过滤就被淹掉;注入条数照样上线
|
|
329
|
-
* (`injectedCount`),让"为什么少了这么多条"能与页面上的数字对账。
|
|
330
|
-
* ③ **两个闸是载荷闸,不是读盘闸**:每条 3 000 字符、累计 50 万字符(CJK 最坏 ≈ 1.5 MB UTF-8)。
|
|
331
|
-
* 按 §1 的读数本机一条都触发不了(最大那条正文 806 KB ≈ 27 万字符、442 条消息)⇒ 它们是防异常
|
|
332
|
-
* 膨胀的;**不设"文件太大就拒绝"**,那种阈值只会把真读得出来的正文挡在外面。
|
|
333
|
-
*
|
|
334
|
-
* 计数与正文**同源同判据**:`userMessageCount` 就是进了正文的提问条数、`assistantMessageCount` 就是
|
|
335
|
-
* 有文字的回复条数——不是一套判据算两遍,否则页面上会出现"计数 421、正文 200"这种对不上账的话。
|
|
332
|
+
* 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
|
|
333
|
+
* AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
|
|
334
|
+
* (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
|
|
335
|
+
* 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
|
|
336
336
|
*/
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
+
}
|
|
342
346
|
const DEFAULT_LIMITS = {
|
|
343
|
-
perMessageChars:
|
|
344
|
-
totalChars:
|
|
347
|
+
perMessageChars: 3e3,
|
|
348
|
+
totalChars: 5e5
|
|
345
349
|
};
|
|
346
350
|
/** 一条记录里那些 `content[]` 段的文字拼起来(`\n` 分隔;非 text 段一律不参与)。 */
|
|
347
351
|
function textOf(content) {
|
|
@@ -355,7 +359,7 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
355
359
|
const dir = trashDirOf(record.id, deps.home);
|
|
356
360
|
if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
|
|
357
361
|
const names = (await deps.fs.listFiles(dir)).sort();
|
|
358
|
-
const logName = names.find((n) => n.endsWith(
|
|
362
|
+
const logName = names.find((n) => n.endsWith(SESSION_LOG_SUFFIX));
|
|
359
363
|
if (logName === void 0) throw verbError("io", `回收站目录里没有会话日志:${dir}(里面有:${names.join(", ") || "空目录"})`);
|
|
360
364
|
let raw;
|
|
361
365
|
try {
|
|
@@ -452,6 +456,343 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
452
456
|
};
|
|
453
457
|
}
|
|
454
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
|
|
455
796
|
//#region src/verbs/delete-session.ts
|
|
456
797
|
function deleteSession(deps) {
|
|
457
798
|
return async function deleteSession(sessionId) {
|
|
@@ -705,7 +1046,7 @@ function requireMethod(method, want, route) {
|
|
|
705
1046
|
async function dispatch(method, rest, req, deps) {
|
|
706
1047
|
if (rest === ROUTES.list) {
|
|
707
1048
|
requireMethod(method, "GET", ROUTES.list);
|
|
708
|
-
return await deps
|
|
1049
|
+
return await reconcileTrash(deps);
|
|
709
1050
|
}
|
|
710
1051
|
if (rest === ROUTES.delete) {
|
|
711
1052
|
requireMethod(method, "POST", ROUTES.delete);
|
|
@@ -727,6 +1068,16 @@ async function dispatch(method, rest, req, deps) {
|
|
|
727
1068
|
}
|
|
728
1069
|
/** 宿主 `webServer.register({kind:'prefix', path: SESSION_DELETE_API_PREFIX, handler})` 的那个 handler。 */
|
|
729
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
|
+
};
|
|
730
1081
|
return async function handle(req, res) {
|
|
731
1082
|
if (!isTrustedRequest(req)) {
|
|
732
1083
|
writeFailure(res, /* @__PURE__ */ new Error("非受信来源"), 403);
|
|
@@ -735,7 +1086,9 @@ function createApiHandler(deps) {
|
|
|
735
1086
|
try {
|
|
736
1087
|
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
737
1088
|
if (!url.pathname.startsWith("/dsh-session-delete-api")) throw new Error(`未知路由:${url.pathname}`);
|
|
738
|
-
|
|
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)));
|
|
739
1092
|
} catch (error) {
|
|
740
1093
|
writeFailure(res, error);
|
|
741
1094
|
}
|
|
@@ -774,241 +1127,15 @@ function createFsPort() {
|
|
|
774
1127
|
async listFiles(p) {
|
|
775
1128
|
return readdir(p);
|
|
776
1129
|
},
|
|
1130
|
+
async listDirs(p) {
|
|
1131
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
1132
|
+
},
|
|
777
1133
|
async readFile(p) {
|
|
778
1134
|
return readFile(p);
|
|
779
1135
|
}
|
|
780
1136
|
};
|
|
781
1137
|
}
|
|
782
1138
|
//#endregion
|
|
783
|
-
//#region src/ports/host-port.ts
|
|
784
|
-
/** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
|
|
785
|
-
function liveSessionsOf(ctx) {
|
|
786
|
-
const store = ctx.get("sessions");
|
|
787
|
-
if (store === void 0 || store === null) return void 0;
|
|
788
|
-
if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
|
|
789
|
-
return store;
|
|
790
|
-
}
|
|
791
|
-
/** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
|
|
792
|
-
* 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
|
|
793
|
-
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
794
|
-
/**
|
|
795
|
-
* 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
|
|
796
|
-
*
|
|
797
|
-
* 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
|
|
798
|
-
* 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
|
|
799
|
-
* (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
|
|
800
|
-
*
|
|
801
|
-
* 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
|
|
802
|
-
* `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
|
|
803
|
-
* else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
|
|
804
|
-
* else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
|
|
805
|
-
* return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
|
|
806
|
-
* 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
|
|
807
|
-
* 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
|
|
808
|
-
* (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
|
|
809
|
-
*/
|
|
810
|
-
function projectKey(cwd) {
|
|
811
|
-
let readable = "";
|
|
812
|
-
let separatorRun = false;
|
|
813
|
-
for (let i = 0; i < cwd.length; i++) {
|
|
814
|
-
const code = cwd.charCodeAt(i);
|
|
815
|
-
const ch = String.fromCharCode(code);
|
|
816
|
-
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
817
|
-
if (!separatorRun) readable += "-";
|
|
818
|
-
separatorRun = true;
|
|
819
|
-
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
820
|
-
readable += ch;
|
|
821
|
-
separatorRun = false;
|
|
822
|
-
} else {
|
|
823
|
-
readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
824
|
-
separatorRun = false;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
828
|
-
}
|
|
829
|
-
/** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
|
|
830
|
-
function projectionCacheOf(ctx) {
|
|
831
|
-
const cache = ctx.get("sessionProjectionCache");
|
|
832
|
-
if (cache === void 0 || cache === null) return void 0;
|
|
833
|
-
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
834
|
-
return cache;
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* 「账本里没有这个会话」的判别串——宿主 `readSessionHeader(id)` 对未知会话抛的是**裸 `Error`**
|
|
838
|
-
* (没有类型、没有码),只认这一句。asar `dsh-workspace/lib/types/index.js` 原文(逐字):
|
|
839
|
-
* `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
|
|
840
|
-
*
|
|
841
|
-
* 为什么按消息判而不是全吞:同一个方法里 `listStoredHeaders()` / `indexHeaders()` 会抛**真存储故障**
|
|
842
|
-
* (读盘失败、账本不一致…),那是 `io` 该走的路,绝不能被当成「无此会话」。
|
|
843
|
-
* 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
|
|
844
|
-
* hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
|
|
845
|
-
* 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
|
|
846
|
-
*/
|
|
847
|
-
const NO_SUCH_SESSION = "session persistence holds no such session";
|
|
848
|
-
/** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
|
|
849
|
-
function isUnknownSession(error) {
|
|
850
|
-
return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
|
|
851
|
-
}
|
|
852
|
-
/**
|
|
853
|
-
* 宿主服务层的**真实绑定**:十二个方法各自把动词层的问题翻成宿主官方服务的调用。
|
|
854
|
-
*
|
|
855
|
-
* 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
|
|
856
|
-
* 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
|
|
857
|
-
* `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(设计稿 §6 的两行映射)。
|
|
858
|
-
*/
|
|
859
|
-
function createHostPort(ctx) {
|
|
860
|
-
const host = ctx;
|
|
861
|
-
const registry = host.workspaceRegistry;
|
|
862
|
-
return {
|
|
863
|
-
/** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
|
|
864
|
-
async workspaceOf(sessionId) {
|
|
865
|
-
return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
|
|
866
|
-
},
|
|
867
|
-
/**
|
|
868
|
-
* 原生 cwd → 宿主项目目录名(`projectKey`);没有 cwd 时按宿主约定落到 `_no-cwd`。
|
|
869
|
-
*
|
|
870
|
-
* 「账本里没有这个会话」= **未命中**,回 `undefined`(不是抛):这正是本方法声明的类型,
|
|
871
|
-
* 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
|
|
872
|
-
* verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ 设计稿 §6 的 `not-found`
|
|
873
|
-
* (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
|
|
874
|
-
*
|
|
875
|
-
* **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
|
|
876
|
-
*/
|
|
877
|
-
async projectDirOf(sessionId) {
|
|
878
|
-
let header;
|
|
879
|
-
try {
|
|
880
|
-
header = await registry.readSessionHeader(sessionId);
|
|
881
|
-
} catch (error) {
|
|
882
|
-
if (isUnknownSession(error)) return void 0;
|
|
883
|
-
throw error;
|
|
884
|
-
}
|
|
885
|
-
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
886
|
-
},
|
|
887
|
-
/**
|
|
888
|
-
* 标题:走官方**投影缓存**读面(`title` 投影单元的 `wire.view` 值就是标题文本本身:
|
|
889
|
-
* 单元定义原文 `{ key: "title", stateVersion: 1, wire: { view: (state) => state } }`,
|
|
890
|
-
* 所以 `values.title` 是 `string | null`)。
|
|
891
|
-
* 契约:**尽力取、取不到返回空串、绝不因此让删除失败** ⇒ 这里吞掉一切异常。
|
|
892
|
-
*/
|
|
893
|
-
async titleOf(sessionId) {
|
|
894
|
-
try {
|
|
895
|
-
const header = await registry.readSessionHeader(sessionId);
|
|
896
|
-
const cache = projectionCacheOf(host);
|
|
897
|
-
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
898
|
-
return typeof title === "string" ? title : "";
|
|
899
|
-
} catch {
|
|
900
|
-
return "";
|
|
901
|
-
}
|
|
902
|
-
},
|
|
903
|
-
/**
|
|
904
|
-
* 活跃判据 = 宿主官方那一条(设计稿 §5):`workspace/session-activity` waterfall 问一次,
|
|
905
|
-
* **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
|
|
906
|
-
* asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
|
|
907
|
-
* if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
|
|
908
|
-
*/
|
|
909
|
-
async isLive(sessionId) {
|
|
910
|
-
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
911
|
-
},
|
|
912
|
-
/** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
|
|
913
|
-
async isArchived(sessionId) {
|
|
914
|
-
return registry.archivedSessionIds.includes(sessionId);
|
|
915
|
-
},
|
|
916
|
-
/**
|
|
917
|
-
* 从 `ctx.sessions`(live store)里摘掉一个会话。
|
|
918
|
-
*
|
|
919
|
-
* **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
|
|
920
|
-
* (见设计稿 §4 末)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来,
|
|
921
|
-
* 于是"删了还在"(真机实测:文件已进回收站、账本已摘,侧栏仍留着那一行)。
|
|
922
|
-
*
|
|
923
|
-
* **这是未声明为契约的内部入口**(设计稿 §4 末记了这次偏离):`liveEntryFor` / `entry.detach()` 是 store
|
|
924
|
-
* 给**会话 owner**(agent 的 effect 链)用的,宿主自己的控制器 `createOrAdopt` 只取 `agents.create()`
|
|
925
|
-
* 返回的 `.agent`、把带 `dispose` 的 handle 丢掉了,`ctx.agents` 也没有 `liveEntryFor` 那样的入口
|
|
926
|
-
* (会话 store 有、agent store 没有,逐成员面核过)⇒ 只能摘到"会话"这一层,上面那具 agent 摘不到,
|
|
927
|
-
* 它之后若走到 `flush(session)` 会撞 `liveEntryFor` 抛 `session "…" is not live in this store`。
|
|
928
|
-
*
|
|
929
|
-
* **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 抛 ⇒ `unknown`(**不通告**,
|
|
930
|
-
* 因为无法断言宿主列表已经干净);内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。
|
|
931
|
-
* 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
|
|
932
|
-
*/
|
|
933
|
-
async evictLive(sessionId) {
|
|
934
|
-
try {
|
|
935
|
-
const store = liveSessionsOf(host);
|
|
936
|
-
if (store === void 0) return "unknown";
|
|
937
|
-
const session = store.get(sessionId);
|
|
938
|
-
if (session === void 0 || session === null) return "not-live";
|
|
939
|
-
const entry = store.liveEntryFor(session);
|
|
940
|
-
if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
|
|
941
|
-
entry.detach();
|
|
942
|
-
return "evicted";
|
|
943
|
-
} catch {
|
|
944
|
-
return "unknown";
|
|
945
|
-
}
|
|
946
|
-
},
|
|
947
|
-
/**
|
|
948
|
-
* 通告客户端"这个会话没了"。
|
|
949
|
-
*
|
|
950
|
-
* **为什么需要**:客户端那一行只由两条路更新——重连时的整表重列(`handleConnected` → `refreshList`),
|
|
951
|
-
* 或宿主转发的 `api-session/*` 事件。删除一个**冷**会话时宿主不会发任何事件(`session/disposed` 只在摘掉
|
|
952
|
-
* 活体时才有),所以那一行会一直挂到下次重连为止(真机实测就是这个形状)。
|
|
953
|
-
*
|
|
954
|
-
* **为什么这样发是对的**:`api-session/removed` 是宿主**自己声明并转发**的事件
|
|
955
|
-
* (asar `dsh-api-remotes/lib/remote-events.js` 的 `API_REMOTE_FORWARDED_EVENTS` 里
|
|
956
|
-
* `{ event: "api-session/removed", mode: "emit" }`;转发器就是 `ctx.on(event, …)` 的普通监听者);
|
|
957
|
-
* 我们不带 carrier 地 emit ⇒ 全量派发到它(见 {@link HostCtxLike.emit})⇒ 客户端
|
|
958
|
-
* `ctx.remote.$on("api-session/removed", …)` 收到后 `recordMutation({kind:'remove'})` + `handleRemoved()`。
|
|
959
|
-
* **只在"宿主列表确实已经不含它"时调用**(调用方按 {@link EvictionOutcome} 判):这条通告在语义上是**如实**
|
|
960
|
-
* 的,不是障眼法。
|
|
961
|
-
*/
|
|
962
|
-
async announceRemoved(sessionId) {
|
|
963
|
-
try {
|
|
964
|
-
host.emit("api-session/removed", sessionId);
|
|
965
|
-
} catch {}
|
|
966
|
-
},
|
|
967
|
-
/**
|
|
968
|
-
* 通告客户端"这个会话回来了"(恢复用)。
|
|
969
|
-
*
|
|
970
|
-
* 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
|
|
971
|
-
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
972
|
-
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**——恢复本身已经完成,只是要等下次重连才上屏。
|
|
973
|
-
*/
|
|
974
|
-
async announceRestored(sessionId) {
|
|
975
|
-
try {
|
|
976
|
-
const controller = host.get("sessionController");
|
|
977
|
-
if (controller === void 0 || controller === null || typeof controller.list !== "function") return;
|
|
978
|
-
const listed = await controller.list();
|
|
979
|
-
const items = Array.isArray(listed) ? listed : listed?.items;
|
|
980
|
-
if (!Array.isArray(items)) return;
|
|
981
|
-
const summary = items.find((item) => item?.sessionId === sessionId);
|
|
982
|
-
if (summary === void 0) return;
|
|
983
|
-
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
984
|
-
} catch {}
|
|
985
|
-
},
|
|
986
|
-
/** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
|
|
987
|
-
async unarchive(sessionId) {
|
|
988
|
-
await registry.unarchiveSession(sessionId);
|
|
989
|
-
},
|
|
990
|
-
/** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
|
|
991
|
-
async detach(sessionId, workspaceId) {
|
|
992
|
-
const workspace = registry.get(workspaceId);
|
|
993
|
-
if (workspace === void 0) throw new Error(`workspace not found (detach): ${workspaceId}`);
|
|
994
|
-
await workspace.detachSession(sessionId);
|
|
995
|
-
},
|
|
996
|
-
/** 挂账本:实体方法 `attachSession`(顶层没有)。 */
|
|
997
|
-
async attach(sessionId, workspaceId) {
|
|
998
|
-
const workspace = registry.get(workspaceId);
|
|
999
|
-
if (workspace === void 0) throw new Error(`workspace not found (attach): ${workspaceId}`);
|
|
1000
|
-
await workspace.attachSession(sessionId);
|
|
1001
|
-
},
|
|
1002
|
-
/** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
|
|
1003
|
-
* (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant"),所以 `workspaceId` 不用
|
|
1004
|
-
* ——未分组的会话没有工作区可传,签名里它是可选的。
|
|
1005
|
-
* 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
|
|
1006
|
-
async archive(sessionId) {
|
|
1007
|
-
await registry.archiveSession(sessionId);
|
|
1008
|
-
}
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
|
-
//#endregion
|
|
1012
1139
|
//#region src/trash/manifest.ts
|
|
1013
1140
|
function createStorageManifest$1(domain) {
|
|
1014
1141
|
return {
|