@xrn1997/dsh-session-delete 0.2.0 → 0.2.2
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 +52 -44
- package/lib/client.js +912 -487
- package/lib/index.js +1746 -1113
- package/lib/types/api/dispatch.d.ts +23 -39
- package/lib/types/api/route-table.d.ts +35 -0
- package/lib/types/api/trash-rows.d.ts +24 -0
- package/lib/types/client/copy.d.ts +13 -0
- package/lib/types/client/remote.d.ts +49 -70
- package/lib/types/client/trash-confirm.d.ts +39 -0
- package/lib/types/client/trash-panel.d.ts +4 -2
- package/lib/types/client/trash-view.d.ts +38 -0
- package/lib/types/client/undo-toast.d.ts +2 -2
- package/lib/types/client/use-trash-query.d.ts +32 -0
- package/lib/types/client/use-trash-selection.d.ts +32 -0
- package/lib/types/index.d.ts +4 -3
- package/lib/types/ports/host-port.d.ts +33 -1
- package/lib/types/remote/map-error.d.ts +1 -1
- package/lib/types/shared/wire.d.ts +103 -9
- package/lib/types/trash/detail.d.ts +5 -23
- package/lib/types/trash/entry.d.ts +25 -2
- package/lib/types/trash/groups.d.ts +45 -0
- package/lib/types/verbs/delete-session.d.ts +5 -8
- package/lib/types/verbs/errors.d.ts +13 -6
- package/lib/types/verbs/member-journal.d.ts +26 -0
- package/lib/types/verbs/orphan-subagents.d.ts +82 -0
- package/lib/types/verbs/purge-trash.d.ts +13 -1
- package/lib/types/verbs/restore-session.d.ts +28 -8
- package/package.json +8 -3
- /package/lib/types/verbs/{apply.d.ts → batch.d.ts} +0 -0
package/lib/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
6
6
|
//#region src/shared/wire.ts
|
|
7
7
|
/**
|
|
8
|
-
* 跨半 wire 契约:`/dsh-session-delete-api`
|
|
8
|
+
* 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
|
|
9
9
|
* Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
|
|
10
10
|
* 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
|
|
11
11
|
*
|
|
@@ -28,13 +28,15 @@ 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
|
-
/**
|
|
31
|
+
/** 六个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
|
|
32
32
|
* 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
|
|
33
33
|
* - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
|
|
34
34
|
* - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
|
|
35
35
|
* - `POST /detail`:单条详情(用 POST 表达一个读:见设计稿 2026-10-15 §3)。
|
|
36
|
-
* - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count`),逐条回 `ApplyOutcome`。
|
|
37
|
-
* - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
|
|
36
|
+
* - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
|
|
37
|
+
* - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
|
|
38
|
+
* - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
|
|
39
|
+
* 第五个动作,这一段只负责"有多少、在哪、多大"。 */
|
|
38
40
|
const ROUTES = {
|
|
39
41
|
list: "/list",
|
|
40
42
|
/** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
|
|
@@ -44,7 +46,9 @@ const ROUTES = {
|
|
|
44
46
|
/** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
|
|
45
47
|
apply: "/apply",
|
|
46
48
|
/** 清空回收站。 */
|
|
47
|
-
empty: "/empty"
|
|
49
|
+
empty: "/empty",
|
|
50
|
+
/** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
|
|
51
|
+
orphans: "/orphans"
|
|
48
52
|
};
|
|
49
53
|
/** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
|
|
50
54
|
const PARAMS = {
|
|
@@ -65,35 +69,33 @@ function isDSHRemoteError(value) {
|
|
|
65
69
|
const candidate = value;
|
|
66
70
|
return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
|
|
67
71
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
const VERB_CODES = [
|
|
72
|
+
/** 失败码的域。与宿主官方域(`workspace/*`、`session/*`…)不重名,见设计稿 §6。 */
|
|
73
|
+
const SESSION_DELETE_ERROR_DOMAIN = "sessiondelete";
|
|
74
|
+
/** 五个**动词码**(设计稿 §6 那张表)。`io` 是唯一兜底。 */
|
|
75
|
+
const VERB_CODES$1 = [
|
|
73
76
|
"live",
|
|
74
77
|
"not-found",
|
|
75
78
|
"trash-empty",
|
|
76
79
|
"project-missing",
|
|
77
80
|
"io"
|
|
78
81
|
];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return e;
|
|
92
|
-
}
|
|
82
|
+
const SESSION_DELETE_CODES = {
|
|
83
|
+
live: "sessiondelete/live",
|
|
84
|
+
"not-found": "sessiondelete/not-found",
|
|
85
|
+
"trash-empty": "sessiondelete/trash-empty",
|
|
86
|
+
"project-missing": "sessiondelete/project-missing",
|
|
87
|
+
io: "sessiondelete/io",
|
|
88
|
+
"bad-method": "sessiondelete/bad-method",
|
|
89
|
+
"not-found-route": "sessiondelete/not-found-route",
|
|
90
|
+
"bad-request": "sessiondelete/bad-request"
|
|
91
|
+
};
|
|
92
|
+
/** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
|
|
93
|
+
const TRASH_DIR_NAME = "session-trash";
|
|
93
94
|
//#endregion
|
|
94
95
|
//#region src/remote/map-error.ts
|
|
95
|
-
/** 插件专属远程域名(设计稿 §6):与宿主官方域(`workspace/*`、`session/*`、`gateway/*`…)不重名。
|
|
96
|
-
|
|
96
|
+
/** 插件专属远程域名(设计稿 §6):与宿主官方域(`workspace/*`、`session/*`、`gateway/*`…)不重名。
|
|
97
|
+
* 字面量的家在 `shared/wire.ts`(ADR-0003)——浏览器半也按同一个域认码。 */
|
|
98
|
+
const DOMAIN = SESSION_DELETE_ERROR_DOMAIN;
|
|
97
99
|
/** 宿主两条类型化错误 → 我们的码(设计稿 §6 的两行映射)。
|
|
98
100
|
* 类不可 import(跨插件值 import 是红线;且宿主包与运行中的宿主不同代)⇒ 按官方构造器写死的 `this.name` 认。
|
|
99
101
|
* 证据:asar `dsh/node_modules/@deepseek-ai/dsh-workspace/lib/index.js` 原文
|
|
@@ -170,7 +172,7 @@ function detailsOf(error) {
|
|
|
170
172
|
* - 其余(别域的怪 code、前缀外的因、非 Error 抛出物)⇒ `io`,message 保留原文。
|
|
171
173
|
*
|
|
172
174
|
* **返回的是线上形状、不是 `Error`**:这份东西唯一的去处是 `src/api/dispatch.ts` 的失败信封
|
|
173
|
-
* (以及 `verbs/
|
|
175
|
+
* (以及 `verbs/batch.ts` 的逐条 `error`),而本仓的跨插件 import 红线也不许为此去引宿主的
|
|
174
176
|
* `RemoteError` 类(实例也上不了线:信封只走这三件)。
|
|
175
177
|
*/
|
|
176
178
|
function toRemoteFailure(error) {
|
|
@@ -192,7 +194,7 @@ function toRemoteFailure(error) {
|
|
|
192
194
|
};
|
|
193
195
|
}
|
|
194
196
|
const reason = typeof raw === "string" && raw.startsWith(`${DOMAIN}/`) ? raw.slice(14) : void 0;
|
|
195
|
-
const fromVerb = reason !== void 0 && VERB_CODES.includes(reason) ? reason : void 0;
|
|
197
|
+
const fromVerb = reason !== void 0 && VERB_CODES$1.includes(reason) ? reason : void 0;
|
|
196
198
|
const host = hostErrorOf(error);
|
|
197
199
|
const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host));
|
|
198
200
|
if (host === void 0 && fromVerb === void 0 && typeof raw === "string" && raw.includes("/")) return {
|
|
@@ -207,742 +209,811 @@ function toRemoteFailure(error) {
|
|
|
207
209
|
};
|
|
208
210
|
}
|
|
209
211
|
//#endregion
|
|
210
|
-
//#region src/
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
return {
|
|
224
|
-
deletedAt: Date.UTC(+y, +mo - 1, +d, +h, +mi, +s),
|
|
225
|
-
sessionDirName
|
|
226
|
-
};
|
|
212
|
+
//#region src/verbs/errors.ts
|
|
213
|
+
/** 五个码 / 三个路由自检码的**家都在 `shared/wire.ts`**(ADR-0003):浏览器半也要认它们(比如
|
|
214
|
+
* `not-found` 在界面上是中性提示而不是失败),所以它们属于两半共同的契约,不属于 Node 半。
|
|
215
|
+
* 这里只转出旧名,免得调用点全改一遍。 */
|
|
216
|
+
const VERB_CODES = VERB_CODES$1;
|
|
217
|
+
/** `sessiondelete/` 这个前缀——拆码用它,别在下面几处各写一遍字面量。 */
|
|
218
|
+
const VERB_CODE_PREFIX = `${SESSION_DELETE_ERROR_DOMAIN}/`;
|
|
219
|
+
function verbError(code, message, cause, details) {
|
|
220
|
+
const e = new Error(message);
|
|
221
|
+
e.code = SESSION_DELETE_CODES[code];
|
|
222
|
+
if (cause !== void 0) e.cause = cause;
|
|
223
|
+
if (details !== void 0) e.details = details;
|
|
224
|
+
return e;
|
|
227
225
|
}
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
/** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
|
|
227
|
+
function routeError(code, httpStatus, message) {
|
|
228
|
+
const e = verbError("io", message);
|
|
229
|
+
e.code = SESSION_DELETE_CODES[code];
|
|
230
|
+
e.httpStatus = httpStatus;
|
|
231
|
+
return e;
|
|
230
232
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
+
/** 一条回滚步骤失败的**标签句**:label 说的是"这一步在退什么",原文带出**那一步抛的东西**
|
|
234
|
+
* (`Error` 取 message,其余一律 `String()`;对象带 `message` 的差异归 `map-error.messageOf` 管)。
|
|
235
|
+
* 调用方不许另抄这份格式——那样 details 里的句子会随各处 catch 变形,界面上就变成"同一个失败三个说法"。 */
|
|
236
|
+
function rollbackReason(label, error) {
|
|
237
|
+
return `${label}:${error instanceof Error ? error.message : String(error)}`;
|
|
238
|
+
}
|
|
239
|
+
/** 内层错误已经挂着的回滚失败(成员自己的就地回滚失败时会有)。组级 catch 再包一次时不能丢。 */
|
|
240
|
+
function carriedRollbackFailures(error) {
|
|
241
|
+
const raw = error?.details?.rollbackFailures;
|
|
242
|
+
return Array.isArray(raw) ? raw.filter((item) => typeof item === "string") : [];
|
|
243
|
+
}
|
|
244
|
+
/** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
|
|
245
|
+
* 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
|
|
246
|
+
* 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
|
|
247
|
+
function withRollbackFailures(error, failures) {
|
|
248
|
+
const raw = error?.code;
|
|
249
|
+
const reason = typeof raw === "string" && raw.startsWith(VERB_CODE_PREFIX) ? raw.slice(VERB_CODE_PREFIX.length) : void 0;
|
|
250
|
+
const code = reason !== void 0 && VERB_CODES.includes(reason) ? reason : "io";
|
|
251
|
+
const carried = carriedRollbackFailures(error);
|
|
252
|
+
const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
|
|
253
|
+
const base = error instanceof Error ? error.message : String(error);
|
|
254
|
+
const extra = failures.filter((failure) => !carried.includes(failure));
|
|
255
|
+
return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
|
|
233
256
|
}
|
|
234
257
|
//#endregion
|
|
235
|
-
//#region src/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
/** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
|
|
243
|
-
const AFTER_DESCRIPTOR = 5;
|
|
244
|
-
const BLOCK_HEADER_BYTES = 3;
|
|
245
|
-
const BLOCK_LAST = 1;
|
|
246
|
-
const BLOCK_TYPE_SHIFT = 1;
|
|
247
|
-
const BLOCK_SIZE_SHIFT = 3;
|
|
248
|
-
const BLOCK_SIZE_MASK = 2097151;
|
|
249
|
-
const BLOCK_RAW = 0;
|
|
250
|
-
const BLOCK_RLE = 1;
|
|
251
|
-
const BLOCK_COMRESSED = 2;
|
|
252
|
-
/** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
|
|
253
|
-
const BLOCK_RESERVED = 3;
|
|
254
|
-
/** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
|
|
255
|
-
const DID_SIZES = [
|
|
256
|
-
0,
|
|
257
|
-
1,
|
|
258
|
-
2,
|
|
259
|
-
4
|
|
260
|
-
];
|
|
261
|
-
const FCS_SIZES_SINGLE = [
|
|
262
|
-
1,
|
|
263
|
-
2,
|
|
264
|
-
4,
|
|
265
|
-
8
|
|
266
|
-
];
|
|
267
|
-
const FCS_SIZES_MULTI = [
|
|
268
|
-
0,
|
|
269
|
-
2,
|
|
270
|
-
4,
|
|
271
|
-
8
|
|
272
|
-
];
|
|
273
|
-
/** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
|
|
274
|
-
const FRAME_TAIL_BYTES = 4;
|
|
275
|
-
function isMagicAt(raw, at) {
|
|
276
|
-
return MAGIC.every((byte, i) => raw[at + i] === byte);
|
|
277
|
-
}
|
|
278
|
-
function readUint24LE(raw, at) {
|
|
279
|
-
return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
|
|
258
|
+
//#region src/ports/host-port.ts
|
|
259
|
+
/** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
|
|
260
|
+
function liveSessionsOf(ctx) {
|
|
261
|
+
const store = ctx.get("sessions");
|
|
262
|
+
if (store === void 0 || store === null) return void 0;
|
|
263
|
+
if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
|
|
264
|
+
return store;
|
|
280
265
|
}
|
|
281
|
-
/**
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
266
|
+
/** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。 */
|
|
267
|
+
async function filterChildrenOf(sessionQuery, values) {
|
|
268
|
+
return sessionQuery.filterSessions([{
|
|
269
|
+
kind: "parent",
|
|
270
|
+
values: [...values].sort()
|
|
271
|
+
}]);
|
|
286
272
|
}
|
|
287
273
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
274
|
+
* 从若干根出发**逐层往下**把盘上的后代问出来,交回 `父(bare)→ 孩子` 的索引。
|
|
275
|
+
*
|
|
276
|
+
* 为什么一次问一层而不是官方的 `traceSession`:那条在父已经进回收站时认不出目标
|
|
277
|
+
* (`SESSION_QUERY_SESSION_NOT_FOUND`),而我们要的那批正是"父已经不在宿主列表里之后还剩在盘上的"。
|
|
278
|
+
* `kind:'parent'` 是唯一能按 `header.parentSession` 反着问的一支。
|
|
279
|
+
*
|
|
280
|
+
* 停下来的条件与代价:**层数**决定宿主调用次数,行数不参与(一层一次,问空即停)。同一条记录只收一次
|
|
281
|
+
* (`seen` 连着根一起记),所以数据里真出现环时结果是"少数一条",不是把清单读挂死。
|
|
282
|
+
* 交回的 id 没有 `header.id` 的记录**整条跳过**——身份冒充不起(同 {@link HostPort.subagentMembers} 那条),
|
|
283
|
+
* 而且没有身份就没法往下问,硬数进去只会让它将来被重复计。
|
|
291
284
|
*/
|
|
292
|
-
function
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
285
|
+
async function descendantsByParent(sessionQuery, roots) {
|
|
286
|
+
const children = /* @__PURE__ */ new Map();
|
|
287
|
+
const seen = new Set(roots.map(bareSessionId));
|
|
288
|
+
let frontier = [...seen];
|
|
289
|
+
while (frontier.length > 0) {
|
|
290
|
+
const values = [];
|
|
291
|
+
for (const bare of frontier) for (const shape of sessionIdShapes(bare)) if (!values.includes(shape)) values.push(shape);
|
|
292
|
+
const next = [];
|
|
293
|
+
for (const record of await filterChildrenOf(sessionQuery, values)) {
|
|
294
|
+
const id = record.header?.id;
|
|
295
|
+
const parent = record.header?.parentSession;
|
|
296
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
297
|
+
if (typeof parent !== "string" || parent.length === 0) continue;
|
|
298
|
+
const bare = bareSessionId(id);
|
|
299
|
+
if (seen.has(bare)) continue;
|
|
300
|
+
seen.add(bare);
|
|
301
|
+
const cwd = record.header?.cwd;
|
|
302
|
+
const descendant = {
|
|
303
|
+
id,
|
|
304
|
+
bare,
|
|
305
|
+
projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd)
|
|
306
|
+
};
|
|
307
|
+
const parentBare = bareSessionId(parent);
|
|
308
|
+
const siblings = children.get(parentBare);
|
|
309
|
+
if (siblings === void 0) children.set(parentBare, [descendant]);
|
|
310
|
+
else siblings.push(descendant);
|
|
311
|
+
next.push(bare);
|
|
316
312
|
}
|
|
317
|
-
|
|
318
|
-
if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
|
|
319
|
-
ranges.push({
|
|
320
|
-
start: at,
|
|
321
|
-
end: cursor
|
|
322
|
-
});
|
|
323
|
-
at = cursor;
|
|
313
|
+
frontier = next;
|
|
324
314
|
}
|
|
325
|
-
return
|
|
315
|
+
return children;
|
|
326
316
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
340
|
-
* ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
|
|
341
|
-
* 实测本机 686 个 `.jsonl.zstd` / 92 421 帧**没有一帧切在行中间**(连最后一帧都整行收尾),
|
|
342
|
-
* 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
|
|
343
|
-
* 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
|
|
344
|
-
* 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
|
|
345
|
-
* 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
|
|
346
|
-
*/
|
|
347
|
-
const NEWLINE = 10;
|
|
348
|
-
/**
|
|
349
|
-
* 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
|
|
350
|
-
* `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
|
|
351
|
-
* 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
|
|
352
|
-
*/
|
|
353
|
-
const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
|
|
354
|
-
/**
|
|
355
|
-
* 同一个会话目录里**可以并存多代日志**(宿主换代时把老代留在原地当历史语料,本机实测 7 个目录如此),
|
|
356
|
-
* 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
|
|
357
|
-
* ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
|
|
358
|
-
*
|
|
359
|
-
* 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
|
|
360
|
-
*/
|
|
361
|
-
function pickSessionLog(names) {
|
|
362
|
-
let best;
|
|
363
|
-
let bestVersion = -1;
|
|
364
|
-
for (const name of names) {
|
|
365
|
-
const match = CANONICAL_ZSTD_LOG.exec(name);
|
|
366
|
-
if (match === null) continue;
|
|
367
|
-
const version = match[1] === void 0 ? 0 : Number(match[1]);
|
|
368
|
-
if (version > bestVersion) {
|
|
369
|
-
bestVersion = version;
|
|
370
|
-
best = name;
|
|
317
|
+
/** 某个根在 `descendantsByParent` 那棵树里的全部后代(深度不设上限,同一条只收一次)。 */
|
|
318
|
+
function subtreeOf(children, rootBare) {
|
|
319
|
+
const out = [];
|
|
320
|
+
const seen = /* @__PURE__ */ new Set([rootBare]);
|
|
321
|
+
let frontier = [rootBare];
|
|
322
|
+
while (frontier.length > 0) {
|
|
323
|
+
const next = [];
|
|
324
|
+
for (const bare of frontier) for (const kid of children.get(bare) ?? []) {
|
|
325
|
+
if (seen.has(kid.bare)) continue;
|
|
326
|
+
seen.add(kid.bare);
|
|
327
|
+
out.push(kid);
|
|
328
|
+
next.push(kid.bare);
|
|
371
329
|
}
|
|
330
|
+
frontier = next;
|
|
372
331
|
}
|
|
373
|
-
return
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
function toMember(d) {
|
|
335
|
+
return {
|
|
336
|
+
sessionId: d.id,
|
|
337
|
+
projectDir: d.projectDir
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/** `subagentCounts` / `subagentMembers` / `subagentMembersBatch` 共用的一次 BFS。
|
|
341
|
+
* 空清单在调用宿主之前就返回(不为一张空表扫全库)。 */
|
|
342
|
+
async function membersFor(sessionQuery, parentIds) {
|
|
343
|
+
const wanted = [];
|
|
344
|
+
for (const id of parentIds) if (id.length > 0 && !wanted.includes(id)) wanted.push(id);
|
|
345
|
+
if (wanted.length === 0) return {};
|
|
346
|
+
const children = await descendantsByParent(sessionQuery, wanted);
|
|
347
|
+
const out = {};
|
|
348
|
+
for (const id of wanted) out[id] = subtreeOf(children, bareSessionId(id));
|
|
349
|
+
return out;
|
|
374
350
|
}
|
|
351
|
+
/** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
|
|
352
|
+
* 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。
|
|
353
|
+
* `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
|
|
354
|
+
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
375
355
|
/**
|
|
376
|
-
*
|
|
356
|
+
* 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
|
|
377
357
|
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
358
|
+
* 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
|
|
359
|
+
* 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
|
|
360
|
+
* (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
|
|
361
|
+
*
|
|
362
|
+
* 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
|
|
363
|
+
* `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
|
|
364
|
+
* else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
|
|
365
|
+
* else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
|
|
366
|
+
* return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
|
|
367
|
+
* 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
|
|
368
|
+
* 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
|
|
369
|
+
* (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
|
|
370
|
+
*
|
|
371
|
+
* **两个调用方共用这一份**:`projectDirOf`(删除时定位会话目录)与回收站对账的重建路径
|
|
372
|
+
* (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
|
|
380
373
|
*/
|
|
381
|
-
function
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
374
|
+
function projectKey(cwd) {
|
|
375
|
+
let readable = "";
|
|
376
|
+
let separatorRun = false;
|
|
377
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
378
|
+
const code = cwd.charCodeAt(i);
|
|
379
|
+
const ch = String.fromCharCode(code);
|
|
380
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
381
|
+
if (!separatorRun) readable += "-";
|
|
382
|
+
separatorRun = true;
|
|
383
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
384
|
+
readable += ch;
|
|
385
|
+
separatorRun = false;
|
|
386
|
+
} else {
|
|
387
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
388
|
+
separatorRun = false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
392
|
+
}
|
|
393
|
+
/** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
|
|
394
|
+
function projectionCacheOf(ctx) {
|
|
395
|
+
const cache = ctx.get("sessionProjectionCache");
|
|
396
|
+
if (cache === void 0 || cache === null) return void 0;
|
|
397
|
+
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
398
|
+
return cache;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 「账本里没有这个会话」的判别串——宿主 `readSessionHeader(id)` 对未知会话抛的是**裸 `Error`**
|
|
402
|
+
* (没有类型、没有码),只认这一句。asar `dsh-workspace/lib/types/index.js` 原文(逐字):
|
|
403
|
+
* `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
|
|
404
|
+
*
|
|
405
|
+
* 为什么按消息判而不是全吞:同一个方法里 `listStoredHeaders()` / `indexHeaders()` 会抛**真存储故障**
|
|
406
|
+
* (读盘失败、账本不一致…),那是 `io` 该走的路,绝不能被当成「无此会话」。
|
|
407
|
+
* 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
|
|
408
|
+
* hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
|
|
409
|
+
* 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
|
|
410
|
+
*/
|
|
411
|
+
const NO_SUCH_SESSION = "session persistence holds no such session";
|
|
412
|
+
/** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
|
|
413
|
+
function isUnknownSession(error) {
|
|
414
|
+
return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
|
|
415
|
+
}
|
|
416
|
+
/** 会话 id 去掉 `session-` 前缀后的形状(同一个父在两代里写法不同,见 {@link HostPort.subagentCounts})。 */
|
|
417
|
+
function bareSessionId(id) {
|
|
418
|
+
return id.startsWith("session-") ? id.slice(8) : id;
|
|
419
|
+
}
|
|
420
|
+
/** 一个 id 的全部可能写法,原写法在前:`session-x` → `[session-x, x]`;`x` → `[x, session-x]`。 */
|
|
421
|
+
function sessionIdShapes(id) {
|
|
422
|
+
const bare = bareSessionId(id);
|
|
423
|
+
return [.../* @__PURE__ */ new Set([
|
|
424
|
+
id,
|
|
425
|
+
bare,
|
|
426
|
+
`session-${bare}`
|
|
427
|
+
])];
|
|
428
|
+
}
|
|
429
|
+
/** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
|
|
430
|
+
* 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
|
|
431
|
+
* 不是"恢复失败"。 */
|
|
432
|
+
async function controllerItems(host) {
|
|
433
|
+
try {
|
|
434
|
+
const controller = host.get("sessionController");
|
|
435
|
+
if (controller === void 0 || controller === null || typeof controller.list !== "function") return [];
|
|
436
|
+
const listed = await controller.list();
|
|
437
|
+
const items = Array.isArray(listed) ? listed : listed?.items;
|
|
438
|
+
return Array.isArray(items) ? items : [];
|
|
439
|
+
} catch {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** 账本里没有那个工作区时 {@link HostPort.attach} / {@link HostPort.detach} 抛的原话开头。
|
|
444
|
+
* **原话与判据同住这里**:抛它的是这个端口,认它的也是端口给的那句,调用方不许另抄一份字符串。 */
|
|
445
|
+
const WORKSPACE_MISSING_MESSAGE = "workspace not found";
|
|
446
|
+
/** 这次失败是不是"账本里没那个工作区"(而不是另有故障)。恢复侧靠它把两种失败分开:
|
|
447
|
+
* 前者按「未分组」收,后者照旧回滚报 `io`——不给真故障静默改归属的机会。 */
|
|
448
|
+
function isWorkspaceMissingError(error) {
|
|
449
|
+
return error instanceof Error && error.message.startsWith(WORKSPACE_MISSING_MESSAGE);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 宿主服务层的**真实绑定**:各方法把动词层的问题翻成宿主官方服务的调用。
|
|
453
|
+
*
|
|
454
|
+
* 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
|
|
455
|
+
* 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
|
|
456
|
+
* `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(设计稿 §6 的两行映射)。
|
|
457
|
+
*/
|
|
458
|
+
function createHostPort(ctx) {
|
|
459
|
+
const host = ctx;
|
|
460
|
+
const registry = host.workspaceRegistry;
|
|
461
|
+
return {
|
|
462
|
+
/** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
|
|
463
|
+
async workspaceOf(sessionId) {
|
|
464
|
+
return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
|
|
465
|
+
},
|
|
466
|
+
/** 账本里还有这个工作区吗:`registry.get(id)` 是 `this.entities.get(id)` 的直通(同步 Map 查找),
|
|
467
|
+
* 未知 id 回 `undefined` ⇒ 这里只做一次判别,不抛。 */
|
|
468
|
+
async workspaceExists(workspaceId) {
|
|
469
|
+
return registry.get(workspaceId) !== void 0;
|
|
470
|
+
},
|
|
471
|
+
/**
|
|
472
|
+
* 原生 cwd → 宿主项目目录名(`projectKey`);没有 cwd 时按宿主约定落到 `_no-cwd`。
|
|
473
|
+
*
|
|
474
|
+
* 「账本里没有这个会话」= **未命中**,回 `undefined`(不是抛):这正是本方法声明的类型,
|
|
475
|
+
* 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
|
|
476
|
+
* verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ 设计稿 §6 的 `not-found`
|
|
477
|
+
* (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
|
|
478
|
+
*
|
|
479
|
+
* **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
|
|
480
|
+
*/
|
|
481
|
+
async projectDirOf(sessionId) {
|
|
482
|
+
let header;
|
|
483
|
+
try {
|
|
484
|
+
header = await registry.readSessionHeader(sessionId);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if (isUnknownSession(error)) return void 0;
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
490
|
+
},
|
|
491
|
+
/**
|
|
492
|
+
* 标题:走官方**投影缓存**读面(`title` 投影单元的 `wire.view` 值就是标题文本本身:
|
|
493
|
+
* 单元定义原文 `{ key: "title", stateVersion: 1, wire: { view: (state) => state } }`,
|
|
494
|
+
* 所以 `values.title` 是 `string | null`)。
|
|
495
|
+
* 契约:**尽力取、取不到返回空串、绝不因此让删除失败** ⇒ 这里吞掉一切异常。
|
|
496
|
+
*/
|
|
497
|
+
async titleOf(sessionId) {
|
|
498
|
+
try {
|
|
499
|
+
const header = await registry.readSessionHeader(sessionId);
|
|
500
|
+
const cache = projectionCacheOf(host);
|
|
501
|
+
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
502
|
+
return typeof title === "string" ? title : "";
|
|
503
|
+
} catch {
|
|
504
|
+
return "";
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
/**
|
|
508
|
+
* 活跃判据 = 宿主官方那一条(设计稿 §5):`workspace/session-activity` waterfall 问一次,
|
|
509
|
+
* **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
|
|
510
|
+
* asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
|
|
511
|
+
* if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
|
|
512
|
+
*/
|
|
513
|
+
async isLive(sessionId) {
|
|
514
|
+
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
515
|
+
},
|
|
516
|
+
/** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
|
|
517
|
+
async isArchived(sessionId) {
|
|
518
|
+
return registry.archivedSessionIds.includes(sessionId);
|
|
519
|
+
},
|
|
520
|
+
/**
|
|
521
|
+
* 从 `ctx.sessions`(live store)里摘掉一个会话。
|
|
522
|
+
*
|
|
523
|
+
* **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
|
|
524
|
+
* (见设计稿 §4 末)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来,
|
|
525
|
+
* 于是"删了还在"(真机实测:文件已进回收站、账本已摘,侧栏仍留着那一行)。
|
|
526
|
+
*
|
|
527
|
+
* **这是未声明为契约的内部入口**(设计稿 §4 末记了这次偏离):`liveEntryFor` / `entry.detach()` 是 store
|
|
528
|
+
* 给**会话 owner**(agent 的 effect 链)用的,宿主自己的控制器 `createOrAdopt` 只取 `agents.create()`
|
|
529
|
+
* 返回的 `.agent`、把带 `dispose` 的 handle 丢掉了,`ctx.agents` 也没有 `liveEntryFor` 那样的入口
|
|
530
|
+
* (会话 store 有、agent store 没有,逐成员面核过)⇒ 只能摘到"会话"这一层,上面那具 agent 摘不到,
|
|
531
|
+
* 它之后若走到 `flush(session)` 会撞 `liveEntryFor` 抛 `session "…" is not live in this store`。
|
|
532
|
+
*
|
|
533
|
+
* **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 抛 ⇒ `unknown`(**不通告**,
|
|
534
|
+
* 因为无法断言宿主列表已经干净);内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。
|
|
535
|
+
* 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
|
|
536
|
+
*/
|
|
537
|
+
async evictLive(sessionId) {
|
|
538
|
+
try {
|
|
539
|
+
const store = liveSessionsOf(host);
|
|
540
|
+
if (store === void 0) return "unknown";
|
|
541
|
+
const session = store.get(sessionId);
|
|
542
|
+
if (session === void 0 || session === null) return "not-live";
|
|
543
|
+
const entry = store.liveEntryFor(session);
|
|
544
|
+
if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
|
|
545
|
+
entry.detach();
|
|
546
|
+
return "evicted";
|
|
547
|
+
} catch {
|
|
548
|
+
return "unknown";
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
/**
|
|
552
|
+
* 通告客户端"这个会话没了"。
|
|
553
|
+
*
|
|
554
|
+
* **为什么需要**:客户端那一行只由两条路更新——重连时的整表重列(`handleConnected` → `refreshList`),
|
|
555
|
+
* 或宿主转发的 `api-session/*` 事件。删除一个**冷**会话时宿主不会发任何事件(`session/disposed` 只在摘掉
|
|
556
|
+
* 活体时才有),所以那一行会一直挂到下次重连为止(真机实测就是这个形状)。
|
|
557
|
+
*
|
|
558
|
+
* **为什么这样发是对的**:`api-session/removed` 是宿主**自己声明并转发**的事件
|
|
559
|
+
* (asar `dsh-api-remotes/lib/remote-events.js` 的 `API_REMOTE_FORWARDED_EVENTS` 里
|
|
560
|
+
* `{ event: "api-session/removed", mode: "emit" }`;转发器就是 `ctx.on(event, …)` 的普通监听者);
|
|
561
|
+
* 我们不带 carrier 地 emit ⇒ 全量派发到它(见 {@link HostCtxLike.emit})⇒ 客户端
|
|
562
|
+
* `ctx.remote.$on("api-session/removed", …)` 收到后 `recordMutation({kind:'remove'})` + `handleRemoved()`。
|
|
563
|
+
* **只在"宿主列表确实已经不含它"时调用**(调用方按 {@link EvictionOutcome} 判):这条通告在语义上是**如实**
|
|
564
|
+
* 的,不是障眼法。
|
|
565
|
+
*/
|
|
566
|
+
async announceRemoved(sessionId) {
|
|
567
|
+
try {
|
|
568
|
+
host.emit("api-session/removed", sessionId);
|
|
569
|
+
} catch {}
|
|
570
|
+
},
|
|
571
|
+
/**
|
|
572
|
+
* 通告客户端"这个会话回来了"。
|
|
573
|
+
*
|
|
574
|
+
* 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
|
|
575
|
+
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
576
|
+
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**。
|
|
577
|
+
*
|
|
578
|
+
* **这一条单条路是删除组回滚用的**:成员已经收过 `api-session/removed`,退回 `sessions/` 之后要补一条
|
|
579
|
+
* "added",否则客户端那一行要等到重连才回来。正常恢复走 {@link announceRestoredBatch} + 一次预取——
|
|
580
|
+
* 100 条的批量用单条路就是 100 遍全表扫,那正是 2026-10-17 那次"恢复很慢"的头号来源。
|
|
581
|
+
*/
|
|
582
|
+
async announceRestored(sessionId) {
|
|
583
|
+
const summary = (await this.restoredSummariesOf()).find((item) => item.sessionId === sessionId)?.summary;
|
|
584
|
+
if (summary === void 0) return;
|
|
585
|
+
try {
|
|
586
|
+
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
587
|
+
} catch {}
|
|
588
|
+
},
|
|
589
|
+
/** 整批预取一次会话表(`[{ sessionId, summary }]`):通告的路径与单条一致,**拉表从每条一次抬成整批一次**。 */
|
|
590
|
+
async restoredSummariesOf() {
|
|
591
|
+
const out = [];
|
|
592
|
+
for (const item of await controllerItems(host)) {
|
|
593
|
+
const sessionId = item?.sessionId;
|
|
594
|
+
if (typeof sessionId === "string") out.push({
|
|
595
|
+
sessionId,
|
|
596
|
+
summary: item
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
},
|
|
601
|
+
/** 一组会话的通告(2026-10-17 批量重构):`known` 给了就**一次都不拉表**,只按它筛。
|
|
602
|
+
* 与 {@link announceRestored} 同一判据、同一事件、同一载荷形状,只是取数由整批共享。 */
|
|
603
|
+
async announceRestoredBatch(ids, known) {
|
|
604
|
+
if (ids.length === 0) return;
|
|
605
|
+
const summaries = known ?? await this.restoredSummariesOf();
|
|
606
|
+
for (const id of ids) {
|
|
607
|
+
const summary = summaries.find((item) => item.sessionId === id)?.summary;
|
|
608
|
+
if (summary === void 0) continue;
|
|
609
|
+
try {
|
|
610
|
+
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
611
|
+
} catch {}
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
/** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
|
|
615
|
+
async unarchive(sessionId) {
|
|
616
|
+
await registry.unarchiveSession(sessionId);
|
|
617
|
+
},
|
|
618
|
+
/** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
|
|
619
|
+
async detach(sessionId, workspaceId) {
|
|
620
|
+
const workspace = registry.get(workspaceId);
|
|
621
|
+
if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (detach): ${workspaceId}`);
|
|
622
|
+
await workspace.detachSession(sessionId);
|
|
623
|
+
},
|
|
624
|
+
/** 挂账本:实体方法 `attachSession`(顶层没有)。 */
|
|
625
|
+
async attach(sessionId, workspaceId) {
|
|
626
|
+
const workspace = registry.get(workspaceId);
|
|
627
|
+
if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (attach): ${workspaceId}`);
|
|
628
|
+
await workspace.attachSession(sessionId);
|
|
629
|
+
},
|
|
630
|
+
/** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
|
|
631
|
+
* (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant"),所以 `workspaceId` 不用
|
|
632
|
+
* ——未分组的会话没有工作区可传,签名里它是可选的。
|
|
633
|
+
* 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
|
|
634
|
+
async archive(sessionId) {
|
|
635
|
+
await registry.archiveSession(sessionId);
|
|
636
|
+
},
|
|
637
|
+
async listSessions() {
|
|
638
|
+
const sessionQuery = host.get("sessionQuery");
|
|
639
|
+
if (typeof sessionQuery?.listSessions !== "function") return void 0;
|
|
640
|
+
const records = await sessionQuery.listSessions();
|
|
641
|
+
const out = [];
|
|
642
|
+
for (const record of records) {
|
|
643
|
+
const id = record.header?.id;
|
|
644
|
+
if (typeof id !== "string" || id === "") continue;
|
|
645
|
+
const parent = record.header?.parentSession;
|
|
646
|
+
const cwd = record.header?.cwd;
|
|
647
|
+
out.push({
|
|
648
|
+
sessionId: id,
|
|
649
|
+
...typeof parent === "string" && parent !== "" ? { parentSessionId: parent } : {},
|
|
650
|
+
projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd),
|
|
651
|
+
subagent: record.header?.origin === "subagent"
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
return out;
|
|
655
|
+
},
|
|
656
|
+
/**
|
|
657
|
+
* 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
|
|
658
|
+
*
|
|
659
|
+
* 数的是**传递闭包**(孩子与孙代都算),不是"直接孩子"(issue #1):宿主客户端对所有
|
|
660
|
+
* `origin:"subagent"` 一律藏行 ⇒ 深度 ≥2 上"删了父还剩多少看不见"原样存在,只数一层就是把那件事
|
|
661
|
+
* 说成没有。问法见 {@link descendantsByParent}。
|
|
662
|
+
*
|
|
663
|
+
* 归堆必须**两边都去前缀**再比:本机实测父会话自己的 header id 带 `session-` 前缀
|
|
664
|
+
* (`session-ac07f2bb-…`)、它的子会话写的 `parentSession` 也带,但 v3 那一代的 id 不带 ⇒
|
|
665
|
+
* 同一个父有两种写法。所以问的时候把一个 id 的两种形状都塞进 `values`,回来按去前缀后的形状归堆,
|
|
666
|
+
* 最后按**调用方给的那个写法**把键交回去(调用方拿 `entry.sessionId` 直接查得到)。
|
|
667
|
+
*/
|
|
668
|
+
async subagentCounts(parentIds) {
|
|
669
|
+
const sessionQuery = host.get("sessionQuery");
|
|
670
|
+
if (typeof sessionQuery?.filterSessions !== "function") return {};
|
|
671
|
+
const members = await membersFor(sessionQuery, parentIds);
|
|
672
|
+
const counts = {};
|
|
673
|
+
for (const [id, list] of Object.entries(members)) if (list.length > 0) counts[id] = list.length;
|
|
674
|
+
return counts;
|
|
675
|
+
},
|
|
676
|
+
/**
|
|
677
|
+
* 删除要一起带走**整个组**,先得把他们点出来:身份取宿主自己记录里的 `header.id`,项目目录由
|
|
678
|
+
* `header.cwd` 走同一个 `projectKey`(与 {@link HostPort.projectDirOf} 同一根算法,不另起一套)。
|
|
679
|
+
* 交回的是**全部后代**——孙代跟着父一起进回收站(issue #1),顺序是逐层往下问出来的(近的先)。
|
|
680
|
+
* `header.id` 缺席的后代直接跳过——不拿目录名冒充身份(冒充了会把一个来路不明的目录搬进回收站)。
|
|
681
|
+
* 服务没挂回 `[]` ⇒ 删除照旧只管父,不因此失败(父的删除本来就已经是完整的一件事)。
|
|
682
|
+
*/
|
|
683
|
+
async subagentMembers(sessionId) {
|
|
684
|
+
const sessionQuery = host.get("sessionQuery");
|
|
685
|
+
if (typeof sessionQuery?.filterSessions !== "function") return [];
|
|
686
|
+
return ((await membersFor(sessionQuery, [sessionId]))[sessionId] ?? []).map(toMember);
|
|
687
|
+
},
|
|
688
|
+
async subagentMembersBatch(parentIds) {
|
|
689
|
+
const sessionQuery = host.get("sessionQuery");
|
|
690
|
+
if (typeof sessionQuery?.filterSessions !== "function") return {};
|
|
691
|
+
const members = await membersFor(sessionQuery, parentIds);
|
|
692
|
+
return Object.fromEntries(Object.entries(members).map(([id, list]) => [id, list.map(toMember)]));
|
|
397
693
|
}
|
|
398
|
-
stats.records += 1;
|
|
399
|
-
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
400
694
|
};
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
695
|
+
}
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/trash/groups.ts
|
|
698
|
+
/** 这些孩子的父。 */
|
|
699
|
+
function kidsOf(rows, parentId) {
|
|
700
|
+
return rows.filter((row) => row.parentEntryId === parentId);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* 清单露面的一行 = 组根,按清册原序。孩子折进根那一行(宿主的客户端本来就把 `origin:"subagent"`
|
|
704
|
+
* 的行整排藏了 ⇒ 我们若单列就会冒出一堆点不到的行)。
|
|
705
|
+
*
|
|
706
|
+
* 折不进去的那两种孩子(父行不在 / 父行自己也是孩子)**自己站出来占一行**——那正是
|
|
707
|
+
* `visibleGroups` 与 `groupOf` 共用同一条判据的地方。
|
|
708
|
+
*/
|
|
709
|
+
function visibleGroups(rows) {
|
|
710
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
711
|
+
const kidsByRoot = /* @__PURE__ */ new Map();
|
|
712
|
+
const folded = /* @__PURE__ */ new Set();
|
|
713
|
+
for (const row of rows) {
|
|
714
|
+
if (row.parentEntryId === void 0) continue;
|
|
715
|
+
const parent = byId.get(row.parentEntryId);
|
|
716
|
+
if (parent === void 0 || parent.parentEntryId !== void 0) continue;
|
|
717
|
+
folded.add(row.id);
|
|
718
|
+
const siblings = kidsByRoot.get(parent.id);
|
|
719
|
+
if (siblings === void 0) kidsByRoot.set(parent.id, [row]);
|
|
720
|
+
else siblings.push(row);
|
|
423
721
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
722
|
+
return rows.filter((row) => !folded.has(row.id)).map((root) => ({
|
|
723
|
+
root,
|
|
724
|
+
kids: kidsByRoot.get(root.id) ?? []
|
|
725
|
+
}));
|
|
427
726
|
}
|
|
428
727
|
/**
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
728
|
+
* 一条 id 所属的整组。三种形状,三个答案:
|
|
729
|
+
* - **组根** ⇒ 它自己 + 它接住的孩子(UI 点的是父那一行,删除/恢复/彻底删除都按整组走);
|
|
730
|
+
* - **孩子**(自己记着 `parentEntryId`)⇒ **只回它自己**。清单不单独列它,只有手工/降级路径会点到这里;
|
|
731
|
+
* - **清册里没有这一条 id,但还有孩子指向它**(父行已被别的路径摘掉)⇒ 那一批孩子;仍然没有 ⇒ 空数组,
|
|
732
|
+
* 调用方据此报 `trash-empty` / 判"已被同批前一条带走"。
|
|
433
733
|
*/
|
|
434
|
-
function
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
header = record;
|
|
439
|
-
return false;
|
|
440
|
-
});
|
|
441
|
-
return header;
|
|
734
|
+
function groupOf(rows, id) {
|
|
735
|
+
const root = rows.find((row) => row.id === id);
|
|
736
|
+
if (root !== void 0) return root.parentEntryId === void 0 ? [root, ...kidsOf(rows, id)] : [root];
|
|
737
|
+
return kidsOf(rows, id);
|
|
442
738
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
739
|
+
/**
|
|
740
|
+
* 「这条是被**同批前一条**带走的,还是整批开始前就没有?」
|
|
741
|
+
*
|
|
742
|
+
* 幂等语义只属于前者:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
|
|
743
|
+
* `trash-empty`,不能被"已被带走"这句话掩盖成成功。**所以判据是快照的原始形状,不是当前形状**:
|
|
744
|
+
* 原始快照里认得出(它自己、它的父、或它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
|
|
745
|
+
*/
|
|
746
|
+
function takenByEarlierInBatch(original, current, id) {
|
|
747
|
+
return groupOf(original, id).length > 0 && groupOf(current, id).length === 0;
|
|
748
|
+
}
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/trash/entry.ts
|
|
751
|
+
/**
|
|
752
|
+
* 清册里**每条记录都必须有**的那几格(`TrashEntry` 的非可选字段)——运行期校验器与「文件里真有的事实」
|
|
753
|
+
* 共用这一份清单。
|
|
754
|
+
*
|
|
755
|
+
* **可选字段不在这里**:`parentEntryId`(级联那批加的)缺席是合法的——老行没有它,缺了就是"没有父";
|
|
756
|
+
* 校验器只管这几格的类型,其余键**原样带走**(`parse` 交回的就是 `raw` 本身)⇒ 新字段照旧存得住。
|
|
757
|
+
*
|
|
758
|
+
* 键集与接口由 `tests/wire-shape.test.ts` 双向钉住:接口加一个必填字段而这里没跟 ⇒ 测试红。
|
|
759
|
+
*/
|
|
760
|
+
const REQUIRED_ENTRY_FIELDS = {
|
|
761
|
+
id: "string",
|
|
762
|
+
sessionId: "string",
|
|
763
|
+
projectDir: "string",
|
|
764
|
+
workspaceId: "string",
|
|
765
|
+
wasArchived: "boolean",
|
|
766
|
+
deletedAt: "number",
|
|
767
|
+
title: "string",
|
|
768
|
+
sizeBytes: "number"
|
|
446
769
|
};
|
|
447
|
-
|
|
448
|
-
function
|
|
449
|
-
|
|
450
|
-
return
|
|
770
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
771
|
+
function makeEntryId(deletedAt, sessionDirName) {
|
|
772
|
+
const d = new Date(deletedAt);
|
|
773
|
+
return `${`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`}-${sessionDirName}`;
|
|
451
774
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
try {
|
|
463
|
-
raw = await deps.fs.readFile(`${dir}/${logName}`);
|
|
464
|
-
} catch (error) {
|
|
465
|
-
throw verbError("io", `读会话日志失败:${String(error)}`, error);
|
|
466
|
-
}
|
|
467
|
-
const detail = {
|
|
468
|
-
entryId: record.id,
|
|
469
|
-
cwd: "",
|
|
470
|
-
createdAt: 0,
|
|
471
|
-
lastActivity: 0,
|
|
472
|
-
logTitle: "",
|
|
473
|
-
models: [],
|
|
474
|
-
turnCount: 0,
|
|
475
|
-
userMessageCount: 0,
|
|
476
|
-
assistantMessageCount: 0,
|
|
477
|
-
toolCallCount: 0,
|
|
478
|
-
injectedCount: 0,
|
|
479
|
-
messages: [],
|
|
480
|
-
omittedByPayloadCap: 0
|
|
481
|
-
};
|
|
482
|
-
const models = /* @__PURE__ */ new Set();
|
|
483
|
-
let usedChars = 0;
|
|
484
|
-
let capped = false;
|
|
485
|
-
/** 收进正文,顺带管两个闸:截到单条上限,累计到总量就停。 */
|
|
486
|
-
function push(role, time, text) {
|
|
487
|
-
if (capped) {
|
|
488
|
-
detail.omittedByPayloadCap += 1;
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
const truncated = text.length > limits.perMessageChars;
|
|
492
|
-
const kept = truncated ? text.slice(0, limits.perMessageChars) : text;
|
|
493
|
-
if (usedChars + kept.length > limits.totalChars) {
|
|
494
|
-
capped = true;
|
|
495
|
-
detail.omittedByPayloadCap += 1;
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
usedChars += kept.length;
|
|
499
|
-
detail.messages.push({
|
|
500
|
-
role,
|
|
501
|
-
time,
|
|
502
|
-
text: kept,
|
|
503
|
-
truncated
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
try {
|
|
507
|
-
walkSessionLog(raw, (r) => {
|
|
508
|
-
if (typeof r.time === "number" && r.time > detail.lastActivity) detail.lastActivity = r.time;
|
|
509
|
-
const data = r.data ?? {};
|
|
510
|
-
switch (r.type) {
|
|
511
|
-
case "session":
|
|
512
|
-
if (typeof r.cwd === "string") detail.cwd = r.cwd;
|
|
513
|
-
if (typeof r.createdAt === "number") detail.createdAt = r.createdAt;
|
|
514
|
-
return;
|
|
515
|
-
case "session/title":
|
|
516
|
-
if (typeof data.title === "string") detail.logTitle = data.title;
|
|
517
|
-
return;
|
|
518
|
-
case "turn/start":
|
|
519
|
-
detail.turnCount += 1;
|
|
520
|
-
return;
|
|
521
|
-
case "tool/call":
|
|
522
|
-
detail.toolCallCount += 1;
|
|
523
|
-
return;
|
|
524
|
-
case "user/message": {
|
|
525
|
-
if ((data.source ?? {}).kind !== "user") {
|
|
526
|
-
detail.injectedCount += 1;
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
const text = textOf(data.content);
|
|
530
|
-
if (text === "") return;
|
|
531
|
-
detail.userMessageCount += 1;
|
|
532
|
-
push("user", r.time ?? 0, text);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
case "assistant/message": {
|
|
536
|
-
const message = data.message ?? {};
|
|
537
|
-
const source = message.source ?? {};
|
|
538
|
-
if (typeof source.model === "string") models.add(source.model);
|
|
539
|
-
const text = textOf(message.content);
|
|
540
|
-
if (text === "") return;
|
|
541
|
-
detail.assistantMessageCount += 1;
|
|
542
|
-
push("assistant", r.time ?? 0, text);
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
default: return;
|
|
546
|
-
}
|
|
547
|
-
});
|
|
548
|
-
} catch (error) {
|
|
549
|
-
throw verbError("io", error instanceof Error ? `读回收站日志失败:${error.message}` : `读回收站日志失败:${String(error)}`, error);
|
|
550
|
-
}
|
|
551
|
-
detail.models = [...models];
|
|
552
|
-
return detail;
|
|
775
|
+
const ENTRY_ID_RE = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-(.+)$/;
|
|
776
|
+
/** `makeEntryId` 的逆。时间戳按 UTC 拆字段再 `Date.UTC` 合回——不交给 `Date.parse`
|
|
777
|
+
* 去猜(`20260930T130500Z` 不是任何被承认的日期格式,猜出来的是 NaN)。 */
|
|
778
|
+
function parseEntryId(id) {
|
|
779
|
+
const m = ENTRY_ID_RE.exec(id);
|
|
780
|
+
if (!m) throw new Error(`bad entry id: ${id}`);
|
|
781
|
+
const [, y, mo, d, h, mi, s, sessionDirName] = m;
|
|
782
|
+
return {
|
|
783
|
+
deletedAt: Date.UTC(+y, +mo - 1, +d, +h, +mi, +s),
|
|
784
|
+
sessionDirName
|
|
553
785
|
};
|
|
554
786
|
}
|
|
787
|
+
/** 路径里的每一段都必须是**单段**:这一层不信宿主的 `header.id` 与清册,破坏性动词直接删目录。
|
|
788
|
+
* `/`、`\`、`.`、`..` 与 NUL 都能把递归删除带出 `sessions/` / `session-trash/`。 */
|
|
789
|
+
function isSinglePathSegment(value) {
|
|
790
|
+
return value !== "" && value !== "." && value !== ".." && !/[\\/\0]/u.test(value);
|
|
791
|
+
}
|
|
792
|
+
function assertSingleSegment(value, label, allowEmpty = false) {
|
|
793
|
+
if (value === "") {
|
|
794
|
+
if (allowEmpty) return;
|
|
795
|
+
throw new Error(`${label} 不能为空`);
|
|
796
|
+
}
|
|
797
|
+
if (!isSinglePathSegment(value)) throw new Error(`${label} 不是单段路径:${value}`);
|
|
798
|
+
}
|
|
799
|
+
/** `sessionDirName` 允许空串:恢复的预检用它取**项目根**(`sessions/<projectDir>`),不是会话目录。 */
|
|
800
|
+
function sessionDirOf(projectDir, sessionDirName, home) {
|
|
801
|
+
assertSingleSegment(projectDir, "项目目录名");
|
|
802
|
+
assertSingleSegment(sessionDirName, "会话目录名", true);
|
|
803
|
+
return `${home}/sessions/${projectDir}/${sessionDirName}`;
|
|
804
|
+
}
|
|
805
|
+
function trashDirOf(entryId, home) {
|
|
806
|
+
assertSingleSegment(entryId, "回收站条目 id");
|
|
807
|
+
return `${home}/${TRASH_DIR_NAME}/${entryId}`;
|
|
808
|
+
}
|
|
555
809
|
//#endregion
|
|
556
|
-
//#region src/
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
810
|
+
//#region src/log/zstd-frames.ts
|
|
811
|
+
const MAGIC = [
|
|
812
|
+
40,
|
|
813
|
+
181,
|
|
814
|
+
47,
|
|
815
|
+
253
|
|
816
|
+
];
|
|
817
|
+
/** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
|
|
818
|
+
const AFTER_DESCRIPTOR = 5;
|
|
819
|
+
const BLOCK_HEADER_BYTES = 3;
|
|
820
|
+
const BLOCK_LAST = 1;
|
|
821
|
+
const BLOCK_TYPE_SHIFT = 1;
|
|
822
|
+
const BLOCK_SIZE_SHIFT = 3;
|
|
823
|
+
const BLOCK_SIZE_MASK = 2097151;
|
|
824
|
+
const BLOCK_RAW = 0;
|
|
825
|
+
const BLOCK_RLE = 1;
|
|
826
|
+
const BLOCK_COMRESSED = 2;
|
|
827
|
+
/** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
|
|
828
|
+
const BLOCK_RESERVED = 3;
|
|
829
|
+
/** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
|
|
830
|
+
const DID_SIZES = [
|
|
831
|
+
0,
|
|
832
|
+
1,
|
|
833
|
+
2,
|
|
834
|
+
4
|
|
835
|
+
];
|
|
836
|
+
const FCS_SIZES_SINGLE = [
|
|
837
|
+
1,
|
|
838
|
+
2,
|
|
839
|
+
4,
|
|
840
|
+
8
|
|
841
|
+
];
|
|
842
|
+
const FCS_SIZES_MULTI = [
|
|
843
|
+
0,
|
|
844
|
+
2,
|
|
845
|
+
4,
|
|
846
|
+
8
|
|
847
|
+
];
|
|
848
|
+
/** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
|
|
849
|
+
const FRAME_TAIL_BYTES = 4;
|
|
850
|
+
function isMagicAt(raw, at) {
|
|
851
|
+
return MAGIC.every((byte, i) => raw[at + i] === byte);
|
|
563
852
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
853
|
+
function readUint24LE(raw, at) {
|
|
854
|
+
return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
|
|
855
|
+
}
|
|
856
|
+
/** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
|
|
857
|
+
function withFrameTail(raw, at) {
|
|
858
|
+
const afterTail = at + FRAME_TAIL_BYTES;
|
|
859
|
+
if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
|
|
860
|
+
return at;
|
|
570
861
|
}
|
|
571
862
|
/**
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* (`SESSION_QUERY_SESSION_NOT_FOUND`),而我们要的那批正是"父已经不在宿主列表里之后还剩在盘上的"。
|
|
576
|
-
* `kind:'parent'` 是唯一能按 `header.parentSession` 反着问的一支。
|
|
577
|
-
*
|
|
578
|
-
* 停下来的条件与代价:**层数**决定宿主调用次数,行数不参与(一层一次,问空即停)。同一条记录只收一次
|
|
579
|
-
* (`seen` 连着根一起记),所以数据里真出现环时结果是"少数一条",不是把清单读挂死。
|
|
580
|
-
* 交回的 id 没有 `header.id` 的记录**整条跳过**——身份冒充不起(同 {@link HostPort.subagentMembers} 那条),
|
|
581
|
-
* 而且没有身份就没法往下问,硬数进去只会让它将来被重复计。
|
|
863
|
+
* 把 `raw` 切成帧区间。
|
|
864
|
+
* @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
|
|
865
|
+
* 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
|
|
582
866
|
*/
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (siblings === void 0) children.set(parentBare, [descendant]);
|
|
608
|
-
else siblings.push(descendant);
|
|
609
|
-
next.push(bare);
|
|
610
|
-
}
|
|
611
|
-
frontier = next;
|
|
612
|
-
}
|
|
613
|
-
return children;
|
|
614
|
-
}
|
|
615
|
-
/** 某个根在 `descendantsByParent` 那棵树里的全部后代(深度不设上限,同一条只收一次)。 */
|
|
616
|
-
function subtreeOf(children, rootBare) {
|
|
617
|
-
const out = [];
|
|
618
|
-
const seen = /* @__PURE__ */ new Set([rootBare]);
|
|
619
|
-
let frontier = [rootBare];
|
|
620
|
-
while (frontier.length > 0) {
|
|
621
|
-
const next = [];
|
|
622
|
-
for (const bare of frontier) for (const kid of children.get(bare) ?? []) {
|
|
623
|
-
if (seen.has(kid.bare)) continue;
|
|
624
|
-
seen.add(kid.bare);
|
|
625
|
-
out.push(kid);
|
|
626
|
-
next.push(kid.bare);
|
|
867
|
+
function frameRanges(raw) {
|
|
868
|
+
const ranges = [];
|
|
869
|
+
let at = 0;
|
|
870
|
+
while (at < raw.length) {
|
|
871
|
+
if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
|
|
872
|
+
const descriptor = raw[at + 4];
|
|
873
|
+
if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
|
|
874
|
+
const fcsFlag = descriptor & 3;
|
|
875
|
+
const singleSegment = descriptor >> 2 & 1;
|
|
876
|
+
const didFlag = descriptor >> 6 & 3;
|
|
877
|
+
let cursor = at + AFTER_DESCRIPTOR;
|
|
878
|
+
if (!singleSegment) cursor += 1;
|
|
879
|
+
cursor += DID_SIZES[didFlag];
|
|
880
|
+
cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
|
|
881
|
+
for (;;) {
|
|
882
|
+
const blockHeader = readUint24LE(raw, cursor);
|
|
883
|
+
cursor += BLOCK_HEADER_BYTES;
|
|
884
|
+
if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
|
|
885
|
+
const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
|
|
886
|
+
const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
|
|
887
|
+
if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
|
|
888
|
+
if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
|
|
889
|
+
else if (blockType === BLOCK_RLE) cursor += 1;
|
|
890
|
+
if (blockHeader & BLOCK_LAST) break;
|
|
627
891
|
}
|
|
628
|
-
|
|
892
|
+
cursor = withFrameTail(raw, cursor);
|
|
893
|
+
if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
|
|
894
|
+
ranges.push({
|
|
895
|
+
start: at,
|
|
896
|
+
end: cursor
|
|
897
|
+
});
|
|
898
|
+
at = cursor;
|
|
629
899
|
}
|
|
630
|
-
return
|
|
900
|
+
return ranges;
|
|
631
901
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
* `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
|
|
635
|
-
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
902
|
+
//#endregion
|
|
903
|
+
//#region src/log/session-log.ts
|
|
636
904
|
/**
|
|
637
|
-
*
|
|
905
|
+
* 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
|
|
906
|
+
* 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
|
|
638
907
|
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
|
|
908
|
+
* **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
|
|
909
|
+
* 这一层只懂编解码,所以它能被单独验穿。
|
|
642
910
|
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
911
|
+
* 两条实测决定的口径:
|
|
912
|
+
* ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
|
|
913
|
+
* `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
|
|
914
|
+
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
915
|
+
* ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
|
|
916
|
+
* 实测本机 686 个 `.jsonl.zstd` / 92 421 帧**没有一帧切在行中间**(连最后一帧都整行收尾),
|
|
917
|
+
* 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
|
|
918
|
+
* 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
|
|
919
|
+
* 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
|
|
920
|
+
* 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
|
|
921
|
+
*/
|
|
922
|
+
const NEWLINE = 10;
|
|
923
|
+
/**
|
|
924
|
+
* 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
|
|
925
|
+
* `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
|
|
926
|
+
* 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
|
|
927
|
+
*/
|
|
928
|
+
const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
|
|
929
|
+
/**
|
|
930
|
+
* 同一个会话目录里**可以并存多代日志**(宿主换代时把老代留在原地当历史语料,本机实测 7 个目录如此),
|
|
931
|
+
* 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
|
|
932
|
+
* ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
|
|
651
933
|
*
|
|
652
|
-
*
|
|
653
|
-
* (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
|
|
934
|
+
* 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
|
|
654
935
|
*/
|
|
655
|
-
function
|
|
656
|
-
let
|
|
657
|
-
let
|
|
658
|
-
for (
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
readable += ch;
|
|
666
|
-
separatorRun = false;
|
|
667
|
-
} else {
|
|
668
|
-
readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
669
|
-
separatorRun = false;
|
|
936
|
+
function pickSessionLog(names) {
|
|
937
|
+
let best;
|
|
938
|
+
let bestVersion = -1;
|
|
939
|
+
for (const name of names) {
|
|
940
|
+
const match = CANONICAL_ZSTD_LOG.exec(name);
|
|
941
|
+
if (match === null) continue;
|
|
942
|
+
const version = match[1] === void 0 ? 0 : Number(match[1]);
|
|
943
|
+
if (version > bestVersion) {
|
|
944
|
+
bestVersion = version;
|
|
945
|
+
best = name;
|
|
670
946
|
}
|
|
671
947
|
}
|
|
672
|
-
return
|
|
673
|
-
}
|
|
674
|
-
/** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
|
|
675
|
-
function projectionCacheOf(ctx) {
|
|
676
|
-
const cache = ctx.get("sessionProjectionCache");
|
|
677
|
-
if (cache === void 0 || cache === null) return void 0;
|
|
678
|
-
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
679
|
-
return cache;
|
|
948
|
+
return best;
|
|
680
949
|
}
|
|
681
950
|
/**
|
|
682
|
-
*
|
|
683
|
-
* (没有类型、没有码),只认这一句。asar `dsh-workspace/lib/types/index.js` 原文(逐字):
|
|
684
|
-
* `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
|
|
951
|
+
* 逐帧走一遍日志,每条记录交给回调。
|
|
685
952
|
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
* 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
|
|
689
|
-
* hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
|
|
690
|
-
* 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
|
|
953
|
+
* 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
|
|
954
|
+
* 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
|
|
691
955
|
*/
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
956
|
+
function walkSessionLog(raw, onRecord) {
|
|
957
|
+
const stats = {
|
|
958
|
+
frames: 0,
|
|
959
|
+
records: 0,
|
|
960
|
+
unparsedLines: 0,
|
|
961
|
+
truncatedTailBytes: 0
|
|
962
|
+
};
|
|
963
|
+
let stopped = false;
|
|
964
|
+
const feed = (line) => {
|
|
965
|
+
if (line === "") return;
|
|
966
|
+
let parsed;
|
|
967
|
+
try {
|
|
968
|
+
parsed = JSON.parse(line);
|
|
969
|
+
} catch {
|
|
970
|
+
stats.unparsedLines += 1;
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
stats.records += 1;
|
|
974
|
+
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
975
|
+
};
|
|
976
|
+
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
977
|
+
let carry = Buffer.alloc(0);
|
|
978
|
+
for (const { start, end } of frameRanges(raw)) {
|
|
979
|
+
if (stopped) break;
|
|
980
|
+
stats.frames += 1;
|
|
981
|
+
let plain;
|
|
982
|
+
try {
|
|
983
|
+
plain = zlib.zstdDecompressSync(raw.subarray(start, end));
|
|
984
|
+
} catch (error) {
|
|
985
|
+
throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
|
|
986
|
+
}
|
|
987
|
+
const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
|
|
988
|
+
const cut = merged.lastIndexOf(NEWLINE);
|
|
989
|
+
if (cut === -1) {
|
|
990
|
+
carry = merged;
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
carry = merged.subarray(cut + 1);
|
|
994
|
+
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
995
|
+
feed(line);
|
|
996
|
+
if (stopped) break;
|
|
997
|
+
}
|
|
722
998
|
}
|
|
999
|
+
stats.truncatedTailBytes = carry.length;
|
|
1000
|
+
if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
|
|
1001
|
+
return stats;
|
|
723
1002
|
}
|
|
724
1003
|
/**
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
* `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(设计稿 §6 的两行映射)。
|
|
1004
|
+
* 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
|
|
1005
|
+
* AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
|
|
1006
|
+
* (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
|
|
1007
|
+
* 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
|
|
730
1008
|
*/
|
|
731
|
-
function
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
/**
|
|
740
|
-
* 原生 cwd → 宿主项目目录名(`projectKey`);没有 cwd 时按宿主约定落到 `_no-cwd`。
|
|
741
|
-
*
|
|
742
|
-
* 「账本里没有这个会话」= **未命中**,回 `undefined`(不是抛):这正是本方法声明的类型,
|
|
743
|
-
* 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
|
|
744
|
-
* verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ 设计稿 §6 的 `not-found`
|
|
745
|
-
* (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
|
|
746
|
-
*
|
|
747
|
-
* **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
|
|
748
|
-
*/
|
|
749
|
-
async projectDirOf(sessionId) {
|
|
750
|
-
let header;
|
|
751
|
-
try {
|
|
752
|
-
header = await registry.readSessionHeader(sessionId);
|
|
753
|
-
} catch (error) {
|
|
754
|
-
if (isUnknownSession(error)) return void 0;
|
|
755
|
-
throw error;
|
|
756
|
-
}
|
|
757
|
-
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
758
|
-
},
|
|
759
|
-
/**
|
|
760
|
-
* 标题:走官方**投影缓存**读面(`title` 投影单元的 `wire.view` 值就是标题文本本身:
|
|
761
|
-
* 单元定义原文 `{ key: "title", stateVersion: 1, wire: { view: (state) => state } }`,
|
|
762
|
-
* 所以 `values.title` 是 `string | null`)。
|
|
763
|
-
* 契约:**尽力取、取不到返回空串、绝不因此让删除失败** ⇒ 这里吞掉一切异常。
|
|
764
|
-
*/
|
|
765
|
-
async titleOf(sessionId) {
|
|
766
|
-
try {
|
|
767
|
-
const header = await registry.readSessionHeader(sessionId);
|
|
768
|
-
const cache = projectionCacheOf(host);
|
|
769
|
-
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
770
|
-
return typeof title === "string" ? title : "";
|
|
771
|
-
} catch {
|
|
772
|
-
return "";
|
|
773
|
-
}
|
|
774
|
-
},
|
|
775
|
-
/**
|
|
776
|
-
* 活跃判据 = 宿主官方那一条(设计稿 §5):`workspace/session-activity` waterfall 问一次,
|
|
777
|
-
* **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
|
|
778
|
-
* asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
|
|
779
|
-
* if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
|
|
780
|
-
*/
|
|
781
|
-
async isLive(sessionId) {
|
|
782
|
-
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
783
|
-
},
|
|
784
|
-
/** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
|
|
785
|
-
async isArchived(sessionId) {
|
|
786
|
-
return registry.archivedSessionIds.includes(sessionId);
|
|
787
|
-
},
|
|
788
|
-
/**
|
|
789
|
-
* 从 `ctx.sessions`(live store)里摘掉一个会话。
|
|
790
|
-
*
|
|
791
|
-
* **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
|
|
792
|
-
* (见设计稿 §4 末)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来,
|
|
793
|
-
* 于是"删了还在"(真机实测:文件已进回收站、账本已摘,侧栏仍留着那一行)。
|
|
794
|
-
*
|
|
795
|
-
* **这是未声明为契约的内部入口**(设计稿 §4 末记了这次偏离):`liveEntryFor` / `entry.detach()` 是 store
|
|
796
|
-
* 给**会话 owner**(agent 的 effect 链)用的,宿主自己的控制器 `createOrAdopt` 只取 `agents.create()`
|
|
797
|
-
* 返回的 `.agent`、把带 `dispose` 的 handle 丢掉了,`ctx.agents` 也没有 `liveEntryFor` 那样的入口
|
|
798
|
-
* (会话 store 有、agent store 没有,逐成员面核过)⇒ 只能摘到"会话"这一层,上面那具 agent 摘不到,
|
|
799
|
-
* 它之后若走到 `flush(session)` 会撞 `liveEntryFor` 抛 `session "…" is not live in this store`。
|
|
800
|
-
*
|
|
801
|
-
* **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 抛 ⇒ `unknown`(**不通告**,
|
|
802
|
-
* 因为无法断言宿主列表已经干净);内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。
|
|
803
|
-
* 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
|
|
804
|
-
*/
|
|
805
|
-
async evictLive(sessionId) {
|
|
806
|
-
try {
|
|
807
|
-
const store = liveSessionsOf(host);
|
|
808
|
-
if (store === void 0) return "unknown";
|
|
809
|
-
const session = store.get(sessionId);
|
|
810
|
-
if (session === void 0 || session === null) return "not-live";
|
|
811
|
-
const entry = store.liveEntryFor(session);
|
|
812
|
-
if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
|
|
813
|
-
entry.detach();
|
|
814
|
-
return "evicted";
|
|
815
|
-
} catch {
|
|
816
|
-
return "unknown";
|
|
817
|
-
}
|
|
818
|
-
},
|
|
819
|
-
/**
|
|
820
|
-
* 通告客户端"这个会话没了"。
|
|
821
|
-
*
|
|
822
|
-
* **为什么需要**:客户端那一行只由两条路更新——重连时的整表重列(`handleConnected` → `refreshList`),
|
|
823
|
-
* 或宿主转发的 `api-session/*` 事件。删除一个**冷**会话时宿主不会发任何事件(`session/disposed` 只在摘掉
|
|
824
|
-
* 活体时才有),所以那一行会一直挂到下次重连为止(真机实测就是这个形状)。
|
|
825
|
-
*
|
|
826
|
-
* **为什么这样发是对的**:`api-session/removed` 是宿主**自己声明并转发**的事件
|
|
827
|
-
* (asar `dsh-api-remotes/lib/remote-events.js` 的 `API_REMOTE_FORWARDED_EVENTS` 里
|
|
828
|
-
* `{ event: "api-session/removed", mode: "emit" }`;转发器就是 `ctx.on(event, …)` 的普通监听者);
|
|
829
|
-
* 我们不带 carrier 地 emit ⇒ 全量派发到它(见 {@link HostCtxLike.emit})⇒ 客户端
|
|
830
|
-
* `ctx.remote.$on("api-session/removed", …)` 收到后 `recordMutation({kind:'remove'})` + `handleRemoved()`。
|
|
831
|
-
* **只在"宿主列表确实已经不含它"时调用**(调用方按 {@link EvictionOutcome} 判):这条通告在语义上是**如实**
|
|
832
|
-
* 的,不是障眼法。
|
|
833
|
-
*/
|
|
834
|
-
async announceRemoved(sessionId) {
|
|
835
|
-
try {
|
|
836
|
-
host.emit("api-session/removed", sessionId);
|
|
837
|
-
} catch {}
|
|
838
|
-
},
|
|
839
|
-
/**
|
|
840
|
-
* 通告客户端"这个会话回来了"(恢复用)。
|
|
841
|
-
*
|
|
842
|
-
* 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
|
|
843
|
-
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
844
|
-
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**——恢复本身已经完成,只是要等下次重连才上屏。
|
|
845
|
-
*
|
|
846
|
-
* **单条路各拉一次表**;批量走 {@link HostPort.announceRestoredBatch} + 一次预取(见 {@link restoredSummariesOf}),
|
|
847
|
-
* 100 条的批量恢复用这条就是 100 遍全表扫——那是 2026-10-17 那次"恢复很慢"的头号来源。
|
|
848
|
-
*/
|
|
849
|
-
async announceRestored(sessionId) {
|
|
850
|
-
const summary = (await this.restoredSummariesOf()).find((item) => item.sessionId === sessionId)?.summary;
|
|
851
|
-
if (summary === void 0) return;
|
|
852
|
-
try {
|
|
853
|
-
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
854
|
-
} catch {}
|
|
855
|
-
},
|
|
856
|
-
/** 整批预取一次会话表(`[{ sessionId, summary }]`):通告的路径与单条一致,**拉表从每条一次抬成整批一次**。 */
|
|
857
|
-
async restoredSummariesOf() {
|
|
858
|
-
const out = [];
|
|
859
|
-
for (const item of await controllerItems(host)) {
|
|
860
|
-
const sessionId = item?.sessionId;
|
|
861
|
-
if (typeof sessionId === "string") out.push({
|
|
862
|
-
sessionId,
|
|
863
|
-
summary: item
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
return out;
|
|
867
|
-
},
|
|
868
|
-
/** 一组会话的通告(2026-10-17 批量重构):`known` 给了就**一次都不拉表**,只按它筛。
|
|
869
|
-
* 与 {@link announceRestored} 同一判据、同一事件、同一载荷形状,只是取数由整批共享。 */
|
|
870
|
-
async announceRestoredBatch(ids, known) {
|
|
871
|
-
if (ids.length === 0) return;
|
|
872
|
-
const summaries = known ?? await this.restoredSummariesOf();
|
|
873
|
-
for (const id of ids) {
|
|
874
|
-
const summary = summaries.find((item) => item.sessionId === id)?.summary;
|
|
875
|
-
if (summary === void 0) continue;
|
|
876
|
-
try {
|
|
877
|
-
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
878
|
-
} catch {}
|
|
879
|
-
}
|
|
880
|
-
},
|
|
881
|
-
/** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
|
|
882
|
-
async unarchive(sessionId) {
|
|
883
|
-
await registry.unarchiveSession(sessionId);
|
|
884
|
-
},
|
|
885
|
-
/** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
|
|
886
|
-
async detach(sessionId, workspaceId) {
|
|
887
|
-
const workspace = registry.get(workspaceId);
|
|
888
|
-
if (workspace === void 0) throw new Error(`workspace not found (detach): ${workspaceId}`);
|
|
889
|
-
await workspace.detachSession(sessionId);
|
|
890
|
-
},
|
|
891
|
-
/** 挂账本:实体方法 `attachSession`(顶层没有)。 */
|
|
892
|
-
async attach(sessionId, workspaceId) {
|
|
893
|
-
const workspace = registry.get(workspaceId);
|
|
894
|
-
if (workspace === void 0) throw new Error(`workspace not found (attach): ${workspaceId}`);
|
|
895
|
-
await workspace.attachSession(sessionId);
|
|
896
|
-
},
|
|
897
|
-
/** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
|
|
898
|
-
* (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant"),所以 `workspaceId` 不用
|
|
899
|
-
* ——未分组的会话没有工作区可传,签名里它是可选的。
|
|
900
|
-
* 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,设计稿 §6)。 */
|
|
901
|
-
async archive(sessionId) {
|
|
902
|
-
await registry.archiveSession(sessionId);
|
|
903
|
-
},
|
|
904
|
-
/**
|
|
905
|
-
* 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
|
|
906
|
-
*
|
|
907
|
-
* 数的是**传递闭包**(孩子与孙代都算),不是"直接孩子"(issue #1):宿主客户端对所有
|
|
908
|
-
* `origin:"subagent"` 一律藏行 ⇒ 深度 ≥2 上"删了父还剩多少看不见"原样存在,只数一层就是把那件事
|
|
909
|
-
* 说成没有。问法见 {@link descendantsByParent}。
|
|
910
|
-
*
|
|
911
|
-
* 归堆必须**两边都去前缀**再比:本机实测父会话自己的 header id 带 `session-` 前缀
|
|
912
|
-
* (`session-ac07f2bb-…`)、它的子会话写的 `parentSession` 也带,但 v3 那一代的 id 不带 ⇒
|
|
913
|
-
* 同一个父有两种写法。所以问的时候把一个 id 的两种形状都塞进 `values`,回来按去前缀后的形状归堆,
|
|
914
|
-
* 最后按**调用方给的那个写法**把键交回去(调用方拿 `entry.sessionId` 直接查得到)。
|
|
915
|
-
*/
|
|
916
|
-
async subagentCounts(parentIds) {
|
|
917
|
-
const wanted = [];
|
|
918
|
-
for (const id of parentIds) if (id.length > 0 && !wanted.includes(id)) wanted.push(id);
|
|
919
|
-
if (wanted.length === 0) return {};
|
|
920
|
-
const sessionQuery = host.get("sessionQuery");
|
|
921
|
-
if (typeof sessionQuery?.filterSessions !== "function") return {};
|
|
922
|
-
const children = await descendantsByParent(sessionQuery, wanted);
|
|
923
|
-
const counts = {};
|
|
924
|
-
for (const id of wanted) {
|
|
925
|
-
const n = subtreeOf(children, bareSessionId(id)).length;
|
|
926
|
-
if (n > 0) counts[id] = n;
|
|
927
|
-
}
|
|
928
|
-
return counts;
|
|
929
|
-
},
|
|
930
|
-
/**
|
|
931
|
-
* 删除要一起带走**整个组**,先得把他们点出来:身份取宿主自己记录里的 `header.id`,项目目录由
|
|
932
|
-
* `header.cwd` 走同一个 `projectKey`(与 {@link HostPort.projectDirOf} 同一根算法,不另起一套)。
|
|
933
|
-
* 交回的是**全部后代**——孙代跟着父一起进回收站(issue #1),顺序是逐层往下问出来的(近的先)。
|
|
934
|
-
* `header.id` 缺席的后代直接跳过——不拿目录名冒充身份(冒充了会把一个来路不明的目录搬进回收站)。
|
|
935
|
-
* 服务没挂回 `[]` ⇒ 删除照旧只管父,不因此失败(父的删除本来就已经是完整的一件事)。
|
|
936
|
-
*/
|
|
937
|
-
async subagentMembers(sessionId) {
|
|
938
|
-
const sessionQuery = host.get("sessionQuery");
|
|
939
|
-
if (typeof sessionQuery?.filterSessions !== "function") return [];
|
|
940
|
-
return subtreeOf(await descendantsByParent(sessionQuery, [sessionId]), bareSessionId(sessionId)).map((d) => ({
|
|
941
|
-
sessionId: d.id,
|
|
942
|
-
projectDir: d.projectDir
|
|
943
|
-
}));
|
|
944
|
-
}
|
|
945
|
-
};
|
|
1009
|
+
function readSessionHeader(raw) {
|
|
1010
|
+
let header;
|
|
1011
|
+
walkSessionLog(raw, (record) => {
|
|
1012
|
+
if (record.type !== "session") return;
|
|
1013
|
+
header = record;
|
|
1014
|
+
return false;
|
|
1015
|
+
});
|
|
1016
|
+
return header;
|
|
946
1017
|
}
|
|
947
1018
|
//#endregion
|
|
948
1019
|
//#region src/trash/reconcile.ts
|
|
@@ -971,11 +1042,11 @@ function createHostPort(ctx) {
|
|
|
971
1042
|
* 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
|
|
972
1043
|
* 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
|
|
973
1044
|
*/
|
|
974
|
-
/** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC
|
|
1045
|
+
/** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`;
|
|
1046
|
+
* 原目录名还必须能安全地拼回磁盘路径——带分隔符的名字仍按"外人"跳过,不让一次对账读崩)。 */
|
|
975
1047
|
function isEntryId(name) {
|
|
976
1048
|
try {
|
|
977
|
-
parseEntryId(name);
|
|
978
|
-
return true;
|
|
1049
|
+
return isSinglePathSegment(parseEntryId(name).sessionDirName);
|
|
979
1050
|
} catch {
|
|
980
1051
|
return false;
|
|
981
1052
|
}
|
|
@@ -1031,7 +1102,7 @@ async function buildEntry(deps, id) {
|
|
|
1031
1102
|
/** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
|
|
1032
1103
|
async function reconcileTrash(deps) {
|
|
1033
1104
|
const rows = await deps.manifest.list();
|
|
1034
|
-
const root = `${deps.home}
|
|
1105
|
+
const root = `${deps.home}/${TRASH_DIR_NAME}`;
|
|
1035
1106
|
if (!await deps.fs.exists(root)) return rows;
|
|
1036
1107
|
const dirIds = /* @__PURE__ */ new Set();
|
|
1037
1108
|
for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
|
|
@@ -1042,120 +1113,90 @@ async function reconcileTrash(deps) {
|
|
|
1042
1113
|
if (rowIds.has(id)) continue;
|
|
1043
1114
|
const entry = await buildEntry(deps, id);
|
|
1044
1115
|
if (entry !== void 0) rebuilds.push(entry);
|
|
1045
|
-
}
|
|
1046
|
-
if (drops.length === 0 && rebuilds.length === 0) return rows;
|
|
1047
|
-
for (const row of drops) await deps.manifest.remove(row.id);
|
|
1048
|
-
for (const entry of rebuilds) await deps.manifest.add(entry);
|
|
1049
|
-
return await deps.manifest.list();
|
|
1050
|
-
}
|
|
1051
|
-
//#endregion
|
|
1052
|
-
//#region src/verbs/purge-trash.ts
|
|
1053
|
-
function purgeTrash(deps) {
|
|
1054
|
-
return async function purgeTrash(entryId, snapshot) {
|
|
1055
|
-
const all = snapshot ?? await deps.manifest.list();
|
|
1056
|
-
const targets = entryId ? all.filter((e) => e.id === entryId || e.parentEntryId === entryId) : [...all];
|
|
1057
|
-
if (entryId && targets.length === 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
1058
|
-
let freedBytes = 0;
|
|
1059
|
-
for (const entry of targets) {
|
|
1060
|
-
const dir = trashDirOf(entry.id, deps.home);
|
|
1061
|
-
if (await deps.fs.exists(dir)) {
|
|
1062
|
-
freedBytes += await deps.fs.sizeOfDir(dir);
|
|
1063
|
-
await deps.fs.removeDir(dir).catch((error) => {
|
|
1064
|
-
throw verbError("io", `删除回收站目录失败:${String(error)}`, error);
|
|
1065
|
-
});
|
|
1066
|
-
}
|
|
1067
|
-
await deps.manifest.remove(entry.id);
|
|
1068
|
-
const at = all.indexOf(entry);
|
|
1069
|
-
if (at >= 0) all.splice(at, 1);
|
|
1070
|
-
if (await deps.host.evictLive(entry.sessionId) !== "unknown") await deps.host.announceRemoved(entry.sessionId);
|
|
1071
|
-
}
|
|
1072
|
-
return {
|
|
1073
|
-
removed: targets.length,
|
|
1074
|
-
freedBytes
|
|
1075
|
-
};
|
|
1076
|
-
};
|
|
1116
|
+
}
|
|
1117
|
+
if (drops.length === 0 && rebuilds.length === 0) return rows;
|
|
1118
|
+
for (const row of drops) await deps.manifest.remove(row.id);
|
|
1119
|
+
for (const entry of rebuilds) await deps.manifest.add(entry);
|
|
1120
|
+
return await deps.manifest.list();
|
|
1077
1121
|
}
|
|
1078
1122
|
//#endregion
|
|
1079
|
-
//#region src/verbs/
|
|
1123
|
+
//#region src/verbs/member-journal.ts
|
|
1080
1124
|
/**
|
|
1081
|
-
*
|
|
1082
|
-
* 只回父就会在回收站里留下一堆点不到的孩子行——清单只列父行,那些行既看不见也清不掉。
|
|
1125
|
+
* 一次成员移动 = 一串**自带反向动作的步骤**(ADR-0002)。
|
|
1083
1126
|
*
|
|
1084
|
-
*
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1127
|
+
* 为什么需要它:`delete-session.ts` 的 `moveOneIn`/`undoOneIn` 与 `restore-session.ts` 的 `restoreOne`
|
|
1128
|
+
* (含它的反向闭包)互为镜像,各自手写了三到四段嵌套 `try/catch`:全仓 `rollbackReason(` **22 处**、
|
|
1129
|
+
* `withRollbackFailures(` **8 处**。那不是"回滚值得自己的候选",而是**同一条正反动作对被写了两遍**。
|
|
1130
|
+
*
|
|
1131
|
+
* 这里独占四件事,三个动词各自只声明"这次要做哪几步、每步怎么退":
|
|
1132
|
+
* ① **相反顺序**退化(最后做成的先退);
|
|
1133
|
+
* ② 一步失败时把**已经做成的**那些退回去;
|
|
1134
|
+
* ③ 失败收集:某一步退不干净时**不让原错误独走**(`details.rollbackFailures`,见 `verbs/errors.ts`);
|
|
1135
|
+
* ④ **非原子步骤**:`undoOnFailure` 让"这一步自己失败了"也跑它的反向动作(例如"取消归档"半做完了,
|
|
1136
|
+
* 得先把会话重新藏回去)。
|
|
1137
|
+
*
|
|
1138
|
+
* 边界:它只服务"成员在 `sessions/` 与 `session-trash/` 之间搬"这一件事。做成通用撤销框架就会吸进
|
|
1139
|
+
* 不属于它的知识(谁该通告、通告几次、哪些失败算成功)。
|
|
1090
1140
|
*/
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
for (const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
|
|
1101
|
-
if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
|
|
1141
|
+
/** 已经把某个前缀做成了的 journal——`undo()` 就退那一个前缀。 */
|
|
1142
|
+
function createJournal(steps) {
|
|
1143
|
+
const done = [];
|
|
1144
|
+
const undoAll = async (alsoFailing) => {
|
|
1145
|
+
const failures = [];
|
|
1146
|
+
if (alsoFailing?.undo !== void 0) failures.push(...await alsoFailing.undo());
|
|
1147
|
+
for (const step of [...done].reverse()) {
|
|
1148
|
+
if (step.undo === void 0) continue;
|
|
1149
|
+
failures.push(...await step.undo());
|
|
1102
1150
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1151
|
+
return failures;
|
|
1152
|
+
};
|
|
1153
|
+
return {
|
|
1154
|
+
async run() {
|
|
1155
|
+
for (const step of steps) {
|
|
1156
|
+
try {
|
|
1157
|
+
await step.run();
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
const failures = await undoAll(step.undoOnFailure === true ? step : void 0);
|
|
1160
|
+
const base = journalFailure(step, error);
|
|
1161
|
+
throw failures.length > 0 ? withRollbackFailures(base, failures) : base;
|
|
1162
|
+
}
|
|
1163
|
+
done.push(step);
|
|
1109
1164
|
}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1165
|
+
},
|
|
1166
|
+
undo() {
|
|
1167
|
+
return undoAll();
|
|
1113
1168
|
}
|
|
1114
|
-
if (options.suppressAnnounce !== true) await deps.host.announceRestoredBatch(restored);
|
|
1115
|
-
return target;
|
|
1116
1169
|
};
|
|
1117
1170
|
}
|
|
1118
|
-
/**
|
|
1119
|
-
|
|
1120
|
-
function restoreTargetDir(deps, entry) {
|
|
1121
|
-
return sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
|
|
1122
|
-
}
|
|
1123
|
-
/** 恢复一个成员,交回它的反向动作。 */
|
|
1124
|
-
async function restoreOne(deps, entry) {
|
|
1125
|
-
const trashDir = trashDirOf(entry.id, deps.home);
|
|
1126
|
-
const sessionDir = restoreTargetDir(deps, entry);
|
|
1127
|
-
await deps.fs.rename(trashDir, sessionDir).catch((error) => {
|
|
1128
|
-
throw verbError("io", `放回会话目录失败:${String(error)}`, error);
|
|
1129
|
-
});
|
|
1130
|
-
if (entry.workspaceId !== "") try {
|
|
1131
|
-
await deps.host.attach(entry.sessionId, entry.workspaceId);
|
|
1132
|
-
} catch (error) {
|
|
1133
|
-
await deps.fs.rename(sessionDir, trashDir).catch(() => {});
|
|
1134
|
-
throw verbError("io", `挂回账本失败:${String(error)}`, error);
|
|
1135
|
-
}
|
|
1136
|
-
if (!entry.wasArchived) try {
|
|
1137
|
-
await deps.host.unarchive(entry.sessionId);
|
|
1138
|
-
} catch (error) {
|
|
1139
|
-
await deps.host.archive(entry.sessionId, entry.workspaceId || void 0).catch(() => {});
|
|
1140
|
-
if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
|
|
1141
|
-
await deps.fs.rename(sessionDir, trashDir).catch(() => {});
|
|
1142
|
-
throw verbError("io", `取消归档失败:${String(error)}`, error);
|
|
1143
|
-
}
|
|
1171
|
+
/** 跑一步反向动作并把它自己的失败收成一条原因(不抛:这些失败由调用方汇总成 `rollbackFailures`)。 */
|
|
1172
|
+
async function attempt(label, run) {
|
|
1144
1173
|
try {
|
|
1145
|
-
await
|
|
1174
|
+
await run();
|
|
1175
|
+
return [];
|
|
1146
1176
|
} catch (error) {
|
|
1147
|
-
|
|
1148
|
-
if (entry.workspaceId !== "") await deps.host.detach(entry.sessionId, entry.workspaceId).catch(() => {});
|
|
1149
|
-
await deps.fs.rename(sessionDir, trashDir).catch(() => {});
|
|
1150
|
-
throw verbError("io", `摘回收站条目失败:${String(error)}`, error);
|
|
1177
|
+
return [rollbackReason(label, error)];
|
|
1151
1178
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
|
|
1182
|
+
* `details.rollbackFailures`,绝不让原错误独走(设计稿第十七批)。删除与恢复两个按组走的动词
|
|
1183
|
+
* 共用这一处——"相反顺序 + 失败合并"不再各写一遍。
|
|
1184
|
+
*/
|
|
1185
|
+
async function rollbackJournals(journals, error) {
|
|
1186
|
+
const failures = [];
|
|
1187
|
+
for (const journal of [...journals].reverse()) failures.push(...await journal.undo());
|
|
1188
|
+
if (failures.length > 0) throw withRollbackFailures(error, failures);
|
|
1189
|
+
throw error;
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* 这一步的失败形状:已经是动词码(`verbError` 的输出)就原样带,否则折成 `sessiondelete/io`。
|
|
1193
|
+
* 原文用 `String(error)` 而不是 `.message`——那 22 处既有措辞就是这么拼的(`Error: boom` 也在里面),
|
|
1194
|
+
* 换一种拼法会让上屏原文跟着变。
|
|
1195
|
+
*/
|
|
1196
|
+
function journalFailure(step, error) {
|
|
1197
|
+
const raw = error?.code;
|
|
1198
|
+
if (typeof raw === "string" && raw.startsWith(`sessiondelete/`)) return error;
|
|
1199
|
+
return verbError("io", `${step.label}:${String(error)}`, error);
|
|
1159
1200
|
}
|
|
1160
1201
|
//#endregion
|
|
1161
1202
|
//#region src/verbs/delete-session.ts
|
|
@@ -1167,13 +1208,15 @@ async function restoreOne(deps, entry) {
|
|
|
1167
1208
|
* 三段,顺序承重:
|
|
1168
1209
|
* ① **先点组、先验活体**(一个字节都不写)。任一成员在跑就整组拒绝——不替用户终止正在跑的活,
|
|
1169
1210
|
* 也绝不允许"父已经搬进回收站、孩子因为在跑而搬不动"这种半套。
|
|
1170
|
-
* ②
|
|
1171
|
-
*
|
|
1211
|
+
* ② **逐个搬**:每个成员是一份 `journal`(`verbs/member-journal.ts`),搬不动时 journal 自己把
|
|
1212
|
+
* 这个成员已经做成的步骤退回去。
|
|
1172
1213
|
* ③ **组级回滚**:某一个成员搬不动时,把**已经搬进去的**那些按相反顺序退回原处(清册行跟着摘、
|
|
1173
1214
|
* 账本跟着挂、可见性跟着还原),失败照抛——不留"半组在回收站、半组在原地"的世界。
|
|
1215
|
+
*
|
|
1216
|
+
* interface 只有**一个 id**:批量的性质不在这里(同批"已被前一条带走"的判据归 `verbs/batch.ts`)。
|
|
1174
1217
|
*/
|
|
1175
1218
|
function deleteSession(deps) {
|
|
1176
|
-
return async function deleteSession(sessionId
|
|
1219
|
+
return async function deleteSession(sessionId) {
|
|
1177
1220
|
const kids = await deps.host.subagentMembers(sessionId);
|
|
1178
1221
|
const parentProjectDir = await deps.host.projectDirOf(sessionId);
|
|
1179
1222
|
if (parentProjectDir === void 0) throw verbError("not-found", `宿主的持久化里没有这个会话:${sessionId}`);
|
|
@@ -1195,111 +1238,538 @@ function deleteSession(deps) {
|
|
|
1195
1238
|
sessionId: kid.sessionId,
|
|
1196
1239
|
projectDir: kid.projectDir
|
|
1197
1240
|
});
|
|
1198
|
-
options.onMembers?.(members.map((member) => member.sessionId));
|
|
1199
1241
|
const deletedAt = deps.now();
|
|
1200
1242
|
const done = [];
|
|
1201
1243
|
let parentEntry;
|
|
1202
1244
|
try {
|
|
1203
1245
|
for (const member of members) {
|
|
1204
1246
|
const isParent = member.sessionId === sessionId;
|
|
1205
|
-
const
|
|
1206
|
-
if (isParent) parentEntry = entry;
|
|
1207
|
-
|
|
1247
|
+
const move = await memberMove(deps, member, deletedAt, isParent ? void 0 : parentEntry.id);
|
|
1248
|
+
if (isParent) parentEntry = move.entry;
|
|
1249
|
+
await move.journal.run();
|
|
1250
|
+
done.push(move.journal);
|
|
1208
1251
|
}
|
|
1209
1252
|
} catch (error) {
|
|
1210
|
-
|
|
1211
|
-
throw error;
|
|
1253
|
+
await rollbackJournals(done, error);
|
|
1212
1254
|
}
|
|
1213
1255
|
return parentEntry;
|
|
1214
1256
|
};
|
|
1215
1257
|
}
|
|
1216
1258
|
/**
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1259
|
+
* 一个成员要做的步骤(顺序承重,照设计稿 §4 那五触点):
|
|
1260
|
+
* 读现场(两件纯读)→ 归档 → 摘账本 → 搬目录 → 写清册行。
|
|
1261
|
+
*
|
|
1262
|
+
* 每一步的反向动作都是"把这件写退回原样";**清册行是最后一件写**,它失败时 journal 会把目录搬回去、
|
|
1263
|
+
* 账本挂回去、可见性还原。清册行的反向动作带一道闸:**回收站目录还没搬回来就不摘那一行**——
|
|
1264
|
+
* 留着行至少元数据还在(与旧实现"目录退不回就不摘行"同一条判据)。
|
|
1220
1265
|
*/
|
|
1221
|
-
async function
|
|
1266
|
+
async function memberMove(deps, member, deletedAt, parentEntryId) {
|
|
1222
1267
|
const { sessionId, projectDir } = member;
|
|
1223
1268
|
const workspaceId = await deps.host.workspaceOf(sessionId);
|
|
1224
1269
|
const sessionDir = sessionDirOf(projectDir, sessionId, deps.home);
|
|
1225
1270
|
const wasArchived = await deps.host.isArchived(sessionId);
|
|
1226
|
-
const
|
|
1227
|
-
const trashDir = trashDirOf(entryId, deps.home);
|
|
1228
|
-
let title;
|
|
1229
|
-
let sizeBytes;
|
|
1230
|
-
try {
|
|
1231
|
-
title = await deps.host.titleOf(sessionId);
|
|
1232
|
-
sizeBytes = await deps.fs.sizeOfDir(sessionDir);
|
|
1233
|
-
} catch (error) {
|
|
1234
|
-
throw verbError("io", `读取会话现场失败:${String(error)}`, error);
|
|
1235
|
-
}
|
|
1236
|
-
if (!wasArchived) try {
|
|
1237
|
-
await deps.host.archive(sessionId, workspaceId ?? void 0);
|
|
1238
|
-
} catch (error) {
|
|
1239
|
-
throw verbError("io", `归档失败:${String(error)}`, error);
|
|
1240
|
-
}
|
|
1241
|
-
if (workspaceId !== void 0) try {
|
|
1242
|
-
await deps.host.detach(sessionId, workspaceId);
|
|
1243
|
-
} catch (error) {
|
|
1244
|
-
if (!wasArchived) await deps.host.unarchive(sessionId).catch(() => {});
|
|
1245
|
-
throw verbError("io", `摘账本失败:${String(error)}`, error);
|
|
1246
|
-
}
|
|
1271
|
+
const trashDir = trashDirOf(makeEntryId(deletedAt, sessionId), deps.home);
|
|
1247
1272
|
const entry = {
|
|
1248
|
-
id:
|
|
1273
|
+
id: makeEntryId(deletedAt, sessionId),
|
|
1249
1274
|
sessionId,
|
|
1250
1275
|
projectDir,
|
|
1251
1276
|
workspaceId: workspaceId ?? "",
|
|
1252
1277
|
wasArchived,
|
|
1253
1278
|
deletedAt,
|
|
1254
|
-
title,
|
|
1255
|
-
sizeBytes,
|
|
1279
|
+
title: "",
|
|
1280
|
+
sizeBytes: 0,
|
|
1256
1281
|
...parentEntryId === void 0 ? {} : { parentEntryId }
|
|
1257
1282
|
};
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1283
|
+
/** 目录真的搬进回收站了吗——决定那一步的反向动作有没有东西可退。 */
|
|
1284
|
+
let moved = false;
|
|
1285
|
+
/** 回滚的三件写是否都真正退回原样:目录、账本、可见性。`announceRestored` 只在这三件都成立时发。 */
|
|
1286
|
+
let movedBack = false;
|
|
1287
|
+
let workspaceBack = workspaceId === void 0;
|
|
1288
|
+
let visibleBack = wasArchived;
|
|
1289
|
+
const journal = createJournal([
|
|
1290
|
+
{
|
|
1291
|
+
label: "读取会话现场失败",
|
|
1292
|
+
run: async () => {
|
|
1293
|
+
entry.title = await deps.host.titleOf(sessionId);
|
|
1294
|
+
entry.sizeBytes = await deps.fs.sizeOfDir(sessionDir);
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
...wasArchived ? [] : [{
|
|
1298
|
+
label: "归档失败",
|
|
1299
|
+
run: async () => {
|
|
1300
|
+
await deps.host.archive(sessionId, workspaceId);
|
|
1301
|
+
},
|
|
1302
|
+
undo: async () => {
|
|
1303
|
+
return await attempt("可见性还原失败", async () => {
|
|
1304
|
+
await deps.host.unarchive(sessionId);
|
|
1305
|
+
visibleBack = true;
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
}],
|
|
1309
|
+
...workspaceId === void 0 ? [] : [{
|
|
1310
|
+
label: "摘账本失败",
|
|
1311
|
+
run: async () => {
|
|
1312
|
+
await deps.host.detach(sessionId, workspaceId);
|
|
1313
|
+
},
|
|
1314
|
+
undo: async () => {
|
|
1315
|
+
return await attempt("账本挂回失败", async () => {
|
|
1316
|
+
await deps.host.attach(sessionId, workspaceId);
|
|
1317
|
+
workspaceBack = true;
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}],
|
|
1321
|
+
{
|
|
1322
|
+
label: "移动会话目录失败",
|
|
1323
|
+
undoOnFailure: true,
|
|
1324
|
+
run: async () => {
|
|
1325
|
+
await deps.fs.ensureDir(`${deps.home}/${TRASH_DIR_NAME}`);
|
|
1326
|
+
await deps.fs.rename(sessionDir, trashDir);
|
|
1327
|
+
moved = true;
|
|
1328
|
+
try {
|
|
1329
|
+
await deps.manifest.add(entry);
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
|
|
1332
|
+
}
|
|
1333
|
+
},
|
|
1334
|
+
undo: async () => {
|
|
1335
|
+
if (!moved) return [];
|
|
1336
|
+
const failures = [...await attempt("目录搬回失败", async () => {
|
|
1337
|
+
await deps.fs.rename(trashDir, sessionDir);
|
|
1338
|
+
movedBack = true;
|
|
1339
|
+
})];
|
|
1340
|
+
if (failures.length === 0) failures.push(...await attempt("清册行摘除失败", () => deps.manifest.remove(entry.id)));
|
|
1341
|
+
return failures;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
]);
|
|
1345
|
+
return {
|
|
1346
|
+
entry,
|
|
1347
|
+
journal: {
|
|
1348
|
+
async run() {
|
|
1349
|
+
await journal.run();
|
|
1350
|
+
if (await deps.host.evictLive(sessionId) !== "unknown") await deps.host.announceRemoved(sessionId);
|
|
1351
|
+
},
|
|
1352
|
+
/** 组级回滚:先把成员自己的步骤按相反顺序退回,再在**三件写都真正回去**时补一条"已恢复"通告。
|
|
1353
|
+
* 这一行此前已经收过 `api-session/removed`;不发"回来了"它就会在客户端消失到重连为止。
|
|
1354
|
+
* 目录没退回、账本没挂回、或可见性没还原 ⇒ 不发——那会是假话(同 restore 侧 `movedBack` 的判据)。 */
|
|
1355
|
+
async undo() {
|
|
1356
|
+
const failures = await journal.undo();
|
|
1357
|
+
if (movedBack && workspaceBack && visibleBack) await deps.host.announceRestored(sessionId).catch(() => {});
|
|
1358
|
+
return failures;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
//#endregion
|
|
1364
|
+
//#region src/verbs/orphan-subagents.ts
|
|
1365
|
+
/** 成员的盘上目录与字节(扫描、清单装配与 purge 共用这一条路径/存在性/度量口径;
|
|
1366
|
+
* 目录缺席回 `undefined`,不拿一份"记录还在、盘上已经没了"的旧读数冒充盘上残留)。 */
|
|
1367
|
+
async function measureMemberDir(deps, member) {
|
|
1368
|
+
const dir = sessionDirOf(member.projectDir, member.sessionId, deps.home);
|
|
1369
|
+
if (!await deps.fs.exists(dir)) return void 0;
|
|
1370
|
+
return {
|
|
1371
|
+
dir,
|
|
1372
|
+
bytes: await deps.fs.sizeOfDir(dir)
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
/** 把一组成员折成"删之前要记下的计划";目录不在的成员跳过(记录与盘不一致是那条成员自己的事)。
|
|
1376
|
+
* `purge` 的盘上后代与 `orphan-clean` 的整组都走这一处,别再各写一遍 measure + workspaceOf。 */
|
|
1377
|
+
async function planMemberRemovals(deps, members) {
|
|
1378
|
+
const plans = [];
|
|
1379
|
+
for (const member of members) {
|
|
1380
|
+
const measured = await measureMemberDir(deps, member);
|
|
1381
|
+
if (measured === void 0) continue;
|
|
1382
|
+
const workspaceId = await deps.host.workspaceOf(member.sessionId);
|
|
1383
|
+
plans.push({
|
|
1384
|
+
sessionId: member.sessionId,
|
|
1385
|
+
...measured,
|
|
1386
|
+
...workspaceId === void 0 ? {} : { workspaceId }
|
|
1387
|
+
});
|
|
1265
1388
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1389
|
+
return plans;
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* 无主子会话 = `origin:"subagent"`、父**既不在当前语料(live ∪ persisted)也不在回收站清册里**的会话。
|
|
1393
|
+
*
|
|
1394
|
+
* 为什么清册里的父不算孤儿:那种是 **stranded**——父还在回收站里等着,孩子本该跟着父一起恢复或一起
|
|
1395
|
+
* 彻底删除(10-15 稿 §11 的组语义),把它的根当孤儿删掉就是**替用户做了一个没人点过的决定**。孤儿只
|
|
1396
|
+
* 有一种来路:父被彻底删掉了(或被手工从盘上清掉了),清册里再也没有这一行。
|
|
1397
|
+
*
|
|
1398
|
+
* 根按**传递闭包**收全部后代(与 `HostPort.subagentCounts` 同一口径:孩子与孙代都算,深度不设上限),
|
|
1399
|
+
* 同一条记录只归一组(`claimed`)——数据里真出现环时结果是"少数一条",不是把一个会话删两遍。
|
|
1400
|
+
* 归堆与去重**两边都去 `session-` 前缀**:同一个父在两代宿主里写法不同(见 `bareSessionId` 的来历)。
|
|
1401
|
+
*/
|
|
1402
|
+
function orphanGroupsOf(corpus, trashSessionIds) {
|
|
1403
|
+
const known = /* @__PURE__ */ new Set();
|
|
1404
|
+
for (const session of corpus) known.add(bareSessionId(session.sessionId));
|
|
1405
|
+
for (const id of trashSessionIds) known.add(bareSessionId(id));
|
|
1406
|
+
const children = /* @__PURE__ */ new Map();
|
|
1407
|
+
for (const session of corpus) {
|
|
1408
|
+
const parent = session.parentSessionId;
|
|
1409
|
+
if (parent === void 0) continue;
|
|
1410
|
+
const key = bareSessionId(parent);
|
|
1411
|
+
const list = children.get(key);
|
|
1412
|
+
if (list === void 0) children.set(key, [session]);
|
|
1413
|
+
else list.push(session);
|
|
1414
|
+
}
|
|
1415
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1416
|
+
const groups = [];
|
|
1417
|
+
for (const session of corpus) {
|
|
1418
|
+
const parent = session.parentSessionId;
|
|
1419
|
+
if (!session.subagent || parent === void 0) continue;
|
|
1420
|
+
if (known.has(bareSessionId(parent))) continue;
|
|
1421
|
+
if (claimed.has(bareSessionId(session.sessionId))) continue;
|
|
1422
|
+
const members = [];
|
|
1423
|
+
const queue = [session];
|
|
1424
|
+
for (let i = 0; i < queue.length; i += 1) {
|
|
1425
|
+
const current = queue[i];
|
|
1426
|
+
const id = bareSessionId(current.sessionId);
|
|
1427
|
+
if (claimed.has(id)) continue;
|
|
1428
|
+
claimed.add(id);
|
|
1429
|
+
members.push(current);
|
|
1430
|
+
for (const child of children.get(id) ?? []) queue.push(child);
|
|
1431
|
+
}
|
|
1432
|
+
groups.push({
|
|
1433
|
+
root: session,
|
|
1434
|
+
parentSessionId: parent,
|
|
1435
|
+
members
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
return groups;
|
|
1439
|
+
}
|
|
1440
|
+
/** 纯读:调用方应先走一次 `/list` 对账(面板的 refresh 就是这样);本函数不写清单。
|
|
1441
|
+
* 写动作 `orphan-clean` 会自己先 reconcile,避免拿一份过期清册判"父没了"。
|
|
1442
|
+
*
|
|
1443
|
+
* `listSessions()` 回 `undefined` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
|
|
1444
|
+
* 拿 undefined 当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
|
|
1445
|
+
* 所以这里按 `io` 响亮失败(AGENTS.md:`undefined` 意味着服务缺席,扫描方不许冒充读数)。 */
|
|
1446
|
+
async function loadOrphanGroups(deps) {
|
|
1447
|
+
const corpus = await deps.host.listSessions();
|
|
1448
|
+
if (corpus === void 0) throw verbError("io", "无主子会话扫描需要宿主的 sessionQuery.listSessions");
|
|
1449
|
+
const groups = orphanGroupsOf(corpus, (await deps.manifest.list()).map((entry) => entry.sessionId));
|
|
1450
|
+
const out = [];
|
|
1451
|
+
for (const group of groups) {
|
|
1452
|
+
const members = [];
|
|
1453
|
+
for (const member of group.members) {
|
|
1454
|
+
const measured = await measureMemberDir(deps, member);
|
|
1455
|
+
members.push({
|
|
1456
|
+
sessionId: member.sessionId,
|
|
1457
|
+
projectDir: member.projectDir,
|
|
1458
|
+
bytes: measured?.bytes ?? 0
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
out.push({
|
|
1462
|
+
root: members[0],
|
|
1463
|
+
parentSessionId: group.parentSessionId,
|
|
1464
|
+
members
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
return out;
|
|
1468
|
+
}
|
|
1469
|
+
/** 把一组组收成那份报告:根清单 + 闭包总数/总字节 + 失踪父的**去重计数** + 按项目目录的分摊。
|
|
1470
|
+
* `groups` 按字节从多到少排(界面上"哪家还剩得最多"要看第一行)。 */
|
|
1471
|
+
function reportOrphans(groups) {
|
|
1472
|
+
const projects = /* @__PURE__ */ new Map();
|
|
1473
|
+
let totalSessions = 0;
|
|
1474
|
+
let totalBytes = 0;
|
|
1475
|
+
for (const group of groups) for (const member of group.members) {
|
|
1476
|
+
totalSessions += 1;
|
|
1477
|
+
totalBytes += member.bytes;
|
|
1478
|
+
const row = projects.get(member.projectDir) ?? {
|
|
1479
|
+
sessions: 0,
|
|
1480
|
+
bytes: 0
|
|
1481
|
+
};
|
|
1482
|
+
row.sessions += 1;
|
|
1483
|
+
row.bytes += member.bytes;
|
|
1484
|
+
projects.set(member.projectDir, row);
|
|
1273
1485
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1486
|
+
return {
|
|
1487
|
+
roots: groups.map((group) => group.root.sessionId),
|
|
1488
|
+
totalSessions,
|
|
1489
|
+
totalBytes,
|
|
1490
|
+
missingParents: new Set(groups.map((group) => bareSessionId(group.parentSessionId))).size,
|
|
1491
|
+
groups: [...projects].map(([projectDir, value]) => ({
|
|
1492
|
+
projectDir,
|
|
1493
|
+
...value
|
|
1494
|
+
})).sort((a, b) => b.bytes - a.bytes)
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* 清一组:先**整组 live 准入(零写)**,再收集目录/字节/账本归属,再 unarchive → detach → 删目录。
|
|
1499
|
+
* 任何 preflight 失败都在第一件写之前抛出;删目录到一半失败时**已删目录不回滚**,失败错误的
|
|
1500
|
+
* `details` 带 `{ removed, freedBytes, failedSessionId }` 作为部分回执(不伪装成整组没动)。
|
|
1501
|
+
* 删目录之后照 purge 的规矩摘活体并如实通告。不碰投影缓存与附件。
|
|
1502
|
+
*
|
|
1503
|
+
* 顺序与删除那一半是**对偶**的,但不是照抄:
|
|
1504
|
+
* - 删除要先把会话藏起来(归档)再摘账本,免得中间那一拍它掉进「未分组」;这里目录是**删掉**不是搬走,
|
|
1505
|
+
* 藏不藏都不影响可见性 ⇒ 归档集反而是**在写之前整批摘掉**的:失败时目录还在,下一次还能重试,
|
|
1506
|
+
* 不会留下"会话没了但还在归档集里挂着"的幽灵 id。
|
|
1507
|
+
* - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以不需要删除侧那次
|
|
1508
|
+
* `wasArchived` 读——这里没有"还原到删除前"的义务。
|
|
1509
|
+
* - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
|
|
1510
|
+
* - `evictLive` 的三态照 `HostPort.evictLive` 的契约:`unknown` 时**不通告**(发了就是假话),
|
|
1511
|
+
* `evicted` / `not-live` 才发 `api-session/removed`。冷会话没有活体可摘,通告照样必须发。
|
|
1512
|
+
*/
|
|
1513
|
+
async function cleanOrphanGroup(deps, group) {
|
|
1514
|
+
for (const member of group.members) if (await deps.host.isLive(member.sessionId)) throw verbError("live", `会话正在运行:${member.sessionId}`);
|
|
1515
|
+
const plans = await planMemberRemovals(deps, group.members);
|
|
1516
|
+
for (const member of group.members) await deps.host.unarchive(member.sessionId);
|
|
1517
|
+
for (const plan of plans) if (plan.workspaceId !== void 0) await deps.host.detach(plan.sessionId, plan.workspaceId);
|
|
1518
|
+
let removed = 0;
|
|
1519
|
+
let freedBytes = 0;
|
|
1520
|
+
for (const plan of plans) {
|
|
1521
|
+
try {
|
|
1522
|
+
await deps.fs.removeDir(plan.dir);
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
throw verbError("io", `删除会话目录失败:${String(error)}`, error, {
|
|
1525
|
+
removed,
|
|
1526
|
+
freedBytes,
|
|
1527
|
+
failedSessionId: plan.sessionId
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
removed += 1;
|
|
1531
|
+
freedBytes += plan.bytes;
|
|
1532
|
+
if (await deps.host.evictLive(plan.sessionId) !== "unknown") await deps.host.announceRemoved(plan.sessionId);
|
|
1533
|
+
}
|
|
1534
|
+
return {
|
|
1535
|
+
removed,
|
|
1536
|
+
freedBytes
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
//#endregion
|
|
1540
|
+
//#region src/verbs/purge-trash.ts
|
|
1541
|
+
/**
|
|
1542
|
+
* 彻底删除:从回收站里**真的**抹掉一条(或整组、或整站),并连带那些"还留在盘上、没被带走"的后代。
|
|
1543
|
+
*
|
|
1544
|
+
* 与另外两个动词的关系(ADR-0002):它的 interface 只有"一个条目 id(或缺席 = 清空)",
|
|
1545
|
+
* 批量的快照与"已被同批前一条带走"的判据不在这里(归 `verbs/batch.ts`)。
|
|
1546
|
+
*
|
|
1547
|
+
* **这条动词不做回滚**:目录删掉就没有"退回去"这回事(设计稿第十七批:删到一半失败时已删目录不回滚;
|
|
1548
|
+
* 带部分计数的 `details` 回执只属于 `orphan-clean`,purge 的 `removed` / `freedBytes` 仍只在成功回执里给)。
|
|
1549
|
+
* 所以它的 journal 只有正向步骤、没有 `undo`——顺序仍由 journal 统一表达,不再是手写的四段 `try/catch`。
|
|
1550
|
+
*/
|
|
1551
|
+
function purgeTrash(deps) {
|
|
1552
|
+
return async function purgeTrash(entryId, snapshot) {
|
|
1553
|
+
const all = snapshot ?? await deps.manifest.list();
|
|
1554
|
+
const targets = entryId === void 0 ? all : groupOf(all, entryId);
|
|
1555
|
+
if (entryId !== void 0 && targets.length === 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
1556
|
+
const manifestIds = new Set(targets.map((entry) => bareSessionId(entry.sessionId)));
|
|
1557
|
+
const membersByParent = await deps.host.subagentMembersBatch(targets.map((entry) => entry.sessionId));
|
|
1558
|
+
const seenDisk = /* @__PURE__ */ new Set();
|
|
1559
|
+
/** 先只做准入与去重(全部是读),整组通过后再量目录/账本——任一成员在跑时一件计划都还没做。 */
|
|
1560
|
+
const candidates = [];
|
|
1561
|
+
for (const entry of targets) for (const member of membersByParent[entry.sessionId] ?? []) {
|
|
1562
|
+
const bare = bareSessionId(member.sessionId);
|
|
1563
|
+
if (manifestIds.has(bare) || seenDisk.has(bare)) continue;
|
|
1564
|
+
seenDisk.add(bare);
|
|
1565
|
+
if (await deps.host.isLive(member.sessionId)) throw verbError("live", `会话正在运行:${member.sessionId}`);
|
|
1566
|
+
candidates.push(member);
|
|
1567
|
+
}
|
|
1568
|
+
const diskPlans = await planMemberRemovals(deps, candidates);
|
|
1569
|
+
let freedBytes = 0;
|
|
1570
|
+
let removed = 0;
|
|
1571
|
+
await createJournal([
|
|
1572
|
+
{
|
|
1573
|
+
label: "取消归档失败",
|
|
1574
|
+
run: async () => {
|
|
1575
|
+
for (const entry of targets) await deps.host.unarchive(entry.sessionId);
|
|
1576
|
+
for (const plan of diskPlans) await deps.host.unarchive(plan.sessionId);
|
|
1577
|
+
}
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
label: "摘账本失败",
|
|
1581
|
+
run: async () => {
|
|
1582
|
+
for (const plan of diskPlans) if (plan.workspaceId !== void 0) await deps.host.detach(plan.sessionId, plan.workspaceId);
|
|
1583
|
+
}
|
|
1584
|
+
},
|
|
1585
|
+
{
|
|
1586
|
+
label: "删除回收站目录失败",
|
|
1587
|
+
run: async () => {
|
|
1588
|
+
for (const entry of targets) {
|
|
1589
|
+
const dir = trashDirOf(entry.id, deps.home);
|
|
1590
|
+
if (await deps.fs.exists(dir)) {
|
|
1591
|
+
freedBytes += await deps.fs.sizeOfDir(dir);
|
|
1592
|
+
await deps.fs.removeDir(dir);
|
|
1593
|
+
}
|
|
1594
|
+
await deps.manifest.remove(entry.id);
|
|
1595
|
+
removed += 1;
|
|
1596
|
+
if (await deps.host.evictLive(entry.sessionId) !== "unknown") await deps.host.announceRemoved(entry.sessionId);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
label: "删除盘上后代目录失败",
|
|
1602
|
+
run: async () => {
|
|
1603
|
+
for (const plan of diskPlans) {
|
|
1604
|
+
await deps.fs.removeDir(plan.dir);
|
|
1605
|
+
removed += 1;
|
|
1606
|
+
freedBytes += plan.bytes;
|
|
1607
|
+
if (await deps.host.evictLive(plan.sessionId) !== "unknown") await deps.host.announceRemoved(plan.sessionId);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
]).run();
|
|
1612
|
+
return {
|
|
1613
|
+
removed,
|
|
1614
|
+
freedBytes
|
|
1615
|
+
};
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
//#endregion
|
|
1619
|
+
//#region src/verbs/restore-session.ts
|
|
1620
|
+
/**
|
|
1621
|
+
* 恢复 = 把删除那次**按原路退回**,而且按组走:当年是按组一起搬进来的(父 + 它在盘上的孩子),
|
|
1622
|
+
* 只回父就会在回收站里留下一堆点不到的孩子行——清单只列父行,那些行既看不见也清不掉。
|
|
1623
|
+
*
|
|
1624
|
+
* 三段与删除对称:
|
|
1625
|
+
* ① **预检整组**(一个字节都不写):每个成员的回收站目录与项目目录都得在。缺席的判据与原来一致
|
|
1626
|
+
* (回收站目录不在 = `trash-empty`;原项目目录不在 = `project-missing`,且绝不静默建目录)。
|
|
1627
|
+
* 预检在写之前,是为了不让"父已经回到 `sessions/`、孩子因为项目目录没了而回不去"这种半组留在外面。
|
|
1628
|
+
* **工作区账本不在这条硬前提里**:宿主的「删除工作区」只删注册、保留日志,记下的工作区没了就按
|
|
1629
|
+
* 「未分组」收(判据在 {@link attachableWorkspaceId}),照旧回盘——恢复是加法,不该因为一个可选的账目而失败。
|
|
1630
|
+
* ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 → 还原可见性 → 摘清册行。
|
|
1631
|
+
* ③ 中途失败时 journal 把这个成员自己退回回收站;**已经恢复完的**兄弟由整组 catch 按相反顺序一起退回。
|
|
1632
|
+
*
|
|
1633
|
+
* **通告不在这里**:一次批量只该拉一遍宿主会话表、只发一次 `api-session/added`(2026-10-17 那次
|
|
1634
|
+
* "恢复很慢"就是逐条发)。通告归 `verbs/batch.ts`(ADR-0002),所以这个动词的 interface 只有
|
|
1635
|
+
* "一个条目 id 进、回收站那一行出",没有批量专用的开关。
|
|
1636
|
+
*/
|
|
1637
|
+
function restoreSession(deps) {
|
|
1638
|
+
return async function restoreSession(entryId, snapshot) {
|
|
1639
|
+
const all = snapshot ?? await deps.manifest.list();
|
|
1640
|
+
const target = all.find((e) => e.id === entryId);
|
|
1641
|
+
if (!target) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
1642
|
+
const group = groupOf(all, entryId);
|
|
1643
|
+
for (const entry of group) {
|
|
1644
|
+
const trashDir = trashDirOf(entry.id, deps.home);
|
|
1645
|
+
if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
|
|
1646
|
+
const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
|
|
1647
|
+
if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
|
|
1648
|
+
}
|
|
1649
|
+
const done = [];
|
|
1650
|
+
try {
|
|
1651
|
+
for (const entry of group) {
|
|
1652
|
+
const move = await memberRestore(deps, entry);
|
|
1653
|
+
await move.journal.run();
|
|
1654
|
+
done.push(move.journal);
|
|
1655
|
+
}
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
await rollbackJournals(done, error);
|
|
1658
|
+
}
|
|
1659
|
+
return target;
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
/** 放回哪儿:用**删除时记进 entryId 的那段原目录名**,不从 `sessionId` 现推(设计稿 §3)——
|
|
1663
|
+
* 因此不依赖宿主当时的目录命名约定(v3 无前缀 / v4 带 `session-` 前缀)。 */
|
|
1664
|
+
function restoreTargetDir(deps, entry) {
|
|
1665
|
+
return sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* 这一次恢复**到底挂不挂账本**:交出能挂的那个 `workspaceId`,挂不了就 `undefined`。
|
|
1669
|
+
*
|
|
1670
|
+
* 两种缺席是同一件事:空串 = 删除时它就没有账目(「未分组」的会话);非空但账本里已经没它 =
|
|
1671
|
+
* 宿主的「删除工作区」只删注册、保留目录与每一份日志,那些会话之后本来就在「未分组」下。
|
|
1672
|
+
* 恢复不该为了挂一个不存在的账本把整条退回回收站。
|
|
1673
|
+
*
|
|
1674
|
+
* 判据**只有这一处**:`memberRestore` 拿它决定挂不挂,`GET /list` 拿它决定行上那句「原工作区已不存在,
|
|
1675
|
+
* 恢复后归入未分组」说不说。两边各问一遍就会有一天一个说、一个不挂。
|
|
1676
|
+
*/
|
|
1677
|
+
async function attachableWorkspaceId(host, entry) {
|
|
1678
|
+
if (entry.workspaceId === "") return void 0;
|
|
1679
|
+
return await host.workspaceExists(entry.workspaceId) ? entry.workspaceId : void 0;
|
|
1680
|
+
}
|
|
1681
|
+
/** 这一条记着的工作区**是不是已经没了**(区别于"删除时它就没有账目"的空串)——清单行据此决定
|
|
1682
|
+
* 要不要在点之前说那句「原工作区已不存在,恢复后归入未分组」。由 {@link attachableWorkspaceId}
|
|
1683
|
+
* 派生,不另起一份判据:那样会有一天一个说、一个不挂。 */
|
|
1684
|
+
async function isWorkspaceMissing(host, entry) {
|
|
1685
|
+
return entry.workspaceId !== "" && await attachableWorkspaceId(host, entry) === void 0;
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* 一个成员的恢复步骤(顺序与删除相反):放回目录 → 挂账本 → 还原可见性 → 摘清册行。
|
|
1689
|
+
*
|
|
1690
|
+
* 账本那一步有个**只认端口那句**的兜底:查过之后、挂之前账本被删掉(宿主的「删除工作区」与这次恢复
|
|
1691
|
+
* 撞上)⇒ 按未分组收、不回滚;**别的**失败是真故障,照旧抛(吞掉它就等于把一次存储故障伪装成"未分组")。
|
|
1692
|
+
*/
|
|
1693
|
+
async function memberRestore(deps, entry) {
|
|
1694
|
+
const trashDir = trashDirOf(entry.id, deps.home);
|
|
1695
|
+
const sessionDir = restoreTargetDir(deps, entry);
|
|
1696
|
+
const workspaceId = await attachableWorkspaceId(deps.host, entry);
|
|
1697
|
+
/** 真挂上了账本才记下那个 id——回滚里"摘账本"那一步只对挂过的成员存在。 */
|
|
1698
|
+
let attached = false;
|
|
1699
|
+
/** 组级回滚时,目录真的退回回收站了才通告「已移除」(否则那条通告会骗客户端删掉一行还在盘上的会话)。 */
|
|
1700
|
+
let movedBack = false;
|
|
1701
|
+
const journal = createJournal([
|
|
1702
|
+
{
|
|
1703
|
+
label: "放回会话目录失败",
|
|
1704
|
+
run: async () => {
|
|
1705
|
+
await deps.fs.rename(trashDir, sessionDir);
|
|
1706
|
+
},
|
|
1707
|
+
undo: async () => {
|
|
1708
|
+
const failures = await attempt("目录退回回收站失败", () => deps.fs.rename(sessionDir, trashDir));
|
|
1709
|
+
if (failures.length === 0) movedBack = true;
|
|
1710
|
+
return failures;
|
|
1711
|
+
}
|
|
1712
|
+
},
|
|
1713
|
+
...workspaceId === void 0 ? [] : [{
|
|
1714
|
+
label: "挂回账本失败",
|
|
1715
|
+
run: async () => {
|
|
1716
|
+
try {
|
|
1717
|
+
await deps.host.attach(entry.sessionId, workspaceId);
|
|
1718
|
+
attached = true;
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
if (!isWorkspaceMissingError(error) || await deps.host.workspaceExists(workspaceId)) throw error;
|
|
1721
|
+
}
|
|
1722
|
+
},
|
|
1723
|
+
undo: async () => attached ? attempt("摘账本失败", () => deps.host.detach(entry.sessionId, workspaceId)) : []
|
|
1724
|
+
}],
|
|
1725
|
+
...entry.wasArchived ? [] : [{
|
|
1726
|
+
label: "取消归档失败",
|
|
1727
|
+
undoOnFailure: true,
|
|
1728
|
+
run: async () => {
|
|
1729
|
+
await deps.host.unarchive(entry.sessionId);
|
|
1730
|
+
},
|
|
1731
|
+
undo: () => attempt("重新归档失败", () => deps.host.archive(entry.sessionId, workspaceId))
|
|
1732
|
+
}],
|
|
1733
|
+
{
|
|
1734
|
+
label: "摘回收站条目失败",
|
|
1735
|
+
run: async () => {
|
|
1736
|
+
await deps.manifest.remove(entry.id);
|
|
1737
|
+
},
|
|
1738
|
+
undo: () => attempt("清册行写回失败", () => deps.manifest.add(entry))
|
|
1739
|
+
}
|
|
1740
|
+
]);
|
|
1741
|
+
return { journal: {
|
|
1742
|
+
run: () => journal.run(),
|
|
1743
|
+
async undo() {
|
|
1744
|
+
const failures = await journal.undo();
|
|
1745
|
+
if (movedBack) {
|
|
1746
|
+
if (await deps.host.evictLive(entry.sessionId).catch(() => "unknown") !== "unknown") await deps.host.announceRemoved(entry.sessionId).catch(() => {});
|
|
1747
|
+
}
|
|
1748
|
+
return failures;
|
|
1749
|
+
}
|
|
1750
|
+
} };
|
|
1285
1751
|
}
|
|
1286
1752
|
//#endregion
|
|
1287
|
-
//#region src/verbs/
|
|
1753
|
+
//#region src/verbs/batch.ts
|
|
1288
1754
|
/**
|
|
1289
|
-
*
|
|
1755
|
+
* **「这一批」的唯一拥有者**(ADR-0002):`POST /apply` 的落点——一组 id 上的同一个动作。
|
|
1756
|
+
*
|
|
1757
|
+
* 五个动作:`delete` / `restore` / `purge` / `count` / `orphan-clean`。
|
|
1290
1758
|
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
* 单条动作就是 N=1 的批量,**仓里不再留第二条执行路径**。
|
|
1759
|
+
* 为什么批量要有自己的 module:批量是**请求**的性质,不是某一条动词的性质。此前它长在三个动词的
|
|
1760
|
+
* interface 上(`onMembers` / `snapshot` / `suppressAnnounce`),于是每个动词都得知道"我是第几个、
|
|
1761
|
+
* 前面的兄弟带走了谁",同一个事实还有三种机制(回调、调用方 `taken` 集合、动词就地收缩数组)。
|
|
1295
1762
|
*
|
|
1296
|
-
*
|
|
1297
|
-
* ①
|
|
1298
|
-
* ②
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1763
|
+
* 这一层独占四件(每条批量语义都在这里):
|
|
1764
|
+
* ① **去重保序**:输入顺序就是界面"清单从上往下"的读感;
|
|
1765
|
+
* ② **清册快照**:整批只读一次"最初的样子",用来判断"已被同批前一条带走";
|
|
1766
|
+
* ③ **逐条信封**:一条失败不传染下一条,失败按 `map-error` 那张表折成 `{code,message,details?}`;
|
|
1767
|
+
* ④ **一批一次通告**:恢复的 `api-session/added` 攒到批尾、配一次预取的摘要表发(逐条发 = 每条一次
|
|
1768
|
+
* 宿主全表扫,那正是 2026-10-17 "恢复很慢"的头号来源)。
|
|
1769
|
+
*
|
|
1770
|
+
* 「已被同批前一条带走」是**幂等成功**,不是失败:用户要的结果本来就已经达成了(`delete`/`restore`
|
|
1771
|
+
* 回 `null`;`purge` 回 `{removed:0,freedBytes:0}`)。判据读 `trash/groups.ts`(ADR-0001),
|
|
1772
|
+
* 与 `/list` 的装配、与三个动词同一条。
|
|
1303
1773
|
*/
|
|
1304
1774
|
function failure(id, action, error) {
|
|
1305
1775
|
return {
|
|
@@ -1318,21 +1788,118 @@ function success(id, action, value) {
|
|
|
1318
1788
|
value
|
|
1319
1789
|
};
|
|
1320
1790
|
}
|
|
1321
|
-
/**
|
|
1322
|
-
function
|
|
1323
|
-
const
|
|
1324
|
-
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1791
|
+
/** 去重且保持输入顺序(重复 id 跑第二遍是"幂等成功"的伪装,不该发生)。 */
|
|
1792
|
+
function unique(ids) {
|
|
1793
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1794
|
+
const out = [];
|
|
1795
|
+
for (const id of ids) {
|
|
1796
|
+
if (seen.has(id)) continue;
|
|
1797
|
+
seen.add(id);
|
|
1798
|
+
out.push(id);
|
|
1799
|
+
}
|
|
1800
|
+
return out;
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* 一条 id 一条结果:**这是批量唯一的形式**(单条就是长度为 1 的批量,仓里不留第二条执行路径)。
|
|
1804
|
+
* 动词只收一个 id、只吐一条结果;`try/catch` 在这一处,失败不传染下一条。
|
|
1805
|
+
*/
|
|
1806
|
+
async function forEachId(action, ids, run) {
|
|
1807
|
+
const outcomes = [];
|
|
1808
|
+
for (const id of unique(ids)) try {
|
|
1809
|
+
outcomes.push(success(id, action, await run(id)));
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
outcomes.push(failure(id, action, error));
|
|
1812
|
+
}
|
|
1813
|
+
return outcomes;
|
|
1814
|
+
}
|
|
1815
|
+
/** 这一组的行从"当前快照"里摘掉(父行带走孩子后,后到的孩子 id 要认得出"已经不在了")。 */
|
|
1816
|
+
function shrink(current, rows) {
|
|
1817
|
+
for (const row of rows) {
|
|
1818
|
+
const at = current.indexOf(row);
|
|
1819
|
+
if (at >= 0) current.splice(at, 1);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
async function applyDelete(deps, ids) {
|
|
1823
|
+
const run = deleteSession(deps);
|
|
1824
|
+
const carried = /* @__PURE__ */ new Set();
|
|
1825
|
+
return forEachId("delete", ids, async (sessionId) => {
|
|
1826
|
+
const root = bareSessionId(sessionId);
|
|
1827
|
+
if (carried.has(root)) return null;
|
|
1828
|
+
const entry = await run(sessionId);
|
|
1829
|
+
for (const row of groupOf(await deps.manifest.list(), entry.id)) if (bareSessionId(row.sessionId) !== root) carried.add(bareSessionId(row.sessionId));
|
|
1830
|
+
return entry;
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
/** 「这条是不是被同批前一条带走了」:原始快照里认得出、当前快照里已经空了。
|
|
1834
|
+
* **收缩归这一层**(动词只拿只读快照)——`apply` 整批只读一次清册,`takenByEarlierInBatch` 不比它多读。 */
|
|
1835
|
+
async function applyRestore(deps, ids) {
|
|
1836
|
+
const original = await deps.manifest.list();
|
|
1837
|
+
const current = [...original];
|
|
1838
|
+
const run = restoreSession(deps);
|
|
1839
|
+
const restored = [];
|
|
1840
|
+
const outcomes = await forEachId("restore", ids, async (entryId) => {
|
|
1841
|
+
if (takenByEarlierInBatch(original, current, entryId)) return null;
|
|
1842
|
+
const group = groupOf(current, entryId);
|
|
1843
|
+
const entry = await run(entryId, current);
|
|
1844
|
+
restored.push(...group.map((row) => row.sessionId));
|
|
1845
|
+
shrink(current, group);
|
|
1846
|
+
return entry;
|
|
1847
|
+
});
|
|
1848
|
+
if (restored.length > 0) await deps.host.announceRestoredBatch(restored, await deps.host.restoredSummariesOf());
|
|
1849
|
+
return outcomes;
|
|
1850
|
+
}
|
|
1851
|
+
async function applyPurge(deps, ids) {
|
|
1852
|
+
const original = await deps.manifest.list();
|
|
1853
|
+
const current = [...original];
|
|
1854
|
+
const run = purgeTrash(deps);
|
|
1855
|
+
return forEachId("purge", ids, async (entryId) => {
|
|
1856
|
+
if (takenByEarlierInBatch(original, current, entryId)) return {
|
|
1857
|
+
removed: 0,
|
|
1858
|
+
freedBytes: 0
|
|
1859
|
+
};
|
|
1860
|
+
const group = groupOf(current, entryId);
|
|
1861
|
+
const stats = await run(entryId, current);
|
|
1862
|
+
shrink(current, group);
|
|
1863
|
+
return stats;
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
/** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentCounts` 本来就是批量口)。
|
|
1867
|
+
* 宿主口缺席时它回 `{}`(这里折成 0);真读失败则整次调用失败,浏览器半按"读不到"把整句不说。 */
|
|
1868
|
+
async function applyCount(deps, ids) {
|
|
1869
|
+
const wanted = unique(ids);
|
|
1870
|
+
const counts = await deps.host.subagentCounts(wanted);
|
|
1871
|
+
return wanted.map((id) => success(id, "count", counts[id] ?? 0));
|
|
1327
1872
|
}
|
|
1328
1873
|
/**
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1874
|
+
* 清一组无主子会话(父已被彻底删掉、孩子还留在 `sessions/` 里的那些)。id 是**根会话 id**——
|
|
1875
|
+
* 界面先 `GET /orphans` 拿 `roots` 再原样带回来,所以这里的 `not-found` 说的是"这句话已经过期了"
|
|
1876
|
+
* (父又被人放回回收站、组已被上一个请求带走,或另一处已经把目录清掉),不是"用户打错了一个 id"。
|
|
1877
|
+
* 根 id 两边都去 `session-` 前缀再比:报告原样回传是正常路径,外部调用带另一种写法也认。
|
|
1878
|
+
*
|
|
1879
|
+
* **这是写动作,排在到达序链上**,而且**先对账再扫**(2026-10-19):判"父没了"用的是清册,而刚被人
|
|
1880
|
+
* 手工搬进 `session-trash/` 的那一格目录此刻可能还没有对应行——不对账就会把"父还在回收站里等着"的
|
|
1881
|
+
* 一组当成孤儿**直接删盘**,替用户做了一个没人点过的决定(那种组该跟着父恢复或彻底删除)。
|
|
1882
|
+
* 对账写在动词这一侧而不是只写在路由里,为的是 `apply` 的批量入口只有一个:链上的排队与这份快照
|
|
1883
|
+
* 谁也别想只占一半。
|
|
1333
1884
|
*/
|
|
1334
|
-
function
|
|
1335
|
-
|
|
1885
|
+
async function applyOrphanClean(deps, ids) {
|
|
1886
|
+
await reconcileTrash(deps);
|
|
1887
|
+
const groups = await loadOrphanGroups(deps);
|
|
1888
|
+
const byRoot = new Map(groups.map((group) => [bareSessionId(group.root.sessionId), group]));
|
|
1889
|
+
const outcomes = [];
|
|
1890
|
+
for (const id of unique(ids)) {
|
|
1891
|
+
const group = byRoot.get(bareSessionId(id));
|
|
1892
|
+
if (group === void 0) {
|
|
1893
|
+
outcomes.push(failure(id, "orphan-clean", verbError("not-found", `不再是可清理的无主子会话:${id}`)));
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
try {
|
|
1897
|
+
outcomes.push(success(id, "orphan-clean", await cleanOrphanGroup(deps, group)));
|
|
1898
|
+
} catch (error) {
|
|
1899
|
+
outcomes.push(failure(id, "orphan-clean", error));
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return outcomes;
|
|
1336
1903
|
}
|
|
1337
1904
|
function applyActions(deps, action, ids) {
|
|
1338
1905
|
switch (action) {
|
|
@@ -1340,98 +1907,270 @@ function applyActions(deps, action, ids) {
|
|
|
1340
1907
|
case "restore": return applyRestore(deps, ids);
|
|
1341
1908
|
case "purge": return applyPurge(deps, ids);
|
|
1342
1909
|
case "count": return applyCount(deps, ids);
|
|
1910
|
+
case "orphan-clean": return applyOrphanClean(deps, ids);
|
|
1343
1911
|
}
|
|
1344
1912
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1913
|
+
const DEFAULT_LIMITS = {
|
|
1914
|
+
perMessageChars: 3e3,
|
|
1915
|
+
totalChars: 5e5
|
|
1916
|
+
};
|
|
1917
|
+
/** 一条记录里那些 `content[]` 段的文字拼起来(`\n` 分隔;非 text 段一律不参与)。 */
|
|
1918
|
+
function textOf(content) {
|
|
1919
|
+
if (!Array.isArray(content)) return "";
|
|
1920
|
+
return content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
|
|
1921
|
+
}
|
|
1922
|
+
function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
1923
|
+
return async function readTrashDetail(entryId) {
|
|
1924
|
+
const record = (await deps.manifest.list()).find((e) => e.id === entryId);
|
|
1925
|
+
if (record === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
|
|
1926
|
+
const dir = trashDirOf(record.id, deps.home);
|
|
1927
|
+
if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
|
|
1928
|
+
const names = await deps.fs.listFiles(dir);
|
|
1929
|
+
const logName = pickSessionLog(names);
|
|
1930
|
+
if (logName === void 0) throw verbError("io", `回收站目录里没有会话日志:${dir}(里面有:${names.join(", ") || "空目录"})`);
|
|
1931
|
+
let raw;
|
|
1355
1932
|
try {
|
|
1356
|
-
|
|
1357
|
-
members = sessionIds;
|
|
1358
|
-
} });
|
|
1359
|
-
for (const id of members) taken.add(id);
|
|
1360
|
-
outcomes.push(success(sessionId, "delete", entry));
|
|
1933
|
+
raw = await deps.fs.readFile(`${dir}/${logName}`);
|
|
1361
1934
|
} catch (error) {
|
|
1362
|
-
|
|
1935
|
+
throw verbError("io", `读会话日志失败:${String(error)}`, error);
|
|
1363
1936
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1937
|
+
const detail = {
|
|
1938
|
+
entryId: record.id,
|
|
1939
|
+
cwd: "",
|
|
1940
|
+
createdAt: 0,
|
|
1941
|
+
lastActivity: 0,
|
|
1942
|
+
logTitle: "",
|
|
1943
|
+
models: [],
|
|
1944
|
+
turnCount: 0,
|
|
1945
|
+
userMessageCount: 0,
|
|
1946
|
+
assistantMessageCount: 0,
|
|
1947
|
+
toolCallCount: 0,
|
|
1948
|
+
injectedCount: 0,
|
|
1949
|
+
messages: [],
|
|
1950
|
+
omittedByPayloadCap: 0
|
|
1951
|
+
};
|
|
1952
|
+
const models = /* @__PURE__ */ new Set();
|
|
1953
|
+
let usedChars = 0;
|
|
1954
|
+
let capped = false;
|
|
1955
|
+
/** 收进正文,顺带管两个闸:截到单条上限,累计到总量就停。 */
|
|
1956
|
+
function push(role, time, text) {
|
|
1957
|
+
if (capped) {
|
|
1958
|
+
detail.omittedByPayloadCap += 1;
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
const truncated = text.length > limits.perMessageChars;
|
|
1962
|
+
const kept = truncated ? text.slice(0, limits.perMessageChars) : text;
|
|
1963
|
+
if (usedChars + kept.length > limits.totalChars) {
|
|
1964
|
+
capped = true;
|
|
1965
|
+
detail.omittedByPayloadCap += 1;
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
usedChars += kept.length;
|
|
1969
|
+
detail.messages.push({
|
|
1970
|
+
role,
|
|
1971
|
+
time,
|
|
1972
|
+
text: kept,
|
|
1973
|
+
truncated
|
|
1974
|
+
});
|
|
1378
1975
|
}
|
|
1379
1976
|
try {
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1977
|
+
walkSessionLog(raw, (r) => {
|
|
1978
|
+
if (typeof r.time === "number" && r.time > detail.lastActivity) detail.lastActivity = r.time;
|
|
1979
|
+
const data = r.data ?? {};
|
|
1980
|
+
switch (r.type) {
|
|
1981
|
+
case "session":
|
|
1982
|
+
if (typeof r.cwd === "string") detail.cwd = r.cwd;
|
|
1983
|
+
if (typeof r.createdAt === "number") detail.createdAt = r.createdAt;
|
|
1984
|
+
return;
|
|
1985
|
+
case "session/title":
|
|
1986
|
+
if (typeof data.title === "string") detail.logTitle = data.title;
|
|
1987
|
+
return;
|
|
1988
|
+
case "turn/start":
|
|
1989
|
+
detail.turnCount += 1;
|
|
1990
|
+
return;
|
|
1991
|
+
case "tool/call":
|
|
1992
|
+
detail.toolCallCount += 1;
|
|
1993
|
+
return;
|
|
1994
|
+
case "user/message": {
|
|
1995
|
+
if ((data.source ?? {}).kind !== "user") {
|
|
1996
|
+
detail.injectedCount += 1;
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const text = textOf(data.content);
|
|
2000
|
+
if (text === "") return;
|
|
2001
|
+
detail.userMessageCount += 1;
|
|
2002
|
+
push("user", r.time ?? 0, text);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
case "assistant/message": {
|
|
2006
|
+
const message = data.message ?? {};
|
|
2007
|
+
const source = message.source ?? {};
|
|
2008
|
+
if (typeof source.model === "string") models.add(source.model);
|
|
2009
|
+
const text = textOf(message.content);
|
|
2010
|
+
if (text === "") return;
|
|
2011
|
+
detail.assistantMessageCount += 1;
|
|
2012
|
+
push("assistant", r.time ?? 0, text);
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
default: return;
|
|
2016
|
+
}
|
|
1383
2017
|
});
|
|
1384
|
-
restored.push(...(group.length > 0 ? group : [entry]).map((e) => e.sessionId));
|
|
1385
|
-
for (const member of group.length > 0 ? group : [entry]) {
|
|
1386
|
-
const at = snapshot.indexOf(member);
|
|
1387
|
-
if (at >= 0) snapshot.splice(at, 1);
|
|
1388
|
-
}
|
|
1389
|
-
outcomes.push(success(entryId, "restore", entry));
|
|
1390
2018
|
} catch (error) {
|
|
1391
|
-
|
|
2019
|
+
throw verbError("io", error instanceof Error ? `读回收站日志失败:${error.message}` : `读回收站日志失败:${String(error)}`, error);
|
|
1392
2020
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
2021
|
+
detail.models = [...models];
|
|
2022
|
+
return detail;
|
|
2023
|
+
};
|
|
1396
2024
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
2025
|
+
//#endregion
|
|
2026
|
+
//#region src/api/trash-rows.ts
|
|
2027
|
+
/**
|
|
2028
|
+
* 清册那一行 → 线上那一行。**显式字面量、不用 `...row`**:线上形状多一个必填字段时,这里编译不过,
|
|
2029
|
+
* 于是"线上多了一格却没人填"不可能悄悄发生(`...row` 会把它带过去,看上去一切正常)。
|
|
2030
|
+
*/
|
|
2031
|
+
function toWire(row, readouts = {}) {
|
|
2032
|
+
return {
|
|
2033
|
+
id: row.id,
|
|
2034
|
+
sessionId: row.sessionId,
|
|
2035
|
+
projectDir: row.projectDir,
|
|
2036
|
+
workspaceId: row.workspaceId,
|
|
2037
|
+
wasArchived: row.wasArchived,
|
|
2038
|
+
deletedAt: row.deletedAt,
|
|
2039
|
+
title: row.title,
|
|
2040
|
+
sizeBytes: row.sizeBytes,
|
|
2041
|
+
releaseBytes: row.sizeBytes + (readouts.subagentBytes ?? 0) + (readouts.strandedBytes ?? 0),
|
|
2042
|
+
...row.parentEntryId === void 0 ? {} : { parentEntryId: row.parentEntryId },
|
|
2043
|
+
...readouts.subagentCount === void 0 ? {} : { subagentCount: readouts.subagentCount },
|
|
2044
|
+
...readouts.subagentBytes === void 0 ? {} : { subagentBytes: readouts.subagentBytes },
|
|
2045
|
+
...readouts.strandedSubagents === void 0 ? {} : { strandedSubagents: readouts.strandedSubagents },
|
|
2046
|
+
...readouts.strandedBytes === void 0 ? {} : { strandedBytes: readouts.strandedBytes },
|
|
2047
|
+
...readouts.workspaceMissing === void 0 ? {} : { workspaceMissing: readouts.workspaceMissing }
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* 把清册的全部行装配成「一行 = 一组」:孩子折进父那一行、不单独列(宿主的客户端本来就藏孩子,
|
|
2052
|
+
* 我们若单列就会冒出一堆点不到的行);父行不在时孩子自己站出来占一行,否则它既看不见也清不掉。
|
|
2053
|
+
* 这些派生值只加在这次回信上,**不落清册**——清册该记的是文件里真有的事实,而"还剩几个孩子"
|
|
2054
|
+
* "工作区还在不在"是随盘上随时变的读数。取数失败照抛(响亮失败,不端一份少了这栏的清单当没事发生)。
|
|
2055
|
+
*/
|
|
2056
|
+
async function assembleTrashRows(deps, rows) {
|
|
2057
|
+
const groups = visibleGroups(rows);
|
|
2058
|
+
const visible = groups.map((group) => group.root);
|
|
2059
|
+
const [membersByParent, missingByRowId] = await Promise.all([deps.host.subagentMembersBatch(visible.map((row) => row.sessionId)), Promise.all(visible.map(async (row) => [row.id, await isWorkspaceMissing(deps.host, row)]))]);
|
|
2060
|
+
const gone = new Map(missingByRowId);
|
|
2061
|
+
return await Promise.all(groups.map(async ({ root, kids }) => {
|
|
2062
|
+
const kidsInGroup = new Set(kids.map((kid) => bareSessionId(kid.sessionId)));
|
|
2063
|
+
let count = 0;
|
|
2064
|
+
let bytes = 0;
|
|
2065
|
+
for (const member of membersByParent[root.sessionId] ?? []) {
|
|
2066
|
+
if (kidsInGroup.has(bareSessionId(member.sessionId))) continue;
|
|
2067
|
+
const measured = await measureMemberDir(deps, member);
|
|
2068
|
+
if (measured === void 0) continue;
|
|
2069
|
+
count += 1;
|
|
2070
|
+
bytes += measured.bytes;
|
|
1414
2071
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
2072
|
+
const missing = gone.get(root.id) === true;
|
|
2073
|
+
return toWire(root, {
|
|
2074
|
+
...kids.length === 0 ? {} : {
|
|
2075
|
+
subagentCount: kids.length,
|
|
2076
|
+
subagentBytes: kids.reduce((total, kid) => total + kid.sizeBytes, 0)
|
|
2077
|
+
},
|
|
2078
|
+
...count === 0 ? {} : {
|
|
2079
|
+
strandedSubagents: count,
|
|
2080
|
+
strandedBytes: bytes
|
|
2081
|
+
},
|
|
2082
|
+
...missing ? { workspaceMissing: true } : {}
|
|
2083
|
+
});
|
|
2084
|
+
}));
|
|
1417
2085
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
2086
|
+
//#endregion
|
|
2087
|
+
//#region src/api/route-table.ts
|
|
2088
|
+
/**
|
|
2089
|
+
* **路由表**(ADR-0004):段名 → `{ 方法, 纯度, body, 处理 }`。一条记录,两个消费者。
|
|
2090
|
+
*
|
|
2091
|
+
* 此前"同一条请求该过哪几道判断"的知识散在两个函数里:`dispatch()` 里六个 `if` 各调一次
|
|
2092
|
+
* `requireMethod`,`purityOf()` 里另一组 `if` 决定"要不要进到达序链",两处靠**注释**维持一致。
|
|
2093
|
+
* 不一致的两种后果都不好在测试里撞见:
|
|
2094
|
+
* - 该纯的判成写 ⇒ 一次探测/大正文读/扫描把删除按钮堵在串行链后面;
|
|
2095
|
+
* - 该写的判成纯 ⇒ 写动作绕开到达序链,与对账交错,回收站出现"目录在、行是降级元数据"的世界;
|
|
2096
|
+
* - body 该读没读 ⇒ 下游拿到 `null`;读了两遍 ⇒ 请求流已被消费(Node 的流只能消费一次)。
|
|
2097
|
+
*
|
|
2098
|
+
* 为什么这张表**不在** `shared/wire.ts`:处理函数会 import 动词与 `node:fs`,而浏览器半要 import
|
|
2099
|
+
* `wire.ts`——值 import 会把 Node 半拖进客户端 bundle,正好撞上 `tsdown` 的纯度门。段名与动作仍住
|
|
2100
|
+
* `wire.ts`(两半的共同契约),这张表只回答"怎么处理"。
|
|
2101
|
+
*/
|
|
2102
|
+
/** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
|
|
2103
|
+
function requireString(body, field) {
|
|
2104
|
+
const value = body?.[field];
|
|
2105
|
+
if (typeof value !== "string" || value === "") throw routeError("bad-request", 400, `请求体需带非空字符串字段 ${field}`);
|
|
2106
|
+
return value;
|
|
1424
2107
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
2108
|
+
const APPLY_ACTIONS = Object.keys({
|
|
2109
|
+
delete: true,
|
|
2110
|
+
restore: true,
|
|
2111
|
+
purge: true,
|
|
2112
|
+
count: true,
|
|
2113
|
+
"orphan-clean": true
|
|
2114
|
+
});
|
|
2115
|
+
/** `/apply` 的动作:认不得的一律点名拒掉,不折成"空批量"糊弄过去。 */
|
|
2116
|
+
function requireAction(body) {
|
|
2117
|
+
const value = body?.[PARAMS.action];
|
|
2118
|
+
if (typeof value !== "string" || !APPLY_ACTIONS.includes(value)) throw routeError("bad-request", 400, `批量动作不认识:需是 ${APPLY_ACTIONS.join(" / ")} 之一`);
|
|
2119
|
+
return value;
|
|
2120
|
+
}
|
|
2121
|
+
/** `/apply` 的 id 组:必须是非空字符串数组(空数组不是"什么都不做",是调用方自己没组好)。 */
|
|
2122
|
+
function requireIdArray(body) {
|
|
2123
|
+
const value = body?.[PARAMS.ids];
|
|
2124
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((id) => typeof id !== "string" || id === "")) throw routeError("bad-request", 400, `请求体需带 ${PARAMS.ids}:非空字符串组成的非空数组`);
|
|
2125
|
+
return value;
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* 六个段名各一行。`Record<段名, RouteSpec>` 是闸:`wire.ts` 加了段名而这里没加,编译不过。
|
|
2129
|
+
*/
|
|
2130
|
+
const ROUTE_TABLE = {
|
|
2131
|
+
[ROUTES.list]: {
|
|
2132
|
+
method: "GET",
|
|
2133
|
+
body: "none",
|
|
2134
|
+
purity: "chained",
|
|
2135
|
+
run: async (deps) => assembleTrashRows(deps, await reconcileTrash(deps))
|
|
2136
|
+
},
|
|
2137
|
+
[ROUTES.ping]: {
|
|
2138
|
+
method: "GET",
|
|
2139
|
+
body: "none",
|
|
2140
|
+
purity: "pure",
|
|
2141
|
+
run: async () => "pong"
|
|
2142
|
+
},
|
|
2143
|
+
[ROUTES.orphans]: {
|
|
2144
|
+
method: "GET",
|
|
2145
|
+
body: "none",
|
|
2146
|
+
purity: "pure",
|
|
2147
|
+
run: async (deps) => reportOrphans(await loadOrphanGroups(deps))
|
|
2148
|
+
},
|
|
2149
|
+
[ROUTES.apply]: {
|
|
2150
|
+
method: "POST",
|
|
2151
|
+
body: "json",
|
|
2152
|
+
purity: (body) => body?.[PARAMS.action] === "count" ? "pure" : "chained",
|
|
2153
|
+
run: async (deps, body) => applyActions(deps, requireAction(body), requireIdArray(body))
|
|
2154
|
+
},
|
|
2155
|
+
[ROUTES.empty]: {
|
|
2156
|
+
method: "POST",
|
|
2157
|
+
body: "none",
|
|
2158
|
+
purity: "chained",
|
|
2159
|
+
run: async (deps) => {
|
|
2160
|
+
const rows = await reconcileTrash(deps);
|
|
2161
|
+
return await purgeTrash(deps)(void 0, rows);
|
|
2162
|
+
}
|
|
2163
|
+
},
|
|
2164
|
+
[ROUTES.detail]: {
|
|
2165
|
+
method: "POST",
|
|
2166
|
+
body: "json",
|
|
2167
|
+
purity: "pure",
|
|
2168
|
+
run: async (deps, body) => readTrashDetail(deps)(requireString(body, PARAMS.entryId))
|
|
1433
2169
|
}
|
|
1434
|
-
|
|
2170
|
+
};
|
|
2171
|
+
/** 段名 → 那一行;不认识的段名回 `undefined`(调用方抛 404,并带出原路径)。 */
|
|
2172
|
+
function routeOf(rest) {
|
|
2173
|
+
return ROUTE_TABLE[rest];
|
|
1435
2174
|
}
|
|
1436
2175
|
//#endregion
|
|
1437
2176
|
//#region src/api/dispatch.ts
|
|
@@ -1457,7 +2196,7 @@ const DESKTOP_APP_HOST = "app";
|
|
|
1457
2196
|
/**
|
|
1458
2197
|
* 「这是桌面壳自己的页面吗」。**必须按 `protocol` + `host` 判,不能比 `URL.origin`**:`dsh-app:`
|
|
1459
2198
|
* 不是 WHATWG 的 special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`(Node 实测),
|
|
1460
|
-
* 拿它跟字面量比永远为假 ⇒ 栅栏会把真机上的每一次请求都挡成 403
|
|
2199
|
+
* 拿它跟字面量比永远为假 ⇒ 栅栏会把真机上的每一次请求都挡成 403,所有入口一个都不出现。
|
|
1461
2200
|
*
|
|
1462
2201
|
* 这个判据为什么不可能被外人伪造:`Origin` 与 `Referer` 都是 forbidden header,网页改不了,
|
|
1463
2202
|
* 只能是浏览器/壳自己带上;而 `dsh-app://app` 只可能是桌面壳自己的页面。
|
|
@@ -1483,10 +2222,9 @@ function writeOk(res, value) {
|
|
|
1483
2222
|
/** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
|
|
1484
2223
|
* **状态语义(2026-10-17 错误面对齐)**:**请求本身不对**不再伪装成 200——请求体不对 400、方法不对 405、
|
|
1485
2224
|
* 未知路由 404、栅栏拒 403(它们不是"一次办成的操作",是"这条请求不归我办/你没资格办")。
|
|
1486
|
-
* **操作失败**仍回 200 +
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
* 不许冒充 §6 的五个动词码上客户端。 */
|
|
2225
|
+
* **操作失败**仍回 200 + 信封。浏览器半先解信封再看状态,所以 4xx 上带的信封照样读得出原文;
|
|
2226
|
+
* `code` 一律折成 `sessiondelete/io`,那三个路由标签(`bad-request` / `bad-method` / `not-found-route`)
|
|
2227
|
+
* 是**抛出处给读代码的人看的站点名**,不许冒充 §6 的五个动词码上客户端。 */
|
|
1490
2228
|
function writeFailure(res, error, status) {
|
|
1491
2229
|
const carried = error?.httpStatus;
|
|
1492
2230
|
writeJson(res, status ?? (typeof carried === "number" ? carried : 200), {
|
|
@@ -1504,8 +2242,9 @@ function writeFailure(res, error, status) {
|
|
|
1504
2242
|
* 壳把 `Origin` 删掉了、只留下 Referer,见 {@link isDesktopShellUrl})。两者都缺席 = 本机工具 /
|
|
1505
2243
|
* curl(注释即此承诺)。**恶意网页拿不到 ②③ 里的任何一个**:那两个头都是 forbidden header、
|
|
1506
2244
|
* 页面伪造不了;而**写口(POST)在任何跨源形态下都必带 Origin** ⇒ 两个头全缺席就等于本机工具。
|
|
1507
|
-
* 唯一的缝是跨源 `no-cors` 的 **GET**(Referer 被 `no-referrer` 掐掉、Origin
|
|
1508
|
-
*
|
|
2245
|
+
* 唯一的缝是跨源 `no-cors` 的 **GET**(Referer 被 `no-referrer` 掐掉、Origin 也不带)——它够得到
|
|
2246
|
+
* 那几个 GET(`list` 会先对账写清册,`ping` / `orphans` 才是纯读),而 no-cors 的回包是 opaque、
|
|
2247
|
+
* 页面读不出内容 ⇒ 不构成泄露(`orphans` 那份报告也只报数量/字节,不含正文)。
|
|
1509
2248
|
*/
|
|
1510
2249
|
function isTrustedRequest(req) {
|
|
1511
2250
|
if (!LOOPBACK.includes(req.socket.remoteAddress ?? "")) return false;
|
|
@@ -1548,117 +2287,20 @@ async function readJsonBody(req) {
|
|
|
1548
2287
|
throw error;
|
|
1549
2288
|
}
|
|
1550
2289
|
}
|
|
1551
|
-
/** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
|
|
1552
|
-
function requireString(body, field) {
|
|
1553
|
-
const value = body?.[field];
|
|
1554
|
-
if (typeof value !== "string" || value === "") throw routeError("bad-request", 400, `请求体需带非空字符串字段 ${field}`);
|
|
1555
|
-
return value;
|
|
1556
|
-
}
|
|
1557
|
-
/** `/apply` 的动作:认不得的一律点名拒掉,不折成"空批量"糊弄过去。 */
|
|
1558
|
-
const APPLY_ACTIONS = [
|
|
1559
|
-
"delete",
|
|
1560
|
-
"restore",
|
|
1561
|
-
"purge",
|
|
1562
|
-
"count"
|
|
1563
|
-
];
|
|
1564
|
-
function requireAction(body) {
|
|
1565
|
-
const value = body?.[PARAMS.action];
|
|
1566
|
-
if (typeof value !== "string" || !APPLY_ACTIONS.includes(value)) throw routeError("bad-request", 400, `批量动作不认识:需是 ${APPLY_ACTIONS.join(" / ")} 之一`);
|
|
1567
|
-
return value;
|
|
1568
|
-
}
|
|
1569
|
-
/** `/apply` 的 id 组:必须是非空字符串数组(空数组不是"什么都不做",是调用方自己没组好)。 */
|
|
1570
|
-
function requireIdArray(body) {
|
|
1571
|
-
const value = body?.[PARAMS.ids];
|
|
1572
|
-
if (!Array.isArray(value) || value.length === 0 || value.some((id) => typeof id !== "string" || id === "")) throw routeError("bad-request", 400, `请求体需带 ${PARAMS.ids}:非空字符串组成的非空数组`);
|
|
1573
|
-
return value;
|
|
1574
|
-
}
|
|
1575
2290
|
function requireMethod(method, want, route) {
|
|
1576
2291
|
if (method !== want) throw routeError("bad-method", 405, `方法不允许:${method}(${SESSION_DELETE_API_PREFIX}${route} 需 ${want})`);
|
|
1577
2292
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
* 我们若单列就会冒出一堆点不到的行);父行不在时孩子自己站出来占一行,否则它既看不见也清不掉。
|
|
1581
|
-
* 三个只读数只加在这次回信上,**不落清册**——清册该记的是
|
|
1582
|
-
* 文件里真有的事实,而"还剩几个孩子"是随盘上随时变的一个数。取数失败照抛(响亮失败,不端一份
|
|
1583
|
-
* 少了这栏的清单当没事发生)。
|
|
1584
|
-
*/
|
|
1585
|
-
async function assembleTrashRows(deps, rows) {
|
|
1586
|
-
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
1587
|
-
const foldedInto = /* @__PURE__ */ new Map();
|
|
1588
|
-
const kidsByParent = /* @__PURE__ */ new Map();
|
|
1589
|
-
for (const row of rows) {
|
|
1590
|
-
if (row.parentEntryId === void 0) continue;
|
|
1591
|
-
const parent = byId.get(row.parentEntryId);
|
|
1592
|
-
if (parent === void 0 || parent.parentEntryId !== void 0) continue;
|
|
1593
|
-
foldedInto.set(row.id, parent.id);
|
|
1594
|
-
const siblings = kidsByParent.get(parent.id);
|
|
1595
|
-
if (siblings === void 0) kidsByParent.set(parent.id, [row]);
|
|
1596
|
-
else siblings.push(row);
|
|
1597
|
-
}
|
|
1598
|
-
const visible = rows.filter((row) => !foldedInto.has(row.id));
|
|
1599
|
-
const stranded = await deps.host.subagentCounts(visible.map((row) => row.sessionId));
|
|
1600
|
-
return visible.map((row) => {
|
|
1601
|
-
const kids = kidsByParent.get(row.id);
|
|
1602
|
-
const left = stranded[row.sessionId];
|
|
1603
|
-
if (kids === void 0 && left === void 0) return row;
|
|
1604
|
-
return {
|
|
1605
|
-
...row,
|
|
1606
|
-
...kids === void 0 ? {} : {
|
|
1607
|
-
subagentCount: kids.length,
|
|
1608
|
-
subagentBytes: kids.reduce((total, kid) => total + kid.sizeBytes, 0)
|
|
1609
|
-
},
|
|
1610
|
-
...left === void 0 ? {} : { strandedSubagents: left }
|
|
1611
|
-
};
|
|
1612
|
-
});
|
|
1613
|
-
}
|
|
1614
|
-
/** 路由表:段名 → 一个把「方法 + 预读好的请求体」变成返回值的处理器。动词在每次调用时才取。
|
|
1615
|
-
* `body` 由 {@link purityOf} 读一次交下来——同一条请求的流不能被消费两遍。 */
|
|
1616
|
-
async function dispatch(method, rest, body, deps) {
|
|
1617
|
-
if (rest === ROUTES.list) {
|
|
1618
|
-
requireMethod(method, "GET", ROUTES.list);
|
|
1619
|
-
return assembleTrashRows(deps, await reconcileTrash(deps));
|
|
1620
|
-
}
|
|
1621
|
-
if (rest === ROUTES.ping) {
|
|
1622
|
-
requireMethod(method, "GET", ROUTES.ping);
|
|
1623
|
-
return "pong";
|
|
1624
|
-
}
|
|
1625
|
-
if (rest === ROUTES.apply) {
|
|
1626
|
-
requireMethod(method, "POST", ROUTES.apply);
|
|
1627
|
-
return await applyActions(deps, requireAction(body), requireIdArray(body));
|
|
1628
|
-
}
|
|
1629
|
-
if (rest === ROUTES.empty) {
|
|
1630
|
-
requireMethod(method, "POST", ROUTES.empty);
|
|
1631
|
-
return await purgeTrash(deps)();
|
|
1632
|
-
}
|
|
1633
|
-
if (rest === ROUTES.detail) {
|
|
1634
|
-
requireMethod(method, "POST", ROUTES.detail);
|
|
1635
|
-
return await readTrashDetail(deps)(requireString(body, PARAMS.entryId));
|
|
1636
|
-
}
|
|
1637
|
-
throw routeError("not-found-route", 404, `未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
|
|
2293
|
+
function isPure(purity, body) {
|
|
2294
|
+
return typeof purity === "function" ? purity(body) === "pure" : purity === "pure";
|
|
1638
2295
|
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
pure: true,
|
|
1642
|
-
body: null
|
|
1643
|
-
};
|
|
1644
|
-
if (rest === ROUTES.detail) return {
|
|
1645
|
-
pure: true,
|
|
1646
|
-
body: await readJsonBody(req)
|
|
1647
|
-
};
|
|
1648
|
-
if (rest !== ROUTES.apply) return {
|
|
1649
|
-
pure: false,
|
|
1650
|
-
body: null
|
|
1651
|
-
};
|
|
1652
|
-
const body = await readJsonBody(req);
|
|
1653
|
-
return {
|
|
1654
|
-
pure: body?.[PARAMS.action] === "count",
|
|
1655
|
-
body
|
|
1656
|
-
};
|
|
2296
|
+
function readBodyFor(kind, req) {
|
|
2297
|
+
return kind === "json" ? readJsonBody(req) : null;
|
|
1657
2298
|
}
|
|
1658
2299
|
function createApiHandler(deps) {
|
|
1659
2300
|
/**
|
|
1660
|
-
* 到达序串行链(文件头
|
|
1661
|
-
*
|
|
2301
|
+
* 到达序串行链(文件头 ⑤):路由表里 `purity === 'chained'` 的请求一个跑完再放下一个。
|
|
2302
|
+
* `tail` 由构造保证**永不 reject**(每个任务自己的失败留在交回给那次请求的那个 promise 上),
|
|
2303
|
+
* 所以一次失败不会卡死链。
|
|
1662
2304
|
*/
|
|
1663
2305
|
let tail = Promise.resolve();
|
|
1664
2306
|
const throughChain = (task) => {
|
|
@@ -1675,9 +2317,12 @@ function createApiHandler(deps) {
|
|
|
1675
2317
|
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
1676
2318
|
if (!url.pathname.startsWith("/dsh-session-delete-api")) throw routeError("not-found-route", 404, `未知路由:${url.pathname}`);
|
|
1677
2319
|
const rest = url.pathname.slice(23);
|
|
1678
|
-
const
|
|
1679
|
-
|
|
1680
|
-
|
|
2320
|
+
const spec = routeOf(rest);
|
|
2321
|
+
if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${req.method ?? "GET"} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
|
|
2322
|
+
requireMethod(req.method ?? "GET", spec.method, rest);
|
|
2323
|
+
const body = await readBodyFor(spec.body, req);
|
|
2324
|
+
const task = () => spec.run(deps, body);
|
|
2325
|
+
writeOk(res, await (isPure(spec.purity, body) ? task() : throughChain(task)));
|
|
1681
2326
|
} catch (error) {
|
|
1682
2327
|
writeFailure(res, error);
|
|
1683
2328
|
}
|
|
@@ -1749,12 +2394,13 @@ const name = "@xrn1997/dsh-session-delete";
|
|
|
1749
2394
|
* 三项都是在隔离数据根上实测注入成功、且本插件真正用到的宿主服务。三个**故意不写**的:
|
|
1750
2395
|
* - `remote`:那是**浏览器半**的服务(asar `dsh-api-gateway/lib/client.js`:`super(ctx, "remote")`),
|
|
1751
2396
|
* Node 半挂的是 `ctx.typertGateway`。把它写进 Node 半的 inject 会让插件永远 pending(同 `slots` 那次的形状)。
|
|
1752
|
-
* - `sessions` / `sessionProjectionCache
|
|
1753
|
-
*
|
|
2397
|
+
* - `sessions` / `sessionProjectionCache`:两者都按 `ctx.get(...)` **可选读**——`sessions` 只给 `evictLive`
|
|
2398
|
+
* 用(活跃判据走 waterfall,见设计稿 §5;服务缺席回 `unknown`),`sessionProjectionCache` 是标题的尽力读面。
|
|
2399
|
+
* inject 一个可能没挂的服务 = 整个插件 pending。
|
|
1754
2400
|
* 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
|
|
1755
2401
|
*
|
|
1756
2402
|
* `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
|
|
1757
|
-
* 前缀路由,浏览器半同源 fetch
|
|
2403
|
+
* 前缀路由,浏览器半同源 fetch 那六个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
|
|
1758
2404
|
* `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
|
|
1759
2405
|
* `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
|
|
1760
2406
|
*/
|
|
@@ -1791,19 +2437,6 @@ function defineDomain(spec) {
|
|
|
1791
2437
|
for (const table of Object.keys(spec.tables)) if (!UNIT_NAME_RE.test(table)) throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`);
|
|
1792
2438
|
return spec;
|
|
1793
2439
|
}
|
|
1794
|
-
/** `TrashEntry` 里**每条记录都必须有**的那几格(`src/trash/entry.ts` 接口的非可选字段)。
|
|
1795
|
-
* **可选字段不在这里**:`parentEntryId`(级联那批加的)缺席是合法的——老行没有它,缺了就是"没有父";
|
|
1796
|
-
* 校验器只管这几格的类型,其余键**原样带走**(`parse` 交回的就是 `raw` 本身)⇒ 新字段照旧存得住。 */
|
|
1797
|
-
const TRASH_ENTRY_FIELDS = {
|
|
1798
|
-
id: "string",
|
|
1799
|
-
sessionId: "string",
|
|
1800
|
-
projectDir: "string",
|
|
1801
|
-
workspaceId: "string",
|
|
1802
|
-
wasArchived: "boolean",
|
|
1803
|
-
deletedAt: "number",
|
|
1804
|
-
title: "string",
|
|
1805
|
-
sizeBytes: "number"
|
|
1806
|
-
};
|
|
1807
2440
|
/**
|
|
1808
2441
|
* 回收站清单的域声明。
|
|
1809
2442
|
*
|
|
@@ -1818,7 +2451,7 @@ const trashDomainSpec = defineDomain({
|
|
|
1818
2451
|
invalidRecords: "backup-and-skip",
|
|
1819
2452
|
tables: { entries: domainTable({ parse(raw) {
|
|
1820
2453
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new TypeError("session trash entry record must be a plain object");
|
|
1821
|
-
for (const [field, kind] of Object.entries(
|
|
2454
|
+
for (const [field, kind] of Object.entries(REQUIRED_ENTRY_FIELDS)) if (typeof raw[field] !== kind) throw new TypeError(`session trash entry record field "${field}" must be a ${kind}`);
|
|
1822
2455
|
return raw;
|
|
1823
2456
|
} }) }
|
|
1824
2457
|
});
|