@xrn1997/dsh-session-delete 0.3.1 → 0.5.0
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/LICENSE +201 -201
- package/README.md +141 -129
- package/cordis.patch.yml +3 -3
- package/lib/client.js +2811 -1264
- package/lib/index.js +2368 -1277
- package/lib/types/api/attachment-orphans.d.ts +11 -0
- package/lib/types/api/attachment.d.ts +23 -0
- package/lib/types/api/dispatch.d.ts +13 -29
- package/lib/types/api/request-trust.d.ts +45 -0
- package/lib/types/api/route-table.d.ts +52 -18
- package/lib/types/api/trash-rows.d.ts +3 -3
- package/lib/types/client/attachment-view.d.ts +27 -0
- package/lib/types/client/batch-bar.d.ts +1 -1
- package/lib/types/client/batch-confirm.d.ts +2 -2
- package/lib/types/client/batch-menu-item.d.ts +1 -1
- package/lib/types/client/batch-select.d.ts +1 -1
- package/lib/types/client/busy-gate.d.ts +6 -2
- package/lib/types/client/confirm-close.d.ts +14 -0
- package/lib/types/client/context.d.ts +3 -2
- package/lib/types/client/copy.d.ts +102 -42
- package/lib/types/client/delete-confirm.d.ts +1 -3
- package/lib/types/client/delete-menu-item.d.ts +1 -1
- package/lib/types/client/host-rows.d.ts +5 -1
- package/lib/types/client/main-view.d.ts +1 -1
- package/lib/types/client/maintenance-parts.d.ts +25 -0
- package/lib/types/client/maintenance-settings.d.ts +22 -0
- package/lib/types/client/markdown.d.ts +9 -2
- package/lib/types/client/orphan-maintenance.d.ts +16 -13
- package/lib/types/client/receipt.d.ts +44 -2
- package/lib/types/client/remote.d.ts +38 -50
- package/lib/types/client/store.d.ts +14 -3
- package/lib/types/client/trash-confirm.d.ts +2 -15
- package/lib/types/client/trash-detail.d.ts +48 -6
- package/lib/types/client/trash-entry.d.ts +1 -1
- package/lib/types/client/trash-nav.d.ts +12 -8
- package/lib/types/client/trash-panel.d.ts +4 -3
- package/lib/types/client/ui/Checkbox.d.ts +1 -1
- package/lib/types/client/ui/Modal.d.ts +2 -2
- package/lib/types/client/ui/Toast.d.ts +3 -1
- package/lib/types/client/ui/icons.d.ts +7 -12
- package/lib/types/client/ui/top-layer.d.ts +10 -0
- package/lib/types/client/undo-notices.d.ts +52 -0
- package/lib/types/client/undo-toast.d.ts +6 -59
- package/lib/types/client/use-column-resize.d.ts +9 -20
- package/lib/types/client/use-maintenance-section.d.ts +52 -0
- package/lib/types/client/use-maintenance.d.ts +17 -0
- package/lib/types/client/use-panel-width.d.ts +1 -1
- package/lib/types/client/use-trash-panel-controller.d.ts +54 -16
- package/lib/types/client/use-trash-query.d.ts +39 -34
- package/lib/types/client/use-trash-selection.d.ts +15 -12
- package/lib/types/client/write-seats.d.ts +36 -28
- package/lib/types/client/write-settlement.d.ts +11 -17
- package/lib/types/client/write.d.ts +21 -8
- package/lib/types/deps.d.ts +18 -7
- package/lib/types/domain/closure.d.ts +44 -0
- package/lib/types/index.d.ts +6 -2
- package/lib/types/log/session-log.d.ts +4 -3
- package/lib/types/log/zstd-frames.d.ts +13 -8
- package/lib/types/ports/agent-handles.d.ts +1 -1
- package/lib/types/ports/connection-port.d.ts +33 -0
- package/lib/types/ports/fs-port.d.ts +24 -9
- package/lib/types/ports/host-port.d.ts +21 -50
- package/lib/types/ports/presence-port.d.ts +31 -0
- package/lib/types/shared/wire.d.ts +175 -38
- package/lib/types/trash/attachments.d.ts +92 -0
- package/lib/types/trash/detail.d.ts +46 -16
- package/lib/types/trash/entry.d.ts +10 -4
- package/lib/types/trash/groups.d.ts +9 -11
- package/lib/types/trash/manifest.d.ts +4 -4
- package/lib/types/trash/storage-manifest.d.ts +1 -1
- package/lib/types/verbs/batch-carried.d.ts +1 -3
- package/lib/types/verbs/delete-session.d.ts +2 -2
- package/lib/types/verbs/errors.d.ts +2 -0
- package/lib/types/verbs/live-admission.d.ts +4 -7
- package/lib/types/verbs/member-journal.d.ts +3 -4
- package/lib/types/verbs/member-presence.d.ts +16 -37
- package/lib/types/verbs/move-group.d.ts +2 -1
- package/lib/types/verbs/orphan-subagents.d.ts +19 -12
- package/lib/types/verbs/permanent-removal.d.ts +60 -25
- package/lib/types/verbs/restore-session.d.ts +7 -13
- package/lib/types/verbs/trash-scope.d.ts +36 -0
- package/package.json +117 -106
- package/lib/types/verbs/purge-trash.d.ts +0 -30
package/lib/index.js
CHANGED
|
@@ -1,24 +1,15 @@
|
|
|
1
|
+
import path, { join, resolve } from "node:path";
|
|
1
2
|
import zlib from "node:zlib";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
//#region src/shared/wire.ts
|
|
7
7
|
/**
|
|
8
|
-
* 跨半 wire
|
|
9
|
-
* Node
|
|
10
|
-
* 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
|
|
8
|
+
* 跨半 wire 契约:前缀、九个端点、请求/响应形状与失败码。
|
|
9
|
+
* Node 半(src/api/dispatch.ts)与浏览器半(src/client/remote.ts)共用这一份,不各写字面量。
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 本文件两者都不涉及,且**零运行时依赖**(client 半把它整个 inline 进 bundle);
|
|
15
|
-
* ② 跨插件一律 `import type`——本文件干脆没有 import。
|
|
16
|
-
*
|
|
17
|
-
* 信封(`RemoteResult`)与失败形状(`RemoteFailure`)也在这里——**因为它们本来就是两半的共同契约**:
|
|
18
|
-
* Node 半按它产出(`src/api/dispatch.ts` 的失败支就是 `RemoteFailure` 那三件),浏览器半按它判别
|
|
19
|
-
* (`src/client/remote.ts` 的 `unwrapRemoteResult` / `failureOf`)。形状取自宿主网关客户端面的原文
|
|
20
|
-
* 「每次 unary 调用 resolve 成 RemoteResult、不为载体问题 reject」,但**它是本仓自己的 wire 契约**,
|
|
21
|
-
* 不是对宿主模块的 import。
|
|
11
|
+
* 构建约束:本文件零运行时依赖、无 import;浏览器半把整个文件 inline 进 bundle。
|
|
12
|
+
* 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
|
|
22
13
|
*/
|
|
23
14
|
/**
|
|
24
15
|
* 插件专属前缀。
|
|
@@ -28,33 +19,79 @@ import { homedir } from "node:os";
|
|
|
28
19
|
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
29
20
|
*/
|
|
30
21
|
const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* - `
|
|
38
|
-
* - `
|
|
39
|
-
*
|
|
22
|
+
/** 九个段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
|
|
23
|
+
* (ADR-0003),家就在这一张表——路由记录(`src/api/route-table.ts`)逐行投影(`ROUTES.x.method`),
|
|
24
|
+
* 浏览器半的 GET 段同样投影、写段把 `ROUTES.x.method` 交给 `src/client/remote.ts` 的 `post()`,
|
|
25
|
+
* 两半都不逐段手写一份。
|
|
26
|
+
* 写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
|
|
27
|
+
* 单条就是长度为 1 的批量,仓里不留第二条执行路径。
|
|
28
|
+
* - `list`:对账并读清单(`session-trash/` 里的目录是准)。
|
|
29
|
+
* - `ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
|
|
30
|
+
* - `detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts` 的 detail 行)。
|
|
31
|
+
* - `apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
|
|
32
|
+
* - `empty`:清空回收站(聚合回执,不按条回)。
|
|
33
|
+
* - `orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
|
|
34
|
+
* 这一段只负责"有多少、在哪、多大"。
|
|
35
|
+
* - `attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
|
|
36
|
+
* - `attachmentOrphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
|
|
37
|
+
* - `attachmentClean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
|
|
40
38
|
const ROUTES = {
|
|
41
|
-
list:
|
|
39
|
+
list: {
|
|
40
|
+
path: "/list",
|
|
41
|
+
method: "GET"
|
|
42
|
+
},
|
|
42
43
|
/** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
|
|
43
|
-
ping:
|
|
44
|
+
ping: {
|
|
45
|
+
path: "/ping",
|
|
46
|
+
method: "GET"
|
|
47
|
+
},
|
|
44
48
|
/** 单条详情(读回收站目录里那条会话自己的日志)。 */
|
|
45
|
-
detail:
|
|
49
|
+
detail: {
|
|
50
|
+
path: "/detail",
|
|
51
|
+
method: "POST"
|
|
52
|
+
},
|
|
46
53
|
/** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
|
|
47
|
-
apply:
|
|
54
|
+
apply: {
|
|
55
|
+
path: "/apply",
|
|
56
|
+
method: "POST"
|
|
57
|
+
},
|
|
48
58
|
/** 清空回收站。 */
|
|
49
|
-
empty:
|
|
59
|
+
empty: {
|
|
60
|
+
path: "/empty",
|
|
61
|
+
method: "POST"
|
|
62
|
+
},
|
|
50
63
|
/** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
|
|
51
|
-
orphans:
|
|
64
|
+
orphans: {
|
|
65
|
+
path: "/orphans",
|
|
66
|
+
method: "GET"
|
|
67
|
+
},
|
|
68
|
+
/** 附件字节(图片/普通文件):全仓唯一的二进制响应段,读的是宿主附件库。 */
|
|
69
|
+
attachment: {
|
|
70
|
+
path: "/attachment",
|
|
71
|
+
method: "GET"
|
|
72
|
+
},
|
|
73
|
+
/** 纯读:扫全部引用源与附件库 `objects`,报无引用图片的数量、字节与预览清单。 */
|
|
74
|
+
attachmentOrphans: {
|
|
75
|
+
path: "/attachment-orphans",
|
|
76
|
+
method: "GET"
|
|
77
|
+
},
|
|
78
|
+
/** 聚合清理:只删 `objects` 下无引用的图片,回 `AttachmentCleanupWire`。 */
|
|
79
|
+
attachmentClean: {
|
|
80
|
+
path: "/attachment-clean",
|
|
81
|
+
method: "POST"
|
|
82
|
+
}
|
|
52
83
|
};
|
|
53
|
-
/**
|
|
84
|
+
/** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
|
|
85
|
+
* 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
|
|
54
86
|
const PARAMS = {
|
|
55
87
|
entryId: "entryId",
|
|
88
|
+
/** `/detail` 的分页游标:只取这个块序号之前的块。缺省 = 从尾部取。 */
|
|
89
|
+
before: "before",
|
|
56
90
|
action: "action",
|
|
57
|
-
ids: "ids"
|
|
91
|
+
ids: "ids",
|
|
92
|
+
attachmentId: "id",
|
|
93
|
+
attachmentKind: "kind",
|
|
94
|
+
attachmentName: "name"
|
|
58
95
|
};
|
|
59
96
|
/**
|
|
60
97
|
* 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
|
|
@@ -110,9 +147,6 @@ function verbCodeOf(error) {
|
|
|
110
147
|
const TRASH_DIR_NAME = "session-trash";
|
|
111
148
|
//#endregion
|
|
112
149
|
//#region src/remote/map-error.ts
|
|
113
|
-
/** 插件专属远程域名(src/shared/wire.ts):与宿主官方域(`workspace/*`、`session/*`、`gateway/*`…)不重名。
|
|
114
|
-
* 字面量的家在 `shared/wire.ts`(ADR-0003)——浏览器半也按同一个域认码。 */
|
|
115
|
-
const DOMAIN = SESSION_DELETE_ERROR_DOMAIN;
|
|
116
150
|
/** 宿主两条类型化错误 → 我们的码(本文件的两行映射)。
|
|
117
151
|
* 类不可 import(跨插件值 import 是红线;且宿主包与运行中的宿主不同代)⇒ 按官方构造器写死的 `this.name` 认。
|
|
118
152
|
* 证据:asar `dsh/node_modules/@deepseek-ai/dsh-workspace/lib/index.js` 原文
|
|
@@ -122,14 +156,21 @@ const HOST_ERROR_CODE = {
|
|
|
122
156
|
WorkspaceUnknownSessionError: "not-found",
|
|
123
157
|
WorkspaceActiveSessionError: "live"
|
|
124
158
|
};
|
|
125
|
-
/** cause 链上第一个**宿主类型化错误**本身(不只它的名字)——它自己身上的字段还要当 `details`
|
|
159
|
+
/** cause 链上第一个**宿主类型化错误**本身(不只它的名字)——它自己身上的字段还要当 `details` 用;
|
|
160
|
+
* 码在 hasOwn 证明的同一处取回(表值恒在,编译器只认贴着取的这一份)。
|
|
126
161
|
* 深度有界(链坏掉不递归下去);查表必须 `hasOwn`:`in` 会命中 `Object.prototype` 的键。 */
|
|
127
162
|
function hostErrorOf(error) {
|
|
128
163
|
let current = error;
|
|
129
164
|
for (let depth = 0; depth < 8; depth++) {
|
|
130
165
|
if (current === null || typeof current !== "object") return void 0;
|
|
131
166
|
const name = current.name;
|
|
132
|
-
if (typeof name === "string" && Object.hasOwn(HOST_ERROR_CODE, name))
|
|
167
|
+
if (typeof name === "string" && Object.hasOwn(HOST_ERROR_CODE, name)) {
|
|
168
|
+
const code = HOST_ERROR_CODE[name];
|
|
169
|
+
if (code !== void 0) return {
|
|
170
|
+
host: current,
|
|
171
|
+
code
|
|
172
|
+
};
|
|
173
|
+
}
|
|
133
174
|
current = current.cause;
|
|
134
175
|
}
|
|
135
176
|
}
|
|
@@ -212,14 +253,14 @@ function toRemoteFailure(error) {
|
|
|
212
253
|
}
|
|
213
254
|
const fromVerb = verbCodeOf(error);
|
|
214
255
|
const host = hostErrorOf(error);
|
|
215
|
-
const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host));
|
|
256
|
+
const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host.host));
|
|
216
257
|
if (host === void 0 && fromVerb === void 0 && typeof raw === "string" && raw.includes("/")) return {
|
|
217
|
-
code:
|
|
258
|
+
code: SESSION_DELETE_CODES.io,
|
|
218
259
|
message: messageOf(error)
|
|
219
260
|
};
|
|
220
|
-
const code = host !== void 0 ?
|
|
261
|
+
const code = host !== void 0 ? host.code : fromVerb ?? "io";
|
|
221
262
|
return {
|
|
222
|
-
code:
|
|
263
|
+
code: SESSION_DELETE_CODES[code],
|
|
223
264
|
message: messageOf(error),
|
|
224
265
|
...own === void 0 ? {} : { details: own }
|
|
225
266
|
};
|
|
@@ -235,6 +276,10 @@ function verbError(code, message, cause, details) {
|
|
|
235
276
|
if (details !== void 0) e.details = details;
|
|
236
277
|
return e;
|
|
237
278
|
}
|
|
279
|
+
/** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
|
|
280
|
+
function trashEmpty(entryId) {
|
|
281
|
+
return verbError("trash-empty", entryId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${entryId}`);
|
|
282
|
+
}
|
|
238
283
|
/** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
|
|
239
284
|
function routeError(code, httpStatus, message) {
|
|
240
285
|
const e = verbError("io", message);
|
|
@@ -265,535 +310,215 @@ function withRollbackFailures(error, failures) {
|
|
|
265
310
|
return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
|
|
266
311
|
}
|
|
267
312
|
//#endregion
|
|
268
|
-
//#region src/
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const existing = service[CAPTURED_AGENT_HANDLES];
|
|
289
|
-
if (existing !== void 0) {
|
|
290
|
-
handles = existing;
|
|
291
|
-
installed = true;
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
let wrappedCount = 0;
|
|
295
|
-
for (const name of ["resume", "create"]) try {
|
|
296
|
-
const original = service[name];
|
|
297
|
-
if (typeof original !== "function") continue;
|
|
298
|
-
const wrapped = function(...args) {
|
|
299
|
-
const result = Reflect.apply(original, this, args);
|
|
300
|
-
result.then((value) => {
|
|
301
|
-
const handle = value;
|
|
302
|
-
const id = handle?.agent?.id;
|
|
303
|
-
if (typeof id !== "string" || id.length === 0 || typeof handle?.dispose !== "function") return;
|
|
304
|
-
handles.set(id, async () => {
|
|
305
|
-
await handle.dispose();
|
|
306
|
-
});
|
|
307
|
-
}, () => void 0);
|
|
308
|
-
return result;
|
|
309
|
-
};
|
|
310
|
-
service[name] = wrapped;
|
|
311
|
-
if (Object.getOwnPropertyDescriptor(service, name)?.value !== wrapped) throw new Error(`agents.${name} is not writable`);
|
|
312
|
-
wrappedCount += 1;
|
|
313
|
-
} catch {}
|
|
314
|
-
if (wrappedCount === 0) return;
|
|
315
|
-
try {
|
|
316
|
-
service[CAPTURED_AGENT_HANDLES] = handles;
|
|
317
|
-
} catch {}
|
|
318
|
-
installed = true;
|
|
319
|
-
};
|
|
320
|
-
const service = ctx.get("agents");
|
|
321
|
-
if (service !== void 0 && service !== null) install(service);
|
|
322
|
-
if (!installed && typeof ctx.inject === "function") try {
|
|
323
|
-
ctx.inject(["agents"], (child) => {
|
|
324
|
-
install(child.get("agents"));
|
|
325
|
-
});
|
|
326
|
-
} catch {}
|
|
327
|
-
return { async dispose(sessionId) {
|
|
328
|
-
const dispose = handles.get(sessionId);
|
|
329
|
-
if (dispose === void 0) return;
|
|
330
|
-
try {
|
|
331
|
-
await dispose();
|
|
332
|
-
} finally {
|
|
333
|
-
handles.delete(sessionId);
|
|
334
|
-
}
|
|
335
|
-
} };
|
|
313
|
+
//#region src/api/request-trust.ts
|
|
314
|
+
/** 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
|
|
315
|
+
* 转发前把 `host` / `origin` / `cookie` / `sec-fetch-site` 删掉、换上一枚宿主自己的 cookie,
|
|
316
|
+
* `Referer` 保留。判据按 `protocol` + `host` 比,**不能比 `URL.origin`**(`dsh-app:` 不是 WHATWG
|
|
317
|
+
* special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`)。 */
|
|
318
|
+
const DESKTOP_APP_SCHEME = "dsh-app:";
|
|
319
|
+
const DESKTOP_APP_HOST = "app";
|
|
320
|
+
/** 回环 socket 的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
|
|
321
|
+
const LOOPBACK_SOCKETS = [
|
|
322
|
+
"127.0.0.1",
|
|
323
|
+
"::1",
|
|
324
|
+
"::ffff:127.0.0.1"
|
|
325
|
+
];
|
|
326
|
+
function isDesktopShellUrl(value) {
|
|
327
|
+
try {
|
|
328
|
+
const url = new URL(value);
|
|
329
|
+
return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
|
|
330
|
+
} catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
336
333
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return store;
|
|
334
|
+
/** 裸 authority(`host` / `host:port`)按给定 scheme 解析;解析不了回 `undefined`。 */
|
|
335
|
+
function parseAuthority(authority, scheme = "http:") {
|
|
336
|
+
try {
|
|
337
|
+
return new URL(`${scheme}//${authority}`);
|
|
338
|
+
} catch {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
345
341
|
}
|
|
346
|
-
/**
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
342
|
+
/** 与宿主 `isLoopbackHostname` 同口径:`localhost`、`[::1]`、任意 127/8。 */
|
|
343
|
+
function isLoopbackHostname(hostname) {
|
|
344
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
345
|
+
const parts = hostname.split(".");
|
|
346
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
347
|
+
}
|
|
348
|
+
/** Host 是不是本部署的 bind 字面量:忽略端口与 `%zone`(zone 是接口选择器,不是地址文本)。 */
|
|
349
|
+
function isBindAddress(hostUrl, bindHost) {
|
|
350
|
+
if (bindHost === void 0) return false;
|
|
351
|
+
const zoneAt = bindHost.indexOf("%");
|
|
352
|
+
const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt);
|
|
353
|
+
const bindUrl = parseAuthority(address.includes(":") ? `[${address}]` : address);
|
|
354
|
+
return bindUrl !== void 0 && bindUrl.hostname === hostUrl.hostname;
|
|
355
|
+
}
|
|
356
|
+
/** 携带 authority 的头(Origin / Referer)是否与 Host 同 authority:按这个头自己的 scheme 归一
|
|
357
|
+
* 默认端口(`http://x:80` 与 Host `x` 是同一个 authority)。 */
|
|
358
|
+
function isSameAuthority(value, hostHeader) {
|
|
359
|
+
let url;
|
|
360
|
+
try {
|
|
361
|
+
url = new URL(value);
|
|
362
|
+
} catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
const scheme = url.protocol === "https:" ? "https:" : url.protocol === "http:" ? "http:" : void 0;
|
|
366
|
+
if (scheme === void 0) return false;
|
|
367
|
+
const hostUrl = parseAuthority(hostHeader, scheme);
|
|
368
|
+
return hostUrl !== void 0 && url.host === hostUrl.host;
|
|
369
|
+
}
|
|
370
|
+
/** 头栅栏:`sec-fetch-site` / Origin / Referer 三条,任何客户端都要过。 */
|
|
371
|
+
function passesHeaderFence(headers, host) {
|
|
372
|
+
if (headers["sec-fetch-site"] === "cross-site") return false;
|
|
373
|
+
const origin = headers.origin;
|
|
374
|
+
if (origin !== void 0 && !isDesktopShellUrl(origin) && !isSameAuthority(origin, host)) return false;
|
|
375
|
+
const referer = headers.referer;
|
|
376
|
+
if (referer !== void 0 && !isDesktopShellUrl(referer) && !isSameAuthority(referer, host)) return false;
|
|
377
|
+
return true;
|
|
352
378
|
}
|
|
353
379
|
/**
|
|
354
|
-
*
|
|
380
|
+
* 这条请求归不归本插件办。`untrusted` = 归属不对(跨源 / 重绑 / 头不成形);`unauthenticated` =
|
|
381
|
+
* 归属对但远程客户端没带宿主身份(原文会提示用 `dsh web` 打印的 URL 打开)。
|
|
355
382
|
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* `kind:'parent'` 是唯一能按 `header.parentSession` 反着问的一支。
|
|
359
|
-
*
|
|
360
|
-
* 停下来的条件与代价:**层数**决定宿主调用次数,行数不参与(一层一次,问空即停)。同一条记录只收一次
|
|
361
|
-
* (`seen` 连着根一起记),所以数据里真出现环时结果是"少数一条",不是把清单读挂死。
|
|
362
|
-
* 交回的 id 没有 `header.id` 的记录**整条跳过**——身份冒充不起(同 {@link HostCorpusPort.subagentMembersOf} 那条),
|
|
363
|
-
* 而且没有身份就没法往下问,硬数进去只会让它将来被重复计。
|
|
383
|
+
* 远程支的两个失败裁决由端口翻译宿主的区分(401 = 缺身份、403 = 归属不对)。身份层缺席(端口报
|
|
384
|
+
* `absent`)一律按归属不对处理:那时没有可换的身份,不能让原文提示用户去补一个不存在的东西。
|
|
364
385
|
*/
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
next.push(bare);
|
|
392
|
-
}
|
|
393
|
-
frontier = next;
|
|
394
|
-
}
|
|
395
|
-
return children;
|
|
386
|
+
function judgeRequest(req, facts) {
|
|
387
|
+
const host = req.headers.host;
|
|
388
|
+
if (host === void 0 || !passesHeaderFence(req.headers, host)) return "untrusted";
|
|
389
|
+
const hostUrl = parseAuthority(host);
|
|
390
|
+
if (hostUrl === void 0) return "untrusted";
|
|
391
|
+
if ((isLoopbackHostname(hostUrl.hostname) || isBindAddress(hostUrl, facts.bindHost)) && LOOPBACK_SOCKETS.includes(req.socket.remoteAddress ?? "")) return "allowed";
|
|
392
|
+
const verdict = facts.connection.verdictFor(req);
|
|
393
|
+
if (verdict.kind === "allowed") return "allowed";
|
|
394
|
+
return verdict.kind === "unauthenticated" ? "unauthenticated" : "untrusted";
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/domain/closure.ts
|
|
398
|
+
/** 会话 id 去掉 `session-` 前缀后的形状:同一个会话在两代宿主里写法不同(v3 代 id 不带前缀、
|
|
399
|
+
* 现行代带),归堆、成组、去重一律按这个形状比——两边都归一,不赌调用方传的是哪一代写法。 */
|
|
400
|
+
function bareSessionId(id) {
|
|
401
|
+
return id.startsWith("session-") ? id.slice(8) : id;
|
|
402
|
+
}
|
|
403
|
+
/** 一个 id 的全部可能写法,原写法在前:`session-x` → `[session-x, x]`;`x` → `[x, session-x]`。
|
|
404
|
+
* 逐层问宿主时按这些写法问:宿主那支按 `header.parentSession` 原文比,不替我们归一。 */
|
|
405
|
+
function sessionIdShapes(id) {
|
|
406
|
+
const bare = bareSessionId(id);
|
|
407
|
+
return [.../* @__PURE__ */ new Set([
|
|
408
|
+
id,
|
|
409
|
+
bare,
|
|
410
|
+
`session-${bare}`
|
|
411
|
+
])];
|
|
396
412
|
}
|
|
397
|
-
/**
|
|
398
|
-
|
|
413
|
+
/** parent(bare) 到 children 索引上的传递闭包:深度不设上限、防环、首见去重(都按 bare 归一后比)。
|
|
414
|
+
* 根的两种写法都认(内部归一次);结果不含根自己。数据里真出现环时代价是「少数一条」,
|
|
415
|
+
* 不是把清单读挂死——环本来不该出现(parentSession 是一条有向边),出现就是手工改过日志或宿主 bug。 */
|
|
416
|
+
function closureOf(children, root) {
|
|
399
417
|
const out = [];
|
|
400
|
-
const seen = /* @__PURE__ */ new Set([
|
|
401
|
-
let frontier = [
|
|
418
|
+
const seen = /* @__PURE__ */ new Set([bareSessionId(root)]);
|
|
419
|
+
let frontier = [...seen];
|
|
402
420
|
while (frontier.length > 0) {
|
|
403
421
|
const next = [];
|
|
404
422
|
for (const bare of frontier) for (const kid of children.get(bare) ?? []) {
|
|
405
|
-
|
|
406
|
-
seen.
|
|
423
|
+
const kidBare = bareSessionId(kid.sessionId);
|
|
424
|
+
if (seen.has(kidBare)) continue;
|
|
425
|
+
seen.add(kidBare);
|
|
407
426
|
out.push(kid);
|
|
408
|
-
next.push(
|
|
427
|
+
next.push(kidBare);
|
|
409
428
|
}
|
|
410
429
|
frontier = next;
|
|
411
430
|
}
|
|
412
431
|
return out;
|
|
413
432
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (wanted.length === 0) return {};
|
|
426
|
-
const children = await descendantsByParent(sessionQuery, wanted);
|
|
427
|
-
const out = {};
|
|
428
|
-
for (const id of wanted) out[id] = subtreeOf(children, bareSessionId(id));
|
|
433
|
+
/** 同一条记录两代写法只认第一次出现(保原序)。全局去重只有这一处实现:谁要按 bare 去重,
|
|
434
|
+
* 交一条列表进来,别在调用点再手写一个 seen 集合。 */
|
|
435
|
+
function firstByBare(members) {
|
|
436
|
+
const seen = /* @__PURE__ */ new Set();
|
|
437
|
+
const out = [];
|
|
438
|
+
for (const member of members) {
|
|
439
|
+
const bare = bareSessionId(member.sessionId);
|
|
440
|
+
if (seen.has(bare)) continue;
|
|
441
|
+
seen.add(bare);
|
|
442
|
+
out.push(member);
|
|
443
|
+
}
|
|
429
444
|
return out;
|
|
430
445
|
}
|
|
431
|
-
/** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
|
|
432
|
-
* 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。
|
|
433
|
-
* `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
|
|
434
|
-
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
435
446
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
|
|
445
|
-
* else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
|
|
446
|
-
* return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
|
|
447
|
-
* 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
|
|
448
|
-
* 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
|
|
449
|
-
* (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
|
|
450
|
-
*
|
|
451
|
-
* **两个调用方共用这一份**:`projectDirOf`(删除时定位会话目录)与回收站对账的重建路径
|
|
452
|
-
* (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
|
|
447
|
+
* 整表语料 → 根闭包组:`parentSessionId` 不在 `known` 里的那些 subagent 各自成一组。`known` 两侧都
|
|
448
|
+
* 归一后再比(传原始 id 即可)。「已知父为什么排除在外」是调用方的领域判据(住
|
|
449
|
+
* `src/verbs/orphan-subagents.ts` 与 `CONTEXT.md` 的「无主子会话」),这一层不替它复述。
|
|
450
|
+
*
|
|
451
|
+
* 组内的后代仍走 `closureOf` 那一份闭包,只是多一条跨组的 `claimed`:一条记录只归第一个认领它的根。
|
|
452
|
+
* 已被前一组收走的那条,连同它整支子树都不再出现在这一组里——后代在那一组已被全部认领,所以
|
|
453
|
+
* 「丢掉已认领的」与「不走进已认领的那支」交回的是同一个集合。环上的代价是「少数一条」,
|
|
454
|
+
* 不是把一次扫描挂死。组序与成员序都按语料原序。
|
|
453
455
|
*/
|
|
454
|
-
function
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
456
|
+
function orphanRootsOf(corpus, known) {
|
|
457
|
+
const knownBares = /* @__PURE__ */ new Set();
|
|
458
|
+
for (const id of known) knownBares.add(bareSessionId(id));
|
|
459
|
+
const children = /* @__PURE__ */ new Map();
|
|
460
|
+
for (const session of corpus) {
|
|
461
|
+
const parent = session.parentSessionId;
|
|
462
|
+
if (parent === void 0) continue;
|
|
463
|
+
const key = bareSessionId(parent);
|
|
464
|
+
const list = children.get(key);
|
|
465
|
+
if (list === void 0) children.set(key, [session]);
|
|
466
|
+
else list.push(session);
|
|
467
|
+
}
|
|
468
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
469
|
+
const groups = [];
|
|
470
|
+
for (const session of corpus) {
|
|
471
|
+
const parent = session.parentSessionId;
|
|
472
|
+
if (!session.subagent || parent === void 0) continue;
|
|
473
|
+
if (knownBares.has(bareSessionId(parent))) continue;
|
|
474
|
+
const rootBare = bareSessionId(session.sessionId);
|
|
475
|
+
if (claimed.has(rootBare)) continue;
|
|
476
|
+
claimed.add(rootBare);
|
|
477
|
+
const members = [session];
|
|
478
|
+
for (const descendant of closureOf(children, rootBare)) {
|
|
479
|
+
const bare = bareSessionId(descendant.sessionId);
|
|
480
|
+
if (claimed.has(bare)) continue;
|
|
481
|
+
claimed.add(bare);
|
|
482
|
+
members.push(descendant);
|
|
469
483
|
}
|
|
484
|
+
groups.push({
|
|
485
|
+
root: session,
|
|
486
|
+
parentSessionId: parent,
|
|
487
|
+
members
|
|
488
|
+
});
|
|
470
489
|
}
|
|
471
|
-
return
|
|
490
|
+
return groups;
|
|
472
491
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
return cache;
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/trash/groups.ts
|
|
494
|
+
/** 这些孩子的父。 */
|
|
495
|
+
function kidsOf(rows, parentId) {
|
|
496
|
+
return rows.filter((row) => row.parentEntryId === parentId);
|
|
479
497
|
}
|
|
480
498
|
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
* `throw new Error(\`cannot validate session '${id}': session persistence holds no such session\`)`。
|
|
499
|
+
* 清单露面的一行 = 组根,按清册原序。孩子折进根那一行(宿主的客户端本来就把 `origin:"subagent"`
|
|
500
|
+
* 的行整排藏了 ⇒ 我们若单列就会冒出一堆点不到的行)。
|
|
484
501
|
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
* 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
|
|
488
|
-
* hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
|
|
489
|
-
* 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
|
|
502
|
+
* 折不进去的那两种孩子(父行不在 / 父行自己也是孩子)**自己站出来占一行**——那正是
|
|
503
|
+
* `visibleGroups` 与 `groupOf` 共用同一条判据的地方。
|
|
490
504
|
*/
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
/** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
|
|
510
|
-
* 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
|
|
511
|
-
* 不是"恢复失败"。 */
|
|
512
|
-
async function controllerItems(host) {
|
|
513
|
-
try {
|
|
514
|
-
const controller = host.get("sessionController");
|
|
515
|
-
if (controller === void 0 || controller === null || typeof controller.list !== "function") return [];
|
|
516
|
-
const listed = await controller.list();
|
|
517
|
-
const items = Array.isArray(listed) ? listed : listed?.items;
|
|
518
|
-
return Array.isArray(items) ? items : [];
|
|
519
|
-
} catch {
|
|
520
|
-
return [];
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
/** 账本里没有那个工作区时 {@link HostPort.attach} / {@link HostPort.detach} 抛的原话开头。
|
|
524
|
-
* **原话与判据同住这里**:抛它的是这个端口,认它的也是端口给的那句,调用方不许另抄一份字符串。 */
|
|
525
|
-
const WORKSPACE_MISSING_MESSAGE = "workspace not found";
|
|
526
|
-
/** 这次失败是不是"账本里没那个工作区"(而不是另有故障)。恢复侧靠它把两种失败分开:
|
|
527
|
-
* 前者按「未分组」收,后者照旧回滚报 `io`——不给真故障静默改归属的机会。 */
|
|
528
|
-
function isWorkspaceMissingError(error) {
|
|
529
|
-
return error instanceof Error && error.message.startsWith(WORKSPACE_MISSING_MESSAGE);
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* 宿主服务层的**真实绑定**:各方法把动词层的问题翻成宿主官方服务的调用。
|
|
533
|
-
*
|
|
534
|
-
* 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
|
|
535
|
-
* 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
|
|
536
|
-
* `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(src/remote/map-error.ts 的两行映射)。
|
|
537
|
-
*/
|
|
538
|
-
function createHostPort(ctx) {
|
|
539
|
-
const host = ctx;
|
|
540
|
-
const registry = host.workspaceRegistry;
|
|
541
|
-
const agentHandles = captureAgentHandles(host);
|
|
542
|
-
return {
|
|
543
|
-
/** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
|
|
544
|
-
async workspaceOf(sessionId) {
|
|
545
|
-
return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
|
|
546
|
-
},
|
|
547
|
-
/** 账本里还有这个工作区吗:`registry.get(id)` 是 `this.entities.get(id)` 的直通(同步 Map 查找),
|
|
548
|
-
* 未知 id 回 `undefined` ⇒ 这里只做一次判别,不抛。 */
|
|
549
|
-
async workspaceExists(workspaceId) {
|
|
550
|
-
return registry.get(workspaceId) !== void 0;
|
|
551
|
-
},
|
|
552
|
-
/**
|
|
553
|
-
* 原生 cwd → 宿主项目目录名(`projectKey`);没有 cwd 时按宿主约定落到 `_no-cwd`。
|
|
554
|
-
*
|
|
555
|
-
* 「账本里没有这个会话」= **未命中**,回 `undefined`(不是抛):这正是本方法声明的类型,
|
|
556
|
-
* 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
|
|
557
|
-
* verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ src/shared/wire.ts 的 `not-found`
|
|
558
|
-
* (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
|
|
559
|
-
*
|
|
560
|
-
* **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
|
|
561
|
-
*/
|
|
562
|
-
async projectDirOf(sessionId) {
|
|
563
|
-
let header;
|
|
564
|
-
try {
|
|
565
|
-
header = await registry.readSessionHeader(sessionId);
|
|
566
|
-
} catch (error) {
|
|
567
|
-
if (isUnknownSession(error)) return void 0;
|
|
568
|
-
throw error;
|
|
569
|
-
}
|
|
570
|
-
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
571
|
-
},
|
|
572
|
-
/**
|
|
573
|
-
* 标题:走官方**投影缓存**读面(`title` 投影单元的 `wire.view` 值就是标题文本本身:
|
|
574
|
-
* 单元定义原文 `{ key: "title", stateVersion: 1, wire: { view: (state) => state } }`,
|
|
575
|
-
* 所以 `values.title` 是 `string | null`)。
|
|
576
|
-
* 契约:**尽力取、取不到返回空串、绝不因此让删除失败** ⇒ 这里吞掉一切异常。
|
|
577
|
-
*/
|
|
578
|
-
async titleOf(sessionId) {
|
|
579
|
-
try {
|
|
580
|
-
const header = await registry.readSessionHeader(sessionId);
|
|
581
|
-
const cache = projectionCacheOf(host);
|
|
582
|
-
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
583
|
-
return typeof title === "string" ? title : "";
|
|
584
|
-
} catch {
|
|
585
|
-
return "";
|
|
586
|
-
}
|
|
587
|
-
},
|
|
588
|
-
/**
|
|
589
|
-
* 活跃判据 = 宿主官方那一条(本文件):`workspace/session-activity` waterfall 问一次,
|
|
590
|
-
* **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
|
|
591
|
-
* asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
|
|
592
|
-
* if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
|
|
593
|
-
*/
|
|
594
|
-
async isLive(sessionId) {
|
|
595
|
-
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
596
|
-
},
|
|
597
|
-
/** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
|
|
598
|
-
async isArchived(sessionId) {
|
|
599
|
-
return registry.archivedSessionIds.includes(sessionId);
|
|
600
|
-
},
|
|
601
|
-
/**
|
|
602
|
-
* 从 `ctx.sessions`(live store)里摘掉会话,并把它的 **agent 一起关掉**(有捕获 handle 时)。
|
|
603
|
-
*
|
|
604
|
-
* **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
|
|
605
|
-
* (见本文件的「通告」一段)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来。
|
|
606
|
-
*
|
|
607
|
-
* **agent 也必须关**:`ctx.agents` 没有 per-agent dispose;宿主 `AgentService.resume` / `create` 返回的
|
|
608
|
-
* `AgentHandle` 带宿主自己的外层 teardown(关写句柄、摘 agent/session),但 `SessionController` 只取
|
|
609
|
-
* `.agent`、把 handle 丢掉。agent handle 捕获(`src/ports/agent-handles.ts`)在 `ctx.agents` 上包一层,
|
|
610
|
-
* 按会话 id 留住 handle;这里先 `entry.detach()`(摘 session、发 `session/disposed`),再
|
|
611
|
-
* `await agentHandles.dispose(sessionId)`。
|
|
612
|
-
* **只摘 session 的后果**:恢复后 `SessionController.resolve` 会先命中那具旧 agent,prompt 被收下、
|
|
613
|
-
* 日志不再增长,直到宿主重启(2026-10-21 真机复现)。
|
|
614
|
-
*
|
|
615
|
-
* **捕获不到 handle 的 agent**(插件激活前已启动的配置 agent,或 `ctx.agents` 形状变化)只能摘 session;
|
|
616
|
-
* 这是已知的残留限制,不拿归档当兜底(ADR-0008)。
|
|
617
|
-
*
|
|
618
|
-
* **所以判据是三态**:读不到 store / 形状不符(旧版宿主)/ detach 或 dispose 抛 ⇒ `unknown`(**不通告**,
|
|
619
|
-
* 因为无法断言宿主列表已经干净);内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。
|
|
620
|
-
* 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
|
|
621
|
-
*/
|
|
622
|
-
async evictLive(sessionId) {
|
|
623
|
-
try {
|
|
624
|
-
const store = liveSessionsOf(host);
|
|
625
|
-
const session = store?.get(sessionId);
|
|
626
|
-
if (store === void 0) return "unknown";
|
|
627
|
-
if (session === void 0 || session === null) {
|
|
628
|
-
await agentHandles.dispose(sessionId);
|
|
629
|
-
return "not-live";
|
|
630
|
-
}
|
|
631
|
-
const entry = store.liveEntryFor(session);
|
|
632
|
-
if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
|
|
633
|
-
entry.detach();
|
|
634
|
-
await agentHandles.dispose(sessionId);
|
|
635
|
-
return "evicted";
|
|
636
|
-
} catch {
|
|
637
|
-
return "unknown";
|
|
638
|
-
}
|
|
639
|
-
},
|
|
640
|
-
/**
|
|
641
|
-
* 通告客户端"这个会话没了"。
|
|
642
|
-
*
|
|
643
|
-
* **为什么需要**:客户端那一行只由两条路更新——重连时的整表重列(`handleConnected` → `refreshList`),
|
|
644
|
-
* 或宿主转发的 `api-session/*` 事件。删除一个**冷**会话时宿主不会发任何事件(`session/disposed` 只在摘掉
|
|
645
|
-
* 活体时才有),所以那一行会一直挂到下次重连为止(真机实测就是这个形状)。
|
|
646
|
-
*
|
|
647
|
-
* **为什么这样发是对的**:`api-session/removed` 是宿主**自己声明并转发**的事件
|
|
648
|
-
* (asar `dsh-api-remotes/lib/remote-events.js` 的 `API_REMOTE_FORWARDED_EVENTS` 里
|
|
649
|
-
* `{ event: "api-session/removed", mode: "emit" }`;转发器就是 `ctx.on(event, …)` 的普通监听者);
|
|
650
|
-
* 我们不带 carrier 地 emit ⇒ 全量派发到它(见 {@link HostCtxLike.emit})⇒ 客户端
|
|
651
|
-
* `ctx.remote.$on("api-session/removed", …)` 收到后 `recordMutation({kind:'remove'})` + `handleRemoved()`。
|
|
652
|
-
* **只在"宿主列表确实已经不含它"时调用**(调用方按 {@link EvictionOutcome} 判):这条通告在语义上是**如实**
|
|
653
|
-
* 的,不是障眼法。
|
|
654
|
-
*/
|
|
655
|
-
async announceRemoved(sessionId) {
|
|
656
|
-
try {
|
|
657
|
-
host.emit("api-session/removed", sessionId);
|
|
658
|
-
} catch {}
|
|
659
|
-
},
|
|
660
|
-
/**
|
|
661
|
-
* 通告客户端"这组会话回来了":**拉一次**宿主自己的会话列表,按 id 逐条发 `api-session/added`。
|
|
662
|
-
*
|
|
663
|
-
* 摘要**取自宿主自己的会话列表**(`ctx.sessionController.list()`),不自己造一行:客户端 `handleSessionAdded`
|
|
664
|
-
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
665
|
-
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**。
|
|
666
|
-
*
|
|
667
|
-
* **取数从 interface 收进了 implementation**:单条就是长度为 1 的批,批量恢复与删除组回滚走同一条路。
|
|
668
|
-
* 一次预取由这里保证——100 条一组的批量若按条拉表就是 100 遍全表扫,那正是 2026-10-17 那次"恢复很慢"的头号来源。
|
|
669
|
-
*/
|
|
670
|
-
async announceRestored(sessionIds) {
|
|
671
|
-
if (sessionIds.length === 0) return;
|
|
672
|
-
const summaries = [];
|
|
673
|
-
for (const item of await controllerItems(host)) {
|
|
674
|
-
const sessionId = item?.sessionId;
|
|
675
|
-
if (typeof sessionId === "string") summaries.push({
|
|
676
|
-
sessionId,
|
|
677
|
-
summary: item
|
|
678
|
-
});
|
|
679
|
-
}
|
|
680
|
-
for (const id of sessionIds) {
|
|
681
|
-
const summary = summaries.find((item) => item.sessionId === id)?.summary;
|
|
682
|
-
if (summary === void 0) continue;
|
|
683
|
-
try {
|
|
684
|
-
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
685
|
-
} catch {}
|
|
686
|
-
}
|
|
687
|
-
},
|
|
688
|
-
/** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
|
|
689
|
-
async unarchive(sessionId) {
|
|
690
|
-
await registry.unarchiveSession(sessionId);
|
|
691
|
-
},
|
|
692
|
-
/** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
|
|
693
|
-
async detach(sessionId, workspaceId) {
|
|
694
|
-
const workspace = registry.get(workspaceId);
|
|
695
|
-
if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (detach): ${workspaceId}`);
|
|
696
|
-
await workspace.detachSession(sessionId);
|
|
697
|
-
},
|
|
698
|
-
/** 挂账本:实体方法 `attachSession`(顶层没有)。 */
|
|
699
|
-
async attach(sessionId, workspaceId) {
|
|
700
|
-
const workspace = registry.get(workspaceId);
|
|
701
|
-
if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (attach): ${workspaceId}`);
|
|
702
|
-
await workspace.attachSession(sessionId);
|
|
703
|
-
},
|
|
704
|
-
/** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
|
|
705
|
-
* (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant"),所以 `workspaceId` 不用
|
|
706
|
-
* ——未分组的会话没有工作区可传,签名里它是可选的。
|
|
707
|
-
* 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,src/shared/wire.ts)。 */
|
|
708
|
-
async archive(sessionId) {
|
|
709
|
-
await registry.archiveSession(sessionId);
|
|
710
|
-
},
|
|
711
|
-
async listSessions() {
|
|
712
|
-
const sessionQuery = host.get("sessionQuery");
|
|
713
|
-
if (typeof sessionQuery?.listSessions !== "function") return { available: false };
|
|
714
|
-
const records = await sessionQuery.listSessions();
|
|
715
|
-
const out = [];
|
|
716
|
-
for (const record of records) {
|
|
717
|
-
const id = record.header?.id;
|
|
718
|
-
if (typeof id !== "string" || id === "") continue;
|
|
719
|
-
const parent = record.header?.parentSession;
|
|
720
|
-
const cwd = record.header?.cwd;
|
|
721
|
-
out.push({
|
|
722
|
-
sessionId: id,
|
|
723
|
-
...typeof parent === "string" && parent !== "" ? { parentSessionId: parent } : {},
|
|
724
|
-
projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd),
|
|
725
|
-
subagent: record.header?.origin === "subagent"
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
return {
|
|
729
|
-
available: true,
|
|
730
|
-
value: out
|
|
731
|
-
};
|
|
732
|
-
},
|
|
733
|
-
/**
|
|
734
|
-
* 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
|
|
735
|
-
*
|
|
736
|
-
* 数的是**传递闭包**(孩子与孙代都算),不是"直接孩子"(issue #1):宿主客户端对所有
|
|
737
|
-
* `origin:"subagent"` 一律藏行 ⇒ 深度 ≥2 上"删了父还剩多少看不见"原样存在,只数一层就是把那件事
|
|
738
|
-
* 说成没有。问法见 {@link descendantsByParent}。
|
|
739
|
-
*
|
|
740
|
-
* 归堆必须**两边都去前缀**再比:本机实测父会话自己的 header id 带 `session-` 前缀
|
|
741
|
-
* (`session-ac07f2bb-…`)、它的子会话写的 `parentSession` 也带,但 v3 那一代的 id 不带 ⇒
|
|
742
|
-
* 同一个父有两种写法。所以问的时候把一个 id 的两种形状都塞进 `values`,回来按去前缀后的形状归堆,
|
|
743
|
-
* 最后按**调用方给的那个写法**把键交回去(调用方拿 `entry.sessionId` 直接查得到)。
|
|
744
|
-
*
|
|
745
|
-
* 删除要一起带走**整个组**,先得把他们点出来:身份取宿主自己记录里的 `header.id`,项目目录由
|
|
746
|
-
* `header.cwd` 走同一个 `projectKey`(与 {@link HostPort.projectDirOf} 同一根算法,不另起一套)。
|
|
747
|
-
* 交回的是**全部后代**——孙代跟着父一起进回收站(issue #1),顺序是逐层往下问出来的(近的先)。
|
|
748
|
-
* `header.id` 缺席的后代直接跳过——不拿目录名冒充身份(冒充了会把一个来路不明的目录搬进回收站)。
|
|
749
|
-
*
|
|
750
|
-
* **缺席显式化**(见 {@link CorpusRead}):`sessionQuery` 没挂,或它的 `filterSessions` 缺席 / 不是函数
|
|
751
|
-
* ⇒ `{ available: false }`,不拿空表冒充"盘上没有后代"——写侧据此决定降级还是响亮失败,不再靠"空表"
|
|
752
|
-
* 兼表两种意思。
|
|
753
|
-
* **先判服务、再判空入参**:语料本来就看不见时,"没有后代"这句话不该被说出口(哪怕入参是空的);
|
|
754
|
-
* 服务在场时空入参由 `membersFor` 的短路保证零宿主调用。
|
|
755
|
-
*/
|
|
756
|
-
async subagentMembersOf(parentIds) {
|
|
757
|
-
const sessionQuery = host.get("sessionQuery");
|
|
758
|
-
if (typeof sessionQuery?.filterSessions !== "function") return { available: false };
|
|
759
|
-
const members = await membersFor(sessionQuery, parentIds);
|
|
760
|
-
return {
|
|
761
|
-
available: true,
|
|
762
|
-
value: Object.fromEntries(Object.entries(members).map(([id, list]) => [id, list.map(toMember)]))
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
};
|
|
766
|
-
}
|
|
767
|
-
//#endregion
|
|
768
|
-
//#region src/trash/groups.ts
|
|
769
|
-
/** 这些孩子的父。 */
|
|
770
|
-
function kidsOf(rows, parentId) {
|
|
771
|
-
return rows.filter((row) => row.parentEntryId === parentId);
|
|
772
|
-
}
|
|
773
|
-
/**
|
|
774
|
-
* 清单露面的一行 = 组根,按清册原序。孩子折进根那一行(宿主的客户端本来就把 `origin:"subagent"`
|
|
775
|
-
* 的行整排藏了 ⇒ 我们若单列就会冒出一堆点不到的行)。
|
|
776
|
-
*
|
|
777
|
-
* 折不进去的那两种孩子(父行不在 / 父行自己也是孩子)**自己站出来占一行**——那正是
|
|
778
|
-
* `visibleGroups` 与 `groupOf` 共用同一条判据的地方。
|
|
779
|
-
*/
|
|
780
|
-
function visibleGroups(rows) {
|
|
781
|
-
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
782
|
-
const kidsByRoot = /* @__PURE__ */ new Map();
|
|
783
|
-
const folded = /* @__PURE__ */ new Set();
|
|
784
|
-
for (const row of rows) {
|
|
785
|
-
if (row.parentEntryId === void 0) continue;
|
|
786
|
-
const parent = byId.get(row.parentEntryId);
|
|
787
|
-
if (parent === void 0 || parent.parentEntryId !== void 0) continue;
|
|
788
|
-
folded.add(row.id);
|
|
789
|
-
const siblings = kidsByRoot.get(parent.id);
|
|
790
|
-
if (siblings === void 0) kidsByRoot.set(parent.id, [row]);
|
|
791
|
-
else siblings.push(row);
|
|
792
|
-
}
|
|
793
|
-
return rows.filter((row) => !folded.has(row.id)).map((root) => ({
|
|
794
|
-
root,
|
|
795
|
-
kids: kidsByRoot.get(root.id) ?? []
|
|
796
|
-
}));
|
|
505
|
+
function visibleGroups(rows) {
|
|
506
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
507
|
+
const kidsByRoot = /* @__PURE__ */ new Map();
|
|
508
|
+
const folded = /* @__PURE__ */ new Set();
|
|
509
|
+
for (const row of rows) {
|
|
510
|
+
if (row.parentEntryId === void 0) continue;
|
|
511
|
+
const parent = byId.get(row.parentEntryId);
|
|
512
|
+
if (parent === void 0 || parent.parentEntryId !== void 0) continue;
|
|
513
|
+
folded.add(row.id);
|
|
514
|
+
const siblings = kidsByRoot.get(parent.id);
|
|
515
|
+
if (siblings === void 0) kidsByRoot.set(parent.id, [row]);
|
|
516
|
+
else siblings.push(row);
|
|
517
|
+
}
|
|
518
|
+
return rows.filter((row) => !folded.has(row.id)).map((root) => ({
|
|
519
|
+
root,
|
|
520
|
+
kids: kidsByRoot.get(root.id) ?? []
|
|
521
|
+
}));
|
|
797
522
|
}
|
|
798
523
|
/**
|
|
799
524
|
* 一条 id 所属的整组。三种形状,三个答案:
|
|
@@ -808,6 +533,14 @@ function groupOf(rows, id) {
|
|
|
808
533
|
return kidsOf(rows, id);
|
|
809
534
|
}
|
|
810
535
|
/**
|
|
536
|
+
* 把一条 id 所属的**整组**从快照里摘掉,回一份新快照(组语义仍只读 `groupOf`,这一条判据不另写一遍)。
|
|
537
|
+
* 「同批前一条已把这一组带走」的收缩就是它:调用方各自持有那份可变视图,本函数不记账。
|
|
538
|
+
*/
|
|
539
|
+
function withoutGroup(rows, id) {
|
|
540
|
+
const gone = new Set(groupOf(rows, id).map((row) => row.id));
|
|
541
|
+
return rows.filter((row) => !gone.has(row.id));
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
811
544
|
* 「这条是被**同批前一条**带走的,还是整批开始前就没有,还是仍在?」
|
|
812
545
|
*
|
|
813
546
|
* 幂等语义只属于 `carried`:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
|
|
@@ -827,19 +560,16 @@ function claimOf(original, current, id) {
|
|
|
827
560
|
/**
|
|
828
561
|
* **同批已带走集**(「已被同批前一条带走」的 id)的唯一实现(ADR-0002 的内部实现)。
|
|
829
562
|
*
|
|
830
|
-
* 三个动作曾各有一套机制:delete 用动词回吐的 `movedSessionIds`,restore/purge 用
|
|
831
|
-
* `original / current` 两份快照 + 旧 boolean 谓词 + `shrink`。这里把判据收成 `claim(id)` + `take(id)`;
|
|
832
563
|
* 两种 id 域各一个 adapter:
|
|
833
|
-
* - entry 适配:restore
|
|
564
|
+
* - entry 适配:restore 的条目 id,`claim` 是三态(fresh / carried / absent),组语义仍读
|
|
834
565
|
* `trash/groups.ts`;
|
|
835
566
|
* - session 适配:delete 的会话 id,`claim` 只有 fresh / carried,集合由动词结果长出。
|
|
836
567
|
*
|
|
837
|
-
* entry 适配的三态判据读 `groups.claimOf`;session 适配没有原始清册视图,只记 `take` 过的会话 id
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
* 当前只读视图(`rows`)只挂在 entry 域上:只有 purge 要它——盘上后代要按清册里还在的 id 排除。
|
|
568
|
+
* entry 适配的三态判据读 `groups.claimOf`;session 适配没有原始清册视图,只记 `take` 过的会话 id。
|
|
569
|
+
* 收缩中的当前只读视图不再外递:purge 那一半连同视图住「清册作用域」(`src/verbs/trash-scope.ts`)。
|
|
570
|
+
* 两个 adapter 只各自**持有**那份视图;按组收缩的判据不抄第二遍,都读 `groups.withoutGroup`。
|
|
841
571
|
*/
|
|
842
|
-
/**
|
|
572
|
+
/** 恢复:原始快照认得、当前视图已经空了 ⇒ 同批前一条带走的。 */
|
|
843
573
|
function createEntryCarried(original) {
|
|
844
574
|
let current = [...original];
|
|
845
575
|
return {
|
|
@@ -847,11 +577,8 @@ function createEntryCarried(original) {
|
|
|
847
577
|
return claimOf(original, current, id);
|
|
848
578
|
},
|
|
849
579
|
take(id) {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
current = current.filter((row) => !gone.has(row.id));
|
|
853
|
-
},
|
|
854
|
-
rows: () => current
|
|
580
|
+
current = withoutGroup(current, id);
|
|
581
|
+
}
|
|
855
582
|
};
|
|
856
583
|
}
|
|
857
584
|
/** 删除:会话 id 域;`take` 只收这次真的搬走的成员(父 + 盘上的孩子)。 */
|
|
@@ -887,6 +614,22 @@ const REQUIRED_ENTRY_FIELDS = {
|
|
|
887
614
|
title: "string",
|
|
888
615
|
sizeBytes: "number"
|
|
889
616
|
};
|
|
617
|
+
/** 清册行 → 线上清册行形状。**显式字面量、不用 `...row`**:线上多一格必填时这里编译不过。
|
|
618
|
+
* 只搬清册里真有的事实、不带任何只读数;`/list` 的 `toWire` 与详情的子会话摘要共用这一份
|
|
619
|
+
* (否则同一条清册行会有两份会漂移的投影)。 */
|
|
620
|
+
function entryWire(row) {
|
|
621
|
+
return {
|
|
622
|
+
id: row.id,
|
|
623
|
+
sessionId: row.sessionId,
|
|
624
|
+
projectDir: row.projectDir,
|
|
625
|
+
workspaceId: row.workspaceId,
|
|
626
|
+
wasArchived: row.wasArchived,
|
|
627
|
+
deletedAt: row.deletedAt,
|
|
628
|
+
title: row.title,
|
|
629
|
+
sizeBytes: row.sizeBytes,
|
|
630
|
+
...row.parentEntryId === void 0 ? {} : { parentEntryId: row.parentEntryId }
|
|
631
|
+
};
|
|
632
|
+
}
|
|
890
633
|
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
891
634
|
function makeEntryId(deletedAt, sessionDirName) {
|
|
892
635
|
const d = new Date(deletedAt);
|
|
@@ -899,6 +642,7 @@ function parseEntryId(id) {
|
|
|
899
642
|
const m = ENTRY_ID_RE.exec(id);
|
|
900
643
|
if (!m) throw new Error(`bad entry id: ${id}`);
|
|
901
644
|
const [, y, mo, d, h, mi, s, sessionDirName] = m;
|
|
645
|
+
if (y === void 0 || mo === void 0 || d === void 0 || h === void 0 || mi === void 0 || s === void 0 || sessionDirName === void 0) throw new Error(`bad entry id: ${id}`);
|
|
902
646
|
return {
|
|
903
647
|
deletedAt: Date.UTC(+y, +mo - 1, +d, +h, +mi, +s),
|
|
904
648
|
sessionDirName
|
|
@@ -929,21 +673,15 @@ function trashDirOf(entryId, home) {
|
|
|
929
673
|
//#endregion
|
|
930
674
|
//#region src/verbs/member-journal.ts
|
|
931
675
|
/**
|
|
932
|
-
* 一次成员移动 =
|
|
676
|
+
* 一次成员移动 = 一串自带反向动作的步骤(ADR-0002)。
|
|
933
677
|
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
678
|
+
* 这里独占四件事,动词只声明“这次要做哪几步、每步怎么退”:
|
|
679
|
+
* - 相反顺序退化;
|
|
680
|
+
* - 一步失败时把已做成的那些退回去;
|
|
681
|
+
* - 失败收集:回滚不干净时不让原错误独走(details.rollbackFailures);
|
|
682
|
+
* - 非原子步骤:undoOnFailure 让“这一步自己失败”也跑反向动作。
|
|
937
683
|
*
|
|
938
|
-
*
|
|
939
|
-
* ① **相反顺序**退化(最后做成的先退);
|
|
940
|
-
* ② 一步失败时把**已经做成的**那些退回去;
|
|
941
|
-
* ③ 失败收集:某一步退不干净时**不让原错误独走**(`details.rollbackFailures`,见 `verbs/errors.ts`);
|
|
942
|
-
* ④ **非原子步骤**:`undoOnFailure` 让"这一步自己失败了"也跑它的反向动作(例如"取消归档"半做完了,
|
|
943
|
-
* 得先把会话重新藏回去)。
|
|
944
|
-
*
|
|
945
|
-
* 边界:它只服务"成员在 `sessions/` 与 `session-trash/` 之间搬"这一件事。做成通用撤销框架就会吸进
|
|
946
|
-
* 不属于它的知识(谁该通告、通告几次、哪些失败算成功)。
|
|
684
|
+
* 边界:只服务成员在 sessions/ 与 session-trash/ 之间搬;不做通用撤销框架。
|
|
947
685
|
*/
|
|
948
686
|
/** 已经把某个前缀做成了的 journal——`undo()` 就退那一个前缀。 */
|
|
949
687
|
function createJournal(steps) {
|
|
@@ -1001,9 +739,8 @@ async function stepUndo(label, run) {
|
|
|
1001
739
|
}
|
|
1002
740
|
/**
|
|
1003
741
|
* **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
|
|
1004
|
-
* `details.rollbackFailures
|
|
1005
|
-
*
|
|
1006
|
-
* "相反顺序 + 失败合并"不再各写一遍。
|
|
742
|
+
* `details.rollbackFailures`,绝不让原错误独走。那些失败句子的合并与措辞住 `src/verbs/errors.ts`;
|
|
743
|
+
* 删除与恢复两个按组走的动词共用这一处。
|
|
1007
744
|
*/
|
|
1008
745
|
async function rollbackJournals(journals, error) {
|
|
1009
746
|
const failures = [];
|
|
@@ -1031,27 +768,28 @@ async function assertNoLive(host, sessionIds) {
|
|
|
1031
768
|
}
|
|
1032
769
|
//#endregion
|
|
1033
770
|
//#region src/verbs/member-presence.ts
|
|
771
|
+
/** 本仓的在场能力实现,也是**全仓唯一的盖章处**:端口形状住 `src/ports/presence-port.ts`,这里只做
|
|
772
|
+
* 三态与真值表。品牌字段是类型层的(unique symbol 不导出、没有运行时值),所以只有这一处显式断言
|
|
773
|
+
* 能把三件方法盖成 `PresenceCapability`——手拼同样的三件满足不了那个面(`tests/deps.test.ts` 钉住)。
|
|
774
|
+
* `announceRestored` 直接交宿主那一手:能力面与宿主面同名同签名,再包一层只会多一个转调。 */
|
|
1034
775
|
function createMemberPresence(host) {
|
|
1035
776
|
return {
|
|
1036
|
-
async memberGone(sessionId, facts = {}) {
|
|
1037
|
-
if (facts.backInTrash
|
|
777
|
+
async memberGone(sessionId, facts = { backInTrash: true }) {
|
|
778
|
+
if (!facts.backInTrash) return;
|
|
1038
779
|
if (await host.evictLive(sessionId) !== "unknown") await host.announceRemoved(sessionId);
|
|
1039
780
|
},
|
|
1040
781
|
async memberBack(sessionId, facts) {
|
|
1041
782
|
if (!facts.diskBack || !facts.workspaceBack) return;
|
|
1042
783
|
await host.announceRestored([sessionId]);
|
|
1043
|
-
}
|
|
784
|
+
},
|
|
785
|
+
announceRestored: host.announceRestored
|
|
1044
786
|
};
|
|
1045
787
|
}
|
|
1046
788
|
/**
|
|
1047
|
-
* 给一次成员移动的 journal 补上「在场那条尾巴」:run
|
|
1048
|
-
*
|
|
1049
|
-
* 为什么是个工厂:删除与恢复此前各手拼一个匿名 journal,形状逐字相同(`journal.run` 加上
|
|
1050
|
-
* `presence.memberGone`/`journal.undo` 加上 `presence.memberBack`),只差键 → 事实的映射。
|
|
1051
|
-
* 两处各写一遍,就是「补在哪一拍」这条顺序又有第二个家。
|
|
789
|
+
* 给一次成员移动的 journal 补上「在场那条尾巴」:run 完全成功之后离场,undo 之后按事实归位或离场。
|
|
1052
790
|
*
|
|
1053
|
-
* 边界:`memberGone` 的 `backInTrash:false` 早退与 `memberBack`
|
|
1054
|
-
*
|
|
791
|
+
* 边界:`memberGone` 的 `backInTrash:false` 早退与 `memberBack` 的两件齐备真值表归 `createMemberPresence`;
|
|
792
|
+
* 这里只把事实原样递过去。
|
|
1055
793
|
*/
|
|
1056
794
|
function withPresenceTail(presence, journal, tail) {
|
|
1057
795
|
const announce = async (undo) => {
|
|
@@ -1123,7 +861,7 @@ async function moveGroup({ root, followers, move }) {
|
|
|
1123
861
|
* 结果带回这次真的搬走的整组(`movedSessionIds`)——它是动词交回的内部数据,不再靠回调借用动词的手。
|
|
1124
862
|
*/
|
|
1125
863
|
function deleteSession(deps) {
|
|
1126
|
-
const presence =
|
|
864
|
+
const presence = deps.presence;
|
|
1127
865
|
return async function deleteSession(sessionId) {
|
|
1128
866
|
const read = await deps.host.subagentMembersOf([sessionId]);
|
|
1129
867
|
const kids = read.available ? read.value[sessionId] ?? [] : [];
|
|
@@ -1169,8 +907,7 @@ function deleteSession(deps) {
|
|
|
1169
907
|
* 会留下"文件在、agent 已关"的半个世界。窗口与可选修法登记在 ADR-0008 的「边界」。
|
|
1170
908
|
*
|
|
1171
909
|
* 每一步的反向动作都是"把这件写退回原样";**清册行是最后一件写**,它失败时 journal 会把目录搬回去、
|
|
1172
|
-
*
|
|
1173
|
-
* (与旧实现"目录退不回就不摘行"同一条判据)。
|
|
910
|
+
* 账本挂回去。清册行的反向动作带一道闸:**回收站目录还没搬回来就不摘那一行**,留着行至少元数据还在。
|
|
1174
911
|
*/
|
|
1175
912
|
async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
|
|
1176
913
|
const { sessionId, projectDir } = member;
|
|
@@ -1242,7 +979,7 @@ async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
|
|
|
1242
979
|
sessionId,
|
|
1243
980
|
onDone: "leave",
|
|
1244
981
|
/** 组级回滚:先按相反顺序退步骤,再判「目录与账本两件写都回去了吗」。
|
|
1245
|
-
*
|
|
982
|
+
* 这一行已经收过 `api-session/removed`;不发「回来了」它就会在客户端消失到重连为止。
|
|
1246
983
|
* 「退没退成」由 journal 的逐步结果交出来,这里只读键,不自己累计布尔。 */
|
|
1247
984
|
onUndone: (undo) => ({
|
|
1248
985
|
kind: "return",
|
|
@@ -1253,25 +990,689 @@ async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
|
|
|
1253
990
|
};
|
|
1254
991
|
}
|
|
1255
992
|
//#endregion
|
|
1256
|
-
//#region src/
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
993
|
+
//#region src/log/zstd-frames.ts
|
|
994
|
+
const MAGIC = [
|
|
995
|
+
40,
|
|
996
|
+
181,
|
|
997
|
+
47,
|
|
998
|
+
253
|
|
999
|
+
];
|
|
1000
|
+
/** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
|
|
1001
|
+
const AFTER_DESCRIPTOR = 5;
|
|
1002
|
+
const BLOCK_HEADER_BYTES = 3;
|
|
1003
|
+
const BLOCK_LAST = 1;
|
|
1004
|
+
const BLOCK_TYPE_SHIFT = 1;
|
|
1005
|
+
const BLOCK_SIZE_SHIFT = 3;
|
|
1006
|
+
const BLOCK_SIZE_MASK = 2097151;
|
|
1007
|
+
const BLOCK_RAW = 0;
|
|
1008
|
+
const BLOCK_RLE = 1;
|
|
1009
|
+
const BLOCK_COMRESSED = 2;
|
|
1010
|
+
/** RFC 8878 表 3 的帧头描述符位段:1-0 字典 ID、2 校验和、5 单段、7-6 FCS 尺寸。 */
|
|
1011
|
+
const DESC_DID_FLAG = 3;
|
|
1012
|
+
const DESC_SINGLE_SHIFT = 5;
|
|
1013
|
+
const DESC_FCS_SHIFT = 6;
|
|
1014
|
+
/** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
|
|
1015
|
+
const BLOCK_RESERVED = 3;
|
|
1016
|
+
/** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
|
|
1017
|
+
const DID_SIZES = [
|
|
1018
|
+
0,
|
|
1019
|
+
1,
|
|
1020
|
+
2,
|
|
1021
|
+
4
|
|
1022
|
+
];
|
|
1023
|
+
const FCS_SIZES_SINGLE = [
|
|
1024
|
+
1,
|
|
1025
|
+
2,
|
|
1026
|
+
4,
|
|
1027
|
+
8
|
|
1028
|
+
];
|
|
1029
|
+
const FCS_SIZES_MULTI = [
|
|
1030
|
+
0,
|
|
1031
|
+
2,
|
|
1032
|
+
4,
|
|
1033
|
+
8
|
|
1034
|
+
];
|
|
1035
|
+
/** 实测的帧尾长度(规范里就是 descriptor 校验位声明的 XXH32;收不收由文件头注的自证判据决定)。 */
|
|
1036
|
+
const FRAME_TAIL_BYTES = 4;
|
|
1037
|
+
function isMagicAt(raw, at) {
|
|
1038
|
+
return MAGIC.every((byte, i) => raw[at + i] === byte);
|
|
1039
|
+
}
|
|
1040
|
+
function readUint24LE(raw, at) {
|
|
1041
|
+
const b0 = raw[at];
|
|
1042
|
+
const b1 = raw[at + 1];
|
|
1043
|
+
const b2 = raw[at + 2];
|
|
1044
|
+
if (b0 === void 0 || b1 === void 0 || b2 === void 0) return void 0;
|
|
1045
|
+
return b0 | b1 << 8 | b2 << 16;
|
|
1046
|
+
}
|
|
1047
|
+
/** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
|
|
1048
|
+
function withFrameTail(raw, at) {
|
|
1049
|
+
const afterTail = at + FRAME_TAIL_BYTES;
|
|
1050
|
+
if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
|
|
1051
|
+
return at;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* 把 `raw` 切成帧区间。
|
|
1055
|
+
* @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
|
|
1056
|
+
* 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
|
|
1057
|
+
*/
|
|
1058
|
+
function frameRanges(raw) {
|
|
1059
|
+
const ranges = [];
|
|
1060
|
+
let at = 0;
|
|
1061
|
+
while (at < raw.length) {
|
|
1062
|
+
if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
|
|
1063
|
+
const descriptor = raw[at + 4];
|
|
1064
|
+
if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
|
|
1065
|
+
const didFlag = descriptor & DESC_DID_FLAG;
|
|
1066
|
+
const singleSegment = descriptor >> DESC_SINGLE_SHIFT & 1;
|
|
1067
|
+
const fcsFlag = descriptor >> DESC_FCS_SHIFT & 3;
|
|
1068
|
+
let cursor = at + AFTER_DESCRIPTOR;
|
|
1069
|
+
if (!singleSegment) cursor += 1;
|
|
1070
|
+
cursor += DID_SIZES[didFlag];
|
|
1071
|
+
cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
|
|
1072
|
+
for (;;) {
|
|
1073
|
+
const blockHeader = readUint24LE(raw, cursor);
|
|
1074
|
+
cursor += BLOCK_HEADER_BYTES;
|
|
1075
|
+
if (blockHeader === void 0 || cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
|
|
1076
|
+
const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
|
|
1077
|
+
const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
|
|
1078
|
+
if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
|
|
1079
|
+
if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
|
|
1080
|
+
else if (blockType === BLOCK_RLE) cursor += 1;
|
|
1081
|
+
if (blockHeader & BLOCK_LAST) break;
|
|
1082
|
+
}
|
|
1083
|
+
cursor = withFrameTail(raw, cursor);
|
|
1084
|
+
if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
|
|
1085
|
+
ranges.push({
|
|
1086
|
+
start: at,
|
|
1087
|
+
end: cursor
|
|
1088
|
+
});
|
|
1089
|
+
at = cursor;
|
|
1090
|
+
}
|
|
1091
|
+
return ranges;
|
|
1092
|
+
}
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/log/session-log.ts
|
|
1095
|
+
/**
|
|
1096
|
+
* 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
|
|
1097
|
+
* 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
|
|
1098
|
+
*
|
|
1099
|
+
* **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
|
|
1100
|
+
* 这一层只懂编解码,所以它能被单独验穿。
|
|
1101
|
+
*
|
|
1102
|
+
* 两条实测决定的口径:
|
|
1103
|
+
* ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
|
|
1104
|
+
* `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
|
|
1105
|
+
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
1106
|
+
* ② **一行的余量跨帧带走**:写作侧一条条 append,帧边界不保证落在行边界;逐帧 split('\n') 会静默少一条
|
|
1107
|
+
* 记录(半个头加半个身,两半都解不开 JSON)。带上余量后跨帧的多字节字符也解对了——拼接在字节层做,
|
|
1108
|
+
* toString('utf8') 只看整行。真被切断的尾巴(写一半)跳过并计数,绝不因为一行就抛整个读数。
|
|
1109
|
+
*/
|
|
1110
|
+
const NEWLINE = 10;
|
|
1111
|
+
/**
|
|
1112
|
+
* 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
|
|
1113
|
+
* `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
|
|
1114
|
+
* 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
|
|
1115
|
+
*/
|
|
1116
|
+
const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
|
|
1117
|
+
/** 这个名字是不是规范日志(任意一代)。附件引用扫描用它区分"老一代日志"与"读不懂的旁文件"。 */
|
|
1118
|
+
function isCanonicalSessionLogName(name) {
|
|
1119
|
+
return CANONICAL_ZSTD_LOG.test(name);
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* 同一个会话目录里可以并存多代日志;只读版本号最大的那代,因为新代包含旧代的记录且末尾更晚
|
|
1123
|
+
* ⇒ 读旧代 = 一份截了尾巴的快照;把几代拼起来 = 重复计数。
|
|
1124
|
+
*
|
|
1125
|
+
* 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
|
|
1126
|
+
*/
|
|
1127
|
+
function pickSessionLog(names) {
|
|
1128
|
+
let best;
|
|
1129
|
+
let bestVersion = -1;
|
|
1130
|
+
for (const name of names) {
|
|
1131
|
+
const match = CANONICAL_ZSTD_LOG.exec(name);
|
|
1132
|
+
if (match === null) continue;
|
|
1133
|
+
const version = match[1] === void 0 ? 0 : Number(match[1]);
|
|
1134
|
+
if (version > bestVersion) {
|
|
1135
|
+
bestVersion = version;
|
|
1136
|
+
best = name;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return best;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* 逐帧走一遍日志,每条记录交给回调。
|
|
1143
|
+
*
|
|
1144
|
+
* 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
|
|
1145
|
+
* 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
|
|
1146
|
+
*/
|
|
1147
|
+
function walkSessionLog(raw, onRecord) {
|
|
1148
|
+
const stats = {
|
|
1149
|
+
frames: 0,
|
|
1150
|
+
records: 0,
|
|
1151
|
+
unparsedLines: 0,
|
|
1152
|
+
truncatedTailBytes: 0
|
|
1153
|
+
};
|
|
1154
|
+
let stopped = false;
|
|
1155
|
+
const feed = (line) => {
|
|
1156
|
+
if (line === "") return;
|
|
1157
|
+
let parsed;
|
|
1158
|
+
try {
|
|
1159
|
+
parsed = JSON.parse(line);
|
|
1160
|
+
} catch {
|
|
1161
|
+
stats.unparsedLines += 1;
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
stats.records += 1;
|
|
1165
|
+
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
1166
|
+
};
|
|
1167
|
+
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
1168
|
+
let carry = Buffer.alloc(0);
|
|
1169
|
+
for (const { start, end } of frameRanges(raw)) {
|
|
1170
|
+
if (stopped) break;
|
|
1171
|
+
stats.frames += 1;
|
|
1172
|
+
let plain;
|
|
1173
|
+
try {
|
|
1174
|
+
plain = zlib.zstdDecompressSync(raw.subarray(start, end));
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`, { cause: error });
|
|
1177
|
+
}
|
|
1178
|
+
const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
|
|
1179
|
+
const cut = merged.lastIndexOf(NEWLINE);
|
|
1180
|
+
if (cut === -1) {
|
|
1181
|
+
carry = merged;
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
carry = merged.subarray(cut + 1);
|
|
1185
|
+
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
1186
|
+
feed(line);
|
|
1187
|
+
if (stopped) break;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
stats.truncatedTailBytes = carry.length;
|
|
1191
|
+
if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
|
|
1192
|
+
return stats;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
|
|
1196
|
+
* AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
|
|
1197
|
+
* (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
|
|
1198
|
+
* 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
|
|
1199
|
+
*/
|
|
1200
|
+
function readSessionHeader(raw) {
|
|
1201
|
+
let header;
|
|
1202
|
+
walkSessionLog(raw, (record) => {
|
|
1203
|
+
if (record.type !== "session") return;
|
|
1204
|
+
header = record;
|
|
1205
|
+
return false;
|
|
1206
|
+
});
|
|
1207
|
+
return header;
|
|
1208
|
+
}
|
|
1209
|
+
//#endregion
|
|
1210
|
+
//#region src/trash/attachments.ts
|
|
1211
|
+
/**
|
|
1212
|
+
* 附件的**唯一域模块**:内容块提取、ID/路径闸、引用扫描、独占计划与执行。
|
|
1213
|
+
*
|
|
1214
|
+
* 两条红线:
|
|
1215
|
+
* ① 只删 `$DSH_HOME/attachments/v1/objects` 下的图片;普通文件、tmp、v2、cache 一律不碰;
|
|
1216
|
+
* 源文件($DSH_HOME 之外)没有进入路径拼接的入口。
|
|
1217
|
+
* ② 只要剩余引用源读不出来,就整体跳过(fail closed)——宁可留孤儿,不许删共享。
|
|
1218
|
+
* 引用源"读不出来"包括:目录/文件读失败、目录非空却没有规范 zstd 日志(明文/陌生布局)、
|
|
1219
|
+
* JSON 超出扫描上限、日志有坏行或尾巴被截断。
|
|
1220
|
+
* 宿主的内核写锁 `session.lock` 例外:它不是会话内容,恒为 0 字节时既不算读不懂、也不算
|
|
1221
|
+
* "目录里有东西";非空才回落到上面的 fail closed(判据见 `imageIdsOfDir`)。
|
|
1222
|
+
*/
|
|
1223
|
+
const ATTACHMENT_ID = /^sha256:[0-9a-f]{64}$/u;
|
|
1224
|
+
/** 合法附件 ID:形状是 `sha256:` + 64 位小写十六进制。
|
|
1225
|
+
* 谓词必须收成**模板字面量形状**而不是 `string`:`attachmentPathOf` 里 `!isAttachmentId(id)` 的
|
|
1226
|
+
* 假支若按「是 string」取反会被 TS 收成 `never`(参数本来就是 string),插值就成了 never 读数。
|
|
1227
|
+
* 这里判的仍是同一条正则,谓词只是把「形状」写进类型面。 */
|
|
1228
|
+
function isAttachmentId(value) {
|
|
1229
|
+
return typeof value === "string" && ATTACHMENT_ID.test(value);
|
|
1230
|
+
}
|
|
1231
|
+
function isRecord(value) {
|
|
1232
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1233
|
+
}
|
|
1234
|
+
function numberOf(value) {
|
|
1235
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
1236
|
+
}
|
|
1237
|
+
/** `statFile` / `readFile` / `sizeOfDir` 里只有 ENOENT 能当"不在";权限、盘挂、I/O 错误都必须露成读不出。 */
|
|
1238
|
+
function isMissing(error) {
|
|
1239
|
+
return error?.code === "ENOENT";
|
|
1240
|
+
}
|
|
1241
|
+
/** 合法附件 ID → 宿主附件库里的绝对路径。`name` 不参与拼接。 */
|
|
1242
|
+
function attachmentPathOf(home, kind, id) {
|
|
1243
|
+
if (!isAttachmentId(id)) throw new Error(`附件 id 形状不对:${id}`);
|
|
1244
|
+
const hex = id.slice(7);
|
|
1245
|
+
const base = `${home}/attachments/v1/${kind === "image" ? "objects" : "file-objects"}`;
|
|
1246
|
+
const file = `${base}/${hex.slice(0, 2)}/${hex}`;
|
|
1247
|
+
const rel = path.relative(path.resolve(base), path.resolve(file));
|
|
1248
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) throw new Error(`附件路径越界:${id}`);
|
|
1249
|
+
return file;
|
|
1250
|
+
}
|
|
1251
|
+
/** 一条消息的 `content[]` 里的附件块;畸形 id / 非附件段一律跳过。 */
|
|
1252
|
+
function attachmentsInContent(content) {
|
|
1253
|
+
if (!Array.isArray(content)) return [];
|
|
1254
|
+
const out = [];
|
|
1255
|
+
for (const part of content) {
|
|
1256
|
+
if (!isRecord(part)) continue;
|
|
1257
|
+
const type = part.type;
|
|
1258
|
+
const attachment = part.attachment;
|
|
1259
|
+
if (type !== "image" && type !== "file" || !isRecord(attachment)) continue;
|
|
1260
|
+
const id = attachment.attachmentId;
|
|
1261
|
+
if (!isAttachmentId(id)) continue;
|
|
1262
|
+
if (type === "image") out.push({
|
|
1263
|
+
kind: "image",
|
|
1264
|
+
id,
|
|
1265
|
+
...typeof attachment.mediaType === "string" ? { mediaType: attachment.mediaType } : {},
|
|
1266
|
+
bytes: numberOf(attachment.bytes),
|
|
1267
|
+
...numberOf(attachment.width) > 0 ? { width: numberOf(attachment.width) } : {},
|
|
1268
|
+
...numberOf(attachment.height) > 0 ? { height: numberOf(attachment.height) } : {},
|
|
1269
|
+
...typeof attachment.name === "string" ? { name: attachment.name } : {}
|
|
1270
|
+
});
|
|
1271
|
+
else out.push({
|
|
1272
|
+
kind: "file",
|
|
1273
|
+
id,
|
|
1274
|
+
name: typeof attachment.name === "string" ? attachment.name : "",
|
|
1275
|
+
bytes: numberOf(attachment.bytes)
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
return out;
|
|
1279
|
+
}
|
|
1280
|
+
function contentArraysOf(record) {
|
|
1281
|
+
const data = record.data;
|
|
1282
|
+
if (!isRecord(data)) return [];
|
|
1283
|
+
const out = [];
|
|
1284
|
+
if (Array.isArray(data.content)) out.push(data.content);
|
|
1285
|
+
if (isRecord(data.message) && Array.isArray(data.message.content)) out.push(data.message.content);
|
|
1286
|
+
return out;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* `walkSessionLog` 对坏行与截断尾巴是**跳过并计数**,不抛:引用扫描如果只看解析出来的记录,
|
|
1290
|
+
* 就会把"少读了一行"当成"那一行没有引用"。这里的 `.damaged` 有值 ⇒ 调用方必须按不可读处理。
|
|
1291
|
+
*/
|
|
1292
|
+
function scanImageIdsInLog(raw) {
|
|
1293
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1294
|
+
const stats = walkSessionLog(raw, (record) => {
|
|
1295
|
+
for (const content of contentArraysOf(record)) for (const ref of attachmentsInContent(content)) if (ref.kind === "image") ids.add(ref.id);
|
|
1296
|
+
});
|
|
1297
|
+
const damaged = stats.unparsedLines > 0 || stats.truncatedTailBytes > 0 ? `日志不完整(坏行 ${stats.unparsedLines} 条、截断尾巴 ${stats.truncatedTailBytes} 字节)` : void 0;
|
|
1298
|
+
return {
|
|
1299
|
+
ids,
|
|
1300
|
+
...damaged === void 0 ? {} : { damaged }
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
/** 宿主写日志时的临时名:`session.vN.jsonl.zstd.<hash>.tmp`(内容写完后才 rename 成规范名)。 */
|
|
1304
|
+
const TEMP_LOG_NAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?\.[0-9a-f]+\.tmp$/u;
|
|
1305
|
+
/**
|
|
1306
|
+
* 宿主的内核写锁:宿主以写方式打开它即截断、**从不写入内容**、释放也从不删除(POSIX 上是
|
|
1307
|
+
* `flock(2)` 的目标,Windows 上没有这个文件)——所以每个已实体化的会话目录、以及跟着搬进
|
|
1308
|
+
* 回收站的条目里,它都与日志常态并存。锁只表达"谁在写",不承载会话记录。
|
|
1309
|
+
*/
|
|
1310
|
+
const LOCK_NAME = "session.lock";
|
|
1311
|
+
/** 规范日志之外允许并存的两类文件:更老一代的规范日志(新代包含旧代)与 0 字节临时日志(没有消息)。
|
|
1312
|
+
* 其余任何文件都可能是规范日志没覆盖到的引用(例如写入中的非空临时日志),一律按不可读处理。
|
|
1313
|
+
* 宿主的 `session.lock` 到不了这里——它在 `imageIdsOfDir` 挑名单时就按"不是会话内容"剥掉了。 */
|
|
1314
|
+
async function unreadableExtraFile(deps, dir, names) {
|
|
1315
|
+
for (const name of names) {
|
|
1316
|
+
if (isCanonicalSessionLogName(name)) continue;
|
|
1317
|
+
if (!TEMP_LOG_NAME.test(name)) return `${dir}: 规范日志旁还有无法确认的文件 ${name}`;
|
|
1318
|
+
if ((await deps.fs.statFile(`${dir}/${name}`)).size > 0) return `${dir}: 规范日志旁还有非空临时日志 ${name}`;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/** 一个会话目录里的图片引用;`unreadable` 有值 = 这一组不可知(调用方按"跳过它"处理)。
|
|
1322
|
+
*
|
|
1323
|
+
* **没有规范 zstd 日志 ≠ 没有引用**:明文日志、别的版本/布局、以及子目录都可能藏着引用。
|
|
1324
|
+
* **有规范日志但目录里还有子目录也不够**:日志只覆盖本目录,子目录里的引用读它看不见——
|
|
1325
|
+
* 同样按不可读处理(宁可留下对象等维护卡兜底,不冒"没引用")。
|
|
1326
|
+
* **规范日志旁的文件也要过关**:老一代规范日志可以并存(新代包含旧代);0 字节临时日志是
|
|
1327
|
+
* 写盘未完成的空产物,也没有引用。非空临时日志、明文日志或其他任何文件仍按不可读处理(fail closed)。
|
|
1328
|
+
* **宿主的内核写锁不是会话内容**:0 字节的锁在判目录形状之前先剥掉——不然每个已实体化的会话目录
|
|
1329
|
+
* (含搬进回收站的条目)都会被读成"读不懂",整份引用扫描在真机上恒跳过;没有日志的锁目录因此与
|
|
1330
|
+
* 空目录同判。非空的锁说明我们对这个文件的理解失效(宿主不会往里写字节),仍按不可读处理。
|
|
1331
|
+
*/
|
|
1332
|
+
async function imageIdsOfDir(deps, dir) {
|
|
1333
|
+
if (!await deps.fs.exists(dir)) return { ids: /* @__PURE__ */ new Set() };
|
|
1334
|
+
let names;
|
|
1335
|
+
try {
|
|
1336
|
+
names = await deps.fs.listFiles(dir);
|
|
1337
|
+
const withoutLock = [];
|
|
1338
|
+
for (const name of names) {
|
|
1339
|
+
if (name !== LOCK_NAME) {
|
|
1340
|
+
withoutLock.push(name);
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if ((await deps.fs.statFile(`${dir}/${name}`)).size > 0) return {
|
|
1344
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1345
|
+
unreadable: `${dir}: 锁文件 ${name} 非空,无法确认里面没有引用`
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
names = withoutLock;
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
return {
|
|
1351
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1352
|
+
unreadable: String(error)
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
const log = pickSessionLog(names);
|
|
1356
|
+
if (log === void 0) {
|
|
1357
|
+
if (names.length > 0 && names.every((name) => TEMP_LOG_NAME.test(name))) {
|
|
1358
|
+
let subdirs;
|
|
1359
|
+
try {
|
|
1360
|
+
subdirs = await deps.fs.listDirs(dir);
|
|
1361
|
+
if (subdirs.length === 0) {
|
|
1362
|
+
let allEmpty = true;
|
|
1363
|
+
for (const name of names) if ((await deps.fs.statFile(`${dir}/${name}`)).size > 0) {
|
|
1364
|
+
allEmpty = false;
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
if (allEmpty) return { ids: /* @__PURE__ */ new Set() };
|
|
1368
|
+
}
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
return {
|
|
1371
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1372
|
+
unreadable: String(error)
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (names.length > 0) return {
|
|
1377
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1378
|
+
unreadable: `${dir}: 没有可读的会话日志`
|
|
1379
|
+
};
|
|
1380
|
+
let subdirs;
|
|
1381
|
+
try {
|
|
1382
|
+
subdirs = await deps.fs.listDirs(dir);
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
return {
|
|
1385
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1386
|
+
unreadable: String(error)
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
if (subdirs.length > 0) return {
|
|
1390
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1391
|
+
unreadable: `${dir}: 没有可读的会话日志`
|
|
1392
|
+
};
|
|
1393
|
+
return { ids: /* @__PURE__ */ new Set() };
|
|
1394
|
+
}
|
|
1395
|
+
try {
|
|
1396
|
+
const extra = await unreadableExtraFile(deps, dir, names);
|
|
1397
|
+
if (extra !== void 0) return {
|
|
1398
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1399
|
+
unreadable: extra
|
|
1400
|
+
};
|
|
1401
|
+
if ((await deps.fs.listDirs(dir)).length > 0) return {
|
|
1402
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1403
|
+
unreadable: `${dir}: 目录里还有子目录,规范日志覆盖不到它们`
|
|
1404
|
+
};
|
|
1405
|
+
const scan = scanImageIdsInLog(await deps.fs.readFile(`${dir}/${log}`));
|
|
1406
|
+
if (scan.damaged !== void 0) return {
|
|
1407
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1408
|
+
unreadable: `${dir}/${log}: ${scan.damaged}`
|
|
1409
|
+
};
|
|
1410
|
+
return { ids: scan.ids };
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
return {
|
|
1413
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1414
|
+
unreadable: String(error)
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
/** 单个 JSON 引用源的扫描上限;超过就记成不可读(fail closed),**不许当作"没有引用"跳过**。 */
|
|
1419
|
+
const MAX_JSON_REF_BYTES = 10485760;
|
|
1420
|
+
/** 读全部剩余引用源;任何一处读不出都记进 `unreadable`。 */
|
|
1421
|
+
async function collectRemainingImageRefs(deps, input) {
|
|
1422
|
+
const exclude = new Set(input.excludeDirs);
|
|
1423
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1424
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1425
|
+
const unreadable = [];
|
|
1426
|
+
const visit = async (dir) => {
|
|
1427
|
+
if (exclude.has(dir) || visited.has(dir)) return;
|
|
1428
|
+
visited.add(dir);
|
|
1429
|
+
const read = await imageIdsOfDir(deps, dir);
|
|
1430
|
+
if (read.unreadable !== void 0) unreadable.push(read.unreadable);
|
|
1431
|
+
for (const id of read.ids) ids.add(id);
|
|
1432
|
+
};
|
|
1433
|
+
if (await deps.fs.exists(input.sessionRoot)) for (const project of await deps.fs.listDirs(input.sessionRoot)) {
|
|
1434
|
+
const projectDir = `${input.sessionRoot}/${project}`;
|
|
1435
|
+
if (exclude.has(projectDir)) continue;
|
|
1436
|
+
for (const session of await deps.fs.listDirs(projectDir)) await visit(`${projectDir}/${session}`);
|
|
1437
|
+
}
|
|
1438
|
+
const trashRoot = `${deps.home}/${TRASH_DIR_NAME}`;
|
|
1439
|
+
if (await deps.fs.exists(trashRoot)) for (const name of await deps.fs.listDirs(trashRoot)) await visit(`${trashRoot}/${name}`);
|
|
1440
|
+
const scanJson = async (root) => {
|
|
1441
|
+
if (!await deps.fs.exists(root)) return;
|
|
1442
|
+
const stack = [root];
|
|
1443
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1444
|
+
while (stack.length > 0) {
|
|
1445
|
+
const dir = stack.pop();
|
|
1446
|
+
try {
|
|
1447
|
+
const real = await deps.fs.realpath(dir);
|
|
1448
|
+
if (seen.has(real)) continue;
|
|
1449
|
+
seen.add(real);
|
|
1450
|
+
for (const name of await deps.fs.listDirs(dir)) stack.push(`${dir}/${name}`);
|
|
1451
|
+
for (const name of await deps.fs.listFiles(dir)) {
|
|
1452
|
+
if (!name.endsWith(".json")) continue;
|
|
1453
|
+
const file = `${dir}/${name}`;
|
|
1454
|
+
const stat = await deps.fs.statFile(file);
|
|
1455
|
+
if (stat.size > MAX_JSON_REF_BYTES) {
|
|
1456
|
+
unreadable.push(`${file}: JSON 引用源 ${stat.size} 字节超过扫描上限 ${MAX_JSON_REF_BYTES} 字节,未扫描`);
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
const text = (await deps.fs.readFile(file)).toString("utf8");
|
|
1460
|
+
for (const match of text.matchAll(/sha256:[0-9a-f]{64}/gu)) ids.add(match[0]);
|
|
1461
|
+
}
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
unreadable.push(`${dir}: ${String(error)}`);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
await scanJson(`${deps.home}/storages`);
|
|
1468
|
+
await scanJson(`${deps.home}/cache`);
|
|
1469
|
+
return {
|
|
1470
|
+
ids,
|
|
1471
|
+
unreadable
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* 独占计划:候选 = 本次被删会话日志里的图片 id;剩余引用 = 活动会话 + 全部未删 `session-trash`
|
|
1476
|
+
* 目录 + storages/cache。
|
|
1477
|
+
* 剩余引用源读不出 ⇒ 整体跳过;候选日志读不出 / 附件对象 stat 失败 ⇒ 只跳过那一部分,其余照常。
|
|
1478
|
+
*/
|
|
1479
|
+
async function planImageAttachmentRemoval(deps, removedDirs) {
|
|
1480
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1481
|
+
let unreadableCandidates = 0;
|
|
1482
|
+
for (const dir of removedDirs) {
|
|
1483
|
+
const read = await imageIdsOfDir(deps, dir);
|
|
1484
|
+
if (read.unreadable !== void 0) {
|
|
1485
|
+
unreadableCandidates += 1;
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
for (const id of read.ids) candidates.add(id);
|
|
1489
|
+
}
|
|
1490
|
+
const skipReasons = [];
|
|
1491
|
+
if (unreadableCandidates > 0) skipReasons.push(`${unreadableCandidates} 个被删会话日志读不出`);
|
|
1492
|
+
if (candidates.size === 0) return {
|
|
1493
|
+
entries: [],
|
|
1494
|
+
bytes: 0,
|
|
1495
|
+
...skipReasons.length > 0 ? { skipped: skipReasons.join(";") } : {}
|
|
1496
|
+
};
|
|
1497
|
+
let remaining;
|
|
1498
|
+
try {
|
|
1499
|
+
remaining = await collectRemainingImageRefs(deps, {
|
|
1500
|
+
sessionRoot: `${deps.home}/sessions`,
|
|
1501
|
+
excludeDirs: removedDirs
|
|
1502
|
+
});
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
return {
|
|
1505
|
+
entries: [],
|
|
1506
|
+
bytes: 0,
|
|
1507
|
+
skipped: `读不到剩余引用源,无法证明独占:${String(error)}`
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
if (remaining.unreadable.length > 0) return {
|
|
1511
|
+
entries: [],
|
|
1512
|
+
bytes: 0,
|
|
1513
|
+
skipped: `读不到剩余引用源,无法证明独占:${remaining.unreadable[0]}`
|
|
1514
|
+
};
|
|
1515
|
+
const entries = [];
|
|
1516
|
+
let bytes = 0;
|
|
1517
|
+
let unreadableObjects = 0;
|
|
1518
|
+
for (const id of candidates) {
|
|
1519
|
+
if (remaining.ids.has(id)) continue;
|
|
1520
|
+
const path = attachmentPathOf(deps.home, "image", id);
|
|
1521
|
+
try {
|
|
1522
|
+
const stat = await deps.fs.statFile(path);
|
|
1523
|
+
entries.push({
|
|
1524
|
+
id,
|
|
1525
|
+
path,
|
|
1526
|
+
bytes: stat.size
|
|
1527
|
+
});
|
|
1528
|
+
bytes += stat.size;
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
if (!isMissing(error)) unreadableObjects += 1;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
if (unreadableObjects > 0) skipReasons.push(`${unreadableObjects} 个附件对象读不出`);
|
|
1534
|
+
return {
|
|
1535
|
+
entries,
|
|
1536
|
+
bytes,
|
|
1537
|
+
...skipReasons.length > 0 ? { skipped: skipReasons.join(";") } : {}
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* 逐文件删;ENOENT 由端口的 `force` 吞掉;其他错误计数并继续。
|
|
1542
|
+
*
|
|
1543
|
+
* 删除动作只认「`home` + `id` 自己推导出来的那一条路径」:计划是数据,手搭的/被改过的条目
|
|
1544
|
+
* 只要路径与推导不一致就拒绝(计数为失败,不删),源文件因此没有删除入口。
|
|
1545
|
+
*/
|
|
1546
|
+
async function executeImageAttachmentRemoval(deps, plan) {
|
|
1547
|
+
let removed = 0;
|
|
1548
|
+
let freedBytes = 0;
|
|
1549
|
+
let failed = 0;
|
|
1550
|
+
for (const entry of plan.entries) {
|
|
1551
|
+
let derived;
|
|
1552
|
+
try {
|
|
1553
|
+
derived = attachmentPathOf(deps.home, "image", entry.id);
|
|
1554
|
+
} catch (error) {
|
|
1555
|
+
failed += 1;
|
|
1556
|
+
console.warn(`附件清理拒绝:计划条目的 id 形状不对:${entry.id}`, error);
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
if (derived !== entry.path) {
|
|
1560
|
+
failed += 1;
|
|
1561
|
+
console.warn(`附件清理拒绝:计划路径与 id 推导不一致:${entry.id}`, entry.path);
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
try {
|
|
1565
|
+
await deps.fs.removeFile(derived);
|
|
1566
|
+
removed += 1;
|
|
1567
|
+
freedBytes += entry.bytes;
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
failed += 1;
|
|
1570
|
+
console.warn(`附件清理失败:${entry.id}`, error);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return {
|
|
1574
|
+
removed,
|
|
1575
|
+
freedBytes,
|
|
1576
|
+
failed
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
/** 真实宿主的图片库布局是 `objects/<2 hex>/<64 hex>`;这两条形状之外的名字一律不是候选。 */
|
|
1580
|
+
const OBJECT_PREFIX = /^[0-9a-f]{2}$/u;
|
|
1581
|
+
const OBJECT_NAME = /^[0-9a-f]{64}$/u;
|
|
1582
|
+
/**
|
|
1583
|
+
* 扫「当前没有任何引用源引用」的图片对象。
|
|
1584
|
+
*
|
|
1585
|
+
* 引用侧与删除计划共用同一条判据:活动会话 + 全部 `session-trash` 目录 + `storages/cache` JSON。
|
|
1586
|
+
* 任何一处读不出 ⇒ 整份报告跳过(fail closed),绝不把"读不出"折成"没有引用"。
|
|
1587
|
+
* 候选侧只认 `attachments/v1/objects` 下 `<2 hex>/<64 hex>` 这一种真实形状:`file-objects` /
|
|
1588
|
+
* `tmp` / `v2` / `cache` 与非规范名字一律不进候选——清理只处理 `.dsh` 内的图片副本。
|
|
1589
|
+
*
|
|
1590
|
+
* 对象列表或 stat 读不出同样整份跳过:报告宁可失败,也不给一份"少算了一些"的清理计划。
|
|
1591
|
+
*/
|
|
1592
|
+
async function scanOrphanImageAttachments(deps) {
|
|
1593
|
+
let remaining;
|
|
1594
|
+
try {
|
|
1595
|
+
remaining = await collectRemainingImageRefs(deps, {
|
|
1596
|
+
sessionRoot: `${deps.home}/sessions`,
|
|
1597
|
+
excludeDirs: []
|
|
1598
|
+
});
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
return {
|
|
1601
|
+
entries: [],
|
|
1602
|
+
bytes: 0,
|
|
1603
|
+
skipped: `读不到引用源,无法判定孤儿附件:${String(error)}`
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
if (remaining.unreadable.length > 0) return {
|
|
1607
|
+
entries: [],
|
|
1608
|
+
bytes: 0,
|
|
1609
|
+
skipped: `读不到引用源,无法判定孤儿附件:${remaining.unreadable[0]}`
|
|
1610
|
+
};
|
|
1611
|
+
const objectsRoot = `${deps.home}/attachments/v1/objects`;
|
|
1612
|
+
const entries = [];
|
|
1613
|
+
let bytes = 0;
|
|
1614
|
+
try {
|
|
1615
|
+
if (!await deps.fs.exists(objectsRoot)) return {
|
|
1616
|
+
entries: [],
|
|
1617
|
+
bytes: 0
|
|
1618
|
+
};
|
|
1619
|
+
for (const prefix of await deps.fs.listDirs(objectsRoot)) {
|
|
1620
|
+
if (!OBJECT_PREFIX.test(prefix)) continue;
|
|
1621
|
+
for (const name of await deps.fs.listFiles(`${objectsRoot}/${prefix}`)) {
|
|
1622
|
+
if (!OBJECT_NAME.test(name) || !name.startsWith(prefix)) continue;
|
|
1623
|
+
const id = `sha256:${name}`;
|
|
1624
|
+
if (remaining.ids.has(id)) continue;
|
|
1625
|
+
const objectPath = attachmentPathOf(deps.home, "image", id);
|
|
1626
|
+
const stat = await deps.fs.statFile(objectPath);
|
|
1627
|
+
entries.push({
|
|
1628
|
+
id,
|
|
1629
|
+
path: objectPath,
|
|
1630
|
+
bytes: stat.size
|
|
1631
|
+
});
|
|
1632
|
+
bytes += stat.size;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
return {
|
|
1637
|
+
entries: [],
|
|
1638
|
+
bytes: 0,
|
|
1639
|
+
skipped: `读不到附件对象列表,无法判定孤儿:${String(error)}`
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
return {
|
|
1643
|
+
entries,
|
|
1644
|
+
bytes
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
/** 清掉扫描那一刻无引用的图片对象;扫描跳过 ⇒ 零删,并原样带回跳过的原因。 */
|
|
1648
|
+
async function cleanOrphanImageAttachments(deps) {
|
|
1649
|
+
const plan = await scanOrphanImageAttachments(deps);
|
|
1650
|
+
if (plan.skipped !== void 0) return {
|
|
1651
|
+
removed: 0,
|
|
1652
|
+
freedBytes: 0,
|
|
1653
|
+
failed: 0,
|
|
1654
|
+
skipped: plan.skipped
|
|
1655
|
+
};
|
|
1656
|
+
return executeImageAttachmentRemoval(deps, plan);
|
|
1657
|
+
}
|
|
1658
|
+
//#endregion
|
|
1659
|
+
//#region src/verbs/permanent-removal.ts
|
|
1660
|
+
/**
|
|
1661
|
+
* 永久删除的测量、计划与执行:先记录目录/字节/账本归属,再按计划动盘。
|
|
1662
|
+
* 计划函数只读;活体准入在计划阶段、任何写之前完成,执行器动第一笔写前再查一次。
|
|
1663
|
+
*
|
|
1664
|
+
* 测量与补账本归属**分家**:`/list` 的只读计划只吃 {@link measureMembers}(零账本读,不再为每个
|
|
1665
|
+
* 盘上后代问一次 `workspaceOf`,`/list` 的失败面不再含账本归属读);`workspaceOf` 只有
|
|
1666
|
+
* purge / orphan-clean 的删除计划({@link planMemberRemovals})才付——executor 摘账本要用它。
|
|
1667
|
+
*/
|
|
1668
|
+
/** `exists → sizeOfDir` 的唯一一条度量口径(成员目录与回收站目录共用):先问在不在,再量字节;
|
|
1669
|
+
* ENOENT 是唯一一种当"不在"的失败,别的错误照旧响亮。别再在调用点各写一遍这段 try/catch。
|
|
1670
|
+
*
|
|
1671
|
+
* **已知粒度**:`sizeOfDir` 递归里撞到的 ENOENT 与"目录本身没了"是同一个码,所以目录里某个文件
|
|
1672
|
+
* 恰好在这一拍消失时,整条目录会被记成「不在」(`/list` 少算这条、删除计划里没有可删的字节,
|
|
1673
|
+
* 而清册行照旧摘——盘上留下目录等下一次 `reconcile` 重建成行,自愈、不丢数据)。复核一次
|
|
1674
|
+
* `exists(dir)` 能把两者分开,却会让「宿主正在写、文件刚被挪走」这类正常竞态把 `/list` 整个打红——
|
|
1675
|
+
* 这里选自愈不选响亮。 */
|
|
1275
1676
|
async function probeDir(deps, dir) {
|
|
1276
1677
|
if (!await deps.fs.exists(dir)) return {
|
|
1277
1678
|
present: false,
|
|
@@ -1300,18 +1701,32 @@ async function measureMemberDir(deps, member) {
|
|
|
1300
1701
|
bytes: probe.bytes
|
|
1301
1702
|
} : void 0;
|
|
1302
1703
|
}
|
|
1303
|
-
/**
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
async function
|
|
1307
|
-
const
|
|
1704
|
+
/** 只测量(目录 + 实测字节):`/list` 的只读计划只吃这一支,零账本读。目录不在的成员跳过
|
|
1705
|
+
* (记录与盘不一致是那条成员自己的事)。为什么不顺手把归属也问了:`/list` 投影只用字节与条数,
|
|
1706
|
+
* 那一格立刻被丢弃——每开一次回收站为它付 N 次宿主调用,还把账本归属读拉进 `/list` 的失败面。 */
|
|
1707
|
+
async function measureMembers(deps, members) {
|
|
1708
|
+
const out = [];
|
|
1308
1709
|
for (const member of members) {
|
|
1309
1710
|
const measured = await measureMemberDir(deps, member);
|
|
1310
1711
|
if (measured === void 0) continue;
|
|
1311
|
-
|
|
1312
|
-
plans.push({
|
|
1712
|
+
out.push({
|
|
1313
1713
|
sessionId: member.sessionId,
|
|
1314
|
-
|
|
1714
|
+
projectDir: member.projectDir,
|
|
1715
|
+
...measured
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
return out;
|
|
1719
|
+
}
|
|
1720
|
+
/** 把一组成员折成"删之前要记下的计划":{@link measureMembers} 的测量之外**逐成员补账本归属**
|
|
1721
|
+
* (`workspaceOf`)——executor 摘账本要用。`/list` 不走这里(它只吃 measureMembers,零账本读)。 */
|
|
1722
|
+
async function planMemberRemovals(deps, members) {
|
|
1723
|
+
const plans = [];
|
|
1724
|
+
for (const measured of await measureMembers(deps, members)) {
|
|
1725
|
+
const workspaceId = await deps.host.workspaceOf(measured.sessionId);
|
|
1726
|
+
plans.push({
|
|
1727
|
+
sessionId: measured.sessionId,
|
|
1728
|
+
dir: measured.dir,
|
|
1729
|
+
bytes: measured.bytes,
|
|
1315
1730
|
...workspaceId === void 0 ? {} : { workspaceId }
|
|
1316
1731
|
});
|
|
1317
1732
|
}
|
|
@@ -1331,7 +1746,7 @@ async function measureTrashRow(deps, entry) {
|
|
|
1331
1746
|
/**
|
|
1332
1747
|
* 盘上后代的选择判据(`/list` 与 purge 的删除计划共用这一份):`subagentMembersOf` 一次问全部父,
|
|
1333
1748
|
* 再按 `bareSessionId` **排除全部清册 id**(跟着父进来的孩子不按盘上残留再算一遍),同一个 bare id 只认
|
|
1334
|
-
*
|
|
1749
|
+
* **遍历顺序里第一次**出现的那条(同一个会话在两代宿主里的写法不同;去重口径住 `src/domain/closure.ts` 的 `firstByBare`)。
|
|
1335
1750
|
*
|
|
1336
1751
|
* 语料看不见(`available: false`)⇒ 每个根都按空成员收:`/list` 与 purge 只许静默降级,
|
|
1337
1752
|
* 盘上残留留给 `GET /orphans` 兜底。
|
|
@@ -1346,32 +1761,41 @@ async function measureTrashRow(deps, entry) {
|
|
|
1346
1761
|
async function selectStrandedMembers(host, manifest, parentIds) {
|
|
1347
1762
|
const manifestIds = new Set(manifest.map((row) => bareSessionId(row.sessionId)));
|
|
1348
1763
|
const read = await host.subagentMembersOf(parentIds);
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
if (!read.available) return wanted;
|
|
1764
|
+
if (!read.available) return parentIds.map(() => []);
|
|
1765
|
+
const flat = [];
|
|
1766
|
+
parentIds.forEach((parentId, groupIndex) => {
|
|
1353
1767
|
for (const member of read.value[parentId] ?? []) {
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1768
|
+
if (manifestIds.has(bareSessionId(member.sessionId))) continue;
|
|
1769
|
+
flat.push({
|
|
1770
|
+
sessionId: member.sessionId,
|
|
1771
|
+
projectDir: member.projectDir,
|
|
1772
|
+
member,
|
|
1773
|
+
groupIndex
|
|
1774
|
+
});
|
|
1358
1775
|
}
|
|
1359
|
-
return wanted;
|
|
1360
1776
|
});
|
|
1777
|
+
const out = parentIds.map(() => []);
|
|
1778
|
+
for (const entry of firstByBare(flat)) {
|
|
1779
|
+
const bucket = out[entry.groupIndex];
|
|
1780
|
+
if (bucket === void 0) throw verbError("io", `子会话分组越界:${entry.member.sessionId}`);
|
|
1781
|
+
bucket.push(entry.member);
|
|
1782
|
+
}
|
|
1783
|
+
return out;
|
|
1361
1784
|
}
|
|
1362
1785
|
/**
|
|
1363
1786
|
* `/list` 的只读计划:一行 = 一组。释放量与 purge 用同一条口径——回收站目录与成员目录**现测**,
|
|
1364
1787
|
* 不用清册里记的 `sizeBytes`(那是删除那一刻的事实,盘上早就变了)。
|
|
1365
1788
|
*
|
|
1366
1789
|
* 盘上后代经 `selectStrandedMembers` 选出(排除全部清册 id、bare id 全局只算一次);目录不在的成员
|
|
1367
|
-
* 不进 `members`(与
|
|
1790
|
+
* 不进 `members`(与 purge 计划同口径)。成员只吃 {@link measureMembers}:**零账本读**——不再为每个
|
|
1791
|
+
* 盘上后代问一次 `workspaceOf`,`/list` 的失败面不再含账本归属读(归属只有 purge 摘账本才付)。
|
|
1368
1792
|
*/
|
|
1369
1793
|
async function planVisibleTrash(deps, rows, groups) {
|
|
1370
1794
|
const candidates = await selectStrandedMembers(deps.host, rows, groups.map((group) => group.root.sessionId));
|
|
1371
1795
|
return await Promise.all(groups.map(async ({ root, kids }, index) => {
|
|
1372
1796
|
const rootRow = await measureTrashRow(deps, root);
|
|
1373
1797
|
const kidRows = await Promise.all(kids.map((kid) => measureTrashRow(deps, kid)));
|
|
1374
|
-
const members = await
|
|
1798
|
+
const members = await measureMembers(deps, candidates[index] ?? []);
|
|
1375
1799
|
const subagentBytes = kidRows.reduce((total, row) => total + row.bytes, 0);
|
|
1376
1800
|
const strandedBytes = members.reduce((total, member) => total + member.bytes, 0);
|
|
1377
1801
|
return {
|
|
@@ -1391,14 +1815,17 @@ function totalBytes(items) {
|
|
|
1391
1815
|
}
|
|
1392
1816
|
/**
|
|
1393
1817
|
* purge 的删除计划:`targets` 由调用方按组语义(`trash/groups.ts`)选好,这里只量、只筛、只查活体。
|
|
1394
|
-
*
|
|
1818
|
+
* **计划函数是执行前的第一道闸**——调用方拿到计划才允许写;计划与第一笔写之间的窗口由
|
|
1819
|
+
* `executeRemoval` 在首笔写前复查(见该函数头注)。
|
|
1395
1820
|
*
|
|
1396
1821
|
* - `trash` 按 targets 原序逐个实测(目录缺席的行 `present:false` / 0 字节,但清册行仍要摘)。
|
|
1397
1822
|
* - 盘上后代经 `selectStrandedMembers` 选出(一次问全部 targets、排除全部清册 id、bare id 全局只计划一次)。
|
|
1398
1823
|
* - `members` 只收盘上还有目录的成员(目录缺席没有可删的字节);`unarchive` = targets + 这些成员:
|
|
1399
1824
|
* 只摘这一次真要碰的 id。
|
|
1825
|
+
* - `attachments` 由被删目录(targets + 这些成员)里的图片引用与全部剩余引用源算独占;共享的保留、
|
|
1826
|
+
* 剩余引用源读不出就整体跳过(判据住 `trash/attachments.ts`,这里只接线)。
|
|
1400
1827
|
* - 活体准入在量目录与任何写之前逐个查 targets 与**全部枚举到的**盘上后代;任一在跑就整条拒绝、
|
|
1401
|
-
*
|
|
1828
|
+
* 零副作用。没有跳过它的开关;执行器首笔写前还会用计划里的 `unarchive` 名单复查(ADR-0027)。
|
|
1402
1829
|
* - `sessionQuery` 缺席时端口回 `{ available: false }` ⇒ members 为空、不抛(盘上残留留给 `GET /orphans` 兜底)。
|
|
1403
1830
|
*/
|
|
1404
1831
|
async function planTrashRemoval(deps, rows, targets) {
|
|
@@ -1406,12 +1833,14 @@ async function planTrashRemoval(deps, rows, targets) {
|
|
|
1406
1833
|
await assertNoLive(deps.host, [...targets.map((entry) => entry.sessionId), ...candidates.map((member) => member.sessionId)]);
|
|
1407
1834
|
const trash = await Promise.all(targets.map((entry) => measureTrashRow(deps, entry)));
|
|
1408
1835
|
const members = await planMemberRemovals(deps, candidates);
|
|
1836
|
+
const attachments = await planImageAttachmentRemoval(deps, [...trash.map((row) => row.dir), ...members.map((member) => member.dir)]);
|
|
1409
1837
|
return {
|
|
1410
1838
|
kind: "trash",
|
|
1411
1839
|
trash,
|
|
1412
1840
|
members,
|
|
1413
1841
|
unarchive: [...targets.map((entry) => entry.sessionId), ...members.map((member) => member.sessionId)],
|
|
1414
|
-
bytes: totalBytes(trash) + totalBytes(members)
|
|
1842
|
+
bytes: totalBytes(trash) + totalBytes(members),
|
|
1843
|
+
attachments
|
|
1415
1844
|
};
|
|
1416
1845
|
}
|
|
1417
1846
|
/**
|
|
@@ -1421,36 +1850,51 @@ async function planTrashRemoval(deps, rows, targets) {
|
|
|
1421
1850
|
* - `unarchive` 覆盖**全部传入成员**(含目录缺席):orphan-clean 在删目录之前整批摘归档集,
|
|
1422
1851
|
* 目录缺席的 id 也可能还挂在归档集里;重试时不会再长出一条幽灵 id。
|
|
1423
1852
|
* - live 准入覆盖**全部传入成员**(含目录缺席):它们都在这一组的可见面里,任一在跑就整组拒绝。
|
|
1853
|
+
* - `attachments` 的剩余引用源由附件域自己扫(活动会话 + 全部 session-trash + storages/cache),
|
|
1854
|
+
* 共享图片因此保留(判据住 `trash/attachments.ts`)。
|
|
1424
1855
|
* - 回执由 `kind:'orphan'` 派生:删目录阶段失败时执行器要交回 `{removed, freedBytes, failedSessionId}`。
|
|
1425
1856
|
*/
|
|
1426
1857
|
async function planOrphanRemoval(deps, members) {
|
|
1427
1858
|
await assertNoLive(deps.host, members.map((member) => member.sessionId));
|
|
1428
1859
|
const present = await planMemberRemovals(deps, members);
|
|
1860
|
+
const attachments = await planImageAttachmentRemoval(deps, present.map((member) => member.dir));
|
|
1429
1861
|
return {
|
|
1430
1862
|
kind: "orphan",
|
|
1431
1863
|
trash: [],
|
|
1432
1864
|
members: present,
|
|
1433
1865
|
unarchive: members.map((member) => member.sessionId),
|
|
1434
|
-
bytes: totalBytes(present)
|
|
1866
|
+
bytes: totalBytes(present),
|
|
1867
|
+
attachments
|
|
1435
1868
|
};
|
|
1436
1869
|
}
|
|
1437
1870
|
/**
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1871
|
+
* 按计划执行一次永久删除:不再量。回执的字节念计划(`plan.bytes`),执行期不再累加一遍。
|
|
1872
|
+
*
|
|
1873
|
+
* **计划 + 执行器、准入查两遍**(ADR-0027):计划期准入证明计划成立时没有会话在跑;执行器首笔写前
|
|
1874
|
+
* 再用同一份名单复查,补上附件扫描撑开的窗口。
|
|
1875
|
+
*
|
|
1876
|
+
* 计划期准入与第一笔写之间还隔着附件引用的全 home 扫描(`planImageAttachmentRemoval`),大 home
|
|
1877
|
+
* 上那是秒级窗口——期间用户 resume 一个会话,它的目录随后仍会被删。所以动任何写之前用计划里同一份
|
|
1878
|
+
* 会话名单**再查一次活体**(只读、廉价);这一查失败仍然零写入。名单取 `plan.unarchive`:
|
|
1879
|
+
* 它覆盖所有会被删目录的会话(回收站行与盘上成员),目录已不在的成员没有可删的东西。
|
|
1880
|
+
*
|
|
1881
|
+
* 顺序:整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
|
|
1882
|
+
* (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)→ 逐条附件对象。
|
|
1883
|
+
* 目录删掉就没有"退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
|
|
1440
1884
|
*
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1443
|
-
* "退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
|
|
1885
|
+
* 附件清理排在**所有会话目录删完之后**:对象只在会话真的没了之后才动;单文件失败只进
|
|
1886
|
+
* `attachments.failed`,不影响(也不回滚)会话删除的回执。
|
|
1444
1887
|
*
|
|
1445
|
-
*
|
|
1888
|
+
* 失败形状:
|
|
1446
1889
|
* - `kind:'orphan'`(orphan-clean)⇒ `io` + `{removed, freedBytes, failedSessionId}`,不伪装成整组没动;
|
|
1447
|
-
* - `kind:'trash'`(purge)⇒ 同一个标签、不带 details
|
|
1448
|
-
* - orphan-clean 的 unarchive / detach
|
|
1890
|
+
* - `kind:'trash'`(purge)⇒ 同一个标签、不带 details;
|
|
1891
|
+
* - orphan-clean 的 unarchive / detach 原样抛出。
|
|
1449
1892
|
*
|
|
1450
1893
|
* 通告只经 `member-presence`(摘活体三态 + `api-session/removed`),不在这里另写一份。
|
|
1451
1894
|
*/
|
|
1452
1895
|
async function executeRemoval(deps, plan) {
|
|
1453
|
-
|
|
1896
|
+
await assertNoLive(deps.host, plan.unarchive);
|
|
1897
|
+
const presence = deps.presence;
|
|
1454
1898
|
let removed = 0;
|
|
1455
1899
|
/** 删到这一刻已经腾出的**进度**:只喂 `orphan` 的部分回执;成功回执念计划的合计(`plan.bytes`)。 */
|
|
1456
1900
|
let freedSoFar = 0;
|
|
@@ -1496,7 +1940,7 @@ async function executeRemoval(deps, plan) {
|
|
|
1496
1940
|
try {
|
|
1497
1941
|
await deps.ledger.remove(row.entry.id);
|
|
1498
1942
|
} catch (error) {
|
|
1499
|
-
fail("
|
|
1943
|
+
fail("摘除回收站条目失败", error, row.entry.sessionId);
|
|
1500
1944
|
}
|
|
1501
1945
|
removed += 1;
|
|
1502
1946
|
await presence.memberGone(row.entry.sessionId);
|
|
@@ -1512,9 +1956,14 @@ async function executeRemoval(deps, plan) {
|
|
|
1512
1956
|
freedSoFar += member.bytes;
|
|
1513
1957
|
await presence.memberGone(member.sessionId);
|
|
1514
1958
|
}
|
|
1959
|
+
const attachments = await executeImageAttachmentRemoval(deps, plan.attachments);
|
|
1515
1960
|
return {
|
|
1516
1961
|
removed,
|
|
1517
|
-
freedBytes: plan.bytes
|
|
1962
|
+
freedBytes: plan.bytes,
|
|
1963
|
+
attachments: {
|
|
1964
|
+
...attachments,
|
|
1965
|
+
...plan.attachments.skipped === void 0 ? {} : { skipped: plan.attachments.skipped }
|
|
1966
|
+
}
|
|
1518
1967
|
};
|
|
1519
1968
|
}
|
|
1520
1969
|
//#endregion
|
|
@@ -1526,174 +1975,493 @@ async function executeRemoval(deps, plan) {
|
|
|
1526
1975
|
* 彻底删除(组语义见 `src/trash/groups.ts`),把它的根当孤儿删掉就是**替用户做了一个没人点过的决定**。孤儿只
|
|
1527
1976
|
* 有一种来路:父被彻底删掉了(或被手工从盘上清掉了),清册里再也没有这一行。
|
|
1528
1977
|
*
|
|
1529
|
-
* 根按**传递闭包**收全部后代(与 `HostCorpusPort.subagentMembersOf`
|
|
1530
|
-
*
|
|
1978
|
+
* 根按**传递闭包**收全部后代(与 `HostCorpusPort.subagentMembersOf` 同一实现:闭包核心住
|
|
1979
|
+
* `src/domain/closure.ts` 的 `orphanRootsOf`,孩子与孙代都算,深度不设上限),同一条记录只归一组
|
|
1980
|
+
* (`claimed`)——数据里真出现环时结果是"少数一条",不是把一个会话删两遍。
|
|
1531
1981
|
* 归堆与去重**两边都去 `session-` 前缀**:同一个父在两代宿主里写法不同(见 `bareSessionId` 的来历)。
|
|
1532
1982
|
*/
|
|
1533
1983
|
function orphanGroupsOf(corpus, trashSessionIds) {
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1984
|
+
return orphanRootsOf(corpus, [...corpus.map((session) => session.sessionId), ...trashSessionIds]);
|
|
1985
|
+
}
|
|
1986
|
+
/** **本函数是纯扫描,不对账、不写清册行**(`/orphans` 声明为 pure,一次扫描不能顺手写行)。
|
|
1987
|
+
* 清册行由调用方选好再交进来:`/orphans` 交 `read.rows()`(raw 索引),`orphan-clean` 交
|
|
1988
|
+
* `ledger.list()`(对账后的行)——否则刚被人手工搬进 `session-trash/` 的父目录还没有行,会被当成
|
|
1989
|
+
* "父没了"直接删盘。扫描不再自己回落到 `ledger.raw()`:读面归属住路由记录(ADR-0004 / ADR-0012)。
|
|
1990
|
+
*
|
|
1991
|
+
* `listSessions()` 回 `{ available: false }` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
|
|
1992
|
+
* 拿缺席当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
|
|
1993
|
+
* 所以这里按 `io` 响亮失败(AGENTS.md:`available: false` 意味着服务缺席,扫描方不许冒充读数)。
|
|
1994
|
+
*
|
|
1995
|
+
* `roots` 只收请求点名的那几组、**在量字节之前过滤**:`/orphans` 不传(报告全部),
|
|
1996
|
+
* `orphan-clean` 传这次的根——否则点一组会把整个家所有孤儿的树都递归量一遍。 */
|
|
1997
|
+
async function loadOrphanGroups(deps, trash, roots) {
|
|
1998
|
+
const read = await deps.host.listSessions();
|
|
1999
|
+
if (!read.available) throw verbError("io", "无主子会话扫描需要宿主的 sessionQuery.listSessions");
|
|
2000
|
+
const corpus = read.value;
|
|
2001
|
+
const all = orphanGroupsOf(corpus, trash.map((entry) => entry.sessionId));
|
|
2002
|
+
const wanted = roots === void 0 ? void 0 : new Set(roots.map((root) => bareSessionId(root)));
|
|
2003
|
+
const groups = wanted === void 0 ? all : all.filter((group) => wanted.has(bareSessionId(group.root.sessionId)));
|
|
2004
|
+
const out = [];
|
|
2005
|
+
for (const group of groups) {
|
|
2006
|
+
const members = [];
|
|
2007
|
+
for (const member of group.members) {
|
|
2008
|
+
const measured = await measureMemberDir(deps, member);
|
|
2009
|
+
members.push({
|
|
2010
|
+
sessionId: member.sessionId,
|
|
2011
|
+
projectDir: member.projectDir,
|
|
2012
|
+
bytes: measured?.bytes ?? 0
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
const root = members[0];
|
|
2016
|
+
if (root === void 0) throw verbError("io", `无主子会话组没有成员(父 ${group.parentSessionId})`);
|
|
2017
|
+
out.push({
|
|
2018
|
+
root,
|
|
2019
|
+
parentSessionId: group.parentSessionId,
|
|
2020
|
+
members
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
return out;
|
|
2024
|
+
}
|
|
2025
|
+
/** 把一组组收成那份报告:根清单 + 闭包总数/总字节 + 失踪父的**去重计数** + 按项目目录的分摊。
|
|
2026
|
+
* `groups` 按字节从多到少排(界面上"哪家还剩得最多"要看第一行)。 */
|
|
2027
|
+
function reportOrphans(groups) {
|
|
2028
|
+
const projects = /* @__PURE__ */ new Map();
|
|
2029
|
+
let totalSessions = 0;
|
|
2030
|
+
let totalBytes = 0;
|
|
2031
|
+
for (const group of groups) for (const member of group.members) {
|
|
2032
|
+
totalSessions += 1;
|
|
2033
|
+
totalBytes += member.bytes;
|
|
2034
|
+
const row = projects.get(member.projectDir) ?? {
|
|
2035
|
+
sessions: 0,
|
|
2036
|
+
bytes: 0
|
|
2037
|
+
};
|
|
2038
|
+
row.sessions += 1;
|
|
2039
|
+
row.bytes += member.bytes;
|
|
2040
|
+
projects.set(member.projectDir, row);
|
|
2041
|
+
}
|
|
2042
|
+
return {
|
|
2043
|
+
roots: groups.map((group) => group.root.sessionId),
|
|
2044
|
+
totalSessions,
|
|
2045
|
+
totalBytes,
|
|
2046
|
+
missingParents: new Set(groups.map((group) => bareSessionId(group.parentSessionId))).size,
|
|
2047
|
+
groups: [...projects].map(([projectDir, value]) => ({
|
|
2048
|
+
projectDir,
|
|
2049
|
+
...value
|
|
2050
|
+
})).sort((a, b) => b.bytes - a.bytes)
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* 清一组:`planOrphanRemoval`(只读计划,整组 live 准入在一切写之前)→ `executeRemoval`(只写)。
|
|
2055
|
+
* 量目录、摘归档集与账本、删目录与成员通告的顺序和失败形状都住 `verbs/permanent-removal.ts`;
|
|
2056
|
+
* 这里只把这一组接上去,别在这再长出一段手写步骤(purge 走的是同一条路)。
|
|
2057
|
+
*
|
|
2058
|
+
* 顺序与删除那一半是**对偶**的,但不是照抄:
|
|
2059
|
+
* - 删除那一半不再用归档藏会话(ADR-0008):它摘账本、搬目录,最后才摘活体。这里目录是**删掉**
|
|
2060
|
+
* 不是搬走,藏不藏都不影响可见性 ⇒ 归档集反而**在写之前整批摘掉**:失败时目录还在,下一次还能重试,
|
|
2061
|
+
* 不会留下"会话没了但还在归档集里挂着"的幽灵 id。
|
|
2062
|
+
* - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以这里不需要那次
|
|
2063
|
+
* `wasArchived` 读——这里没有"还原到删除前"的义务。
|
|
2064
|
+
* - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
|
|
2065
|
+
* - 独占图片的剩余引用源由附件域自己扫:活动会话 + 全部 session-trash + storages/cache。
|
|
2066
|
+
*/
|
|
2067
|
+
async function cleanOrphanGroup(deps, group) {
|
|
2068
|
+
return await executeRemoval(deps, await planOrphanRemoval(deps, group.members));
|
|
2069
|
+
}
|
|
2070
|
+
//#endregion
|
|
2071
|
+
//#region src/ports/agent-handles.ts
|
|
2072
|
+
const CAPTURED_AGENT_HANDLES = Symbol.for("dsh-session-delete.captured-agent-handles");
|
|
2073
|
+
/** 在 `ctx.agents` 上包一层 `resume` / `create`,把返回的 agent handle 的 dispose 按会话 id 记下。
|
|
2074
|
+
* 服务缺席时走 `ctx.inject(['agents'])` **等服务挂上再装**——live profile 的插件激活顺序可能早于
|
|
2075
|
+
* `agents` 服务就绪;只在 apply 时 `get` 一次会让捕获静默缺席(真机症状:重启前在回收站的能冷恢复,
|
|
2076
|
+
* 刚删除的会话恢复后旧 agent 拦住 prompt)。形状不符 / 属性不可写都只是**不捕获**,不影响插件激活。
|
|
2077
|
+
*
|
|
2078
|
+
* **安装时机**:先 `get('agents')`,没装成再 `inject(['agents'])`(两者只装一次,符号表兜住)。
|
|
2079
|
+
* **symbol 表**:句柄表挂在服务实例的 {@link CAPTURED_AGENT_HANDLES} 上;同一个 agents 服务上的第二次
|
|
2080
|
+
* 安装(`get` 与 `inject` 各一次,或第二个 `createHostPort`)看见它就直接共用,不把 wrapper 套第二层。
|
|
2081
|
+
* **读回判据**:cordis 的 traceable 服务代理每次读函数都会新包一层(`get` 里的 `createShadowMethod`),
|
|
2082
|
+
* 所以只能看 own property descriptor 的身份;拿 `service[name]` 读回比身份是必然的假警报。
|
|
2083
|
+
*/
|
|
2084
|
+
function captureAgentHandles(ctx) {
|
|
2085
|
+
let handles = /* @__PURE__ */ new Map();
|
|
2086
|
+
let installed = false;
|
|
2087
|
+
const install = (candidate) => {
|
|
2088
|
+
if (installed) return;
|
|
2089
|
+
const service = candidate;
|
|
2090
|
+
if (service === void 0 || service === null) return;
|
|
2091
|
+
const existing = service[CAPTURED_AGENT_HANDLES];
|
|
2092
|
+
if (existing !== void 0) {
|
|
2093
|
+
handles = existing;
|
|
2094
|
+
installed = true;
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
let wrappedCount = 0;
|
|
2098
|
+
for (const name of ["resume", "create"]) try {
|
|
2099
|
+
const original = service[name];
|
|
2100
|
+
if (typeof original !== "function") continue;
|
|
2101
|
+
const wrapped = function(...args) {
|
|
2102
|
+
const result = Reflect.apply(original, this, args);
|
|
2103
|
+
result.then((value) => {
|
|
2104
|
+
const handle = value;
|
|
2105
|
+
const id = handle?.agent?.id;
|
|
2106
|
+
const dispose = handle?.dispose;
|
|
2107
|
+
if (typeof id !== "string" || id.length === 0 || typeof dispose !== "function") return;
|
|
2108
|
+
handles.set(id, async () => {
|
|
2109
|
+
await dispose.call(handle);
|
|
2110
|
+
});
|
|
2111
|
+
}, () => void 0);
|
|
2112
|
+
return result;
|
|
2113
|
+
};
|
|
2114
|
+
service[name] = wrapped;
|
|
2115
|
+
if (Object.getOwnPropertyDescriptor(service, name)?.value !== wrapped) throw new Error(`agents.${name} is not writable`);
|
|
2116
|
+
wrappedCount += 1;
|
|
2117
|
+
} catch {}
|
|
2118
|
+
if (wrappedCount === 0) return;
|
|
2119
|
+
try {
|
|
2120
|
+
service[CAPTURED_AGENT_HANDLES] = handles;
|
|
2121
|
+
} catch {}
|
|
2122
|
+
installed = true;
|
|
2123
|
+
};
|
|
2124
|
+
const service = ctx.get("agents");
|
|
2125
|
+
if (service !== void 0 && service !== null) install(service);
|
|
2126
|
+
if (!installed && typeof ctx.inject === "function") try {
|
|
2127
|
+
ctx.inject(["agents"], (child) => {
|
|
2128
|
+
install(child.get("agents"));
|
|
2129
|
+
});
|
|
2130
|
+
} catch {}
|
|
2131
|
+
return { async dispose(sessionId) {
|
|
2132
|
+
const dispose = handles.get(sessionId);
|
|
2133
|
+
if (dispose === void 0) return;
|
|
2134
|
+
try {
|
|
2135
|
+
await dispose();
|
|
2136
|
+
} finally {
|
|
2137
|
+
handles.delete(sessionId);
|
|
2138
|
+
}
|
|
2139
|
+
} };
|
|
2140
|
+
}
|
|
2141
|
+
//#endregion
|
|
2142
|
+
//#region src/ports/host-port.ts
|
|
2143
|
+
/** live store 的最小方法面判据(同 `projectionCacheOf` 的手法:按方法面判,不按 constructor.name)。 */
|
|
2144
|
+
function liveSessionsOf(ctx) {
|
|
2145
|
+
const store = ctx.get("sessions");
|
|
2146
|
+
if (store === void 0 || store === null) return void 0;
|
|
2147
|
+
if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
|
|
2148
|
+
return store;
|
|
2149
|
+
}
|
|
2150
|
+
/** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。 */
|
|
2151
|
+
async function filterChildrenOf(sessionQuery, values) {
|
|
2152
|
+
return sessionQuery.filterSessions([{
|
|
2153
|
+
kind: "parent",
|
|
2154
|
+
values: [...values].sort()
|
|
2155
|
+
}]);
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* 从若干根逐层向下问出后代,交回 `父(bare)→ 孩子` 的索引。
|
|
2159
|
+
* 用 `kind:'parent'` 而不是 traceSession:父进回收站后 traceSession 认不出目标(宿主失败码
|
|
2160
|
+
* `SESSION_QUERY_SESSION_NOT_FOUND`),而这里要的正是父不在宿主列表之后仍留在盘上的那批。
|
|
2161
|
+
* 出处:asar `dsh-session-query/lib/index.js` 的 `filterSessions`。一层一次宿主调用,问空即停;
|
|
2162
|
+
* `seen` 防环;没有 `header.id` 的记录整条跳过(无法作为身份继续下问)。
|
|
2163
|
+
*/
|
|
2164
|
+
async function descendantsByParent(sessionQuery, roots) {
|
|
1537
2165
|
const children = /* @__PURE__ */ new Map();
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
const
|
|
1542
|
-
const
|
|
1543
|
-
|
|
1544
|
-
|
|
2166
|
+
const seen = new Set(roots.map(bareSessionId));
|
|
2167
|
+
let frontier = [...seen];
|
|
2168
|
+
while (frontier.length > 0) {
|
|
2169
|
+
const values = [];
|
|
2170
|
+
for (const bare of frontier) for (const shape of sessionIdShapes(bare)) if (!values.includes(shape)) values.push(shape);
|
|
2171
|
+
const next = [];
|
|
2172
|
+
for (const record of await filterChildrenOf(sessionQuery, values)) {
|
|
2173
|
+
const id = record.header?.id;
|
|
2174
|
+
const parent = record.header?.parentSession;
|
|
2175
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
2176
|
+
if (typeof parent !== "string" || parent.length === 0) continue;
|
|
2177
|
+
const bare = bareSessionId(id);
|
|
2178
|
+
if (seen.has(bare)) continue;
|
|
2179
|
+
seen.add(bare);
|
|
2180
|
+
const cwd = record.header?.cwd;
|
|
2181
|
+
const descendant = {
|
|
2182
|
+
sessionId: id,
|
|
2183
|
+
projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd)
|
|
2184
|
+
};
|
|
2185
|
+
const parentBare = bareSessionId(parent);
|
|
2186
|
+
const siblings = children.get(parentBare);
|
|
2187
|
+
if (siblings === void 0) children.set(parentBare, [descendant]);
|
|
2188
|
+
else siblings.push(descendant);
|
|
2189
|
+
next.push(bare);
|
|
2190
|
+
}
|
|
2191
|
+
frontier = next;
|
|
1545
2192
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
2193
|
+
return children;
|
|
2194
|
+
}
|
|
2195
|
+
/** {@link HostCorpusPort.subagentMembersOf} 的那一次 BFS:这里只负责问宿主,某个根的后代闭包
|
|
2196
|
+
* 由纯部件的 `closureOf` 在交回的索引上收(深度不设上限、防环、首见去重都在那一处)。
|
|
2197
|
+
* 空清单在调用宿主之前就返回(不为一张空表扫全库)。 */
|
|
2198
|
+
async function membersFor(sessionQuery, parentIds) {
|
|
2199
|
+
const wanted = [];
|
|
2200
|
+
for (const id of parentIds) if (id.length > 0 && !wanted.includes(id)) wanted.push(id);
|
|
2201
|
+
if (wanted.length === 0) return {};
|
|
2202
|
+
const children = await descendantsByParent(sessionQuery, wanted);
|
|
2203
|
+
const out = {};
|
|
2204
|
+
for (const id of wanted) out[id] = closureOf(children, id);
|
|
2205
|
+
return out;
|
|
2206
|
+
}
|
|
2207
|
+
/** 无 cwd 的会话落在 `_no-cwd/`;`projectDirOf` 与回收站对账共用这个字面量。
|
|
2208
|
+
* 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectDir`(`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
|
|
2209
|
+
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
2210
|
+
/** 宿主 `projectKey(cwd)` 的逐字镜像:跨插件不能 import,宿主也没把它暴露成服务。
|
|
2211
|
+
* 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectKey`;调用链
|
|
2212
|
+
* `locate` → `logPath` → `projectDir` → `projectKey`。本机实测 `D:/develop/GitHub/dsh-session-delete`
|
|
2213
|
+
* → `--D-develop-GitHub-dsh-session-delete--`。`projectDirOf` 与回收站对账重建共用这一份;
|
|
2214
|
+
* 宿主换代先复核算法。 */
|
|
2215
|
+
function projectKey(cwd) {
|
|
2216
|
+
let readable = "";
|
|
2217
|
+
let separatorRun = false;
|
|
2218
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
2219
|
+
const code = cwd.charCodeAt(i);
|
|
2220
|
+
const ch = String.fromCharCode(code);
|
|
2221
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
2222
|
+
if (!separatorRun) readable += "-";
|
|
2223
|
+
separatorRun = true;
|
|
2224
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
2225
|
+
readable += ch;
|
|
2226
|
+
separatorRun = false;
|
|
2227
|
+
} else {
|
|
2228
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
2229
|
+
separatorRun = false;
|
|
1562
2230
|
}
|
|
1563
|
-
groups.push({
|
|
1564
|
-
root: session,
|
|
1565
|
-
parentSessionId: parent,
|
|
1566
|
-
members
|
|
1567
|
-
});
|
|
1568
2231
|
}
|
|
1569
|
-
return
|
|
2232
|
+
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
1570
2233
|
}
|
|
1571
|
-
/**
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
* 拿缺席当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
|
|
1578
|
-
* 所以这里按 `io` 响亮失败(AGENTS.md:`available: false` 意味着服务缺席,扫描方不许冒充读数)。 */
|
|
1579
|
-
async function loadOrphanGroups(deps, trash) {
|
|
1580
|
-
const read = await deps.host.listSessions();
|
|
1581
|
-
if (!read.available) throw verbError("io", "无主子会话扫描需要宿主的 sessionQuery.listSessions");
|
|
1582
|
-
const corpus = read.value;
|
|
1583
|
-
const groups = orphanGroupsOf(corpus, (trash ?? await deps.ledger.raw()).map((entry) => entry.sessionId));
|
|
1584
|
-
const out = [];
|
|
1585
|
-
for (const group of groups) {
|
|
1586
|
-
const members = [];
|
|
1587
|
-
for (const member of group.members) {
|
|
1588
|
-
const measured = await measureMemberDir(deps, member);
|
|
1589
|
-
members.push({
|
|
1590
|
-
sessionId: member.sessionId,
|
|
1591
|
-
projectDir: member.projectDir,
|
|
1592
|
-
bytes: measured?.bytes ?? 0
|
|
1593
|
-
});
|
|
1594
|
-
}
|
|
1595
|
-
out.push({
|
|
1596
|
-
root: members[0],
|
|
1597
|
-
parentSessionId: group.parentSessionId,
|
|
1598
|
-
members
|
|
1599
|
-
});
|
|
1600
|
-
}
|
|
1601
|
-
return out;
|
|
2234
|
+
/** 投影缓存服务的最小方法面判据(按方法面判,不按 `constructor.name` 判)。 */
|
|
2235
|
+
function projectionCacheOf(ctx) {
|
|
2236
|
+
const cache = ctx.get("sessionProjectionCache");
|
|
2237
|
+
if (cache === void 0 || cache === null) return void 0;
|
|
2238
|
+
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
2239
|
+
return cache;
|
|
1602
2240
|
}
|
|
1603
|
-
/**
|
|
1604
|
-
* `
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
2241
|
+
/** 宿主 `readSessionHeader` 对未知会话抛的是裸 `Error`,只认这句;真存储故障照抛,不能折成未命中。
|
|
2242
|
+
* 出处:asar `dsh-workspace/lib/types/index.js` 的
|
|
2243
|
+
* `cannot validate session '${id}': session persistence holds no such session`。
|
|
2244
|
+
* 同文件有类型的近似句是 `WorkspaceUnknownSessionError`(`hold` 无 `s`),不会误伤。 */
|
|
2245
|
+
const NO_SUCH_SESSION = "session persistence holds no such session";
|
|
2246
|
+
/** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
|
|
2247
|
+
function isUnknownSession(error) {
|
|
2248
|
+
return error instanceof Error && error.message.includes(NO_SUCH_SESSION);
|
|
2249
|
+
}
|
|
2250
|
+
/** 宿主会话表的一次取数(`ctx.sessionController.list()` 的两种返回形状都折成数组)。
|
|
2251
|
+
* 服务没挂 / 形状不对 / 抛错 ⇒ 空表——**这条不替宿主兜错误**:调用方拿空表的语义都是"少发一条通告",
|
|
2252
|
+
* 不是"恢复失败"。 */
|
|
2253
|
+
async function controllerItems(host) {
|
|
2254
|
+
try {
|
|
2255
|
+
const controller = host.get("sessionController");
|
|
2256
|
+
if (controller === void 0 || controller === null || typeof controller.list !== "function") return [];
|
|
2257
|
+
const listed = await controller.list();
|
|
2258
|
+
const items = Array.isArray(listed) ? listed : listed?.items;
|
|
2259
|
+
return Array.isArray(items) ? items.map((item) => item) : [];
|
|
2260
|
+
} catch {
|
|
2261
|
+
return [];
|
|
1619
2262
|
}
|
|
1620
|
-
return {
|
|
1621
|
-
roots: groups.map((group) => group.root.sessionId),
|
|
1622
|
-
totalSessions,
|
|
1623
|
-
totalBytes,
|
|
1624
|
-
missingParents: new Set(groups.map((group) => bareSessionId(group.parentSessionId))).size,
|
|
1625
|
-
groups: [...projects].map(([projectDir, value]) => ({
|
|
1626
|
-
projectDir,
|
|
1627
|
-
...value
|
|
1628
|
-
})).sort((a, b) => b.bytes - a.bytes)
|
|
1629
|
-
};
|
|
1630
2263
|
}
|
|
1631
|
-
/**
|
|
1632
|
-
*
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
*
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
* 不是搬走,藏不藏都不影响可见性 ⇒ 归档集反而**在写之前整批摘掉**:失败时目录还在,下一次还能重试,
|
|
1639
|
-
* 不会留下"会话没了但还在归档集里挂着"的幽灵 id。
|
|
1640
|
-
* - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以这里不需要那次
|
|
1641
|
-
* `wasArchived` 读——这里没有"还原到删除前"的义务。
|
|
1642
|
-
* - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
|
|
1643
|
-
*/
|
|
1644
|
-
async function cleanOrphanGroup(deps, group) {
|
|
1645
|
-
return await executeRemoval(deps, await planOrphanRemoval(deps, group.members));
|
|
2264
|
+
/** 账本里没有那个工作区时 {@link HostPort.attach} / {@link HostPort.detach} 抛的原话开头。
|
|
2265
|
+
* **原话与判据同住这里**:抛它的是这个端口,认它的也是端口给的那句,调用方不许另抄一份字符串。 */
|
|
2266
|
+
const WORKSPACE_MISSING_MESSAGE = "workspace not found";
|
|
2267
|
+
/** 这次失败是不是"账本里没那个工作区"(而不是另有故障)。恢复侧靠它把两种失败分开:
|
|
2268
|
+
* 前者按「未分组」收,后者照旧回滚报 `io`——不给真故障静默改归属的机会。 */
|
|
2269
|
+
function isWorkspaceMissingError(error) {
|
|
2270
|
+
return error instanceof Error && error.message.startsWith(WORKSPACE_MISSING_MESSAGE);
|
|
1646
2271
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
*
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
2272
|
+
/** 宿主服务的真实绑定;出错原样抛,由 `toRemoteFailure` 按 name 映射。
|
|
2273
|
+
* 各方法调用的服务与方法见上方成员注释;错误映射表住 `src/remote/map-error.ts`。 */
|
|
2274
|
+
function createHostPort(ctx) {
|
|
2275
|
+
const host = ctx;
|
|
2276
|
+
const registry = host.workspaceRegistry;
|
|
2277
|
+
const agentHandles = captureAgentHandles(host);
|
|
2278
|
+
return {
|
|
2279
|
+
/** 账本里记着这个会话的工作区:`registry.list()` 的实体 `sessionIds`(该 getter 已按 canonical cwd 过滤过)。 */
|
|
2280
|
+
async workspaceOf(sessionId) {
|
|
2281
|
+
return registry.list().find((workspace) => workspace.sessionIds.includes(sessionId))?.id;
|
|
2282
|
+
},
|
|
2283
|
+
/** 账本里还有这个工作区吗:`registry.get(id)` 是 `this.entities.get(id)` 的直通(同步 Map 查找),
|
|
2284
|
+
* 未知 id 回 `undefined` ⇒ 这里只做一次判别,不抛。 */
|
|
2285
|
+
async workspaceExists(workspaceId) {
|
|
2286
|
+
return registry.get(workspaceId) !== void 0;
|
|
2287
|
+
},
|
|
2288
|
+
/**
|
|
2289
|
+
* cwd → 宿主项目目录名;没有 cwd 落到 `_no-cwd`。
|
|
2290
|
+
* 账本里没有这个会话回 `undefined`(未命中,不是故障);真存储故障照抛。
|
|
2291
|
+
* 未命中由 `delete-session.ts` 折成 `not-found`,真故障留 `io`——两者不能混。
|
|
2292
|
+
*/
|
|
2293
|
+
async projectDirOf(sessionId) {
|
|
2294
|
+
let header;
|
|
2295
|
+
try {
|
|
2296
|
+
header = await registry.readSessionHeader(sessionId);
|
|
2297
|
+
} catch (error) {
|
|
2298
|
+
if (isUnknownSession(error)) return void 0;
|
|
2299
|
+
throw error;
|
|
2300
|
+
}
|
|
2301
|
+
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
2302
|
+
},
|
|
2303
|
+
/** 标题走宿主投影缓存;尽力取,取不到返回空串,绝不因此让删除失败。 */
|
|
2304
|
+
async titleOf(sessionId) {
|
|
2305
|
+
try {
|
|
2306
|
+
const header = await registry.readSessionHeader(sessionId);
|
|
2307
|
+
const cache = projectionCacheOf(host);
|
|
2308
|
+
const title = (cache?.cachedSnapshot(header, ["title"]) ?? cache?.cachedPredecessorTitle(header))?.values.title;
|
|
2309
|
+
return typeof title === "string" ? title : "";
|
|
2310
|
+
} catch {
|
|
2311
|
+
return "";
|
|
2312
|
+
}
|
|
2313
|
+
},
|
|
2314
|
+
/** 活跃判据走宿主 `workspace/session-activity` waterfall:空数组 = 无活动;
|
|
2315
|
+
* 与宿主 archiveSession 的准入同源。 */
|
|
2316
|
+
async isLive(sessionId) {
|
|
2317
|
+
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
2318
|
+
},
|
|
2319
|
+
/** registry 全局归档集(数组,`state.archivedSessionIds`)。 */
|
|
2320
|
+
async isArchived(sessionId) {
|
|
2321
|
+
return registry.archivedSessionIds.includes(sessionId);
|
|
2322
|
+
},
|
|
2323
|
+
/**
|
|
2324
|
+
* 从 `ctx.sessions` 摘掉会话,并关闭捕获到的 agent handle。
|
|
2325
|
+
*
|
|
2326
|
+
* 会话列表 = 磁盘持久化 ∪ 内存活体,搬走文件只清掉一侧;宿主 `SessionController` 会丢掉
|
|
2327
|
+
* `AgentHandle`,所以 `agent-handles.ts` 按会话 id 留住 handle,这里先 detach 再 dispose。
|
|
2328
|
+
* 捕获不到 handle(插件激活前已启动,或 `ctx.agents` 形状变化)时只能摘 session,这是已知限制,
|
|
2329
|
+
* 不拿归档兜底(ADR-0008)。
|
|
2330
|
+
*
|
|
2331
|
+
* 三态:读不到 store / 形状不符 / detach 或 dispose 抛 ⇒ `unknown`(不通告);
|
|
2332
|
+
* 内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。任何一步都不抛。
|
|
2333
|
+
*/
|
|
2334
|
+
async evictLive(sessionId) {
|
|
2335
|
+
try {
|
|
2336
|
+
const store = liveSessionsOf(host);
|
|
2337
|
+
const session = store?.get(sessionId);
|
|
2338
|
+
if (store === void 0) return "unknown";
|
|
2339
|
+
if (session === void 0 || session === null) {
|
|
2340
|
+
await agentHandles.dispose(sessionId);
|
|
2341
|
+
return "not-live";
|
|
2342
|
+
}
|
|
2343
|
+
const entry = store.liveEntryFor(session);
|
|
2344
|
+
if (entry === void 0 || entry === null || typeof entry.detach !== "function") return "unknown";
|
|
2345
|
+
entry.detach();
|
|
2346
|
+
await agentHandles.dispose(sessionId);
|
|
2347
|
+
return "evicted";
|
|
2348
|
+
} catch {
|
|
2349
|
+
return "unknown";
|
|
2350
|
+
}
|
|
2351
|
+
},
|
|
2352
|
+
/**
|
|
2353
|
+
* 通告客户端这个会话没了:发宿主自己转发的 `api-session/removed`,客户端当场删行。
|
|
2354
|
+
* 冷会话没有 `session/disposed`,不发这条就只能等重连。
|
|
2355
|
+
* 只在宿主列表确实已不含它时调用(调用方按 {@link EvictionOutcome} 判);不发假通告。
|
|
2356
|
+
*/
|
|
2357
|
+
async announceRemoved(sessionId) {
|
|
2358
|
+
try {
|
|
2359
|
+
host.emit("api-session/removed", sessionId);
|
|
2360
|
+
} catch {}
|
|
2361
|
+
},
|
|
2362
|
+
/**
|
|
2363
|
+
* 通告这组会话回来了:拉一次宿主会话表,按 id 逐条发 `api-session/added`。
|
|
2364
|
+
* 摘要直接用宿主 `sessionController.list()` 的形状;取不到就不发。
|
|
2365
|
+
* 批量只预取一次,避免按条全表扫。
|
|
2366
|
+
*/
|
|
2367
|
+
async announceRestored(sessionIds) {
|
|
2368
|
+
if (sessionIds.length === 0) return;
|
|
2369
|
+
const summaries = [];
|
|
2370
|
+
for (const item of await controllerItems(host)) {
|
|
2371
|
+
const sessionId = item?.sessionId;
|
|
2372
|
+
if (typeof sessionId === "string") summaries.push({
|
|
2373
|
+
sessionId,
|
|
2374
|
+
summary: item
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
for (const id of sessionIds) {
|
|
2378
|
+
const summary = summaries.find((item) => item.sessionId === id)?.summary;
|
|
2379
|
+
if (summary === void 0) continue;
|
|
2380
|
+
try {
|
|
2381
|
+
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
2382
|
+
} catch {}
|
|
2383
|
+
}
|
|
2384
|
+
},
|
|
2385
|
+
/** 官方 `unarchiveSession(sessionId)`:未归档是幂等 no-op。 */
|
|
2386
|
+
async unarchive(sessionId) {
|
|
2387
|
+
await registry.unarchiveSession(sessionId);
|
|
2388
|
+
},
|
|
2389
|
+
/** 摘账本:实体方法 `detachSession`(顶层没有);未知工作区**响亮失败**,不静默当成功。 */
|
|
2390
|
+
async detach(sessionId, workspaceId) {
|
|
2391
|
+
const workspace = registry.get(workspaceId);
|
|
2392
|
+
if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (detach): ${workspaceId}`);
|
|
2393
|
+
await workspace.detachSession(sessionId);
|
|
2394
|
+
},
|
|
2395
|
+
/** 挂账本:实体方法 `attachSession`(顶层没有)。 */
|
|
2396
|
+
async attach(sessionId, workspaceId) {
|
|
2397
|
+
const workspace = registry.get(workspaceId);
|
|
2398
|
+
if (workspace === void 0) throw new Error(`${WORKSPACE_MISSING_MESSAGE} (attach): ${workspaceId}`);
|
|
2399
|
+
await workspace.attachSession(sessionId);
|
|
2400
|
+
},
|
|
2401
|
+
/** 确保归档:官方 `archiveSession(sessionId)`。归档是 registry **全局**的,与工作区账目无关
|
|
2402
|
+
* (JSDoc 原文 "its workspace accounting — or lack of one — is irrelevant")⇒ 没有工作区入参,
|
|
2403
|
+
* 调用方也不许再传(传了就是暗示一个从不生效的作用域)。
|
|
2404
|
+
* 不带 `stopActivity` ⇒ 有活动即抛 `WorkspaceActiveSessionError`(→ `sessiondelete/live`,src/shared/wire.ts)。 */
|
|
2405
|
+
async archive(sessionId) {
|
|
2406
|
+
await registry.archiveSession(sessionId);
|
|
2407
|
+
},
|
|
2408
|
+
async listSessions() {
|
|
2409
|
+
const sessionQuery = host.get("sessionQuery");
|
|
2410
|
+
if (typeof sessionQuery?.listSessions !== "function") return { available: false };
|
|
2411
|
+
const records = await sessionQuery.listSessions();
|
|
2412
|
+
const out = [];
|
|
2413
|
+
for (const record of records) {
|
|
2414
|
+
const id = record.header?.id;
|
|
2415
|
+
if (typeof id !== "string" || id === "") continue;
|
|
2416
|
+
const parent = record.header?.parentSession;
|
|
2417
|
+
const cwd = record.header?.cwd;
|
|
2418
|
+
out.push({
|
|
2419
|
+
sessionId: id,
|
|
2420
|
+
...typeof parent === "string" && parent !== "" ? { parentSessionId: parent } : {},
|
|
2421
|
+
projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd),
|
|
2422
|
+
subagent: record.header?.origin === "subagent"
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
return {
|
|
2426
|
+
available: true,
|
|
2427
|
+
value: out
|
|
2428
|
+
};
|
|
2429
|
+
},
|
|
2430
|
+
/**
|
|
2431
|
+
* 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
|
|
2432
|
+
* 数传递闭包(孩子与孙代),不是直接孩子;`session-` 前缀两边都去掉再比,
|
|
2433
|
+
* 按调用方给的写法回键。`sessionQuery` 缺席或方法缺失 ⇒ `{available:false}`,
|
|
2434
|
+
* 不拿空表冒充“没有后代”;服务在场时空入参零宿主调用。
|
|
2435
|
+
*/
|
|
2436
|
+
async subagentMembersOf(parentIds) {
|
|
2437
|
+
const sessionQuery = host.get("sessionQuery");
|
|
2438
|
+
if (typeof sessionQuery?.filterSessions !== "function") return { available: false };
|
|
2439
|
+
return {
|
|
2440
|
+
available: true,
|
|
2441
|
+
value: await membersFor(sessionQuery, parentIds)
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
1670
2444
|
};
|
|
1671
2445
|
}
|
|
1672
2446
|
//#endregion
|
|
1673
2447
|
//#region src/verbs/restore-session.ts
|
|
1674
2448
|
/**
|
|
1675
|
-
*
|
|
1676
|
-
* 只回父就会在回收站里留下一堆点不到的孩子行——清单只列父行,那些行既看不见也清不掉。
|
|
2449
|
+
* 恢复:把删除那次按原路退回,并按组走。
|
|
1677
2450
|
*
|
|
1678
|
-
*
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
* 「未分组」收(判据在 {@link attachableWorkspaceId}),照旧回盘——恢复是加法,不该因为一个可选的账目而失败。
|
|
1684
|
-
* ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 →(旧清单迁移)取消归档 → 摘清册行。
|
|
1685
|
-
* ③ 中途失败时 journal 把这个成员自己退回回收站;**已经恢复完的**兄弟由整组 catch 按相反顺序一起退回。
|
|
2451
|
+
* 三段:
|
|
2452
|
+
* 1. 预检整组(不写盘):每个成员的回收站目录与原项目目录都必须在;工作区账本缺席不算硬失败,
|
|
2453
|
+
* 恢复后归入「未分组」。预检在任何写之前,避免留下半组。
|
|
2454
|
+
* 2. 逐个恢复:文件先回来 → 挂账本 →(旧条目)取消归档 → 摘清册行。
|
|
2455
|
+
* 3. 中途失败由 journal 回滚当前成员;已恢复的兄弟由整组按相反顺序退回。
|
|
1686
2456
|
*
|
|
1687
|
-
*
|
|
1688
|
-
* "恢复很慢"就是逐条发)。通告归 `verbs/batch.ts`(ADR-0002),所以这个动词的 interface 是
|
|
1689
|
-
* "一个条目 id + 这一组进、回收站那一行出",没有批量专用的开关。
|
|
2457
|
+
* 通告不在本文件:批量层在批尾一次 `announceRestored(ids)`(ADR-0002)。
|
|
1690
2458
|
*/
|
|
1691
2459
|
function restoreSession(deps) {
|
|
1692
|
-
const presence =
|
|
2460
|
+
const presence = deps.presence;
|
|
1693
2461
|
return async function restoreSession(entryId, group) {
|
|
1694
2462
|
const [root, ...followers] = group;
|
|
1695
2463
|
const target = group.find((e) => e.id === entryId);
|
|
1696
|
-
if (root === void 0 || target === void 0) throw
|
|
2464
|
+
if (root === void 0 || target === void 0) throw trashEmpty(entryId);
|
|
1697
2465
|
for (const entry of group) {
|
|
1698
2466
|
const trashDir = trashDirOf(entry.id, deps.home);
|
|
1699
2467
|
if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
|
|
@@ -1713,8 +2481,7 @@ function restoreSession(deps) {
|
|
|
1713
2481
|
return target;
|
|
1714
2482
|
};
|
|
1715
2483
|
}
|
|
1716
|
-
/**
|
|
1717
|
-
* 因此不依赖宿主当时的目录命名约定(v3 无前缀 / v4 带 `session-` 前缀)。 */
|
|
2484
|
+
/** 放回哪儿:用删除时记进 entryId 的原目录名,不从 sessionId 现推;因此不依赖宿主的目录命名约定。 */
|
|
1718
2485
|
function restoreTargetDir(deps, entry) {
|
|
1719
2486
|
return sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
|
|
1720
2487
|
}
|
|
@@ -1739,20 +2506,16 @@ async function isWorkspaceMissing(host, entry) {
|
|
|
1739
2506
|
return entry.workspaceId !== "" && await attachableWorkspaceId(host, entry) === void 0;
|
|
1740
2507
|
}
|
|
1741
2508
|
/**
|
|
1742
|
-
*
|
|
1743
|
-
*
|
|
1744
|
-
* 账本那一步有个**只认端口那句**的兜底:查过之后、挂之前账本被删掉(宿主的「删除工作区」与这次恢复
|
|
1745
|
-
* 撞上)⇒ 按未分组收、不回滚;**别的**失败是真故障,照旧抛(吞掉它就等于把一次存储故障伪装成"未分组")。
|
|
2509
|
+
* 一个成员的恢复步骤:放回目录 → 挂账本 →(旧条目)取消归档 → 摘清册行。
|
|
2510
|
+
* 挂账本时若工作区刚被删除,按未分组收、不回滚;其他失败照抛,不能伪装成未分组。
|
|
1746
2511
|
*/
|
|
1747
2512
|
async function memberRestore(deps, presence, entry) {
|
|
1748
2513
|
const trashDir = trashDirOf(entry.id, deps.home);
|
|
1749
2514
|
const sessionDir = restoreTargetDir(deps, entry);
|
|
1750
2515
|
const workspaceId = await attachableWorkspaceId(deps.host, entry);
|
|
1751
|
-
/**
|
|
1752
|
-
*
|
|
1753
|
-
*
|
|
1754
|
-
* 恢复时取消归档。`wasArchived:true` 是用户删除前自己归档的,保持归档态不动。
|
|
1755
|
-
*/
|
|
2516
|
+
/** 旧条目迁移:早期删除用归档藏会话,盘上仍是归档态。
|
|
2517
|
+
* `wasArchived:false` 且当前仍归档 ⇒ 只可能是旧条目,恢复时取消归档;
|
|
2518
|
+
* `wasArchived:true` 是用户删除前自己归档的,保持归档态。 */
|
|
1756
2519
|
const legacyArchived = !entry.wasArchived && await deps.host.isArchived(entry.sessionId);
|
|
1757
2520
|
/** 真挂上了账本才记下那个 id——回滚里"摘账本"那一步只对挂过的成员存在。 */
|
|
1758
2521
|
let attached = false;
|
|
@@ -1791,7 +2554,7 @@ async function memberRestore(deps, presence, entry) {
|
|
|
1791
2554
|
run: async () => {
|
|
1792
2555
|
await deps.host.unarchive(entry.sessionId);
|
|
1793
2556
|
},
|
|
1794
|
-
undo: () => stepUndo("重新归档失败", () => deps.host.archive(entry.sessionId
|
|
2557
|
+
undo: () => stepUndo("重新归档失败", () => deps.host.archive(entry.sessionId))
|
|
1795
2558
|
}],
|
|
1796
2559
|
{
|
|
1797
2560
|
key: "manifest",
|
|
@@ -1810,28 +2573,55 @@ async function memberRestore(deps, presence, entry) {
|
|
|
1810
2573
|
}) };
|
|
1811
2574
|
}
|
|
1812
2575
|
//#endregion
|
|
2576
|
+
//#region src/verbs/trash-scope.ts
|
|
2577
|
+
function createTrashScope(deps, rows) {
|
|
2578
|
+
const original = rows;
|
|
2579
|
+
let current = [...rows];
|
|
2580
|
+
/** 拿目标、把计划交给执行器:`targets` 缺席(`undefined`)= 清空。计划只读——活体准入在**一切写
|
|
2581
|
+
* 之前**(任一在跑就整条拒绝、零副作用)。盘上后代按**当前视图里还在的 id** 排除(跟着父进来的
|
|
2582
|
+
* 孩子不按"盘上残留"再删一遍);`sessionQuery` 缺席时端口回 `{ available: false }` ⇒ 这次只清
|
|
2583
|
+
* 清册里的那部分,盘上残留留给 `GET /orphans` 兜底(与 delete 的降级一致,不折成失败)。 */
|
|
2584
|
+
async function purgeGroup(targets, requestedId) {
|
|
2585
|
+
const selected = targets ?? current;
|
|
2586
|
+
if (targets !== void 0 && selected.length === 0) throw trashEmpty(requestedId);
|
|
2587
|
+
return await executeRemoval(deps, await planTrashRemoval(deps, current, selected));
|
|
2588
|
+
}
|
|
2589
|
+
/** 收缩是内部闭包、不外露:唯一的观察面是后续 `purge` / `purgeAll` / `orphanGroups` 看见的当前视图。 */
|
|
2590
|
+
const take = (id) => {
|
|
2591
|
+
current = withoutGroup(current, id);
|
|
2592
|
+
};
|
|
2593
|
+
return {
|
|
2594
|
+
purge: async (id) => {
|
|
2595
|
+
const claim = claimOf(original, current, id);
|
|
2596
|
+
if (claim.verdict === "carried") return {
|
|
2597
|
+
removed: 0,
|
|
2598
|
+
freedBytes: 0
|
|
2599
|
+
};
|
|
2600
|
+
const stats = await purgeGroup(claim.verdict === "fresh" ? claim.group : [], id);
|
|
2601
|
+
take(id);
|
|
2602
|
+
return stats;
|
|
2603
|
+
},
|
|
2604
|
+
purgeAll: async () => {
|
|
2605
|
+
const stats = await purgeGroup(void 0);
|
|
2606
|
+
current = [];
|
|
2607
|
+
return stats;
|
|
2608
|
+
},
|
|
2609
|
+
orphanGroups: (roots) => loadOrphanGroups(deps, current, roots)
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
//#endregion
|
|
1813
2613
|
//#region src/verbs/batch.ts
|
|
1814
2614
|
/**
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1817
|
-
* 五个动作:`delete` / `restore` / `purge` / `count` / `orphan-clean`。
|
|
2615
|
+
* 「这一批」的唯一实现(ADR-0002):POST /apply 的落点——一组 id 上的同一个动作。
|
|
1818
2616
|
*
|
|
1819
|
-
*
|
|
1820
|
-
*
|
|
1821
|
-
*
|
|
2617
|
+
* 批量是请求的性质,不是单个动词的性质。这里拥有三件事:
|
|
2618
|
+
* - 去重并保持输入顺序;
|
|
2619
|
+
* - 逐条信封:一条失败不传染下一条,失败按 map-error 折成错误对象;
|
|
2620
|
+
* - 恢复通告攒到批尾,一次 announceRestored(ids)。
|
|
1822
2621
|
*
|
|
1823
|
-
*
|
|
1824
|
-
*
|
|
1825
|
-
*
|
|
1826
|
-
* ③ **一批一次通告**:恢复的 `api-session/added` 攒到批尾、调一次 `announceRestored(ids)`(预取与逐条
|
|
1827
|
-
* 发送都在 adapter 里;逐条调 = 每条一次宿主全表扫,那正是 2026-10-17 "恢复很慢"的头号来源)。
|
|
1828
|
-
*
|
|
1829
|
-
* **同批已带走集**(整批只读一次"最初的样子")住 `src/verbs/batch-carried.ts`;这里消费它的
|
|
1830
|
-
* `claim` / `take`,purge 另取 entry 域的 `rows()` 作为当前只读清册。
|
|
1831
|
-
*
|
|
1832
|
-
* 「已被同批前一条带走」是**幂等成功**,不是失败:用户要的结果本来就已经达成了(`delete`/`restore`
|
|
1833
|
-
* 回 `null`;`purge` 回 `{removed:0,freedBytes:0}`)。判据仍归 `trash/groups.ts`(ADR-0001,
|
|
1834
|
-
* 由 `batch-carried.ts` 调用),同批已带走集只决定"这一条还该不该交给动词"。
|
|
2622
|
+
* 「已被同批前一条带走」是幂等成功(delete/restore 回 null,purge 回 0),不是失败;
|
|
2623
|
+
* 判据在 trash/groups.ts,同批已带走集:delete / restore 在 batch-carried.ts,purge 的那份连同
|
|
2624
|
+
* 收缩视图住 trash-scope.ts(清册作用域)。
|
|
1835
2625
|
*/
|
|
1836
2626
|
function failure(id, action, error) {
|
|
1837
2627
|
return {
|
|
@@ -1870,313 +2660,329 @@ async function forEachId(action, ids, run) {
|
|
|
1870
2660
|
for (const id of unique(ids)) try {
|
|
1871
2661
|
outcomes.push(success(id, action, await run(id)));
|
|
1872
2662
|
} catch (error) {
|
|
1873
|
-
outcomes.push(failure(id, action, error));
|
|
1874
|
-
}
|
|
1875
|
-
return outcomes;
|
|
1876
|
-
}
|
|
1877
|
-
async function applyDelete(deps, ids) {
|
|
1878
|
-
const run = deleteSession(deps);
|
|
1879
|
-
const carried = createSessionCarried();
|
|
1880
|
-
return forEachId("delete", ids, async (sessionId) => {
|
|
1881
|
-
if (carried.claim(sessionId).verdict === "carried") return null;
|
|
1882
|
-
const { entry, movedSessionIds } = await run(sessionId);
|
|
1883
|
-
for (const movedId of movedSessionIds) carried.take(movedId);
|
|
1884
|
-
return entry;
|
|
1885
|
-
});
|
|
1886
|
-
}
|
|
1887
|
-
/** 「这条是不是被同批前一条带走了」与视图收缩都归 `batch-carried.ts`;`apply` 整批只读一次清册,
|
|
1888
|
-
* 动词只拿它交回的只读组与当前清册。 */
|
|
1889
|
-
async function applyRestore(deps, ids) {
|
|
1890
|
-
const carried = createEntryCarried(await deps.ledger.list());
|
|
1891
|
-
const run = restoreSession(deps);
|
|
1892
|
-
const restored = [];
|
|
1893
|
-
const outcomes = await forEachId("restore", ids, async (entryId) => {
|
|
1894
|
-
const claim = carried.claim(entryId);
|
|
1895
|
-
if (claim.verdict === "carried") return null;
|
|
1896
|
-
const group = claim.verdict === "fresh" ? claim.group : [];
|
|
1897
|
-
const entry = await run(entryId, group);
|
|
1898
|
-
carried.take(entryId);
|
|
1899
|
-
restored.push(...group.map((row) => row.sessionId));
|
|
1900
|
-
return entry;
|
|
1901
|
-
});
|
|
1902
|
-
if (restored.length > 0) await deps.host.announceRestored(restored);
|
|
1903
|
-
return outcomes;
|
|
1904
|
-
}
|
|
1905
|
-
async function applyPurge(deps, ids) {
|
|
1906
|
-
const carried = createEntryCarried(await deps.ledger.list());
|
|
1907
|
-
const run = purgeTrash(deps);
|
|
1908
|
-
return forEachId("purge", ids, async (entryId) => {
|
|
1909
|
-
const claim = carried.claim(entryId);
|
|
1910
|
-
if (claim.verdict === "carried") return {
|
|
1911
|
-
removed: 0,
|
|
1912
|
-
freedBytes: 0
|
|
1913
|
-
};
|
|
1914
|
-
const group = claim.verdict === "fresh" ? claim.group : [];
|
|
1915
|
-
const stats = await run(group, carried.rows(), entryId);
|
|
1916
|
-
carried.take(entryId);
|
|
1917
|
-
return stats;
|
|
1918
|
-
});
|
|
1919
|
-
}
|
|
1920
|
-
/** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentMembersOf` 本来就是批量口)。
|
|
1921
|
-
* 宿主口缺席(`available:false`)时按 0(这里不再有"空表"这一步);真读失败则整次调用失败,
|
|
1922
|
-
* 浏览器半按"读不到"把整句不说。 */
|
|
1923
|
-
async function applyCount(deps, ids) {
|
|
1924
|
-
const wanted = unique(ids);
|
|
1925
|
-
const read = await deps.host.subagentMembersOf(wanted);
|
|
1926
|
-
return wanted.map((id) => success(id, "count", read.available ? read.value[id]?.length ?? 0 : 0));
|
|
1927
|
-
}
|
|
1928
|
-
/**
|
|
1929
|
-
* 清一组无主子会话(父已被彻底删掉、孩子还留在 `sessions/` 里的那些)。id 是**根会话 id**——
|
|
1930
|
-
* 界面先 `GET /orphans` 拿 `roots` 再原样带回来,所以这里的 `not-found` 说的是"这句话已经过期了"
|
|
1931
|
-
* (父又被人放回回收站、组已被上一个请求带走,或另一处已经把目录清掉),不是"用户打错了一个 id"。
|
|
1932
|
-
* 根 id 两边都去 `session-` 前缀再比:报告原样回传是正常路径,外部调用带另一种写法也认。
|
|
1933
|
-
*
|
|
1934
|
-
* **这是写动作,排在到达序链上**,而且**先对账再扫**:`loadOrphanGroups` 本身只扫描、不写清册;
|
|
1935
|
-
* 这里先把 `ledger.list()` 对账后的行交给它——刚被人手工搬进 `session-trash/` 的那一格目录此刻可能还
|
|
1936
|
-
* 没有对应行,拿 raw 索引扫就会把"父还在回收站里等着"的一组当成孤儿**直接删盘**,替用户做了一个没人
|
|
1937
|
-
* 点过的决定(那种组该跟着父恢复或彻底删除)。
|
|
1938
|
-
*/
|
|
1939
|
-
async function applyOrphanClean(deps, ids) {
|
|
1940
|
-
const groups = await loadOrphanGroups(deps, await deps.ledger.list());
|
|
1941
|
-
const byRoot = new Map(groups.map((group) => [bareSessionId(group.root.sessionId), group]));
|
|
1942
|
-
return forEachId("orphan-clean", ids, async (id) => {
|
|
1943
|
-
const group = byRoot.get(bareSessionId(id));
|
|
1944
|
-
if (group === void 0) throw verbError("not-found", `不再是可清理的无主子会话:${id}`);
|
|
1945
|
-
return await cleanOrphanGroup(deps, group);
|
|
1946
|
-
});
|
|
1947
|
-
}
|
|
1948
|
-
function applyActions(deps, action, ids) {
|
|
1949
|
-
switch (action) {
|
|
1950
|
-
case "delete": return applyDelete(deps, ids);
|
|
1951
|
-
case "restore": return applyRestore(deps, ids);
|
|
1952
|
-
case "purge": return applyPurge(deps, ids);
|
|
1953
|
-
case "count": return applyCount(deps, ids);
|
|
1954
|
-
case "orphan-clean": return applyOrphanClean(deps, ids);
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
//#endregion
|
|
1958
|
-
//#region src/log/zstd-frames.ts
|
|
1959
|
-
const MAGIC = [
|
|
1960
|
-
40,
|
|
1961
|
-
181,
|
|
1962
|
-
47,
|
|
1963
|
-
253
|
|
1964
|
-
];
|
|
1965
|
-
/** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
|
|
1966
|
-
const AFTER_DESCRIPTOR = 5;
|
|
1967
|
-
const BLOCK_HEADER_BYTES = 3;
|
|
1968
|
-
const BLOCK_LAST = 1;
|
|
1969
|
-
const BLOCK_TYPE_SHIFT = 1;
|
|
1970
|
-
const BLOCK_SIZE_SHIFT = 3;
|
|
1971
|
-
const BLOCK_SIZE_MASK = 2097151;
|
|
1972
|
-
const BLOCK_RAW = 0;
|
|
1973
|
-
const BLOCK_RLE = 1;
|
|
1974
|
-
const BLOCK_COMRESSED = 2;
|
|
1975
|
-
/** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
|
|
1976
|
-
const BLOCK_RESERVED = 3;
|
|
1977
|
-
/** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
|
|
1978
|
-
const DID_SIZES = [
|
|
1979
|
-
0,
|
|
1980
|
-
1,
|
|
1981
|
-
2,
|
|
1982
|
-
4
|
|
1983
|
-
];
|
|
1984
|
-
const FCS_SIZES_SINGLE = [
|
|
1985
|
-
1,
|
|
1986
|
-
2,
|
|
1987
|
-
4,
|
|
1988
|
-
8
|
|
1989
|
-
];
|
|
1990
|
-
const FCS_SIZES_MULTI = [
|
|
1991
|
-
0,
|
|
1992
|
-
2,
|
|
1993
|
-
4,
|
|
1994
|
-
8
|
|
1995
|
-
];
|
|
1996
|
-
/** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
|
|
1997
|
-
const FRAME_TAIL_BYTES = 4;
|
|
1998
|
-
function isMagicAt(raw, at) {
|
|
1999
|
-
return MAGIC.every((byte, i) => raw[at + i] === byte);
|
|
2000
|
-
}
|
|
2001
|
-
function readUint24LE(raw, at) {
|
|
2002
|
-
return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
|
|
2003
|
-
}
|
|
2004
|
-
/** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
|
|
2005
|
-
function withFrameTail(raw, at) {
|
|
2006
|
-
const afterTail = at + FRAME_TAIL_BYTES;
|
|
2007
|
-
if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
|
|
2008
|
-
return at;
|
|
2009
|
-
}
|
|
2010
|
-
/**
|
|
2011
|
-
* 把 `raw` 切成帧区间。
|
|
2012
|
-
* @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
|
|
2013
|
-
* 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
|
|
2014
|
-
*/
|
|
2015
|
-
function frameRanges(raw) {
|
|
2016
|
-
const ranges = [];
|
|
2017
|
-
let at = 0;
|
|
2018
|
-
while (at < raw.length) {
|
|
2019
|
-
if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
|
|
2020
|
-
const descriptor = raw[at + 4];
|
|
2021
|
-
if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
|
|
2022
|
-
const fcsFlag = descriptor & 3;
|
|
2023
|
-
const singleSegment = descriptor >> 2 & 1;
|
|
2024
|
-
const didFlag = descriptor >> 6 & 3;
|
|
2025
|
-
let cursor = at + AFTER_DESCRIPTOR;
|
|
2026
|
-
if (!singleSegment) cursor += 1;
|
|
2027
|
-
cursor += DID_SIZES[didFlag];
|
|
2028
|
-
cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
|
|
2029
|
-
for (;;) {
|
|
2030
|
-
const blockHeader = readUint24LE(raw, cursor);
|
|
2031
|
-
cursor += BLOCK_HEADER_BYTES;
|
|
2032
|
-
if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
|
|
2033
|
-
const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
|
|
2034
|
-
const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
|
|
2035
|
-
if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
|
|
2036
|
-
if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
|
|
2037
|
-
else if (blockType === BLOCK_RLE) cursor += 1;
|
|
2038
|
-
if (blockHeader & BLOCK_LAST) break;
|
|
2039
|
-
}
|
|
2040
|
-
cursor = withFrameTail(raw, cursor);
|
|
2041
|
-
if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
|
|
2042
|
-
ranges.push({
|
|
2043
|
-
start: at,
|
|
2044
|
-
end: cursor
|
|
2045
|
-
});
|
|
2046
|
-
at = cursor;
|
|
2047
|
-
}
|
|
2048
|
-
return ranges;
|
|
2049
|
-
}
|
|
2050
|
-
//#endregion
|
|
2051
|
-
//#region src/log/session-log.ts
|
|
2052
|
-
/**
|
|
2053
|
-
* 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
|
|
2054
|
-
* 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
|
|
2055
|
-
*
|
|
2056
|
-
* **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
|
|
2057
|
-
* 这一层只懂编解码,所以它能被单独验穿。
|
|
2058
|
-
*
|
|
2059
|
-
* 两条实测决定的口径:
|
|
2060
|
-
* ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
|
|
2061
|
-
* `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
|
|
2062
|
-
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
2063
|
-
* ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
|
|
2064
|
-
* 本机实测**没有一帧切在行中间**(连最后一帧都整行收尾;绝对帧数随盘上会话数摆动,那份读数
|
|
2065
|
-
* 不在这里钉,来历留在 git 历史),
|
|
2066
|
-
* 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
|
|
2067
|
-
* 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
|
|
2068
|
-
* 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
|
|
2069
|
-
* 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
|
|
2070
|
-
*/
|
|
2071
|
-
const NEWLINE = 10;
|
|
2072
|
-
/**
|
|
2073
|
-
* 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
|
|
2074
|
-
* `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
|
|
2075
|
-
* 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
|
|
2076
|
-
*/
|
|
2077
|
-
const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
|
|
2078
|
-
/**
|
|
2079
|
-
* 同一个会话目录里**可以并存多代日志**(宿主换代时把老代留在原地当历史语料,本机实测 7 个目录如此),
|
|
2080
|
-
* 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
|
|
2081
|
-
* ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
|
|
2082
|
-
*
|
|
2083
|
-
* 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
|
|
2084
|
-
*/
|
|
2085
|
-
function pickSessionLog(names) {
|
|
2086
|
-
let best;
|
|
2087
|
-
let bestVersion = -1;
|
|
2088
|
-
for (const name of names) {
|
|
2089
|
-
const match = CANONICAL_ZSTD_LOG.exec(name);
|
|
2090
|
-
if (match === null) continue;
|
|
2091
|
-
const version = match[1] === void 0 ? 0 : Number(match[1]);
|
|
2092
|
-
if (version > bestVersion) {
|
|
2093
|
-
bestVersion = version;
|
|
2094
|
-
best = name;
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
return best;
|
|
2098
|
-
}
|
|
2099
|
-
/**
|
|
2100
|
-
* 逐帧走一遍日志,每条记录交给回调。
|
|
2101
|
-
*
|
|
2102
|
-
* 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
|
|
2103
|
-
* 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
|
|
2104
|
-
*/
|
|
2105
|
-
function walkSessionLog(raw, onRecord) {
|
|
2106
|
-
const stats = {
|
|
2107
|
-
frames: 0,
|
|
2108
|
-
records: 0,
|
|
2109
|
-
unparsedLines: 0,
|
|
2110
|
-
truncatedTailBytes: 0
|
|
2111
|
-
};
|
|
2112
|
-
let stopped = false;
|
|
2113
|
-
const feed = (line) => {
|
|
2114
|
-
if (line === "") return;
|
|
2115
|
-
let parsed;
|
|
2116
|
-
try {
|
|
2117
|
-
parsed = JSON.parse(line);
|
|
2118
|
-
} catch {
|
|
2119
|
-
stats.unparsedLines += 1;
|
|
2120
|
-
return;
|
|
2121
|
-
}
|
|
2122
|
-
stats.records += 1;
|
|
2123
|
-
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
2124
|
-
};
|
|
2125
|
-
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
2126
|
-
let carry = Buffer.alloc(0);
|
|
2127
|
-
for (const { start, end } of frameRanges(raw)) {
|
|
2128
|
-
if (stopped) break;
|
|
2129
|
-
stats.frames += 1;
|
|
2130
|
-
let plain;
|
|
2131
|
-
try {
|
|
2132
|
-
plain = zlib.zstdDecompressSync(raw.subarray(start, end));
|
|
2133
|
-
} catch (error) {
|
|
2134
|
-
throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
|
|
2135
|
-
}
|
|
2136
|
-
const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
|
|
2137
|
-
const cut = merged.lastIndexOf(NEWLINE);
|
|
2138
|
-
if (cut === -1) {
|
|
2139
|
-
carry = merged;
|
|
2140
|
-
continue;
|
|
2141
|
-
}
|
|
2142
|
-
carry = merged.subarray(cut + 1);
|
|
2143
|
-
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
2144
|
-
feed(line);
|
|
2145
|
-
if (stopped) break;
|
|
2146
|
-
}
|
|
2663
|
+
outcomes.push(failure(id, action, error));
|
|
2147
2664
|
}
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2665
|
+
return outcomes;
|
|
2666
|
+
}
|
|
2667
|
+
async function applyDelete(deps, ids) {
|
|
2668
|
+
const run = deleteSession(deps);
|
|
2669
|
+
const carried = createSessionCarried();
|
|
2670
|
+
return forEachId("delete", ids, async (sessionId) => {
|
|
2671
|
+
if (carried.claim(sessionId).verdict === "carried") return null;
|
|
2672
|
+
const { entry, movedSessionIds } = await run(sessionId);
|
|
2673
|
+
for (const movedId of movedSessionIds) carried.take(movedId);
|
|
2674
|
+
return entry;
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
/** restore 的「这条是不是被同批前一条带走了」与视图收缩都归 `batch-carried.ts`(purge 那一半归
|
|
2678
|
+
* `trash-scope.ts`);`apply` 整批只读一次清册,动词只拿它交回的只读组与当前清册。 */
|
|
2679
|
+
async function applyRestore(deps, ids) {
|
|
2680
|
+
const carried = createEntryCarried(await deps.ledger.list());
|
|
2681
|
+
const run = restoreSession(deps);
|
|
2682
|
+
const restored = [];
|
|
2683
|
+
const outcomes = await forEachId("restore", ids, async (entryId) => {
|
|
2684
|
+
const claim = carried.claim(entryId);
|
|
2685
|
+
if (claim.verdict === "carried") return null;
|
|
2686
|
+
const group = claim.verdict === "fresh" ? claim.group : [];
|
|
2687
|
+
const entry = await run(entryId, group);
|
|
2688
|
+
carried.take(entryId);
|
|
2689
|
+
restored.push(...group.map((row) => row.sessionId));
|
|
2690
|
+
return entry;
|
|
2691
|
+
});
|
|
2692
|
+
if (restored.length > 0) await deps.presence.announceRestored(restored);
|
|
2693
|
+
return outcomes;
|
|
2694
|
+
}
|
|
2695
|
+
/** purge 的整批语义收进清册作用域:选组、收缩视图、同批带走的幂等(回 0)与 absent 的
|
|
2696
|
+
* trash-empty(原文点名)都在 `scope.purge(id)` 里——这里整批只读一次清册、逐 id 只交一个 id,
|
|
2697
|
+
* 两份数组(算好的组 + 收缩中的视图)不再从批量层递两遍。 */
|
|
2698
|
+
async function applyPurge(deps, ids) {
|
|
2699
|
+
const scope = createTrashScope(deps, await deps.ledger.list());
|
|
2700
|
+
return forEachId("purge", ids, (entryId) => scope.purge(entryId));
|
|
2701
|
+
}
|
|
2702
|
+
/** 删除确认框那句「含 N 个子会话」:一次问完整批(`subagentMembersOf` 本来就是批量口)。
|
|
2703
|
+
* 宿主口缺席(`available:false`)时按 0(这里不再有"空表"这一步);真读失败则整次调用失败,
|
|
2704
|
+
* 浏览器半按"读不到"把整句不说。 */
|
|
2705
|
+
async function applyCount(deps, ids) {
|
|
2706
|
+
const wanted = unique(ids);
|
|
2707
|
+
const read = await deps.host.subagentMembersOf(wanted);
|
|
2708
|
+
return wanted.map((id) => success(id, "count", read.available ? read.value[id]?.length ?? 0 : 0));
|
|
2151
2709
|
}
|
|
2152
2710
|
/**
|
|
2153
|
-
*
|
|
2154
|
-
*
|
|
2155
|
-
*
|
|
2156
|
-
*
|
|
2711
|
+
* 清一组无主子会话(父已被彻底删掉、孩子还留在 `sessions/` 里的那些)。id 是**根会话 id**——
|
|
2712
|
+
* 界面先 `GET /orphans` 拿 `roots` 再原样带回来,所以这里的 `not-found` 说的是"这句话已经过期了"
|
|
2713
|
+
* (父又被人放回回收站、组已被上一个请求带走,或另一处已经把目录清掉),不是"用户打错了一个 id"。
|
|
2714
|
+
* 根 id 两边都去 `session-` 前缀再比:报告原样回传是正常路径,外部调用带另一种写法也认。
|
|
2715
|
+
*
|
|
2716
|
+
* **这是写动作,排在到达序链上**,而且**先对账再扫**:`loadOrphanGroups` 本身只扫描、不写清册;
|
|
2717
|
+
* 这里先把 `ledger.list()` 对账后的行交给它——刚被人手工搬进 `session-trash/` 的那一格目录此刻可能还
|
|
2718
|
+
* 没有对应行,拿 raw 索引扫就会把"父还在回收站里等着"的一组当成孤儿**直接删盘**,替用户做了一个没人
|
|
2719
|
+
* 点过的决定(那种组该跟着父恢复或彻底删除)。
|
|
2157
2720
|
*/
|
|
2158
|
-
function
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2721
|
+
async function applyOrphanClean(deps, ids) {
|
|
2722
|
+
const groups = await createTrashScope(deps, await deps.ledger.list()).orphanGroups(ids);
|
|
2723
|
+
const byRoot = new Map(groups.map((group) => [bareSessionId(group.root.sessionId), group]));
|
|
2724
|
+
return forEachId("orphan-clean", ids, async (id) => {
|
|
2725
|
+
const group = byRoot.get(bareSessionId(id));
|
|
2726
|
+
if (group === void 0) throw verbError("not-found", `不再是可清理的无主子会话:${id}`);
|
|
2727
|
+
return await cleanOrphanGroup(deps, group);
|
|
2164
2728
|
});
|
|
2165
|
-
return header;
|
|
2166
2729
|
}
|
|
2730
|
+
function applyActions(deps, action, ids) {
|
|
2731
|
+
switch (action) {
|
|
2732
|
+
case "delete": return applyDelete(deps, ids);
|
|
2733
|
+
case "restore": return applyRestore(deps, ids);
|
|
2734
|
+
case "purge": return applyPurge(deps, ids);
|
|
2735
|
+
case "count": return applyCount(deps, ids);
|
|
2736
|
+
case "orphan-clean": return applyOrphanClean(deps, ids);
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
//#endregion
|
|
2740
|
+
//#region src/trash/detail.ts
|
|
2741
|
+
/**
|
|
2742
|
+
* 回收站**单条详情**:从回收站目录里那条会话自己的日志读出元信息与正文块(用例见 `tests/trash-detail.test.ts`)。
|
|
2743
|
+
*
|
|
2744
|
+
* 三层里的策略层——编解码在 `src/log/*`,这里只回答"给谁看、给多少、算不算失败"。
|
|
2745
|
+
*
|
|
2746
|
+
* 四条硬口径,都不是口味:
|
|
2747
|
+
* ① **寻址只认清册(raw 索引)**:`entryId` 先在对账之外的 raw 索引里查,目录一律由 `trashDirOf(record.id, home)` 推
|
|
2748
|
+
* ⇒ 外部输入进不了文件系统寻址(不给"传个路径就读任意目录"留缝)。
|
|
2749
|
+
* ② **正文是块,不是消息**:`user/message` 按 `source.kind` 拆成 user / injection;`assistant/message.content`
|
|
2750
|
+
* 按 `text` / `reasoning` 拆块,附件并进相邻的文字块;`tool/call` 建工具块、`tool/result` 按 `callId` 回填;
|
|
2751
|
+
* `system/message` 与 `compaction/summary` 各成一块。turn/step/job/request 这类簿记事件不进正文——它们不是
|
|
2752
|
+
* "用户想看的对话",铺进来只会把真正的提问和回复淹掉。
|
|
2753
|
+
* ③ **分页是游标,不是总量闸**:一次只回尾部一页,`olderCursor` 指页首;客户端把游标原样回传取更早一页。
|
|
2754
|
+
* 页有两道闸:块数 `pageBlocks` 与正文总量 `pageChars`(单块超字符闸仍保留一块,游标才能前进);
|
|
2755
|
+
* 每块另有 `perBlockChars` 的单块闸(正文与工具参数/结果各自算),超了标 `truncated`。
|
|
2756
|
+
* 构建时用**有界缓冲只留当前页**,不把整条日志的正文堆在内存里;工具结果可能落在游标之后,
|
|
2757
|
+
* 仍会回填到页内那个调用块(别把"已完成"误报成"未完成")。
|
|
2758
|
+
* ④ **计数与正文同源过滤、各过各的闸**:`userMessageCount` / `injectedCount` 只数建了块的;`assistantMessageCount`
|
|
2759
|
+
* 数**每一条** assistant/message(含只带 reasoning / tool-call 的);`toolCallCount` 数每一条 tool/call。
|
|
2760
|
+
* 计数是整条日志的,不受分页与任何字符闸影响。
|
|
2761
|
+
*
|
|
2762
|
+
* 附件随块一起收(`attachmentsInContent`):每块最多 20、每页最多 200;超出的**静默丢**,不另立计数——
|
|
2763
|
+
* "少了几张图"与"这块被截断"不是同一件事,拿一个数兼报会让两个数都对不上。
|
|
2764
|
+
*/
|
|
2765
|
+
/** 每块文字的上限(字符)。正文、工具参数、工具结果各自吃这一刀。 */
|
|
2766
|
+
const PER_BLOCK_CHAR_CAP = 16e3;
|
|
2767
|
+
/** 一页正文的总字符上限(块按日志顺序累计;单块超闸时仍保留一块)。 */
|
|
2768
|
+
const PAGE_CHAR_LIMIT = 5e5;
|
|
2769
|
+
/** 一块最多带多少张附件上线。 */
|
|
2770
|
+
const MAX_ATTACHMENTS_PER_BLOCK = 20;
|
|
2771
|
+
/** 一页最多带多少张附件上线。 */
|
|
2772
|
+
const MAX_ATTACHMENTS_PER_PAGE = 200;
|
|
2167
2773
|
const DEFAULT_LIMITS = {
|
|
2168
|
-
|
|
2169
|
-
|
|
2774
|
+
perBlockChars: PER_BLOCK_CHAR_CAP,
|
|
2775
|
+
pageBlocks: 30,
|
|
2776
|
+
pageChars: PAGE_CHAR_LIMIT,
|
|
2777
|
+
subagents: 200
|
|
2170
2778
|
};
|
|
2171
2779
|
/** 一条记录里那些 `content[]` 段的文字拼起来(`\n` 分隔;非 text 段一律不参与)。 */
|
|
2172
2780
|
function textOf(content) {
|
|
2173
2781
|
if (!Array.isArray(content)) return "";
|
|
2174
2782
|
return content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
|
|
2175
2783
|
}
|
|
2784
|
+
/** 一条记录的 `content[]` 里的附件块,映射成线上读数(形状只映射,不改判据——闸在 `attachmentsInContent` 里)。 */
|
|
2785
|
+
function attachmentWires(content) {
|
|
2786
|
+
return attachmentsInContent(content).map((ref) => ({
|
|
2787
|
+
attachmentId: ref.id,
|
|
2788
|
+
kind: ref.kind,
|
|
2789
|
+
...ref.name === void 0 ? {} : { name: ref.name },
|
|
2790
|
+
...ref.kind === "image" && ref.mediaType !== void 0 ? { mediaType: ref.mediaType } : {},
|
|
2791
|
+
bytes: ref.bytes,
|
|
2792
|
+
...ref.kind === "image" && ref.width !== void 0 ? { width: ref.width } : {},
|
|
2793
|
+
...ref.kind === "image" && ref.height !== void 0 ? { height: ref.height } : {}
|
|
2794
|
+
}));
|
|
2795
|
+
}
|
|
2796
|
+
/** 单块闸:超过上限就截一刀并置标记。 */
|
|
2797
|
+
function clip(text, cap) {
|
|
2798
|
+
return text.length > cap ? {
|
|
2799
|
+
text: text.slice(0, cap),
|
|
2800
|
+
truncated: true
|
|
2801
|
+
} : {
|
|
2802
|
+
text,
|
|
2803
|
+
truncated: false
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
/** 「这块有没有内容」的**唯一判据**:非空正文或至少一个附件。建块(`blockBody`)与出线收尾
|
|
2807
|
+
* (`PageBuilder.finish` 的附件总量闸之后)都认这一支——判据写两遍就是两份会漂移的抄本,
|
|
2808
|
+
* 渲染侧因此不必、也不许再兜空块。 */
|
|
2809
|
+
function hasContent(text, attachments) {
|
|
2810
|
+
return text !== "" || attachments.length > 0;
|
|
2811
|
+
}
|
|
2812
|
+
/** 一块的正文:没内容 ⇒ 这块不建;否则过单块闸截一刀。 */
|
|
2813
|
+
function blockBody(rawText, attachments, cap) {
|
|
2814
|
+
const text = rawText.trim();
|
|
2815
|
+
const kept = attachments.slice(0, MAX_ATTACHMENTS_PER_BLOCK);
|
|
2816
|
+
if (!hasContent(text, kept)) return void 0;
|
|
2817
|
+
const clipped = clip(text, cap);
|
|
2818
|
+
return {
|
|
2819
|
+
text: clipped.text,
|
|
2820
|
+
truncated: clipped.truncated,
|
|
2821
|
+
attachments: kept
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
function textBlock(kind, time, rawText, attachments, cap) {
|
|
2825
|
+
const body = blockBody(rawText, attachments, cap);
|
|
2826
|
+
return body === void 0 ? void 0 : {
|
|
2827
|
+
kind,
|
|
2828
|
+
id: "",
|
|
2829
|
+
time,
|
|
2830
|
+
...body
|
|
2831
|
+
};
|
|
2832
|
+
}
|
|
2833
|
+
function injectionBlock(time, sourceKind, rawText, attachments, cap) {
|
|
2834
|
+
const body = blockBody(rawText, attachments, cap);
|
|
2835
|
+
return body === void 0 ? void 0 : {
|
|
2836
|
+
kind: "injection",
|
|
2837
|
+
id: "",
|
|
2838
|
+
time,
|
|
2839
|
+
sourceKind,
|
|
2840
|
+
...body
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
/** 一块在页级字符闸里占多少字符:正文与工具参数/结果都算;截断标记与附件元数据不计。 */
|
|
2844
|
+
function blockChars(block) {
|
|
2845
|
+
switch (block.kind) {
|
|
2846
|
+
case "tool": return block.args.length + block.result.length;
|
|
2847
|
+
case "user":
|
|
2848
|
+
case "assistant":
|
|
2849
|
+
case "reasoning":
|
|
2850
|
+
case "injection":
|
|
2851
|
+
case "system":
|
|
2852
|
+
case "compaction": return block.text.length;
|
|
2853
|
+
default: return unhandledChars(block);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
/** 新块类型没登记字符口径时编译失败;运行时(前后半版本错配)按 0 计。 */
|
|
2857
|
+
function unhandledChars(block) {
|
|
2858
|
+
return 0;
|
|
2859
|
+
}
|
|
2860
|
+
/**
|
|
2861
|
+
* 一页的有界缓冲:只留游标之前**最后** `pageBlocks` 块,且正文总量不超过 `pageChars`。
|
|
2862
|
+
*
|
|
2863
|
+
* `nextIndex` 数的是整条日志的块序(包括游标之后的块),`buffer` 只装本页。工具结果可能落在
|
|
2864
|
+
* 游标之后:`toolByCallId` 让它在页内那个调用块上回填。
|
|
2865
|
+
*
|
|
2866
|
+
* 配对认的是**块对象**,不是 callId 这一格:宿主重试会让同一个 callId 建出两块,按 callId 摘映射
|
|
2867
|
+
* 会把仍然在页内的另一块误摘成「未完成」——ADR-0026 决定 3 明令不许。所以每个 callId 挂一串页内
|
|
2868
|
+
* 块(按日志顺序),挤出谁就摘谁。本机回收站与 live 日志实测:callId 与结果一比一、无重复、无逆序,
|
|
2869
|
+
* 这一串常态只有一个元素;串与下面的配法是给非常态兜底的。
|
|
2870
|
+
*/
|
|
2871
|
+
var PageBuilder = class PageBuilder {
|
|
2872
|
+
buffer = [];
|
|
2873
|
+
toolByCallId = /* @__PURE__ */ new Map();
|
|
2874
|
+
nextIndex = 0;
|
|
2875
|
+
chars = 0;
|
|
2876
|
+
/** `end` 是本页的右边界(不含):块序号大于等于它的不入页;`Infinity` = 从尾部取。 */
|
|
2877
|
+
end;
|
|
2878
|
+
limit;
|
|
2879
|
+
cap;
|
|
2880
|
+
pageChars;
|
|
2881
|
+
constructor(end, limit, cap, pageChars) {
|
|
2882
|
+
this.end = end;
|
|
2883
|
+
this.limit = limit;
|
|
2884
|
+
this.cap = cap;
|
|
2885
|
+
this.pageChars = pageChars;
|
|
2886
|
+
}
|
|
2887
|
+
/** 追加一块:id 用整条日志的块序号盖,页内只留最后 `limit` 块 / `pageChars` 字符。 */
|
|
2888
|
+
add(block) {
|
|
2889
|
+
const index = this.nextIndex++;
|
|
2890
|
+
if (index >= this.end) return;
|
|
2891
|
+
block.id = String(index);
|
|
2892
|
+
const chars = blockChars(block);
|
|
2893
|
+
this.buffer.push({
|
|
2894
|
+
index,
|
|
2895
|
+
block,
|
|
2896
|
+
chars
|
|
2897
|
+
});
|
|
2898
|
+
this.chars += chars;
|
|
2899
|
+
this.trim();
|
|
2900
|
+
if (block.kind === "tool") this.hook(block);
|
|
2901
|
+
}
|
|
2902
|
+
/** 挤出页首:块数超限,或正文超字符闸(但至少留一块,游标才能前进)。 */
|
|
2903
|
+
trim() {
|
|
2904
|
+
while (this.buffer.length > this.limit || this.buffer.length > 1 && this.chars > this.pageChars) {
|
|
2905
|
+
const evicted = this.buffer.shift();
|
|
2906
|
+
if (evicted === void 0) return;
|
|
2907
|
+
this.chars -= evicted.chars;
|
|
2908
|
+
if (evicted.block.kind === "tool") this.unhook(evicted.block);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
/** 没带 callId 的调用块无从配对:照常入页(界面念「未完成」),只是不进配对串。 */
|
|
2912
|
+
static pairable(block) {
|
|
2913
|
+
return block.callId !== "";
|
|
2914
|
+
}
|
|
2915
|
+
hook(block) {
|
|
2916
|
+
if (!PageBuilder.pairable(block)) return;
|
|
2917
|
+
const queued = this.toolByCallId.get(block.callId);
|
|
2918
|
+
if (queued === void 0) this.toolByCallId.set(block.callId, [block]);
|
|
2919
|
+
else queued.push(block);
|
|
2920
|
+
}
|
|
2921
|
+
/** 挤出页的块从它的配对串里按对象摘掉;串空了连 key 一起摘,不留空串。 */
|
|
2922
|
+
unhook(block) {
|
|
2923
|
+
if (!PageBuilder.pairable(block)) return;
|
|
2924
|
+
const queued = this.toolByCallId.get(block.callId);
|
|
2925
|
+
if (queued === void 0) return;
|
|
2926
|
+
const at = queued.indexOf(block);
|
|
2927
|
+
if (at < 0) return;
|
|
2928
|
+
if (queued.length === 1) this.toolByCallId.delete(block.callId);
|
|
2929
|
+
else queued.splice(at, 1);
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* `tool/result` 回填:配给这一串里**最早那还没有结果**的调用块(同一个 callId 出两块时,结果按
|
|
2933
|
+
* 到达顺序配给先出的调用,两块各留一个读数)。
|
|
2934
|
+
*
|
|
2935
|
+
* 两种情况落不了点,都不改写已经配上结果的块:调用块在更早的页里(那一页自己走一趟时配上);
|
|
2936
|
+
* 结果条数多过页内待配的调用块(一次调用一个读数,后到的不覆盖先配上的)。口径见
|
|
2937
|
+
* `tests/trash-detail.test.ts` 的工具配对那几例。
|
|
2938
|
+
*
|
|
2939
|
+
* 落不了点就丢,不缓存:日志按时间落盘、结果恒在它的调用之后,逆序时这条结果无家可归、块停在
|
|
2940
|
+
* 未完成——为它把游标之后的结果正文留在手里,就拆掉了「只留当前页」这个内存界(ADR-0026 决定 3)。
|
|
2941
|
+
*
|
|
2942
|
+
* 同一 callId 的前一块已被挤出页时,结果会配给页内后一块,后一块自己的结果随之落空:宿主重试
|
|
2943
|
+
* 造出的双块没有可区分的结果序,宁可配对错位也不把仍在页内的块报成「未完成」。这是非常态兜底的
|
|
2944
|
+
* 代价,不另开缓存去修(那会重新把游标之后的正文留在手里)。
|
|
2945
|
+
*
|
|
2946
|
+
* 回填的正文也要计入页级字符闸:超了就挤页首,被挤掉的调用块会随更早一页重新走一遍日志、
|
|
2947
|
+
* 在那一页里配上同一个结果,不会丢成「未完成」。
|
|
2948
|
+
*/
|
|
2949
|
+
result(callId, rawResult, isError, attachments) {
|
|
2950
|
+
const queued = this.toolByCallId.get(callId);
|
|
2951
|
+
if (queued === void 0) return;
|
|
2952
|
+
const block = queued.find((candidate) => candidate.status === "unfinished");
|
|
2953
|
+
if (block === void 0) return;
|
|
2954
|
+
const clipped = clip(rawResult.trim(), this.cap);
|
|
2955
|
+
block.result = clipped.text;
|
|
2956
|
+
block.resultTruncated = clipped.truncated;
|
|
2957
|
+
block.status = isError ? "error" : "ok";
|
|
2958
|
+
block.attachments = attachments.slice(0, MAX_ATTACHMENTS_PER_BLOCK);
|
|
2959
|
+
const entry = this.buffer.find((candidate) => candidate.block === block);
|
|
2960
|
+
if (entry !== void 0) {
|
|
2961
|
+
const chars = blockChars(block);
|
|
2962
|
+
this.chars += chars - entry.chars;
|
|
2963
|
+
entry.chars = chars;
|
|
2964
|
+
this.trim();
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
finish() {
|
|
2968
|
+
let used = 0;
|
|
2969
|
+
for (const { block } of this.buffer) {
|
|
2970
|
+
const room = Math.max(0, Math.min(block.attachments.length, MAX_ATTACHMENTS_PER_PAGE - used));
|
|
2971
|
+
block.attachments = block.attachments.slice(0, room);
|
|
2972
|
+
used += room;
|
|
2973
|
+
}
|
|
2974
|
+
const first = this.buffer[0];
|
|
2975
|
+
return {
|
|
2976
|
+
blocks: this.buffer.map(({ block }) => block).filter((block) => block.kind === "tool" || hasContent(block.text, block.attachments)),
|
|
2977
|
+
olderCursor: first !== void 0 && first.index > 0 ? first.index : null
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
};
|
|
2176
2981
|
function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
2177
|
-
return async function readTrashDetail(entryId) {
|
|
2178
|
-
const
|
|
2179
|
-
|
|
2982
|
+
return async function readTrashDetail(entryId, before) {
|
|
2983
|
+
const all = await deps.rows();
|
|
2984
|
+
const record = all.find((e) => e.id === entryId);
|
|
2985
|
+
if (record === void 0) throw trashEmpty(entryId);
|
|
2180
2986
|
const dir = trashDirOf(record.id, deps.home);
|
|
2181
2987
|
if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
|
|
2182
2988
|
const names = await deps.fs.listFiles(dir);
|
|
@@ -2200,37 +3006,16 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
2200
3006
|
assistantMessageCount: 0,
|
|
2201
3007
|
toolCallCount: 0,
|
|
2202
3008
|
injectedCount: 0,
|
|
2203
|
-
|
|
2204
|
-
|
|
3009
|
+
blocks: [],
|
|
3010
|
+
olderCursor: null
|
|
2205
3011
|
};
|
|
2206
3012
|
const models = /* @__PURE__ */ new Set();
|
|
2207
|
-
|
|
2208
|
-
let capped = false;
|
|
2209
|
-
/** 收进正文,顺带管两个闸:截到单条上限,累计到总量就停。 */
|
|
2210
|
-
function push(role, time, text) {
|
|
2211
|
-
if (capped) {
|
|
2212
|
-
detail.omittedByPayloadCap += 1;
|
|
2213
|
-
return;
|
|
2214
|
-
}
|
|
2215
|
-
const truncated = text.length > limits.perMessageChars;
|
|
2216
|
-
const kept = truncated ? text.slice(0, limits.perMessageChars) : text;
|
|
2217
|
-
if (usedChars + kept.length > limits.totalChars) {
|
|
2218
|
-
capped = true;
|
|
2219
|
-
detail.omittedByPayloadCap += 1;
|
|
2220
|
-
return;
|
|
2221
|
-
}
|
|
2222
|
-
usedChars += kept.length;
|
|
2223
|
-
detail.messages.push({
|
|
2224
|
-
role,
|
|
2225
|
-
time,
|
|
2226
|
-
text: kept,
|
|
2227
|
-
truncated
|
|
2228
|
-
});
|
|
2229
|
-
}
|
|
3013
|
+
const page = new PageBuilder(before ?? Number.POSITIVE_INFINITY, Math.max(1, limits.pageBlocks), limits.perBlockChars, Math.max(1, limits.pageChars ?? 5e5));
|
|
2230
3014
|
try {
|
|
2231
3015
|
walkSessionLog(raw, (r) => {
|
|
2232
3016
|
if (typeof r.time === "number" && r.time > detail.lastActivity) detail.lastActivity = r.time;
|
|
2233
3017
|
const data = r.data ?? {};
|
|
3018
|
+
const time = r.time ?? 0;
|
|
2234
3019
|
switch (r.type) {
|
|
2235
3020
|
case "session":
|
|
2236
3021
|
if (typeof r.cwd === "string") detail.cwd = r.cwd;
|
|
@@ -2242,28 +3027,95 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
2242
3027
|
case "turn/start":
|
|
2243
3028
|
detail.turnCount += 1;
|
|
2244
3029
|
return;
|
|
2245
|
-
case "tool/call":
|
|
2246
|
-
detail.toolCallCount += 1;
|
|
2247
|
-
return;
|
|
2248
3030
|
case "user/message": {
|
|
2249
|
-
|
|
2250
|
-
|
|
3031
|
+
const source = data.source ?? {};
|
|
3032
|
+
const sourceKind = typeof source.kind === "string" ? source.kind : "";
|
|
3033
|
+
const text = textOf(data.content);
|
|
3034
|
+
const attachments = attachmentWires(data.content);
|
|
3035
|
+
if (sourceKind === "user") {
|
|
3036
|
+
const block = textBlock("user", time, text, attachments, limits.perBlockChars);
|
|
3037
|
+
if (block !== void 0) {
|
|
3038
|
+
detail.userMessageCount += 1;
|
|
3039
|
+
page.add(block);
|
|
3040
|
+
}
|
|
2251
3041
|
return;
|
|
2252
3042
|
}
|
|
2253
|
-
const
|
|
2254
|
-
if (
|
|
2255
|
-
|
|
2256
|
-
|
|
3043
|
+
const block = injectionBlock(time, sourceKind, text, attachments, limits.perBlockChars);
|
|
3044
|
+
if (block !== void 0) {
|
|
3045
|
+
detail.injectedCount += 1;
|
|
3046
|
+
page.add(block);
|
|
3047
|
+
}
|
|
2257
3048
|
return;
|
|
2258
3049
|
}
|
|
2259
3050
|
case "assistant/message": {
|
|
3051
|
+
detail.assistantMessageCount += 1;
|
|
2260
3052
|
const message = data.message ?? {};
|
|
2261
3053
|
const source = message.source ?? {};
|
|
2262
3054
|
if (typeof source.model === "string") models.add(source.model);
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
3055
|
+
const parts = Array.isArray(message.content) ? message.content : [];
|
|
3056
|
+
let pendingText = [];
|
|
3057
|
+
let pendingAttachments = [];
|
|
3058
|
+
const flush = () => {
|
|
3059
|
+
const block = textBlock("assistant", time, pendingText.join("\n"), pendingAttachments, limits.perBlockChars);
|
|
3060
|
+
if (block !== void 0) page.add(block);
|
|
3061
|
+
pendingText = [];
|
|
3062
|
+
pendingAttachments = [];
|
|
3063
|
+
};
|
|
3064
|
+
for (const part of parts) {
|
|
3065
|
+
if (part?.type === "text" && typeof part.text === "string") {
|
|
3066
|
+
pendingText.push(part.text);
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
if (part?.type === "reasoning" && typeof part.text === "string") {
|
|
3070
|
+
flush();
|
|
3071
|
+
const block = textBlock("reasoning", time, part.text, [], limits.perBlockChars);
|
|
3072
|
+
if (block !== void 0) page.add(block);
|
|
3073
|
+
continue;
|
|
3074
|
+
}
|
|
3075
|
+
if (part?.type === "image" || part?.type === "file") {
|
|
3076
|
+
pendingAttachments.push(...attachmentWires([part]));
|
|
3077
|
+
continue;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
flush();
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
case "tool/call": {
|
|
3084
|
+
detail.toolCallCount += 1;
|
|
3085
|
+
const callId = typeof data.callId === "string" ? data.callId : "";
|
|
3086
|
+
const name = typeof data.name === "string" ? data.name : "";
|
|
3087
|
+
const args = clip(typeof data.arguments === "string" ? data.arguments : "", limits.perBlockChars);
|
|
3088
|
+
page.add({
|
|
3089
|
+
kind: "tool",
|
|
3090
|
+
id: "",
|
|
3091
|
+
time,
|
|
3092
|
+
callId,
|
|
3093
|
+
name,
|
|
3094
|
+
status: "unfinished",
|
|
3095
|
+
args: args.text,
|
|
3096
|
+
argsTruncated: args.truncated,
|
|
3097
|
+
result: "",
|
|
3098
|
+
resultTruncated: false,
|
|
3099
|
+
attachments: []
|
|
3100
|
+
});
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
case "tool/result": {
|
|
3104
|
+
const message = data.message ?? {};
|
|
3105
|
+
const callId = typeof message.toolCallId === "string" ? message.toolCallId : "";
|
|
3106
|
+
if (callId === "") return;
|
|
3107
|
+
page.result(callId, textOf(message.content), message.isError === true, attachmentWires(message.content));
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
case "system/message": {
|
|
3111
|
+
const message = data.message ?? {};
|
|
3112
|
+
const block = textBlock("system", time, textOf(message.content), attachmentWires(message.content), limits.perBlockChars);
|
|
3113
|
+
if (block !== void 0) page.add(block);
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
case "compaction/summary": {
|
|
3117
|
+
const block = textBlock("compaction", time, textOf(data.summary), [], limits.perBlockChars);
|
|
3118
|
+
if (block !== void 0) page.add(block);
|
|
2267
3119
|
return;
|
|
2268
3120
|
}
|
|
2269
3121
|
default: return;
|
|
@@ -2272,28 +3124,118 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
2272
3124
|
} catch (error) {
|
|
2273
3125
|
throw verbError("io", error instanceof Error ? `读回收站日志失败:${error.message}` : `读回收站日志失败:${String(error)}`, error);
|
|
2274
3126
|
}
|
|
3127
|
+
const finished = page.finish();
|
|
3128
|
+
detail.blocks = finished.blocks;
|
|
3129
|
+
detail.olderCursor = finished.olderCursor;
|
|
2275
3130
|
detail.models = [...models];
|
|
3131
|
+
const kids = visibleGroups(all).find((group) => group.root.id === record.id)?.kids ?? [];
|
|
3132
|
+
if (kids.length > 0) {
|
|
3133
|
+
const limit = limits.subagents ?? 200;
|
|
3134
|
+
detail.subagents = kids.slice(0, limit).map(entryWire);
|
|
3135
|
+
if (kids.length > limit) detail.subagentsTotal = kids.length;
|
|
3136
|
+
}
|
|
2276
3137
|
return detail;
|
|
2277
3138
|
};
|
|
2278
3139
|
}
|
|
2279
3140
|
//#endregion
|
|
3141
|
+
//#region src/api/attachment.ts
|
|
3142
|
+
const MAX_ATTACHMENT_BYTES = 20971520;
|
|
3143
|
+
const PNG_MAGIC = Buffer.from([
|
|
3144
|
+
137,
|
|
3145
|
+
80,
|
|
3146
|
+
78,
|
|
3147
|
+
71,
|
|
3148
|
+
13,
|
|
3149
|
+
10,
|
|
3150
|
+
26,
|
|
3151
|
+
10
|
|
3152
|
+
]);
|
|
3153
|
+
/** 按魔数认图片类型;认不出回 `undefined`(调用方按未知字节处理,不猜)。 */
|
|
3154
|
+
function sniffImage(bytes) {
|
|
3155
|
+
if (bytes.length >= PNG_MAGIC.length && bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) return "image/png";
|
|
3156
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
3157
|
+
const head6 = bytes.subarray(0, 6).toString("ascii");
|
|
3158
|
+
if (head6 === "GIF87a" || head6 === "GIF89a") return "image/gif";
|
|
3159
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* 下载名过闸:只当头部里的一个串用,**不参与寻址**。
|
|
3163
|
+
* 路径分隔符、控制字符、`..` 与不成对的代理项一律换 `_`;按码点截到 120 个字符(不劈开代理对,
|
|
3164
|
+
* 否则 `encodeURIComponent` 会在写头时抛)。
|
|
3165
|
+
*/
|
|
3166
|
+
function safeFilename(name, fallback) {
|
|
3167
|
+
const raw = name === null || name === "" ? fallback : name;
|
|
3168
|
+
const base = Array.from(raw).slice(0, 120).join("").replace(/[\\/:*?"<>|\u0000-\u001f\uD800-\uDFFF]/gu, "_").replace(/\.\./gu, "_");
|
|
3169
|
+
return base === "" ? fallback : base;
|
|
3170
|
+
}
|
|
3171
|
+
async function readAttachmentResponse(deps, query) {
|
|
3172
|
+
const id = query.get(PARAMS.attachmentId);
|
|
3173
|
+
const kind = query.get(PARAMS.attachmentKind);
|
|
3174
|
+
if (!isAttachmentId(id)) throw routeError("bad-request", 400, `附件 id 需为 sha256:<64 位小写 hex>:${String(id)}`);
|
|
3175
|
+
if (kind !== "image" && kind !== "file") throw routeError("bad-request", 400, `附件 kind 只认 image / file:${String(kind)}`);
|
|
3176
|
+
const path = attachmentPathOf(deps.home, kind, id);
|
|
3177
|
+
let size;
|
|
3178
|
+
try {
|
|
3179
|
+
size = (await deps.fs.statFile(path)).size;
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
throw verbError("io", `附件不在:${id}(${String(error)})`, error);
|
|
3182
|
+
}
|
|
3183
|
+
if (size > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${size} 字节)`);
|
|
3184
|
+
const bytes = await deps.fs.readFile(path);
|
|
3185
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${bytes.length} 字节)`);
|
|
3186
|
+
if (kind === "file") return {
|
|
3187
|
+
body: bytes,
|
|
3188
|
+
contentType: "application/octet-stream",
|
|
3189
|
+
disposition: "attachment",
|
|
3190
|
+
filename: safeFilename(query.get(PARAMS.attachmentName), id.slice(7, 19))
|
|
3191
|
+
};
|
|
3192
|
+
const contentType = sniffImage(bytes);
|
|
3193
|
+
if (contentType === void 0) return {
|
|
3194
|
+
body: bytes,
|
|
3195
|
+
contentType: "application/octet-stream",
|
|
3196
|
+
disposition: "attachment",
|
|
3197
|
+
filename: safeFilename(query.get(PARAMS.attachmentName), `${id.slice(7, 19)}.bin`)
|
|
3198
|
+
};
|
|
3199
|
+
return {
|
|
3200
|
+
body: bytes,
|
|
3201
|
+
contentType,
|
|
3202
|
+
disposition: "inline"
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
//#endregion
|
|
3206
|
+
//#region src/api/attachment-orphans.ts
|
|
3207
|
+
/**
|
|
3208
|
+
* 孤儿附件的报告段:`GET /attachment-orphans`。
|
|
3209
|
+
*
|
|
3210
|
+
* 报告把扫描计划折成数量/字节 + 对象清单(`id`/`bytes`,供设置页预览缩略图;最多
|
|
3211
|
+
* `ORPHAN_PREVIEW_LIMIT` 条,清理不受预览条数限制)。扫描跳过(引用源或对象列表读不出)时
|
|
3212
|
+
* **报告必须失败**——回一份 0 会让界面说"没有孤儿",而这类失败恰恰是"不知道",不是"没有"。
|
|
3213
|
+
* 清理是 `POST /attachment-clean`,由路由直接接到 `cleanOrphanImageAttachments`。
|
|
3214
|
+
*/
|
|
3215
|
+
/** 预览清单的上限:与详情附件的载荷闸同一量级。数量与字节仍按全量报告。 */
|
|
3216
|
+
const ORPHAN_PREVIEW_LIMIT = 200;
|
|
3217
|
+
async function reportAttachmentOrphans(deps) {
|
|
3218
|
+
const plan = await scanOrphanImageAttachments(deps);
|
|
3219
|
+
if (plan.skipped !== void 0) throw verbError("io", plan.skipped);
|
|
3220
|
+
return {
|
|
3221
|
+
count: plan.entries.length,
|
|
3222
|
+
totalBytes: plan.bytes,
|
|
3223
|
+
items: plan.entries.slice(0, ORPHAN_PREVIEW_LIMIT).map((entry) => ({
|
|
3224
|
+
attachmentId: entry.id,
|
|
3225
|
+
bytes: entry.bytes
|
|
3226
|
+
}))
|
|
3227
|
+
};
|
|
3228
|
+
}
|
|
3229
|
+
//#endregion
|
|
2280
3230
|
//#region src/api/trash-rows.ts
|
|
2281
3231
|
/**
|
|
2282
|
-
* 清册那一行 →
|
|
2283
|
-
*
|
|
3232
|
+
* 清册那一行 → 线上那一行。清册字段走 `entryWire` 那份显式字面量(线上形状多一个必填字段时编译不过,
|
|
3233
|
+
* `...row` 会把它带过去),这里只补 `/list` 的只读数。
|
|
2284
3234
|
*/
|
|
2285
3235
|
function toWire(row, readouts) {
|
|
2286
3236
|
return {
|
|
2287
|
-
|
|
2288
|
-
sessionId: row.sessionId,
|
|
2289
|
-
projectDir: row.projectDir,
|
|
2290
|
-
workspaceId: row.workspaceId,
|
|
2291
|
-
wasArchived: row.wasArchived,
|
|
2292
|
-
deletedAt: row.deletedAt,
|
|
2293
|
-
title: row.title,
|
|
2294
|
-
sizeBytes: row.sizeBytes,
|
|
3237
|
+
...entryWire(row),
|
|
2295
3238
|
releaseBytes: readouts.releaseBytes,
|
|
2296
|
-
...row.parentEntryId === void 0 ? {} : { parentEntryId: row.parentEntryId },
|
|
2297
3239
|
...readouts.subagentCount === void 0 ? {} : { subagentCount: readouts.subagentCount },
|
|
2298
3240
|
...readouts.subagentBytes === void 0 ? {} : { subagentBytes: readouts.subagentBytes },
|
|
2299
3241
|
...readouts.strandedSubagents === void 0 ? {} : { strandedSubagents: readouts.strandedSubagents },
|
|
@@ -2330,18 +3272,17 @@ async function assembleTrashRows(deps, rows) {
|
|
|
2330
3272
|
//#endregion
|
|
2331
3273
|
//#region src/api/route-table.ts
|
|
2332
3274
|
/**
|
|
2333
|
-
*
|
|
3275
|
+
* 路由表(ADR-0004;deps 切片见 ADR-0032):段名 → { 方法, 纯度, 读面, body, deps 切片, 处理 }。一条记录,两个消费者。
|
|
2334
3276
|
*
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
* -
|
|
2339
|
-
* -
|
|
2340
|
-
* - body 该读没读 ⇒ 下游拿到 `null`;读了两遍 ⇒ 请求流已被消费(Node 的流只能消费一次)。
|
|
3277
|
+
* 一致性要求:
|
|
3278
|
+
* - 该纯的判成写 ⇒ 探测/正文读/扫描被堵在串行链后;
|
|
3279
|
+
* - 该写的判成纯 ⇒ 写动作绕开到达序链,与对账交错;
|
|
3280
|
+
* - body 该读没读 ⇒ 下游拿到 null;读两遍 ⇒ 流已被消费;
|
|
3281
|
+
* - 切片声明宽了 ⇒ 危险能力(删目录 / 时钟)漏进读段的 run;窄了 ⇒ 写段的处理编译不过。
|
|
2341
3282
|
*
|
|
2342
|
-
*
|
|
2343
|
-
*
|
|
2344
|
-
*
|
|
3283
|
+
* 这张表不放 shared/wire.ts:处理函数会 import 动词与 node:fs,而浏览器半要 import wire.ts。
|
|
3284
|
+
* 段名、方法与动作仍住 wire.ts(`ROUTES` 每项 `{ path, method }`,本表的 method 只投影、不写第二份字面量);
|
|
3285
|
+
* 这张表只回答“怎么处理”。
|
|
2345
3286
|
*/
|
|
2346
3287
|
/** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
|
|
2347
3288
|
function requireString(body, field) {
|
|
@@ -2349,19 +3290,42 @@ function requireString(body, field) {
|
|
|
2349
3290
|
if (typeof value !== "string" || value === "") throw routeError("bad-request", 400, `请求体需带非空字符串字段 ${field}`);
|
|
2350
3291
|
return value;
|
|
2351
3292
|
}
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
}
|
|
2359
|
-
/**
|
|
2360
|
-
|
|
3293
|
+
/** `/detail` 的分页游标:缺省 = 从尾部取;给了就必须是非负整数(负数、小数、字符串都算请求不对)。 */
|
|
3294
|
+
function optionalCursor(body) {
|
|
3295
|
+
const value = body?.[PARAMS.before];
|
|
3296
|
+
if (value === void 0) return void 0;
|
|
3297
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw routeError("bad-request", 400, `请求体字段 ${PARAMS.before} 需是非负整数`);
|
|
3298
|
+
return value;
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* `/apply` 的五个动作与各自的**静态纯度**。一张表三个用途:动作名单由键派生,纯度从同一格读,
|
|
3302
|
+
* 唯一识别器(`actionOf`)也只认这些键——新增动作必须同时给出纯度,漏了编译不过;
|
|
3303
|
+
* 新加一个纯读动作也不会再静默落进 `chained`。
|
|
3304
|
+
*/
|
|
3305
|
+
const APPLY_ACTION_TABLE = {
|
|
3306
|
+
delete: "chained",
|
|
3307
|
+
restore: "chained",
|
|
3308
|
+
purge: "chained",
|
|
3309
|
+
count: "pure",
|
|
3310
|
+
"orphan-clean": "chained"
|
|
3311
|
+
};
|
|
3312
|
+
const APPLY_ACTIONS = Object.keys(APPLY_ACTION_TABLE);
|
|
3313
|
+
/**
|
|
3314
|
+
* `/apply` 的动作:**全仓唯一识别器**——纯度函数与动作拒否都从这里过,
|
|
3315
|
+
* 不各解析一遍动作。不抛:缺动作 / 不是字符串 / 不在名单,一律回 `undefined`;
|
|
3316
|
+
* 抛不抛、抛哪句话归 `requireAction`。
|
|
3317
|
+
*/
|
|
3318
|
+
function actionOf(body) {
|
|
2361
3319
|
const value = body?.[PARAMS.action];
|
|
2362
|
-
if (typeof value !== "string" || !
|
|
3320
|
+
if (typeof value !== "string" || !Object.hasOwn(APPLY_ACTION_TABLE, value)) return void 0;
|
|
2363
3321
|
return value;
|
|
2364
3322
|
}
|
|
3323
|
+
/** `/apply` 的动作:认不得的一律点名拒掉,不折成"空批量"糊弄过去。 */
|
|
3324
|
+
function requireAction(body) {
|
|
3325
|
+
const action = actionOf(body);
|
|
3326
|
+
if (action === void 0) throw routeError("bad-request", 400, `批量动作不认识:需是 ${APPLY_ACTIONS.join(" / ")} 之一`);
|
|
3327
|
+
return action;
|
|
3328
|
+
}
|
|
2365
3329
|
/** `/apply` 的 id 组:必须是非空字符串数组(空数组不是"什么都不做",是调用方自己没组好)。 */
|
|
2366
3330
|
function requireIdArray(body) {
|
|
2367
3331
|
const value = body?.[PARAMS.ids];
|
|
@@ -2369,47 +3333,99 @@ function requireIdArray(body) {
|
|
|
2369
3333
|
return value;
|
|
2370
3334
|
}
|
|
2371
3335
|
/**
|
|
2372
|
-
*
|
|
3336
|
+
* 九个段名各一行。`Record<段名, AnyRouteSpec>` 是闸:`wire.ts` 加了段名而这里没加,编译不过;
|
|
3337
|
+
* 每行的 `slice` 又把该行的 `run` 钉在自己那一格 deps 上(ADR-0032)。
|
|
2373
3338
|
*/
|
|
2374
3339
|
const ROUTE_TABLE = {
|
|
2375
|
-
[ROUTES.list]: {
|
|
2376
|
-
|
|
3340
|
+
[ROUTES.list.path]: {
|
|
3341
|
+
slice: "core",
|
|
3342
|
+
method: ROUTES.list.method,
|
|
2377
3343
|
body: "none",
|
|
2378
3344
|
purity: "chained",
|
|
2379
|
-
|
|
3345
|
+
read: "list",
|
|
3346
|
+
response: "json",
|
|
3347
|
+
run: async (deps, _body, read) => assembleTrashRows(deps, await read.rows())
|
|
2380
3348
|
},
|
|
2381
|
-
[ROUTES.ping]: {
|
|
2382
|
-
|
|
3349
|
+
[ROUTES.ping.path]: {
|
|
3350
|
+
slice: "core",
|
|
3351
|
+
method: ROUTES.ping.method,
|
|
2383
3352
|
body: "none",
|
|
2384
3353
|
purity: "pure",
|
|
3354
|
+
read: "none",
|
|
3355
|
+
response: "json",
|
|
2385
3356
|
run: async () => "pong"
|
|
2386
3357
|
},
|
|
2387
|
-
[ROUTES.
|
|
2388
|
-
|
|
3358
|
+
[ROUTES.attachment.path]: {
|
|
3359
|
+
slice: "core",
|
|
3360
|
+
method: ROUTES.attachment.method,
|
|
3361
|
+
body: "none",
|
|
3362
|
+
purity: "pure",
|
|
3363
|
+
read: "none",
|
|
3364
|
+
response: "binary",
|
|
3365
|
+
run: async (deps, _body, _read, query) => readAttachmentResponse(deps, query)
|
|
3366
|
+
},
|
|
3367
|
+
[ROUTES.attachmentOrphans.path]: {
|
|
3368
|
+
slice: "core",
|
|
3369
|
+
method: ROUTES.attachmentOrphans.method,
|
|
3370
|
+
body: "none",
|
|
3371
|
+
purity: "pure",
|
|
3372
|
+
read: "none",
|
|
3373
|
+
response: "json",
|
|
3374
|
+
run: async (deps) => reportAttachmentOrphans(deps)
|
|
3375
|
+
},
|
|
3376
|
+
[ROUTES.attachmentClean.path]: {
|
|
3377
|
+
slice: "removal",
|
|
3378
|
+
method: ROUTES.attachmentClean.method,
|
|
3379
|
+
body: "none",
|
|
3380
|
+
purity: "chained",
|
|
3381
|
+
read: "none",
|
|
3382
|
+
response: "json",
|
|
3383
|
+
run: async (deps) => cleanOrphanImageAttachments(deps)
|
|
3384
|
+
},
|
|
3385
|
+
[ROUTES.orphans.path]: {
|
|
3386
|
+
slice: "core",
|
|
3387
|
+
method: ROUTES.orphans.method,
|
|
2389
3388
|
body: "none",
|
|
2390
3389
|
purity: "pure",
|
|
2391
|
-
|
|
3390
|
+
read: "raw",
|
|
3391
|
+
response: "json",
|
|
3392
|
+
run: async (deps, _body, read) => reportOrphans(await loadOrphanGroups(deps, await read.rows()))
|
|
2392
3393
|
},
|
|
2393
|
-
[ROUTES.apply]: {
|
|
2394
|
-
|
|
3394
|
+
[ROUTES.apply.path]: {
|
|
3395
|
+
slice: "full",
|
|
3396
|
+
method: ROUTES.apply.method,
|
|
2395
3397
|
body: "json",
|
|
2396
|
-
purity: (body) =>
|
|
3398
|
+
purity: (body) => {
|
|
3399
|
+
const action = actionOf(body);
|
|
3400
|
+
return action === void 0 ? "chained" : APPLY_ACTION_TABLE[action];
|
|
3401
|
+
},
|
|
3402
|
+
read: "none",
|
|
3403
|
+
response: "json",
|
|
2397
3404
|
run: async (deps, body) => applyActions(deps, requireAction(body), requireIdArray(body))
|
|
2398
3405
|
},
|
|
2399
|
-
[ROUTES.empty]: {
|
|
2400
|
-
|
|
3406
|
+
[ROUTES.empty.path]: {
|
|
3407
|
+
slice: "removal",
|
|
3408
|
+
method: ROUTES.empty.method,
|
|
2401
3409
|
body: "none",
|
|
2402
3410
|
purity: "chained",
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
3411
|
+
read: "list",
|
|
3412
|
+
response: "json",
|
|
3413
|
+
run: async (deps, _body, read) => {
|
|
3414
|
+
return await createTrashScope(deps, await read.rows()).purgeAll();
|
|
2406
3415
|
}
|
|
2407
3416
|
},
|
|
2408
|
-
[ROUTES.detail]: {
|
|
2409
|
-
|
|
3417
|
+
[ROUTES.detail.path]: {
|
|
3418
|
+
slice: "core",
|
|
3419
|
+
method: ROUTES.detail.method,
|
|
2410
3420
|
body: "json",
|
|
2411
3421
|
purity: "pure",
|
|
2412
|
-
|
|
3422
|
+
read: "raw",
|
|
3423
|
+
response: "json",
|
|
3424
|
+
run: async (deps, body, read) => readTrashDetail({
|
|
3425
|
+
fs: deps.fs,
|
|
3426
|
+
rows: () => read.rows(),
|
|
3427
|
+
home: deps.home
|
|
3428
|
+
})(requireString(body, PARAMS.entryId), optionalCursor(body))
|
|
2413
3429
|
}
|
|
2414
3430
|
};
|
|
2415
3431
|
/** 段名 → 那一行;不认识的段名回 `undefined`(调用方抛 404,并带出原路径)。 */
|
|
@@ -2420,39 +3436,6 @@ function routeOf(rest) {
|
|
|
2420
3436
|
//#region src/api/dispatch.ts
|
|
2421
3437
|
/** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
|
|
2422
3438
|
const JSON_BODY_MAX_BYTES = 1048576;
|
|
2423
|
-
/** 回环地址的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
|
|
2424
|
-
const LOOPBACK = [
|
|
2425
|
-
"127.0.0.1",
|
|
2426
|
-
"::1",
|
|
2427
|
-
"::ffff:127.0.0.1"
|
|
2428
|
-
];
|
|
2429
|
-
/**
|
|
2430
|
-
* 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
|
|
2431
|
-
* 页面里对**非静态路径**的请求(`/`、`/index.html`、`/assets/*`、`/plugins/…` 之外的一切——包括
|
|
2432
|
-
* 我们这条前缀路由)都由 `protocol.handle("dsh-app")` 转给宿主自己的 web server;转发前它把
|
|
2433
|
-
* `host` / `origin` / `cookie` / `sec-fetch-site` 四个头**删掉**、换上一枚宿主自己的 cookie,路径与
|
|
2434
|
-
* 查询串原样带走(逐字:`for (const name of ["host","origin","cookie","sec-fetch-site"]) headers.delete(name)`)。
|
|
2435
|
-
* ⇒ 桌面版打到我们这条路由上的请求是**没有 `Origin`** 的,`Referer` 则保留(同源请求照常带,
|
|
2436
|
-
* 页面就住在 `dsh-app://app/`)。壳自己在上游只放行 `origin ∈ {null, "dsh-app://app"}`。
|
|
2437
|
-
*/
|
|
2438
|
-
const DESKTOP_APP_SCHEME = "dsh-app:";
|
|
2439
|
-
const DESKTOP_APP_HOST = "app";
|
|
2440
|
-
/**
|
|
2441
|
-
* 「这是桌面壳自己的页面吗」。**必须按 `protocol` + `host` 判,不能比 `URL.origin`**:`dsh-app:`
|
|
2442
|
-
* 不是 WHATWG 的 special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`(Node 实测),
|
|
2443
|
-
* 拿它跟字面量比永远为假 ⇒ 栅栏会把真机上的每一次请求都挡成 403,所有入口一个都不出现。
|
|
2444
|
-
*
|
|
2445
|
-
* 这个判据为什么不可能被外人伪造:`Origin` 与 `Referer` 都是 forbidden header,网页改不了,
|
|
2446
|
-
* 只能是浏览器/壳自己带上;而 `dsh-app://app` 只可能是桌面壳自己的页面。
|
|
2447
|
-
*/
|
|
2448
|
-
function isDesktopShellUrl(value) {
|
|
2449
|
-
try {
|
|
2450
|
-
const url = new URL(value);
|
|
2451
|
-
return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
|
|
2452
|
-
} catch {
|
|
2453
|
-
return false;
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
3439
|
function writeJson(res, status, body) {
|
|
2457
3440
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
2458
3441
|
res.end(JSON.stringify(body));
|
|
@@ -2463,8 +3446,30 @@ function writeOk(res, value) {
|
|
|
2463
3446
|
value
|
|
2464
3447
|
});
|
|
2465
3448
|
}
|
|
3449
|
+
/**
|
|
3450
|
+
* 二进制响应(`/attachment` 专用)。四道头都是判据不是口味:
|
|
3451
|
+
* - `content-type` 由处理函数给(图片按魔数认,文件一律 octet-stream);
|
|
3452
|
+
* - `content-length` 与字节数同源;
|
|
3453
|
+
* - `cache-control: private, no-store`:附件可能来自已删除会话,不许被任何中间层留下;
|
|
3454
|
+
* - `nosniff` + `attachment` 下载名:字节内容与类型不一致时浏览器不许改猜。
|
|
3455
|
+
*
|
|
3456
|
+
* **失败不走这里**:处理函数抛出的错误仍由 `writeFailure` 写 JSON 信封(图片的 `onError` 会换成
|
|
3457
|
+
* 占位;普通文件走 `<a download>`,没有页面内失败面)。
|
|
3458
|
+
*/
|
|
3459
|
+
function writeBinary(res, body) {
|
|
3460
|
+
const headers = {
|
|
3461
|
+
"content-type": body.contentType,
|
|
3462
|
+
"content-length": String(body.body.length),
|
|
3463
|
+
"cache-control": "private, no-store",
|
|
3464
|
+
"x-content-type-options": "nosniff"
|
|
3465
|
+
};
|
|
3466
|
+
if (body.filename !== void 0) headers["content-disposition"] = `${body.disposition}; filename*=UTF-8''${encodeURIComponent(body.filename)}`;
|
|
3467
|
+
else if (body.disposition === "attachment") headers["content-disposition"] = "attachment";
|
|
3468
|
+
res.writeHead(200, headers);
|
|
3469
|
+
res.end(body.body);
|
|
3470
|
+
}
|
|
2466
3471
|
/** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
|
|
2467
|
-
*
|
|
3472
|
+
* **状态语义**:**请求本身不对**不再伪装成 200——请求体不对 400、方法不对 405、
|
|
2468
3473
|
* 未知路由 404、栅栏拒 403(它们不是"一次办成的操作",是"这条请求不归我办/你没资格办")。
|
|
2469
3474
|
* **操作失败**仍回 200 + 信封。浏览器半先解信封再看状态,所以 4xx 上带的信封照样读得出原文;
|
|
2470
3475
|
* `code` 一律折成 `sessiondelete/io`,那三个路由标签(`bad-request` / `bad-method` / `not-found-route`)
|
|
@@ -2476,38 +3481,10 @@ function writeFailure(res, error, status) {
|
|
|
2476
3481
|
error: toRemoteFailure(error)
|
|
2477
3482
|
});
|
|
2478
3483
|
}
|
|
2479
|
-
/**
|
|
2480
|
-
*
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
* 放行的三种形状,各自都是"浏览器/壳自动带上、网页改不了"的那种:
|
|
2484
|
-
* ① `Origin` 与 `Host` 同源(浏览器直接打开宿主那行 URL);② `Origin` 是桌面壳的 `dsh-app://app`
|
|
2485
|
-
* (壳自己在上游也只放行这个 origin);③ `Referer` 是桌面壳的 `dsh-app://app`(**真机那条路**:
|
|
2486
|
-
* 壳把 `Origin` 删掉了、只留下 Referer,见 {@link isDesktopShellUrl})。两者都缺席 = 本机工具 /
|
|
2487
|
-
* curl(注释即此承诺)。**恶意网页拿不到 ②③ 里的任何一个**:那两个头都是 forbidden header、
|
|
2488
|
-
* 页面伪造不了;而**写口(POST)在任何跨源形态下都必带 Origin** ⇒ 两个头全缺席就等于本机工具。
|
|
2489
|
-
* 唯一的缝是跨源 `no-cors` 的 **GET**(Referer 被 `no-referrer` 掐掉、Origin 也不带)——它够得到
|
|
2490
|
-
* 那几个 GET(`list` 会先对账写清册,`ping` / `orphans` 才是纯读),而 no-cors 的回包是 opaque、
|
|
2491
|
-
* 页面读不出内容 ⇒ 不构成泄露(`orphans` 那份报告也只报数量/字节,不含正文)。
|
|
2492
|
-
*/
|
|
2493
|
-
function isTrustedRequest(req) {
|
|
2494
|
-
if (!LOOPBACK.includes(req.socket.remoteAddress ?? "")) return false;
|
|
2495
|
-
const host = req.headers.host;
|
|
2496
|
-
const origin = req.headers.origin;
|
|
2497
|
-
if (origin !== void 0 && !isDesktopShellUrl(origin)) try {
|
|
2498
|
-
if (new URL(origin).host !== host) return false;
|
|
2499
|
-
} catch {
|
|
2500
|
-
return false;
|
|
2501
|
-
}
|
|
2502
|
-
const referer = req.headers.referer;
|
|
2503
|
-
if (referer === void 0) return true;
|
|
2504
|
-
if (isDesktopShellUrl(referer)) return true;
|
|
2505
|
-
try {
|
|
2506
|
-
return new URL(referer).host === host;
|
|
2507
|
-
} catch {
|
|
2508
|
-
return false;
|
|
2509
|
-
}
|
|
2510
|
-
}
|
|
3484
|
+
/** 栅栏拒绝的两种原文:归属不对(跨源 / 重绑 / 头不成形)与远程客户端没带宿主身份。
|
|
3485
|
+
* 两者都是 403,但客户端会上屏,原文要能告诉用户下一步做什么。 */
|
|
3486
|
+
const UNTRUSTED_MESSAGE = "非受信来源";
|
|
3487
|
+
const UNAUTHENTICATED_MESSAGE = "需要宿主的 Web 身份:请用 dsh web 打印的那一行 URL 打开页面后重试";
|
|
2511
3488
|
/** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 400 + 失败信封)。
|
|
2512
3489
|
* **请求本身不对**(400)与**那次操作读不到东西**(200 + 信封)是两件事:前者是调用方没组好请求,
|
|
2513
3490
|
* 后者是盘上没有;把前者也写成 200,代理与日志里就再也分不出这两类。 */
|
|
@@ -2540,9 +3517,58 @@ function isPure(purity, body) {
|
|
|
2540
3517
|
function readBodyFor(kind, req) {
|
|
2541
3518
|
return kind === "json" ? readJsonBody(req) : null;
|
|
2542
3519
|
}
|
|
2543
|
-
|
|
3520
|
+
/**
|
|
3521
|
+
* 全量面按行收窄(ADR-0032):组合根装出的 {@link DeleteDeps} 只交给 `/apply`;每一段只拿到
|
|
3522
|
+
* 自己声明的那一格(路由表的 `slice`)。收窄是**运行期事实**,不是类型投影:`core` 拿到的是
|
|
3523
|
+
* 新建的 `CoreFsPort` 面,`removeDir` / `removeFile` 根本不在对象上;`now` 也不在 `core` / `removal`
|
|
3524
|
+
* 上。三格逐行 `satisfies` 是闸:全量面必须结构满足每一格;`coreOf` 的 fs 字面量又逼着
|
|
3525
|
+
* `CoreFsPort` 新增方法时同步补进来——漏了编译不过,而不是等某个段的 `run` 静默拿到超面。
|
|
3526
|
+
*/
|
|
3527
|
+
function coreOf(full) {
|
|
3528
|
+
return {
|
|
3529
|
+
fs: {
|
|
3530
|
+
exists: (p) => full.fs.exists(p),
|
|
3531
|
+
ensureDir: (p) => full.fs.ensureDir(p),
|
|
3532
|
+
rename: (from, to) => full.fs.rename(from, to),
|
|
3533
|
+
sizeOfDir: (p) => full.fs.sizeOfDir(p),
|
|
3534
|
+
listFiles: (p) => full.fs.listFiles(p),
|
|
3535
|
+
listDirs: (p) => full.fs.listDirs(p),
|
|
3536
|
+
realpath: (p) => full.fs.realpath(p),
|
|
3537
|
+
readFile: (p) => full.fs.readFile(p),
|
|
3538
|
+
statFile: (p) => full.fs.statFile(p)
|
|
3539
|
+
},
|
|
3540
|
+
host: full.host,
|
|
3541
|
+
ledger: full.ledger,
|
|
3542
|
+
home: full.home,
|
|
3543
|
+
presence: full.presence
|
|
3544
|
+
};
|
|
3545
|
+
}
|
|
3546
|
+
/** `removal` 保留删除能力,但不带会话删除的时钟;`full` 只给 `/apply`。 */
|
|
3547
|
+
function removalOf(full) {
|
|
3548
|
+
return {
|
|
3549
|
+
fs: full.fs,
|
|
3550
|
+
host: full.host,
|
|
3551
|
+
ledger: full.ledger,
|
|
3552
|
+
home: full.home,
|
|
3553
|
+
presence: full.presence
|
|
3554
|
+
};
|
|
3555
|
+
}
|
|
3556
|
+
/** 三格只建一次:收窄对象在 handler 构造期固定,请求路径只做引用选择。 */
|
|
3557
|
+
function slicesOf(full) {
|
|
3558
|
+
return {
|
|
3559
|
+
core: coreOf(full),
|
|
3560
|
+
removal: removalOf(full),
|
|
3561
|
+
full
|
|
3562
|
+
};
|
|
3563
|
+
}
|
|
3564
|
+
/** 按行选格:`spec.slice` 与三格的键同域,查表结果的类型由 `S` 直接给出。 */
|
|
3565
|
+
function depsFor(spec, slices) {
|
|
3566
|
+
return slices[spec.slice];
|
|
3567
|
+
}
|
|
3568
|
+
function createApiHandler(deps, trust) {
|
|
3569
|
+
const slices = slicesOf(deps);
|
|
2544
3570
|
/**
|
|
2545
|
-
*
|
|
3571
|
+
* 到达序串行链(见文件头):路由表里 `purity === 'chained'` 的请求一个跑完再放下一个。
|
|
2546
3572
|
* `tail` 由构造保证**永不 reject**(每个任务自己的失败留在交回给那次请求的那个 promise 上),
|
|
2547
3573
|
* 所以一次失败不会卡死链。
|
|
2548
3574
|
*/
|
|
@@ -2553,8 +3579,9 @@ function createApiHandler(deps) {
|
|
|
2553
3579
|
return started;
|
|
2554
3580
|
};
|
|
2555
3581
|
return async function handle(req, res) {
|
|
2556
|
-
|
|
2557
|
-
|
|
3582
|
+
const verdict = judgeRequest(req, trust);
|
|
3583
|
+
if (verdict !== "allowed") {
|
|
3584
|
+
writeFailure(res, /* @__PURE__ */ new Error(verdict === "untrusted" ? UNTRUSTED_MESSAGE : UNAUTHENTICATED_MESSAGE), 403);
|
|
2558
3585
|
return;
|
|
2559
3586
|
}
|
|
2560
3587
|
try {
|
|
@@ -2565,14 +3592,40 @@ function createApiHandler(deps) {
|
|
|
2565
3592
|
if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${req.method ?? "GET"} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
|
|
2566
3593
|
requireMethod(req.method ?? "GET", spec.method, rest);
|
|
2567
3594
|
const body = await readBodyFor(spec.body, req);
|
|
2568
|
-
const
|
|
2569
|
-
|
|
3595
|
+
const kind = spec.read;
|
|
3596
|
+
let rowsPromise;
|
|
3597
|
+
const read = { rows() {
|
|
3598
|
+
if (kind === "none") return Promise.reject(/* @__PURE__ */ new Error("route read: none"));
|
|
3599
|
+
rowsPromise ??= kind === "list" ? deps.ledger.list() : deps.ledger.raw();
|
|
3600
|
+
return rowsPromise;
|
|
3601
|
+
} };
|
|
3602
|
+
const task = () => spec.run(depsFor(spec, slices), body, read, url.searchParams);
|
|
3603
|
+
const result = await (isPure(spec.purity, body) ? task() : throughChain(task));
|
|
3604
|
+
if (spec.response === "binary") writeBinary(res, result);
|
|
3605
|
+
else writeOk(res, result);
|
|
2570
3606
|
} catch (error) {
|
|
2571
3607
|
writeFailure(res, error);
|
|
2572
3608
|
}
|
|
2573
3609
|
};
|
|
2574
3610
|
}
|
|
2575
3611
|
//#endregion
|
|
3612
|
+
//#region src/ports/connection-port.ts
|
|
3613
|
+
const HOST_UNAUTHENTICATED = 401;
|
|
3614
|
+
/** 成员抛错按「归属不对」拒绝(fail-closed)。 */
|
|
3615
|
+
function createConnectionPort(ctx) {
|
|
3616
|
+
return { verdictFor: (req) => {
|
|
3617
|
+
const service = ctx.get("connection");
|
|
3618
|
+
if (service === void 0 || typeof service.requestRejection !== "function") return { kind: "absent" };
|
|
3619
|
+
try {
|
|
3620
|
+
const status = service.requestRejection(req);
|
|
3621
|
+
if (status === void 0) return { kind: "allowed" };
|
|
3622
|
+
return { kind: status === HOST_UNAUTHENTICATED ? "unauthenticated" : "untrusted" };
|
|
3623
|
+
} catch {
|
|
3624
|
+
return { kind: "untrusted" };
|
|
3625
|
+
}
|
|
3626
|
+
} };
|
|
3627
|
+
}
|
|
3628
|
+
//#endregion
|
|
2576
3629
|
//#region src/ports/fs-port.ts
|
|
2577
3630
|
/** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
|
|
2578
3631
|
* `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
|
|
@@ -2593,7 +3646,13 @@ function createFsPort() {
|
|
|
2593
3646
|
await mkdir(p, { recursive: true });
|
|
2594
3647
|
},
|
|
2595
3648
|
async rename(from, to) {
|
|
2596
|
-
|
|
3649
|
+
if (existsSync(to)) throw new Error(`目标已存在,拒绝覆盖:${to}`);
|
|
3650
|
+
try {
|
|
3651
|
+
await rename(from, to);
|
|
3652
|
+
} catch (error) {
|
|
3653
|
+
if (error.code === "EXDEV") throw new Error(`跨文件系统搬不动(sessions 与 session-trash 需在同一卷):${from} → ${to}`, { cause: error });
|
|
3654
|
+
throw error;
|
|
3655
|
+
}
|
|
2597
3656
|
},
|
|
2598
3657
|
async removeDir(p) {
|
|
2599
3658
|
await rm(p, {
|
|
@@ -2606,10 +3665,30 @@ function createFsPort() {
|
|
|
2606
3665
|
return readdir(p);
|
|
2607
3666
|
},
|
|
2608
3667
|
async listDirs(p) {
|
|
2609
|
-
|
|
3668
|
+
const dirs = [];
|
|
3669
|
+
for (const entry of await readdir(p, { withFileTypes: true })) {
|
|
3670
|
+
if (entry.isDirectory()) {
|
|
3671
|
+
dirs.push(entry.name);
|
|
3672
|
+
continue;
|
|
3673
|
+
}
|
|
3674
|
+
if (!entry.isSymbolicLink()) continue;
|
|
3675
|
+
try {
|
|
3676
|
+
if ((await stat(join(p, entry.name))).isDirectory()) dirs.push(entry.name);
|
|
3677
|
+
} catch {}
|
|
3678
|
+
}
|
|
3679
|
+
return dirs;
|
|
3680
|
+
},
|
|
3681
|
+
async realpath(p) {
|
|
3682
|
+
return realpath(p);
|
|
2610
3683
|
},
|
|
2611
3684
|
async readFile(p) {
|
|
2612
3685
|
return readFile(p);
|
|
3686
|
+
},
|
|
3687
|
+
async statFile(p) {
|
|
3688
|
+
return { size: (await stat(p)).size };
|
|
3689
|
+
},
|
|
3690
|
+
async removeFile(p) {
|
|
3691
|
+
await rm(p, { force: true });
|
|
2613
3692
|
}
|
|
2614
3693
|
};
|
|
2615
3694
|
}
|
|
@@ -2776,7 +3855,7 @@ function createStorageManifest(domain) {
|
|
|
2776
3855
|
}
|
|
2777
3856
|
//#endregion
|
|
2778
3857
|
//#region src/trash/storage-manifest.ts
|
|
2779
|
-
/** 本插件自己的域(CONTEXT.md
|
|
3858
|
+
/** 本插件自己的域(CONTEXT.md 的「清册」条:介质落在数据根下的 `storages/`)。
|
|
2780
3859
|
*
|
|
2781
3860
|
* **名字必须带插件专属前缀**:domain 名是 `storageDomain` facility 里的**全局键**,而**一个名字在
|
|
2782
3861
|
* 一个 facility 里只能 `open()` 一次**(句柄归调用方所有)——别人先占了同名域,本插件会**在激活时**
|
|
@@ -2799,8 +3878,10 @@ function domainTable(schema) {
|
|
|
2799
3878
|
function defineDomain(spec) {
|
|
2800
3879
|
if (!UNIT_NAME_RE.test(spec.name)) throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`);
|
|
2801
3880
|
if (!Number.isInteger(spec.version) || spec.version < 0) throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`);
|
|
2802
|
-
|
|
2803
|
-
if (
|
|
3881
|
+
const layout = spec.layout;
|
|
3882
|
+
if (layout !== void 0 && layout !== "single" && layout !== "per-record") throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${layout}`);
|
|
3883
|
+
const invalidRecords = spec.invalidRecords;
|
|
3884
|
+
if (invalidRecords !== void 0 && invalidRecords !== "backup-and-skip") throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present`);
|
|
2804
3885
|
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}`);
|
|
2805
3886
|
return spec;
|
|
2806
3887
|
}
|
|
@@ -2826,7 +3907,7 @@ const trashDomainSpec = defineDomain({
|
|
|
2826
3907
|
* 清单的真实绑定:官方 storage domain → `ManifestPort`(`StorageDomainLike` 在动词层保持一种读法)。
|
|
2827
3908
|
* 域**懒开**且成功句柄只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
|
|
2828
3909
|
* 插件卸载时关掉句柄并把 `close()` 的落地交回框架(句柄归调用方所有,这是 `open()` 的 JSDoc 明写的契约;
|
|
2829
|
-
*
|
|
3910
|
+
* 不能 `void` 走掉——没关严的句柄会让下一份同名域撞 `already-open`)。
|
|
2830
3911
|
*
|
|
2831
3912
|
* **失败不缓存**:`open()` 被拒是瞬态(域被别的持有者占着、装载竞态),把第一次的 rejected promise
|
|
2832
3913
|
* 缓存起来等于"这个会话永远删不掉"——下一次读会重新走 `open()`,成功才落下活句柄。
|
|
@@ -2877,10 +3958,14 @@ const name = "@xrn1997/dsh-session-delete";
|
|
|
2877
3958
|
* 它同样可能晚于本插件激活,写进插件 inject 只会让整个插件 pending。
|
|
2878
3959
|
*
|
|
2879
3960
|
* `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
|
|
2880
|
-
* 前缀路由,浏览器半同源 fetch
|
|
3961
|
+
* 前缀路由,浏览器半同源 fetch 那九个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
|
|
2881
3962
|
* `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
|
|
2882
3963
|
* `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
|
|
2883
3964
|
*
|
|
3965
|
+
* `connection` 只喂请求栅栏的远程支(`src/api/request-trust.ts` ②),按 `ctx.get` 可选读:
|
|
3966
|
+
* 服务缺席时插件照常激活,远程客户端 fail-closed。它不进 `inject`——写进去会让没有 web 组成的
|
|
3967
|
+
* profile 整个停在 pending。
|
|
3968
|
+
*
|
|
2884
3969
|
* 清册的三层各有各的家:raw 绑定 `src/trash/storage-manifest.ts`、读面(对账 + 读)`src/trash/ledger.ts`、
|
|
2885
3970
|
* 数据根 `src/ports/home.ts`。这里只把它们接起来,任何一层都不在组合根里长判据。
|
|
2886
3971
|
*/
|
|
@@ -2890,7 +3975,7 @@ const inject = [
|
|
|
2890
3975
|
"webServer"
|
|
2891
3976
|
];
|
|
2892
3977
|
/**
|
|
2893
|
-
* 装配顺序:**清单 → 端口 → 动词依赖 → 前缀路由**(`DeleteDeps`
|
|
3978
|
+
* 装配顺序:**清单 → 端口 → 在场能力 → 动词依赖 → 前缀路由**(`DeleteDeps` 把这些依赖面合成一颗)。
|
|
2894
3979
|
*
|
|
2895
3980
|
* 路由用 `ctx.effect` 挂、disposer 归装载它的那根 fiber:插件卸载即摘路由(`register` 返回的
|
|
2896
3981
|
* disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(kind, path)`
|
|
@@ -2900,19 +3985,25 @@ function apply(ctx) {
|
|
|
2900
3985
|
const fs = createFsPort();
|
|
2901
3986
|
const home = resolveHome();
|
|
2902
3987
|
const rawManifest = createHostStorageManifest(ctx);
|
|
3988
|
+
const host = createHostPort(ctx);
|
|
2903
3989
|
const deps = {
|
|
2904
3990
|
fs,
|
|
2905
|
-
host
|
|
3991
|
+
host,
|
|
2906
3992
|
ledger: createTrashLedger(rawManifest, fs, home),
|
|
2907
3993
|
home,
|
|
3994
|
+
presence: createMemberPresence(host),
|
|
2908
3995
|
now: () => Date.now()
|
|
2909
3996
|
};
|
|
2910
3997
|
const webServer = ctx.webServer;
|
|
3998
|
+
const connection = createConnectionPort(ctx);
|
|
2911
3999
|
ctx.effect(() => {
|
|
2912
4000
|
const dispose = webServer.register({
|
|
2913
4001
|
kind: "prefix",
|
|
2914
4002
|
path: SESSION_DELETE_API_PREFIX,
|
|
2915
|
-
handler: createApiHandler(deps
|
|
4003
|
+
handler: createApiHandler(deps, {
|
|
4004
|
+
bindHost: webServer.host,
|
|
4005
|
+
connection
|
|
4006
|
+
})
|
|
2916
4007
|
});
|
|
2917
4008
|
return () => {
|
|
2918
4009
|
dispose();
|