@xrn1997/dsh-session-delete 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、五个端点段、请求字段名**——
2
+ * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
3
3
  * Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
4
4
  * 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
5
5
  *
@@ -22,13 +22,15 @@
22
22
  * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
23
23
  */
24
24
  export declare const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
25
- /** 五个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
25
+ /** 六个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
26
26
  * 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
27
27
  * - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
28
28
  * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
29
29
  * - `POST /detail`:单条详情(用 POST 表达一个读:见设计稿 2026-10-15 §3)。
30
- * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count`),逐条回 `ApplyOutcome`。
31
- * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。 */
30
+ * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
31
+ * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
32
+ * - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
33
+ * 第五个动作,这一段只负责"有多少、在哪、多大"。 */
32
34
  export declare const ROUTES: {
33
35
  readonly list: "/list";
34
36
  /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
@@ -39,18 +41,23 @@ export declare const ROUTES: {
39
41
  readonly apply: "/apply";
40
42
  /** 清空回收站。 */
41
43
  readonly empty: "/empty";
44
+ /** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
45
+ readonly orphans: "/orphans";
42
46
  };
43
- /** `/apply` 的四个动作。id 的含义随动作走:`delete`/`count` 是**宿主会话 id**(还没进回收站),
44
- * `restore`/`purge` 是**回收站条目 id**。 */
45
- export type ApplyAction = 'delete' | 'restore' | 'purge' | 'count';
47
+ /** `/apply` 的五个动作。id 的含义随动作走:`delete`/`count`/`orphan-clean` 是**宿主会话 id**
48
+ * (前两个还没进回收站,第三个压根没有回收站行),`restore`/`purge` 是**回收站条目 id**。 */
49
+ export type ApplyAction = 'delete' | 'restore' | 'purge' | 'count' | 'orphan-clean';
46
50
  /** 每个动作的成功值:`count` 是一个数(拿不到就是 0,界面上那半句整句不出现);
47
51
  * `delete`/`restore` 是回收站那一行(`null` = 这一条已被**同批前一条**带走,幂等 no-op);
48
- * `purge` 是这一组的聚合回执(同批已带走 ⇒ `{removed:0,freedBytes:0}`)。 */
52
+ * `purge` 是这一组的聚合回执(同批已带走 ⇒ `{removed:0,freedBytes:0}`);
53
+ * `orphan-clean` 删的是**盘上目录**(不进回收站),所以清册那一行给不出 `null` 这种幂等 no-op——
54
+ * 组已经不在了就是 `not-found`,见 `verbs/batch.ts`。 */
49
55
  export type ApplyValueByAction = {
50
56
  delete: TrashEntryWire | null;
51
57
  restore: TrashEntryWire | null;
52
58
  purge: PurgeStatsWire;
53
59
  count: number;
60
+ 'orphan-clean': OrphanCleanStatsWire;
54
61
  };
55
62
  /** 一条动作的落地:原始输入 id 一律回带,成败各按信封(成功 `value` / 失败 `error`)。
56
63
  * 按 `action` + `ok` 双重可判别——浏览器半的便捷包装靠它把 `value` 收成各自动作的类型。 */
@@ -67,7 +74,8 @@ export type ApplyOutcome = {
67
74
  error: RemoteFailure;
68
75
  };
69
76
  }[ApplyAction];
70
- /** `TrashEntry` 的线上形状(浏览器半自己有一份窄镜像 `TrashEntryLike`)。 */
77
+ /** 清册那一行(`trash/entry.ts` 的 `TrashEntry`)的**线上形状**。
78
+ * 它与落地形状不是同一个东西:线上**只发**这里列的东西,落地形状另有运行期必填清单(见 `entry.ts`)。 */
71
79
  export interface TrashEntryWire {
72
80
  id: string;
73
81
  sessionId: string;
@@ -79,11 +87,79 @@ export interface TrashEntryWire {
79
87
  sizeBytes: number;
80
88
  parentEntryId?: string;
81
89
  }
90
+ /**
91
+ * `GET /list` 真正发出去的那一行:清册那一行 + 六个**现算现给**的只读数(ADR-0003)。
92
+ *
93
+ * 六个只读数都**不落清册**:它们随盘上状态随时变,清册只记文件里真有的事实。所以除了必填的
94
+ * `releaseBytes` 之外全是可选字段,而且「缺席」与「是 0」是两件事——界面上不许拿 0 冒充「没有」,
95
+ * 也不许拿缺席冒充否。
96
+ */
97
+ export interface TrashRowWire extends TrashEntryWire {
98
+ /** **这一行「彻底删除」会释放多少**:本行 + 跟着进来的孩子 + 盘上还留着的后代。
99
+ * 它是**存储侧的事实**(彻底删除删的正是这三样),所以由 Node 半算一次过线;界面上头部合计与
100
+ * 单条确认框共用这一个数——不许各算一遍(各算一遍就是两套口径,且客户端那套会重复计数)。 */
101
+ releaseBytes: number;
102
+ /** 跟着这条父会话**一起进了回收站**的孩子数(清册里 `parentEntryId` 指向它的行)。 */
103
+ subagentCount?: number;
104
+ /** 那些孩子的字节合计(头部合计要算上它们,不能只加父自己那一格)。 */
105
+ subagentBytes?: number;
106
+ /** **还留在盘上、没被带走**的孩子数(宿主 `sessionQuery`)。与 `subagentCount` 各说各的。 */
107
+ strandedSubagents?: number;
108
+ /** 那些盘上后代的实际字节合计;彻底删除/清空会一起释放。 */
109
+ strandedBytes?: number;
110
+ /** 删除时记下的工作区**已经不在账本里**(宿主的「删除工作区」只删注册、保留日志)。 */
111
+ workspaceMissing?: boolean;
112
+ }
113
+ /** 单条详情里的一条消息(`trash/detail.ts` 读出来的那一份)。 */
114
+ export interface TrashDetailMessageWire {
115
+ role: 'user' | 'assistant';
116
+ time: number;
117
+ text: string;
118
+ truncated: boolean;
119
+ }
120
+ /** 回收站单条详情的线上形状(`POST /detail`)。 */
121
+ export interface TrashDetailWire {
122
+ entryId: string;
123
+ /** 日志 header 里的原串(**无损**,比从 `projectKey` 反推那一路强,设计稿 §7 组头那条是另一回事)。 */
124
+ cwd: string;
125
+ createdAt: number;
126
+ lastActivity: number;
127
+ /** 日志内 `session/title` 那条记录;清册标题为空串时客户端拿它兜底。 */
128
+ logTitle: string;
129
+ models: string[];
130
+ turnCount: number;
131
+ userMessageCount: number;
132
+ assistantMessageCount: number;
133
+ toolCallCount: number;
134
+ injectedCount: number;
135
+ messages: TrashDetailMessageWire[];
136
+ omittedByPayloadCap: number;
137
+ }
82
138
  /** 清空回收站的回执(与旧 `purge` 同形)。 */
83
139
  export interface PurgeStatsWire {
84
140
  removed: number;
85
141
  freedBytes: number;
86
142
  }
143
+ /** 无主子会话扫描里**一个项目目录**的汇总(界面上那条"哪一家还剩多少"的读数)。 */
144
+ export interface OrphanProjectGroupWire {
145
+ projectDir: string;
146
+ sessions: number;
147
+ bytes: number;
148
+ }
149
+ /** 一次孤儿扫描的报告(纯读的产物)。`roots` 是每组那个"父没了"的 subagent 自己写的原形状;
150
+ * `missingParents` 按**去前缀后的形状**去重——同一个失踪父在两种写法下只算一个。 */
151
+ export interface OrphanReportWire {
152
+ roots: readonly string[];
153
+ totalSessions: number;
154
+ totalBytes: number;
155
+ missingParents: number;
156
+ groups: readonly OrphanProjectGroupWire[];
157
+ }
158
+ /** 清理一组无主子会话的回执(与 {@link PurgeStatsWire} 同形,但计的是"删掉的会话目录")。 */
159
+ export interface OrphanCleanStatsWire {
160
+ removed: number;
161
+ freedBytes: number;
162
+ }
87
163
  /** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
88
164
  export declare const PARAMS: {
89
165
  readonly entryId: "entryId";
@@ -120,6 +196,24 @@ export type RemoteResult<T> = {
120
196
  * 浏览器半是 `src/client/remote.ts` 的 `throwRemoteFailure`。
121
197
  */
122
198
  export declare function isDSHRemoteError(value: unknown): value is RemoteFailure;
199
+ /** 失败码的域。与宿主官方域(`workspace/*`、`session/*`…)不重名,见设计稿 §6。 */
200
+ export declare const SESSION_DELETE_ERROR_DOMAIN = "sessiondelete";
201
+ /** 五个**动词码**(设计稿 §6 那张表)。`io` 是唯一兜底。 */
202
+ export declare const VERB_CODES: readonly ["live", "not-found", "trash-empty", "project-missing", "io"];
203
+ export type VerbCode = (typeof VERB_CODES)[number];
204
+ /** 三个**路由自检码**:请求本身不对,不是一次操作的结果(它们带 HTTP 状态,见 `api/dispatch.ts`)。 */
205
+ export declare const ROUTE_CODES: readonly ["bad-method", "not-found-route", "bad-request"];
206
+ export type RouteCode = (typeof ROUTE_CODES)[number];
207
+ /**
208
+ * 完整码(`sessiondelete/<后缀>`)。**`Record<…, …>` 是闸**:加一个后缀而忘了这里,
209
+ * 编译不过;写错一个字面量,也编译不过。
210
+ */
211
+ export type AllCodeSuffix = VerbCode | RouteCode;
212
+ export declare const SESSION_DELETE_CODES: Record<AllCodeSuffix, `sessiondelete/${AllCodeSuffix}`>;
213
+ /** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
214
+ export declare const TRASH_DIR_NAME = "session-trash";
215
+ /** 页脚/文案里带尾斜杠的那一版(`splitOn` 按它切一刀再把目录名包成 code)。 */
216
+ export declare const TRASH_DIR_LABEL = "session-trash/";
123
217
  /**
124
218
  * 宿主 `projectKey` 的 **`~XXXX` 转义还原**。
125
219
  *
@@ -1,32 +1,14 @@
1
1
  import type { ManifestPort } from './manifest.js';
2
2
  import type { FsPort } from '../ports/fs-port.js';
3
+ import type { TrashDetailMessageWire, TrashDetailWire } from '../shared/wire.js';
3
4
  /** 每条文字的上限(字符)。超出即截断并置 `truncated`。 */
4
5
  export declare const PER_MESSAGE_CHAR_CAP = 3000;
5
6
  /** 一次回信的正文总量上限(字符)。 */
6
7
  export declare const TOTAL_CHAR_CAP = 500000;
7
- export interface TrashDetailMessage {
8
- role: 'user' | 'assistant';
9
- time: number;
10
- text: string;
11
- truncated: boolean;
12
- }
13
- export interface TrashDetail {
14
- entryId: string;
15
- /** 日志 header 里的原串(**无损**,比从 `projectKey` 反推那一路强,设计稿 §7 组头那条是另一回事)。 */
16
- cwd: string;
17
- createdAt: number;
18
- lastActivity: number;
19
- /** 日志内 `session/title` 那条记录;清册标题为空串时客户端拿它兜底。 */
20
- logTitle: string;
21
- models: string[];
22
- turnCount: number;
23
- userMessageCount: number;
24
- assistantMessageCount: number;
25
- toolCallCount: number;
26
- injectedCount: number;
27
- messages: TrashDetailMessage[];
28
- omittedByPayloadCap: number;
29
- }
8
+ /** 详情正文里的一条消息 / 整份详情:**线上形状的家在 `shared/wire.ts`**(ADR-0003)——
9
+ * Node 半产出它,浏览器半按同一份类型消费,这里只留旧名做别名。 */
10
+ export type TrashDetailMessage = TrashDetailMessageWire;
11
+ export type TrashDetail = TrashDetailWire;
30
12
  export type DetailDeps = {
31
13
  fs: FsPort;
32
14
  manifest: ManifestPort;
@@ -12,8 +12,8 @@ export interface TrashEntry {
12
12
  * 恢复时按这个字段还原可见性:true ⇒ 仍处归档态,false ⇒ 取消归档放回树里。 */
13
13
  wasArchived: boolean;
14
14
  deletedAt: number;
15
- /** 删除时**尽力**取的会话标题(来源:`storages/session_projcache/sessions/<会话id>.json`
16
- * 里的 `rows.title.val`);取不到存空串,绝不因此让删除失败。 */
15
+ /** 删除时**尽力**取的会话标题(来源:宿主 projection cache 服务;底层是
16
+ * `storages/session_projcache/sessions/<会话id>.json` 里的 `title` 投影)。取不到存空串,绝不因此让删除失败。 */
17
17
  title: string;
18
18
  /** 删除时用 `fs.sizeOfDir` 量到的会话目录字节数。 */
19
19
  sizeBytes: number;
@@ -24,6 +24,25 @@ export interface TrashEntry {
24
24
  * 的如实结果,不假装知道(设计稿 2026-10-15 §10)。 */
25
25
  parentEntryId?: string;
26
26
  }
27
+ /**
28
+ * 清册里**每条记录都必须有**的那几格(`TrashEntry` 的非可选字段)——运行期校验器与「文件里真有的事实」
29
+ * 共用这一份清单。
30
+ *
31
+ * **可选字段不在这里**:`parentEntryId`(级联那批加的)缺席是合法的——老行没有它,缺了就是"没有父";
32
+ * 校验器只管这几格的类型,其余键**原样带走**(`parse` 交回的就是 `raw` 本身)⇒ 新字段照旧存得住。
33
+ *
34
+ * 键集与接口由 `tests/wire-shape.test.ts` 双向钉住:接口加一个必填字段而这里没跟 ⇒ 测试红。
35
+ */
36
+ export declare const REQUIRED_ENTRY_FIELDS: {
37
+ readonly id: "string";
38
+ readonly sessionId: "string";
39
+ readonly projectDir: "string";
40
+ readonly workspaceId: "string";
41
+ readonly wasArchived: "boolean";
42
+ readonly deletedAt: "number";
43
+ readonly title: "string";
44
+ readonly sizeBytes: "number";
45
+ };
27
46
  export declare function makeEntryId(deletedAt: number, sessionDirName: string): string;
28
47
  /** `makeEntryId` 的逆。时间戳按 UTC 拆字段再 `Date.UTC` 合回——不交给 `Date.parse`
29
48
  * 去猜(`20260930T130500Z` 不是任何被承认的日期格式,猜出来的是 NaN)。 */
@@ -31,5 +50,9 @@ export declare function parseEntryId(id: string): {
31
50
  deletedAt: number;
32
51
  sessionDirName: string;
33
52
  };
53
+ /** 路径里的每一段都必须是**单段**:这一层不信宿主的 `header.id` 与清册,破坏性动词直接删目录。
54
+ * `/`、`\`、`.`、`..` 与 NUL 都能把递归删除带出 `sessions/` / `session-trash/`。 */
55
+ export declare function isSinglePathSegment(value: string): boolean;
56
+ /** `sessionDirName` 允许空串:恢复的预检用它取**项目根**(`sessions/<projectDir>`),不是会话目录。 */
34
57
  export declare function sessionDirOf(projectDir: string, sessionDirName: string, home: string): string;
35
58
  export declare function trashDirOf(entryId: string, home: string): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * **组语义的唯一家**(ADR-0001):回收站里「这一组有谁」以及「哪些行在清单里露面」。
3
+ *
4
+ * 此前这条关系在四个地方各算一遍,而且已经算出两种答案:
5
+ * `assembleTrashRows` 与 `restoreSession` 都认为「父自己也是孩子时,它接不住孩子」,
6
+ * 而 `purgeTrash` 的 `id === x || parentEntryId === x` 不认这条 ⇒ 嵌套组上它会连带删掉不属于这一组的孙代
7
+ * (不可恢复)。判据原文只在这里写一次:
8
+ *
9
+ * **接得住孩子的只有组根**——自己不记 `parentEntryId` 的那一行。父行不在、或父行自己也是孩子时,
10
+ * 孩子必须自己占一行,否则它的字节会被头部合计数两遍(而它自己既看不见也清不掉)。
11
+ *
12
+ * 三个函数都是**纯函数**、都吃一份快照:`/apply` 那条路会在同一批里多次重问、而快照会被就地收缩,
13
+ * 收缩的时机归调用方——groups module 不持有可变状态,也就没有"这份快照谁负责更新"的第二套契约。
14
+ */
15
+ import type { TrashEntry } from './entry.js';
16
+ /** 一条露面的组根,连同它接住的孩子(顺序与清册一致)。 */
17
+ export interface TrashGroupView {
18
+ readonly root: TrashEntry;
19
+ /** 跟着这条根进来的孩子(`parentEntryId` 指向根的 id)。根自己不在里面。 */
20
+ readonly kids: readonly TrashEntry[];
21
+ }
22
+ /**
23
+ * 清单露面的一行 = 组根,按清册原序。孩子折进根那一行(宿主的客户端本来就把 `origin:"subagent"`
24
+ * 的行整排藏了 ⇒ 我们若单列就会冒出一堆点不到的行)。
25
+ *
26
+ * 折不进去的那两种孩子(父行不在 / 父行自己也是孩子)**自己站出来占一行**——那正是
27
+ * `visibleGroups` 与 `groupOf` 共用同一条判据的地方。
28
+ */
29
+ export declare function visibleGroups(rows: readonly TrashEntry[]): TrashGroupView[];
30
+ /**
31
+ * 一条 id 所属的整组。三种形状,三个答案:
32
+ * - **组根** ⇒ 它自己 + 它接住的孩子(UI 点的是父那一行,删除/恢复/彻底删除都按整组走);
33
+ * - **孩子**(自己记着 `parentEntryId`)⇒ **只回它自己**。清单不单独列它,只有手工/降级路径会点到这里;
34
+ * - **清册里没有这一条 id,但还有孩子指向它**(父行已被别的路径摘掉)⇒ 那一批孩子;仍然没有 ⇒ 空数组,
35
+ * 调用方据此报 `trash-empty` / 判"已被同批前一条带走"。
36
+ */
37
+ export declare function groupOf(rows: readonly TrashEntry[], id: string): TrashEntry[];
38
+ /**
39
+ * 「这条是被**同批前一条**带走的,还是整批开始前就没有?」
40
+ *
41
+ * 幂等语义只属于前者:后来者本来就不在清册里(用户点了条幽灵 id、或对账刚摘掉它)必须照样响亮报
42
+ * `trash-empty`,不能被"已被带走"这句话掩盖成成功。**所以判据是快照的原始形状,不是当前形状**:
43
+ * 原始快照里认得出(它自己、它的父、或它的孩子任一在),而当前快照里已经空了 ⇒ 同批带走的。
44
+ */
45
+ export declare function takenByEarlierInBatch(original: readonly TrashEntry[], current: readonly TrashEntry[], id: string): boolean;
@@ -17,14 +17,11 @@ export interface DeleteDeps {
17
17
  * 三段,顺序承重:
18
18
  * ① **先点组、先验活体**(一个字节都不写)。任一成员在跑就整组拒绝——不替用户终止正在跑的活,
19
19
  * 也绝不允许"父已经搬进回收站、孩子因为在跑而搬不动"这种半套。
20
- * ② **逐个搬**:每个成员内部沿用原来那套顺序与就地回滚(归档 → 摘账本 → 搬目录 → 写清册),
21
- * 任何一步失败就把这个成员自己退回原地。
20
+ * ② **逐个搬**:每个成员是一份 `journal`(`verbs/member-journal.ts`),搬不动时 journal 自己把
21
+ * 这个成员已经做成的步骤退回去。
22
22
  * ③ **组级回滚**:某一个成员搬不动时,把**已经搬进去的**那些按相反顺序退回原处(清册行跟着摘、
23
23
  * 账本跟着挂、可见性跟着还原),失败照抛——不留"半组在回收站、半组在原地"的世界。
24
+ *
25
+ * interface 只有**一个 id**:批量的性质不在这里(同批"已被前一条带走"的判据归 `verbs/batch.ts`)。
24
26
  */
25
- export declare function deleteSession(deps: DeleteDeps): (sessionId: string,
26
- /** 批量层(`verbs/apply.ts`)在写之前收一份"这次真正要搬的成员"(父 + 盘上存在的孩子),
27
- * 成功后用它记下"已被同批前一条带走"的 id。单条调用不传。 */
28
- options?: {
29
- readonly onMembers?: (sessionIds: readonly string[]) => void;
30
- }) => Promise<TrashEntry>;
27
+ export declare function deleteSession(deps: DeleteDeps): (sessionId: string) => Promise<TrashEntry>;
@@ -1,11 +1,10 @@
1
- /** 五个码(设计稿 §6 的表)。**唯一定义处**:`remote/map-error.ts` 从这份数组反推它的 `Code`,
2
- * 免得"加了一个码只在一边加"让两张表各自漂移(漂移的后果是那个码静默降级成 `io`)。 */
1
+ /** 五个码 / 三个路由自检码的**家都在 `shared/wire.ts`**(ADR-0003):浏览器半也要认它们(比如
2
+ * `not-found` 在界面上是中性提示而不是失败),所以它们属于两半共同的契约,不属于 Node 半。
3
+ * 这里只转出旧名,免得调用点全改一遍。 */
4
+ import { type RouteCode, type VerbCode } from '../shared/wire.js';
3
5
  export declare const VERB_CODES: readonly ["live", "not-found", "trash-empty", "project-missing", "io"];
4
- export type VerbCode = (typeof VERB_CODES)[number];
5
- /** 路由层的自检(不是五个动词码之一):**请求本身不对**——方法、路径、body 形状。
6
- * 它上的是 HTTP 状态那条口(404/405),信封里也带着它,好让只看信封的调用方拿到同一句原文。 */
7
6
  export declare const ROUTE_CODES: readonly ["bad-method", "not-found-route", "bad-request"];
8
- export type RouteCode = (typeof ROUTE_CODES)[number];
7
+ export type { RouteCode, VerbCode };
9
8
  /** 一次失败的**结构**:码、上屏原文、可选 cause(`map-error` 沿 cause 链认宿主类型化错误)、
10
9
  * 可选的结构化附带物(对齐官方 `rpcErrorSchema` 的第三件 `details`)。 */
11
10
  export interface VerbError extends Error {
@@ -18,3 +17,11 @@ export declare function verbError(code: VerbCode, message: string, cause?: unkno
18
17
  export declare function routeError(code: RouteCode, httpStatus: number, message: string): VerbError & {
19
18
  httpStatus: number;
20
19
  };
20
+ /** 一条回滚步骤失败的**标签句**:label 说的是"这一步在退什么",原文带出**那一步抛的东西**
21
+ * (`Error` 取 message,其余一律 `String()`;对象带 `message` 的差异归 `map-error.messageOf` 管)。
22
+ * 调用方不许另抄这份格式——那样 details 里的句子会随各处 catch 变形,界面上就变成"同一个失败三个说法"。 */
23
+ export declare function rollbackReason(label: string, error: unknown): string;
24
+ /** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
25
+ * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
26
+ * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
27
+ export declare function withRollbackFailures(error: unknown, failures: readonly string[]): VerbError;
@@ -0,0 +1,26 @@
1
+ export interface JournalStep {
2
+ /** 这一步在做什么。失败原文按「`<label>:<原错误>`」拼——与既有那 22 处 `rollbackReason` 的措辞同形。 */
3
+ readonly label: string;
4
+ /** 做这一步。抛什么都由 journal 折成 `sessiondelete/io`(除非它自己已经是一个动词码)。 */
5
+ run(): Promise<void>;
6
+ /** 反向动作。**`run` 成功之后**才会被登记;返回**这一步自己**没能退干净的原因列表(空 = 完全退回)。 */
7
+ undo?(): Promise<readonly string[]>;
8
+ /** 这一步**自己**失败时也跑 `undo`:给"可能已经半做完"的非原子步骤用(取消归档是唯一的例子)。 */
9
+ readonly undoOnFailure?: boolean;
10
+ }
11
+ export interface Journal {
12
+ /** 按顺序跑完。中途失败 ⇒ 已做成的按相反顺序退回,然后把原错误(带 `rollbackFailures`)抛出去。 */
13
+ run(): Promise<void>;
14
+ /** 把**已经做成**的步骤按相反顺序退回去(整组回滚用:组里后面那个成员搬不动时叫这一句)。 */
15
+ undo(): Promise<readonly string[]>;
16
+ }
17
+ /** 已经把某个前缀做成了的 journal——`undo()` 就退那一个前缀。 */
18
+ export declare function createJournal(steps: readonly JournalStep[]): Journal;
19
+ /** 跑一步反向动作并把它自己的失败收成一条原因(不抛:这些失败由调用方汇总成 `rollbackFailures`)。 */
20
+ export declare function attempt(label: string, run: () => Promise<unknown>): Promise<readonly string[]>;
21
+ /**
22
+ * **组级回滚**:把已经做成的成员按相反顺序退回;某一步退不干净时把失败并进原错误的
23
+ * `details.rollbackFailures`,绝不让原错误独走(设计稿第十七批)。删除与恢复两个按组走的动词
24
+ * 共用这一处——"相反顺序 + 失败合并"不再各写一遍。
25
+ */
26
+ export declare function rollbackJournals(journals: readonly Journal[], error: unknown): Promise<never>;
@@ -0,0 +1,82 @@
1
+ import { type SessionIdentity } from '../ports/host-port.js';
2
+ import type { OrphanCleanStatsWire, OrphanReportWire } from '../shared/wire.js';
3
+ import type { DeleteDeps } from './delete-session.js';
4
+ /** 组里一个人的盘上身份(字节是**扫描那一刻**量的,清理时按目录重量一次,不拿这份快照冒充准数)。 */
5
+ export interface OrphanMember {
6
+ readonly sessionId: string;
7
+ readonly projectDir: string;
8
+ readonly bytes: number;
9
+ }
10
+ /** 一组无主子会话:根是那"父没了"的 subagent,`members` 是它的**传递闭包**(含根自己)。 */
11
+ export interface OrphanGroup {
12
+ readonly root: OrphanMember;
13
+ readonly parentSessionId: string;
14
+ readonly members: readonly OrphanMember[];
15
+ }
16
+ export interface MeasuredMemberDir {
17
+ readonly dir: string;
18
+ readonly bytes: number;
19
+ }
20
+ /** 一个成员被删之前要记下的三件:目录、字节、账本归属(缺席 = 未分组)。purge 与 orphan-clean 共用。 */
21
+ export interface MemberRemovalPlan extends MeasuredMemberDir {
22
+ readonly sessionId: string;
23
+ readonly workspaceId?: string;
24
+ }
25
+ /** 成员的盘上目录与字节(扫描、清单装配与 purge 共用这一条路径/存在性/度量口径;
26
+ * 目录缺席回 `undefined`,不拿一份"记录还在、盘上已经没了"的旧读数冒充盘上残留)。 */
27
+ export declare function measureMemberDir(deps: DeleteDeps, member: {
28
+ readonly sessionId: string;
29
+ readonly projectDir: string;
30
+ }): Promise<MeasuredMemberDir | undefined>;
31
+ /** 把一组成员折成"删之前要记下的计划";目录不在的成员跳过(记录与盘不一致是那条成员自己的事)。
32
+ * `purge` 的盘上后代与 `orphan-clean` 的整组都走这一处,别再各写一遍 measure + workspaceOf。 */
33
+ export declare function planMemberRemovals(deps: DeleteDeps, members: readonly {
34
+ readonly sessionId: string;
35
+ readonly projectDir: string;
36
+ }[]): Promise<MemberRemovalPlan[]>;
37
+ /** {@link orphanGroupsOf} 的中间形状:还没量字节的那一组(纯函数不碰盘)。 */
38
+ interface RawGroup {
39
+ readonly root: SessionIdentity;
40
+ readonly parentSessionId: string;
41
+ readonly members: readonly SessionIdentity[];
42
+ }
43
+ /**
44
+ * 无主子会话 = `origin:"subagent"`、父**既不在当前语料(live ∪ persisted)也不在回收站清册里**的会话。
45
+ *
46
+ * 为什么清册里的父不算孤儿:那种是 **stranded**——父还在回收站里等着,孩子本该跟着父一起恢复或一起
47
+ * 彻底删除(10-15 稿 §11 的组语义),把它的根当孤儿删掉就是**替用户做了一个没人点过的决定**。孤儿只
48
+ * 有一种来路:父被彻底删掉了(或被手工从盘上清掉了),清册里再也没有这一行。
49
+ *
50
+ * 根按**传递闭包**收全部后代(与 `HostPort.subagentCounts` 同一口径:孩子与孙代都算,深度不设上限),
51
+ * 同一条记录只归一组(`claimed`)——数据里真出现环时结果是"少数一条",不是把一个会话删两遍。
52
+ * 归堆与去重**两边都去 `session-` 前缀**:同一个父在两代宿主里写法不同(见 `bareSessionId` 的来历)。
53
+ */
54
+ export declare function orphanGroupsOf(corpus: readonly SessionIdentity[], trashSessionIds: readonly string[]): RawGroup[];
55
+ /** 纯读:调用方应先走一次 `/list` 对账(面板的 refresh 就是这样);本函数不写清单。
56
+ * 写动作 `orphan-clean` 会自己先 reconcile,避免拿一份过期清册判"父没了"。
57
+ *
58
+ * `listSessions()` 回 `undefined` 时**必须抛**:那是宿主的会话语料服务没挂,不是"这个家没有孤儿"。
59
+ * 拿 undefined 当空语料 ⇒ 报告说"零条孤儿"、界面上一句"没有要清理的",而盘上真有几百 MB——那是假话,
60
+ * 所以这里按 `io` 响亮失败(AGENTS.md:`undefined` 意味着服务缺席,扫描方不许冒充读数)。 */
61
+ export declare function loadOrphanGroups(deps: DeleteDeps): Promise<readonly OrphanGroup[]>;
62
+ /** 把一组组收成那份报告:根清单 + 闭包总数/总字节 + 失踪父的**去重计数** + 按项目目录的分摊。
63
+ * `groups` 按字节从多到少排(界面上"哪家还剩得最多"要看第一行)。 */
64
+ export declare function reportOrphans(groups: readonly OrphanGroup[]): OrphanReportWire;
65
+ /**
66
+ * 清一组:先**整组 live 准入(零写)**,再收集目录/字节/账本归属,再 unarchive → detach → 删目录。
67
+ * 任何 preflight 失败都在第一件写之前抛出;删目录到一半失败时**已删目录不回滚**,失败错误的
68
+ * `details` 带 `{ removed, freedBytes, failedSessionId }` 作为部分回执(不伪装成整组没动)。
69
+ * 删目录之后照 purge 的规矩摘活体并如实通告。不碰投影缓存与附件。
70
+ *
71
+ * 顺序与删除那一半是**对偶**的,但不是照抄:
72
+ * - 删除要先把会话藏起来(归档)再摘账本,免得中间那一拍它掉进「未分组」;这里目录是**删掉**不是搬走,
73
+ * 藏不藏都不影响可见性 ⇒ 归档集反而是**在写之前整批摘掉**的:失败时目录还在,下一次还能重试,
74
+ * 不会留下"会话没了但还在归档集里挂着"的幽灵 id。
75
+ * - `unarchive` 对未归档的会话是官方幂等 no-op(`registry.unarchiveSession`),所以不需要删除侧那次
76
+ * `wasArchived` 读——这里没有"还原到删除前"的义务。
77
+ * - 摘账本只在账本真记着它时做(`workspaceOf` 未命中 = 本来就是「未分组」,不是故障)。
78
+ * - `evictLive` 的三态照 `HostPort.evictLive` 的契约:`unknown` 时**不通告**(发了就是假话),
79
+ * `evicted` / `not-live` 才发 `api-session/removed`。冷会话没有活体可摘,通告照样必须发。
80
+ */
81
+ export declare function cleanOrphanGroup(deps: DeleteDeps, group: OrphanGroup): Promise<OrphanCleanStatsWire>;
82
+ export {};
@@ -1,6 +1,18 @@
1
1
  import { type TrashEntry } from '../trash/entry.js';
2
2
  import type { DeleteDeps } from './delete-session.js';
3
- export declare function purgeTrash(deps: DeleteDeps): (entryId?: string, snapshot?: TrashEntry[]) => Promise<{
3
+ /**
4
+ * 彻底删除:从回收站里**真的**抹掉一条(或整组、或整站),并连带那些"还留在盘上、没被带走"的后代。
5
+ *
6
+ * 与另外两个动词的关系(ADR-0002):它的 interface 只有"一个条目 id(或缺席 = 清空)",
7
+ * 批量的快照与"已被同批前一条带走"的判据不在这里(归 `verbs/batch.ts`)。
8
+ *
9
+ * **这条动词不做回滚**:目录删掉就没有"退回去"这回事(设计稿第十七批:删到一半失败时已删目录不回滚;
10
+ * 带部分计数的 `details` 回执只属于 `orphan-clean`,purge 的 `removed` / `freedBytes` 仍只在成功回执里给)。
11
+ * 所以它的 journal 只有正向步骤、没有 `undo`——顺序仍由 journal 统一表达,不再是手写的四段 `try/catch`。
12
+ */
13
+ export declare function purgeTrash(deps: DeleteDeps): (entryId?: string,
14
+ /** **只读**清册快照:批量层已经读过一次就递下来(ADR-0002);收缩归批量层,动词不碰它。 */
15
+ snapshot?: readonly TrashEntry[]) => Promise<{
4
16
  removed: number;
5
17
  freedBytes: number;
6
18
  }>;
@@ -1,4 +1,5 @@
1
1
  import { type TrashEntry } from '../trash/entry.js';
2
+ import { type HostPort } from '../ports/host-port.js';
2
3
  import type { DeleteDeps } from './delete-session.js';
3
4
  /**
4
5
  * 恢复 = 把删除那次**按原路退回**,而且按组走:当年是按组一起搬进来的(父 + 它在盘上的孩子),
@@ -8,12 +9,31 @@ import type { DeleteDeps } from './delete-session.js';
8
9
  * ① **预检整组**(一个字节都不写):每个成员的回收站目录与项目目录都得在。缺席的判据与原来一致
9
10
  * (回收站目录不在 = `trash-empty`;原项目目录不在 = `project-missing`,且绝不静默建目录)。
10
11
  * 预检在写之前,是为了不让"父已经回到 `sessions/`、孩子因为项目目录没了而回不去"这种半组留在外面。
11
- * ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 → 还原可见性 → 摘清册行 → 通告回来。
12
- * ③ 中途失败把这个成员自己退回回收站(就地回滚),并把**已经恢复完的**兄弟按相反顺序一起退回,失败照抛。
12
+ * **工作区账本不在这条硬前提里**:宿主的「删除工作区」只删注册、保留日志,记下的工作区没了就按
13
+ * 「未分组」收(判据在 {@link attachableWorkspaceId}),照旧回盘——恢复是加法,不该因为一个可选的账目而失败。
14
+ * ② **逐个恢复**,顺序与删除相反:文件先回来 → 挂账本 → 还原可见性 → 摘清册行。
15
+ * ③ 中途失败时 journal 把这个成员自己退回回收站;**已经恢复完的**兄弟由整组 catch 按相反顺序一起退回。
16
+ *
17
+ * **通告不在这里**:一次批量只该拉一遍宿主会话表、只发一次 `api-session/added`(2026-10-17 那次
18
+ * "恢复很慢"就是逐条发)。通告归 `verbs/batch.ts`(ADR-0002),所以这个动词的 interface 只有
19
+ * "一个条目 id 进、回收站那一行出",没有批量专用的开关。
20
+ */
21
+ export declare function restoreSession(deps: DeleteDeps): (entryId: string,
22
+ /** **只读**清册快照:批量层已经读过一次就递下来,整批因此只读一次(ADR-0002)。
23
+ * 动词**不收缩**它——收缩与"已被同批前一条带走"的判据都归 `verbs/batch.ts`。 */
24
+ snapshot?: readonly TrashEntry[]) => Promise<TrashEntry>;
25
+ /**
26
+ * 这一次恢复**到底挂不挂账本**:交出能挂的那个 `workspaceId`,挂不了就 `undefined`。
27
+ *
28
+ * 两种缺席是同一件事:空串 = 删除时它就没有账目(「未分组」的会话);非空但账本里已经没它 =
29
+ * 宿主的「删除工作区」只删注册、保留目录与每一份日志,那些会话之后本来就在「未分组」下。
30
+ * 恢复不该为了挂一个不存在的账本把整条退回回收站。
31
+ *
32
+ * 判据**只有这一处**:`memberRestore` 拿它决定挂不挂,`GET /list` 拿它决定行上那句「原工作区已不存在,
33
+ * 恢复后归入未分组」说不说。两边各问一遍就会有一天一个说、一个不挂。
13
34
  */
14
- export declare function restoreSession(deps: DeleteDeps): (entryId: string, options?: {
15
- readonly snapshot?: TrashEntry[];
16
- /** 批量(`verbs/apply.ts`)**自己收尾一次发**时传 `suppressAnnounce`:动词不再逐条通告——
17
- * 单条一份摘要 = 一条一次宿主全表扫,那是 2026-10-17 "恢复很慢"的头号来源。 */
18
- readonly suppressAnnounce?: boolean;
19
- }) => Promise<TrashEntry>;
35
+ export declare function attachableWorkspaceId(host: Pick<HostPort, 'workspaceExists'>, entry: TrashEntry): Promise<string | undefined>;
36
+ /** 这一条记着的工作区**是不是已经没了**(区别于"删除时它就没有账目"的空串)——清单行据此决定
37
+ * 要不要在点之前说那句「原工作区已不存在,恢复后归入未分组」。由 {@link attachableWorkspaceId}
38
+ * 派生,不另起一份判据:那样会有一天一个说、一个不挂。 */
39
+ export declare function isWorkspaceMissing(host: Pick<HostPort, 'workspaceExists'>, entry: TrashEntry): Promise<boolean>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xrn1997/dsh-session-delete",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "在 DeepSeek Harness 里删除会话:删除=移入回收站(可恢复),另设显式彻底删除",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -29,7 +29,8 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=24"
32
+ "node": ">=24",
33
+ "dsh": ">=0.1.7-rc.2 <0.3.0-0"
33
34
  },
34
35
  "scripts": {
35
36
  "build": "tsdown && tsc -p tsconfig.build.json",
@@ -49,9 +50,11 @@
49
50
  "@types/node": "24.19.0",
50
51
  "@types/react": "18.3.31",
51
52
  "@types/react-dom": "18.3.5",
53
+ "@types/semver": "^7.8.0",
52
54
  "jsdom": "^26.1.0",
53
55
  "react": "^18.3.1",
54
56
  "react-dom": "18.3.1",
57
+ "semver": "^7.8.5",
55
58
  "tsdown": "0.23.0",
56
59
  "typescript": "5.9.3",
57
60
  "vitest": "3.2.7"
@@ -68,7 +71,9 @@
68
71
  },
69
72
  "compatibility": {
70
73
  "dshReleases": {
71
- "0.2.0-rc.2": "compatible"
74
+ "0.2.0-rc.2": "compatible",
75
+ "0.2.1-rc.1": "compatible",
76
+ "0.2.1-rc.2": "compatible"
72
77
  }
73
78
  }
74
79
  },
File without changes