@xrn1997/dsh-session-delete 0.3.0 → 0.4.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/README.md +21 -15
- package/lib/client.js +3317 -2173
- package/lib/index.js +1691 -964
- 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 +9 -29
- package/lib/types/api/request-trust.d.ts +45 -0
- package/lib/types/api/route-table.d.ts +28 -14
- package/lib/types/client/attachment-view.d.ts +27 -0
- package/lib/types/client/batch-bar.d.ts +3 -2
- package/lib/types/client/batch-confirm.d.ts +3 -3
- 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 -0
- package/lib/types/client/confirm-close.d.ts +11 -0
- package/lib/types/client/copy.d.ts +48 -38
- package/lib/types/client/delete-confirm.d.ts +0 -2
- package/lib/types/client/host-rows.d.ts +18 -3
- package/lib/types/client/main-view.d.ts +8 -3
- 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 -1
- package/lib/types/client/orphan-maintenance.d.ts +45 -0
- package/lib/types/client/receipt.d.ts +75 -5
- package/lib/types/client/remote.d.ts +49 -69
- package/lib/types/client/store.d.ts +31 -0
- package/lib/types/client/trash-confirm.d.ts +2 -15
- package/lib/types/client/trash-nav.d.ts +30 -0
- 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/icons.d.ts +6 -11
- 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 -52
- package/lib/types/client/use-column-resize.d.ts +9 -20
- package/lib/types/client/use-maintenance.d.ts +45 -0
- package/lib/types/client/use-panel-width.d.ts +1 -1
- package/lib/types/client/use-trash-panel-controller.d.ts +27 -23
- package/lib/types/client/use-trash-query.d.ts +39 -25
- package/lib/types/client/write-seats.d.ts +44 -0
- package/lib/types/client/write-settlement.d.ts +66 -0
- package/lib/types/client/write.d.ts +54 -0
- package/lib/types/deps.d.ts +20 -9
- package/lib/types/index.d.ts +8 -3
- package/lib/types/log/session-log.d.ts +4 -3
- package/lib/types/log/zstd-frames.d.ts +14 -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 +13 -1
- package/lib/types/ports/host-port.d.ts +21 -63
- package/lib/types/ports/presence-port.d.ts +22 -0
- package/lib/types/shared/wire.d.ts +77 -26
- package/lib/types/trash/attachments.d.ts +85 -0
- package/lib/types/trash/detail.d.ts +29 -6
- package/lib/types/trash/entry.d.ts +3 -3
- package/lib/types/trash/groups.d.ts +18 -14
- package/lib/types/trash/ledger.d.ts +20 -0
- package/lib/types/trash/manifest.d.ts +2 -1
- package/lib/types/trash/reconcile.d.ts +4 -3
- package/lib/types/trash/storage-manifest.d.ts +1 -1
- package/lib/types/verbs/batch-carried.d.ts +20 -0
- package/lib/types/verbs/delete-session.d.ts +6 -5
- package/lib/types/verbs/errors.d.ts +4 -10
- package/lib/types/verbs/live-admission.d.ts +10 -0
- package/lib/types/verbs/member-journal.d.ts +25 -7
- package/lib/types/verbs/member-presence.d.ts +33 -24
- package/lib/types/verbs/move-group.d.ts +20 -0
- package/lib/types/verbs/orphan-subagents.d.ts +11 -5
- package/lib/types/verbs/permanent-removal.d.ts +31 -24
- package/lib/types/verbs/purge-trash.d.ts +21 -8
- package/lib/types/verbs/restore-session.d.ts +12 -16
- package/package.json +4 -2
- package/lib/types/client/write-commit.d.ts +0 -10
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* ① tsdown 的浏览器半纯度门拦 Node 内建与平台模块表外的 `@deepseek-ai/*` 值 import——
|
|
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。
|
|
8
|
+
* 跨半 wire 契约:前缀、九个端点、请求/响应形状与失败码。
|
|
9
|
+
* Node 半(src/api/dispatch.ts)与浏览器半(src/client/remote.ts)共用这一份,不各写字面量。
|
|
10
|
+
*
|
|
11
|
+
* 构建约束:本文件零运行时依赖、无 import;浏览器半把整个文件 inline 进 bundle。
|
|
12
|
+
* 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
|
|
22
13
|
*/
|
|
23
14
|
/**
|
|
24
15
|
* 插件专属前缀。
|
|
@@ -28,15 +19,18 @@ import { homedir } from "node:os";
|
|
|
28
19
|
* 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
|
|
29
20
|
*/
|
|
30
21
|
const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
33
|
-
* - `GET /list
|
|
34
|
-
* - `GET /ping
|
|
35
|
-
* - `POST /detail`:单条详情(用 POST
|
|
22
|
+
/** 九个段名(相对前缀)。写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
|
|
23
|
+
* 单条就是长度为 1 的批量,仓里不留第二条执行路径。
|
|
24
|
+
* - `GET /list`:对账并读清单(`session-trash/` 里的目录是准)。
|
|
25
|
+
* - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
|
|
26
|
+
* - `POST /detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts`)。
|
|
36
27
|
* - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
|
|
37
|
-
* - `POST /empty
|
|
38
|
-
* - `GET /orphans
|
|
39
|
-
*
|
|
28
|
+
* - `POST /empty`:清空回收站(聚合回执,不按条回)。
|
|
29
|
+
* - `GET /orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
|
|
30
|
+
* 这一段只负责"有多少、在哪、多大"。
|
|
31
|
+
* - `GET /attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
|
|
32
|
+
* - `GET /attachment-orphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
|
|
33
|
+
* - `POST /attachment-clean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
|
|
40
34
|
const ROUTES = {
|
|
41
35
|
list: "/list",
|
|
42
36
|
/** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
|
|
@@ -48,13 +42,23 @@ const ROUTES = {
|
|
|
48
42
|
/** 清空回收站。 */
|
|
49
43
|
empty: "/empty",
|
|
50
44
|
/** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
|
|
51
|
-
orphans: "/orphans"
|
|
45
|
+
orphans: "/orphans",
|
|
46
|
+
/** 附件字节(图片/普通文件):全仓唯一的二进制响应段,读的是宿主附件库。 */
|
|
47
|
+
attachment: "/attachment",
|
|
48
|
+
/** 纯读:扫全部引用源与附件库 `objects`,报无引用图片的数量、字节与预览清单。 */
|
|
49
|
+
attachmentOrphans: "/attachment-orphans",
|
|
50
|
+
/** 聚合清理:只删 `objects` 下无引用的图片,回 `AttachmentCleanupWire`。 */
|
|
51
|
+
attachmentClean: "/attachment-clean"
|
|
52
52
|
};
|
|
53
|
-
/**
|
|
53
|
+
/** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
|
|
54
|
+
* 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
|
|
54
55
|
const PARAMS = {
|
|
55
56
|
entryId: "entryId",
|
|
56
57
|
action: "action",
|
|
57
|
-
ids: "ids"
|
|
58
|
+
ids: "ids",
|
|
59
|
+
attachmentId: "id",
|
|
60
|
+
attachmentKind: "kind",
|
|
61
|
+
attachmentName: "name"
|
|
58
62
|
};
|
|
59
63
|
/**
|
|
60
64
|
* 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
|
|
@@ -72,7 +76,7 @@ function isDSHRemoteError(value) {
|
|
|
72
76
|
/** 失败码的域。与宿主官方域(`workspace/*`、`session/*`…)不重名,见本文件。 */
|
|
73
77
|
const SESSION_DELETE_ERROR_DOMAIN = "sessiondelete";
|
|
74
78
|
/** 五个**动词码**(本文件那张表)。`io` 是唯一兜底。 */
|
|
75
|
-
const VERB_CODES
|
|
79
|
+
const VERB_CODES = [
|
|
76
80
|
"live",
|
|
77
81
|
"not-found",
|
|
78
82
|
"trash-empty",
|
|
@@ -89,13 +93,27 @@ const SESSION_DELETE_CODES = {
|
|
|
89
93
|
"not-found-route": "sessiondelete/not-found-route",
|
|
90
94
|
"bad-request": "sessiondelete/bad-request"
|
|
91
95
|
};
|
|
96
|
+
/** 失败码的前缀。读码只走下面两条,别在调用点各写一份字面量。 */
|
|
97
|
+
const CODE_PREFIX = `${SESSION_DELETE_ERROR_DOMAIN}/`;
|
|
98
|
+
/** `error.code` 是不是本仓的失败形状(`sessiondelete/<后缀>`)。**只认前缀**:后缀在不在册是
|
|
99
|
+
* {@link verbCodeOf} 的问题。任何要「已经是本仓的码就原样对待」的调用点都问这一句,不在调用点
|
|
100
|
+
* 再写一份前缀字面量。 */
|
|
101
|
+
function isSessionDeleteCodeError(error) {
|
|
102
|
+
const raw = error?.code;
|
|
103
|
+
return typeof raw === "string" && raw.startsWith(CODE_PREFIX);
|
|
104
|
+
}
|
|
105
|
+
/** `error.code` 折回那个动词词:前缀与后缀两件都成立才交回(后缀要在 {@link VERB_CODES} 册上)。
|
|
106
|
+
* 路由自检码、别域的码、非字符串 code 一律 `undefined`。 */
|
|
107
|
+
function verbCodeOf(error) {
|
|
108
|
+
const raw = error?.code;
|
|
109
|
+
if (typeof raw !== "string" || !raw.startsWith(CODE_PREFIX)) return void 0;
|
|
110
|
+
const suffix = raw.slice(CODE_PREFIX.length);
|
|
111
|
+
return VERB_CODES.includes(suffix) ? suffix : void 0;
|
|
112
|
+
}
|
|
92
113
|
/** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
|
|
93
114
|
const TRASH_DIR_NAME = "session-trash";
|
|
94
115
|
//#endregion
|
|
95
116
|
//#region src/remote/map-error.ts
|
|
96
|
-
/** 插件专属远程域名(src/shared/wire.ts):与宿主官方域(`workspace/*`、`session/*`、`gateway/*`…)不重名。
|
|
97
|
-
* 字面量的家在 `shared/wire.ts`(ADR-0003)——浏览器半也按同一个域认码。 */
|
|
98
|
-
const DOMAIN = SESSION_DELETE_ERROR_DOMAIN;
|
|
99
117
|
/** 宿主两条类型化错误 → 我们的码(本文件的两行映射)。
|
|
100
118
|
* 类不可 import(跨插件值 import 是红线;且宿主包与运行中的宿主不同代)⇒ 按官方构造器写死的 `this.name` 认。
|
|
101
119
|
* 证据:asar `dsh/node_modules/@deepseek-ai/dsh-workspace/lib/index.js` 原文
|
|
@@ -185,7 +203,7 @@ function toRemoteFailure(error) {
|
|
|
185
203
|
};
|
|
186
204
|
}
|
|
187
205
|
const raw = error?.code;
|
|
188
|
-
if (typeof raw === "string" && OFFICIAL_CODE.test(raw) && !
|
|
206
|
+
if (typeof raw === "string" && OFFICIAL_CODE.test(raw) && !isSessionDeleteCodeError(error)) {
|
|
189
207
|
const details = detailsOf(error);
|
|
190
208
|
return {
|
|
191
209
|
code: raw,
|
|
@@ -193,36 +211,24 @@ function toRemoteFailure(error) {
|
|
|
193
211
|
...details === void 0 ? {} : { details }
|
|
194
212
|
};
|
|
195
213
|
}
|
|
196
|
-
const
|
|
197
|
-
const fromVerb = reason !== void 0 && VERB_CODES$1.includes(reason) ? reason : void 0;
|
|
214
|
+
const fromVerb = verbCodeOf(error);
|
|
198
215
|
const host = hostErrorOf(error);
|
|
199
216
|
const own = detailsOf(error) ?? (host === void 0 ? void 0 : detailsOf(host));
|
|
200
217
|
if (host === void 0 && fromVerb === void 0 && typeof raw === "string" && raw.includes("/")) return {
|
|
201
|
-
code:
|
|
218
|
+
code: SESSION_DELETE_CODES.io,
|
|
202
219
|
message: messageOf(error)
|
|
203
220
|
};
|
|
204
221
|
const code = host !== void 0 ? HOST_ERROR_CODE[host.name] : fromVerb ?? "io";
|
|
205
222
|
return {
|
|
206
|
-
code:
|
|
223
|
+
code: SESSION_DELETE_CODES[code],
|
|
207
224
|
message: messageOf(error),
|
|
208
225
|
...own === void 0 ? {} : { details: own }
|
|
209
226
|
};
|
|
210
227
|
}
|
|
211
228
|
//#endregion
|
|
212
229
|
//#region src/verbs/errors.ts
|
|
213
|
-
/**
|
|
214
|
-
*
|
|
215
|
-
* 这里只转出旧名,免得调用点全改一遍。 */
|
|
216
|
-
const VERB_CODES = VERB_CODES$1;
|
|
217
|
-
/** `sessiondelete/` 这个前缀——拆码用它,别在下面几处各写一遍字面量。 */
|
|
218
|
-
const VERB_CODE_PREFIX = `${SESSION_DELETE_ERROR_DOMAIN}/`;
|
|
219
|
-
/** `error.code` 是不是本仓的动词码(`sessiondelete/<码>`)。**唯一判据**:`journalFailure` 与永久删除
|
|
220
|
-
* 执行器要"已经是动词码就原样抛"时都问这一句。同文件的 `withRollbackFailures` 还要码的后缀,所以它
|
|
221
|
-
* 就地 `slice` 一次——前缀仍只住 `VERB_CODE_PREFIX` 这一处,别在调用点另写一份字面量。 */
|
|
222
|
-
function isVerbCodeError(error) {
|
|
223
|
-
const raw = error?.code;
|
|
224
|
-
return typeof raw === "string" && raw.startsWith(VERB_CODE_PREFIX);
|
|
225
|
-
}
|
|
230
|
+
/** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
|
|
231
|
+
* 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
|
|
226
232
|
function verbError(code, message, cause, details) {
|
|
227
233
|
const e = new Error(message);
|
|
228
234
|
e.code = SESSION_DELETE_CODES[code];
|
|
@@ -230,6 +236,10 @@ function verbError(code, message, cause, details) {
|
|
|
230
236
|
if (details !== void 0) e.details = details;
|
|
231
237
|
return e;
|
|
232
238
|
}
|
|
239
|
+
/** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
|
|
240
|
+
function trashEmpty(entryId) {
|
|
241
|
+
return verbError("trash-empty", entryId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${entryId}`);
|
|
242
|
+
}
|
|
233
243
|
/** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
|
|
234
244
|
function routeError(code, httpStatus, message) {
|
|
235
245
|
const e = verbError("io", message);
|
|
@@ -252,9 +262,7 @@ function carriedRollbackFailures(error) {
|
|
|
252
262
|
* 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
|
|
253
263
|
* 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
|
|
254
264
|
function withRollbackFailures(error, failures) {
|
|
255
|
-
const
|
|
256
|
-
const reason = typeof raw === "string" && raw.startsWith(VERB_CODE_PREFIX) ? raw.slice(VERB_CODE_PREFIX.length) : void 0;
|
|
257
|
-
const code = reason !== void 0 && VERB_CODES.includes(reason) ? reason : "io";
|
|
265
|
+
const code = verbCodeOf(error) ?? "io";
|
|
258
266
|
const carried = carriedRollbackFailures(error);
|
|
259
267
|
const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
|
|
260
268
|
const base = error instanceof Error ? error.message : String(error);
|
|
@@ -262,6 +270,90 @@ function withRollbackFailures(error, failures) {
|
|
|
262
270
|
return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
|
|
263
271
|
}
|
|
264
272
|
//#endregion
|
|
273
|
+
//#region src/api/request-trust.ts
|
|
274
|
+
/** 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
|
|
275
|
+
* 转发前把 `host` / `origin` / `cookie` / `sec-fetch-site` 删掉、换上一枚宿主自己的 cookie,
|
|
276
|
+
* `Referer` 保留。判据按 `protocol` + `host` 比,**不能比 `URL.origin`**(`dsh-app:` 不是 WHATWG
|
|
277
|
+
* special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`)。 */
|
|
278
|
+
const DESKTOP_APP_SCHEME = "dsh-app:";
|
|
279
|
+
const DESKTOP_APP_HOST = "app";
|
|
280
|
+
/** 回环 socket 的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
|
|
281
|
+
const LOOPBACK_SOCKETS = [
|
|
282
|
+
"127.0.0.1",
|
|
283
|
+
"::1",
|
|
284
|
+
"::ffff:127.0.0.1"
|
|
285
|
+
];
|
|
286
|
+
function isDesktopShellUrl(value) {
|
|
287
|
+
try {
|
|
288
|
+
const url = new URL(value);
|
|
289
|
+
return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** 裸 authority(`host` / `host:port`)按给定 scheme 解析;解析不了回 `undefined`。 */
|
|
295
|
+
function parseAuthority(authority, scheme = "http:") {
|
|
296
|
+
try {
|
|
297
|
+
return new URL(`${scheme}//${authority}`);
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** 与宿主 `isLoopbackHostname` 同口径:`localhost`、`[::1]`、任意 127/8。 */
|
|
303
|
+
function isLoopbackHostname(hostname) {
|
|
304
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
305
|
+
const parts = hostname.split(".");
|
|
306
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
307
|
+
}
|
|
308
|
+
/** Host 是不是本部署的 bind 字面量:忽略端口与 `%zone`(zone 是接口选择器,不是地址文本)。 */
|
|
309
|
+
function isBindAddress(hostUrl, bindHost) {
|
|
310
|
+
if (bindHost === void 0) return false;
|
|
311
|
+
const zoneAt = bindHost.indexOf("%");
|
|
312
|
+
const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt);
|
|
313
|
+
const bindUrl = parseAuthority(address.includes(":") ? `[${address}]` : address);
|
|
314
|
+
return bindUrl !== void 0 && bindUrl.hostname === hostUrl.hostname;
|
|
315
|
+
}
|
|
316
|
+
/** 携带 authority 的头(Origin / Referer)是否与 Host 同 authority:按这个头自己的 scheme 归一
|
|
317
|
+
* 默认端口(`http://x:80` 与 Host `x` 是同一个 authority)。 */
|
|
318
|
+
function isSameAuthority(value, hostHeader) {
|
|
319
|
+
let url;
|
|
320
|
+
try {
|
|
321
|
+
url = new URL(value);
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
const scheme = url.protocol === "https:" ? "https:" : url.protocol === "http:" ? "http:" : void 0;
|
|
326
|
+
if (scheme === void 0) return false;
|
|
327
|
+
const hostUrl = parseAuthority(hostHeader, scheme);
|
|
328
|
+
return hostUrl !== void 0 && url.host === hostUrl.host;
|
|
329
|
+
}
|
|
330
|
+
/** 头栅栏:`sec-fetch-site` / Origin / Referer 三条,任何客户端都要过。 */
|
|
331
|
+
function passesHeaderFence(headers, host) {
|
|
332
|
+
if (headers["sec-fetch-site"] === "cross-site") return false;
|
|
333
|
+
const origin = headers.origin;
|
|
334
|
+
if (origin !== void 0 && !isDesktopShellUrl(origin) && !isSameAuthority(origin, host)) return false;
|
|
335
|
+
const referer = headers.referer;
|
|
336
|
+
if (referer !== void 0 && !isDesktopShellUrl(referer) && !isSameAuthority(referer, host)) return false;
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* 这条请求归不归本插件办。`untrusted` = 归属不对(跨源 / 重绑 / 头不成形);`unauthenticated` =
|
|
341
|
+
* 归属对但远程客户端没带宿主身份(原文会提示用 `dsh web` 打印的 URL 打开)。
|
|
342
|
+
*
|
|
343
|
+
* 远程支的两个失败裁决由端口翻译宿主的区分(401 = 缺身份、403 = 归属不对)。身份层缺席(端口报
|
|
344
|
+
* `absent`)一律按归属不对处理:那时没有可换的身份,不能让原文提示用户去补一个不存在的东西。
|
|
345
|
+
*/
|
|
346
|
+
function judgeRequest(req, facts) {
|
|
347
|
+
const host = req.headers.host;
|
|
348
|
+
if (host === void 0 || !passesHeaderFence(req.headers, host)) return "untrusted";
|
|
349
|
+
const hostUrl = parseAuthority(host);
|
|
350
|
+
if (hostUrl === void 0) return "untrusted";
|
|
351
|
+
if ((isLoopbackHostname(hostUrl.hostname) || isBindAddress(hostUrl, facts.bindHost)) && LOOPBACK_SOCKETS.includes(req.socket.remoteAddress ?? "")) return "allowed";
|
|
352
|
+
const verdict = facts.connection.verdictFor(req);
|
|
353
|
+
if (verdict.kind === "allowed") return "allowed";
|
|
354
|
+
return verdict.kind === "unauthenticated" ? "unauthenticated" : "untrusted";
|
|
355
|
+
}
|
|
356
|
+
//#endregion
|
|
265
357
|
//#region src/ports/agent-handles.ts
|
|
266
358
|
const CAPTURED_AGENT_HANDLES = Symbol.for("dsh-session-delete.captured-agent-handles");
|
|
267
359
|
/** 在 `ctx.agents` 上包一层 `resume` / `create`,把返回的 agent handle 的 dispose 按会话 id 记下。
|
|
@@ -348,16 +440,11 @@ async function filterChildrenOf(sessionQuery, values) {
|
|
|
348
440
|
}]);
|
|
349
441
|
}
|
|
350
442
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
* `
|
|
356
|
-
*
|
|
357
|
-
* 停下来的条件与代价:**层数**决定宿主调用次数,行数不参与(一层一次,问空即停)。同一条记录只收一次
|
|
358
|
-
* (`seen` 连着根一起记),所以数据里真出现环时结果是"少数一条",不是把清单读挂死。
|
|
359
|
-
* 交回的 id 没有 `header.id` 的记录**整条跳过**——身份冒充不起(同 {@link HostCorpusPort.subagentMembersOf} 那条),
|
|
360
|
-
* 而且没有身份就没法往下问,硬数进去只会让它将来被重复计。
|
|
443
|
+
* 从若干根逐层向下问出后代,交回 `父(bare)→ 孩子` 的索引。
|
|
444
|
+
* 用 `kind:'parent'` 而不是 traceSession:父进回收站后 traceSession 认不出目标(宿主失败码
|
|
445
|
+
* `SESSION_QUERY_SESSION_NOT_FOUND`),而这里要的正是父不在宿主列表之后仍留在盘上的那批。
|
|
446
|
+
* 出处:asar `dsh-session-query/lib/index.js` 的 `filterSessions`。一层一次宿主调用,问空即停;
|
|
447
|
+
* `seen` 防环;没有 `header.id` 的记录整条跳过(无法作为身份继续下问)。
|
|
361
448
|
*/
|
|
362
449
|
async function descendantsByParent(sessionQuery, roots) {
|
|
363
450
|
const children = /* @__PURE__ */ new Map();
|
|
@@ -425,29 +512,14 @@ async function membersFor(sessionQuery, parentIds) {
|
|
|
425
512
|
for (const id of wanted) out[id] = subtreeOf(children, bareSessionId(id));
|
|
426
513
|
return out;
|
|
427
514
|
}
|
|
428
|
-
/** 无 cwd 的会话落在
|
|
429
|
-
* 的 `projectDir
|
|
430
|
-
* `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
|
|
515
|
+
/** 无 cwd 的会话落在 `_no-cwd/`;`projectDirOf` 与回收站对账共用这个字面量。
|
|
516
|
+
* 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectDir`(`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
|
|
431
517
|
const NO_CWD_PROJECT_DIR = "_no-cwd";
|
|
432
|
-
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
* (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
|
|
438
|
-
*
|
|
439
|
-
* 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
|
|
440
|
-
* `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
|
|
441
|
-
* else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
|
|
442
|
-
* else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
|
|
443
|
-
* return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
|
|
444
|
-
* 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
|
|
445
|
-
* 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
|
|
446
|
-
* (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
|
|
447
|
-
*
|
|
448
|
-
* **两个调用方共用这一份**:`projectDirOf`(删除时定位会话目录)与回收站对账的重建路径
|
|
449
|
-
* (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
|
|
450
|
-
*/
|
|
518
|
+
/** 宿主 `projectKey(cwd)` 的逐字镜像:跨插件不能 import,宿主也没把它暴露成服务。
|
|
519
|
+
* 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectKey`;调用链
|
|
520
|
+
* `locate` → `logPath` → `projectDir` → `projectKey`。本机实测 `D:/develop/GitHub/dsh-session-delete`
|
|
521
|
+
* → `--D-develop-GitHub-dsh-session-delete--`。`projectDirOf` 与回收站对账重建共用这一份;
|
|
522
|
+
* 宿主换代先复核算法。 */
|
|
451
523
|
function projectKey(cwd) {
|
|
452
524
|
let readable = "";
|
|
453
525
|
let separatorRun = false;
|
|
@@ -474,17 +546,10 @@ function projectionCacheOf(ctx) {
|
|
|
474
546
|
if (typeof cache.cachedSnapshot !== "function" || typeof cache.cachedPredecessorTitle !== "function") return void 0;
|
|
475
547
|
return cache;
|
|
476
548
|
}
|
|
477
|
-
/**
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
* `
|
|
481
|
-
*
|
|
482
|
-
* 为什么按消息判而不是全吞:同一个方法里 `listStoredHeaders()` / `indexHeaders()` 会抛**真存储故障**
|
|
483
|
-
* (读盘失败、账本不一致…),那是 `io` 该走的路,绝不能被当成「无此会话」。
|
|
484
|
-
* 同文件另一处近似句是有类型的那条(`cannot ${verb} session '…': live sessions and session persistence
|
|
485
|
-
* hold no such session`,`WorkspaceUnknownSessionError`)——`holds` 与 `hold`、有无 `live sessions and`
|
|
486
|
-
* 都不相同,所以这条串不会误伤它,`toRemoteFailure` 的 cause 链映射仍按 `name` 走。
|
|
487
|
-
*/
|
|
549
|
+
/** 宿主 `readSessionHeader` 对未知会话抛的是裸 `Error`,只认这句;真存储故障照抛,不能折成未命中。
|
|
550
|
+
* 出处:asar `dsh-workspace/lib/types/index.js` 的
|
|
551
|
+
* `cannot validate session '${id}': session persistence holds no such session`。
|
|
552
|
+
* 同文件有类型的近似句是 `WorkspaceUnknownSessionError`(`hold` 无 `s`),不会误伤。 */
|
|
488
553
|
const NO_SUCH_SESSION = "session persistence holds no such session";
|
|
489
554
|
/** 只认上面那一句;其余(含非 Error 抛出物)一律**不算未命中**。 */
|
|
490
555
|
function isUnknownSession(error) {
|
|
@@ -525,13 +590,8 @@ const WORKSPACE_MISSING_MESSAGE = "workspace not found";
|
|
|
525
590
|
function isWorkspaceMissingError(error) {
|
|
526
591
|
return error instanceof Error && error.message.startsWith(WORKSPACE_MISSING_MESSAGE);
|
|
527
592
|
}
|
|
528
|
-
/**
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
* 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
|
|
532
|
-
* 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
|
|
533
|
-
* `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(src/remote/map-error.ts 的两行映射)。
|
|
534
|
-
*/
|
|
593
|
+
/** 宿主服务的真实绑定;出错原样抛,由 `toRemoteFailure` 按 name 映射。
|
|
594
|
+
* 各方法调用的服务与方法见上方成员注释;错误映射表住 `src/remote/map-error.ts`。 */
|
|
535
595
|
function createHostPort(ctx) {
|
|
536
596
|
const host = ctx;
|
|
537
597
|
const registry = host.workspaceRegistry;
|
|
@@ -547,14 +607,9 @@ function createHostPort(ctx) {
|
|
|
547
607
|
return registry.get(workspaceId) !== void 0;
|
|
548
608
|
},
|
|
549
609
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
* 也是 `delete-session.ts` 已经写好的那条分支 `if (!projectDir) throw
|
|
554
|
-
* verbError('not-found', …)` 的入口。若让它裸抛,那句话永远为假 ⇒ src/shared/wire.ts 的 `not-found`
|
|
555
|
-
* (「会话目录或账本条目不存在」)在这条最自然的路径上拿不到码,客户端只会看到 `sessiondelete/io`。
|
|
556
|
-
*
|
|
557
|
-
* **只吞这一种**:其余错误(真存储故障)照抛,让 `toRemoteFailure` 兜成 `io` 而不是假 `not-found`。
|
|
610
|
+
* cwd → 宿主项目目录名;没有 cwd 落到 `_no-cwd`。
|
|
611
|
+
* 账本里没有这个会话回 `undefined`(未命中,不是故障);真存储故障照抛。
|
|
612
|
+
* 未命中由 `delete-session.ts` 折成 `not-found`,真故障留 `io`——两者不能混。
|
|
558
613
|
*/
|
|
559
614
|
async projectDirOf(sessionId) {
|
|
560
615
|
let header;
|
|
@@ -566,12 +621,7 @@ function createHostPort(ctx) {
|
|
|
566
621
|
}
|
|
567
622
|
return header.cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(header.cwd);
|
|
568
623
|
},
|
|
569
|
-
/**
|
|
570
|
-
* 标题:走官方**投影缓存**读面(`title` 投影单元的 `wire.view` 值就是标题文本本身:
|
|
571
|
-
* 单元定义原文 `{ key: "title", stateVersion: 1, wire: { view: (state) => state } }`,
|
|
572
|
-
* 所以 `values.title` 是 `string | null`)。
|
|
573
|
-
* 契约:**尽力取、取不到返回空串、绝不因此让删除失败** ⇒ 这里吞掉一切异常。
|
|
574
|
-
*/
|
|
624
|
+
/** 标题走宿主投影缓存;尽力取,取不到返回空串,绝不因此让删除失败。 */
|
|
575
625
|
async titleOf(sessionId) {
|
|
576
626
|
try {
|
|
577
627
|
const header = await registry.readSessionHeader(sessionId);
|
|
@@ -582,12 +632,8 @@ function createHostPort(ctx) {
|
|
|
582
632
|
return "";
|
|
583
633
|
}
|
|
584
634
|
},
|
|
585
|
-
/**
|
|
586
|
-
*
|
|
587
|
-
* **空数组 = 无活动**;与宿主自己的 `archiveSession` 准入同源,不自创第二套。
|
|
588
|
-
* asar 原文:`const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
|
|
589
|
-
* if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);`
|
|
590
|
-
*/
|
|
635
|
+
/** 活跃判据走宿主 `workspace/session-activity` waterfall:空数组 = 无活动;
|
|
636
|
+
* 与宿主 archiveSession 的准入同源。 */
|
|
591
637
|
async isLive(sessionId) {
|
|
592
638
|
return (await host.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]))).length > 0;
|
|
593
639
|
},
|
|
@@ -596,25 +642,15 @@ function createHostPort(ctx) {
|
|
|
596
642
|
return registry.archivedSessionIds.includes(sessionId);
|
|
597
643
|
},
|
|
598
644
|
/**
|
|
599
|
-
* 从 `ctx.sessions
|
|
600
|
-
*
|
|
601
|
-
* **为什么需要**:会话列表读面是并集——`sessionQuery.listSessions()` = 磁盘上的持久化 ∪ `ctx.sessions` 里的活体
|
|
602
|
-
* (见本文件的「通告」一段)。搬走文件只清掉一侧;本机运行期打开过的会话是活体,列表照样把它交出来。
|
|
603
|
-
*
|
|
604
|
-
* **agent 也必须关**:`ctx.agents` 没有 per-agent dispose;宿主 `AgentService.resume` / `create` 返回的
|
|
605
|
-
* `AgentHandle` 带宿主自己的外层 teardown(关写句柄、摘 agent/session),但 `SessionController` 只取
|
|
606
|
-
* `.agent`、把 handle 丢掉。agent handle 捕获(`src/ports/agent-handles.ts`)在 `ctx.agents` 上包一层,
|
|
607
|
-
* 按会话 id 留住 handle;这里先 `entry.detach()`(摘 session、发 `session/disposed`),再
|
|
608
|
-
* `await agentHandles.dispose(sessionId)`。
|
|
609
|
-
* **只摘 session 的后果**:恢复后 `SessionController.resolve` 会先命中那具旧 agent,prompt 被收下、
|
|
610
|
-
* 日志不再增长,直到宿主重启(2026-10-21 真机复现)。
|
|
645
|
+
* 从 `ctx.sessions` 摘掉会话,并关闭捕获到的 agent handle。
|
|
611
646
|
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
647
|
+
* 会话列表 = 磁盘持久化 ∪ 内存活体,搬走文件只清掉一侧;宿主 `SessionController` 会丢掉
|
|
648
|
+
* `AgentHandle`,所以 `agent-handles.ts` 按会话 id 留住 handle,这里先 detach 再 dispose。
|
|
649
|
+
* 捕获不到 handle(插件激活前已启动,或 `ctx.agents` 形状变化)时只能摘 session,这是已知限制,
|
|
650
|
+
* 不拿归档兜底(ADR-0008)。
|
|
614
651
|
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
* 任何一步都不抛:删除的持久化部分已经完成,这里不该把它翻成失败。
|
|
652
|
+
* 三态:读不到 store / 形状不符 / detach 或 dispose 抛 ⇒ `unknown`(不通告);
|
|
653
|
+
* 内存里本来没有 ⇒ `not-live`;摘成功 ⇒ `evicted`。任何一步都不抛。
|
|
618
654
|
*/
|
|
619
655
|
async evictLive(sessionId) {
|
|
620
656
|
try {
|
|
@@ -635,19 +671,9 @@ function createHostPort(ctx) {
|
|
|
635
671
|
}
|
|
636
672
|
},
|
|
637
673
|
/**
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* 或宿主转发的 `api-session/*` 事件。删除一个**冷**会话时宿主不会发任何事件(`session/disposed` 只在摘掉
|
|
642
|
-
* 活体时才有),所以那一行会一直挂到下次重连为止(真机实测就是这个形状)。
|
|
643
|
-
*
|
|
644
|
-
* **为什么这样发是对的**:`api-session/removed` 是宿主**自己声明并转发**的事件
|
|
645
|
-
* (asar `dsh-api-remotes/lib/remote-events.js` 的 `API_REMOTE_FORWARDED_EVENTS` 里
|
|
646
|
-
* `{ event: "api-session/removed", mode: "emit" }`;转发器就是 `ctx.on(event, …)` 的普通监听者);
|
|
647
|
-
* 我们不带 carrier 地 emit ⇒ 全量派发到它(见 {@link HostCtxLike.emit})⇒ 客户端
|
|
648
|
-
* `ctx.remote.$on("api-session/removed", …)` 收到后 `recordMutation({kind:'remove'})` + `handleRemoved()`。
|
|
649
|
-
* **只在"宿主列表确实已经不含它"时调用**(调用方按 {@link EvictionOutcome} 判):这条通告在语义上是**如实**
|
|
650
|
-
* 的,不是障眼法。
|
|
674
|
+
* 通告客户端这个会话没了:发宿主自己转发的 `api-session/removed`,客户端当场删行。
|
|
675
|
+
* 冷会话没有 `session/disposed`,不发这条就只能等重连。
|
|
676
|
+
* 只在宿主列表确实已不含它时调用(调用方按 {@link EvictionOutcome} 判);不发假通告。
|
|
651
677
|
*/
|
|
652
678
|
async announceRemoved(sessionId) {
|
|
653
679
|
try {
|
|
@@ -655,41 +681,21 @@ function createHostPort(ctx) {
|
|
|
655
681
|
} catch {}
|
|
656
682
|
},
|
|
657
683
|
/**
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* 要的是宿主 `summaryFor` 那套形状(sessionId / updatedAt / blank / projections…),照抄宿主的那一份最可靠。
|
|
662
|
-
* 取不到(控制器缺席 / 列表里没有它 / 抛错)就**一条都不发**。
|
|
663
|
-
*
|
|
664
|
-
* **这一条单条路是删除组回滚用的**:成员已经收过 `api-session/removed`,退回 `sessions/` 之后要补一条
|
|
665
|
-
* "added",否则客户端那一行要等到重连才回来。正常恢复走 {@link announceRestoredBatch} + 一次预取——
|
|
666
|
-
* 100 条的批量用单条路就是 100 遍全表扫,那正是 2026-10-17 那次"恢复很慢"的头号来源。
|
|
684
|
+
* 通告这组会话回来了:拉一次宿主会话表,按 id 逐条发 `api-session/added`。
|
|
685
|
+
* 摘要直接用宿主 `sessionController.list()` 的形状;取不到就不发。
|
|
686
|
+
* 批量只预取一次,避免按条全表扫。
|
|
667
687
|
*/
|
|
668
|
-
async announceRestored(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
try {
|
|
672
|
-
host.emit("api-session/added", JSON.parse(JSON.stringify(summary)));
|
|
673
|
-
} catch {}
|
|
674
|
-
},
|
|
675
|
-
/** 整批预取一次会话表(`[{ sessionId, summary }]`):通告的路径与单条一致,**拉表从每条一次抬成整批一次**。 */
|
|
676
|
-
async restoredSummariesOf() {
|
|
677
|
-
const out = [];
|
|
688
|
+
async announceRestored(sessionIds) {
|
|
689
|
+
if (sessionIds.length === 0) return;
|
|
690
|
+
const summaries = [];
|
|
678
691
|
for (const item of await controllerItems(host)) {
|
|
679
692
|
const sessionId = item?.sessionId;
|
|
680
|
-
if (typeof sessionId === "string")
|
|
693
|
+
if (typeof sessionId === "string") summaries.push({
|
|
681
694
|
sessionId,
|
|
682
695
|
summary: item
|
|
683
696
|
});
|
|
684
697
|
}
|
|
685
|
-
|
|
686
|
-
},
|
|
687
|
-
/** 一组会话的通告(2026-10-17 批量重构):`known` 给了就**一次都不拉表**,只按它筛。
|
|
688
|
-
* 与 {@link announceRestored} 同一判据、同一事件、同一载荷形状,只是取数由整批共享。 */
|
|
689
|
-
async announceRestoredBatch(ids, known) {
|
|
690
|
-
if (ids.length === 0) return;
|
|
691
|
-
const summaries = known ?? await this.restoredSummariesOf();
|
|
692
|
-
for (const id of ids) {
|
|
698
|
+
for (const id of sessionIds) {
|
|
693
699
|
const summary = summaries.find((item) => item.sessionId === id)?.summary;
|
|
694
700
|
if (summary === void 0) continue;
|
|
695
701
|
try {
|
|
@@ -744,26 +750,9 @@ function createHostPort(ctx) {
|
|
|
744
750
|
},
|
|
745
751
|
/**
|
|
746
752
|
* 官方 `sessionQuery.filterSessions([{kind:'parent', values}])`,不自扫 `~/.dsh/sessions`。
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
* 说成没有。问法见 {@link descendantsByParent}。
|
|
751
|
-
*
|
|
752
|
-
* 归堆必须**两边都去前缀**再比:本机实测父会话自己的 header id 带 `session-` 前缀
|
|
753
|
-
* (`session-ac07f2bb-…`)、它的子会话写的 `parentSession` 也带,但 v3 那一代的 id 不带 ⇒
|
|
754
|
-
* 同一个父有两种写法。所以问的时候把一个 id 的两种形状都塞进 `values`,回来按去前缀后的形状归堆,
|
|
755
|
-
* 最后按**调用方给的那个写法**把键交回去(调用方拿 `entry.sessionId` 直接查得到)。
|
|
756
|
-
*
|
|
757
|
-
* 删除要一起带走**整个组**,先得把他们点出来:身份取宿主自己记录里的 `header.id`,项目目录由
|
|
758
|
-
* `header.cwd` 走同一个 `projectKey`(与 {@link HostPort.projectDirOf} 同一根算法,不另起一套)。
|
|
759
|
-
* 交回的是**全部后代**——孙代跟着父一起进回收站(issue #1),顺序是逐层往下问出来的(近的先)。
|
|
760
|
-
* `header.id` 缺席的后代直接跳过——不拿目录名冒充身份(冒充了会把一个来路不明的目录搬进回收站)。
|
|
761
|
-
*
|
|
762
|
-
* **缺席显式化**(见 {@link CorpusRead}):`sessionQuery` 没挂,或它的 `filterSessions` 缺席 / 不是函数
|
|
763
|
-
* ⇒ `{ available: false }`,不拿空表冒充"盘上没有后代"——写侧据此决定降级还是响亮失败,不再靠"空表"
|
|
764
|
-
* 兼表两种意思。
|
|
765
|
-
* **先判服务、再判空入参**:语料本来就看不见时,"没有后代"这句话不该被说出口(哪怕入参是空的);
|
|
766
|
-
* 服务在场时空入参由 `membersFor` 的短路保证零宿主调用。
|
|
753
|
+
* 数传递闭包(孩子与孙代),不是直接孩子;`session-` 前缀两边都去掉再比,
|
|
754
|
+
* 按调用方给的写法回键。`sessionQuery` 缺席或方法缺失 ⇒ `{available:false}`,
|
|
755
|
+
* 不拿空表冒充“没有后代”;服务在场时空入参零宿主调用。
|
|
767
756
|
*/
|
|
768
757
|
async subagentMembersOf(parentIds) {
|
|
769
758
|
const sessionQuery = host.get("sessionQuery");
|
|
@@ -820,14 +809,59 @@ function groupOf(rows, id) {
|
|
|
820
809
|
return kidsOf(rows, id);
|
|
821
810
|
}
|
|
822
811
|
/**
|
|
823
|
-
*
|
|
812
|
+
* 「这条是被**同批前一条**带走的,还是整批开始前就没有,还是仍在?」
|
|
824
813
|
*
|
|
825
|
-
*
|
|
814
|
+
* 幂等语义只属于 `carried`:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
|
|
826
815
|
* `trash-empty`,不能被"已被带走"这句话掩盖成成功。**所以判据是快照的原始形状,不是当前形状**:
|
|
827
|
-
*
|
|
816
|
+
* 原始快照里认得出(它自己、或指向它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
|
|
817
|
+
*/
|
|
818
|
+
function claimOf(original, current, id) {
|
|
819
|
+
if (groupOf(original, id).length === 0) return { verdict: "absent" };
|
|
820
|
+
const group = groupOf(current, id);
|
|
821
|
+
return group.length === 0 ? { verdict: "carried" } : {
|
|
822
|
+
verdict: "fresh",
|
|
823
|
+
group
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region src/verbs/batch-carried.ts
|
|
828
|
+
/**
|
|
829
|
+
* **同批已带走集**(「已被同批前一条带走」的 id)的唯一实现(ADR-0002 的内部实现)。
|
|
830
|
+
*
|
|
831
|
+
* 两种 id 域各一个 adapter:
|
|
832
|
+
* - entry 适配:restore / purge 的条目 id,`claim` 是三态(fresh / carried / absent),组语义仍读
|
|
833
|
+
* `trash/groups.ts`;
|
|
834
|
+
* - session 适配:delete 的会话 id,`claim` 只有 fresh / carried,集合由动词结果长出。
|
|
835
|
+
*
|
|
836
|
+
* entry 适配的三态判据读 `groups.claimOf`;session 适配没有原始清册视图,只记 `take` 过的会话 id。
|
|
837
|
+
* 当前只读视图(`rows`)只挂在 entry 域上:只有 purge 要它——盘上后代要按清册里还在的 id 排除。
|
|
828
838
|
*/
|
|
829
|
-
|
|
830
|
-
|
|
839
|
+
/** 恢复 / 彻底删除:原始快照认得、当前视图已经空了 ⇒ 同批前一条带走的。 */
|
|
840
|
+
function createEntryCarried(original) {
|
|
841
|
+
let current = [...original];
|
|
842
|
+
return {
|
|
843
|
+
claim(id) {
|
|
844
|
+
return claimOf(original, current, id);
|
|
845
|
+
},
|
|
846
|
+
take(id) {
|
|
847
|
+
const gone = new Set(groupOf(current, id).map((row) => row.id));
|
|
848
|
+
if (gone.size === 0) return;
|
|
849
|
+
current = current.filter((row) => !gone.has(row.id));
|
|
850
|
+
},
|
|
851
|
+
rows: () => current
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
/** 删除:会话 id 域;`take` 只收这次真的搬走的成员(父 + 盘上的孩子)。 */
|
|
855
|
+
function createSessionCarried() {
|
|
856
|
+
const carried = /* @__PURE__ */ new Set();
|
|
857
|
+
return {
|
|
858
|
+
claim(id) {
|
|
859
|
+
return carried.has(bareSessionId(id)) ? { verdict: "carried" } : { verdict: "fresh" };
|
|
860
|
+
},
|
|
861
|
+
take(id) {
|
|
862
|
+
carried.add(bareSessionId(id));
|
|
863
|
+
}
|
|
864
|
+
};
|
|
831
865
|
}
|
|
832
866
|
//#endregion
|
|
833
867
|
//#region src/trash/entry.ts
|
|
@@ -890,412 +924,174 @@ function trashDirOf(entryId, home) {
|
|
|
890
924
|
return `${home}/${TRASH_DIR_NAME}/${entryId}`;
|
|
891
925
|
}
|
|
892
926
|
//#endregion
|
|
893
|
-
//#region src/
|
|
894
|
-
const MAGIC = [
|
|
895
|
-
40,
|
|
896
|
-
181,
|
|
897
|
-
47,
|
|
898
|
-
253
|
|
899
|
-
];
|
|
900
|
-
/** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
|
|
901
|
-
const AFTER_DESCRIPTOR = 5;
|
|
902
|
-
const BLOCK_HEADER_BYTES = 3;
|
|
903
|
-
const BLOCK_LAST = 1;
|
|
904
|
-
const BLOCK_TYPE_SHIFT = 1;
|
|
905
|
-
const BLOCK_SIZE_SHIFT = 3;
|
|
906
|
-
const BLOCK_SIZE_MASK = 2097151;
|
|
907
|
-
const BLOCK_RAW = 0;
|
|
908
|
-
const BLOCK_RLE = 1;
|
|
909
|
-
const BLOCK_COMRESSED = 2;
|
|
910
|
-
/** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
|
|
911
|
-
const BLOCK_RESERVED = 3;
|
|
912
|
-
/** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
|
|
913
|
-
const DID_SIZES = [
|
|
914
|
-
0,
|
|
915
|
-
1,
|
|
916
|
-
2,
|
|
917
|
-
4
|
|
918
|
-
];
|
|
919
|
-
const FCS_SIZES_SINGLE = [
|
|
920
|
-
1,
|
|
921
|
-
2,
|
|
922
|
-
4,
|
|
923
|
-
8
|
|
924
|
-
];
|
|
925
|
-
const FCS_SIZES_MULTI = [
|
|
926
|
-
0,
|
|
927
|
-
2,
|
|
928
|
-
4,
|
|
929
|
-
8
|
|
930
|
-
];
|
|
931
|
-
/** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
|
|
932
|
-
const FRAME_TAIL_BYTES = 4;
|
|
933
|
-
function isMagicAt(raw, at) {
|
|
934
|
-
return MAGIC.every((byte, i) => raw[at + i] === byte);
|
|
935
|
-
}
|
|
936
|
-
function readUint24LE(raw, at) {
|
|
937
|
-
return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
|
|
938
|
-
}
|
|
939
|
-
/** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
|
|
940
|
-
function withFrameTail(raw, at) {
|
|
941
|
-
const afterTail = at + FRAME_TAIL_BYTES;
|
|
942
|
-
if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
|
|
943
|
-
return at;
|
|
944
|
-
}
|
|
927
|
+
//#region src/verbs/member-journal.ts
|
|
945
928
|
/**
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
929
|
+
* 一次成员移动 = 一串自带反向动作的步骤(ADR-0002)。
|
|
930
|
+
*
|
|
931
|
+
* 这里独占四件事,动词只声明“这次要做哪几步、每步怎么退”:
|
|
932
|
+
* - 相反顺序退化;
|
|
933
|
+
* - 一步失败时把已做成的那些退回去;
|
|
934
|
+
* - 失败收集:回滚不干净时不让原错误独走(details.rollbackFailures);
|
|
935
|
+
* - 非原子步骤:undoOnFailure 让“这一步自己失败”也跑反向动作。
|
|
936
|
+
*
|
|
937
|
+
* 边界:只服务成员在 sessions/ 与 session-trash/ 之间搬;不做通用撤销框架。
|
|
949
938
|
*/
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
939
|
+
/** 已经把某个前缀做成了的 journal——`undo()` 就退那一个前缀。 */
|
|
940
|
+
function createJournal(steps) {
|
|
941
|
+
const done = [];
|
|
942
|
+
const undoAll = async (alsoFailing) => {
|
|
943
|
+
const failures = [];
|
|
944
|
+
const restored = /* @__PURE__ */ new Map();
|
|
945
|
+
const runUndo = async (step) => {
|
|
946
|
+
if (step.undo === void 0) return;
|
|
947
|
+
const result = await step.undo();
|
|
948
|
+
failures.push(...result.failures);
|
|
949
|
+
restored.set(step.key, result.restored);
|
|
950
|
+
};
|
|
951
|
+
if (alsoFailing?.undo !== void 0) await runUndo(alsoFailing);
|
|
952
|
+
for (const step of [...done].reverse()) await runUndo(step);
|
|
953
|
+
return {
|
|
954
|
+
failures,
|
|
955
|
+
restored: (key) => restored.get(key) ?? false
|
|
956
|
+
};
|
|
957
|
+
};
|
|
958
|
+
return {
|
|
959
|
+
async run() {
|
|
960
|
+
for (const step of steps) {
|
|
961
|
+
try {
|
|
962
|
+
await step.run();
|
|
963
|
+
} catch (error) {
|
|
964
|
+
const undo = await undoAll(step.undoOnFailure === true ? step : void 0);
|
|
965
|
+
const base = journalFailure(step, error);
|
|
966
|
+
throw undo.failures.length > 0 ? withRollbackFailures(base, undo.failures) : base;
|
|
967
|
+
}
|
|
968
|
+
done.push(step);
|
|
969
|
+
}
|
|
970
|
+
},
|
|
971
|
+
undo() {
|
|
972
|
+
return undoAll();
|
|
974
973
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/** 跑一步反向动作并把它自己的失败收成一条原因(不抛:这些失败由调用方汇总成 `rollbackFailures`)。 */
|
|
977
|
+
async function attempt(label, run) {
|
|
978
|
+
try {
|
|
979
|
+
await run();
|
|
980
|
+
return [];
|
|
981
|
+
} catch (error) {
|
|
982
|
+
return [rollbackReason(label, error)];
|
|
982
983
|
}
|
|
983
|
-
return ranges;
|
|
984
984
|
}
|
|
985
|
-
|
|
986
|
-
|
|
985
|
+
/** 一步反向动作的完整结果:主写是否回到原样 + 失败串。附属写失败只进 `failures`,不推翻 `restored`。 */
|
|
986
|
+
async function stepUndo(label, run) {
|
|
987
|
+
const failures = await attempt(label, run);
|
|
988
|
+
return {
|
|
989
|
+
restored: failures.length === 0,
|
|
990
|
+
failures
|
|
991
|
+
};
|
|
992
|
+
}
|
|
987
993
|
/**
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
* **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
|
|
992
|
-
* 这一层只懂编解码,所以它能被单独验穿。
|
|
993
|
-
*
|
|
994
|
-
* 两条实测决定的口径:
|
|
995
|
-
* ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
|
|
996
|
-
* `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
|
|
997
|
-
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
998
|
-
* ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
|
|
999
|
-
* 本机实测**没有一帧切在行中间**(连最后一帧都整行收尾;绝对帧数随盘上会话数摆动,那份读数
|
|
1000
|
-
* 不在这里钉,来历留在 git 历史),
|
|
1001
|
-
* 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
|
|
1002
|
-
* 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
|
|
1003
|
-
* 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
|
|
1004
|
-
* 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
|
|
994
|
+
* **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
|
|
995
|
+
* `details.rollbackFailures`,绝不让原错误独走。那些失败句子的合并与措辞住 `src/verbs/errors.ts`;
|
|
996
|
+
* 删除与恢复两个按组走的动词共用这一处。
|
|
1005
997
|
*/
|
|
1006
|
-
|
|
998
|
+
async function rollbackJournals(journals, error) {
|
|
999
|
+
const failures = [];
|
|
1000
|
+
for (const journal of [...journals].reverse()) {
|
|
1001
|
+
const undo = await journal.undo();
|
|
1002
|
+
failures.push(...undo.failures);
|
|
1003
|
+
}
|
|
1004
|
+
if (failures.length > 0) throw withRollbackFailures(error, failures);
|
|
1005
|
+
throw error;
|
|
1006
|
+
}
|
|
1007
1007
|
/**
|
|
1008
|
-
*
|
|
1009
|
-
* `
|
|
1010
|
-
*
|
|
1008
|
+
* 这一步的失败形状:已经是动词码(`verbError` 的输出)就原样带,否则折成 `sessiondelete/io`。
|
|
1009
|
+
* 原文用 `String(error)` 而不是 `.message`——那 22 处既有措辞就是这么拼的(`Error: boom` 也在里面),
|
|
1010
|
+
* 换一种拼法会让上屏原文跟着变。
|
|
1011
1011
|
*/
|
|
1012
|
-
|
|
1012
|
+
function journalFailure(step, error) {
|
|
1013
|
+
if (isSessionDeleteCodeError(error)) return error;
|
|
1014
|
+
return verbError("io", `${step.label}:${String(error)}`, error);
|
|
1015
|
+
}
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region src/verbs/live-admission.ts
|
|
1018
|
+
/** 逐个问,任一在跑就抛 `live`。名单的挑选(哪些成员算这一组)归调用方,这里只按传入顺序问完。 */
|
|
1019
|
+
async function assertNoLive(host, sessionIds) {
|
|
1020
|
+
for (const sessionId of sessionIds) if (await host.isLive(sessionId)) throw verbError("live", `会话正在运行:${sessionId}`);
|
|
1021
|
+
}
|
|
1022
|
+
//#endregion
|
|
1023
|
+
//#region src/verbs/member-presence.ts
|
|
1024
|
+
/** 本仓的在场能力实现:端口形状住 `src/ports/presence-port.ts`,这里只做三态与真值表。
|
|
1025
|
+
* `announceRestored` 直接交宿主那一手:能力面与宿主面同名同签名,再包一层只会多一个转调。 */
|
|
1026
|
+
function createMemberPresence(host) {
|
|
1027
|
+
return {
|
|
1028
|
+
async memberGone(sessionId, facts = { backInTrash: true }) {
|
|
1029
|
+
if (!facts.backInTrash) return;
|
|
1030
|
+
if (await host.evictLive(sessionId) !== "unknown") await host.announceRemoved(sessionId);
|
|
1031
|
+
},
|
|
1032
|
+
async memberBack(sessionId, facts) {
|
|
1033
|
+
if (!facts.diskBack || !facts.workspaceBack) return;
|
|
1034
|
+
await host.announceRestored([sessionId]);
|
|
1035
|
+
},
|
|
1036
|
+
announceRestored: host.announceRestored
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1013
1039
|
/**
|
|
1014
|
-
*
|
|
1015
|
-
* 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
|
|
1016
|
-
* ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
|
|
1040
|
+
* 给一次成员移动的 journal 补上「在场那条尾巴」:run 完全成功之后离场,undo 之后按事实归位或离场。
|
|
1017
1041
|
*
|
|
1018
|
-
*
|
|
1042
|
+
* 边界:`memberGone` 的 `backInTrash:false` 早退与 `memberBack` 的两件齐备真值表归 `createMemberPresence`;
|
|
1043
|
+
* 这里只把事实原样递过去。
|
|
1019
1044
|
*/
|
|
1020
|
-
function
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
if (match === null) continue;
|
|
1026
|
-
const version = match[1] === void 0 ? 0 : Number(match[1]);
|
|
1027
|
-
if (version > bestVersion) {
|
|
1028
|
-
bestVersion = version;
|
|
1029
|
-
best = name;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
return best;
|
|
1033
|
-
}
|
|
1034
|
-
/**
|
|
1035
|
-
* 逐帧走一遍日志,每条记录交给回调。
|
|
1036
|
-
*
|
|
1037
|
-
* 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
|
|
1038
|
-
* 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
|
|
1039
|
-
*/
|
|
1040
|
-
function walkSessionLog(raw, onRecord) {
|
|
1041
|
-
const stats = {
|
|
1042
|
-
frames: 0,
|
|
1043
|
-
records: 0,
|
|
1044
|
-
unparsedLines: 0,
|
|
1045
|
-
truncatedTailBytes: 0
|
|
1046
|
-
};
|
|
1047
|
-
let stopped = false;
|
|
1048
|
-
const feed = (line) => {
|
|
1049
|
-
if (line === "") return;
|
|
1050
|
-
let parsed;
|
|
1051
|
-
try {
|
|
1052
|
-
parsed = JSON.parse(line);
|
|
1053
|
-
} catch {
|
|
1054
|
-
stats.unparsedLines += 1;
|
|
1045
|
+
function withPresenceTail(presence, journal, tail) {
|
|
1046
|
+
const announce = async (undo) => {
|
|
1047
|
+
const fact = tail.onUndone(undo);
|
|
1048
|
+
if (fact.kind === "leave") {
|
|
1049
|
+
await presence.memberGone(tail.sessionId, { backInTrash: fact.backInTrash });
|
|
1055
1050
|
return;
|
|
1056
1051
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1052
|
+
await presence.memberBack(tail.sessionId, {
|
|
1053
|
+
diskBack: fact.diskBack,
|
|
1054
|
+
workspaceBack: fact.workspaceBack
|
|
1055
|
+
});
|
|
1059
1056
|
};
|
|
1060
|
-
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
1061
|
-
let carry = Buffer.alloc(0);
|
|
1062
|
-
for (const { start, end } of frameRanges(raw)) {
|
|
1063
|
-
if (stopped) break;
|
|
1064
|
-
stats.frames += 1;
|
|
1065
|
-
let plain;
|
|
1066
|
-
try {
|
|
1067
|
-
plain = zlib.zstdDecompressSync(raw.subarray(start, end));
|
|
1068
|
-
} catch (error) {
|
|
1069
|
-
throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
|
|
1070
|
-
}
|
|
1071
|
-
const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
|
|
1072
|
-
const cut = merged.lastIndexOf(NEWLINE);
|
|
1073
|
-
if (cut === -1) {
|
|
1074
|
-
carry = merged;
|
|
1075
|
-
continue;
|
|
1076
|
-
}
|
|
1077
|
-
carry = merged.subarray(cut + 1);
|
|
1078
|
-
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
1079
|
-
feed(line);
|
|
1080
|
-
if (stopped) break;
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
stats.truncatedTailBytes = carry.length;
|
|
1084
|
-
if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
|
|
1085
|
-
return stats;
|
|
1086
|
-
}
|
|
1087
|
-
/**
|
|
1088
|
-
* 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
|
|
1089
|
-
* AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
|
|
1090
|
-
* (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
|
|
1091
|
-
* 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
|
|
1092
|
-
*/
|
|
1093
|
-
function readSessionHeader(raw) {
|
|
1094
|
-
let header;
|
|
1095
|
-
walkSessionLog(raw, (record) => {
|
|
1096
|
-
if (record.type !== "session") return;
|
|
1097
|
-
header = record;
|
|
1098
|
-
return false;
|
|
1099
|
-
});
|
|
1100
|
-
return header;
|
|
1101
|
-
}
|
|
1102
|
-
//#endregion
|
|
1103
|
-
//#region src/trash/reconcile.ts
|
|
1104
|
-
/**
|
|
1105
|
-
* 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(CONTEXT.md 的「对账」条)。
|
|
1106
|
-
*
|
|
1107
|
-
* 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
|
|
1108
|
-
* `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
|
|
1109
|
-
*
|
|
1110
|
-
* 五条口径(都不是口味):
|
|
1111
|
-
* ① **每次读清单都对账**(`GET /list` 的正文就是"对账 + 读"):挂载、刷新、别处动过后的自动重读、
|
|
1112
|
-
* 客户端启动探测全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。代价 = 每次读多一次目录枚举
|
|
1113
|
-
* (本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
|
|
1114
|
-
* ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
|
|
1115
|
-
* (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
|
|
1116
|
-
* ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
|
|
1117
|
-
* `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
|
|
1118
|
-
* `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
|
|
1119
|
-
* 落在「未分组」)、`wasArchived: false`(文件侧没有归档事实;旧条目恢复走迁移支)、`title: ''`(详情有日志标题兜底)
|
|
1120
|
-
* ——这是重建的固有代价,不是可以"再读读看"的东西。
|
|
1121
|
-
* ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
|
|
1122
|
-
* 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
|
|
1123
|
-
* ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
|
|
1124
|
-
*
|
|
1125
|
-
* 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
|
|
1126
|
-
* 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
|
|
1127
|
-
* 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
|
|
1128
|
-
*/
|
|
1129
|
-
/** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`;
|
|
1130
|
-
* 原目录名还必须能安全地拼回磁盘路径——带分隔符的名字仍按"外人"跳过,不让一次对账读崩)。 */
|
|
1131
|
-
function isEntryId(name) {
|
|
1132
|
-
try {
|
|
1133
|
-
return isSinglePathSegment(parseEntryId(name).sessionDirName);
|
|
1134
|
-
} catch {
|
|
1135
|
-
return false;
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
/**
|
|
1139
|
-
* 从一个手工放进回收站的目录重建清单行。**任一件事实拿不到就整体放弃这一条**(回 `undefined`):
|
|
1140
|
-
* 半截行比"不列"更坏——恢复会照它把目录搬去一个错的地方(同 `detail.ts` 那条"半截正文"的口径)。
|
|
1141
|
-
*
|
|
1142
|
-
* `sessionId` 取日志 header 的 `id`(恢复之后宿主按日志 header 建索引,那才是会话身份);
|
|
1143
|
-
* header 缺席这个字段才回落到目录名里那段原目录名。
|
|
1144
|
-
*/
|
|
1145
|
-
async function buildEntry(deps, id) {
|
|
1146
|
-
const dir = trashDirOf(id, deps.home);
|
|
1147
|
-
let logName;
|
|
1148
|
-
try {
|
|
1149
|
-
logName = pickSessionLog(await deps.fs.listFiles(dir));
|
|
1150
|
-
} catch {
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
if (logName === void 0) return void 0;
|
|
1154
|
-
let raw;
|
|
1155
|
-
try {
|
|
1156
|
-
raw = await deps.fs.readFile(`${dir}/${logName}`);
|
|
1157
|
-
} catch {
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
let header;
|
|
1161
|
-
try {
|
|
1162
|
-
header = readSessionHeader(raw);
|
|
1163
|
-
} catch {
|
|
1164
|
-
return;
|
|
1165
|
-
}
|
|
1166
|
-
if (header === void 0) return void 0;
|
|
1167
|
-
let sizeBytes;
|
|
1168
|
-
try {
|
|
1169
|
-
sizeBytes = await deps.fs.sizeOfDir(dir);
|
|
1170
|
-
} catch {
|
|
1171
|
-
return;
|
|
1172
|
-
}
|
|
1173
|
-
const { deletedAt, sessionDirName } = parseEntryId(id);
|
|
1174
|
-
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
1175
1057
|
return {
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1058
|
+
async run() {
|
|
1059
|
+
await journal.run();
|
|
1060
|
+
if (tail.onDone === "leave") await presence.memberGone(tail.sessionId);
|
|
1061
|
+
},
|
|
1062
|
+
async undo() {
|
|
1063
|
+
const undo = await journal.undo();
|
|
1064
|
+
await announce(undo);
|
|
1065
|
+
return undo;
|
|
1066
|
+
}
|
|
1184
1067
|
};
|
|
1185
1068
|
}
|
|
1186
|
-
/** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
|
|
1187
|
-
async function reconcileTrash(deps) {
|
|
1188
|
-
const rows = await deps.manifest.list();
|
|
1189
|
-
const root = `${deps.home}/${TRASH_DIR_NAME}`;
|
|
1190
|
-
if (!await deps.fs.exists(root)) return rows;
|
|
1191
|
-
const dirIds = /* @__PURE__ */ new Set();
|
|
1192
|
-
for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
|
|
1193
|
-
const rowIds = new Set(rows.map((row) => row.id));
|
|
1194
|
-
const drops = rows.filter((row) => !dirIds.has(row.id));
|
|
1195
|
-
const rebuilds = [];
|
|
1196
|
-
for (const id of dirIds) {
|
|
1197
|
-
if (rowIds.has(id)) continue;
|
|
1198
|
-
const entry = await buildEntry(deps, id);
|
|
1199
|
-
if (entry !== void 0) rebuilds.push(entry);
|
|
1200
|
-
}
|
|
1201
|
-
if (drops.length === 0 && rebuilds.length === 0) return rows;
|
|
1202
|
-
for (const row of drops) await deps.manifest.remove(row.id);
|
|
1203
|
-
for (const entry of rebuilds) await deps.manifest.add(entry);
|
|
1204
|
-
return await deps.manifest.list();
|
|
1205
|
-
}
|
|
1206
1069
|
//#endregion
|
|
1207
|
-
//#region src/verbs/
|
|
1070
|
+
//#region src/verbs/move-group.ts
|
|
1208
1071
|
/**
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
* 为什么需要它:`delete-session.ts` 的 `moveOneIn`/`undoOneIn` 与 `restore-session.ts` 的 `restoreOne`
|
|
1212
|
-
* (含它的反向闭包)互为镜像,各自手写了三到四段嵌套 `try/catch`:迁移前全仓 `rollbackReason(` **22 处**、
|
|
1213
|
-
* `withRollbackFailures(` **8 处**。那不是"回滚值得自己的候选",而是**同一条正反动作对被写了两遍**。
|
|
1072
|
+
* 组级成员移动:组根先跑(拿到 `undefined`),跟随者按序跑并拿到根结果;任一失败就把**已经做成**的
|
|
1073
|
+
* 按相反顺序退回。
|
|
1214
1074
|
*
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
* ③ 失败收集:某一步退不干净时**不让原错误独走**(`details.rollbackFailures`,见 `verbs/errors.ts`);
|
|
1219
|
-
* ④ **非原子步骤**:`undoOnFailure` 让"这一步自己失败了"也跑它的反向动作(例如"取消归档"半做完了,
|
|
1220
|
-
* 得先把会话重新藏回去)。
|
|
1221
|
-
*
|
|
1222
|
-
* 边界:它只服务"成员在 `sessions/` 与 `session-trash/` 之间搬"这一件事。做成通用撤销框架就会吸进
|
|
1223
|
-
* 不属于它的知识(谁该通告、通告几次、哪些失败算成功)。
|
|
1075
|
+
* 这里拥有顺序、整组回滚与**组根结果的交回**:根先跑,跟随者按序拿到根结果;调用方直接拿
|
|
1076
|
+
* 返回的根结果,不按位置索引结果数组。跟随者自己的结果不向上交回——当前调用方不需要。
|
|
1077
|
+
* 单成员的步骤与反向动作仍归 `member-journal.ts`;这条归属的裁决见 ADR-0002。
|
|
1224
1078
|
*/
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
if (step.undo === void 0) continue;
|
|
1233
|
-
failures.push(...await step.undo());
|
|
1234
|
-
}
|
|
1235
|
-
return failures;
|
|
1236
|
-
};
|
|
1237
|
-
return {
|
|
1238
|
-
async run() {
|
|
1239
|
-
for (const step of steps) {
|
|
1240
|
-
try {
|
|
1241
|
-
await step.run();
|
|
1242
|
-
} catch (error) {
|
|
1243
|
-
const failures = await undoAll(step.undoOnFailure === true ? step : void 0);
|
|
1244
|
-
const base = journalFailure(step, error);
|
|
1245
|
-
throw failures.length > 0 ? withRollbackFailures(base, failures) : base;
|
|
1246
|
-
}
|
|
1247
|
-
done.push(step);
|
|
1248
|
-
}
|
|
1249
|
-
},
|
|
1250
|
-
undo() {
|
|
1251
|
-
return undoAll();
|
|
1252
|
-
}
|
|
1079
|
+
async function moveGroup({ root, followers, move }) {
|
|
1080
|
+
const journals = [];
|
|
1081
|
+
const run = async (member, rootResult) => {
|
|
1082
|
+
const prepared = await move(member, rootResult);
|
|
1083
|
+
await prepared.journal.run();
|
|
1084
|
+
journals.push(prepared.journal);
|
|
1085
|
+
return prepared.result;
|
|
1253
1086
|
};
|
|
1254
|
-
}
|
|
1255
|
-
/** 跑一步反向动作并把它自己的失败收成一条原因(不抛:这些失败由调用方汇总成 `rollbackFailures`)。 */
|
|
1256
|
-
async function attempt(label, run) {
|
|
1257
1087
|
try {
|
|
1258
|
-
await run();
|
|
1259
|
-
|
|
1088
|
+
const rootResult = await run(root, void 0);
|
|
1089
|
+
for (const follower of followers) await run(follower, rootResult);
|
|
1090
|
+
return rootResult;
|
|
1260
1091
|
} catch (error) {
|
|
1261
|
-
return
|
|
1092
|
+
return await rollbackJournals(journals, error);
|
|
1262
1093
|
}
|
|
1263
1094
|
}
|
|
1264
|
-
/**
|
|
1265
|
-
* **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
|
|
1266
|
-
* `details.rollbackFailures`,绝不让原错误独走(2026-10-19 `f2a7757`;那些失败句子的合并与措辞住
|
|
1267
|
-
* `src/verbs/errors.ts`,这里只是唯一的调用方)。删除与恢复两个按组走的动词共用这一处,
|
|
1268
|
-
* "相反顺序 + 失败合并"不再各写一遍。
|
|
1269
|
-
*/
|
|
1270
|
-
async function rollbackJournals(journals, error) {
|
|
1271
|
-
const failures = [];
|
|
1272
|
-
for (const journal of [...journals].reverse()) failures.push(...await journal.undo());
|
|
1273
|
-
if (failures.length > 0) throw withRollbackFailures(error, failures);
|
|
1274
|
-
throw error;
|
|
1275
|
-
}
|
|
1276
|
-
/**
|
|
1277
|
-
* 这一步的失败形状:已经是动词码(`verbError` 的输出)就原样带,否则折成 `sessiondelete/io`。
|
|
1278
|
-
* 原文用 `String(error)` 而不是 `.message`——那 22 处既有措辞就是这么拼的(`Error: boom` 也在里面),
|
|
1279
|
-
* 换一种拼法会让上屏原文跟着变。
|
|
1280
|
-
*/
|
|
1281
|
-
function journalFailure(step, error) {
|
|
1282
|
-
if (isVerbCodeError(error)) return error;
|
|
1283
|
-
return verbError("io", `${step.label}:${String(error)}`, error);
|
|
1284
|
-
}
|
|
1285
|
-
//#endregion
|
|
1286
|
-
//#region src/verbs/member-presence.ts
|
|
1287
|
-
function createMemberPresence(host) {
|
|
1288
|
-
return {
|
|
1289
|
-
async memberGone(sessionId, facts = {}) {
|
|
1290
|
-
if (facts.backInTrash === false) return;
|
|
1291
|
-
if (await host.evictLive(sessionId) !== "unknown") await host.announceRemoved(sessionId);
|
|
1292
|
-
},
|
|
1293
|
-
async memberBack(sessionId, facts) {
|
|
1294
|
-
if (!facts.diskBack || !facts.workspaceBack) return;
|
|
1295
|
-
await host.announceRestored(sessionId);
|
|
1296
|
-
}
|
|
1297
|
-
};
|
|
1298
|
-
}
|
|
1299
1095
|
//#endregion
|
|
1300
1096
|
//#region src/verbs/delete-session.ts
|
|
1301
1097
|
/**
|
|
@@ -1303,7 +1099,7 @@ function createMemberPresence(host) {
|
|
|
1303
1099
|
* 整排藏了 ⇒ 那些目录既看不见也继续占着字节(本机实测 41 条父留了 320 个目录 / 74.6 MB)。
|
|
1304
1100
|
* 所以这里把父在盘上的孩子一起搬进来,各自成目录、各自一行,清册用 `parentEntryId` 记它是谁的孩子。
|
|
1305
1101
|
*
|
|
1306
|
-
*
|
|
1102
|
+
* 三段,顺序承重(成员循环与整组回滚的实现住 `verbs/move-group.ts`):
|
|
1307
1103
|
* ① **先点组、先验活体**(一个字节都不写)。任一成员在跑就整组拒绝——不替用户终止正在跑的活,
|
|
1308
1104
|
* 也绝不允许"父已经搬进回收站、孩子因为在跑而搬不动"这种半套。
|
|
1309
1105
|
* ② **逐个搬**:每个成员是一份 `journal`(`verbs/member-journal.ts`),搬不动时 journal 自己把
|
|
@@ -1311,50 +1107,43 @@ function createMemberPresence(host) {
|
|
|
1311
1107
|
* ③ **组级回滚**:某一个成员搬不动时,把**已经搬进去的**那些按相反顺序退回原处(清册行跟着摘、
|
|
1312
1108
|
* 账本跟着挂),失败照抛——不留"半组在回收站、半组在原地"的世界。
|
|
1313
1109
|
*
|
|
1314
|
-
* interface 只有**一个 id
|
|
1110
|
+
* interface 只有**一个 id**:批量的性质不在这里(同批已带走集归 `verbs/batch-carried.ts`,
|
|
1111
|
+
* 组语义归 `trash/groups.ts`)。
|
|
1315
1112
|
* 结果带回这次真的搬走的整组(`movedSessionIds`)——它是动词交回的内部数据,不再靠回调借用动词的手。
|
|
1316
1113
|
*/
|
|
1317
1114
|
function deleteSession(deps) {
|
|
1318
|
-
const presence =
|
|
1115
|
+
const presence = deps.presence;
|
|
1319
1116
|
return async function deleteSession(sessionId) {
|
|
1320
1117
|
const read = await deps.host.subagentMembersOf([sessionId]);
|
|
1321
1118
|
const kids = read.available ? read.value[sessionId] ?? [] : [];
|
|
1322
1119
|
const parentProjectDir = await deps.host.projectDirOf(sessionId);
|
|
1323
1120
|
if (parentProjectDir === void 0) throw verbError("not-found", `宿主的持久化里没有这个会话:${sessionId}`);
|
|
1324
|
-
|
|
1325
|
-
sessionId,
|
|
1326
|
-
projectDir: parentProjectDir
|
|
1327
|
-
}, ...kids.map((k) => ({
|
|
1328
|
-
sessionId: k.sessionId,
|
|
1329
|
-
projectDir: k.projectDir
|
|
1330
|
-
}))];
|
|
1331
|
-
for (const member of candidates) if (await deps.host.isLive(member.sessionId)) throw verbError("live", `会话正在运行:${member.sessionId}`);
|
|
1121
|
+
await assertNoLive(deps.host, [sessionId, ...kids.map((kid) => kid.sessionId)]);
|
|
1332
1122
|
const parentDir = sessionDirOf(parentProjectDir, sessionId, deps.home);
|
|
1333
1123
|
if (!await deps.fs.exists(parentDir)) throw verbError("not-found", `会话目录不存在:${parentDir}`);
|
|
1334
|
-
const
|
|
1124
|
+
const parent = {
|
|
1335
1125
|
sessionId,
|
|
1336
1126
|
projectDir: parentProjectDir
|
|
1337
|
-
}
|
|
1127
|
+
};
|
|
1128
|
+
const members = [parent];
|
|
1338
1129
|
for (const kid of kids) if (await deps.fs.exists(sessionDirOf(kid.projectDir, kid.sessionId, deps.home))) members.push({
|
|
1339
1130
|
sessionId: kid.sessionId,
|
|
1340
1131
|
projectDir: kid.projectDir
|
|
1341
1132
|
});
|
|
1342
1133
|
const deletedAt = deps.now();
|
|
1343
|
-
const done = [];
|
|
1344
|
-
let parentEntry;
|
|
1345
|
-
try {
|
|
1346
|
-
for (const member of members) {
|
|
1347
|
-
const isParent = member.sessionId === sessionId;
|
|
1348
|
-
const move = await memberMove(deps, presence, member, deletedAt, isParent ? void 0 : parentEntry.id);
|
|
1349
|
-
if (isParent) parentEntry = move.entry;
|
|
1350
|
-
await move.journal.run();
|
|
1351
|
-
done.push(move.journal);
|
|
1352
|
-
}
|
|
1353
|
-
} catch (error) {
|
|
1354
|
-
await rollbackJournals(done, error);
|
|
1355
|
-
}
|
|
1356
1134
|
return {
|
|
1357
|
-
entry:
|
|
1135
|
+
entry: await moveGroup({
|
|
1136
|
+
root: parent,
|
|
1137
|
+
followers: members.slice(1),
|
|
1138
|
+
move: async (member, rootResult) => {
|
|
1139
|
+
const parentEntryId = rootResult?.id;
|
|
1140
|
+
const move = await memberMove(deps, presence, member, deletedAt, parentEntryId);
|
|
1141
|
+
return {
|
|
1142
|
+
journal: move.journal,
|
|
1143
|
+
result: move.entry
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
}),
|
|
1358
1147
|
movedSessionIds: members.map((member) => member.sessionId)
|
|
1359
1148
|
};
|
|
1360
1149
|
};
|
|
@@ -1369,8 +1158,7 @@ function deleteSession(deps) {
|
|
|
1369
1158
|
* 会留下"文件在、agent 已关"的半个世界。窗口与可选修法登记在 ADR-0008 的「边界」。
|
|
1370
1159
|
*
|
|
1371
1160
|
* 每一步的反向动作都是"把这件写退回原样";**清册行是最后一件写**,它失败时 journal 会把目录搬回去、
|
|
1372
|
-
*
|
|
1373
|
-
* (与旧实现"目录退不回就不摘行"同一条判据)。
|
|
1161
|
+
* 账本挂回去。清册行的反向动作带一道闸:**回收站目录还没搬回来就不摘那一行**,留着行至少元数据还在。
|
|
1374
1162
|
*/
|
|
1375
1163
|
async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
|
|
1376
1164
|
const { sessionId, projectDir } = member;
|
|
@@ -1391,86 +1179,707 @@ async function memberMove(deps, presence, member, deletedAt, parentEntryId) {
|
|
|
1391
1179
|
};
|
|
1392
1180
|
/** 目录真的搬进回收站了吗——决定那一步的反向动作有没有东西可退。 */
|
|
1393
1181
|
let moved = false;
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
}
|
|
1405
|
-
},
|
|
1406
|
-
...workspaceId === void 0 ? [] : [{
|
|
1407
|
-
label: "摘账本失败",
|
|
1408
|
-
run: async () => {
|
|
1409
|
-
await deps.host.detach(sessionId, workspaceId);
|
|
1182
|
+
return {
|
|
1183
|
+
entry,
|
|
1184
|
+
journal: withPresenceTail(presence, createJournal([
|
|
1185
|
+
{
|
|
1186
|
+
key: "read",
|
|
1187
|
+
label: "读取会话现场失败",
|
|
1188
|
+
run: async () => {
|
|
1189
|
+
entry.title = await deps.host.titleOf(sessionId);
|
|
1190
|
+
entry.sizeBytes = await deps.fs.sizeOfDir(sessionDir);
|
|
1191
|
+
}
|
|
1410
1192
|
},
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1193
|
+
...workspaceId === void 0 ? [] : [{
|
|
1194
|
+
key: "workspace",
|
|
1195
|
+
label: "摘账本失败",
|
|
1196
|
+
run: async () => {
|
|
1197
|
+
await deps.host.detach(sessionId, workspaceId);
|
|
1198
|
+
},
|
|
1199
|
+
undo: () => stepUndo("账本挂回失败", () => deps.host.attach(sessionId, workspaceId))
|
|
1200
|
+
}],
|
|
1201
|
+
{
|
|
1202
|
+
key: "dir",
|
|
1203
|
+
label: "移动会话目录失败",
|
|
1204
|
+
undoOnFailure: true,
|
|
1205
|
+
run: async () => {
|
|
1206
|
+
await deps.fs.ensureDir(`${deps.home}/${TRASH_DIR_NAME}`);
|
|
1207
|
+
await deps.fs.rename(sessionDir, trashDir);
|
|
1208
|
+
moved = true;
|
|
1209
|
+
try {
|
|
1210
|
+
await deps.ledger.add(entry);
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
throw verbError("io", `登记回收站条目失败:${String(error)}`, error);
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
undo: async () => {
|
|
1216
|
+
if (!moved) return {
|
|
1217
|
+
restored: true,
|
|
1218
|
+
failures: []
|
|
1219
|
+
};
|
|
1220
|
+
const directory = await stepUndo("目录搬回失败", () => deps.fs.rename(trashDir, sessionDir));
|
|
1221
|
+
const failures = [...directory.failures];
|
|
1222
|
+
if (directory.restored) failures.push(...await attempt("清册行摘除失败", () => deps.ledger.remove(entry.id)));
|
|
1223
|
+
return {
|
|
1224
|
+
restored: directory.restored,
|
|
1225
|
+
failures
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1416
1228
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1229
|
+
]), {
|
|
1230
|
+
sessionId,
|
|
1231
|
+
onDone: "leave",
|
|
1232
|
+
/** 组级回滚:先按相反顺序退步骤,再判「目录与账本两件写都回去了吗」。
|
|
1233
|
+
* 这一行已经收过 `api-session/removed`;不发「回来了」它就会在客户端消失到重连为止。
|
|
1234
|
+
* 「退没退成」由 journal 的逐步结果交出来,这里只读键,不自己累计布尔。 */
|
|
1235
|
+
onUndone: (undo) => ({
|
|
1236
|
+
kind: "return",
|
|
1237
|
+
diskBack: undo.restored("dir"),
|
|
1238
|
+
workspaceBack: workspaceId === void 0 ? true : undo.restored("workspace")
|
|
1239
|
+
})
|
|
1240
|
+
})
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region src/log/zstd-frames.ts
|
|
1245
|
+
const MAGIC = [
|
|
1246
|
+
40,
|
|
1247
|
+
181,
|
|
1248
|
+
47,
|
|
1249
|
+
253
|
|
1250
|
+
];
|
|
1251
|
+
/** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
|
|
1252
|
+
const AFTER_DESCRIPTOR = 5;
|
|
1253
|
+
const BLOCK_HEADER_BYTES = 3;
|
|
1254
|
+
const BLOCK_LAST = 1;
|
|
1255
|
+
const BLOCK_TYPE_SHIFT = 1;
|
|
1256
|
+
const BLOCK_SIZE_SHIFT = 3;
|
|
1257
|
+
const BLOCK_SIZE_MASK = 2097151;
|
|
1258
|
+
const BLOCK_RAW = 0;
|
|
1259
|
+
const BLOCK_RLE = 1;
|
|
1260
|
+
const BLOCK_COMRESSED = 2;
|
|
1261
|
+
/** RFC 8878 表 3 的帧头描述符位段:1-0 字典 ID、2 校验和、5 单段、7-6 FCS 尺寸。 */
|
|
1262
|
+
const DESC_DID_FLAG = 3;
|
|
1263
|
+
const DESC_SINGLE_SHIFT = 5;
|
|
1264
|
+
const DESC_FCS_SHIFT = 6;
|
|
1265
|
+
/** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
|
|
1266
|
+
const BLOCK_RESERVED = 3;
|
|
1267
|
+
/** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
|
|
1268
|
+
const DID_SIZES = [
|
|
1269
|
+
0,
|
|
1270
|
+
1,
|
|
1271
|
+
2,
|
|
1272
|
+
4
|
|
1273
|
+
];
|
|
1274
|
+
const FCS_SIZES_SINGLE = [
|
|
1275
|
+
1,
|
|
1276
|
+
2,
|
|
1277
|
+
4,
|
|
1278
|
+
8
|
|
1279
|
+
];
|
|
1280
|
+
const FCS_SIZES_MULTI = [
|
|
1281
|
+
0,
|
|
1282
|
+
2,
|
|
1283
|
+
4,
|
|
1284
|
+
8
|
|
1285
|
+
];
|
|
1286
|
+
/** 实测的帧尾长度(规范里就是 descriptor 校验位声明的 XXH32;收不收由文件头注的自证判据决定)。 */
|
|
1287
|
+
const FRAME_TAIL_BYTES = 4;
|
|
1288
|
+
function isMagicAt(raw, at) {
|
|
1289
|
+
return MAGIC.every((byte, i) => raw[at + i] === byte);
|
|
1290
|
+
}
|
|
1291
|
+
function readUint24LE(raw, at) {
|
|
1292
|
+
return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
|
|
1293
|
+
}
|
|
1294
|
+
/** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
|
|
1295
|
+
function withFrameTail(raw, at) {
|
|
1296
|
+
const afterTail = at + FRAME_TAIL_BYTES;
|
|
1297
|
+
if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
|
|
1298
|
+
return at;
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* 把 `raw` 切成帧区间。
|
|
1302
|
+
* @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
|
|
1303
|
+
* 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
|
|
1304
|
+
*/
|
|
1305
|
+
function frameRanges(raw) {
|
|
1306
|
+
const ranges = [];
|
|
1307
|
+
let at = 0;
|
|
1308
|
+
while (at < raw.length) {
|
|
1309
|
+
if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
|
|
1310
|
+
const descriptor = raw[at + 4];
|
|
1311
|
+
if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
|
|
1312
|
+
const didFlag = descriptor & DESC_DID_FLAG;
|
|
1313
|
+
const singleSegment = descriptor >> DESC_SINGLE_SHIFT & 1;
|
|
1314
|
+
const fcsFlag = descriptor >> DESC_FCS_SHIFT & 3;
|
|
1315
|
+
let cursor = at + AFTER_DESCRIPTOR;
|
|
1316
|
+
if (!singleSegment) cursor += 1;
|
|
1317
|
+
cursor += DID_SIZES[didFlag];
|
|
1318
|
+
cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
|
|
1319
|
+
for (;;) {
|
|
1320
|
+
const blockHeader = readUint24LE(raw, cursor);
|
|
1321
|
+
cursor += BLOCK_HEADER_BYTES;
|
|
1322
|
+
if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
|
|
1323
|
+
const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
|
|
1324
|
+
const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
|
|
1325
|
+
if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
|
|
1326
|
+
if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
|
|
1327
|
+
else if (blockType === BLOCK_RLE) cursor += 1;
|
|
1328
|
+
if (blockHeader & BLOCK_LAST) break;
|
|
1329
|
+
}
|
|
1330
|
+
cursor = withFrameTail(raw, cursor);
|
|
1331
|
+
if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
|
|
1332
|
+
ranges.push({
|
|
1333
|
+
start: at,
|
|
1334
|
+
end: cursor
|
|
1335
|
+
});
|
|
1336
|
+
at = cursor;
|
|
1337
|
+
}
|
|
1338
|
+
return ranges;
|
|
1339
|
+
}
|
|
1340
|
+
//#endregion
|
|
1341
|
+
//#region src/log/session-log.ts
|
|
1342
|
+
/**
|
|
1343
|
+
* 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
|
|
1344
|
+
* 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
|
|
1345
|
+
*
|
|
1346
|
+
* **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
|
|
1347
|
+
* 这一层只懂编解码,所以它能被单独验穿。
|
|
1348
|
+
*
|
|
1349
|
+
* 两条实测决定的口径:
|
|
1350
|
+
* ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
|
|
1351
|
+
* `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
|
|
1352
|
+
* 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
|
|
1353
|
+
* ② **一行的余量跨帧带走**:写作侧一条条 append,帧边界不保证落在行边界;逐帧 split('\n') 会静默少一条
|
|
1354
|
+
* 记录(半个头加半个身,两半都解不开 JSON)。带上余量后跨帧的多字节字符也解对了——拼接在字节层做,
|
|
1355
|
+
* toString('utf8') 只看整行。真被切断的尾巴(写一半)跳过并计数,绝不因为一行就抛整个读数。
|
|
1356
|
+
*/
|
|
1357
|
+
const NEWLINE = 10;
|
|
1358
|
+
/**
|
|
1359
|
+
* 宿主给每一代日志的词法(逐字取自 `@deepseek-ai/dsh-session-format@0.2.0-rc.2` 的
|
|
1360
|
+
* `CANONICAL_LOG_FILENAME`,剥掉压缩后缀那段由我们自己在正则里补上 `.zstd`):
|
|
1361
|
+
* 裸名 `session.jsonl` 是**第 0 代**,往后是 `session.vN`;`.v0`、前导零、大写、临时名都不是规范名。
|
|
1362
|
+
*/
|
|
1363
|
+
const CANONICAL_ZSTD_LOG = /^session(?:\.v([1-9][0-9]*))?\.jsonl\.zstd$/u;
|
|
1364
|
+
/** 这个名字是不是规范日志(任意一代)。附件引用扫描用它区分"老一代日志"与"读不懂的旁文件"。 */
|
|
1365
|
+
function isCanonicalSessionLogName(name) {
|
|
1366
|
+
return CANONICAL_ZSTD_LOG.test(name);
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* 同一个会话目录里可以并存多代日志;只读版本号最大的那代,因为新代包含旧代的记录且末尾更晚
|
|
1370
|
+
* ⇒ 读旧代 = 一份截了尾巴的快照;把几代拼起来 = 重复计数。
|
|
1371
|
+
*
|
|
1372
|
+
* 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
|
|
1373
|
+
*/
|
|
1374
|
+
function pickSessionLog(names) {
|
|
1375
|
+
let best;
|
|
1376
|
+
let bestVersion = -1;
|
|
1377
|
+
for (const name of names) {
|
|
1378
|
+
const match = CANONICAL_ZSTD_LOG.exec(name);
|
|
1379
|
+
if (match === null) continue;
|
|
1380
|
+
const version = match[1] === void 0 ? 0 : Number(match[1]);
|
|
1381
|
+
if (version > bestVersion) {
|
|
1382
|
+
bestVersion = version;
|
|
1383
|
+
best = name;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return best;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* 逐帧走一遍日志,每条记录交给回调。
|
|
1390
|
+
*
|
|
1391
|
+
* 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
|
|
1392
|
+
* 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
|
|
1393
|
+
*/
|
|
1394
|
+
function walkSessionLog(raw, onRecord) {
|
|
1395
|
+
const stats = {
|
|
1396
|
+
frames: 0,
|
|
1397
|
+
records: 0,
|
|
1398
|
+
unparsedLines: 0,
|
|
1399
|
+
truncatedTailBytes: 0
|
|
1400
|
+
};
|
|
1401
|
+
let stopped = false;
|
|
1402
|
+
const feed = (line) => {
|
|
1403
|
+
if (line === "") return;
|
|
1404
|
+
let parsed;
|
|
1405
|
+
try {
|
|
1406
|
+
parsed = JSON.parse(line);
|
|
1407
|
+
} catch {
|
|
1408
|
+
stats.unparsedLines += 1;
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
stats.records += 1;
|
|
1412
|
+
if (onRecord(parsed !== null && typeof parsed === "object" ? parsed : {}) === false) stopped = true;
|
|
1413
|
+
};
|
|
1414
|
+
/** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
|
|
1415
|
+
let carry = Buffer.alloc(0);
|
|
1416
|
+
for (const { start, end } of frameRanges(raw)) {
|
|
1417
|
+
if (stopped) break;
|
|
1418
|
+
stats.frames += 1;
|
|
1419
|
+
let plain;
|
|
1420
|
+
try {
|
|
1421
|
+
plain = zlib.zstdDecompressSync(raw.subarray(start, end));
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
|
|
1424
|
+
}
|
|
1425
|
+
const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
|
|
1426
|
+
const cut = merged.lastIndexOf(NEWLINE);
|
|
1427
|
+
if (cut === -1) {
|
|
1428
|
+
carry = merged;
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
carry = merged.subarray(cut + 1);
|
|
1432
|
+
for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) {
|
|
1433
|
+
feed(line);
|
|
1434
|
+
if (stopped) break;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
stats.truncatedTailBytes = carry.length;
|
|
1438
|
+
if (carry.length > 0 && !stopped) feed(carry.toString("utf8"));
|
|
1439
|
+
return stats;
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
|
|
1443
|
+
* AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
|
|
1444
|
+
* (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
|
|
1445
|
+
* 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
|
|
1446
|
+
*/
|
|
1447
|
+
function readSessionHeader(raw) {
|
|
1448
|
+
let header;
|
|
1449
|
+
walkSessionLog(raw, (record) => {
|
|
1450
|
+
if (record.type !== "session") return;
|
|
1451
|
+
header = record;
|
|
1452
|
+
return false;
|
|
1453
|
+
});
|
|
1454
|
+
return header;
|
|
1455
|
+
}
|
|
1456
|
+
//#endregion
|
|
1457
|
+
//#region src/trash/attachments.ts
|
|
1458
|
+
/**
|
|
1459
|
+
* 附件的**唯一域模块**:内容块提取、ID/路径闸、引用扫描、独占计划与执行。
|
|
1460
|
+
*
|
|
1461
|
+
* 两条红线:
|
|
1462
|
+
* ① 只删 `$DSH_HOME/attachments/v1/objects` 下的图片;普通文件、tmp、v2、cache 一律不碰;
|
|
1463
|
+
* 源文件($DSH_HOME 之外)没有进入路径拼接的入口。
|
|
1464
|
+
* ② 只要剩余引用源读不出来,就整体跳过(fail closed)——宁可留孤儿,不许删共享。
|
|
1465
|
+
* 引用源"读不出来"包括:目录/文件读失败、目录非空却没有规范 zstd 日志(明文/陌生布局)、
|
|
1466
|
+
* JSON 超出扫描上限、日志有坏行或尾巴被截断。
|
|
1467
|
+
*/
|
|
1468
|
+
const ATTACHMENT_ID = /^sha256:[0-9a-f]{64}$/u;
|
|
1469
|
+
function isAttachmentId(value) {
|
|
1470
|
+
return typeof value === "string" && ATTACHMENT_ID.test(value);
|
|
1471
|
+
}
|
|
1472
|
+
function isRecord(value) {
|
|
1473
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1474
|
+
}
|
|
1475
|
+
function numberOf(value) {
|
|
1476
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
1477
|
+
}
|
|
1478
|
+
/** `statFile` / `readFile` / `sizeOfDir` 里只有 ENOENT 能当"不在";权限、盘挂、I/O 错误都必须露成读不出。 */
|
|
1479
|
+
function isMissing(error) {
|
|
1480
|
+
return error?.code === "ENOENT";
|
|
1481
|
+
}
|
|
1482
|
+
/** 合法附件 ID → 宿主附件库里的绝对路径。`name` 不参与拼接。 */
|
|
1483
|
+
function attachmentPathOf(home, kind, id) {
|
|
1484
|
+
if (!isAttachmentId(id)) throw new Error(`附件 id 形状不对:${id}`);
|
|
1485
|
+
const hex = id.slice(7);
|
|
1486
|
+
const base = `${home}/attachments/v1/${kind === "image" ? "objects" : "file-objects"}`;
|
|
1487
|
+
const file = `${base}/${hex.slice(0, 2)}/${hex}`;
|
|
1488
|
+
const rel = path.relative(path.resolve(base), path.resolve(file));
|
|
1489
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) throw new Error(`附件路径越界:${id}`);
|
|
1490
|
+
return file;
|
|
1491
|
+
}
|
|
1492
|
+
/** 一条消息的 `content[]` 里的附件块;畸形 id / 非附件段一律跳过。 */
|
|
1493
|
+
function attachmentsInContent(content) {
|
|
1494
|
+
if (!Array.isArray(content)) return [];
|
|
1495
|
+
const out = [];
|
|
1496
|
+
for (const part of content) {
|
|
1497
|
+
if (!isRecord(part)) continue;
|
|
1498
|
+
const type = part.type;
|
|
1499
|
+
const attachment = part.attachment;
|
|
1500
|
+
if (type !== "image" && type !== "file" || !isRecord(attachment)) continue;
|
|
1501
|
+
const id = attachment.attachmentId;
|
|
1502
|
+
if (!isAttachmentId(id)) continue;
|
|
1503
|
+
if (type === "image") out.push({
|
|
1504
|
+
kind: "image",
|
|
1505
|
+
id,
|
|
1506
|
+
...typeof attachment.mediaType === "string" ? { mediaType: attachment.mediaType } : {},
|
|
1507
|
+
bytes: numberOf(attachment.bytes),
|
|
1508
|
+
...numberOf(attachment.width) > 0 ? { width: numberOf(attachment.width) } : {},
|
|
1509
|
+
...numberOf(attachment.height) > 0 ? { height: numberOf(attachment.height) } : {},
|
|
1510
|
+
...typeof attachment.name === "string" ? { name: attachment.name } : {}
|
|
1511
|
+
});
|
|
1512
|
+
else out.push({
|
|
1513
|
+
kind: "file",
|
|
1514
|
+
id,
|
|
1515
|
+
name: typeof attachment.name === "string" ? attachment.name : "",
|
|
1516
|
+
bytes: numberOf(attachment.bytes)
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
return out;
|
|
1520
|
+
}
|
|
1521
|
+
function contentArraysOf(record) {
|
|
1522
|
+
const data = record.data;
|
|
1523
|
+
if (!isRecord(data)) return [];
|
|
1524
|
+
const out = [];
|
|
1525
|
+
if (Array.isArray(data.content)) out.push(data.content);
|
|
1526
|
+
if (isRecord(data.message) && Array.isArray(data.message.content)) out.push(data.message.content);
|
|
1527
|
+
return out;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* `walkSessionLog` 对坏行与截断尾巴是**跳过并计数**,不抛:引用扫描如果只看解析出来的记录,
|
|
1531
|
+
* 就会把"少读了一行"当成"那一行没有引用"。这里的 `.damaged` 有值 ⇒ 调用方必须按不可读处理。
|
|
1532
|
+
*/
|
|
1533
|
+
function scanImageIdsInLog(raw) {
|
|
1534
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1535
|
+
const stats = walkSessionLog(raw, (record) => {
|
|
1536
|
+
for (const content of contentArraysOf(record)) for (const ref of attachmentsInContent(content)) if (ref.kind === "image") ids.add(ref.id);
|
|
1537
|
+
});
|
|
1538
|
+
const damaged = stats.unparsedLines > 0 || stats.truncatedTailBytes > 0 ? `日志不完整(坏行 ${stats.unparsedLines} 条、截断尾巴 ${stats.truncatedTailBytes} 字节)` : void 0;
|
|
1539
|
+
return {
|
|
1540
|
+
ids,
|
|
1541
|
+
...damaged === void 0 ? {} : { damaged }
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
/** 宿主写日志时的临时名:`session.vN.jsonl.zstd.<hash>.tmp`(内容写完后才 rename 成规范名)。 */
|
|
1545
|
+
const TEMP_LOG_NAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?\.[0-9a-f]+\.tmp$/u;
|
|
1546
|
+
/** 规范日志之外允许并存的两类文件:更老一代的规范日志(新代包含旧代)与 0 字节临时日志(没有消息)。
|
|
1547
|
+
* 其余任何文件都可能是规范日志没覆盖到的引用(例如写入中的非空临时日志),一律按不可读处理。 */
|
|
1548
|
+
async function unreadableExtraFile(deps, dir, names) {
|
|
1549
|
+
for (const name of names) {
|
|
1550
|
+
if (isCanonicalSessionLogName(name)) continue;
|
|
1551
|
+
if (!TEMP_LOG_NAME.test(name)) return `${dir}: 规范日志旁还有无法确认的文件 ${name}`;
|
|
1552
|
+
if ((await deps.fs.statFile(`${dir}/${name}`)).size > 0) return `${dir}: 规范日志旁还有非空临时日志 ${name}`;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/** 一个会话目录里的图片引用;`unreadable` 有值 = 这一组不可知(调用方按"跳过它"处理)。
|
|
1556
|
+
*
|
|
1557
|
+
* **没有规范 zstd 日志 ≠ 没有引用**:明文日志、别的版本/布局、以及子目录都可能藏着引用。
|
|
1558
|
+
* **有规范日志但目录里还有子目录也不够**:日志只覆盖本目录,子目录里的引用读它看不见——
|
|
1559
|
+
* 同样按不可读处理(宁可留下对象等维护卡兜底,不冒"没引用")。
|
|
1560
|
+
* **规范日志旁的文件也要过关**:老一代规范日志可以并存(新代包含旧代);0 字节临时日志是
|
|
1561
|
+
* 写盘未完成的空产物,也没有引用。非空临时日志、明文日志或其他任何文件仍按不可读处理(fail closed)。
|
|
1562
|
+
*/
|
|
1563
|
+
async function imageIdsOfDir(deps, dir) {
|
|
1564
|
+
if (!await deps.fs.exists(dir)) return { ids: /* @__PURE__ */ new Set() };
|
|
1565
|
+
let names;
|
|
1566
|
+
try {
|
|
1567
|
+
names = await deps.fs.listFiles(dir);
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
return {
|
|
1570
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1571
|
+
unreadable: String(error)
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
const log = pickSessionLog(names);
|
|
1575
|
+
if (log === void 0) {
|
|
1576
|
+
if (names.length > 0 && names.every((name) => TEMP_LOG_NAME.test(name))) {
|
|
1577
|
+
let subdirs;
|
|
1578
|
+
try {
|
|
1579
|
+
subdirs = await deps.fs.listDirs(dir);
|
|
1580
|
+
if (subdirs.length === 0) {
|
|
1581
|
+
let allEmpty = true;
|
|
1582
|
+
for (const name of names) if ((await deps.fs.statFile(`${dir}/${name}`)).size > 0) {
|
|
1583
|
+
allEmpty = false;
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
if (allEmpty) return { ids: /* @__PURE__ */ new Set() };
|
|
1429
1587
|
}
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
return {
|
|
1590
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1591
|
+
unreadable: String(error)
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (names.length > 0) return {
|
|
1596
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1597
|
+
unreadable: `${dir}: 没有可读的会话日志`
|
|
1598
|
+
};
|
|
1599
|
+
let subdirs;
|
|
1600
|
+
try {
|
|
1601
|
+
subdirs = await deps.fs.listDirs(dir);
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
return {
|
|
1604
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1605
|
+
unreadable: String(error)
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
if (subdirs.length > 0) return {
|
|
1609
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1610
|
+
unreadable: `${dir}: 没有可读的会话日志`
|
|
1611
|
+
};
|
|
1612
|
+
return { ids: /* @__PURE__ */ new Set() };
|
|
1613
|
+
}
|
|
1614
|
+
try {
|
|
1615
|
+
const extra = await unreadableExtraFile(deps, dir, names);
|
|
1616
|
+
if (extra !== void 0) return {
|
|
1617
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1618
|
+
unreadable: extra
|
|
1619
|
+
};
|
|
1620
|
+
if ((await deps.fs.listDirs(dir)).length > 0) return {
|
|
1621
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1622
|
+
unreadable: `${dir}: 目录里还有子目录,规范日志覆盖不到它们`
|
|
1623
|
+
};
|
|
1624
|
+
const scan = scanImageIdsInLog(await deps.fs.readFile(`${dir}/${log}`));
|
|
1625
|
+
if (scan.damaged !== void 0) return {
|
|
1626
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1627
|
+
unreadable: `${dir}/${log}: ${scan.damaged}`
|
|
1628
|
+
};
|
|
1629
|
+
return { ids: scan.ids };
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
return {
|
|
1632
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1633
|
+
unreadable: String(error)
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
/** 单个 JSON 引用源的扫描上限;超过就记成不可读(fail closed),**不许当作"没有引用"跳过**。 */
|
|
1638
|
+
const MAX_JSON_REF_BYTES = 10485760;
|
|
1639
|
+
/** 读全部剩余引用源;任何一处读不出都记进 `unreadable`。 */
|
|
1640
|
+
async function collectRemainingImageRefs(deps, input) {
|
|
1641
|
+
const exclude = new Set(input.excludeDirs);
|
|
1642
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1643
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1644
|
+
const unreadable = [];
|
|
1645
|
+
const visit = async (dir) => {
|
|
1646
|
+
if (exclude.has(dir) || visited.has(dir)) return;
|
|
1647
|
+
visited.add(dir);
|
|
1648
|
+
const read = await imageIdsOfDir(deps, dir);
|
|
1649
|
+
if (read.unreadable !== void 0) unreadable.push(read.unreadable);
|
|
1650
|
+
for (const id of read.ids) ids.add(id);
|
|
1651
|
+
};
|
|
1652
|
+
if (await deps.fs.exists(input.sessionRoot)) for (const project of await deps.fs.listDirs(input.sessionRoot)) {
|
|
1653
|
+
const projectDir = `${input.sessionRoot}/${project}`;
|
|
1654
|
+
if (exclude.has(projectDir)) continue;
|
|
1655
|
+
for (const session of await deps.fs.listDirs(projectDir)) await visit(`${projectDir}/${session}`);
|
|
1656
|
+
}
|
|
1657
|
+
const trashRoot = `${deps.home}/${TRASH_DIR_NAME}`;
|
|
1658
|
+
if (await deps.fs.exists(trashRoot)) for (const name of await deps.fs.listDirs(trashRoot)) await visit(`${trashRoot}/${name}`);
|
|
1659
|
+
const scanJson = async (root) => {
|
|
1660
|
+
if (!await deps.fs.exists(root)) return;
|
|
1661
|
+
const stack = [root];
|
|
1662
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1663
|
+
while (stack.length > 0) {
|
|
1664
|
+
const dir = stack.pop();
|
|
1665
|
+
try {
|
|
1666
|
+
const real = await deps.fs.realpath(dir);
|
|
1667
|
+
if (seen.has(real)) continue;
|
|
1668
|
+
seen.add(real);
|
|
1669
|
+
for (const name of await deps.fs.listDirs(dir)) stack.push(`${dir}/${name}`);
|
|
1670
|
+
for (const name of await deps.fs.listFiles(dir)) {
|
|
1671
|
+
if (!name.endsWith(".json")) continue;
|
|
1672
|
+
const file = `${dir}/${name}`;
|
|
1673
|
+
const stat = await deps.fs.statFile(file);
|
|
1674
|
+
if (stat.size > MAX_JSON_REF_BYTES) {
|
|
1675
|
+
unreadable.push(`${file}: JSON 引用源 ${stat.size} 字节超过扫描上限 ${MAX_JSON_REF_BYTES} 字节,未扫描`);
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
const text = (await deps.fs.readFile(file)).toString("utf8");
|
|
1679
|
+
for (const match of text.matchAll(/sha256:[0-9a-f]{64}/gu)) ids.add(match[0]);
|
|
1680
|
+
}
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
unreadable.push(`${dir}: ${String(error)}`);
|
|
1439
1683
|
}
|
|
1440
1684
|
}
|
|
1441
|
-
|
|
1685
|
+
};
|
|
1686
|
+
await scanJson(`${deps.home}/storages`);
|
|
1687
|
+
await scanJson(`${deps.home}/cache`);
|
|
1442
1688
|
return {
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1689
|
+
ids,
|
|
1690
|
+
unreadable
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* 独占计划:候选 = 本次被删会话日志里的图片 id;剩余引用 = 活动会话 + 全部未删 `session-trash`
|
|
1695
|
+
* 目录 + storages/cache。
|
|
1696
|
+
* 剩余引用源读不出 ⇒ 整体跳过;候选日志读不出 / 附件对象 stat 失败 ⇒ 只跳过那一部分,其余照常。
|
|
1697
|
+
*/
|
|
1698
|
+
async function planImageAttachmentRemoval(deps, removedDirs) {
|
|
1699
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1700
|
+
let unreadableCandidates = 0;
|
|
1701
|
+
for (const dir of removedDirs) {
|
|
1702
|
+
const read = await imageIdsOfDir(deps, dir);
|
|
1703
|
+
if (read.unreadable !== void 0) {
|
|
1704
|
+
unreadableCandidates += 1;
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
for (const id of read.ids) candidates.add(id);
|
|
1708
|
+
}
|
|
1709
|
+
const skipReasons = [];
|
|
1710
|
+
if (unreadableCandidates > 0) skipReasons.push(`${unreadableCandidates} 个被删会话日志读不出`);
|
|
1711
|
+
if (candidates.size === 0) return {
|
|
1712
|
+
entries: [],
|
|
1713
|
+
bytes: 0,
|
|
1714
|
+
...skipReasons.length > 0 ? { skipped: skipReasons.join(";") } : {}
|
|
1715
|
+
};
|
|
1716
|
+
let remaining;
|
|
1717
|
+
try {
|
|
1718
|
+
remaining = await collectRemainingImageRefs(deps, {
|
|
1719
|
+
sessionRoot: `${deps.home}/sessions`,
|
|
1720
|
+
excludeDirs: removedDirs
|
|
1721
|
+
});
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
return {
|
|
1724
|
+
entries: [],
|
|
1725
|
+
bytes: 0,
|
|
1726
|
+
skipped: `读不到剩余引用源,无法证明独占:${String(error)}`
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
if (remaining.unreadable.length > 0) return {
|
|
1730
|
+
entries: [],
|
|
1731
|
+
bytes: 0,
|
|
1732
|
+
skipped: `读不到剩余引用源,无法证明独占:${remaining.unreadable[0]}`
|
|
1733
|
+
};
|
|
1734
|
+
const entries = [];
|
|
1735
|
+
let bytes = 0;
|
|
1736
|
+
let unreadableObjects = 0;
|
|
1737
|
+
for (const id of candidates) {
|
|
1738
|
+
if (remaining.ids.has(id)) continue;
|
|
1739
|
+
const path = attachmentPathOf(deps.home, "image", id);
|
|
1740
|
+
try {
|
|
1741
|
+
const stat = await deps.fs.statFile(path);
|
|
1742
|
+
entries.push({
|
|
1743
|
+
id,
|
|
1744
|
+
path,
|
|
1745
|
+
bytes: stat.size
|
|
1746
|
+
});
|
|
1747
|
+
bytes += stat.size;
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
if (!isMissing(error)) unreadableObjects += 1;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
if (unreadableObjects > 0) skipReasons.push(`${unreadableObjects} 个附件对象读不出`);
|
|
1753
|
+
return {
|
|
1754
|
+
entries,
|
|
1755
|
+
bytes,
|
|
1756
|
+
...skipReasons.length > 0 ? { skipped: skipReasons.join(";") } : {}
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* 逐文件删;ENOENT 由端口的 `force` 吞掉;其他错误计数并继续。
|
|
1761
|
+
*
|
|
1762
|
+
* 删除动作只认「`home` + `id` 自己推导出来的那一条路径」:计划是数据,手搭的/被改过的条目
|
|
1763
|
+
* 只要路径与推导不一致就拒绝(计数为失败,不删),源文件因此没有删除入口。
|
|
1764
|
+
*/
|
|
1765
|
+
async function executeImageAttachmentRemoval(deps, plan) {
|
|
1766
|
+
let removed = 0;
|
|
1767
|
+
let freedBytes = 0;
|
|
1768
|
+
let failed = 0;
|
|
1769
|
+
for (const entry of plan.entries) {
|
|
1770
|
+
let derived;
|
|
1771
|
+
try {
|
|
1772
|
+
derived = attachmentPathOf(deps.home, "image", entry.id);
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
failed += 1;
|
|
1775
|
+
console.warn(`附件清理拒绝:计划条目的 id 形状不对:${entry.id}`, error);
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
if (derived !== entry.path) {
|
|
1779
|
+
failed += 1;
|
|
1780
|
+
console.warn(`附件清理拒绝:计划路径与 id 推导不一致:${entry.id}`, entry.path);
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1783
|
+
try {
|
|
1784
|
+
await deps.fs.removeFile(derived);
|
|
1785
|
+
removed += 1;
|
|
1786
|
+
freedBytes += entry.bytes;
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
failed += 1;
|
|
1789
|
+
console.warn(`附件清理失败:${entry.id}`, error);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return {
|
|
1793
|
+
removed,
|
|
1794
|
+
freedBytes,
|
|
1795
|
+
failed
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
/** 真实宿主的图片库布局是 `objects/<2 hex>/<64 hex>`;这两条形状之外的名字一律不是候选。 */
|
|
1799
|
+
const OBJECT_PREFIX = /^[0-9a-f]{2}$/u;
|
|
1800
|
+
const OBJECT_NAME = /^[0-9a-f]{64}$/u;
|
|
1801
|
+
/**
|
|
1802
|
+
* 扫「当前没有任何引用源引用」的图片对象。
|
|
1803
|
+
*
|
|
1804
|
+
* 引用侧与删除计划共用同一条判据:活动会话 + 全部 `session-trash` 目录 + `storages/cache` JSON。
|
|
1805
|
+
* 任何一处读不出 ⇒ 整份报告跳过(fail closed),绝不把"读不出"折成"没有引用"。
|
|
1806
|
+
* 候选侧只认 `attachments/v1/objects` 下 `<2 hex>/<64 hex>` 这一种真实形状:`file-objects` /
|
|
1807
|
+
* `tmp` / `v2` / `cache` 与非规范名字一律不进候选——清理只处理 `.dsh` 内的图片副本。
|
|
1808
|
+
*
|
|
1809
|
+
* 对象列表或 stat 读不出同样整份跳过:报告宁可失败,也不给一份"少算了一些"的清理计划。
|
|
1810
|
+
*/
|
|
1811
|
+
async function scanOrphanImageAttachments(deps) {
|
|
1812
|
+
let remaining;
|
|
1813
|
+
try {
|
|
1814
|
+
remaining = await collectRemainingImageRefs(deps, {
|
|
1815
|
+
sessionRoot: `${deps.home}/sessions`,
|
|
1816
|
+
excludeDirs: []
|
|
1817
|
+
});
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
return {
|
|
1820
|
+
entries: [],
|
|
1821
|
+
bytes: 0,
|
|
1822
|
+
skipped: `读不到引用源,无法判定孤儿附件:${String(error)}`
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
if (remaining.unreadable.length > 0) return {
|
|
1826
|
+
entries: [],
|
|
1827
|
+
bytes: 0,
|
|
1828
|
+
skipped: `读不到引用源,无法判定孤儿附件:${remaining.unreadable[0]}`
|
|
1829
|
+
};
|
|
1830
|
+
const objectsRoot = `${deps.home}/attachments/v1/objects`;
|
|
1831
|
+
const entries = [];
|
|
1832
|
+
let bytes = 0;
|
|
1833
|
+
try {
|
|
1834
|
+
if (!await deps.fs.exists(objectsRoot)) return {
|
|
1835
|
+
entries: [],
|
|
1836
|
+
bytes: 0
|
|
1837
|
+
};
|
|
1838
|
+
for (const prefix of await deps.fs.listDirs(objectsRoot)) {
|
|
1839
|
+
if (!OBJECT_PREFIX.test(prefix)) continue;
|
|
1840
|
+
for (const name of await deps.fs.listFiles(`${objectsRoot}/${prefix}`)) {
|
|
1841
|
+
if (!OBJECT_NAME.test(name) || !name.startsWith(prefix)) continue;
|
|
1842
|
+
const id = `sha256:${name}`;
|
|
1843
|
+
if (remaining.ids.has(id)) continue;
|
|
1844
|
+
const objectPath = attachmentPathOf(deps.home, "image", id);
|
|
1845
|
+
const stat = await deps.fs.statFile(objectPath);
|
|
1846
|
+
entries.push({
|
|
1847
|
+
id,
|
|
1848
|
+
path: objectPath,
|
|
1849
|
+
bytes: stat.size
|
|
1456
1850
|
});
|
|
1457
|
-
|
|
1851
|
+
bytes += stat.size;
|
|
1458
1852
|
}
|
|
1459
1853
|
}
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
return {
|
|
1856
|
+
entries: [],
|
|
1857
|
+
bytes: 0,
|
|
1858
|
+
skipped: `读不到附件对象列表,无法判定孤儿:${String(error)}`
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
return {
|
|
1862
|
+
entries,
|
|
1863
|
+
bytes
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
/** 清掉扫描那一刻无引用的图片对象;扫描跳过 ⇒ 零删,并原样带回跳过的原因。 */
|
|
1867
|
+
async function cleanOrphanImageAttachments(deps) {
|
|
1868
|
+
const plan = await scanOrphanImageAttachments(deps);
|
|
1869
|
+
if (plan.skipped !== void 0) return {
|
|
1870
|
+
removed: 0,
|
|
1871
|
+
freedBytes: 0,
|
|
1872
|
+
failed: 0,
|
|
1873
|
+
skipped: plan.skipped
|
|
1460
1874
|
};
|
|
1875
|
+
return executeImageAttachmentRemoval(deps, plan);
|
|
1461
1876
|
}
|
|
1462
1877
|
//#endregion
|
|
1463
1878
|
//#region src/verbs/permanent-removal.ts
|
|
1464
1879
|
/**
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1467
|
-
* 写不写由执行器决定,但活体准入在这里就做掉,调用方拿到计划才允许动盘。
|
|
1880
|
+
* 永久删除的测量、计划与执行:先记录目录/字节/账本归属,再按计划动盘。
|
|
1881
|
+
* 计划函数只读;活体准入在计划阶段、任何写之前完成。
|
|
1468
1882
|
*/
|
|
1469
|
-
/** `exists` 与 `sizeOfDir` 之间目录消失(ENOENT)是唯一一种当"不在"的失败:盘没挂、权限、I/O 错误
|
|
1470
|
-
* 都不许伪装成"目录没了",那会让 `/list` 静默少算释放量、让删除计划少一条该删的目录。 */
|
|
1471
|
-
function isMissing(error) {
|
|
1472
|
-
return error?.code === "ENOENT";
|
|
1473
|
-
}
|
|
1474
1883
|
/** `exists → sizeOfDir` 的唯一一条度量口径(成员目录与回收站目录共用):先问在不在,再量字节;
|
|
1475
1884
|
* ENOENT 是唯一一种当"不在"的失败,别的错误照旧响亮。别再在调用点各写一遍这段 try/catch。
|
|
1476
1885
|
*
|
|
@@ -1593,36 +2002,36 @@ async function planVisibleTrash(deps, rows, groups) {
|
|
|
1593
2002
|
};
|
|
1594
2003
|
}));
|
|
1595
2004
|
}
|
|
1596
|
-
/** 活体准入:逐个问,任一在跑就抛 `live`。**计划函数就是执行前的闸**——调用方拿到计划才允许写,
|
|
1597
|
-
* 所以这里必须发生在任何写之前(`admission:'none'` 的调用方自己承担这个风险,不进来)。 */
|
|
1598
|
-
async function assertNoLive(deps, sessionIds) {
|
|
1599
|
-
for (const sessionId of sessionIds) if (await deps.host.isLive(sessionId)) throw verbError("live", `会话正在运行:${sessionId}`);
|
|
1600
|
-
}
|
|
1601
2005
|
function totalBytes(items) {
|
|
1602
2006
|
return items.reduce((total, item) => total + item.bytes, 0);
|
|
1603
2007
|
}
|
|
1604
2008
|
/**
|
|
1605
2009
|
* purge 的删除计划:`targets` 由调用方按组语义(`trash/groups.ts`)选好,这里只量、只筛、只查活体。
|
|
2010
|
+
* **计划函数就是执行前的闸**——调用方拿到计划才允许写,所以准入必须发生在任何写之前。
|
|
1606
2011
|
*
|
|
1607
2012
|
* - `trash` 按 targets 原序逐个实测(目录缺席的行 `present:false` / 0 字节,但清册行仍要摘)。
|
|
1608
2013
|
* - 盘上后代经 `selectStrandedMembers` 选出(一次问全部 targets、排除全部清册 id、bare id 全局只计划一次)。
|
|
1609
2014
|
* - `members` 只收盘上还有目录的成员(目录缺席没有可删的字节);`unarchive` = targets + 这些成员:
|
|
1610
2015
|
* 只摘这一次真要碰的 id。
|
|
1611
|
-
* - `
|
|
1612
|
-
*
|
|
2016
|
+
* - `attachments` 由被删目录(targets + 这些成员)里的图片引用与全部剩余引用源算独占;共享的保留、
|
|
2017
|
+
* 剩余引用源读不出就整体跳过(判据住 `trash/attachments.ts`,这里只接线)。
|
|
2018
|
+
* - 活体准入在量目录与任何写之前逐个查 targets 与**全部枚举到的**盘上后代;任一在跑就整条拒绝、
|
|
2019
|
+
* 零副作用。没有跳过它的开关——计划就是执行前的闸(ADR-0006)。
|
|
1613
2020
|
* - `sessionQuery` 缺席时端口回 `{ available: false }` ⇒ members 为空、不抛(盘上残留留给 `GET /orphans` 兜底)。
|
|
1614
2021
|
*/
|
|
1615
|
-
async function planTrashRemoval(deps, rows, targets
|
|
2022
|
+
async function planTrashRemoval(deps, rows, targets) {
|
|
1616
2023
|
const candidates = (await selectStrandedMembers(deps.host, rows, targets.map((entry) => entry.sessionId))).flat();
|
|
1617
|
-
|
|
2024
|
+
await assertNoLive(deps.host, [...targets.map((entry) => entry.sessionId), ...candidates.map((member) => member.sessionId)]);
|
|
1618
2025
|
const trash = await Promise.all(targets.map((entry) => measureTrashRow(deps, entry)));
|
|
1619
2026
|
const members = await planMemberRemovals(deps, candidates);
|
|
2027
|
+
const attachments = await planImageAttachmentRemoval(deps, [...trash.map((row) => row.dir), ...members.map((member) => member.dir)]);
|
|
1620
2028
|
return {
|
|
1621
2029
|
kind: "trash",
|
|
1622
2030
|
trash,
|
|
1623
2031
|
members,
|
|
1624
2032
|
unarchive: [...targets.map((entry) => entry.sessionId), ...members.map((member) => member.sessionId)],
|
|
1625
|
-
bytes: totalBytes(trash) + totalBytes(members)
|
|
2033
|
+
bytes: totalBytes(trash) + totalBytes(members),
|
|
2034
|
+
attachments
|
|
1626
2035
|
};
|
|
1627
2036
|
}
|
|
1628
2037
|
/**
|
|
@@ -1632,45 +2041,54 @@ async function planTrashRemoval(deps, rows, targets, options) {
|
|
|
1632
2041
|
* - `unarchive` 覆盖**全部传入成员**(含目录缺席):orphan-clean 在删目录之前整批摘归档集,
|
|
1633
2042
|
* 目录缺席的 id 也可能还挂在归档集里;重试时不会再长出一条幽灵 id。
|
|
1634
2043
|
* - live 准入覆盖**全部传入成员**(含目录缺席):它们都在这一组的可见面里,任一在跑就整组拒绝。
|
|
2044
|
+
* - `attachments` 的剩余引用源由附件域自己扫(活动会话 + 全部 session-trash + storages/cache),
|
|
2045
|
+
* 共享图片因此保留(判据住 `trash/attachments.ts`)。
|
|
1635
2046
|
* - 回执由 `kind:'orphan'` 派生:删目录阶段失败时执行器要交回 `{removed, freedBytes, failedSessionId}`。
|
|
1636
2047
|
*/
|
|
1637
2048
|
async function planOrphanRemoval(deps, members) {
|
|
1638
|
-
await assertNoLive(deps, members.map((member) => member.sessionId));
|
|
2049
|
+
await assertNoLive(deps.host, members.map((member) => member.sessionId));
|
|
1639
2050
|
const present = await planMemberRemovals(deps, members);
|
|
2051
|
+
const attachments = await planImageAttachmentRemoval(deps, present.map((member) => member.dir));
|
|
1640
2052
|
return {
|
|
1641
2053
|
kind: "orphan",
|
|
1642
2054
|
trash: [],
|
|
1643
2055
|
members: present,
|
|
1644
2056
|
unarchive: members.map((member) => member.sessionId),
|
|
1645
|
-
bytes: totalBytes(present)
|
|
2057
|
+
bytes: totalBytes(present),
|
|
2058
|
+
attachments
|
|
1646
2059
|
};
|
|
1647
2060
|
}
|
|
1648
2061
|
/**
|
|
1649
|
-
*
|
|
2062
|
+
* 按计划执行一次永久删除:只写、不再量、不再判活体(计划就是执行前的闸)。回执的字节念计划
|
|
2063
|
+
* (`plan.bytes`),执行期不再累加一遍。
|
|
2064
|
+
*
|
|
2065
|
+
* 顺序:整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
|
|
2066
|
+
* (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)→ 逐条附件对象。
|
|
2067
|
+
* 目录删掉就没有"退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
|
|
1650
2068
|
*
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
1653
|
-
* "退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
|
|
2069
|
+
* 附件清理排在**所有会话目录删完之后**:对象只在会话真的没了之后才动;单文件失败只进
|
|
2070
|
+
* `attachments.failed`,不影响(也不回滚)会话删除的回执。
|
|
1654
2071
|
*
|
|
1655
|
-
*
|
|
2072
|
+
* 失败形状:
|
|
1656
2073
|
* - `kind:'orphan'`(orphan-clean)⇒ `io` + `{removed, freedBytes, failedSessionId}`,不伪装成整组没动;
|
|
1657
|
-
* - `kind:'trash'`(purge)⇒ 同一个标签、不带 details
|
|
1658
|
-
* - orphan-clean 的 unarchive / detach
|
|
2074
|
+
* - `kind:'trash'`(purge)⇒ 同一个标签、不带 details;
|
|
2075
|
+
* - orphan-clean 的 unarchive / detach 原样抛出。
|
|
1659
2076
|
*
|
|
1660
2077
|
* 通告只经 `member-presence`(摘活体三态 + `api-session/removed`),不在这里另写一份。
|
|
1661
2078
|
*/
|
|
1662
2079
|
async function executeRemoval(deps, plan) {
|
|
1663
|
-
const presence =
|
|
2080
|
+
const presence = deps.presence;
|
|
1664
2081
|
let removed = 0;
|
|
1665
|
-
|
|
2082
|
+
/** 删到这一刻已经腾出的**进度**:只喂 `orphan` 的部分回执;成功回执念计划的合计(`plan.bytes`)。 */
|
|
2083
|
+
let freedSoFar = 0;
|
|
1666
2084
|
/** 删除途中失败的唯一出口:标签沿用旧措辞;部分回执只属于 `orphan`(由计划的 `kind` 派生)。
|
|
1667
2085
|
* 已经是本仓动词码的错误原样抛(与 `member-journal.ts` 的 `journalFailure` 同一条判据)——
|
|
1668
2086
|
* `not-found` / `live` 之类不许在这里被折成 `io`。 */
|
|
1669
2087
|
const fail = (label, error, failedSessionId) => {
|
|
1670
|
-
if (
|
|
2088
|
+
if (isSessionDeleteCodeError(error)) throw error;
|
|
1671
2089
|
const details = plan.kind === "orphan" ? {
|
|
1672
2090
|
removed,
|
|
1673
|
-
freedBytes,
|
|
2091
|
+
freedBytes: freedSoFar,
|
|
1674
2092
|
failedSessionId
|
|
1675
2093
|
} : void 0;
|
|
1676
2094
|
throw verbError("io", `${label}:${String(error)}`, error, details);
|
|
@@ -1700,10 +2118,10 @@ async function executeRemoval(deps, plan) {
|
|
|
1700
2118
|
} catch (error) {
|
|
1701
2119
|
fail("删除回收站目录失败", error, row.entry.sessionId);
|
|
1702
2120
|
}
|
|
1703
|
-
|
|
2121
|
+
freedSoFar += row.bytes;
|
|
1704
2122
|
}
|
|
1705
2123
|
try {
|
|
1706
|
-
await deps.
|
|
2124
|
+
await deps.ledger.remove(row.entry.id);
|
|
1707
2125
|
} catch (error) {
|
|
1708
2126
|
fail("删除回收站目录失败", error, row.entry.sessionId);
|
|
1709
2127
|
}
|
|
@@ -1718,12 +2136,17 @@ async function executeRemoval(deps, plan) {
|
|
|
1718
2136
|
fail(memberLabel, error, member.sessionId);
|
|
1719
2137
|
}
|
|
1720
2138
|
removed += 1;
|
|
1721
|
-
|
|
2139
|
+
freedSoFar += member.bytes;
|
|
1722
2140
|
await presence.memberGone(member.sessionId);
|
|
1723
2141
|
}
|
|
2142
|
+
const attachments = await executeImageAttachmentRemoval(deps, plan.attachments);
|
|
1724
2143
|
return {
|
|
1725
2144
|
removed,
|
|
1726
|
-
freedBytes
|
|
2145
|
+
freedBytes: plan.bytes,
|
|
2146
|
+
attachments: {
|
|
2147
|
+
...attachments,
|
|
2148
|
+
...plan.attachments.skipped === void 0 ? {} : { skipped: plan.attachments.skipped }
|
|
2149
|
+
}
|
|
1727
2150
|
};
|
|
1728
2151
|
}
|
|
1729
2152
|
//#endregion
|
|
@@ -1777,17 +2200,19 @@ function orphanGroupsOf(corpus, trashSessionIds) {
|
|
|
1777
2200
|
}
|
|
1778
2201
|
return groups;
|
|
1779
2202
|
}
|
|
1780
|
-
/**
|
|
1781
|
-
*
|
|
2203
|
+
/** **本函数是纯扫描,不对账、不写清册行**(`/orphans` 声明为 pure,一次扫描不能顺手写行)。
|
|
2204
|
+
* 清册行由调用方选好再交进来:`/orphans` 交 `read.rows()`(raw 索引),`orphan-clean` 交
|
|
2205
|
+
* `ledger.list()`(对账后的行)——否则刚被人手工搬进 `session-trash/` 的父目录还没有行,会被当成
|
|
2206
|
+
* "父没了"直接删盘。扫描不再自己回落到 `ledger.raw()`:读面归属住路由记录(ADR-0004 / ADR-0012)。
|
|
1782
2207
|
*
|
|
1783
2208
|
* `listSessions()` 回 `{ available: false }` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
|
|
1784
2209
|
* 拿缺席当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
|
|
1785
2210
|
* 所以这里按 `io` 响亮失败(AGENTS.md:`available: false` 意味着服务缺席,扫描方不许冒充读数)。 */
|
|
1786
|
-
async function loadOrphanGroups(deps) {
|
|
2211
|
+
async function loadOrphanGroups(deps, trash) {
|
|
1787
2212
|
const read = await deps.host.listSessions();
|
|
1788
2213
|
if (!read.available) throw verbError("io", "无主子会话扫描需要宿主的 sessionQuery.listSessions");
|
|
1789
2214
|
const corpus = read.value;
|
|
1790
|
-
const groups = orphanGroupsOf(corpus,
|
|
2215
|
+
const groups = orphanGroupsOf(corpus, trash.map((entry) => entry.sessionId));
|
|
1791
2216
|
const out = [];
|
|
1792
2217
|
for (const group of groups) {
|
|
1793
2218
|
const members = [];
|
|
@@ -1847,6 +2272,7 @@ function reportOrphans(groups) {
|
|
|
1847
2272
|
* - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以这里不需要那次
|
|
1848
2273
|
* `wasArchived` 读——这里没有"还原到删除前"的义务。
|
|
1849
2274
|
* - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
|
|
2275
|
+
* - 独占图片的剩余引用源由附件域自己扫:活动会话 + 全部 session-trash + storages/cache。
|
|
1850
2276
|
*/
|
|
1851
2277
|
async function cleanOrphanGroup(deps, group) {
|
|
1852
2278
|
return await executeRemoval(deps, await planOrphanRemoval(deps, group.members));
|
|
@@ -1856,72 +2282,68 @@ async function cleanOrphanGroup(deps, group) {
|
|
|
1856
2282
|
/**
|
|
1857
2283
|
* 彻底删除:从回收站里**真的**抹掉一条(或整组、或整站),并连带那些"还留在盘上、没被带走"的后代。
|
|
1858
2284
|
*
|
|
1859
|
-
* 与另外两个动词的关系(ADR-0002):它的 interface
|
|
1860
|
-
|
|
2285
|
+
* 与另外两个动词的关系(ADR-0002):它的 interface 是"这一组目标 + 一份只读清册快照
|
|
2286
|
+
*(targets 缺席 = 清空)";目标由 `verbs/batch-carried.ts` 按组语义算好,批量的"已被同批前一条
|
|
2287
|
+
* 带走"判据不在这里。`requestedId` 是唯一的例外参数,但它**不参与任何判据**:只是让
|
|
2288
|
+
* `trash-empty` 的原文点得出用户点的是哪一条(原文要上屏,点名承重),批量语义仍在批量层。
|
|
1861
2289
|
*
|
|
1862
|
-
*
|
|
2290
|
+
* **这条动词只做两件事:拿目标、把计划交给执行器**。量目录、筛盘上后代、活体准入与"先退归档集、
|
|
1863
2291
|
* 再摘账本、最后删目录"的顺序都住 `verbs/permanent-removal.ts`——计划只读,执行器只写,计划就是
|
|
1864
2292
|
* 执行前的闸(别在这里再长出一段手写步骤)。
|
|
1865
2293
|
*
|
|
1866
|
-
* **这条动词不做回滚**:目录删掉就没有"退回去"
|
|
2294
|
+
* **这条动词不做回滚**:目录删掉就没有"退回去"这回事(删到一半失败时已删目录不回滚;
|
|
1867
2295
|
* 带部分计数的 `details` 回执只属于 `orphan-clean`,purge 的 `removed` / `freedBytes` 仍只在成功回执里给)。
|
|
1868
2296
|
* 对应地,`kind:'trash'` 的计划失败时执行器只抛那一步的 `io` 与原文、不带 details。
|
|
2297
|
+
*
|
|
2298
|
+
* 回执里的 `attachments` 原样来自执行器:独占图片在会话目录删完之后才清,附件失败只计数、不改
|
|
2299
|
+
* `removed` / `freedBytes`(`skipped` = 有候选或剩余引用源读不出、这一轮部分或整体没清,原因上屏)。
|
|
1869
2300
|
*/
|
|
1870
2301
|
function purgeTrash(deps) {
|
|
1871
|
-
return async function purgeTrash(
|
|
1872
|
-
const
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
return await executeRemoval(deps, await planTrashRemoval(deps, all, targets, { admission: "live" }));
|
|
2302
|
+
return async function purgeTrash(targets, rows, requestedId) {
|
|
2303
|
+
const selected = targets ?? rows;
|
|
2304
|
+
if (targets !== void 0 && selected.length === 0) throw trashEmpty(requestedId);
|
|
2305
|
+
return await executeRemoval(deps, await planTrashRemoval(deps, rows, selected));
|
|
1876
2306
|
};
|
|
1877
2307
|
}
|
|
1878
2308
|
//#endregion
|
|
1879
2309
|
//#region src/verbs/restore-session.ts
|
|
1880
2310
|
/**
|
|
1881
|
-
*
|
|
1882
|
-
*
|
|
1883
|
-
*
|
|
1884
|
-
*
|
|
1885
|
-
*
|
|
1886
|
-
*
|
|
1887
|
-
*
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
1890
|
-
* ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 →(旧清单迁移)取消归档 → 摘清册行。
|
|
1891
|
-
* ③ 中途失败时 journal 把这个成员自己退回回收站;**已经恢复完的**兄弟由整组 catch 按相反顺序一起退回。
|
|
1892
|
-
*
|
|
1893
|
-
* **通告不在这里**:一次批量只该拉一遍宿主会话表、只发一次 `api-session/added`(2026-10-17 那次
|
|
1894
|
-
* "恢复很慢"就是逐条发)。通告归 `verbs/batch.ts`(ADR-0002),所以这个动词的 interface 只有
|
|
1895
|
-
* "一个条目 id 进、回收站那一行出",没有批量专用的开关。
|
|
2311
|
+
* 恢复:把删除那次按原路退回,并按组走。
|
|
2312
|
+
*
|
|
2313
|
+
* 三段:
|
|
2314
|
+
* 1. 预检整组(不写盘):每个成员的回收站目录与原项目目录都必须在;工作区账本缺席不算硬失败,
|
|
2315
|
+
* 恢复后归入「未分组」。预检在任何写之前,避免留下半组。
|
|
2316
|
+
* 2. 逐个恢复:文件先回来 → 挂账本 →(旧条目)取消归档 → 摘清册行。
|
|
2317
|
+
* 3. 中途失败由 journal 回滚当前成员;已恢复的兄弟由整组按相反顺序退回。
|
|
2318
|
+
*
|
|
2319
|
+
* 通告不在本文件:批量层在批尾一次 `announceRestored(ids)`(ADR-0002)。
|
|
1896
2320
|
*/
|
|
1897
2321
|
function restoreSession(deps) {
|
|
1898
|
-
const presence =
|
|
1899
|
-
return async function restoreSession(entryId,
|
|
1900
|
-
const
|
|
1901
|
-
const target =
|
|
1902
|
-
if (
|
|
1903
|
-
const group = groupOf(all, entryId);
|
|
2322
|
+
const presence = deps.presence;
|
|
2323
|
+
return async function restoreSession(entryId, group) {
|
|
2324
|
+
const [root, ...followers] = group;
|
|
2325
|
+
const target = group.find((e) => e.id === entryId);
|
|
2326
|
+
if (root === void 0 || target === void 0) throw trashEmpty(entryId);
|
|
1904
2327
|
for (const entry of group) {
|
|
1905
2328
|
const trashDir = trashDirOf(entry.id, deps.home);
|
|
1906
2329
|
if (!await deps.fs.exists(trashDir)) throw verbError("trash-empty", `回收站条目已不在磁盘上:${trashDir}`);
|
|
1907
2330
|
const projectRoot = sessionDirOf(entry.projectDir, "", deps.home).replace(/\/$/, "");
|
|
1908
2331
|
if (!await deps.fs.exists(`${deps.home}/sessions`) || !await deps.fs.exists(projectRoot)) throw verbError("project-missing", `原项目目录已不存在:${projectRoot}`);
|
|
1909
2332
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2333
|
+
await moveGroup({
|
|
2334
|
+
root,
|
|
2335
|
+
followers,
|
|
2336
|
+
move: async (entry) => {
|
|
2337
|
+
return {
|
|
2338
|
+
journal: (await memberRestore(deps, presence, entry)).journal,
|
|
2339
|
+
result: void 0
|
|
2340
|
+
};
|
|
1916
2341
|
}
|
|
1917
|
-
}
|
|
1918
|
-
await rollbackJournals(done, error);
|
|
1919
|
-
}
|
|
2342
|
+
});
|
|
1920
2343
|
return target;
|
|
1921
2344
|
};
|
|
1922
2345
|
}
|
|
1923
|
-
/**
|
|
1924
|
-
* 因此不依赖宿主当时的目录命名约定(v3 无前缀 / v4 带 `session-` 前缀)。 */
|
|
2346
|
+
/** 放回哪儿:用删除时记进 entryId 的原目录名,不从 sessionId 现推;因此不依赖宿主的目录命名约定。 */
|
|
1925
2347
|
function restoreTargetDir(deps, entry) {
|
|
1926
2348
|
return sessionDirOf(entry.projectDir, parseEntryId(entry.id).sessionDirName, deps.home);
|
|
1927
2349
|
}
|
|
@@ -1946,38 +2368,30 @@ async function isWorkspaceMissing(host, entry) {
|
|
|
1946
2368
|
return entry.workspaceId !== "" && await attachableWorkspaceId(host, entry) === void 0;
|
|
1947
2369
|
}
|
|
1948
2370
|
/**
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
* 账本那一步有个**只认端口那句**的兜底:查过之后、挂之前账本被删掉(宿主的「删除工作区」与这次恢复
|
|
1952
|
-
* 撞上)⇒ 按未分组收、不回滚;**别的**失败是真故障,照旧抛(吞掉它就等于把一次存储故障伪装成"未分组")。
|
|
2371
|
+
* 一个成员的恢复步骤:放回目录 → 挂账本 →(旧条目)取消归档 → 摘清册行。
|
|
2372
|
+
* 挂账本时若工作区刚被删除,按未分组收、不回滚;其他失败照抛,不能伪装成未分组。
|
|
1953
2373
|
*/
|
|
1954
2374
|
async function memberRestore(deps, presence, entry) {
|
|
1955
2375
|
const trashDir = trashDirOf(entry.id, deps.home);
|
|
1956
2376
|
const sessionDir = restoreTargetDir(deps, entry);
|
|
1957
2377
|
const workspaceId = await attachableWorkspaceId(deps.host, entry);
|
|
1958
|
-
/**
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
1961
|
-
* 恢复时取消归档。`wasArchived:true` 是用户删除前自己归档的,保持归档态不动。
|
|
1962
|
-
*/
|
|
2378
|
+
/** 旧条目迁移:早期删除用归档藏会话,盘上仍是归档态。
|
|
2379
|
+
* `wasArchived:false` 且当前仍归档 ⇒ 只可能是旧条目,恢复时取消归档;
|
|
2380
|
+
* `wasArchived:true` 是用户删除前自己归档的,保持归档态。 */
|
|
1963
2381
|
const legacyArchived = !entry.wasArchived && await deps.host.isArchived(entry.sessionId);
|
|
1964
2382
|
/** 真挂上了账本才记下那个 id——回滚里"摘账本"那一步只对挂过的成员存在。 */
|
|
1965
2383
|
let attached = false;
|
|
1966
|
-
|
|
1967
|
-
let movedBack = false;
|
|
1968
|
-
const journal = createJournal([
|
|
2384
|
+
return { journal: withPresenceTail(presence, createJournal([
|
|
1969
2385
|
{
|
|
2386
|
+
key: "dir",
|
|
1970
2387
|
label: "放回会话目录失败",
|
|
1971
2388
|
run: async () => {
|
|
1972
2389
|
await deps.fs.rename(trashDir, sessionDir);
|
|
1973
2390
|
},
|
|
1974
|
-
undo:
|
|
1975
|
-
const failures = await attempt("目录退回回收站失败", () => deps.fs.rename(sessionDir, trashDir));
|
|
1976
|
-
if (failures.length === 0) movedBack = true;
|
|
1977
|
-
return failures;
|
|
1978
|
-
}
|
|
2391
|
+
undo: () => stepUndo("目录退回回收站失败", () => deps.fs.rename(sessionDir, trashDir))
|
|
1979
2392
|
},
|
|
1980
2393
|
...workspaceId === void 0 ? [] : [{
|
|
2394
|
+
key: "workspace",
|
|
1981
2395
|
label: "挂回账本失败",
|
|
1982
2396
|
run: async () => {
|
|
1983
2397
|
try {
|
|
@@ -1987,54 +2401,51 @@ async function memberRestore(deps, presence, entry) {
|
|
|
1987
2401
|
if (!isWorkspaceMissingError(error) || await deps.host.workspaceExists(workspaceId)) throw error;
|
|
1988
2402
|
}
|
|
1989
2403
|
},
|
|
1990
|
-
undo: async () =>
|
|
2404
|
+
undo: async () => {
|
|
2405
|
+
if (!attached) return {
|
|
2406
|
+
restored: true,
|
|
2407
|
+
failures: []
|
|
2408
|
+
};
|
|
2409
|
+
return stepUndo("摘账本失败", () => deps.host.detach(entry.sessionId, workspaceId));
|
|
2410
|
+
}
|
|
1991
2411
|
}],
|
|
1992
2412
|
...!legacyArchived ? [] : [{
|
|
2413
|
+
key: "archive",
|
|
1993
2414
|
label: "取消归档失败",
|
|
1994
2415
|
undoOnFailure: true,
|
|
1995
2416
|
run: async () => {
|
|
1996
2417
|
await deps.host.unarchive(entry.sessionId);
|
|
1997
2418
|
},
|
|
1998
|
-
undo: () =>
|
|
2419
|
+
undo: () => stepUndo("重新归档失败", () => deps.host.archive(entry.sessionId, workspaceId))
|
|
1999
2420
|
}],
|
|
2000
2421
|
{
|
|
2422
|
+
key: "manifest",
|
|
2001
2423
|
label: "摘回收站条目失败",
|
|
2002
2424
|
run: async () => {
|
|
2003
|
-
await deps.
|
|
2425
|
+
await deps.ledger.remove(entry.id);
|
|
2004
2426
|
},
|
|
2005
|
-
undo: () =>
|
|
2006
|
-
}
|
|
2007
|
-
]);
|
|
2008
|
-
return { journal: {
|
|
2009
|
-
run: () => journal.run(),
|
|
2010
|
-
async undo() {
|
|
2011
|
-
const failures = await journal.undo();
|
|
2012
|
-
await presence.memberGone(entry.sessionId, { backInTrash: movedBack });
|
|
2013
|
-
return failures;
|
|
2427
|
+
undo: () => stepUndo("清册行写回失败", () => deps.ledger.add(entry))
|
|
2014
2428
|
}
|
|
2015
|
-
|
|
2429
|
+
]), {
|
|
2430
|
+
sessionId: entry.sessionId,
|
|
2431
|
+
onUndone: (undo) => ({
|
|
2432
|
+
kind: "leave",
|
|
2433
|
+
backInTrash: undo.restored("dir")
|
|
2434
|
+
})
|
|
2435
|
+
}) };
|
|
2016
2436
|
}
|
|
2017
2437
|
//#endregion
|
|
2018
2438
|
//#region src/verbs/batch.ts
|
|
2019
2439
|
/**
|
|
2020
|
-
*
|
|
2440
|
+
* 「这一批」的唯一实现(ADR-0002):POST /apply 的落点——一组 id 上的同一个动作。
|
|
2021
2441
|
*
|
|
2022
|
-
*
|
|
2442
|
+
* 批量是请求的性质,不是单个动词的性质。这里拥有三件事:
|
|
2443
|
+
* - 去重并保持输入顺序;
|
|
2444
|
+
* - 逐条信封:一条失败不传染下一条,失败按 map-error 折成错误对象;
|
|
2445
|
+
* - 恢复通告攒到批尾,一次 announceRestored(ids)。
|
|
2023
2446
|
*
|
|
2024
|
-
*
|
|
2025
|
-
*
|
|
2026
|
-
* 前面的兄弟带走了谁",同一个事实还有三种机制(回调、调用方 `taken` 集合、动词就地收缩数组)。
|
|
2027
|
-
*
|
|
2028
|
-
* 这一层独占四件(每条批量语义都在这里):
|
|
2029
|
-
* ① **去重保序**:输入顺序就是界面"清单从上往下"的读感;
|
|
2030
|
-
* ② **清册快照**:整批只读一次"最初的样子",用来判断"已被同批前一条带走";
|
|
2031
|
-
* ③ **逐条信封**:一条失败不传染下一条,失败按 `map-error` 那张表折成 `{code,message,details?}`;
|
|
2032
|
-
* ④ **一批一次通告**:恢复的 `api-session/added` 攒到批尾、配一次预取的摘要表发(逐条发 = 每条一次
|
|
2033
|
-
* 宿主全表扫,那正是 2026-10-17 "恢复很慢"的头号来源)。
|
|
2034
|
-
*
|
|
2035
|
-
* 「已被同批前一条带走」是**幂等成功**,不是失败:用户要的结果本来就已经达成了(`delete`/`restore`
|
|
2036
|
-
* 回 `null`;`purge` 回 `{removed:0,freedBytes:0}`)。判据读 `trash/groups.ts`(ADR-0001),
|
|
2037
|
-
* 与 `/list` 的装配、与三个动词同一条。
|
|
2447
|
+
* 「已被同批前一条带走」是幂等成功(delete/restore 回 null,purge 回 0),不是失败;
|
|
2448
|
+
* 判据在 trash/groups.ts,同批已带走集在 batch-carried.ts。
|
|
2038
2449
|
*/
|
|
2039
2450
|
function failure(id, action, error) {
|
|
2040
2451
|
return {
|
|
@@ -2077,54 +2488,46 @@ async function forEachId(action, ids, run) {
|
|
|
2077
2488
|
}
|
|
2078
2489
|
return outcomes;
|
|
2079
2490
|
}
|
|
2080
|
-
/** 这一组的行从"当前快照"里摘掉(父行带走孩子后,后到的孩子 id 要认得出"已经不在了")。 */
|
|
2081
|
-
function shrink(current, rows) {
|
|
2082
|
-
for (const row of rows) {
|
|
2083
|
-
const at = current.indexOf(row);
|
|
2084
|
-
if (at >= 0) current.splice(at, 1);
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
2491
|
async function applyDelete(deps, ids) {
|
|
2088
2492
|
const run = deleteSession(deps);
|
|
2089
|
-
const carried =
|
|
2493
|
+
const carried = createSessionCarried();
|
|
2090
2494
|
return forEachId("delete", ids, async (sessionId) => {
|
|
2091
|
-
|
|
2092
|
-
if (carried.has(root)) return null;
|
|
2495
|
+
if (carried.claim(sessionId).verdict === "carried") return null;
|
|
2093
2496
|
const { entry, movedSessionIds } = await run(sessionId);
|
|
2094
|
-
for (const movedId of movedSessionIds) carried.
|
|
2497
|
+
for (const movedId of movedSessionIds) carried.take(movedId);
|
|
2095
2498
|
return entry;
|
|
2096
2499
|
});
|
|
2097
2500
|
}
|
|
2098
|
-
/**
|
|
2099
|
-
*
|
|
2501
|
+
/** 「这条是不是被同批前一条带走了」与视图收缩都归 `batch-carried.ts`;`apply` 整批只读一次清册,
|
|
2502
|
+
* 动词只拿它交回的只读组与当前清册。 */
|
|
2100
2503
|
async function applyRestore(deps, ids) {
|
|
2101
|
-
const
|
|
2102
|
-
const current = [...original];
|
|
2504
|
+
const carried = createEntryCarried(await deps.ledger.list());
|
|
2103
2505
|
const run = restoreSession(deps);
|
|
2104
2506
|
const restored = [];
|
|
2105
2507
|
const outcomes = await forEachId("restore", ids, async (entryId) => {
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
const
|
|
2508
|
+
const claim = carried.claim(entryId);
|
|
2509
|
+
if (claim.verdict === "carried") return null;
|
|
2510
|
+
const group = claim.verdict === "fresh" ? claim.group : [];
|
|
2511
|
+
const entry = await run(entryId, group);
|
|
2512
|
+
carried.take(entryId);
|
|
2109
2513
|
restored.push(...group.map((row) => row.sessionId));
|
|
2110
|
-
shrink(current, group);
|
|
2111
2514
|
return entry;
|
|
2112
2515
|
});
|
|
2113
|
-
if (restored.length > 0) await deps.
|
|
2516
|
+
if (restored.length > 0) await deps.presence.announceRestored(restored);
|
|
2114
2517
|
return outcomes;
|
|
2115
2518
|
}
|
|
2116
2519
|
async function applyPurge(deps, ids) {
|
|
2117
|
-
const
|
|
2118
|
-
const current = [...original];
|
|
2520
|
+
const carried = createEntryCarried(await deps.ledger.list());
|
|
2119
2521
|
const run = purgeTrash(deps);
|
|
2120
2522
|
return forEachId("purge", ids, async (entryId) => {
|
|
2121
|
-
|
|
2523
|
+
const claim = carried.claim(entryId);
|
|
2524
|
+
if (claim.verdict === "carried") return {
|
|
2122
2525
|
removed: 0,
|
|
2123
2526
|
freedBytes: 0
|
|
2124
2527
|
};
|
|
2125
|
-
const group =
|
|
2126
|
-
const stats = await run(
|
|
2127
|
-
|
|
2528
|
+
const group = claim.verdict === "fresh" ? claim.group : [];
|
|
2529
|
+
const stats = await run(group, carried.rows(), entryId);
|
|
2530
|
+
carried.take(entryId);
|
|
2128
2531
|
return stats;
|
|
2129
2532
|
});
|
|
2130
2533
|
}
|
|
@@ -2142,30 +2545,19 @@ async function applyCount(deps, ids) {
|
|
|
2142
2545
|
* (父又被人放回回收站、组已被上一个请求带走,或另一处已经把目录清掉),不是"用户打错了一个 id"。
|
|
2143
2546
|
* 根 id 两边都去 `session-` 前缀再比:报告原样回传是正常路径,外部调用带另一种写法也认。
|
|
2144
2547
|
*
|
|
2145
|
-
*
|
|
2146
|
-
*
|
|
2147
|
-
*
|
|
2148
|
-
*
|
|
2149
|
-
* 谁也别想只占一半。
|
|
2548
|
+
* **这是写动作,排在到达序链上**,而且**先对账再扫**:`loadOrphanGroups` 本身只扫描、不写清册;
|
|
2549
|
+
* 这里先把 `ledger.list()` 对账后的行交给它——刚被人手工搬进 `session-trash/` 的那一格目录此刻可能还
|
|
2550
|
+
* 没有对应行,拿 raw 索引扫就会把"父还在回收站里等着"的一组当成孤儿**直接删盘**,替用户做了一个没人
|
|
2551
|
+
* 点过的决定(那种组该跟着父恢复或彻底删除)。
|
|
2150
2552
|
*/
|
|
2151
2553
|
async function applyOrphanClean(deps, ids) {
|
|
2152
|
-
await
|
|
2153
|
-
const groups = await loadOrphanGroups(deps);
|
|
2554
|
+
const groups = await loadOrphanGroups(deps, await deps.ledger.list());
|
|
2154
2555
|
const byRoot = new Map(groups.map((group) => [bareSessionId(group.root.sessionId), group]));
|
|
2155
|
-
|
|
2156
|
-
for (const id of unique(ids)) {
|
|
2556
|
+
return forEachId("orphan-clean", ids, async (id) => {
|
|
2157
2557
|
const group = byRoot.get(bareSessionId(id));
|
|
2158
|
-
if (group === void 0) {
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
}
|
|
2162
|
-
try {
|
|
2163
|
-
outcomes.push(success(id, "orphan-clean", await cleanOrphanGroup(deps, group)));
|
|
2164
|
-
} catch (error) {
|
|
2165
|
-
outcomes.push(failure(id, "orphan-clean", error));
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
return outcomes;
|
|
2558
|
+
if (group === void 0) throw verbError("not-found", `不再是可清理的无主子会话:${id}`);
|
|
2559
|
+
return await cleanOrphanGroup(deps, group);
|
|
2560
|
+
});
|
|
2169
2561
|
}
|
|
2170
2562
|
function applyActions(deps, action, ids) {
|
|
2171
2563
|
switch (action) {
|
|
@@ -2176,19 +2568,63 @@ function applyActions(deps, action, ids) {
|
|
|
2176
2568
|
case "orphan-clean": return applyOrphanClean(deps, ids);
|
|
2177
2569
|
}
|
|
2178
2570
|
}
|
|
2571
|
+
//#endregion
|
|
2572
|
+
//#region src/trash/detail.ts
|
|
2573
|
+
/**
|
|
2574
|
+
* 回收站**单条详情**:从回收站目录里那条会话自己的日志读出元信息与正文(用例见 `tests/trash-detail.test.ts`)。
|
|
2575
|
+
*
|
|
2576
|
+
* 三层里的策略层——编解码在 `src/log/*`,这里只回答"给谁看、给多少、算不算失败"。
|
|
2577
|
+
*
|
|
2578
|
+
* 三条硬口径,都不是口味:
|
|
2579
|
+
* ① **寻址只认清册(raw 索引)**:`entryId` 先在对账之外的 raw 索引里查,目录一律由 `trashDirOf(record.id, home)` 推
|
|
2580
|
+
* ⇒ 外部输入进不了文件系统寻址(不给"传个路径就读任意目录"留缝)。
|
|
2581
|
+
* ② **正文只认 `source.kind === "user"`**:`user/message` 里绝大多数是宿主注入的上下文
|
|
2582
|
+
* (本机实测一条会话:真实提问 21 条、注入 241 条),不过滤就被淹掉;注入条数照样上线
|
|
2583
|
+
* (`injectedCount`),让"为什么少了这么多条"能与页面上的数字对账。
|
|
2584
|
+
* ③ **两个闸是载荷闸,不是读盘闸**:每条 3 000 字符、累计 50 万字符(CJK 最坏 ≈ 1.5 MB UTF-8)。
|
|
2585
|
+
* 本机存量一条都触发不了(量级随盘上会话数摆动,不在这里钉数)⇒ 它们是防异常
|
|
2586
|
+
* 膨胀的;**不设"文件太大就拒绝"**,那种阈值只会把真读得出来的正文挡在外面。
|
|
2587
|
+
*
|
|
2588
|
+
* 计数与正文**同源过滤、各过各的闸**:`user/message` 先要求 `source.kind === 'user'`,两类都过"空文本**且**
|
|
2589
|
+
* 无附件"过滤;`userMessageCount` / `assistantMessageCount` 数的是通过过滤的条数,**不受载荷闸影响**。正文再受
|
|
2590
|
+
* 单条/累计两个闸:单条超限截断并把那一条标成 `truncated`,累计到上限后剩下的整条不再收、只累进
|
|
2591
|
+
* `omittedByPayloadCap`——所以计数可以大于正文条数,详情里两件事分开说。
|
|
2592
|
+
* 附件随消息一起收(`attachmentsInContent`),也有两个闸:每条 20、总量 200;超出的**静默丢**,
|
|
2593
|
+
* 不累进 `omittedByPayloadCap`——那个计数说的是"整条正文没进来",拿它兼报少了几张附件会让两个数都对不上。
|
|
2594
|
+
*/
|
|
2595
|
+
/** 每条文字的上限(字符)。超出即截断并置 `truncated`。 */
|
|
2596
|
+
const PER_MESSAGE_CHAR_CAP = 3e3;
|
|
2597
|
+
/** 一次回信的正文总量上限(字符)。 */
|
|
2598
|
+
const TOTAL_CHAR_CAP = 5e5;
|
|
2599
|
+
/** 一条消息带多少张附件上线。 */
|
|
2600
|
+
const MAX_ATTACHMENTS_PER_MESSAGE = 20;
|
|
2601
|
+
/** 一次回信的附件总量上限。 */
|
|
2602
|
+
const MAX_ATTACHMENTS_TOTAL = 200;
|
|
2179
2603
|
const DEFAULT_LIMITS = {
|
|
2180
|
-
perMessageChars:
|
|
2181
|
-
totalChars:
|
|
2604
|
+
perMessageChars: PER_MESSAGE_CHAR_CAP,
|
|
2605
|
+
totalChars: TOTAL_CHAR_CAP
|
|
2182
2606
|
};
|
|
2183
2607
|
/** 一条记录里那些 `content[]` 段的文字拼起来(`\n` 分隔;非 text 段一律不参与)。 */
|
|
2184
2608
|
function textOf(content) {
|
|
2185
2609
|
if (!Array.isArray(content)) return "";
|
|
2186
2610
|
return content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
|
|
2187
2611
|
}
|
|
2612
|
+
/** 一条记录的 `content[]` 里的附件块,映射成线上读数(形状只映射,不改判据——闸在 `attachmentsInContent` 里)。 */
|
|
2613
|
+
function attachmentWires(content) {
|
|
2614
|
+
return attachmentsInContent(content).map((ref) => ({
|
|
2615
|
+
attachmentId: ref.id,
|
|
2616
|
+
kind: ref.kind,
|
|
2617
|
+
...ref.name === void 0 ? {} : { name: ref.name },
|
|
2618
|
+
...ref.kind === "image" && ref.mediaType !== void 0 ? { mediaType: ref.mediaType } : {},
|
|
2619
|
+
bytes: ref.bytes,
|
|
2620
|
+
...ref.kind === "image" && ref.width !== void 0 ? { width: ref.width } : {},
|
|
2621
|
+
...ref.kind === "image" && ref.height !== void 0 ? { height: ref.height } : {}
|
|
2622
|
+
}));
|
|
2623
|
+
}
|
|
2188
2624
|
function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
2189
2625
|
return async function readTrashDetail(entryId) {
|
|
2190
|
-
const record = (await deps.
|
|
2191
|
-
if (record === void 0) throw
|
|
2626
|
+
const record = (await deps.rows()).find((e) => e.id === entryId);
|
|
2627
|
+
if (record === void 0) throw trashEmpty(entryId);
|
|
2192
2628
|
const dir = trashDirOf(record.id, deps.home);
|
|
2193
2629
|
if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
|
|
2194
2630
|
const names = await deps.fs.listFiles(dir);
|
|
@@ -2217,9 +2653,10 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
2217
2653
|
};
|
|
2218
2654
|
const models = /* @__PURE__ */ new Set();
|
|
2219
2655
|
let usedChars = 0;
|
|
2656
|
+
let usedAttachments = 0;
|
|
2220
2657
|
let capped = false;
|
|
2221
|
-
/**
|
|
2222
|
-
function push(role, time, text) {
|
|
2658
|
+
/** 收进正文,顺带管两个闸:截到单条上限,累计到总量就停;附件另有 20/200 两道闸。 */
|
|
2659
|
+
function push(role, time, text, attachments) {
|
|
2223
2660
|
if (capped) {
|
|
2224
2661
|
detail.omittedByPayloadCap += 1;
|
|
2225
2662
|
return;
|
|
@@ -2232,11 +2669,15 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
2232
2669
|
return;
|
|
2233
2670
|
}
|
|
2234
2671
|
usedChars += kept.length;
|
|
2672
|
+
const room = Math.max(0, Math.min(MAX_ATTACHMENTS_PER_MESSAGE, MAX_ATTACHMENTS_TOTAL - usedAttachments));
|
|
2673
|
+
const keptAttachments = attachments.slice(0, room);
|
|
2674
|
+
usedAttachments += keptAttachments.length;
|
|
2235
2675
|
detail.messages.push({
|
|
2236
2676
|
role,
|
|
2237
2677
|
time,
|
|
2238
2678
|
text: kept,
|
|
2239
|
-
truncated
|
|
2679
|
+
truncated,
|
|
2680
|
+
attachments: keptAttachments
|
|
2240
2681
|
});
|
|
2241
2682
|
}
|
|
2242
2683
|
try {
|
|
@@ -2263,9 +2704,10 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
2263
2704
|
return;
|
|
2264
2705
|
}
|
|
2265
2706
|
const text = textOf(data.content);
|
|
2266
|
-
|
|
2707
|
+
const attachments = attachmentWires(data.content);
|
|
2708
|
+
if (text === "" && attachments.length === 0) return;
|
|
2267
2709
|
detail.userMessageCount += 1;
|
|
2268
|
-
push("user", r.time ?? 0, text);
|
|
2710
|
+
push("user", r.time ?? 0, text, attachments);
|
|
2269
2711
|
return;
|
|
2270
2712
|
}
|
|
2271
2713
|
case "assistant/message": {
|
|
@@ -2273,9 +2715,10 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
2273
2715
|
const source = message.source ?? {};
|
|
2274
2716
|
if (typeof source.model === "string") models.add(source.model);
|
|
2275
2717
|
const text = textOf(message.content);
|
|
2276
|
-
|
|
2718
|
+
const attachments = attachmentWires(message.content);
|
|
2719
|
+
if (text === "" && attachments.length === 0) return;
|
|
2277
2720
|
detail.assistantMessageCount += 1;
|
|
2278
|
-
push("assistant", r.time ?? 0, text);
|
|
2721
|
+
push("assistant", r.time ?? 0, text, attachments);
|
|
2279
2722
|
return;
|
|
2280
2723
|
}
|
|
2281
2724
|
default: return;
|
|
@@ -2289,6 +2732,94 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
|
|
|
2289
2732
|
};
|
|
2290
2733
|
}
|
|
2291
2734
|
//#endregion
|
|
2735
|
+
//#region src/api/attachment.ts
|
|
2736
|
+
const MAX_ATTACHMENT_BYTES = 20971520;
|
|
2737
|
+
const PNG_MAGIC = Buffer.from([
|
|
2738
|
+
137,
|
|
2739
|
+
80,
|
|
2740
|
+
78,
|
|
2741
|
+
71,
|
|
2742
|
+
13,
|
|
2743
|
+
10,
|
|
2744
|
+
26,
|
|
2745
|
+
10
|
|
2746
|
+
]);
|
|
2747
|
+
/** 按魔数认图片类型;认不出回 `undefined`(调用方按未知字节处理,不猜)。 */
|
|
2748
|
+
function sniffImage(bytes) {
|
|
2749
|
+
if (bytes.length >= PNG_MAGIC.length && bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) return "image/png";
|
|
2750
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
2751
|
+
const head6 = bytes.subarray(0, 6).toString("ascii");
|
|
2752
|
+
if (head6 === "GIF87a" || head6 === "GIF89a") return "image/gif";
|
|
2753
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* 下载名过闸:只当头部里的一个串用,**不参与寻址**。
|
|
2757
|
+
* 路径分隔符、控制字符、`..` 与不成对的代理项一律换 `_`;按码点截到 120 个字符(不劈开代理对,
|
|
2758
|
+
* 否则 `encodeURIComponent` 会在写头时抛)。
|
|
2759
|
+
*/
|
|
2760
|
+
function safeFilename(name, fallback) {
|
|
2761
|
+
const base = [...name === null || name === "" ? fallback : name].slice(0, 120).join("").replace(/[\\/:*?"<>|\u0000-\u001f\uD800-\uDFFF]/gu, "_").replace(/\.\./gu, "_");
|
|
2762
|
+
return base === "" ? fallback : base;
|
|
2763
|
+
}
|
|
2764
|
+
async function readAttachmentResponse(deps, query) {
|
|
2765
|
+
const id = query.get(PARAMS.attachmentId);
|
|
2766
|
+
const kind = query.get(PARAMS.attachmentKind);
|
|
2767
|
+
if (!isAttachmentId(id)) throw routeError("bad-request", 400, `附件 id 需为 sha256:<64 位小写 hex>:${String(id)}`);
|
|
2768
|
+
if (kind !== "image" && kind !== "file") throw routeError("bad-request", 400, `附件 kind 只认 image / file:${String(kind)}`);
|
|
2769
|
+
const path = attachmentPathOf(deps.home, kind, id);
|
|
2770
|
+
let size;
|
|
2771
|
+
try {
|
|
2772
|
+
size = (await deps.fs.statFile(path)).size;
|
|
2773
|
+
} catch (error) {
|
|
2774
|
+
throw verbError("io", `附件不在:${id}(${String(error)})`, error);
|
|
2775
|
+
}
|
|
2776
|
+
if (size > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${size} 字节)`);
|
|
2777
|
+
const bytes = await deps.fs.readFile(path);
|
|
2778
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${bytes.length} 字节)`);
|
|
2779
|
+
if (kind === "file") return {
|
|
2780
|
+
body: bytes,
|
|
2781
|
+
contentType: "application/octet-stream",
|
|
2782
|
+
disposition: "attachment",
|
|
2783
|
+
filename: safeFilename(query.get(PARAMS.attachmentName), id.slice(7, 19))
|
|
2784
|
+
};
|
|
2785
|
+
const contentType = sniffImage(bytes);
|
|
2786
|
+
if (contentType === void 0) return {
|
|
2787
|
+
body: bytes,
|
|
2788
|
+
contentType: "application/octet-stream",
|
|
2789
|
+
disposition: "attachment",
|
|
2790
|
+
filename: safeFilename(query.get(PARAMS.attachmentName), `${id.slice(7, 19)}.bin`)
|
|
2791
|
+
};
|
|
2792
|
+
return {
|
|
2793
|
+
body: bytes,
|
|
2794
|
+
contentType,
|
|
2795
|
+
disposition: "inline"
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
//#endregion
|
|
2799
|
+
//#region src/api/attachment-orphans.ts
|
|
2800
|
+
/**
|
|
2801
|
+
* 孤儿附件的报告段:`GET /attachment-orphans`。
|
|
2802
|
+
*
|
|
2803
|
+
* 报告把扫描计划折成数量/字节 + 对象清单(`id`/`bytes`,供设置页预览缩略图;最多
|
|
2804
|
+
* `ORPHAN_PREVIEW_LIMIT` 条,清理不受预览条数限制)。扫描跳过(引用源或对象列表读不出)时
|
|
2805
|
+
* **报告必须失败**——回一份 0 会让界面说"没有孤儿",而这类失败恰恰是"不知道",不是"没有"。
|
|
2806
|
+
* 清理是 `POST /attachment-clean`,由路由直接接到 `cleanOrphanImageAttachments`。
|
|
2807
|
+
*/
|
|
2808
|
+
/** 预览清单的上限:与详情附件的载荷闸同一量级。数量与字节仍按全量报告。 */
|
|
2809
|
+
const ORPHAN_PREVIEW_LIMIT = 200;
|
|
2810
|
+
async function reportAttachmentOrphans(deps) {
|
|
2811
|
+
const plan = await scanOrphanImageAttachments(deps);
|
|
2812
|
+
if (plan.skipped !== void 0) throw verbError("io", plan.skipped);
|
|
2813
|
+
return {
|
|
2814
|
+
count: plan.entries.length,
|
|
2815
|
+
totalBytes: plan.bytes,
|
|
2816
|
+
items: plan.entries.slice(0, ORPHAN_PREVIEW_LIMIT).map((entry) => ({
|
|
2817
|
+
attachmentId: entry.id,
|
|
2818
|
+
bytes: entry.bytes
|
|
2819
|
+
}))
|
|
2820
|
+
};
|
|
2821
|
+
}
|
|
2822
|
+
//#endregion
|
|
2292
2823
|
//#region src/api/trash-rows.ts
|
|
2293
2824
|
/**
|
|
2294
2825
|
* 清册那一行 → 线上那一行。**显式字面量、不用 `...row`**:线上形状多一个必填字段时,这里编译不过,
|
|
@@ -2342,18 +2873,15 @@ async function assembleTrashRows(deps, rows) {
|
|
|
2342
2873
|
//#endregion
|
|
2343
2874
|
//#region src/api/route-table.ts
|
|
2344
2875
|
/**
|
|
2345
|
-
*
|
|
2346
|
-
*
|
|
2347
|
-
*
|
|
2348
|
-
*
|
|
2349
|
-
*
|
|
2350
|
-
* -
|
|
2351
|
-
*
|
|
2352
|
-
*
|
|
2353
|
-
*
|
|
2354
|
-
* 为什么这张表**不在** `shared/wire.ts`:处理函数会 import 动词与 `node:fs`,而浏览器半要 import
|
|
2355
|
-
* `wire.ts`——值 import 会把 Node 半拖进客户端 bundle,正好撞上 `tsdown` 的纯度门。段名与动作仍住
|
|
2356
|
-
* `wire.ts`(两半的共同契约),这张表只回答"怎么处理"。
|
|
2876
|
+
* 路由表(ADR-0004):段名 → { 方法, 纯度, 读面, body, 处理 }。一条记录,两个消费者。
|
|
2877
|
+
*
|
|
2878
|
+
* 一致性要求:
|
|
2879
|
+
* - 该纯的判成写 ⇒ 探测/正文读/扫描被堵在串行链后;
|
|
2880
|
+
* - 该写的判成纯 ⇒ 写动作绕开到达序链,与对账交错;
|
|
2881
|
+
* - body 该读没读 ⇒ 下游拿到 null;读两遍 ⇒ 流已被消费。
|
|
2882
|
+
*
|
|
2883
|
+
* 这张表不放 shared/wire.ts:处理函数会 import 动词与 node:fs,而浏览器半要 import wire.ts。
|
|
2884
|
+
* 段名与动作仍住 wire.ts;这张表只回答“怎么处理”。
|
|
2357
2885
|
*/
|
|
2358
2886
|
/** 必填的非空字符串字段(缺、空串、类型不对都是**同一句话**:说清是哪个字段)。 */
|
|
2359
2887
|
function requireString(body, field) {
|
|
@@ -2361,17 +2889,26 @@ function requireString(body, field) {
|
|
|
2361
2889
|
if (typeof value !== "string" || value === "") throw routeError("bad-request", 400, `请求体需带非空字符串字段 ${field}`);
|
|
2362
2890
|
return value;
|
|
2363
2891
|
}
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
"
|
|
2370
|
-
|
|
2892
|
+
/**
|
|
2893
|
+
* `/apply` 的五个动作与各自的**静态纯度**。一张表两个用途:动作名单由键派生,纯度也从同一格读——
|
|
2894
|
+
* 新增动作必须同时给出纯度,漏了编译不过;新加一个纯读动作也不会再静默落进 `chained`。
|
|
2895
|
+
*/
|
|
2896
|
+
const APPLY_ACTION_TABLE = {
|
|
2897
|
+
delete: "chained",
|
|
2898
|
+
restore: "chained",
|
|
2899
|
+
purge: "chained",
|
|
2900
|
+
count: "pure",
|
|
2901
|
+
"orphan-clean": "chained"
|
|
2902
|
+
};
|
|
2903
|
+
const APPLY_ACTIONS = Object.keys(APPLY_ACTION_TABLE);
|
|
2904
|
+
/** 认不认得这个动作:`/apply` 的纯度与拒否共用同一条判据,名单只有上面那一张。 */
|
|
2905
|
+
function isApplyAction(value) {
|
|
2906
|
+
return typeof value === "string" && Object.hasOwn(APPLY_ACTION_TABLE, value);
|
|
2907
|
+
}
|
|
2371
2908
|
/** `/apply` 的动作:认不得的一律点名拒掉,不折成"空批量"糊弄过去。 */
|
|
2372
2909
|
function requireAction(body) {
|
|
2373
2910
|
const value = body?.[PARAMS.action];
|
|
2374
|
-
if (
|
|
2911
|
+
if (!isApplyAction(value)) throw routeError("bad-request", 400, `批量动作不认识:需是 ${APPLY_ACTIONS.join(" / ")} 之一`);
|
|
2375
2912
|
return value;
|
|
2376
2913
|
}
|
|
2377
2914
|
/** `/apply` 的 id 组:必须是非空字符串数组(空数组不是"什么都不做",是调用方自己没组好)。 */
|
|
@@ -2381,39 +2918,76 @@ function requireIdArray(body) {
|
|
|
2381
2918
|
return value;
|
|
2382
2919
|
}
|
|
2383
2920
|
/**
|
|
2384
|
-
*
|
|
2921
|
+
* 九个段名各一行。`Record<段名, RouteSpec>` 是闸:`wire.ts` 加了段名而这里没加,编译不过。
|
|
2385
2922
|
*/
|
|
2386
2923
|
const ROUTE_TABLE = {
|
|
2387
2924
|
[ROUTES.list]: {
|
|
2388
2925
|
method: "GET",
|
|
2389
2926
|
body: "none",
|
|
2390
2927
|
purity: "chained",
|
|
2391
|
-
|
|
2928
|
+
read: "list",
|
|
2929
|
+
response: "json",
|
|
2930
|
+
run: async (deps, _body, read) => assembleTrashRows(deps, await read.rows())
|
|
2392
2931
|
},
|
|
2393
2932
|
[ROUTES.ping]: {
|
|
2394
2933
|
method: "GET",
|
|
2395
2934
|
body: "none",
|
|
2396
2935
|
purity: "pure",
|
|
2936
|
+
read: "none",
|
|
2937
|
+
response: "json",
|
|
2397
2938
|
run: async () => "pong"
|
|
2398
2939
|
},
|
|
2940
|
+
[ROUTES.attachment]: {
|
|
2941
|
+
method: "GET",
|
|
2942
|
+
body: "none",
|
|
2943
|
+
purity: "pure",
|
|
2944
|
+
read: "none",
|
|
2945
|
+
response: "binary",
|
|
2946
|
+
run: async (deps, _body, _read, query) => readAttachmentResponse(deps, query)
|
|
2947
|
+
},
|
|
2948
|
+
[ROUTES.attachmentOrphans]: {
|
|
2949
|
+
method: "GET",
|
|
2950
|
+
body: "none",
|
|
2951
|
+
purity: "pure",
|
|
2952
|
+
read: "none",
|
|
2953
|
+
response: "json",
|
|
2954
|
+
run: async (deps) => reportAttachmentOrphans(deps)
|
|
2955
|
+
},
|
|
2956
|
+
[ROUTES.attachmentClean]: {
|
|
2957
|
+
method: "POST",
|
|
2958
|
+
body: "none",
|
|
2959
|
+
purity: "chained",
|
|
2960
|
+
read: "none",
|
|
2961
|
+
response: "json",
|
|
2962
|
+
run: async (deps) => cleanOrphanImageAttachments(deps)
|
|
2963
|
+
},
|
|
2399
2964
|
[ROUTES.orphans]: {
|
|
2400
2965
|
method: "GET",
|
|
2401
2966
|
body: "none",
|
|
2402
2967
|
purity: "pure",
|
|
2403
|
-
|
|
2968
|
+
read: "raw",
|
|
2969
|
+
response: "json",
|
|
2970
|
+
run: async (deps, _body, read) => reportOrphans(await loadOrphanGroups(deps, await read.rows()))
|
|
2404
2971
|
},
|
|
2405
2972
|
[ROUTES.apply]: {
|
|
2406
2973
|
method: "POST",
|
|
2407
2974
|
body: "json",
|
|
2408
|
-
purity: (body) =>
|
|
2975
|
+
purity: (body) => {
|
|
2976
|
+
const action = body?.[PARAMS.action];
|
|
2977
|
+
return isApplyAction(action) ? APPLY_ACTION_TABLE[action] : "chained";
|
|
2978
|
+
},
|
|
2979
|
+
read: "none",
|
|
2980
|
+
response: "json",
|
|
2409
2981
|
run: async (deps, body) => applyActions(deps, requireAction(body), requireIdArray(body))
|
|
2410
2982
|
},
|
|
2411
2983
|
[ROUTES.empty]: {
|
|
2412
2984
|
method: "POST",
|
|
2413
2985
|
body: "none",
|
|
2414
2986
|
purity: "chained",
|
|
2415
|
-
|
|
2416
|
-
|
|
2987
|
+
read: "list",
|
|
2988
|
+
response: "json",
|
|
2989
|
+
run: async (deps, _body, read) => {
|
|
2990
|
+
const rows = await read.rows();
|
|
2417
2991
|
return await purgeTrash(deps)(void 0, rows);
|
|
2418
2992
|
}
|
|
2419
2993
|
},
|
|
@@ -2421,7 +2995,13 @@ const ROUTE_TABLE = {
|
|
|
2421
2995
|
method: "POST",
|
|
2422
2996
|
body: "json",
|
|
2423
2997
|
purity: "pure",
|
|
2424
|
-
|
|
2998
|
+
read: "raw",
|
|
2999
|
+
response: "json",
|
|
3000
|
+
run: async (deps, body, read) => readTrashDetail({
|
|
3001
|
+
fs: deps.fs,
|
|
3002
|
+
rows: () => read.rows(),
|
|
3003
|
+
home: deps.home
|
|
3004
|
+
})(requireString(body, PARAMS.entryId))
|
|
2425
3005
|
}
|
|
2426
3006
|
};
|
|
2427
3007
|
/** 段名 → 那一行;不认识的段名回 `undefined`(调用方抛 404,并带出原路径)。 */
|
|
@@ -2432,39 +3012,6 @@ function routeOf(rest) {
|
|
|
2432
3012
|
//#region src/api/dispatch.ts
|
|
2433
3013
|
/** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
|
|
2434
3014
|
const JSON_BODY_MAX_BYTES = 1048576;
|
|
2435
|
-
/** 回环地址的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
|
|
2436
|
-
const LOOPBACK = [
|
|
2437
|
-
"127.0.0.1",
|
|
2438
|
-
"::1",
|
|
2439
|
-
"::ffff:127.0.0.1"
|
|
2440
|
-
];
|
|
2441
|
-
/**
|
|
2442
|
-
* 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
|
|
2443
|
-
* 页面里对**非静态路径**的请求(`/`、`/index.html`、`/assets/*`、`/plugins/…` 之外的一切——包括
|
|
2444
|
-
* 我们这条前缀路由)都由 `protocol.handle("dsh-app")` 转给宿主自己的 web server;转发前它把
|
|
2445
|
-
* `host` / `origin` / `cookie` / `sec-fetch-site` 四个头**删掉**、换上一枚宿主自己的 cookie,路径与
|
|
2446
|
-
* 查询串原样带走(逐字:`for (const name of ["host","origin","cookie","sec-fetch-site"]) headers.delete(name)`)。
|
|
2447
|
-
* ⇒ 桌面版打到我们这条路由上的请求是**没有 `Origin`** 的,`Referer` 则保留(同源请求照常带,
|
|
2448
|
-
* 页面就住在 `dsh-app://app/`)。壳自己在上游只放行 `origin ∈ {null, "dsh-app://app"}`。
|
|
2449
|
-
*/
|
|
2450
|
-
const DESKTOP_APP_SCHEME = "dsh-app:";
|
|
2451
|
-
const DESKTOP_APP_HOST = "app";
|
|
2452
|
-
/**
|
|
2453
|
-
* 「这是桌面壳自己的页面吗」。**必须按 `protocol` + `host` 判,不能比 `URL.origin`**:`dsh-app:`
|
|
2454
|
-
* 不是 WHATWG 的 special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`(Node 实测),
|
|
2455
|
-
* 拿它跟字面量比永远为假 ⇒ 栅栏会把真机上的每一次请求都挡成 403,所有入口一个都不出现。
|
|
2456
|
-
*
|
|
2457
|
-
* 这个判据为什么不可能被外人伪造:`Origin` 与 `Referer` 都是 forbidden header,网页改不了,
|
|
2458
|
-
* 只能是浏览器/壳自己带上;而 `dsh-app://app` 只可能是桌面壳自己的页面。
|
|
2459
|
-
*/
|
|
2460
|
-
function isDesktopShellUrl(value) {
|
|
2461
|
-
try {
|
|
2462
|
-
const url = new URL(value);
|
|
2463
|
-
return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
|
|
2464
|
-
} catch {
|
|
2465
|
-
return false;
|
|
2466
|
-
}
|
|
2467
|
-
}
|
|
2468
3015
|
function writeJson(res, status, body) {
|
|
2469
3016
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
2470
3017
|
res.end(JSON.stringify(body));
|
|
@@ -2475,8 +3022,30 @@ function writeOk(res, value) {
|
|
|
2475
3022
|
value
|
|
2476
3023
|
});
|
|
2477
3024
|
}
|
|
3025
|
+
/**
|
|
3026
|
+
* 二进制响应(`/attachment` 专用)。四道头都是判据不是口味:
|
|
3027
|
+
* - `content-type` 由处理函数给(图片按魔数认,文件一律 octet-stream);
|
|
3028
|
+
* - `content-length` 与字节数同源;
|
|
3029
|
+
* - `cache-control: private, no-store`:附件可能来自已删除会话,不许被任何中间层留下;
|
|
3030
|
+
* - `nosniff` + `attachment` 下载名:字节内容与类型不一致时浏览器不许改猜。
|
|
3031
|
+
*
|
|
3032
|
+
* **失败不走这里**:处理函数抛出的错误仍由 `writeFailure` 写 JSON 信封(图片的 `onError` 会换成
|
|
3033
|
+
* 占位;普通文件走 `<a download>`,没有页面内失败面)。
|
|
3034
|
+
*/
|
|
3035
|
+
function writeBinary(res, body) {
|
|
3036
|
+
const headers = {
|
|
3037
|
+
"content-type": body.contentType,
|
|
3038
|
+
"content-length": String(body.body.length),
|
|
3039
|
+
"cache-control": "private, no-store",
|
|
3040
|
+
"x-content-type-options": "nosniff"
|
|
3041
|
+
};
|
|
3042
|
+
if (body.filename !== void 0) headers["content-disposition"] = `${body.disposition}; filename*=UTF-8''${encodeURIComponent(body.filename)}`;
|
|
3043
|
+
else if (body.disposition === "attachment") headers["content-disposition"] = "attachment";
|
|
3044
|
+
res.writeHead(200, headers);
|
|
3045
|
+
res.end(body.body);
|
|
3046
|
+
}
|
|
2478
3047
|
/** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
|
|
2479
|
-
*
|
|
3048
|
+
* **状态语义**:**请求本身不对**不再伪装成 200——请求体不对 400、方法不对 405、
|
|
2480
3049
|
* 未知路由 404、栅栏拒 403(它们不是"一次办成的操作",是"这条请求不归我办/你没资格办")。
|
|
2481
3050
|
* **操作失败**仍回 200 + 信封。浏览器半先解信封再看状态,所以 4xx 上带的信封照样读得出原文;
|
|
2482
3051
|
* `code` 一律折成 `sessiondelete/io`,那三个路由标签(`bad-request` / `bad-method` / `not-found-route`)
|
|
@@ -2488,38 +3057,10 @@ function writeFailure(res, error, status) {
|
|
|
2488
3057
|
error: toRemoteFailure(error)
|
|
2489
3058
|
});
|
|
2490
3059
|
}
|
|
2491
|
-
/**
|
|
2492
|
-
*
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
* 放行的三种形状,各自都是"浏览器/壳自动带上、网页改不了"的那种:
|
|
2496
|
-
* ① `Origin` 与 `Host` 同源(浏览器直接打开宿主那行 URL);② `Origin` 是桌面壳的 `dsh-app://app`
|
|
2497
|
-
* (壳自己在上游也只放行这个 origin);③ `Referer` 是桌面壳的 `dsh-app://app`(**真机那条路**:
|
|
2498
|
-
* 壳把 `Origin` 删掉了、只留下 Referer,见 {@link isDesktopShellUrl})。两者都缺席 = 本机工具 /
|
|
2499
|
-
* curl(注释即此承诺)。**恶意网页拿不到 ②③ 里的任何一个**:那两个头都是 forbidden header、
|
|
2500
|
-
* 页面伪造不了;而**写口(POST)在任何跨源形态下都必带 Origin** ⇒ 两个头全缺席就等于本机工具。
|
|
2501
|
-
* 唯一的缝是跨源 `no-cors` 的 **GET**(Referer 被 `no-referrer` 掐掉、Origin 也不带)——它够得到
|
|
2502
|
-
* 那几个 GET(`list` 会先对账写清册,`ping` / `orphans` 才是纯读),而 no-cors 的回包是 opaque、
|
|
2503
|
-
* 页面读不出内容 ⇒ 不构成泄露(`orphans` 那份报告也只报数量/字节,不含正文)。
|
|
2504
|
-
*/
|
|
2505
|
-
function isTrustedRequest(req) {
|
|
2506
|
-
if (!LOOPBACK.includes(req.socket.remoteAddress ?? "")) return false;
|
|
2507
|
-
const host = req.headers.host;
|
|
2508
|
-
const origin = req.headers.origin;
|
|
2509
|
-
if (origin !== void 0 && !isDesktopShellUrl(origin)) try {
|
|
2510
|
-
if (new URL(origin).host !== host) return false;
|
|
2511
|
-
} catch {
|
|
2512
|
-
return false;
|
|
2513
|
-
}
|
|
2514
|
-
const referer = req.headers.referer;
|
|
2515
|
-
if (referer === void 0) return true;
|
|
2516
|
-
if (isDesktopShellUrl(referer)) return true;
|
|
2517
|
-
try {
|
|
2518
|
-
return new URL(referer).host === host;
|
|
2519
|
-
} catch {
|
|
2520
|
-
return false;
|
|
2521
|
-
}
|
|
2522
|
-
}
|
|
3060
|
+
/** 栅栏拒绝的两种原文:归属不对(跨源 / 重绑 / 头不成形)与远程客户端没带宿主身份。
|
|
3061
|
+
* 两者都是 403,但客户端会上屏,原文要能告诉用户下一步做什么。 */
|
|
3062
|
+
const UNTRUSTED_MESSAGE = "非受信来源";
|
|
3063
|
+
const UNAUTHENTICATED_MESSAGE = "需要宿主的 Web 身份:请用 dsh web 打印的那一行 URL 打开页面后重试";
|
|
2523
3064
|
/** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 400 + 失败信封)。
|
|
2524
3065
|
* **请求本身不对**(400)与**那次操作读不到东西**(200 + 信封)是两件事:前者是调用方没组好请求,
|
|
2525
3066
|
* 后者是盘上没有;把前者也写成 200,代理与日志里就再也分不出这两类。 */
|
|
@@ -2552,9 +3093,9 @@ function isPure(purity, body) {
|
|
|
2552
3093
|
function readBodyFor(kind, req) {
|
|
2553
3094
|
return kind === "json" ? readJsonBody(req) : null;
|
|
2554
3095
|
}
|
|
2555
|
-
function createApiHandler(deps) {
|
|
3096
|
+
function createApiHandler(deps, trust) {
|
|
2556
3097
|
/**
|
|
2557
|
-
*
|
|
3098
|
+
* 到达序串行链(见文件头):路由表里 `purity === 'chained'` 的请求一个跑完再放下一个。
|
|
2558
3099
|
* `tail` 由构造保证**永不 reject**(每个任务自己的失败留在交回给那次请求的那个 promise 上),
|
|
2559
3100
|
* 所以一次失败不会卡死链。
|
|
2560
3101
|
*/
|
|
@@ -2565,8 +3106,9 @@ function createApiHandler(deps) {
|
|
|
2565
3106
|
return started;
|
|
2566
3107
|
};
|
|
2567
3108
|
return async function handle(req, res) {
|
|
2568
|
-
|
|
2569
|
-
|
|
3109
|
+
const verdict = judgeRequest(req, trust);
|
|
3110
|
+
if (verdict !== "allowed") {
|
|
3111
|
+
writeFailure(res, /* @__PURE__ */ new Error(verdict === "untrusted" ? UNTRUSTED_MESSAGE : UNAUTHENTICATED_MESSAGE), 403);
|
|
2570
3112
|
return;
|
|
2571
3113
|
}
|
|
2572
3114
|
try {
|
|
@@ -2577,14 +3119,40 @@ function createApiHandler(deps) {
|
|
|
2577
3119
|
if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${req.method ?? "GET"} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
|
|
2578
3120
|
requireMethod(req.method ?? "GET", spec.method, rest);
|
|
2579
3121
|
const body = await readBodyFor(spec.body, req);
|
|
2580
|
-
const
|
|
2581
|
-
|
|
3122
|
+
const kind = spec.read;
|
|
3123
|
+
let rowsPromise;
|
|
3124
|
+
const read = { rows() {
|
|
3125
|
+
if (kind === "none") return Promise.reject(/* @__PURE__ */ new Error("route read: none"));
|
|
3126
|
+
rowsPromise ??= kind === "list" ? deps.ledger.list() : deps.ledger.raw();
|
|
3127
|
+
return rowsPromise;
|
|
3128
|
+
} };
|
|
3129
|
+
const task = () => spec.run(deps, body, read, url.searchParams);
|
|
3130
|
+
const result = await (isPure(spec.purity, body) ? task() : throughChain(task));
|
|
3131
|
+
if (spec.response === "binary") writeBinary(res, result);
|
|
3132
|
+
else writeOk(res, result);
|
|
2582
3133
|
} catch (error) {
|
|
2583
3134
|
writeFailure(res, error);
|
|
2584
3135
|
}
|
|
2585
3136
|
};
|
|
2586
3137
|
}
|
|
2587
3138
|
//#endregion
|
|
3139
|
+
//#region src/ports/connection-port.ts
|
|
3140
|
+
const HOST_UNAUTHENTICATED = 401;
|
|
3141
|
+
/** 成员抛错按「归属不对」拒绝(fail-closed)。 */
|
|
3142
|
+
function createConnectionPort(ctx) {
|
|
3143
|
+
return { verdictFor: (req) => {
|
|
3144
|
+
const service = ctx.get("connection");
|
|
3145
|
+
if (service === void 0 || typeof service.requestRejection !== "function") return { kind: "absent" };
|
|
3146
|
+
try {
|
|
3147
|
+
const status = service.requestRejection(req);
|
|
3148
|
+
if (status === void 0) return { kind: "allowed" };
|
|
3149
|
+
return { kind: status === HOST_UNAUTHENTICATED ? "unauthenticated" : "untrusted" };
|
|
3150
|
+
} catch {
|
|
3151
|
+
return { kind: "untrusted" };
|
|
3152
|
+
}
|
|
3153
|
+
} };
|
|
3154
|
+
}
|
|
3155
|
+
//#endregion
|
|
2588
3156
|
//#region src/ports/fs-port.ts
|
|
2589
3157
|
/** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
|
|
2590
3158
|
* `this` 变成 undefined)。所以端口只做转发,递归在模块作用域里自调。 */
|
|
@@ -2605,7 +3173,13 @@ function createFsPort() {
|
|
|
2605
3173
|
await mkdir(p, { recursive: true });
|
|
2606
3174
|
},
|
|
2607
3175
|
async rename(from, to) {
|
|
2608
|
-
|
|
3176
|
+
if (existsSync(to)) throw new Error(`目标已存在,拒绝覆盖:${to}`);
|
|
3177
|
+
try {
|
|
3178
|
+
await rename(from, to);
|
|
3179
|
+
} catch (error) {
|
|
3180
|
+
if (error.code === "EXDEV") throw new Error(`跨文件系统搬不动(sessions 与 session-trash 需在同一卷):${from} → ${to}`, { cause: error });
|
|
3181
|
+
throw error;
|
|
3182
|
+
}
|
|
2609
3183
|
},
|
|
2610
3184
|
async removeDir(p) {
|
|
2611
3185
|
await rm(p, {
|
|
@@ -2618,10 +3192,30 @@ function createFsPort() {
|
|
|
2618
3192
|
return readdir(p);
|
|
2619
3193
|
},
|
|
2620
3194
|
async listDirs(p) {
|
|
2621
|
-
|
|
3195
|
+
const dirs = [];
|
|
3196
|
+
for (const entry of await readdir(p, { withFileTypes: true })) {
|
|
3197
|
+
if (entry.isDirectory()) {
|
|
3198
|
+
dirs.push(entry.name);
|
|
3199
|
+
continue;
|
|
3200
|
+
}
|
|
3201
|
+
if (!entry.isSymbolicLink()) continue;
|
|
3202
|
+
try {
|
|
3203
|
+
if ((await stat(join(p, entry.name))).isDirectory()) dirs.push(entry.name);
|
|
3204
|
+
} catch {}
|
|
3205
|
+
}
|
|
3206
|
+
return dirs;
|
|
3207
|
+
},
|
|
3208
|
+
async realpath(p) {
|
|
3209
|
+
return realpath(p);
|
|
2622
3210
|
},
|
|
2623
3211
|
async readFile(p) {
|
|
2624
3212
|
return readFile(p);
|
|
3213
|
+
},
|
|
3214
|
+
async statFile(p) {
|
|
3215
|
+
return { size: (await stat(p)).size };
|
|
3216
|
+
},
|
|
3217
|
+
async removeFile(p) {
|
|
3218
|
+
await rm(p, { force: true });
|
|
2625
3219
|
}
|
|
2626
3220
|
};
|
|
2627
3221
|
}
|
|
@@ -2653,6 +3247,125 @@ function resolveHome(env = process.env, homeDir = homedir()) {
|
|
|
2653
3247
|
return resolve(raw);
|
|
2654
3248
|
}
|
|
2655
3249
|
//#endregion
|
|
3250
|
+
//#region src/trash/reconcile.ts
|
|
3251
|
+
/**
|
|
3252
|
+
* 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(CONTEXT.md 的「对账」条)。
|
|
3253
|
+
*
|
|
3254
|
+
* 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
|
|
3255
|
+
* `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
|
|
3256
|
+
*
|
|
3257
|
+
* 五条口径(都不是口味):
|
|
3258
|
+
* ① **每次真相读都对账**(`src/trash/ledger.ts` 的 `list()`;`GET /list` 的正文就是"对账 + 读"):挂载、
|
|
3259
|
+
* 刷新、别处动过后的自动重读、客户端挂载后的首次列表读全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。
|
|
3260
|
+
* pure 读面(`/orphans`、`/detail`)走 `raw()`,不对账——对账会写行,必须排在到达序链上。
|
|
3261
|
+
* 代价 = 每次真相读多一次目录枚举(本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
|
|
3262
|
+
* ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
|
|
3263
|
+
* (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
|
|
3264
|
+
* ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
|
|
3265
|
+
* `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
|
|
3266
|
+
* `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
|
|
3267
|
+
* 落在「未分组」)、`wasArchived: false`(文件侧没有归档事实;旧条目恢复走迁移支)、`title: ''`(详情有日志标题兜底)
|
|
3268
|
+
* ——这是重建的固有代价,不是可以"再读读看"的东西。
|
|
3269
|
+
* ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
|
|
3270
|
+
* 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
|
|
3271
|
+
* ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
|
|
3272
|
+
*
|
|
3273
|
+
* 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
|
|
3274
|
+
* 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
|
|
3275
|
+
* 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
|
|
3276
|
+
*/
|
|
3277
|
+
/** 目录名是不是一条条目 id(形状契约在 `entry.ts`:`<删除时刻UTC>-<原目录名>`;
|
|
3278
|
+
* 原目录名还必须能安全地拼回磁盘路径——带分隔符的名字仍按"外人"跳过,不让一次对账读崩)。 */
|
|
3279
|
+
function isEntryId(name) {
|
|
3280
|
+
try {
|
|
3281
|
+
return isSinglePathSegment(parseEntryId(name).sessionDirName);
|
|
3282
|
+
} catch {
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
/**
|
|
3287
|
+
* 从一个手工放进回收站的目录重建清单行。**任一件事实拿不到就整体放弃这一条**(回 `undefined`):
|
|
3288
|
+
* 半截行比"不列"更坏——恢复会照它把目录搬去一个错的地方(同 `detail.ts` 那条"半截正文"的口径)。
|
|
3289
|
+
*
|
|
3290
|
+
* `sessionId` 取日志 header 的 `id`(恢复之后宿主按日志 header 建索引,那才是会话身份);
|
|
3291
|
+
* header 缺席这个字段才回落到目录名里那段原目录名。
|
|
3292
|
+
*/
|
|
3293
|
+
async function buildEntry(deps, id) {
|
|
3294
|
+
const dir = trashDirOf(id, deps.home);
|
|
3295
|
+
let logName;
|
|
3296
|
+
try {
|
|
3297
|
+
logName = pickSessionLog(await deps.fs.listFiles(dir));
|
|
3298
|
+
} catch {
|
|
3299
|
+
return;
|
|
3300
|
+
}
|
|
3301
|
+
if (logName === void 0) return void 0;
|
|
3302
|
+
let raw;
|
|
3303
|
+
try {
|
|
3304
|
+
raw = await deps.fs.readFile(`${dir}/${logName}`);
|
|
3305
|
+
} catch {
|
|
3306
|
+
return;
|
|
3307
|
+
}
|
|
3308
|
+
let header;
|
|
3309
|
+
try {
|
|
3310
|
+
header = readSessionHeader(raw);
|
|
3311
|
+
} catch {
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
if (header === void 0) return void 0;
|
|
3315
|
+
let sizeBytes;
|
|
3316
|
+
try {
|
|
3317
|
+
sizeBytes = await deps.fs.sizeOfDir(dir);
|
|
3318
|
+
} catch {
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
const { deletedAt, sessionDirName } = parseEntryId(id);
|
|
3322
|
+
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
3323
|
+
return {
|
|
3324
|
+
id,
|
|
3325
|
+
sessionId: typeof header.id === "string" && header.id !== "" ? header.id : sessionDirName,
|
|
3326
|
+
projectDir: cwd === "" ? NO_CWD_PROJECT_DIR : projectKey(cwd),
|
|
3327
|
+
workspaceId: "",
|
|
3328
|
+
wasArchived: false,
|
|
3329
|
+
deletedAt,
|
|
3330
|
+
title: "",
|
|
3331
|
+
sizeBytes
|
|
3332
|
+
};
|
|
3333
|
+
}
|
|
3334
|
+
/** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
|
|
3335
|
+
async function reconcileTrash(deps) {
|
|
3336
|
+
const rows = await deps.manifest.list();
|
|
3337
|
+
const root = `${deps.home}/${TRASH_DIR_NAME}`;
|
|
3338
|
+
if (!await deps.fs.exists(root)) return rows;
|
|
3339
|
+
const dirIds = /* @__PURE__ */ new Set();
|
|
3340
|
+
for (const name of await deps.fs.listDirs(root)) if (isEntryId(name)) dirIds.add(name);
|
|
3341
|
+
const rowIds = new Set(rows.map((row) => row.id));
|
|
3342
|
+
const drops = rows.filter((row) => !dirIds.has(row.id));
|
|
3343
|
+
const rebuilds = [];
|
|
3344
|
+
for (const id of dirIds) {
|
|
3345
|
+
if (rowIds.has(id)) continue;
|
|
3346
|
+
const entry = await buildEntry(deps, id);
|
|
3347
|
+
if (entry !== void 0) rebuilds.push(entry);
|
|
3348
|
+
}
|
|
3349
|
+
if (drops.length === 0 && rebuilds.length === 0) return rows;
|
|
3350
|
+
for (const row of drops) await deps.manifest.remove(row.id);
|
|
3351
|
+
for (const entry of rebuilds) await deps.manifest.add(entry);
|
|
3352
|
+
return await deps.manifest.list();
|
|
3353
|
+
}
|
|
3354
|
+
//#endregion
|
|
3355
|
+
//#region src/trash/ledger.ts
|
|
3356
|
+
function createTrashLedger(raw, fs, home) {
|
|
3357
|
+
return {
|
|
3358
|
+
list: () => reconcileTrash({
|
|
3359
|
+
fs,
|
|
3360
|
+
manifest: raw,
|
|
3361
|
+
home
|
|
3362
|
+
}),
|
|
3363
|
+
raw: () => raw.list(),
|
|
3364
|
+
add: (entry) => raw.add(entry),
|
|
3365
|
+
remove: (id) => raw.remove(id)
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
//#endregion
|
|
2656
3369
|
//#region src/trash/manifest.ts
|
|
2657
3370
|
function createStorageManifest(domain) {
|
|
2658
3371
|
return {
|
|
@@ -2669,7 +3382,7 @@ function createStorageManifest(domain) {
|
|
|
2669
3382
|
}
|
|
2670
3383
|
//#endregion
|
|
2671
3384
|
//#region src/trash/storage-manifest.ts
|
|
2672
|
-
/** 本插件自己的域(CONTEXT.md
|
|
3385
|
+
/** 本插件自己的域(CONTEXT.md 的「清册」条:介质落在数据根下的 `storages/`)。
|
|
2673
3386
|
*
|
|
2674
3387
|
* **名字必须带插件专属前缀**:domain 名是 `storageDomain` facility 里的**全局键**,而**一个名字在
|
|
2675
3388
|
* 一个 facility 里只能 `open()` 一次**(句柄归调用方所有)——别人先占了同名域,本插件会**在激活时**
|
|
@@ -2719,7 +3432,7 @@ const trashDomainSpec = defineDomain({
|
|
|
2719
3432
|
* 清单的真实绑定:官方 storage domain → `ManifestPort`(`StorageDomainLike` 在动词层保持一种读法)。
|
|
2720
3433
|
* 域**懒开**且成功句柄只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
|
|
2721
3434
|
* 插件卸载时关掉句柄并把 `close()` 的落地交回框架(句柄归调用方所有,这是 `open()` 的 JSDoc 明写的契约;
|
|
2722
|
-
*
|
|
3435
|
+
* 不能 `void` 走掉——没关严的句柄会让下一份同名域撞 `already-open`)。
|
|
2723
3436
|
*
|
|
2724
3437
|
* **失败不缓存**:`open()` 被拒是瞬态(域被别的持有者占着、装载竞态),把第一次的 rejected promise
|
|
2725
3438
|
* 缓存起来等于"这个会话永远删不掉"——下一次读会重新走 `open()`,成功才落下活句柄。
|
|
@@ -2770,11 +3483,16 @@ const name = "@xrn1997/dsh-session-delete";
|
|
|
2770
3483
|
* 它同样可能晚于本插件激活,写进插件 inject 只会让整个插件 pending。
|
|
2771
3484
|
*
|
|
2772
3485
|
* `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
|
|
2773
|
-
* 前缀路由,浏览器半同源 fetch
|
|
3486
|
+
* 前缀路由,浏览器半同源 fetch 那九个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
|
|
2774
3487
|
* `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
|
|
2775
3488
|
* `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
|
|
2776
3489
|
*
|
|
2777
|
-
*
|
|
3490
|
+
* `connection` 只喂请求栅栏的远程支(`src/api/request-trust.ts` ②),按 `ctx.get` 可选读:
|
|
3491
|
+
* 服务缺席时插件照常激活,远程客户端 fail-closed。它不进 `inject`——写进去会让没有 web 组成的
|
|
3492
|
+
* profile 整个停在 pending。
|
|
3493
|
+
*
|
|
3494
|
+
* 清册的三层各有各的家:raw 绑定 `src/trash/storage-manifest.ts`、读面(对账 + 读)`src/trash/ledger.ts`、
|
|
3495
|
+
* 数据根 `src/ports/home.ts`。这里只把它们接起来,任何一层都不在组合根里长判据。
|
|
2778
3496
|
*/
|
|
2779
3497
|
const inject = [
|
|
2780
3498
|
"workspaceRegistry",
|
|
@@ -2782,26 +3500,35 @@ const inject = [
|
|
|
2782
3500
|
"webServer"
|
|
2783
3501
|
];
|
|
2784
3502
|
/**
|
|
2785
|
-
* 装配顺序:**清单 → 端口 → 动词依赖 → 前缀路由**(`DeleteDeps`
|
|
3503
|
+
* 装配顺序:**清单 → 端口 → 在场能力 → 动词依赖 → 前缀路由**(`DeleteDeps` 把这些依赖面合成一颗)。
|
|
2786
3504
|
*
|
|
2787
3505
|
* 路由用 `ctx.effect` 挂、disposer 归装载它的那根 fiber:插件卸载即摘路由(`register` 返回的
|
|
2788
3506
|
* disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(kind, path)`
|
|
2789
3507
|
* 在 `register` 里直接抛(`webserver: duplicate prefix route "…"`),响亮失败而不是悄悄覆盖。
|
|
2790
3508
|
*/
|
|
2791
3509
|
function apply(ctx) {
|
|
3510
|
+
const fs = createFsPort();
|
|
3511
|
+
const home = resolveHome();
|
|
3512
|
+
const rawManifest = createHostStorageManifest(ctx);
|
|
3513
|
+
const host = createHostPort(ctx);
|
|
2792
3514
|
const deps = {
|
|
2793
|
-
fs
|
|
2794
|
-
host
|
|
2795
|
-
|
|
2796
|
-
home
|
|
3515
|
+
fs,
|
|
3516
|
+
host,
|
|
3517
|
+
ledger: createTrashLedger(rawManifest, fs, home),
|
|
3518
|
+
home,
|
|
3519
|
+
presence: createMemberPresence(host),
|
|
2797
3520
|
now: () => Date.now()
|
|
2798
3521
|
};
|
|
2799
3522
|
const webServer = ctx.webServer;
|
|
3523
|
+
const connection = createConnectionPort(ctx);
|
|
2800
3524
|
ctx.effect(() => {
|
|
2801
3525
|
const dispose = webServer.register({
|
|
2802
3526
|
kind: "prefix",
|
|
2803
3527
|
path: SESSION_DELETE_API_PREFIX,
|
|
2804
|
-
handler: createApiHandler(deps
|
|
3528
|
+
handler: createApiHandler(deps, {
|
|
3529
|
+
bindHost: webServer.host,
|
|
3530
|
+
connection
|
|
3531
|
+
})
|
|
2805
3532
|
});
|
|
2806
3533
|
return () => {
|
|
2807
3534
|
dispose();
|