@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.
Files changed (74) hide show
  1. package/README.md +21 -15
  2. package/lib/client.js +3317 -2173
  3. package/lib/index.js +1691 -964
  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 +3 -2
  11. package/lib/types/client/batch-confirm.d.ts +3 -3
  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/busy-gate.d.ts +6 -0
  15. package/lib/types/client/confirm-close.d.ts +11 -0
  16. package/lib/types/client/copy.d.ts +48 -38
  17. package/lib/types/client/delete-confirm.d.ts +0 -2
  18. package/lib/types/client/host-rows.d.ts +18 -3
  19. package/lib/types/client/main-view.d.ts +8 -3
  20. package/lib/types/client/maintenance-parts.d.ts +25 -0
  21. package/lib/types/client/maintenance-settings.d.ts +22 -0
  22. package/lib/types/client/markdown.d.ts +9 -1
  23. package/lib/types/client/orphan-maintenance.d.ts +45 -0
  24. package/lib/types/client/receipt.d.ts +75 -5
  25. package/lib/types/client/remote.d.ts +49 -69
  26. package/lib/types/client/store.d.ts +31 -0
  27. package/lib/types/client/trash-confirm.d.ts +2 -15
  28. package/lib/types/client/trash-nav.d.ts +30 -0
  29. package/lib/types/client/trash-panel.d.ts +4 -3
  30. package/lib/types/client/ui/Checkbox.d.ts +1 -1
  31. package/lib/types/client/ui/Modal.d.ts +2 -2
  32. package/lib/types/client/ui/icons.d.ts +6 -11
  33. package/lib/types/client/ui/top-layer.d.ts +10 -0
  34. package/lib/types/client/undo-notices.d.ts +52 -0
  35. package/lib/types/client/undo-toast.d.ts +6 -52
  36. package/lib/types/client/use-column-resize.d.ts +9 -20
  37. package/lib/types/client/use-maintenance.d.ts +45 -0
  38. package/lib/types/client/use-panel-width.d.ts +1 -1
  39. package/lib/types/client/use-trash-panel-controller.d.ts +27 -23
  40. package/lib/types/client/use-trash-query.d.ts +39 -25
  41. package/lib/types/client/write-seats.d.ts +44 -0
  42. package/lib/types/client/write-settlement.d.ts +66 -0
  43. package/lib/types/client/write.d.ts +54 -0
  44. package/lib/types/deps.d.ts +20 -9
  45. package/lib/types/index.d.ts +8 -3
  46. package/lib/types/log/session-log.d.ts +4 -3
  47. package/lib/types/log/zstd-frames.d.ts +14 -8
  48. package/lib/types/ports/agent-handles.d.ts +1 -1
  49. package/lib/types/ports/connection-port.d.ts +33 -0
  50. package/lib/types/ports/fs-port.d.ts +13 -1
  51. package/lib/types/ports/host-port.d.ts +21 -63
  52. package/lib/types/ports/presence-port.d.ts +22 -0
  53. package/lib/types/shared/wire.d.ts +77 -26
  54. package/lib/types/trash/attachments.d.ts +85 -0
  55. package/lib/types/trash/detail.d.ts +29 -6
  56. package/lib/types/trash/entry.d.ts +3 -3
  57. package/lib/types/trash/groups.d.ts +18 -14
  58. package/lib/types/trash/ledger.d.ts +20 -0
  59. package/lib/types/trash/manifest.d.ts +2 -1
  60. package/lib/types/trash/reconcile.d.ts +4 -3
  61. package/lib/types/trash/storage-manifest.d.ts +1 -1
  62. package/lib/types/verbs/batch-carried.d.ts +20 -0
  63. package/lib/types/verbs/delete-session.d.ts +6 -5
  64. package/lib/types/verbs/errors.d.ts +4 -10
  65. package/lib/types/verbs/live-admission.d.ts +10 -0
  66. package/lib/types/verbs/member-journal.d.ts +25 -7
  67. package/lib/types/verbs/member-presence.d.ts +33 -24
  68. package/lib/types/verbs/move-group.d.ts +20 -0
  69. package/lib/types/verbs/orphan-subagents.d.ts +11 -5
  70. package/lib/types/verbs/permanent-removal.d.ts +31 -24
  71. package/lib/types/verbs/purge-trash.d.ts +21 -8
  72. package/lib/types/verbs/restore-session.d.ts +12 -16
  73. package/package.json +4 -2
  74. package/lib/types/client/write-commit.d.ts +0 -10
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 宿主 `connection` 服务(`dsh-client-connection` 的 `HostConnectionService`,`super(ctx, "connection")`)
3
+ * 的窄镜像:`requestRejection(request)` 一次做宿主 `/api` 的那两道——Host/Origin 栅栏与「已认证 live peer」。
4
+ *
5
+ * **每次请求惰性解析服务**,不在激活时取一次:插件与 `client-connection` 的激活顺序没有承诺,取早了
6
+ * 会让远程支永远缺席(LAN 上全 403 且难排查)。`ctx.get` 是同步查表,代价可以忽略。
7
+ *
8
+ * **状态码在这里翻成裁决**(401 = 归属对、缺身份;403 = 归属不对;其余按归属不对处理),
9
+ * 调用方拿到的就是自己的词表,不需要知道宿主用哪个码表达哪件事。成员名与返回值按 asar
10
+ * `dsh-client-connection/lib/index.js` 原文读实。
11
+ */
12
+ import type { IncomingMessage } from 'node:http';
13
+ import type { Context } from '@deepseek-ai/cordis';
14
+ export type ConnectionVerdict = {
15
+ readonly kind: 'allowed';
16
+ }
17
+ /** 组成里没有这个服务(无 web 组成 / 老代次)——调用方 fail-closed。 */
18
+ | {
19
+ readonly kind: 'absent';
20
+ }
21
+ /** 宿主认可归属,但这条请求没带已认证身份。 */
22
+ | {
23
+ readonly kind: 'unauthenticated';
24
+ }
25
+ /** 宿主不认可归属(Host/Origin 栅栏不过,或它给出的其余状态码)。 */
26
+ | {
27
+ readonly kind: 'untrusted';
28
+ };
29
+ export interface ConnectionPort {
30
+ verdictFor(req: IncomingMessage): ConnectionVerdict;
31
+ }
32
+ /** 成员抛错按「归属不对」拒绝(fail-closed)。 */
33
+ export declare function createConnectionPort(ctx: Context): ConnectionPort;
@@ -2,15 +2,27 @@ export interface FsPort {
2
2
  exists(p: string): Promise<boolean>;
3
3
  /** 建目录(含中间层)。首删时 `~/.dsh/session-trash/` 还不存在,而 `rename` 不建父目录。 */
4
4
  ensureDir(p: string): Promise<void>;
5
+ /** 搬目录:**目标已存在一律失败**,跨卷(EXDEV)也失败并说清楚——不替换、也不偷偷改成复制。
6
+ * Linux 的 `rename` 会静默替代空目录而 Windows 不会,这个差别不能交给操作系统决定。 */
5
7
  rename(from: string, to: string): Promise<void>;
6
8
  removeDir(p: string): Promise<void>;
7
9
  sizeOfDir(p: string): Promise<number>;
8
10
  /** 目录下的条目名(**不递归**)。回收站详情要用它在那条目录里挑 `*.jsonl.zstd`。 */
9
11
  listFiles(p: string): Promise<string[]>;
10
- /** 目录下的**子目录**名(不递归)。回收站对账用它找出"清单里还没有行"的条目目录——
12
+ /** 目录下的**子目录**名(不递归;**跟随软链**)。回收站对账用它找出"清单里还没有行"的条目目录——
11
13
  * 与 `listFiles` 分开是为了让"拒收文件"发生在挑选阶段(回收站根里落进来的杂散文件当外人)。 */
12
14
  listDirs(p: string): Promise<string[]>;
15
+ /** 解析符号链接后的真实路径。递归走目录时防环用:跟随软链之后,目录图不再是树。 */
16
+ realpath(p: string): Promise<string>;
13
17
  /** 整个文件读进内存。实测本机回收站里最大单文件 4.66 MB;**内存口径由逐帧解压把住**(见 `src/log/session-log.ts`),不在这里。 */
14
18
  readFile(p: string): Promise<Buffer>;
19
+ /** 只读的单文件读数:附件渲染与独占计划都用它(不递归、不跟随目录)。 */
20
+ statFile(p: string): Promise<{
21
+ size: number;
22
+ }>;
23
+ /** 删单个文件(不是目录)。危险能力,只挂到 `RemovalDeps` 上。 */
24
+ removeFile(p: string): Promise<void>;
15
25
  }
26
+ /** 动词的基础面:读与搬移都有,**不拿删除**。删目录与删单文件只挂在 `RemovalDeps` 上(ADR-0006)。 */
27
+ export type CoreFsPort = Omit<FsPort, 'removeDir' | 'removeFile'>;
16
28
  export declare function createFsPort(): FsPort;
@@ -1,23 +1,12 @@
1
1
  /** 摘内存的三种落地(见 {@link HostPort.evictLive}):`not-live` / `evicted` 都意味着**宿主列表已经不含它**;
2
2
  * `unknown` 意味着读不到或摘不掉,**不能**据此通告客户端(发了就是假话,重连后那一行会自己长回来)。 */
3
3
  export type EvictionOutcome = 'evicted' | 'not-live' | 'unknown';
4
- /**
5
- * 角色一:**身份与归属**——会话是谁、落在哪个项目/工作区、活不活/归档没有,以及账本与归档的写面。
6
- *
7
- * 为什么拆成角色:宿主能力的端口按角色切成三块,而不是一个什么都有的接口。调用方要的是
8
- * "一句话能说清的那一组能力",而一个什么都有的端口让每条用例都得凑齐不相干的成员。
9
- * 这里只切**类型**,adapter 仍是同一颗对象。
10
- */
4
+ /** 角色一:身份与归属——会话是谁、落在哪个项目/工作区、活不活/归档没有,以及账本与归档的写面。
5
+ * 只切类型,adapter 仍是同一颗对象。 */
11
6
  export interface HostIdentityPort {
12
7
  workspaceOf(sessionId: string): Promise<string | undefined>;
13
- /** 账本里还有这个工作区吗。**只读的判别**(`registry.get(id) !== undefined`),不抛。
14
- *
15
- * **为什么恢复需要它**:宿主的「删除工作区」只删**注册**、保留目录与每一份会话日志
16
- * (asar `WorkspaceRegistry.delete` 的 JSDoc 原文 "Delete one workspace registration while
17
- * retaining its directory and every session log"),那些会话之后显示在「未分组」下。回收站里
18
- * **删在工作区之前**的条目记着那个已被删掉的 `workspaceId`,恢复时若照旧 `attach` 就会
19
- * `workspace not found` 整条失败(会话留在回收站里,用户看起来就是"恢复报错")。所以恢复
20
- * 先问这一句:不在 ⇒ 不挂账本,照常回盘、归入「未分组」(与宿主自己处理这类会话一致)。 */
8
+ /** 账本里还有这个工作区吗:只读判别,不抛。
9
+ * 宿主的「删除工作区」只删注册、保留目录与日志;恢复这类会话时跳过 attach,归入「未分组」。 */
21
10
  workspaceExists(workspaceId: string): Promise<boolean>;
22
11
  /** 会话落盘的**项目目录名**(`<sessions>/<projectKey(cwd)>`);账本里没有这个会话 ⇒ `undefined`。
23
12
  * 与 `workspaceOf` 同一口径:**未命中是正常的 no-op,不是故障**(存储真故障照抛)。 */
@@ -49,23 +38,9 @@ export interface HostPresencePort {
49
38
  /** **如实通告**客户端"这个会话没了":发宿主自己声明并转发的 `api-session/removed`,客户端当场删行。
50
39
  * 冷会话没有活体可摘,但同样需要这一条——否则那一行要等到重连(`handleConnected` → 重列)才消失。 */
51
40
  announceRemoved(sessionId: string): Promise<void>;
52
- /** **如实通告**客户端"这个会话回来了":摘要取自宿主自己的会话列表,发 `api-session/added`。
53
- * 正常恢复走 {@link HostPresencePort.announceRestoredBatch}(整批一次预取);这一条单条路是**删除组回滚**
54
- * 用的(成员已经收过"已移除"、退回原处后补发)。取不到摘要就不发。 */
55
- announceRestored(sessionId: string): Promise<void>;
56
- /** 宿主会话表的一次预取:`[{ sessionId, summary }]`,`summary` 是宿主**原样**那一条。
57
- * 给 {@link announceRestoredBatch} 当 `known` 用——整批只拉一次表。服务没挂回空表,不抛。 */
58
- restoredSummariesOf(): Promise<readonly {
59
- readonly sessionId: string;
60
- readonly summary: unknown;
61
- }[]>;
62
- /** 一组会话的通告(2026-10-17 批量重构):`known` 是调用方**整批预取**的摘要表——给了就一次都不
63
- * 再拉表(每条一次 `controller.list()` 在 100 条的批量里就是 100 遍全表扫,那是那次"恢复很慢"的
64
- * 头号来源);没给则自己拉一次。缺席 id 不发,不影响已完成的恢复。 */
65
- announceRestoredBatch(ids: readonly string[], known?: readonly {
66
- readonly sessionId: string;
67
- readonly summary: unknown;
68
- }[]): Promise<void>;
41
+ /** 一组会话的「回来了」通告:implementation 拉一次宿主会话表,按 id 逐条发 added。
42
+ * 单条就是长度为 1 的批;批量只调一次。缺席 id 不发,不影响已完成的恢复。 */
43
+ announceRestored(sessionIds: readonly string[]): Promise<void>;
69
44
  }
70
45
  /**
71
46
  * 一次语料读的结果:**缺席显式化**——`available: false` 说的是"这一趟看不见语料"(`sessionQuery` 没挂,
@@ -83,7 +58,7 @@ export type CorpusRead<T> = {
83
58
  * 角色三:**会话语料**——整表身份与子会话闭包。
84
59
  *
85
60
  * 两个读面都把"这一趟看不见语料"写成显式的 {@link CorpusRead}:`{ available: false }` 与
86
- * "看见了、里面是空的"是两件事。**缺席之后怎么办归调用方,清单见 `CONTEXT.md` 的「语料缺席」**。
61
+ * "看见了、里面是空的"是两件事。**缺席之后怎么办归调用方,清单见 `docs/architecture.md` §2 的「语料缺席」**。
87
62
  */
88
63
  export interface HostCorpusPort {
89
64
  /** 整个会话语料(live ∪ persisted)的只读身份表。**服务缺席是显式的**:`sessionQuery` 没挂,或它的
@@ -96,8 +71,11 @@ export interface HostCorpusPort {
96
71
  * 键按调用方传进来的写法回(同一个父的 `session-x` / `x` 两种写法是同一个根,见 `membersFor`)。 */
97
72
  subagentMembersOf(parentIds: readonly string[]): Promise<CorpusRead<Record<string, readonly SubagentMember[]>>>;
98
73
  }
99
- /** 复合别名:adapter(`createHostPort`)与调用方把这 17 个方法当一个端口用;三个角色是它的切面。 */
74
+ /** 复合别名:adapter(`createHostPort`)与调用方把这 15 个方法当一个端口用;三个角色是它的切面。 */
100
75
  export type HostPort = HostIdentityPort & HostPresencePort & HostCorpusPort;
76
+ /** 动词的宿主面:身份与语料两片;不含在场三件。摘活体与通告只经 `CoreDeps.presence`(`PresenceCapability`)。
77
+ * 用交集而不是 `Omit` 黑名单:`HostPresencePort` 将来长出新方法时,不会自动漏进每个动词的 `host`。 */
78
+ export type HostCorePort = HostIdentityPort & HostCorpusPort;
101
79
  export interface SessionIdentity {
102
80
  readonly sessionId: string;
103
81
  readonly parentSessionId?: string;
@@ -108,40 +86,20 @@ export interface SubagentMember {
108
86
  readonly sessionId: string;
109
87
  readonly projectDir: string;
110
88
  }
111
- /** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
112
- * 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。
113
- * `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
89
+ /** 无 cwd 的会话落在 `_no-cwd/`;`projectDirOf` 与回收站对账共用这个字面量。
90
+ * 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectDir`(`if (cwd === void 0) return join(root, "_no-cwd");`)。 */
114
91
  export declare const NO_CWD_PROJECT_DIR = "_no-cwd";
115
- /**
116
- * 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
117
- *
118
- * 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
119
- * 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
120
- * (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
121
- *
122
- * 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
123
- * `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
124
- * else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
125
- * else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
126
- * return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
127
- * 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
128
- * 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
129
- * (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
130
- *
131
- * **两个调用方共用这一份**:`projectDirOf`(删除时定位会话目录)与回收站对账的重建路径
132
- * (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
133
- */
92
+ /** 宿主 `projectKey(cwd)` 的逐字镜像:跨插件不能 import,宿主也没把它暴露成服务。
93
+ * 出处:asar `dsh-session-persistence-jsonl/lib/index.js` 的 `projectKey`;调用链
94
+ * `locate` → `logPath` → `projectDir` → `projectKey`。本机实测 `D:/develop/GitHub/dsh-session-delete`
95
+ * → `--D-develop-GitHub-dsh-session-delete--`。`projectDirOf` 与回收站对账重建共用这一份;
96
+ * 宿主换代先复核算法。 */
134
97
  export declare function projectKey(cwd: string): string;
135
98
  /** 会话 id 去掉 `session-` 前缀后的形状(同一个父在两代里写法不同,见 {@link HostCorpusPort.subagentMembersOf})。 */
136
99
  export declare function bareSessionId(id: string): string;
137
100
  /** 这次失败是不是"账本里没那个工作区"(而不是另有故障)。恢复侧靠它把两种失败分开:
138
101
  * 前者按「未分组」收,后者照旧回滚报 `io`——不给真故障静默改归属的机会。 */
139
102
  export declare function isWorkspaceMissingError(error: unknown): boolean;
140
- /**
141
- * 宿主服务层的**真实绑定**:各方法把动词层的问题翻成宿主官方服务的调用。
142
- *
143
- * 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
144
- * 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
145
- * `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(src/remote/map-error.ts 的两行映射)。
146
- */
103
+ /** 宿主服务的真实绑定;出错原样抛,由 `toRemoteFailure` 按 name 映射。
104
+ * 各方法调用的服务与方法见上方成员注释;错误映射表住 `src/remote/map-error.ts`。 */
147
105
  export declare function createHostPort(ctx: unknown): HostPort;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 在场能力:动词只拿到「成员离场 / 归位 / 一批归位」三条,不再拿到宿主的 `evictLive` / `announce*`。
3
+ *
4
+ * 生产实现住 `src/verbs/member-presence.ts`;组合根装配一次,挂在 `CoreDeps.presence`。这样
5
+ * “谁可以摘活体、谁可以发通告”由依赖面回答,而不是靠源码扫描维持。
6
+ */
7
+ export interface MemberBackFacts {
8
+ readonly diskBack: boolean;
9
+ readonly workspaceBack: boolean;
10
+ }
11
+ export interface PresenceCapability {
12
+ /** 成员已经不在宿主可见列表:摘活体;三态不是 unknown 时发 removed。
13
+ * `backInTrash: false` 表示目录没退回回收站(恢复回滚失败),此时什么都不做;`facts` 缺席按
14
+ * 「正常退回」处理(`createMemberPresence` 的缺省),不再留第三种含义。 */
15
+ memberGone(sessionId: string, facts?: {
16
+ readonly backInTrash: boolean;
17
+ }): Promise<void>;
18
+ /** 成员已经回到原来的可见状态:目录与账本两件写都回去才发 added。 */
19
+ memberBack(sessionId: string, facts: MemberBackFacts): Promise<void>;
20
+ /** 批量恢复的「回来了」通告;单条就是长度为 1 的批。 */
21
+ announceRestored(ids: readonly string[]): Promise<void>;
22
+ }
@@ -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` 是上屏原文、
@@ -212,6 +256,13 @@ export type RouteCode = (typeof ROUTE_CODES)[number];
212
256
  */
213
257
  export type AllCodeSuffix = VerbCode | RouteCode;
214
258
  export declare const SESSION_DELETE_CODES: Record<AllCodeSuffix, `sessiondelete/${AllCodeSuffix}`>;
259
+ /** `error.code` 是不是本仓的失败形状(`sessiondelete/<后缀>`)。**只认前缀**:后缀在不在册是
260
+ * {@link verbCodeOf} 的问题。任何要「已经是本仓的码就原样对待」的调用点都问这一句,不在调用点
261
+ * 再写一份前缀字面量。 */
262
+ export declare function isSessionDeleteCodeError(error: unknown): boolean;
263
+ /** `error.code` 折回那个动词词:前缀与后缀两件都成立才交回(后缀要在 {@link VERB_CODES} 册上)。
264
+ * 路由自检码、别域的码、非字符串 code 一律 `undefined`。 */
265
+ export declare function verbCodeOf(error: unknown): VerbCode | undefined;
215
266
  /** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
216
267
  export declare const TRASH_DIR_NAME = "session-trash";
217
268
  /** 页脚/文案里带尾斜杠的那一版(`splitOn` 按它切一刀再把目录名包成 code)。 */
@@ -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,17 +1,40 @@
1
- import type { ManifestPort } from './manifest.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 = {
32
+ /** 详情自己直接要的三件(判目录在不在、挑日志、读日志)。对账不在这条路上,所以不需要 `listDirs`。 */
13
33
  fs: Pick<FsPort, 'exists' | 'listFiles' | 'readFile'>;
14
- manifest: ManifestPort;
34
+ /** 路由交来的清册 **raw 行**:`/detail` 声明为 pure,读一次不能顺手对账——对账会写清册行,那是写动作,
35
+ * 必须排在到达序链上。读面由路由记录选好(`read: 'raw'`),这里只消费行,不再自己挑 `list()` / `raw()`。
36
+ * 代价是有意的:只有目录、清册还没这一行的手工搬入条目读不到(`/list` 对账后可见)。 */
37
+ rows: () => Promise<readonly TrashEntry[]>;
15
38
  home: string;
16
39
  };
17
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,16 +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
- * 收缩的时机归调用方——groups module 不持有可变状态,也就没有"这份快照谁负责更新"的第二套契约。
7
+ * 三个函数都是纯函数、都吃一份快照;收缩时机归调用方,本模块不持有可变状态。
14
8
  */
15
9
  import type { TrashEntry } from './entry.js';
16
10
  /** 一条露面的组根,连同它接住的孩子(顺序与清册一致)。 */
@@ -35,11 +29,21 @@ export declare function visibleGroups(rows: readonly TrashEntry[]): TrashGroupVi
35
29
  * 调用方据此报 `trash-empty` / 判"已被同批前一条带走"。
36
30
  */
37
31
  export declare function groupOf(rows: readonly TrashEntry[], id: string): TrashEntry[];
32
+ /** 一条 id 在「原始快照 vs 当前视图」里的三态。判别联合:**只有 `fresh` 带当前这一组**——
33
+ * 组语义只对"还在"的 id 有意义,carried / absent 没有可交的组,调用方也就无从误用。 */
34
+ export type GroupClaim = {
35
+ readonly verdict: 'fresh';
36
+ readonly group: readonly TrashEntry[];
37
+ } | {
38
+ readonly verdict: 'carried';
39
+ } | {
40
+ readonly verdict: 'absent';
41
+ };
38
42
  /**
39
- * 「这条是被**同批前一条**带走的,还是整批开始前就没有?」
43
+ * 「这条是被**同批前一条**带走的,还是整批开始前就没有,还是仍在?」
40
44
  *
41
- * 幂等语义只属于前者:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
45
+ * 幂等语义只属于 `carried`:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
42
46
  * `trash-empty`,不能被"已被带走"这句话掩盖成成功。**所以判据是快照的原始形状,不是当前形状**:
43
- * 原始快照里认得出(它自己、它的父、或它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
47
+ * 原始快照里认得出(它自己、或指向它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
44
48
  */
45
- export declare function takenByEarlierInBatch(original: readonly TrashEntry[], current: readonly TrashEntry[], id: string): boolean;
49
+ export declare function claimOf(original: readonly TrashEntry[], current: readonly TrashEntry[], id: string): GroupClaim;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 清册的两个读面:**真相读走 `list()`(先对账,再交行);pure 读面走 `raw()`(只回索引,不写行)**。
3
+ *
4
+ * 外部事实源是 `session-trash/` 里的目录;`manifest.list()` 只是 raw 索引。
5
+ * 消费方只认这个 seam:写动作与真相读用 `list()`,不再各自记得"读前先 reconcile";`/orphans` 与 `/detail`
6
+ * 声明为 pure,只许 `raw()`——一次 pure 读若顺手对账,就等于把一个写动作带到了到达序链之外。
7
+ * **它不是 `ManifestPort`**:同名 `list()` 的语义已经换成真相读,拿它当 raw 端口用会漏对账或递归对账。
8
+ * 两条写口从 raw 端口取;raw 端口本身只留给本 module 与 `src/trash/reconcile.ts` 的 implementation。
9
+ */
10
+ import type { FsPort } from '../ports/fs-port.js';
11
+ import type { TrashEntry } from './entry.js';
12
+ import type { ManifestPort } from './manifest.js';
13
+ export interface TrashLedger extends Pick<ManifestPort, 'add' | 'remove'> {
14
+ /** 真相读:先对账、再交行。`/list`、`/empty` 与写动词都认这一条。 */
15
+ list(): Promise<TrashEntry[]>;
16
+ /** raw 索引快照:**不对账、不写行**。只给声明为 pure 的读面(`/orphans` 扫描、`/detail` 详情);
17
+ * 要知道"此刻真有的一切"用 `list()`。 */
18
+ raw(): Promise<TrashEntry[]>;
19
+ }
20
+ export declare function createTrashLedger(raw: ManifestPort, fs: FsPort, home: string): TrashLedger;
@@ -1,5 +1,6 @@
1
1
  import type { TrashEntry } from './entry.js';
2
- /** 回收站清单的**唯一**读法(动词只依赖这个端口,不认 storage domain 的形状)。 */
2
+ /** 回收站清单的 **raw 索引端口**:只给 `src/trash/ledger.ts` 与对账实现;动词与路由只认 ledger,
3
+ * 不认 storage domain 的形状。`list()` 不保证与 `session-trash/` 目录一致(对账在 ledger 的 `list()`)。 */
3
4
  export interface ManifestPort {
4
5
  list(): Promise<TrashEntry[]>;
5
6
  add(e: TrashEntry): Promise<void>;