@wangxiaocuo/dsh-session-trash 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/client.js +145 -42
- package/lib/errors.js +61 -1
- package/lib/fsops.js +8 -1
- package/lib/state.js +47 -4
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
本文件记录这个插件的所有重要变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
|
|
5
|
+
## [0.6.3] - 2026-11-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `/state` 的条目新增稳定的 `lastErrorCode`,页面据此把失败原因本地化;`lastError` 保留英文原始诊断,只用作 tooltip。
|
|
10
|
+
- 新增本 CHANGELOG,并把它包含在 npm 包里。
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- 中文界面不再直接显示 Host 的英文诊断;「在 Finder 中显示」改为平台中性的「在文件管理器中显示」。
|
|
15
|
+
- 英文界面使用正确的单复数(会话数、子会话数、清理/删除数量)。
|
|
16
|
+
- 清理结果区分部分失败与全部失败,失败时使用失败 Toast 并给出数量。
|
|
17
|
+
- 到期倒计时每分钟刷新;条目的数据变少时页码自动收敛。
|
|
18
|
+
- 生成 `/state` 时,同一原始工作区只探测一次,而不是每个条目各探测一次。
|
|
19
|
+
- 未知大小显示为 `—`,不再显示 `0 B`。
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- 恢复、永久删除、从索引移除或显示工作区成功后,清除该行残留的旧错误提示。
|
|
24
|
+
- 跨设备复制后源目录删除失败时,HTTP 错误不再返回源路径/回收站绝对路径;错误 `details` 只暴露白名单字段(`code`、`phase`、`sessionId`、`metrics` 等)。
|
|
25
|
+
- 跨设备复制校验现在比较符号链接数量,能发现复制过程中被静默丢弃的符号链接。
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
|
|
29
|
+
- 新增 wire 错误脱敏、`lastErrorCode` 映射、工作区探测去重、清理部分失败、行错误清除、未知大小和符号链接校验的回归测试。
|
|
30
|
+
|
|
31
|
+
## [0.6.2] - 2026-11-11
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- 客户端写操作串行化:同一时间只允许一个写请求,行级 pending 状态独立清理,程序化重复点击会被忽略。
|
|
36
|
+
- 写请求 30 秒、`/state` 15 秒硬超时;设置页卸载时取消在途请求,超时显示本地化文案。
|
|
37
|
+
- 后台刷新不再禁用行操作;`ActionMenu` 在变为禁用时自动关闭。
|
|
38
|
+
|
|
39
|
+
### Tests / CI
|
|
40
|
+
|
|
41
|
+
- 测试入口改为跨平台的 `test/run-unit.mjs`(不依赖 shell glob)。
|
|
42
|
+
- CI 增加 Windows(Node 22)矩阵,并把单元测试失败输出转成 check annotations。
|
|
43
|
+
- e2e 支持跨平台发现桌面版,Windows 上跳过依赖 POSIX 权限位的清理失败场景。
|
|
44
|
+
|
|
45
|
+
### Docs
|
|
46
|
+
|
|
47
|
+
- README 用可核对的验证矩阵替代原先无法验证的跨平台表述。
|
|
48
|
+
|
|
49
|
+
## [0.6.1] - 2026-11-11
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- Host 冒烟测试改为普通 Node 可运行:索引版本断言改为读取 `INDEX_VERSION`,不再硬编码;Cordis 来自 `@deepseek-ai/cordis` devDependency;自己创建并清理临时 `DSH_HOME`。
|
|
54
|
+
|
|
55
|
+
### Performance
|
|
56
|
+
|
|
57
|
+
- 批量删除/清理改为批量提交索引:200 条目的永久删除从 400 次索引提交、约 70 MB 写入降到 2 次提交、约 0.4 MB。
|
|
58
|
+
- 启动恢复复用 reconcile 的探测结果,并对成员目录并行探测;单个条目变化时只重写它自己的 `meta.json`。
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- GitHub Actions CI(单元测试、冒烟测试、覆盖率、打包内容检查)。
|
|
63
|
+
|
|
64
|
+
## [0.6.0] - 2026-11-09
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- 隐藏的 subagent 子会话随父会话一起移入回收站、一起恢复;含子会话的条目显示数量徽章。
|
|
69
|
+
- 生成组合包并以 `@wangxiaocuo/dsh-session-trash` 发布。
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- **BREAKING**:索引 schema 升级为 v2(条目携带 `members`);旧版本插件无法读取 v2 索引,会进入保护模式。降级前请先清空回收站。
|
|
74
|
+
|
|
75
|
+
## [0.5.1] - 2026-11-09
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
|
|
79
|
+
- 回收站工具栏的「永久删除全部」与二次确认;`missing` 记录只移出索引,不删除会话文件。
|
|
80
|
+
|
|
81
|
+
[0.6.3]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.2...v0.6.3
|
|
82
|
+
[0.6.2]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.1...v0.6.2
|
|
83
|
+
[0.6.1]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.0...v0.6.1
|
|
84
|
+
[0.6.0]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.5.1...v0.6.0
|
|
85
|
+
[0.5.1]: https://github.com/wangxiaocuo/dsh-session-trash/releases/tag/v0.5.1
|
package/README.en.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[简体中文](./README.md) | English
|
|
4
4
|
|
|
5
|
-
[GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash)
|
|
5
|
+
[GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash) · [Changelog](./CHANGELOG.md)
|
|
6
6
|
|
|
7
7
|
A session trash for DeepSeek Harness. Move cold sessions out of the sidebar without deleting their logs, restore them within the retention window, or delete the logs permanently. Built for DSH 0.2.2-rc.1 on Desktop and Web. Host and Client ship in one package, with no build step.
|
|
8
8
|
|
|
@@ -132,7 +132,7 @@ Restore and maintenance refusals include `TRASH_ENTRY_NOT_FOUND`, `TRASH_STATE_I
|
|
|
132
132
|
|
|
133
133
|
`/state.stats.cleanup` reports the last cleanup attempt, the next sweep, a pending retry, consecutive failures, and the last run summary.
|
|
134
134
|
|
|
135
|
-
`/state` entries gain `subagentCount` (hidden children moved along) and `members` (parent first, each with `sessionId`, `origin`, and `sizeBytes`); `sizeBytes`/`fileCount` cover the whole tree. The `entry` returned by `/trash` uses the same shape.
|
|
135
|
+
`/state` entries gain `subagentCount` (hidden children moved along) and `members` (parent first, each with `sessionId`, `origin`, and `sizeBytes`); `sizeBytes`/`fileCount` cover the whole tree. The `entry` returned by `/trash` uses the same shape. Entries also carry `lastErrorCode` (a stable category the page localizes) and `lastError` (the raw English diagnostic, used only as a tooltip).
|
|
136
136
|
|
|
137
137
|
## Storage and recovery
|
|
138
138
|
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
简体中文 | [English](./README.en.md)
|
|
4
4
|
|
|
5
|
-
[GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash)
|
|
5
|
+
[GitHub](https://github.com/wangxiaocuo/dsh-session-trash) · [npm](https://www.npmjs.com/package/@wangxiaocuo/dsh-session-trash) · [更新记录](./CHANGELOG.md)
|
|
6
6
|
|
|
7
7
|
DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,日志保留在本地;保留期内可以恢复,到期后自动删除日志。面向 DSH 0.2.2-rc.1 的 Desktop 和 Web,Host 和 Client 在同一个包里,不需要构建步骤。
|
|
8
8
|
|
|
@@ -131,7 +131,7 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
|
|
|
131
131
|
|
|
132
132
|
`/state` 的 `stats.cleanup` 记录上次清理时间、下次清理时间、重试时间、连续失败次数和上次运行摘要。
|
|
133
133
|
|
|
134
|
-
`/state` 的条目新增 `subagentCount`(一并移入的隐藏子会话数)和 `members`(父会话在前,含 `sessionId`、`origin`、`sizeBytes`);`sizeBytes`/`fileCount` 是整棵树的总量。`/trash` 返回的 `entry`
|
|
134
|
+
`/state` 的条目新增 `subagentCount`(一并移入的隐藏子会话数)和 `members`(父会话在前,含 `sessionId`、`origin`、`sizeBytes`);`sizeBytes`/`fileCount` 是整棵树的总量。`/trash` 返回的 `entry` 使用同一形状。条目还带 `lastErrorCode`(稳定的失败分类,供页面本地化)和 `lastError`(英文原始诊断,只作 tooltip)。
|
|
135
135
|
|
|
136
136
|
## 数据与恢复
|
|
137
137
|
|
package/client.js
CHANGED
|
@@ -32,6 +32,19 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
* Preflight refusals the user can act on themselves. Those keep a visible
|
|
33
33
|
* menu entry that explains the refusal instead of hiding it.
|
|
34
34
|
*/
|
|
35
|
+
/** Host `lastErrorCode` values mapped to owner-facing messages. */
|
|
36
|
+
const LAST_ERROR_KEYS = {
|
|
37
|
+
TRASH_COPY_MISSING: 'lastErrorTrashCopyMissing',
|
|
38
|
+
TRASH_COPY_MISSING_MEMBERS: 'lastErrorTrashCopyMissingMembers',
|
|
39
|
+
TRASH_OBJECT_MISSING: 'lastErrorTrashObjectMissing',
|
|
40
|
+
PURGE_FAILED: 'lastErrorPurgeFailed',
|
|
41
|
+
RESTORE_INVISIBLE: 'lastErrorRestoreInvisible',
|
|
42
|
+
MOVE_UNSETTLED: 'lastErrorMoveUnsettled',
|
|
43
|
+
SOURCE_CONFLICT: 'lastErrorSourceConflict',
|
|
44
|
+
INDEX_WRITE_FAILED: 'lastErrorIndexWriteFailed',
|
|
45
|
+
IO_ERROR: 'lastErrorIo',
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
const MOVE_BLOCKED_KEYS = {
|
|
36
49
|
SESSION_LIVE: { label: 'moveBlockedLive', toast: 'blockedLiveToast' },
|
|
37
50
|
SESSION_ACTIVE: { label: 'moveBlockedActive', toast: 'blockedActiveToast' },
|
|
@@ -77,7 +90,8 @@ window.__ModuleLoader__.load({
|
|
|
77
90
|
retry: '重试',
|
|
78
91
|
cleanupExpired: '清理已过期',
|
|
79
92
|
cleaning: '正在清理…',
|
|
80
|
-
|
|
93
|
+
statsCountOne: '{count} 个会话',
|
|
94
|
+
statsCountOther: '{count} 个会话',
|
|
81
95
|
statsSize: '占用 {size}',
|
|
82
96
|
searchLabel: '搜索回收站',
|
|
83
97
|
searchPlaceholder: '搜索标题、Session ID 或原始工作区',
|
|
@@ -127,7 +141,8 @@ window.__ModuleLoader__.load({
|
|
|
127
141
|
badgeMigration: '需要迁移',
|
|
128
142
|
badgeExpiring: '即将到期',
|
|
129
143
|
badgeExpired: '已过期',
|
|
130
|
-
|
|
144
|
+
badgeSubagentsOne: '含 {count} 个子会话',
|
|
145
|
+
badgeSubagentsOther: '含 {count} 个子会话',
|
|
131
146
|
stateTrashing: '正在移入…',
|
|
132
147
|
stateRestoring: '正在恢复…',
|
|
133
148
|
statePurging: '正在删除…',
|
|
@@ -135,7 +150,7 @@ window.__ModuleLoader__.load({
|
|
|
135
150
|
actionRestoreFor: '恢复「{title}」',
|
|
136
151
|
actionMoreFor: '「{title}」的更多操作',
|
|
137
152
|
actionRestoreOrphan: '恢复为孤儿会话',
|
|
138
|
-
actionReveal: '
|
|
153
|
+
actionReveal: '在文件管理器中显示',
|
|
139
154
|
actionRevealDisabled: '原工作区已不存在',
|
|
140
155
|
actionCopyId: '复制 Session ID',
|
|
141
156
|
actionForget: '从索引移除',
|
|
@@ -152,23 +167,37 @@ window.__ModuleLoader__.load({
|
|
|
152
167
|
purgeAllTitle: '永久删除回收站中的全部 {count} 个会话?',
|
|
153
168
|
purgeAllBody: '此操作不可撤销。附件、图片和搜索索引等衍生物不会被删除。',
|
|
154
169
|
purgeAllConfirm: '全部永久删除',
|
|
155
|
-
|
|
170
|
+
purgeAllDoneOne: '已永久删除 {count} 个会话',
|
|
171
|
+
purgeAllDoneOther: '已永久删除 {count} 个会话',
|
|
156
172
|
purgeAllNone: '回收站中没有可删除的会话',
|
|
157
|
-
purgeAllPartial: '
|
|
173
|
+
purgeAllPartial: '批量删除完成:成功 {removed} 个,失败 {failed} 个。',
|
|
158
174
|
forgetTitle: '从回收站列表移除这条缺失记录?',
|
|
159
175
|
forgetBody: '不会删除任何文件;仅移除索引中的这条记录。',
|
|
160
176
|
forgetConfirm: '从索引移除',
|
|
161
|
-
|
|
177
|
+
cleanupDoneOne: '已清理 {count} 个过期会话',
|
|
178
|
+
cleanupDoneOther: '已清理 {count} 个过期会话',
|
|
162
179
|
cleanupNone: '没有需要清理的过期会话',
|
|
163
|
-
|
|
164
|
-
|
|
180
|
+
cleanupFailedBannerOne: '上次自动清理有 {count} 个会话未能删除。',
|
|
181
|
+
cleanupFailedBannerOther: '上次自动清理有 {count} 个会话未能删除。',
|
|
182
|
+
cleanupErrorBanner: '上次自动清理失败,请稍后在本页重试。',
|
|
183
|
+
cleanupFailed: '清理失败:还有 {failed} 个过期会话没有删除。',
|
|
184
|
+
cleanupPartial: '已清理 {purged} 个过期会话,{failed} 个没有删除。',
|
|
165
185
|
cleanupRetryAt: '将在 {time} 自动重试。',
|
|
166
186
|
cleanupRetryNow: '立即重试',
|
|
167
187
|
copySuccess: '已复制 Session ID',
|
|
168
188
|
copyFailed: '无法复制 Session ID,请手动复制',
|
|
169
|
-
revealSuccess: '
|
|
189
|
+
revealSuccess: '已在文件管理器中显示原工作区',
|
|
170
190
|
revealFailed: '无法打开原工作区',
|
|
171
191
|
lastErrorLabel: '上次错误:{message}',
|
|
192
|
+
lastErrorTrashCopyMissing: '回收站里的会话副本缺失。',
|
|
193
|
+
lastErrorTrashCopyMissingMembers: '回收站里的部分子会话副本缺失。',
|
|
194
|
+
lastErrorTrashObjectMissing: '回收站对象目录缺失。',
|
|
195
|
+
lastErrorPurgeFailed: '会话日志删除失败。',
|
|
196
|
+
lastErrorRestoreInvisible: '恢复后会话存储没有识别到该会话。',
|
|
197
|
+
lastErrorMoveUnsettled: '会话已移动,但会话存储仍报告它存在。',
|
|
198
|
+
lastErrorSourceConflict: '原会话目录与回收站副本同时存在,两者都已保留。',
|
|
199
|
+
lastErrorIndexWriteFailed: '回收站索引写入失败。',
|
|
200
|
+
lastErrorIo: '文件操作失败。',
|
|
172
201
|
footnote: '回收站目录:{path}',
|
|
173
202
|
move: '移入回收站',
|
|
174
203
|
moveBlockedLive: '移入回收站(会话已打开)',
|
|
@@ -177,7 +206,8 @@ window.__ModuleLoader__.load({
|
|
|
177
206
|
undo: '撤销',
|
|
178
207
|
undoFailed: '撤销失败',
|
|
179
208
|
trashSuccess: '已将「{title}」移入回收站',
|
|
180
|
-
|
|
209
|
+
trashSuccessTreeOne: '「{title}」及其 {count} 个子会话已移入回收站',
|
|
210
|
+
trashSuccessTreeOther: '「{title}」及其 {count} 个子会话已移入回收站',
|
|
181
211
|
blockedLiveToast: '该会话已在本机打开。DSH 目前不会释放已打开的会话,请重启 DSH 后再移入回收站。',
|
|
182
212
|
blockedActiveToast: '该会话还有正在运行的任务或子代理,等它停下来后再移入回收站。',
|
|
183
213
|
blockedDescendantsToast: '该会话包含可见的子会话,只能使用 DSH 自带的「归档」整棵会话树。',
|
|
@@ -202,7 +232,7 @@ window.__ModuleLoader__.load({
|
|
|
202
232
|
errorIndexCorrupt: '回收站索引损坏,已进入保护模式。',
|
|
203
233
|
errorIndexOk: '回收站索引可正常读取,无需重建。',
|
|
204
234
|
errorForgetNotAllowed: '只有文件缺失的记录可以从索引移除。',
|
|
205
|
-
errorRevealUnavailable: '
|
|
235
|
+
errorRevealUnavailable: '当前环境无法打开文件管理器。',
|
|
206
236
|
errorRevealFailed: '无法打开原工作区。',
|
|
207
237
|
errorIo: '文件操作失败,请稍后重试。',
|
|
208
238
|
}
|
|
@@ -215,7 +245,8 @@ window.__ModuleLoader__.load({
|
|
|
215
245
|
retry: 'Retry',
|
|
216
246
|
cleanupExpired: 'Delete expired',
|
|
217
247
|
cleaning: 'Cleaning…',
|
|
218
|
-
|
|
248
|
+
statsCountOne: '{count} session',
|
|
249
|
+
statsCountOther: '{count} sessions',
|
|
219
250
|
statsSize: '{size} used',
|
|
220
251
|
searchLabel: 'Search the trash',
|
|
221
252
|
searchPlaceholder: 'Search title, Session ID, or original workspace',
|
|
@@ -265,7 +296,8 @@ window.__ModuleLoader__.load({
|
|
|
265
296
|
badgeMigration: 'Migration required',
|
|
266
297
|
badgeExpiring: 'Expiring soon',
|
|
267
298
|
badgeExpired: 'Expired',
|
|
268
|
-
|
|
299
|
+
badgeSubagentsOne: '{count} sub-session',
|
|
300
|
+
badgeSubagentsOther: '{count} sub-sessions',
|
|
269
301
|
stateTrashing: 'Moving…',
|
|
270
302
|
stateRestoring: 'Restoring…',
|
|
271
303
|
statePurging: 'Deleting…',
|
|
@@ -273,7 +305,7 @@ window.__ModuleLoader__.load({
|
|
|
273
305
|
actionRestoreFor: 'Restore “{title}”',
|
|
274
306
|
actionMoreFor: 'More actions for “{title}”',
|
|
275
307
|
actionRestoreOrphan: 'Restore as orphan',
|
|
276
|
-
actionReveal: 'Show in
|
|
308
|
+
actionReveal: 'Show in file manager',
|
|
277
309
|
actionRevealDisabled: 'The original workspace no longer exists',
|
|
278
310
|
actionCopyId: 'Copy Session ID',
|
|
279
311
|
actionForget: 'Remove from index',
|
|
@@ -290,23 +322,37 @@ window.__ModuleLoader__.load({
|
|
|
290
322
|
purgeAllTitle: 'Permanently delete all {count} sessions in the trash?',
|
|
291
323
|
purgeAllBody: 'This cannot be undone. Attachments, images, and search index rows are not deleted.',
|
|
292
324
|
purgeAllConfirm: 'Delete all permanently',
|
|
293
|
-
|
|
325
|
+
purgeAllDoneOne: 'Permanently deleted {count} session',
|
|
326
|
+
purgeAllDoneOther: 'Permanently deleted {count} sessions',
|
|
294
327
|
purgeAllNone: 'No sessions to delete',
|
|
295
|
-
purgeAllPartial: '
|
|
328
|
+
purgeAllPartial: 'Bulk delete finished: {removed} removed, {failed} failed.',
|
|
296
329
|
forgetTitle: 'Remove this missing record from the trash list?',
|
|
297
330
|
forgetBody: 'No file is deleted; only this index record is removed.',
|
|
298
331
|
forgetConfirm: 'Remove from index',
|
|
299
|
-
|
|
332
|
+
cleanupDoneOne: 'Deleted {count} expired session',
|
|
333
|
+
cleanupDoneOther: 'Deleted {count} expired sessions',
|
|
300
334
|
cleanupNone: 'No expired sessions to delete',
|
|
301
|
-
|
|
302
|
-
|
|
335
|
+
cleanupFailedBannerOne: 'The last automatic cleanup could not delete {count} expired session.',
|
|
336
|
+
cleanupFailedBannerOther: 'The last automatic cleanup could not delete {count} expired sessions.',
|
|
337
|
+
cleanupErrorBanner: 'The last automatic cleanup failed. Retry from this page later.',
|
|
338
|
+
cleanupFailed: 'Cleanup could not delete {failed} of the expired sessions.',
|
|
339
|
+
cleanupPartial: 'Cleanup deleted {purged}; {failed} could not be deleted.',
|
|
303
340
|
cleanupRetryAt: 'Next retry: {time}.',
|
|
304
341
|
cleanupRetryNow: 'Retry now',
|
|
305
342
|
copySuccess: 'Session ID copied',
|
|
306
343
|
copyFailed: 'Could not copy the Session ID. Copy it manually.',
|
|
307
|
-
revealSuccess: 'Revealed the original workspace in
|
|
344
|
+
revealSuccess: 'Revealed the original workspace in the file manager',
|
|
308
345
|
revealFailed: 'Could not open the original workspace',
|
|
309
346
|
lastErrorLabel: 'Last error: {message}',
|
|
347
|
+
lastErrorTrashCopyMissing: 'The stored copy of this session is missing from the trash.',
|
|
348
|
+
lastErrorTrashCopyMissingMembers: 'Some sub-session copies are missing from the trash.',
|
|
349
|
+
lastErrorTrashObjectMissing: 'The trash object directory is missing.',
|
|
350
|
+
lastErrorPurgeFailed: 'The session log could not be deleted.',
|
|
351
|
+
lastErrorRestoreInvisible: 'Session storage did not report the restored session.',
|
|
352
|
+
lastErrorMoveUnsettled: 'The session was moved, but session storage still reports it.',
|
|
353
|
+
lastErrorSourceConflict: 'The original session directory and the trash copy both exist; both were kept.',
|
|
354
|
+
lastErrorIndexWriteFailed: 'The trash index could not be written.',
|
|
355
|
+
lastErrorIo: 'A file operation failed.',
|
|
310
356
|
footnote: 'Trash directory: {path}',
|
|
311
357
|
move: 'Move to trash',
|
|
312
358
|
moveBlockedLive: 'Move to trash (session is open)',
|
|
@@ -315,7 +361,8 @@ window.__ModuleLoader__.load({
|
|
|
315
361
|
undo: 'Undo',
|
|
316
362
|
undoFailed: 'Could not undo',
|
|
317
363
|
trashSuccess: 'Moved “{title}” to the trash',
|
|
318
|
-
|
|
364
|
+
trashSuccessTreeOne: '“{title}” and its single sub-session were moved to the trash',
|
|
365
|
+
trashSuccessTreeOther: '“{title}” and its {count} sub-sessions were moved to the trash',
|
|
319
366
|
blockedLiveToast: 'This session is open in DSH. DSH does not release opened sessions yet; restart DSH, then move it to the trash.',
|
|
320
367
|
blockedActiveToast: 'This session still has running work; wait for it to stop, then move it to the trash.',
|
|
321
368
|
blockedDescendantsToast: 'This session has visible child sessions; use the built-in Archive action for the whole tree instead.',
|
|
@@ -340,7 +387,7 @@ window.__ModuleLoader__.load({
|
|
|
340
387
|
errorIndexCorrupt: 'The trash index is damaged, so protection mode is on.',
|
|
341
388
|
errorIndexOk: 'The trash index is readable and does not need a rebuild.',
|
|
342
389
|
errorForgetNotAllowed: 'Only records whose log is missing can be removed from the index.',
|
|
343
|
-
errorRevealUnavailable: 'This environment cannot open
|
|
390
|
+
errorRevealUnavailable: 'This environment cannot open a file manager.',
|
|
344
391
|
errorRevealFailed: 'Could not open the original workspace.',
|
|
345
392
|
errorIo: 'A file operation failed. Try again.',
|
|
346
393
|
}
|
|
@@ -359,11 +406,26 @@ window.__ModuleLoader__.load({
|
|
|
359
406
|
}
|
|
360
407
|
|
|
361
408
|
/**
|
|
362
|
-
*
|
|
363
|
-
*
|
|
409
|
+
* Pick the grammatical singular/plural locale form for a count.
|
|
410
|
+
*
|
|
411
|
+
* @param {(key: string) => string} t bound translator.
|
|
412
|
+
* @param {string} key base locale key (without the One/Other suffix).
|
|
413
|
+
* @param {number} count item count.
|
|
414
|
+
* @param {Record<string, unknown>} [values] extra placeholders.
|
|
415
|
+
* @returns {string} the formatted text.
|
|
416
|
+
*/
|
|
417
|
+
function countText(t, key, count, values = {}) {
|
|
418
|
+
const template = t(`${key}${count === 1 ? 'One' : 'Other'}`)
|
|
419
|
+
return format(template, { ...values, count })
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {number | null | undefined} bytes raw byte count.
|
|
424
|
+
* @returns {string} a compact human-readable size, or an em dash when unknown.
|
|
364
425
|
*/
|
|
365
426
|
function formatBytes(bytes) {
|
|
366
|
-
if (typeof bytes !== 'number' || !Number.isFinite(bytes)
|
|
427
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return '—'
|
|
428
|
+
if (bytes <= 0) return '0 B'
|
|
367
429
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
368
430
|
let value = bytes
|
|
369
431
|
let unit = 0
|
|
@@ -1090,6 +1152,7 @@ window.__ModuleLoader__.load({
|
|
|
1090
1152
|
const [rebuilding, setRebuilding] = React.useState(false)
|
|
1091
1153
|
const [purgingAll, setPurgingAll] = React.useState(false)
|
|
1092
1154
|
const [announcement, setAnnouncement] = React.useState('')
|
|
1155
|
+
const [clock, setClock] = React.useState(() => Date.now())
|
|
1093
1156
|
const [query, setQuery] = React.useState('')
|
|
1094
1157
|
const [filter, setFilter] = React.useState('all')
|
|
1095
1158
|
const [page, setPage] = React.useState(1)
|
|
@@ -1149,6 +1212,19 @@ window.__ModuleLoader__.load({
|
|
|
1149
1212
|
})
|
|
1150
1213
|
}
|
|
1151
1214
|
|
|
1215
|
+
/**
|
|
1216
|
+
* @param {string} sessionId row whose owner-facing error is resolved.
|
|
1217
|
+
* @returns {void}
|
|
1218
|
+
*/
|
|
1219
|
+
function clearRowError(sessionId) {
|
|
1220
|
+
setRowErrors((previous) => {
|
|
1221
|
+
if (!(sessionId in previous)) return previous
|
|
1222
|
+
const next = { ...previous }
|
|
1223
|
+
delete next[sessionId]
|
|
1224
|
+
return next
|
|
1225
|
+
})
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1152
1228
|
/**
|
|
1153
1229
|
* Open an abort scope for one write. It is cancelled by a hard timeout
|
|
1154
1230
|
* and by the unmount cleanup, so a stuck request can never wedge the
|
|
@@ -1283,6 +1359,15 @@ window.__ModuleLoader__.load({
|
|
|
1283
1359
|
}
|
|
1284
1360
|
}, [load])
|
|
1285
1361
|
|
|
1362
|
+
// Keep relative expiry text honest on a page left open for a long time.
|
|
1363
|
+
React.useEffect(() => {
|
|
1364
|
+
if (typeof window === 'undefined' || typeof window.setInterval !== 'function') return undefined
|
|
1365
|
+
const handle = window.setInterval(() => setClock(Date.now()), 60_000)
|
|
1366
|
+
return () => {
|
|
1367
|
+
if (typeof window.clearInterval === 'function') window.clearInterval(handle)
|
|
1368
|
+
}
|
|
1369
|
+
}, [])
|
|
1370
|
+
|
|
1286
1371
|
const data = view.data
|
|
1287
1372
|
const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
|
|
1288
1373
|
entries.sort((left, right) => (right.trashedAt ?? 0) - (left.trashedAt ?? 0))
|
|
@@ -1323,7 +1408,7 @@ window.__ModuleLoader__.load({
|
|
|
1323
1408
|
const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
|
|
1324
1409
|
const recovered = data !== null && data.index !== undefined && data.index.status === 'recovered'
|
|
1325
1410
|
const unindexed = data !== null && data.index !== undefined && typeof data.index.unindexed === 'number' ? data.index.unindexed : 0
|
|
1326
|
-
const now =
|
|
1411
|
+
const now = clock
|
|
1327
1412
|
const normalizedQuery = query.trim().toLowerCase()
|
|
1328
1413
|
|
|
1329
1414
|
/**
|
|
@@ -1356,6 +1441,9 @@ window.__ModuleLoader__.load({
|
|
|
1356
1441
|
const filteredEntries = entries.filter((entry) => matchesQuery(entry) && matchesFilter(entry))
|
|
1357
1442
|
const pageCount = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
|
|
1358
1443
|
const currentPage = Math.min(Math.max(1, page), pageCount)
|
|
1444
|
+
React.useEffect(() => {
|
|
1445
|
+
if (page > pageCount) setPage(pageCount)
|
|
1446
|
+
}, [page, pageCount])
|
|
1359
1447
|
const pageStart = (currentPage - 1) * PAGE_SIZE
|
|
1360
1448
|
const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
|
|
1361
1449
|
const filtersActive = normalizedQuery !== '' || filter !== 'all'
|
|
@@ -1444,6 +1532,7 @@ window.__ModuleLoader__.load({
|
|
|
1444
1532
|
session !== null && session !== undefined && typeof session.title === 'string' && session.title !== ''
|
|
1445
1533
|
? session.title
|
|
1446
1534
|
: entry.title
|
|
1535
|
+
clearRowError(entry.sessionId)
|
|
1447
1536
|
const message = format(t(orphan ? 'restoreOrphanSuccess' : 'restoreSuccess'), { title })
|
|
1448
1537
|
bumpRevision()
|
|
1449
1538
|
ctx.contextMenus.notify(message, true)
|
|
@@ -1472,6 +1561,7 @@ window.__ModuleLoader__.load({
|
|
|
1472
1561
|
fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1473
1562
|
return
|
|
1474
1563
|
}
|
|
1564
|
+
clearRowError(entry.sessionId)
|
|
1475
1565
|
const message = format(t('purgeSuccess'), { title: entry.title })
|
|
1476
1566
|
bumpRevision()
|
|
1477
1567
|
ctx.contextMenus.notify(message, true)
|
|
@@ -1500,6 +1590,7 @@ window.__ModuleLoader__.load({
|
|
|
1500
1590
|
fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1501
1591
|
return
|
|
1502
1592
|
}
|
|
1593
|
+
clearRowError(entry.sessionId)
|
|
1503
1594
|
bumpRevision()
|
|
1504
1595
|
ctx.contextMenus.notify(t('forgetSuccess'), true)
|
|
1505
1596
|
announce(t('forgetSuccess'))
|
|
@@ -1527,6 +1618,7 @@ window.__ModuleLoader__.load({
|
|
|
1527
1618
|
fail(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1528
1619
|
return
|
|
1529
1620
|
}
|
|
1621
|
+
clearRowError(entry.sessionId)
|
|
1530
1622
|
ctx.contextMenus.notify(t('revealSuccess'), true)
|
|
1531
1623
|
announce(t('revealSuccess'))
|
|
1532
1624
|
} finally {
|
|
@@ -1563,13 +1655,15 @@ window.__ModuleLoader__.load({
|
|
|
1563
1655
|
ctx.contextMenus.notify(describeActionFailure(outcome.status, outcome.payload, t), false)
|
|
1564
1656
|
return
|
|
1565
1657
|
}
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1658
|
+
const payload = outcome.payload
|
|
1659
|
+
const purged = payload !== null && payload !== undefined && typeof payload.purged === 'number' ? payload.purged : 0
|
|
1660
|
+
const failed = payload !== null && payload !== undefined && typeof payload.failed === 'number' ? payload.failed : 0
|
|
1661
|
+
let message
|
|
1662
|
+
if (failed > 0 && purged > 0) message = format(t('cleanupPartial'), { purged, failed })
|
|
1663
|
+
else if (failed > 0) message = format(t('cleanupFailed'), { failed })
|
|
1664
|
+
else message = purged > 0 ? countText(t, 'cleanupDone', purged) : t('cleanupNone')
|
|
1571
1665
|
bumpRevision()
|
|
1572
|
-
ctx.contextMenus.notify(message,
|
|
1666
|
+
ctx.contextMenus.notify(message, failed === 0)
|
|
1573
1667
|
announce(message)
|
|
1574
1668
|
} finally {
|
|
1575
1669
|
endWrite()
|
|
@@ -1606,7 +1700,7 @@ window.__ModuleLoader__.load({
|
|
|
1606
1700
|
failed > 0
|
|
1607
1701
|
? format(t('purgeAllPartial'), { removed, failed })
|
|
1608
1702
|
: removed > 0
|
|
1609
|
-
?
|
|
1703
|
+
? countText(t, 'purgeAllDone', removed)
|
|
1610
1704
|
: t('purgeAllNone')
|
|
1611
1705
|
bumpRevision()
|
|
1612
1706
|
ctx.contextMenus.notify(message, failed === 0)
|
|
@@ -1741,7 +1835,7 @@ window.__ModuleLoader__.load({
|
|
|
1741
1835
|
if (subagentCount > 0) {
|
|
1742
1836
|
badges.push({
|
|
1743
1837
|
key: 'subagents',
|
|
1744
|
-
label:
|
|
1838
|
+
label: countText(t, 'badgeSubagents', subagentCount),
|
|
1745
1839
|
tone: 'info',
|
|
1746
1840
|
})
|
|
1747
1841
|
}
|
|
@@ -1827,9 +1921,7 @@ window.__ModuleLoader__.load({
|
|
|
1827
1921
|
const retryText =
|
|
1828
1922
|
cleanupRetryAt !== null ? format(t('cleanupRetryAt'), { time: formatDate(cleanupRetryAt, locale, true) }) : ''
|
|
1829
1923
|
const cleanupMessage =
|
|
1830
|
-
cleanupFailed > 0
|
|
1831
|
-
? format(t('cleanupFailedBanner'), { count: cleanupFailed })
|
|
1832
|
-
: format(t('cleanupErrorBanner'), { message: cleanupError })
|
|
1924
|
+
cleanupFailed > 0 ? countText(t, 'cleanupFailedBanner', cleanupFailed) : t('cleanupErrorBanner')
|
|
1833
1925
|
banners.push(
|
|
1834
1926
|
h(
|
|
1835
1927
|
'div',
|
|
@@ -1837,7 +1929,7 @@ window.__ModuleLoader__.load({
|
|
|
1837
1929
|
h(TrashGlyph, { size: 16 }),
|
|
1838
1930
|
h(
|
|
1839
1931
|
'span',
|
|
1840
|
-
{ className: 'dsh-trash__bannerText' },
|
|
1932
|
+
{ className: 'dsh-trash__bannerText', title: cleanupError ?? undefined },
|
|
1841
1933
|
cleanupMessage,
|
|
1842
1934
|
retryText === '' ? null : ` ${retryText}`,
|
|
1843
1935
|
),
|
|
@@ -1882,7 +1974,7 @@ window.__ModuleLoader__.load({
|
|
|
1882
1974
|
h(
|
|
1883
1975
|
'span',
|
|
1884
1976
|
{ className: 'dsh-trash__stats' },
|
|
1885
|
-
|
|
1977
|
+
countText(t, 'statsCount', entries.length),
|
|
1886
1978
|
h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
|
|
1887
1979
|
format(t('statsSize'), { size: formatBytes(totalBytes) }),
|
|
1888
1980
|
h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
|
|
@@ -2047,7 +2139,14 @@ window.__ModuleLoader__.load({
|
|
|
2047
2139
|
{ className: 'dsh-trash__list', 'aria-label': t('listLabel') },
|
|
2048
2140
|
visibleEntries.map((entry) => {
|
|
2049
2141
|
const rowBusy = pendingRows.has(entry.sessionId)
|
|
2050
|
-
const
|
|
2142
|
+
const rowError = rowErrors[entry.sessionId]
|
|
2143
|
+
const lastErrorCode = typeof entry.lastErrorCode === 'string' ? entry.lastErrorCode : null
|
|
2144
|
+
const lastErrorText =
|
|
2145
|
+
typeof entry.lastError === 'string' && entry.lastError !== ''
|
|
2146
|
+
? t(LAST_ERROR_KEYS[lastErrorCode] ?? 'lastErrorIo')
|
|
2147
|
+
: null
|
|
2148
|
+
const error = rowError ?? lastErrorText
|
|
2149
|
+
const errorTitle = rowError === undefined && typeof entry.lastError === 'string' ? entry.lastError : undefined
|
|
2051
2150
|
const title = entry.title !== undefined && entry.title !== '' ? entry.title : entry.sessionId
|
|
2052
2151
|
return h(
|
|
2053
2152
|
'li',
|
|
@@ -2087,7 +2186,11 @@ window.__ModuleLoader__.load({
|
|
|
2087
2186
|
h('span', null, formatBytes(entry.sizeBytes)),
|
|
2088
2187
|
),
|
|
2089
2188
|
error !== null && error !== undefined && error !== ''
|
|
2090
|
-
? h(
|
|
2189
|
+
? h(
|
|
2190
|
+
'div',
|
|
2191
|
+
{ className: 'dsh-trash__rowError', role: 'status', title: errorTitle },
|
|
2192
|
+
format(t('lastErrorLabel'), { message: error }),
|
|
2193
|
+
)
|
|
2091
2194
|
: null,
|
|
2092
2195
|
),
|
|
2093
2196
|
h(
|
|
@@ -2252,7 +2355,7 @@ window.__ModuleLoader__.load({
|
|
|
2252
2355
|
: 0
|
|
2253
2356
|
const message =
|
|
2254
2357
|
subagentCount > 0
|
|
2255
|
-
?
|
|
2358
|
+
? countText(t, 'trashSuccessTree', subagentCount, { title })
|
|
2256
2359
|
: format(t('trashSuccess'), { title })
|
|
2257
2360
|
ctx.contextMenus.notify(message, true, {
|
|
2258
2361
|
label: t('undo'),
|
package/lib/errors.js
CHANGED
|
@@ -62,16 +62,76 @@ export function statusOf(error) {
|
|
|
62
62
|
return ERROR_STATUS[code] ?? 500
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Detail keys the Host may return to the Client. Anything else - notably raw
|
|
67
|
+
* filesystem paths or nested error text - stays in the Host log, because the
|
|
68
|
+
* wire contract promises that the trash root's absolute path is never exposed.
|
|
69
|
+
*/
|
|
70
|
+
const SAFE_DETAIL_KEYS = new Set([
|
|
71
|
+
'sessionId',
|
|
72
|
+
'trashId',
|
|
73
|
+
'code',
|
|
74
|
+
'phase',
|
|
75
|
+
'copied',
|
|
76
|
+
'kind',
|
|
77
|
+
'targetKind',
|
|
78
|
+
'expected',
|
|
79
|
+
'measured',
|
|
80
|
+
'activity',
|
|
81
|
+
'descendants',
|
|
82
|
+
'sessions',
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Metrics snapshots are the only nested detail value the Host returns; keep
|
|
87
|
+
* only their numeric counters so no path can ride along.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} value candidate metrics object.
|
|
90
|
+
* @returns {object | undefined} the numeric metrics.
|
|
91
|
+
*/
|
|
92
|
+
function sanitizeMetrics(value) {
|
|
93
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
94
|
+
const safe = {}
|
|
95
|
+
for (const key of ['sizeBytes', 'fileCount', 'symlinkCount']) {
|
|
96
|
+
if (typeof value[key] === 'number') safe[key] = value[key]
|
|
97
|
+
}
|
|
98
|
+
return Object.keys(safe).length === 0 ? undefined : safe
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {unknown} details arbitrary structured context.
|
|
103
|
+
* @returns {object | undefined} the same context restricted to whitelisted keys.
|
|
104
|
+
*/
|
|
105
|
+
export function sanitizeDetails(details) {
|
|
106
|
+
if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
|
|
107
|
+
const safe = {}
|
|
108
|
+
for (const [key, value] of Object.entries(details)) {
|
|
109
|
+
if (!SAFE_DETAIL_KEYS.has(key)) continue
|
|
110
|
+
if (key === 'expected' || key === 'measured') {
|
|
111
|
+
const metrics = sanitizeMetrics(value)
|
|
112
|
+
if (metrics !== undefined) safe[key] = metrics
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
safe[key] = value.filter((item) => typeof item === 'string' || typeof item === 'number')
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) safe[key] = value
|
|
120
|
+
}
|
|
121
|
+
return Object.keys(safe).length === 0 ? undefined : safe
|
|
122
|
+
}
|
|
123
|
+
|
|
65
124
|
/**
|
|
66
125
|
* @param {unknown} error arbitrary thrown value.
|
|
67
126
|
* @returns {{ code: string, message: string, details?: object }} the wire error shape.
|
|
68
127
|
*/
|
|
69
128
|
export function toWireError(error) {
|
|
70
129
|
if (error instanceof TrashError) {
|
|
130
|
+
const details = sanitizeDetails(error.details)
|
|
71
131
|
return {
|
|
72
132
|
code: error.code,
|
|
73
133
|
message: error.message,
|
|
74
|
-
...(
|
|
134
|
+
...(details === undefined ? {} : { details }),
|
|
75
135
|
}
|
|
76
136
|
}
|
|
77
137
|
const message = error instanceof Error ? error.message : String(error)
|
package/lib/fsops.js
CHANGED
|
@@ -294,6 +294,11 @@ export function metricsMatch(entry, measured) {
|
|
|
294
294
|
if (entry === null || entry === undefined) return true
|
|
295
295
|
if (typeof entry.sizeBytes === 'number' && entry.sizeBytes !== measured.sizeBytes) return false
|
|
296
296
|
if (typeof entry.fileCount === 'number' && entry.fileCount !== measured.fileCount) return false
|
|
297
|
+
// A cross-device copy records the symlink count of the source; comparing it
|
|
298
|
+
// catches a symlink that the copy silently dropped.
|
|
299
|
+
if (typeof entry.symlinkCount === 'number' && typeof measured.symlinkCount === 'number' && entry.symlinkCount !== measured.symlinkCount) {
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
297
302
|
return true
|
|
298
303
|
}
|
|
299
304
|
|
|
@@ -356,10 +361,12 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
|
|
|
356
361
|
try {
|
|
357
362
|
await fs.rm(source, { recursive: true, force: false, maxRetries: 3, retryDelay: 100 })
|
|
358
363
|
} catch (error) {
|
|
364
|
+
// The paths stay in the Host log; the wire error only carries the error
|
|
365
|
+
// code and the fact that a verified copy already exists.
|
|
359
366
|
throw new TrashError(
|
|
360
367
|
'IO_ERROR',
|
|
361
368
|
'The session was copied to the destination, but the source could not be removed.',
|
|
362
|
-
{
|
|
369
|
+
{ code: errorCode(error) ?? null, copied: true },
|
|
363
370
|
)
|
|
364
371
|
}
|
|
365
372
|
}
|
package/lib/state.js
CHANGED
|
@@ -5,6 +5,34 @@ import { memberList } from './members.js'
|
|
|
5
5
|
/** Maximum concurrent filesystem probes while describing entries. */
|
|
6
6
|
const PROBE_CONCURRENCY = 3
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Known failure families recorded in `lastError`, in match order. The raw text
|
|
10
|
+
* stays an English diagnostic for logs and tooltips; the code is what the
|
|
11
|
+
* Client localizes.
|
|
12
|
+
*/
|
|
13
|
+
const LAST_ERROR_KINDS = [
|
|
14
|
+
[/^The trash copy of this session is missing\./, 'TRASH_COPY_MISSING'],
|
|
15
|
+
[/^The trash copy is missing these member sessions:/, 'TRASH_COPY_MISSING_MEMBERS'],
|
|
16
|
+
[/^The trash object directory is missing\./, 'TRASH_OBJECT_MISSING'],
|
|
17
|
+
[/^The session log could not be deleted:/, 'PURGE_FAILED'],
|
|
18
|
+
[/^The restored session (tree )?(is|was) not visible to session storage\./, 'RESTORE_INVISIBLE'],
|
|
19
|
+
[/^Session storage still reports the moved session tree/, 'MOVE_UNSETTLED'],
|
|
20
|
+
[/^An original session directory unexpectedly exists/, 'SOURCE_CONFLICT'],
|
|
21
|
+
[/^The trash index could not be committed/, 'INDEX_WRITE_FAILED'],
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} message raw `lastError` text.
|
|
26
|
+
* @returns {string | null} a stable failure family, or null when none was recorded.
|
|
27
|
+
*/
|
|
28
|
+
export function lastErrorCode(message) {
|
|
29
|
+
if (typeof message !== 'string' || message === '') return null
|
|
30
|
+
for (const [pattern, code] of LAST_ERROR_KINDS) {
|
|
31
|
+
if (pattern.test(message)) return code
|
|
32
|
+
}
|
|
33
|
+
return 'IO_ERROR'
|
|
34
|
+
}
|
|
35
|
+
|
|
8
36
|
/**
|
|
9
37
|
* @param {string | null | undefined} directory candidate workspace directory.
|
|
10
38
|
* @returns {Promise<boolean>} whether the directory exists right now.
|
|
@@ -31,7 +59,14 @@ async function directoryExists(directory) {
|
|
|
31
59
|
* }} deps state inputs.
|
|
32
60
|
* @returns {{ snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> }} the state service.
|
|
33
61
|
*/
|
|
34
|
-
export function createStateService({
|
|
62
|
+
export function createStateService({
|
|
63
|
+
store,
|
|
64
|
+
config,
|
|
65
|
+
trashRootDisplay,
|
|
66
|
+
nextSweepAt = () => null,
|
|
67
|
+
cleanup,
|
|
68
|
+
directoryExists: probeDirectory = directoryExists,
|
|
69
|
+
}) {
|
|
35
70
|
/**
|
|
36
71
|
* @returns {object} cleanup diagnostics, or a neutral fallback.
|
|
37
72
|
*/
|
|
@@ -58,11 +93,17 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
58
93
|
|
|
59
94
|
/**
|
|
60
95
|
* @param {object} entry committed index entry.
|
|
96
|
+
* @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
|
|
97
|
+
* started for this snapshot, so entries sharing a cwd probe it once.
|
|
61
98
|
* @returns {Promise<object>} the wire entry.
|
|
62
99
|
*/
|
|
63
|
-
async function describeEntry(entry) {
|
|
100
|
+
async function describeEntry(entry, probeCache = new Map()) {
|
|
64
101
|
const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
|
|
65
|
-
|
|
102
|
+
let workspaceNowExists = null
|
|
103
|
+
if (cwd !== null) {
|
|
104
|
+
if (!probeCache.has(cwd)) probeCache.set(cwd, probeDirectory(cwd))
|
|
105
|
+
workspaceNowExists = await probeCache.get(cwd)
|
|
106
|
+
}
|
|
66
107
|
const expiresAt =
|
|
67
108
|
typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
|
|
68
109
|
const trashed = entry.state === 'trashed'
|
|
@@ -85,6 +126,7 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
85
126
|
canForget: entry.state === 'missing',
|
|
86
127
|
requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
|
|
87
128
|
lastError: entry.lastError ?? null,
|
|
129
|
+
lastErrorCode: lastErrorCode(entry.lastError),
|
|
88
130
|
subagentCount: Math.max(0, members.length - 1),
|
|
89
131
|
members: members.map((member) => ({
|
|
90
132
|
sessionId: member.sessionId,
|
|
@@ -104,7 +146,8 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
104
146
|
* @returns {Promise<object>} the complete `/state` response body.
|
|
105
147
|
*/
|
|
106
148
|
async snapshot() {
|
|
107
|
-
const
|
|
149
|
+
const probeCache = new Map()
|
|
150
|
+
const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry, probeCache))
|
|
108
151
|
let totalBytes = 0
|
|
109
152
|
let failedEntries = 0
|
|
110
153
|
for (const entry of entries) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wangxiaocuo/dsh-session-trash",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "DeepSeek Harness plugin: move cold sessions into a recoverable trash, restore them within the retention window, or delete their logs permanently.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wangxiaocuo",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"locale/*.json",
|
|
41
41
|
"icon.svg",
|
|
42
42
|
"README.md",
|
|
43
|
-
"README.en.md"
|
|
43
|
+
"README.en.md",
|
|
44
|
+
"CHANGELOG.md"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
47
|
"test": "node test/run-unit.mjs",
|