@xrn1997/dsh-session-delete 0.3.1 → 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.
Files changed (64) hide show
  1. package/README.md +20 -14
  2. package/lib/client.js +1686 -1053
  3. package/lib/index.js +1387 -771
  4. package/lib/types/api/attachment-orphans.d.ts +11 -0
  5. package/lib/types/api/attachment.d.ts +23 -0
  6. package/lib/types/api/dispatch.d.ts +9 -29
  7. package/lib/types/api/request-trust.d.ts +45 -0
  8. package/lib/types/api/route-table.d.ts +28 -14
  9. package/lib/types/client/attachment-view.d.ts +27 -0
  10. package/lib/types/client/batch-bar.d.ts +1 -1
  11. package/lib/types/client/batch-confirm.d.ts +2 -2
  12. package/lib/types/client/batch-menu-item.d.ts +1 -1
  13. package/lib/types/client/batch-select.d.ts +1 -1
  14. package/lib/types/client/confirm-close.d.ts +11 -0
  15. package/lib/types/client/copy.d.ts +48 -38
  16. package/lib/types/client/delete-confirm.d.ts +0 -2
  17. package/lib/types/client/main-view.d.ts +1 -1
  18. package/lib/types/client/maintenance-parts.d.ts +25 -0
  19. package/lib/types/client/maintenance-settings.d.ts +22 -0
  20. package/lib/types/client/markdown.d.ts +9 -1
  21. package/lib/types/client/orphan-maintenance.d.ts +29 -11
  22. package/lib/types/client/receipt.d.ts +42 -2
  23. package/lib/types/client/remote.d.ts +32 -48
  24. package/lib/types/client/trash-confirm.d.ts +2 -15
  25. package/lib/types/client/trash-panel.d.ts +4 -3
  26. package/lib/types/client/ui/Checkbox.d.ts +1 -1
  27. package/lib/types/client/ui/Modal.d.ts +2 -2
  28. package/lib/types/client/ui/icons.d.ts +6 -11
  29. package/lib/types/client/ui/top-layer.d.ts +10 -0
  30. package/lib/types/client/undo-notices.d.ts +52 -0
  31. package/lib/types/client/undo-toast.d.ts +5 -59
  32. package/lib/types/client/use-column-resize.d.ts +9 -20
  33. package/lib/types/client/use-maintenance.d.ts +45 -0
  34. package/lib/types/client/use-panel-width.d.ts +1 -1
  35. package/lib/types/client/use-trash-panel-controller.d.ts +24 -7
  36. package/lib/types/client/use-trash-query.d.ts +35 -32
  37. package/lib/types/client/write-seats.d.ts +5 -13
  38. package/lib/types/client/write-settlement.d.ts +10 -16
  39. package/lib/types/client/write.d.ts +8 -5
  40. package/lib/types/deps.d.ts +18 -7
  41. package/lib/types/index.d.ts +6 -2
  42. package/lib/types/log/session-log.d.ts +4 -3
  43. package/lib/types/log/zstd-frames.d.ts +14 -8
  44. package/lib/types/ports/agent-handles.d.ts +1 -1
  45. package/lib/types/ports/connection-port.d.ts +33 -0
  46. package/lib/types/ports/fs-port.d.ts +13 -1
  47. package/lib/types/ports/host-port.d.ts +17 -45
  48. package/lib/types/ports/presence-port.d.ts +22 -0
  49. package/lib/types/shared/wire.d.ts +70 -26
  50. package/lib/types/trash/attachments.d.ts +85 -0
  51. package/lib/types/trash/detail.d.ts +28 -8
  52. package/lib/types/trash/entry.d.ts +3 -3
  53. package/lib/types/trash/groups.d.ts +4 -11
  54. package/lib/types/trash/storage-manifest.d.ts +1 -1
  55. package/lib/types/verbs/delete-session.d.ts +2 -2
  56. package/lib/types/verbs/errors.d.ts +2 -0
  57. package/lib/types/verbs/live-admission.d.ts +4 -7
  58. package/lib/types/verbs/member-journal.d.ts +3 -4
  59. package/lib/types/verbs/member-presence.d.ts +14 -37
  60. package/lib/types/verbs/orphan-subagents.d.ts +9 -8
  61. package/lib/types/verbs/permanent-removal.d.ts +24 -18
  62. package/lib/types/verbs/purge-trash.d.ts +8 -3
  63. package/lib/types/verbs/restore-session.d.ts +7 -13
  64. package/package.json +2 -1
@@ -1,18 +1,9 @@
1
1
  /**
2
- * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
3
- * Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
4
- * 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
2
+ * 跨半 wire 契约:前缀、九个端点、请求/响应形状与失败码。
3
+ * Node 半(src/api/dispatch.ts)与浏览器半(src/client/remote.ts)共用这一份,不各写字面量。
5
4
  *
6
- * 两条构建期硬约束(不是偏好):
7
- * ① tsdown 的浏览器半纯度门拦 Node 内建与平台模块表外的 `@deepseek-ai/*` 值 import——
8
- * 本文件两者都不涉及,且**零运行时依赖**(client 半把它整个 inline 进 bundle);
9
- * ② 跨插件一律 `import type`——本文件干脆没有 import。
10
- *
11
- * 信封(`RemoteResult`)与失败形状(`RemoteFailure`)也在这里——**因为它们本来就是两半的共同契约**:
12
- * Node 半按它产出(`src/api/dispatch.ts` 的失败支就是 `RemoteFailure` 那三件),浏览器半按它判别
13
- * (`src/client/remote.ts` 的 `unwrapRemoteResult` / `failureOf`)。形状取自宿主网关客户端面的原文
14
- * 「每次 unary 调用 resolve 成 RemoteResult、不为载体问题 reject」,但**它是本仓自己的 wire 契约**,
15
- * 不是对宿主模块的 import。
5
+ * 构建约束:本文件零运行时依赖、无 import;浏览器半把整个文件 inline 进 bundle。
6
+ * 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
16
7
  */
17
8
  /**
18
9
  * 插件专属前缀。
@@ -22,15 +13,18 @@
22
13
  * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
23
14
  */
24
15
  export declare const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
25
- /** 六个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
26
- * 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
27
- * - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
28
- * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
29
- * - `POST /detail`:单条详情(用 POST 表达一个读:判据就写在 `src/api/route-table.ts` 那一行的上方)。
16
+ /** 九个段名(相对前缀)。写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
17
+ * 单条就是长度为 1 的批量,仓里不留第二条执行路径。
18
+ * - `GET /list`:对账并读清单(`session-trash/` 里的目录是准)。
19
+ * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
20
+ * - `POST /detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts`)。
30
21
  * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
31
- * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
32
- * - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
33
- * 第五个动作,这一段只负责"有多少、在哪、多大"。 */
22
+ * - `POST /empty`:清空回收站(聚合回执,不按条回)。
23
+ * - `GET /orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
24
+ * 这一段只负责"有多少、在哪、多大"。
25
+ * - `GET /attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
26
+ * - `GET /attachment-orphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
27
+ * - `POST /attachment-clean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
34
28
  export declare const ROUTES: {
35
29
  readonly list: "/list";
36
30
  /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
@@ -43,9 +37,17 @@ export declare const ROUTES: {
43
37
  readonly empty: "/empty";
44
38
  /** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
45
39
  readonly orphans: "/orphans";
40
+ /** 附件字节(图片/普通文件):全仓唯一的二进制响应段,读的是宿主附件库。 */
41
+ readonly attachment: "/attachment";
42
+ /** 纯读:扫全部引用源与附件库 `objects`,报无引用图片的数量、字节与预览清单。 */
43
+ readonly attachmentOrphans: "/attachment-orphans";
44
+ /** 聚合清理:只删 `objects` 下无引用的图片,回 `AttachmentCleanupWire`。 */
45
+ readonly attachmentClean: "/attachment-clean";
46
46
  };
47
47
  /** `/apply` 的五个动作。id 的含义随动作走:`delete`/`count`/`orphan-clean` 是**宿主会话 id**
48
- * (前两个还没进回收站,第三个压根没有回收站行),`restore`/`purge` 是**回收站条目 id**。 */
48
+ * (前两个还没进回收站,第三个压根没有回收站行),`restore`/`purge` 是**回收站条目 id**。
49
+ * 两域都是 string(线上形状);这里不引入品牌类型——错域不靠类型拦,交给各动词在运行期按自己的
50
+ * 语义处理,批处理不猜格式。 */
49
51
  export type ApplyAction = 'delete' | 'restore' | 'purge' | 'count' | 'orphan-clean';
50
52
  /** 每个动作的成功值:`count` 是一个数(拿不到就是 0,界面上那半句整句不出现);
51
53
  * `delete`/`restore` 是回收站那一行(`null` = 这一条已被**同批前一条**带走,幂等 no-op);
@@ -112,12 +114,25 @@ export interface TrashRowWire extends TrashEntryWire {
112
114
  /** 删除时记下的工作区**已经不在账本里**(宿主的「删除工作区」只删注册、保留日志)。 */
113
115
  workspaceMissing?: boolean;
114
116
  }
115
- /** 单条详情里的一条消息(`trash/detail.ts` 读出来的那一份)。 */
117
+ /** 单条详情里的一条附件读数(`trash/attachments.ts` 的 `attachmentsInContent` 产出、详情投影上线)。
118
+ * `attachmentId` 是宿主附件库的 sha256 键,也是 `GET /attachment` 的唯一寻址依据。 */
119
+ export interface TrashDetailAttachmentWire {
120
+ attachmentId: string;
121
+ kind: 'image' | 'file';
122
+ name?: string;
123
+ mediaType?: string;
124
+ bytes: number;
125
+ width?: number;
126
+ height?: number;
127
+ }
128
+ /** 单条详情里的一条消息(`trash/detail.ts` 读出来的那一份)。
129
+ * `attachments` 必填:没有附件就是空数组(界面上"这一条带没带附件"不靠字段缺席判断)。 */
116
130
  export interface TrashDetailMessageWire {
117
131
  role: 'user' | 'assistant';
118
132
  time: number;
119
133
  text: string;
120
134
  truncated: boolean;
135
+ attachments: TrashDetailAttachmentWire[];
121
136
  }
122
137
  /** 回收站单条详情的线上形状(`POST /detail`)。 */
123
138
  export interface TrashDetailWire {
@@ -137,10 +152,33 @@ export interface TrashDetailWire {
137
152
  messages: TrashDetailMessageWire[];
138
153
  omittedByPayloadCap: number;
139
154
  }
140
- /** 清空回收站的回执(与旧 `purge` 同形)。 */
155
+ /** 一次永久删除顺带、或一次孤儿附件清理的附件读数。`skipped` 有值 = 这一轮**部分或整体没清**:
156
+ * 有候选日志或剩余引用源读不出(原因上屏)。此时 `removed` 仍可能 > 0——损坏的只是某一组候选,
157
+ * 其余照常清;跳过不是失败:`failed` 是逐文件真出错的那几件。 */
158
+ export interface AttachmentCleanupWire {
159
+ removed: number;
160
+ freedBytes: number;
161
+ failed: number;
162
+ skipped?: string;
163
+ }
164
+ /** 孤儿附件报告里的一条:内容哈希 + 实测字节。没有原始文件名(附件库只存内容)。 */
165
+ export interface AttachmentOrphanItemWire {
166
+ attachmentId: string;
167
+ bytes: number;
168
+ }
169
+ /** 孤儿附件报告(`GET /attachment-orphans`):数量、总字节与对象清单。
170
+ * 清单只带 id/bytes(不含路径/文件名),供设置页预览缩略图,最多 200 条;数量与字节按全量算。
171
+ * 读不出任何一处引用源时**不返回这一形状**,而是 `io` 失败——不拿 0 冒充"没有孤儿"。 */
172
+ export interface AttachmentOrphanReportWire {
173
+ count: number;
174
+ totalBytes: number;
175
+ items: AttachmentOrphanItemWire[];
176
+ }
177
+ /** 清空回收站的聚合回执。`attachments` 缺席 = 这一版/这一轮没有附件清理读数(不拿零冒充"清过")。 */
141
178
  export interface PurgeStatsWire {
142
179
  removed: number;
143
180
  freedBytes: number;
181
+ attachments?: AttachmentCleanupWire;
144
182
  }
145
183
  /** 无主子会话扫描里**一个项目目录**的汇总(界面上那条"哪一家还剩多少"的读数)。 */
146
184
  export interface OrphanProjectGroupWire {
@@ -157,16 +195,22 @@ export interface OrphanReportWire {
157
195
  missingParents: number;
158
196
  groups: readonly OrphanProjectGroupWire[];
159
197
  }
160
- /** 清理一组无主子会话的回执(与 {@link PurgeStatsWire} 同形,但计的是"删掉的会话目录")。 */
198
+ /** 清理一组无主子会话的回执(与 {@link PurgeStatsWire} 同形,但计的是"删掉的会话目录")。
199
+ * `attachments` 的口径与 purge 相同。 */
161
200
  export interface OrphanCleanStatsWire {
162
201
  removed: number;
163
202
  freedBytes: number;
203
+ attachments?: AttachmentCleanupWire;
164
204
  }
165
- /** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
205
+ /** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
206
+ * 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
166
207
  export declare const PARAMS: {
167
208
  readonly entryId: "entryId";
168
209
  readonly action: "action";
169
210
  readonly ids: "ids";
211
+ readonly attachmentId: "id";
212
+ readonly attachmentKind: "kind";
213
+ readonly attachmentName: "name";
170
214
  };
171
215
  /**
172
216
  * 失败信封的**线上形状**(本文件):`code` 是判别依据、`message` 是上屏原文、
@@ -0,0 +1,85 @@
1
+ import type { FsPort } from '../ports/fs-port.js';
2
+ import { type AttachmentCleanupWire } from '../shared/wire.js';
3
+ export interface ImageRef {
4
+ kind: 'image';
5
+ id: string;
6
+ mediaType?: string;
7
+ bytes: number;
8
+ width?: number;
9
+ height?: number;
10
+ name?: string;
11
+ }
12
+ export interface FileRef {
13
+ kind: 'file';
14
+ id: string;
15
+ name: string;
16
+ bytes: number;
17
+ }
18
+ export type AttachmentRef = ImageRef | FileRef;
19
+ export declare function isAttachmentId(value: unknown): value is string;
20
+ /** `statFile` / `readFile` / `sizeOfDir` 里只有 ENOENT 能当"不在";权限、盘挂、I/O 错误都必须露成读不出。 */
21
+ export declare function isMissing(error: unknown): boolean;
22
+ /** 合法附件 ID → 宿主附件库里的绝对路径。`name` 不参与拼接。 */
23
+ export declare function attachmentPathOf(home: string, kind: 'image' | 'file', id: string): string;
24
+ /** 一条消息的 `content[]` 里的附件块;畸形 id / 非附件段一律跳过。 */
25
+ export declare function attachmentsInContent(content: unknown): AttachmentRef[];
26
+ export interface AttachmentReadDeps {
27
+ fs: Pick<FsPort, 'exists' | 'listFiles' | 'listDirs' | 'readFile' | 'statFile' | 'realpath'>;
28
+ home: string;
29
+ }
30
+ export interface AttachmentRemovalDeps extends AttachmentReadDeps {
31
+ fs: Pick<FsPort, 'exists' | 'listFiles' | 'listDirs' | 'readFile' | 'statFile' | 'realpath' | 'removeFile'>;
32
+ }
33
+ /** 一个会话目录里的图片引用;`unreadable` 有值 = 这一组不可知(调用方按"跳过它"处理)。
34
+ *
35
+ * **没有规范 zstd 日志 ≠ 没有引用**:明文日志、别的版本/布局、以及子目录都可能藏着引用。
36
+ * **有规范日志但目录里还有子目录也不够**:日志只覆盖本目录,子目录里的引用读它看不见——
37
+ * 同样按不可读处理(宁可留下对象等维护卡兜底,不冒"没引用")。
38
+ * **规范日志旁的文件也要过关**:老一代规范日志可以并存(新代包含旧代);0 字节临时日志是
39
+ * 写盘未完成的空产物,也没有引用。非空临时日志、明文日志或其他任何文件仍按不可读处理(fail closed)。
40
+ */
41
+ export declare function imageIdsOfDir(deps: AttachmentReadDeps, dir: string): Promise<{
42
+ ids: Set<string>;
43
+ unreadable?: string;
44
+ }>;
45
+ interface PlannedImageRemoval {
46
+ id: string;
47
+ path: string;
48
+ bytes: number;
49
+ }
50
+ export interface ImageRemovalPlan {
51
+ entries: readonly PlannedImageRemoval[];
52
+ bytes: number;
53
+ skipped?: string;
54
+ }
55
+ /**
56
+ * 独占计划:候选 = 本次被删会话日志里的图片 id;剩余引用 = 活动会话 + 全部未删 `session-trash`
57
+ * 目录 + storages/cache。
58
+ * 剩余引用源读不出 ⇒ 整体跳过;候选日志读不出 / 附件对象 stat 失败 ⇒ 只跳过那一部分,其余照常。
59
+ */
60
+ export declare function planImageAttachmentRemoval(deps: AttachmentReadDeps, removedDirs: readonly string[]): Promise<ImageRemovalPlan>;
61
+ /**
62
+ * 逐文件删;ENOENT 由端口的 `force` 吞掉;其他错误计数并继续。
63
+ *
64
+ * 删除动作只认「`home` + `id` 自己推导出来的那一条路径」:计划是数据,手搭的/被改过的条目
65
+ * 只要路径与推导不一致就拒绝(计数为失败,不删),源文件因此没有删除入口。
66
+ */
67
+ export declare function executeImageAttachmentRemoval(deps: AttachmentRemovalDeps, plan: ImageRemovalPlan): Promise<{
68
+ removed: number;
69
+ freedBytes: number;
70
+ failed: number;
71
+ }>;
72
+ /**
73
+ * 扫「当前没有任何引用源引用」的图片对象。
74
+ *
75
+ * 引用侧与删除计划共用同一条判据:活动会话 + 全部 `session-trash` 目录 + `storages/cache` JSON。
76
+ * 任何一处读不出 ⇒ 整份报告跳过(fail closed),绝不把"读不出"折成"没有引用"。
77
+ * 候选侧只认 `attachments/v1/objects` 下 `<2 hex>/<64 hex>` 这一种真实形状:`file-objects` /
78
+ * `tmp` / `v2` / `cache` 与非规范名字一律不进候选——清理只处理 `.dsh` 内的图片副本。
79
+ *
80
+ * 对象列表或 stat 读不出同样整份跳过:报告宁可失败,也不给一份"少算了一些"的清理计划。
81
+ */
82
+ export declare function scanOrphanImageAttachments(deps: AttachmentReadDeps): Promise<ImageRemovalPlan>;
83
+ /** 清掉扫描那一刻无引用的图片对象;扫描跳过 ⇒ 零删,并原样带回跳过的原因。 */
84
+ export declare function cleanOrphanImageAttachments(deps: AttachmentRemovalDeps): Promise<AttachmentCleanupWire>;
85
+ export {};
@@ -1,20 +1,40 @@
1
- import type { TrashLedger } from './ledger.js';
1
+ /**
2
+ * 回收站**单条详情**:从回收站目录里那条会话自己的日志读出元信息与正文(用例见 `tests/trash-detail.test.ts`)。
3
+ *
4
+ * 三层里的策略层——编解码在 `src/log/*`,这里只回答"给谁看、给多少、算不算失败"。
5
+ *
6
+ * 三条硬口径,都不是口味:
7
+ * ① **寻址只认清册(raw 索引)**:`entryId` 先在对账之外的 raw 索引里查,目录一律由 `trashDirOf(record.id, home)` 推
8
+ * ⇒ 外部输入进不了文件系统寻址(不给"传个路径就读任意目录"留缝)。
9
+ * ② **正文只认 `source.kind === "user"`**:`user/message` 里绝大多数是宿主注入的上下文
10
+ * (本机实测一条会话:真实提问 21 条、注入 241 条),不过滤就被淹掉;注入条数照样上线
11
+ * (`injectedCount`),让"为什么少了这么多条"能与页面上的数字对账。
12
+ * ③ **两个闸是载荷闸,不是读盘闸**:每条 3 000 字符、累计 50 万字符(CJK 最坏 ≈ 1.5 MB UTF-8)。
13
+ * 本机存量一条都触发不了(量级随盘上会话数摆动,不在这里钉数)⇒ 它们是防异常
14
+ * 膨胀的;**不设"文件太大就拒绝"**,那种阈值只会把真读得出来的正文挡在外面。
15
+ *
16
+ * 计数与正文**同源过滤、各过各的闸**:`user/message` 先要求 `source.kind === 'user'`,两类都过"空文本**且**
17
+ * 无附件"过滤;`userMessageCount` / `assistantMessageCount` 数的是通过过滤的条数,**不受载荷闸影响**。正文再受
18
+ * 单条/累计两个闸:单条超限截断并把那一条标成 `truncated`,累计到上限后剩下的整条不再收、只累进
19
+ * `omittedByPayloadCap`——所以计数可以大于正文条数,详情里两件事分开说。
20
+ * 附件随消息一起收(`attachmentsInContent`),也有两个闸:每条 20、总量 200;超出的**静默丢**,
21
+ * 不累进 `omittedByPayloadCap`——那个计数说的是"整条正文没进来",拿它兼报少了几张附件会让两个数都对不上。
22
+ */
23
+ import { type TrashEntry } from './entry.js';
2
24
  import type { FsPort } from '../ports/fs-port.js';
3
- import type { TrashDetailMessageWire, TrashDetailWire } from '../shared/wire.js';
25
+ import type { TrashDetailWire } from '../shared/wire.js';
4
26
  /** 每条文字的上限(字符)。超出即截断并置 `truncated`。 */
5
27
  export declare const PER_MESSAGE_CHAR_CAP = 3000;
6
28
  /** 一次回信的正文总量上限(字符)。 */
7
29
  export declare const TOTAL_CHAR_CAP = 500000;
8
- /** 详情正文里的一条消息 / 整份详情:**线上形状的家在 `shared/wire.ts`**(ADR-0003)——
9
- * Node 半产出它,浏览器半按同一份类型消费,这里只留旧名做别名。 */
10
- export type TrashDetailMessage = TrashDetailMessageWire;
11
30
  export type TrashDetail = TrashDetailWire;
12
31
  export type DetailDeps = {
13
32
  /** 详情自己直接要的三件(判目录在不在、挑日志、读日志)。对账不在这条路上,所以不需要 `listDirs`。 */
14
33
  fs: Pick<FsPort, 'exists' | 'listFiles' | 'readFile'>;
15
- /** 详情只读清册的 **raw 面**:`/detail` 声明为 pure,读一次不能顺手对账——对账会写清册行,那是写动作,
16
- * 必须排在到达序链上。代价是有意的:只有目录、清册还没这一行的手工搬入条目读不到(`/list` 对账后可见)。 */
17
- ledger: Pick<TrashLedger, 'raw'>;
34
+ /** 路由交来的清册 **raw 行**:`/detail` 声明为 pure,读一次不能顺手对账——对账会写清册行,那是写动作,
35
+ * 必须排在到达序链上。读面由路由记录选好(`read: 'raw'`),这里只消费行,不再自己挑 `list()` / `raw()`。
36
+ * 代价是有意的:只有目录、清册还没这一行的手工搬入条目读不到(`/list` 对账后可见)。 */
37
+ rows: () => Promise<readonly TrashEntry[]>;
18
38
  home: string;
19
39
  };
20
40
  /** 两个载荷闸(可调是为把策略单独验穿;生产走的就是这两个默认值,用例里钉着)。 */
@@ -8,8 +8,8 @@ export interface TrashEntry {
8
8
  /** 删除时它所属的工作区;**空串 = 删除时就没有账目**(「未分组」的会话,宿主的"删除工作区"会造出这种),
9
9
  * 恢复时据此跳过 `attach`。 */
10
10
  workspaceId: string;
11
- /** 删除**之前**的归档态,**只作事实记录**:2026-10-21 起删除不归档、恢复也不按它还原可见性。
12
- * 旧清单迁移只用它区分"删除时是我们归档的"(false 且当前仍归档)与"用户删前自己归档的"(true,保持归档)。 */
11
+ /** 删除前的归档态,只作事实记录:删除不再改变归档,恢复也不按它还原可见性。
12
+ * 旧条目迁移用它区分“删除时被我们归档的”(false 且当前仍归档)与“用户删前自己归档的”(true)。 */
13
13
  wasArchived: boolean;
14
14
  deletedAt: number;
15
15
  /** 删除时**尽力**取的会话标题(来源:宿主 projection cache 服务;底层是
@@ -19,7 +19,7 @@ export interface TrashEntry {
19
19
  sizeBytes: number;
20
20
  /** **组语义**:这一条是跟着父一起进回收站的子会话 ⇒ 记父那一行的 `id`。
21
21
  * 缺席 = 这一行是父(或它自己就是被单独删掉的)。清单据此把孩子折进父行、不单独列。
22
- * 老代留底 / 清册被清后由目录重建出来的行**没有这个字段**(文件侧没有它的副本,与
22
+ * 清册被清后由目录重建出来的行没有这个字段(文件侧没有它的副本,与
23
23
  * `workspaceId`/`wasArchived`/`title` 同一类降级),那一趟它们会各占一行——那是"分组信息随清册一起没了"
24
24
  * 的如实结果,不假装知道(CONTEXT.md 的「无主子会话」条)。 */
25
25
  parentEntryId?: string;
@@ -1,17 +1,10 @@
1
1
  /**
2
- * **组语义的唯一家**(ADR-0001):回收站里「这一组有谁」以及「哪些行在清单里露面」。
2
+ * 组语义的唯一家(ADR-0001):回收站里「这一组有谁」以及「哪些行在清单里露面」。
3
3
  *
4
- * 此前这条关系在四个地方各算一遍,而且已经算出两种答案:
5
- * `assembleTrashRows` 与 `restoreSession` 都认为「父自己也是孩子时,它接不住孩子」,
6
- * 而 `purgeTrash` 的 `id === x || parentEntryId === x` 不认这条 ⇒ 嵌套组上它会连带删掉不属于这一组的孙代
7
- * (不可恢复)。判据原文只在这里写一次:
4
+ * 接得住孩子的只有组根——自己不记 `parentEntryId` 的那一行。父行不在、或父行自己也是孩子时,
5
+ * 孩子必须自己占一行,否则它的字节会被头部合计数两遍,而它自己既看不见也清不掉。
8
6
  *
9
- * **接得住孩子的只有组根**——自己不记 `parentEntryId` 的那一行。父行不在、或父行自己也是孩子时,
10
- * 孩子必须自己占一行,否则它的字节会被头部合计数两遍(而它自己既看不见也清不掉)。
11
- *
12
- * 三个函数都是**纯函数**、都吃一份快照:`/apply` 那条路会在同一批里多次重问;批量层持有一份
13
- * 会收缩的当前视图(`verbs/batch-carried.ts`),收缩时机归调用方——groups module 不持有可变状态,
14
- * 也就没有"这份快照谁负责更新"的第二套契约。
7
+ * 三个函数都是纯函数、都吃一份快照;收缩时机归调用方,本模块不持有可变状态。
15
8
  */
16
9
  import type { TrashEntry } from './entry.js';
17
10
  /** 一条露面的组根,连同它接住的孩子(顺序与清册一致)。 */
@@ -4,7 +4,7 @@ import { type ManifestPort } from './manifest.js';
4
4
  * 清单的真实绑定:官方 storage domain → `ManifestPort`(`StorageDomainLike` 在动词层保持一种读法)。
5
5
  * 域**懒开**且成功句柄只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
6
6
  * 插件卸载时关掉句柄并把 `close()` 的落地交回框架(句柄归调用方所有,这是 `open()` 的 JSDoc 明写的契约;
7
- * 2026-10-17 起不再 `void` 走掉——没关严的句柄会让下一份同名域撞 `already-open`)。
7
+ * 不能 `void` 走掉——没关严的句柄会让下一份同名域撞 `already-open`)。
8
8
  *
9
9
  * **失败不缓存**:`open()` 被拒是瞬态(域被别的持有者占着、装载竞态),把第一次的 rejected promise
10
10
  * 缓存起来等于"这个会话永远删不掉"——下一次读会重新走 `open()`,成功才落下活句柄。
@@ -1,5 +1,5 @@
1
1
  import { type TrashEntry } from '../trash/entry.js';
2
- import type { DeleteDeps } from '../deps.js';
2
+ import type { DeleteSessionDeps } from '../deps.js';
3
3
  export interface DeleteResult {
4
4
  readonly entry: TrashEntry;
5
5
  /** 这一次真的搬进回收站的成员(父 + 盘上存在的孩子);批量层用它建同批已带走集。 */
@@ -22,4 +22,4 @@ export interface DeleteResult {
22
22
  * 组语义归 `trash/groups.ts`)。
23
23
  * 结果带回这次真的搬走的整组(`movedSessionIds`)——它是动词交回的内部数据,不再靠回调借用动词的手。
24
24
  */
25
- export declare function deleteSession(deps: DeleteDeps): (sessionId: string) => Promise<DeleteResult>;
25
+ export declare function deleteSession(deps: DeleteSessionDeps): (sessionId: string) => Promise<DeleteResult>;
@@ -9,6 +9,8 @@ export interface VerbError extends Error {
9
9
  cause?: unknown;
10
10
  }
11
11
  export declare function verbError(code: VerbCode, message: string, cause?: unknown, details?: Record<string, unknown>): VerbError;
12
+ /** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
13
+ export declare function trashEmpty(entryId?: string): VerbError;
12
14
  /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
13
15
  export declare function routeError(code: RouteCode, httpStatus: number, message: string): VerbError & {
14
16
  httpStatus: number;
@@ -1,12 +1,9 @@
1
1
  /**
2
- * 活体准入:一次删除 / 永久删除 / 孤儿清理之前,逐个问「这个会话在宿主内存里 attach 着吗」——
3
- * 任一个在跑就整组拒绝、零副作用(词表见 CONTEXT.md 的「活体」条;裁决见 ADR-0008)。
2
+ * 活体准入:删除 / 永久删除 / 孤儿清理之前,逐个问会话是否在宿主内存里 attach 着;
3
+ * 任一在跑就整组拒绝、零副作用(ADR-0008)。
4
4
  *
5
- * 为什么单独一个 module:删除的逐个成员与永久删除计划的 targets + 盘上后代,此前各手写一遍同一条
6
- * 循环、同一句原文。两处只要有一处改了措辞或漏查一个 id,客户端拿到的就是两种「还在跑」。
7
- *
8
- * 边界:**什么时候查归调用方**。永久删除的准入仍住在计划里、在任何写之前(ADR-0006:计划就是执行前的闸);
9
- * 这个 module 只拥有「查什么、怎么判、怎么说」。
5
+ * 边界:什么时候查归调用方——永久删除的准入在计划里、任何写之前(ADR-0006);
6
+ * 本模块只拥有「查什么、怎么判、怎么说」。
10
7
  */
11
8
  import type { HostIdentityPort } from '../ports/host-port.js';
12
9
  /** 逐个问,任一在跑就抛 `live`。名单的挑选(哪些成员算这一组)归调用方,这里只按传入顺序问完。 */
@@ -16,7 +16,7 @@ export interface JournalUndo {
16
16
  export interface JournalStep {
17
17
  /** 稳定的领域键,给 `restored(key)` 用。 */
18
18
  readonly key: JournalStepKey;
19
- /** 这一步在做什么。失败原文按「`<label>:<原错误>`」拼——与迁移前那 22 处 `rollbackReason` 的措辞同形。 */
19
+ /** 这一步在做什么。失败原文按「`<label>:<原错误>`」拼。 */
20
20
  readonly label: string;
21
21
  /** 做这一步。抛什么都由 journal 折成 `sessiondelete/io`(除非它自己已经是一个动词码)。 */
22
22
  run(): Promise<void>;
@@ -39,8 +39,7 @@ export declare function attempt(label: string, run: () => Promise<unknown>): Pro
39
39
  export declare function stepUndo(label: string, run: () => Promise<unknown>): Promise<StepUndo>;
40
40
  /**
41
41
  * **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
42
- * `details.rollbackFailures`,绝不让原错误独走(2026-10-19 `f2a7757`;那些失败句子的合并与措辞住
43
- * `src/verbs/errors.ts`,这里只是唯一的调用方)。删除与恢复两个按组走的动词共用这一处,
44
- * "相反顺序 + 失败合并"不再各写一遍。
42
+ * `details.rollbackFailures`,绝不让原错误独走。那些失败句子的合并与措辞住 `src/verbs/errors.ts`;
43
+ * 删除与恢复两个按组走的动词共用这一处。
45
44
  */
46
45
  export declare function rollbackJournals(journals: readonly Journal[], error: unknown): Promise<never>;
@@ -1,42 +1,23 @@
1
1
  /**
2
2
  * 成员离场 / 归位的通告协议:摘活体、三态判断、成对事件只在这里做。
3
3
  *
4
- * 为什么要有它:delete(成功与回滚)/ restore 回滚 / purge(两个分支)/ orphan-clean
5
- * 六处调用曾经各写一遍
6
- * `evictLive → !== 'unknown' → announceRemoved`;删除回滚还要自己拼目录与账本两个布尔
7
- * 才发 `announceRestored`。
8
- * 这条协议说错一次就是客户端留着假行、或删掉真行,所以只留一个家。
4
+ * 调用方包括 delete、restore 回滚、purge、orphan-clean;协议说错一次就是客户端留假行或删真行。
9
5
  *
10
6
  * 边界:
11
- * - 批量恢复自己发起的那次通告(`announceRestored(ids)`)归 `verbs/batch.ts`(ADR-0002):同一个方法、
12
- * 同一份预取,只是调用方是那一批的主人。这里只判单成员事实。
13
- * - 本模块不决定哪个键算归位:动词在 `tail.onUndone` 里把 `restored(key)` 读成事实再交进来,真值表住这里;
14
- * `run` / `undo` 之后补一条通告的**形状**由 {@link withPresenceTail} 拥有。
15
- * - 本模块用到的三条(`evictLive` / `announceRemoved` / `announceRestored`)按契约不抛(evictLive 三态、
16
- * announce* 内部吞异常),这里不再叠 catch。
7
+ * - 批量恢复的编排归 `verbs/batch.ts`(ADR-0002);本模块只实现 `announceRestored(ids)`(ADR-0019)。
8
+ * - 哪个键算归位由动词在 `tail.onUndone` 里读成事实再交进来,真值表住这里;
9
+ * `run`/`undo` 之后补通告的形状由 {@link withPresenceTail} 拥有。
10
+ * - `evictLive`/`announce*` 按契约不抛,这里不再叠 catch。
17
11
  */
18
12
  import type { HostPresencePort } from '../ports/host-port.js';
13
+ import type { PresenceCapability } from '../ports/presence-port.js';
19
14
  import type { Journal, JournalUndo } from './member-journal.js';
20
15
  /** 本模块实际用到的三条宿主能力。`Pick` 此刻覆盖 `HostPresencePort` 的三条方法,仍留着它当闸:
21
- * 将来端口多出第四条时,这里不会被动跟着变宽——这里是单成员协议,批量恢复由 `verbs/batch.ts`
22
- * 自己发起,不该因为共用同一颗 adapter 就在类型上声明依赖。 */
16
+ * 将来端口多出第四条时,这里不会被动跟着变宽——能力面只暴露这三条,批量通告的编排归 `verbs/batch.ts`。 */
23
17
  export type MemberPresenceHost = Pick<HostPresencePort, 'evictLive' | 'announceRemoved' | 'announceRestored'>;
24
- export interface MemberBackFacts {
25
- /** 目录搬回原处了吗。 */
26
- readonly diskBack: boolean;
27
- /** 账本挂回去了吗(本来没有账目时算 true)。 */
28
- readonly workspaceBack: boolean;
29
- }
30
- export interface MemberPresence {
31
- /** 成员已经不在宿主可见列表:摘活体;三态不是 unknown 时发 removed。
32
- * `backInTrash: false` 表示目录没退回回收站(恢复回滚失败),此时什么都不做。 */
33
- memberGone(sessionId: string, facts?: {
34
- readonly backInTrash?: boolean;
35
- }): Promise<void>;
36
- /** 成员已经回到原来的可见状态:目录与账本两件写都回去才发 added。 */
37
- memberBack(sessionId: string, facts: MemberBackFacts): Promise<void>;
38
- }
39
- export declare function createMemberPresence(host: MemberPresenceHost): MemberPresence;
18
+ /** 本仓的在场能力实现:端口形状住 `src/ports/presence-port.ts`,这里只做三态与真值表。
19
+ * `announceRestored` 直接交宿主那一手:能力面与宿主面同名同签名,再包一层只会多一个转调。 */
20
+ export declare function createMemberPresence(host: MemberPresenceHost): PresenceCapability;
40
21
  /** 一次 undo 之后要发哪一条通告:`leave` = 成员已不在可见面,`return` = 已回到原可见状态。 */
41
22
  export type PresenceAnnounce = {
42
23
  readonly kind: 'leave';
@@ -55,13 +36,9 @@ export interface PresenceTail {
55
36
  onUndone(undo: JournalUndo): PresenceAnnounce;
56
37
  }
57
38
  /**
58
- * 给一次成员移动的 journal 补上「在场那条尾巴」:run 完全成功之后离场、undo 之后按事实归位或离场。
59
- *
60
- * 为什么是个工厂:删除与恢复此前各手拼一个匿名 journal,形状逐字相同(`journal.run` 加上
61
- * `presence.memberGone`/`journal.undo` 加上 `presence.memberBack`),只差键 → 事实的映射。
62
- * 两处各写一遍,就是「补在哪一拍」这条顺序又有第二个家。
39
+ * 给一次成员移动的 journal 补上「在场那条尾巴」:run 完全成功之后离场,undo 之后按事实归位或离场。
63
40
  *
64
- * 边界:`memberGone` 的 `backInTrash:false` 早退与 `memberBack` 的两件齐备真值表仍归 `createMemberPresence`;
65
- * 这里只把事实原样递过去,不替它判断。
41
+ * 边界:`memberGone` 的 `backInTrash:false` 早退与 `memberBack` 的两件齐备真值表归 `createMemberPresence`;
42
+ * 这里只把事实原样递过去。
66
43
  */
67
- export declare function withPresenceTail(presence: MemberPresence, journal: Journal, tail: PresenceTail): Journal;
44
+ export declare function withPresenceTail(presence: PresenceCapability, journal: Journal, tail: PresenceTail): Journal;
@@ -1,7 +1,7 @@
1
1
  import { type SessionIdentity } from '../ports/host-port.js';
2
2
  import type { TrashEntry } from '../trash/entry.js';
3
3
  import type { OrphanCleanStatsWire, OrphanReportWire } from '../shared/wire.js';
4
- import type { CoreDeps } from '../deps.js';
4
+ import type { CoreDeps, RemovalDeps } from '../deps.js';
5
5
  /** 组里一个人的盘上身份(字节是**扫描那一刻**量的,清理时按目录重量一次,不拿这份快照冒充准数)。 */
6
6
  export interface OrphanMember {
7
7
  readonly sessionId: string;
@@ -32,17 +32,17 @@ interface RawGroup {
32
32
  * 归堆与去重**两边都去 `session-` 前缀**:同一个父在两代宿主里写法不同(见 `bareSessionId` 的来历)。
33
33
  */
34
34
  export declare function orphanGroupsOf(corpus: readonly SessionIdentity[], trashSessionIds: readonly string[]): RawGroup[];
35
- /** **本函数是纯扫描,不对账、不写清册行**(`/orphans` 声明为 pure,一次扫描不能顺手写行)。清册默认读
36
- * `ledger.raw()`;写路径(`orphan-clean`)可以在调它之前先 `ledger.list()` 对账,并把交回的行递进来,
37
- * 免得同一份索引再读一遍——否则刚被人手工搬进 `session-trash/` 的父目录还没有行,会被当成"父没了"
38
- * 直接删盘。
35
+ /** **本函数是纯扫描,不对账、不写清册行**(`/orphans` 声明为 pure,一次扫描不能顺手写行)。
36
+ * 清册行由调用方选好再交进来:`/orphans` 交 `read.rows()`(raw 索引),`orphan-clean` 交
37
+ * `ledger.list()`(对账后的行)——否则刚被人手工搬进 `session-trash/` 的父目录还没有行,会被当成
38
+ * "父没了"直接删盘。扫描不再自己回落到 `ledger.raw()`:读面归属住路由记录(ADR-0004 / ADR-0012)。
39
39
  *
40
40
  * `listSessions()` 回 `{ available: false }` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
41
41
  * 拿缺席当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
42
42
  * 所以这里按 `io` 响亮失败(AGENTS.md:`available: false` 意味着服务缺席,扫描方不许冒充读数)。 */
43
43
  export declare function loadOrphanGroups(deps: CoreDeps,
44
- /** 调用方已对账过的清册行(`ledger.list()`);缺席则读 `ledger.raw()`。 */
45
- trash?: readonly TrashEntry[]): Promise<readonly OrphanGroup[]>;
44
+ /** 调用方选好并交来的清册行:`/orphans` 交 `read.rows()`,`orphan-clean` 交 `ledger.list()`。 */
45
+ trash: readonly TrashEntry[]): Promise<readonly OrphanGroup[]>;
46
46
  /** 把一组组收成那份报告:根清单 + 闭包总数/总字节 + 失踪父的**去重计数** + 按项目目录的分摊。
47
47
  * `groups` 按字节从多到少排(界面上"哪家还剩得最多"要看第一行)。 */
48
48
  export declare function reportOrphans(groups: readonly OrphanGroup[]): OrphanReportWire;
@@ -58,6 +58,7 @@ export declare function reportOrphans(groups: readonly OrphanGroup[]): OrphanRep
58
58
  * - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以这里不需要那次
59
59
  * `wasArchived` 读——这里没有"还原到删除前"的义务。
60
60
  * - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
61
+ * - 独占图片的剩余引用源由附件域自己扫:活动会话 + 全部 session-trash + storages/cache。
61
62
  */
62
- export declare function cleanOrphanGroup(deps: CoreDeps, group: OrphanGroup): Promise<OrphanCleanStatsWire>;
63
+ export declare function cleanOrphanGroup(deps: RemovalDeps, group: OrphanGroup): Promise<OrphanCleanStatsWire>;
63
64
  export {};
@@ -1,11 +1,12 @@
1
1
  /**
2
- * 永久删除的**测量、计划与执行**:一次永久删除(`/list` 的只读读数或 purge / orphan-clean 的删除计划)
3
- * 之前要记下的事实(目录、字节、账本归属),以及拿到计划之后按它动盘的那一步。计划函数**只读**——
4
- * 写不写由执行器决定,但活体准入在这里就做掉,调用方拿到计划才允许动盘。
2
+ * 永久删除的测量、计划与执行:先记录目录/字节/账本归属,再按计划动盘。
3
+ * 计划函数只读;活体准入在计划阶段、任何写之前完成。
5
4
  */
6
5
  import { type TrashEntry } from '../trash/entry.js';
7
6
  import type { TrashGroupView } from '../trash/groups.js';
8
- import type { CoreDeps } from '../deps.js';
7
+ import { type ImageRemovalPlan } from '../trash/attachments.js';
8
+ import type { CoreDeps, RemovalDeps } from '../deps.js';
9
+ import { type AttachmentCleanupWire } from '../shared/wire.js';
9
10
  export interface MeasuredMemberDir {
10
11
  readonly dir: string;
11
12
  readonly bytes: number;
@@ -24,13 +25,6 @@ export declare function measureMemberDir(deps: CoreDeps, member: {
24
25
  readonly sessionId: string;
25
26
  readonly projectDir: string;
26
27
  }): Promise<MeasuredMemberDir | undefined>;
27
- /** 把一组成员折成"删之前要记下的计划";目录不在的成员跳过(记录与盘不一致是那条成员自己的事)。
28
- * `purge` 的盘上后代、`orphan-clean` 的整组与 `/list` 的盘上残留都走这一处,别再各写一遍
29
- * measure + workspaceOf。 */
30
- export declare function planMemberRemovals(deps: CoreDeps, members: readonly {
31
- readonly sessionId: string;
32
- readonly projectDir: string;
33
- }[]): Promise<PlannedMemberRemoval[]>;
34
28
  /** 回收站里的一条要删的行:目录、在不在、实测字节(缺席 = 0)。purge 与 `/list` 都由它量。 */
35
29
  export interface PlannedTrashRow {
36
30
  readonly entry: TrashEntry;
@@ -66,6 +60,10 @@ export interface RemovalPlan {
66
60
  readonly unarchive: readonly string[];
67
61
  /** 计划量到的合计;成功回执的 freedBytes 只念它。 */
68
62
  readonly bytes: number;
63
+ /** 这一次要连带清掉的独占图片对象(`trash/attachments.ts` 的域判定;空计划 = 没有可删的、或
64
+ * 剩余引用源读不出而整体跳过——原因在 `skipped` 里)。附件字节**不进** `bytes`:那是会话目录的
65
+ * 释放量,附件读数单独走回执的 `attachments`。 */
66
+ readonly attachments: ImageRemovalPlan;
69
67
  }
70
68
  /**
71
69
  * purge 的删除计划:`targets` 由调用方按组语义(`trash/groups.ts`)选好,这里只量、只筛、只查活体。
@@ -75,6 +73,8 @@ export interface RemovalPlan {
75
73
  * - 盘上后代经 `selectStrandedMembers` 选出(一次问全部 targets、排除全部清册 id、bare id 全局只计划一次)。
76
74
  * - `members` 只收盘上还有目录的成员(目录缺席没有可删的字节);`unarchive` = targets + 这些成员:
77
75
  * 只摘这一次真要碰的 id。
76
+ * - `attachments` 由被删目录(targets + 这些成员)里的图片引用与全部剩余引用源算独占;共享的保留、
77
+ * 剩余引用源读不出就整体跳过(判据住 `trash/attachments.ts`,这里只接线)。
78
78
  * - 活体准入在量目录与任何写之前逐个查 targets 与**全部枚举到的**盘上后代;任一在跑就整条拒绝、
79
79
  * 零副作用。没有跳过它的开关——计划就是执行前的闸(ADR-0006)。
80
80
  * - `sessionQuery` 缺席时端口回 `{ available: false }` ⇒ members 为空、不抛(盘上残留留给 `GET /orphans` 兜底)。
@@ -87,6 +87,8 @@ export declare function planTrashRemoval(deps: CoreDeps, rows: readonly TrashEnt
87
87
  * - `unarchive` 覆盖**全部传入成员**(含目录缺席):orphan-clean 在删目录之前整批摘归档集,
88
88
  * 目录缺席的 id 也可能还挂在归档集里;重试时不会再长出一条幽灵 id。
89
89
  * - live 准入覆盖**全部传入成员**(含目录缺席):它们都在这一组的可见面里,任一在跑就整组拒绝。
90
+ * - `attachments` 的剩余引用源由附件域自己扫(活动会话 + 全部 session-trash + storages/cache),
91
+ * 共享图片因此保留(判据住 `trash/attachments.ts`)。
90
92
  * - 回执由 `kind:'orphan'` 派生:删目录阶段失败时执行器要交回 `{removed, freedBytes, failedSessionId}`。
91
93
  */
92
94
  export declare function planOrphanRemoval(deps: CoreDeps, members: readonly {
@@ -97,18 +99,22 @@ export declare function planOrphanRemoval(deps: CoreDeps, members: readonly {
97
99
  * 按计划执行一次永久删除:只写、不再量、不再判活体(计划就是执行前的闸)。回执的字节念计划
98
100
  * (`plan.bytes`),执行期不再累加一遍。
99
101
  *
100
- * 顺序(与迁移前的 purge / orphan-clean 一致):整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
101
- * (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)。目录删掉就没有
102
- * "退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
102
+ * 顺序:整批摘归档集 → 摘有账本的成员的账本 → 逐条回收站行
103
+ * (在才删目录 → 摘清册行 → 通告成员离场)→ 逐条盘上成员(删目录 → 通告成员离场)→ 逐条附件对象。
104
+ * 目录删掉就没有"退回去"这回事,中途失败**不回滚**;已经删掉的由下面的计数器如实交回。
105
+ *
106
+ * 附件清理排在**所有会话目录删完之后**:对象只在会话真的没了之后才动;单文件失败只进
107
+ * `attachments.failed`,不影响(也不回滚)会话删除的回执。
103
108
  *
104
- * 失败形状沿用调用方原来的措辞与结构:
109
+ * 失败形状:
105
110
  * - `kind:'orphan'`(orphan-clean)⇒ `io` + `{removed, freedBytes, failedSessionId}`,不伪装成整组没动;
106
- * - `kind:'trash'`(purge)⇒ 同一个标签、不带 details。
107
- * - orphan-clean 的 unarchive / detach 在旧实现里不包装,这里原样抛出(不改变现有错误形状)。
111
+ * - `kind:'trash'`(purge)⇒ 同一个标签、不带 details;
112
+ * - orphan-clean 的 unarchive / detach 原样抛出。
108
113
  *
109
114
  * 通告只经 `member-presence`(摘活体三态 + `api-session/removed`),不在这里另写一份。
110
115
  */
111
- export declare function executeRemoval(deps: CoreDeps, plan: RemovalPlan): Promise<{
116
+ export declare function executeRemoval(deps: RemovalDeps, plan: RemovalPlan): Promise<{
112
117
  removed: number;
113
118
  freedBytes: number;
119
+ attachments: AttachmentCleanupWire;
114
120
  }>;