@wangxiaocuo/dsh-session-trash 0.6.3 → 0.6.4
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 +17 -0
- package/README.en.md +8 -6
- package/README.md +8 -6
- package/client.js +19 -4
- package/index.js +2 -2
- package/lib/cleanup.js +8 -8
- package/lib/eligibility.js +5 -5
- package/lib/errors.js +7 -7
- package/lib/fsops.js +8 -8
- package/lib/http.js +22 -14
- package/lib/inventory.js +16 -16
- package/lib/members.js +6 -6
- package/lib/state.js +11 -9
- package/lib/store.js +18 -15
- package/lib/trash-service.js +60 -59
- package/package.json +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录这个插件的所有重要变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.6.4] - 2026-11-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- e2e 支持 `DSH_E2E_DSH_BIN` 启动 npm 安装的 `@deepseek-ai/dsh` web Host,不再依赖桌面版;Linux/Windows 也能跑真实 Host 端到端测试。
|
|
12
|
+
- CI 新增真实 Host e2e job(Ubuntu + Windows),失败时输出 check annotations。
|
|
13
|
+
- 新增 `pnpm typecheck`(`tsc --checkJs`,先覆盖 `lib/` 和 `index.js`)并接入 CI;DSH 服务通过 `types/dsh-context.d.ts` 的 ambient 声明提供类型。
|
|
14
|
+
- 新增 jsdom + 真实 React 渲染测试(`test/client-dom.test.js`),覆盖行菜单的方向键/Escape/Tab/外点关闭,以及确认对话框的焦点陷阱和焦点恢复。
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- ActionMenu:Escape 不再冒泡到宿主设置弹层;Tab 会关闭菜单并让焦点自然移动;分隔符使用 `role="separator"`。
|
|
19
|
+
- ConfirmDialog:打开它的行或按钮被移除后,焦点回退到列表面板,不再丢到 `body`。
|
|
20
|
+
|
|
5
21
|
## [0.6.3] - 2026-11-11
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -78,6 +94,7 @@
|
|
|
78
94
|
|
|
79
95
|
- 回收站工具栏的「永久删除全部」与二次确认;`missing` 记录只移出索引,不删除会话文件。
|
|
80
96
|
|
|
97
|
+
[0.6.4]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.3...v0.6.4
|
|
81
98
|
[0.6.3]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.2...v0.6.3
|
|
82
99
|
[0.6.2]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.1...v0.6.2
|
|
83
100
|
[0.6.1]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.0...v0.6.1
|
package/README.en.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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
|
|
|
9
|
-
Status: feature complete. CI (GitHub Actions) runs the unit
|
|
9
|
+
Status: feature complete. CI (GitHub Actions) runs the unit tests, the Host smoke test, and the real `dsh web` end-to-end suite on Ubuntu (Node 20/22) and Windows (Node 22), booting the Host from an npm-installed DSH runtime; macOS also ran the suite against the real desktop Host manually. Windows skips the cases that rely on POSIX permission bits.
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
@@ -167,7 +167,7 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
|
|
|
167
167
|
- Permanent deletion removes the session log only. Attachments, images, projection-cache rows, and search-index rows are not deleted.
|
|
168
168
|
- `/state` returns all entry summaries in one response. Search, filtering, and paging happen in the Client.
|
|
169
169
|
- There is no cross-window live push. Another window's change appears after a refresh or when the window regains focus.
|
|
170
|
-
- The Windows and Linux
|
|
170
|
+
- The desktop apps on Windows and Linux have not been verified manually; CI boots an npm-installed `dsh web` Host, so desktop-shell UI and desktop-only features (such as reveal in the file manager) are still verified on macOS only. On Windows, cases that rely on POSIX permission bits are skipped, and long paths over 260 characters / symlink copying depend on system configuration (the related tests skip themselves when unsupported).
|
|
171
171
|
- macOS path comparison is case-insensitive. On a case-sensitive APFS volume, paths that differ only by case are treated as the same location.
|
|
172
172
|
- Multiple DSH processes sharing one `DSH_HOME` serialize trash operations through `.lock`. A process paused for more than 10 minutes can lose the lock to another process.
|
|
173
173
|
|
|
@@ -175,9 +175,10 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
|
|
|
175
175
|
|
|
176
176
|
```bash
|
|
177
177
|
pnpm install
|
|
178
|
-
pnpm test # unit tests
|
|
178
|
+
pnpm test # unit tests + real-DOM component tests (jsdom)
|
|
179
|
+
pnpm typecheck # tsc --checkJs (currently lib/ and index.js)
|
|
179
180
|
pnpm test:smoke # Host smoke test (real Cordis, plain Node)
|
|
180
|
-
npm run test:e2e # real dsh web end-to-end (
|
|
181
|
+
npm run test:e2e # real dsh web end-to-end (desktop app or npm-installed DSH)
|
|
181
182
|
```
|
|
182
183
|
|
|
183
184
|
The Host smoke test wires the plugin into a real Cordis context through the `@deepseek-ai/cordis` devDependency, creates its own temporary `DSH_HOME`, and removes it afterwards; it does not need the desktop app. Set `DSH_SMOKE_KEEP=1` to keep the temporary home for debugging. `pnpm test` enumerates the test files through `test/run-unit.mjs`, so it does not depend on shell glob expansion and behaves the same on Windows and POSIX shells.
|
|
@@ -188,9 +189,10 @@ What CI verifies:
|
|
|
188
189
|
|---|---|
|
|
189
190
|
| Unit tests, Host smoke test | CI: Ubuntu Node 20/22, Windows Node 22 |
|
|
190
191
|
| Package contents (`npm pack`) | CI: Ubuntu |
|
|
191
|
-
| Real `dsh web` end to end |
|
|
192
|
+
| Real `dsh web` end to end (npm-installed DSH) | CI: Ubuntu, Windows (Node 22) |
|
|
193
|
+
| Real `dsh web` end to end (desktop app) | macOS + DSH 0.2.2-rc.1 (run manually) |
|
|
192
194
|
|
|
193
|
-
`npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow.
|
|
195
|
+
`npm run test:e2e` boots a real `dsh web` Host in a temporary `DSH_HOME` and runs the full flow. By default it discovers the desktop app in the platform's usual install locations (or uses `DSH_ELECTRON`); set `DSH_E2E_DSH_BIN` to the `lib/bin.js` of an npm-installed `@deepseek-ai/dsh` to boot the Host with plain Node instead - CI and Linux/Windows use that mode, with no desktop app required. When no Host is found it prints a skip message and exits 0. The cleanup-failure scenario that relies on POSIX permission bits is skipped on Windows. Set `DSH_E2E_KEEP=1` to keep the temporary home and logs.
|
|
194
196
|
|
|
195
197
|
Project layout:
|
|
196
198
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,日志保留在本地;保留期内可以恢复,到期后自动删除日志。面向 DSH 0.2.2-rc.1 的 Desktop 和 Web,Host 和 Client 在同一个包里,不需要构建步骤。
|
|
8
8
|
|
|
9
|
-
当前状态:功能已实现。CI(GitHub Actions)在 Ubuntu(Node 20/22)和 Windows(Node 22
|
|
9
|
+
当前状态:功能已实现。CI(GitHub Actions)在 Ubuntu(Node 20/22)和 Windows(Node 22)上跑单元测试、Host 冒烟测试和真实 `dsh web` 端到端测试(Host 由 npm 安装的 DSH runtime 启动);macOS 另外在真实桌面版 Host 上手动跑通过 e2e。Windows 上依赖 POSIX 权限位的用例会显式跳过。
|
|
10
10
|
|
|
11
11
|
## 功能
|
|
12
12
|
|
|
@@ -166,7 +166,7 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
|
|
|
166
166
|
- 永久删除只删除会话日志。附件、图片、投影缓存和搜索索引不会被删除。
|
|
167
167
|
- `/state` 一次返回全部条目摘要,页面在客户端搜索、筛选和分页。
|
|
168
168
|
- 没有跨窗口实时推送。另一个窗口的变更要等刷新或窗口重新获得焦点后出现。
|
|
169
|
-
- Windows
|
|
169
|
+
- Windows/Linux 的桌面版尚未手动验证;CI 用 npm 安装的 `dsh web` 覆盖真实 Host 行为,但桌面壳 UI 与桌面专属能力(例如「在文件管理器中显示」)仍只在 macOS 上验证。Windows 上依赖 POSIX 权限位的用例会跳过;超过 260 字符的长路径和符号链接复制受系统设置影响,对应用例在不支持时自动跳过。
|
|
170
170
|
- macOS 的路径比较按大小写不敏感处理。在大小写敏感的 APFS 卷上,仅大小写不同的两个路径会被视为同一位置。
|
|
171
171
|
- 多个 DSH 进程共享同一个 `DSH_HOME` 时,回收站操作通过 `.lock` 串行;进程暂停超过 10 分钟后,锁可能被另一个进程接管。
|
|
172
172
|
|
|
@@ -174,9 +174,10 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
|
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
176
|
pnpm install
|
|
177
|
-
pnpm test # 单元测试
|
|
177
|
+
pnpm test # 单元测试 + 真实 DOM 组件测试(jsdom)
|
|
178
|
+
pnpm typecheck # tsc --checkJs(当前覆盖 lib/ 和 index.js)
|
|
178
179
|
pnpm test:smoke # Host 冒烟测试(真实 Cordis,普通 Node 即可)
|
|
179
|
-
npm run test:e2e # 真实 dsh web
|
|
180
|
+
npm run test:e2e # 真实 dsh web 端到端(桌面版或 npm 安装的 DSH)
|
|
180
181
|
```
|
|
181
182
|
|
|
182
183
|
Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实插件上下文,自己创建并清理临时 `DSH_HOME`,不需要桌面版;设置 `DSH_SMOKE_KEEP=1` 可以保留临时目录用于排查。`pnpm test` 通过 `test/run-unit.mjs` 枚举测试文件,不依赖 shell 展开 glob,Windows 和 POSIX shell 行为一致。
|
|
@@ -187,9 +188,10 @@ Host 冒烟测试通过 `@deepseek-ai/cordis` 这个 devDependency 装配真实
|
|
|
187
188
|
|---|---|
|
|
188
189
|
| 单元测试、Host 冒烟测试 | CI:Ubuntu Node 20/22、Windows Node 22 |
|
|
189
190
|
| 打包内容(`npm pack`) | CI:Ubuntu |
|
|
190
|
-
| 真实 `dsh web`
|
|
191
|
+
| 真实 `dsh web` 端到端(npm 安装的 DSH) | CI:Ubuntu、Windows(Node 22) |
|
|
192
|
+
| 真实 `dsh web` 端到端(桌面版) | macOS + DSH 0.2.2-rc.1(手动执行) |
|
|
191
193
|
|
|
192
|
-
`npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME`
|
|
194
|
+
`npm run test:e2e` 会启动真实 `dsh web` Host,在临时 `DSH_HOME` 中跑完整流程。默认按平台在常见安装位置查找桌面版(也可以用 `DSH_ELECTRON` 指定);设置 `DSH_E2E_DSH_BIN` 指向 npm 安装的 `@deepseek-ai/dsh` 的 `lib/bin.js` 时,改用普通 Node 启动 Host——CI 和 Linux/Windows 都走这条路径,不依赖桌面版。找不到 Host 时打印跳过信息并以 0 退出。依赖 POSIX 权限位的「清理失败」场景在 Windows 上会跳过。设置 `DSH_E2E_KEEP=1` 可以保留临时目录和日志。
|
|
193
195
|
|
|
194
196
|
项目结构:
|
|
195
197
|
|
package/client.js
CHANGED
|
@@ -889,8 +889,14 @@ window.__ModuleLoader__.load({
|
|
|
889
889
|
if (wrapRef.current !== null && !wrapRef.current.contains(event.target)) setOpen(false)
|
|
890
890
|
}
|
|
891
891
|
const onKeyDown = (event) => {
|
|
892
|
+
if (event.key === 'Tab') {
|
|
893
|
+
// Let focus leave the menu naturally; just stop leaving it open.
|
|
894
|
+
setOpen(false)
|
|
895
|
+
return
|
|
896
|
+
}
|
|
892
897
|
if (event.key !== 'Escape') return
|
|
893
898
|
event.preventDefault()
|
|
899
|
+
event.stopPropagation()
|
|
894
900
|
setOpen(false)
|
|
895
901
|
if (buttonRef.current !== null) buttonRef.current.focus()
|
|
896
902
|
}
|
|
@@ -992,9 +998,11 @@ window.__ModuleLoader__.load({
|
|
|
992
998
|
},
|
|
993
999
|
items.map((item, index) =>
|
|
994
1000
|
h(
|
|
995
|
-
|
|
1001
|
+
React.Fragment,
|
|
996
1002
|
{ key: item.key },
|
|
997
|
-
item.separatorBefore === true && index > 0
|
|
1003
|
+
item.separatorBefore === true && index > 0
|
|
1004
|
+
? h('div', { className: 'dsh-trash__menuSeparator', role: 'separator' })
|
|
1005
|
+
: null,
|
|
998
1006
|
h(
|
|
999
1007
|
'button',
|
|
1000
1008
|
{
|
|
@@ -1034,7 +1042,7 @@ window.__ModuleLoader__.load({
|
|
|
1034
1042
|
* }} props dialog content and handlers.
|
|
1035
1043
|
* @returns {import('react').ReactElement} the dialog overlay.
|
|
1036
1044
|
*/
|
|
1037
|
-
function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm }) {
|
|
1045
|
+
function ConfirmDialog({ kind, entry, count, t, onCancel, onConfirm, fallbackRef }) {
|
|
1038
1046
|
const dialogRef = React.useRef(null)
|
|
1039
1047
|
const cancelRef = React.useRef(null)
|
|
1040
1048
|
const titleId = `dsh-trash-dialog-title-${kind}`
|
|
@@ -1068,7 +1076,12 @@ window.__ModuleLoader__.load({
|
|
|
1068
1076
|
document.addEventListener('keydown', onKeyDown, true)
|
|
1069
1077
|
return () => {
|
|
1070
1078
|
document.removeEventListener('keydown', onKeyDown, true)
|
|
1071
|
-
if (previous !== null && typeof previous.focus === 'function'
|
|
1079
|
+
if (previous !== null && typeof previous.focus === 'function' && previous.isConnected !== false) {
|
|
1080
|
+
previous.focus()
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
const fallback = fallbackRef === null || fallbackRef === undefined ? null : fallbackRef.current
|
|
1084
|
+
if (fallback !== null && typeof fallback.focus === 'function') fallback.focus()
|
|
1072
1085
|
}
|
|
1073
1086
|
}, [onCancel])
|
|
1074
1087
|
|
|
@@ -2269,6 +2282,7 @@ window.__ModuleLoader__.load({
|
|
|
2269
2282
|
className: 'dsh-trash__panel',
|
|
2270
2283
|
key: 'panel',
|
|
2271
2284
|
ref: panelRef,
|
|
2285
|
+
tabIndex: -1,
|
|
2272
2286
|
'aria-busy': readsBusy ? 'true' : 'false',
|
|
2273
2287
|
},
|
|
2274
2288
|
panelBody,
|
|
@@ -2289,6 +2303,7 @@ window.__ModuleLoader__.load({
|
|
|
2289
2303
|
count: dialog.count,
|
|
2290
2304
|
t,
|
|
2291
2305
|
onCancel: closeDialog,
|
|
2306
|
+
fallbackRef: panelRef,
|
|
2292
2307
|
onConfirm: () => {
|
|
2293
2308
|
const current = dialog
|
|
2294
2309
|
setDialog(null)
|
package/index.js
CHANGED
|
@@ -34,7 +34,7 @@ function createLogger(ctx) {
|
|
|
34
34
|
const prefix = 'dsh-session-trash'
|
|
35
35
|
let sink = null
|
|
36
36
|
try {
|
|
37
|
-
const factory = ctx.get('logger')
|
|
37
|
+
const factory = /** @type {any} */ (ctx.get('logger'))
|
|
38
38
|
if (typeof factory === 'function') {
|
|
39
39
|
const instance = factory(prefix)
|
|
40
40
|
if (instance !== null && instance !== undefined && typeof instance.info === 'function') sink = instance
|
|
@@ -65,7 +65,7 @@ function createLogger(ctx) {
|
|
|
65
65
|
* recovery, and the retention sweep.
|
|
66
66
|
*
|
|
67
67
|
* @param {import('@deepseek-ai/cordis').Context} ctx plugin context.
|
|
68
|
-
* @param {
|
|
68
|
+
* @param {any} config validated plugin configuration.
|
|
69
69
|
* @returns {void}
|
|
70
70
|
*/
|
|
71
71
|
export function apply(ctx, config) {
|
package/lib/cleanup.js
CHANGED
|
@@ -14,7 +14,7 @@ export const CLEANUP_RETRY_MAX_MS = 60 * 60 * 1000
|
|
|
14
14
|
const STOPPED_RESULT = Object.freeze({ purged: 0, failed: 0, settled: 0 })
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* @param {
|
|
17
|
+
* @param {any} error arbitrary failure.
|
|
18
18
|
* @returns {string} a concise message.
|
|
19
19
|
*/
|
|
20
20
|
function messageOf(error) {
|
|
@@ -22,7 +22,7 @@ function messageOf(error) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* @param {
|
|
25
|
+
* @param {any} error arbitrary failure.
|
|
26
26
|
* @returns {string} a bounded diagnostic message for `/state`.
|
|
27
27
|
*/
|
|
28
28
|
function errorText(error) {
|
|
@@ -60,18 +60,18 @@ function resolveTimer(ctx) {
|
|
|
60
60
|
*
|
|
61
61
|
* @param {{
|
|
62
62
|
* ctx: import('@deepseek-ai/cordis').Context,
|
|
63
|
-
* trashService: { sweep: (options: { allowPurge: boolean, now: number, reason: string }) => Promise<
|
|
63
|
+
* trashService: { sweep: (options: { allowPurge: boolean, now: number, reason: string }) => Promise<any> },
|
|
64
64
|
* config: { sweepIntervalHours: number, enableAutoCleanup: boolean },
|
|
65
65
|
* logger: { info: Function, warn: Function, error: Function, debug: Function },
|
|
66
66
|
* }} deps host services and the configured cadence.
|
|
67
67
|
* @returns {{
|
|
68
68
|
* start: () => void,
|
|
69
69
|
* stop: () => void,
|
|
70
|
-
* runNow: (options?: { manual?: boolean, reason?: string }) => Promise<
|
|
70
|
+
* runNow: (options?: { manual?: boolean, reason?: string }) => Promise<any>,
|
|
71
71
|
* maybeSweepOnPageOpen: () => void,
|
|
72
72
|
* nextSweepAt: () => number | null,
|
|
73
73
|
* lastSweepAt: () => number,
|
|
74
|
-
* diagnostics: () =>
|
|
74
|
+
* diagnostics: () => any,
|
|
75
75
|
* }} the cleanup service.
|
|
76
76
|
*/
|
|
77
77
|
export function createCleanupService({ ctx, trashService, config, logger }) {
|
|
@@ -91,7 +91,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
|
|
|
91
91
|
let disposeRetry = null
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* @param {
|
|
94
|
+
* @param {any} error sweep failure.
|
|
95
95
|
* @returns {void}
|
|
96
96
|
*/
|
|
97
97
|
function report(error) {
|
|
@@ -148,7 +148,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
|
|
|
148
148
|
* because an automatic sweep may have been started with `allowPurge: false`.
|
|
149
149
|
*
|
|
150
150
|
* @param {{ manual?: boolean, reason?: string }} [options] manual trigger and diagnostic label.
|
|
151
|
-
* @returns {Promise<
|
|
151
|
+
* @returns {Promise<any>} the sweep summary.
|
|
152
152
|
*/
|
|
153
153
|
async function runNow(options = {}) {
|
|
154
154
|
if (stopped) return { ...STOPPED_RESULT }
|
|
@@ -324,7 +324,7 @@ export function createCleanupService({ ctx, trashService, config, logger }) {
|
|
|
324
324
|
/**
|
|
325
325
|
* Read-only cleanup diagnostics for `/state` and the settings page.
|
|
326
326
|
*
|
|
327
|
-
* @returns {
|
|
327
|
+
* @returns {any} a JSON-safe diagnostic snapshot.
|
|
328
328
|
*/
|
|
329
329
|
diagnostics() {
|
|
330
330
|
return {
|
package/lib/eligibility.js
CHANGED
|
@@ -7,14 +7,14 @@ export const PREFLIGHT_TTL_MS = 2000
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @typedef {{ sessionId: string, header: object }} DescendantRef
|
|
10
|
-
* @typedef {{ sessionId: string, header:
|
|
10
|
+
* @typedef {{ sessionId: string, header: any, location: { path: string }, formatStatus?: string }} PlannedMember
|
|
11
11
|
* @typedef {{ eligible: true, members: PlannedMember[] } | { eligible: false, code: string, message: string, details?: object }} PreflightResult
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* @param {string} code refusal code.
|
|
16
16
|
* @param {string} message owner-facing explanation.
|
|
17
|
-
* @param {
|
|
17
|
+
* @param {any} [details] optional structured context.
|
|
18
18
|
* @returns {{ eligible: false, code: string, message: string, details?: object }} a refusal.
|
|
19
19
|
*/
|
|
20
20
|
function refuse(code, message, details) {
|
|
@@ -24,7 +24,7 @@ function refuse(code, message, details) {
|
|
|
24
24
|
/**
|
|
25
25
|
* Collect every stored session whose lineage descends from `sessionId`.
|
|
26
26
|
*
|
|
27
|
-
* @param {
|
|
27
|
+
* @param {any} persistence persistence service.
|
|
28
28
|
* @param {string} sessionId root session.
|
|
29
29
|
* @returns {Promise<DescendantRef[]>} descendant identity and stored header.
|
|
30
30
|
*/
|
|
@@ -59,7 +59,7 @@ async function descendantsFromListing(persistence, sessionId) {
|
|
|
59
59
|
* A grandchild whose direct parent is no longer stored still proves that the
|
|
60
60
|
* root session owns a lineage the trash must not split.
|
|
61
61
|
*
|
|
62
|
-
* @param {readonly
|
|
62
|
+
* @param {readonly any[]} nodes descendant nodes from the trace.
|
|
63
63
|
* @returns {DescendantRef[]} every descendant session identity and header.
|
|
64
64
|
*/
|
|
65
65
|
function collectTraceDescendants(nodes) {
|
|
@@ -123,7 +123,7 @@ export function createEligibility({ ctx, store, logger }) {
|
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
125
|
* @param {string} sessionId descendant session to validate.
|
|
126
|
-
* @returns {Promise<
|
|
126
|
+
* @returns {Promise<any>} either `{ member }` with the planned member or `{ refusal }`.
|
|
127
127
|
*/
|
|
128
128
|
async function planDescendant(sessionId) {
|
|
129
129
|
let snapshot
|
package/lib/errors.js
CHANGED
|
@@ -43,7 +43,7 @@ export class TrashError extends Error {
|
|
|
43
43
|
/**
|
|
44
44
|
* @param {string} code stable error code.
|
|
45
45
|
* @param {string} message owner-facing explanation.
|
|
46
|
-
* @param {
|
|
46
|
+
* @param {any} [details] optional structured context.
|
|
47
47
|
*/
|
|
48
48
|
constructor(code, message, details) {
|
|
49
49
|
super(message)
|
|
@@ -54,7 +54,7 @@ export class TrashError extends Error {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* @param {
|
|
57
|
+
* @param {any} error arbitrary thrown value.
|
|
58
58
|
* @returns {number} HTTP status for this error.
|
|
59
59
|
*/
|
|
60
60
|
export function statusOf(error) {
|
|
@@ -86,8 +86,8 @@ const SAFE_DETAIL_KEYS = new Set([
|
|
|
86
86
|
* Metrics snapshots are the only nested detail value the Host returns; keep
|
|
87
87
|
* only their numeric counters so no path can ride along.
|
|
88
88
|
*
|
|
89
|
-
* @param {
|
|
90
|
-
* @returns {
|
|
89
|
+
* @param {any} value candidate metrics object.
|
|
90
|
+
* @returns {any | undefined} the numeric metrics.
|
|
91
91
|
*/
|
|
92
92
|
function sanitizeMetrics(value) {
|
|
93
93
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
@@ -99,8 +99,8 @@ function sanitizeMetrics(value) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* @param {
|
|
103
|
-
* @returns {
|
|
102
|
+
* @param {any} details arbitrary structured context.
|
|
103
|
+
* @returns {any | undefined} the same context restricted to whitelisted keys.
|
|
104
104
|
*/
|
|
105
105
|
export function sanitizeDetails(details) {
|
|
106
106
|
if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
|
|
@@ -122,7 +122,7 @@ export function sanitizeDetails(details) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
* @param {
|
|
125
|
+
* @param {any} error arbitrary thrown value.
|
|
126
126
|
* @returns {{ code: string, message: string, details?: object }} the wire error shape.
|
|
127
127
|
*/
|
|
128
128
|
export function toWireError(error) {
|
package/lib/fsops.js
CHANGED
|
@@ -36,7 +36,7 @@ const COPY_OPTIONS = {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* @param {
|
|
39
|
+
* @param {any} error thrown filesystem failure.
|
|
40
40
|
* @returns {string | undefined} the Node error code.
|
|
41
41
|
*/
|
|
42
42
|
export function errorCode(error) {
|
|
@@ -194,7 +194,7 @@ export function isInside(root, target) {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
/**
|
|
197
|
-
* @param {
|
|
197
|
+
* @param {any} trashId candidate object identity.
|
|
198
198
|
* @returns {boolean} whether the identity is one safe path segment.
|
|
199
199
|
*/
|
|
200
200
|
export function isSafeTrashId(trashId) {
|
|
@@ -214,7 +214,7 @@ export function isSafeTrashId(trashId) {
|
|
|
214
214
|
* object. Session ids are `session-<uuid>` or a bare uuid today, so the
|
|
215
215
|
* accepted alphabet is deliberately conservative.
|
|
216
216
|
*
|
|
217
|
-
* @param {
|
|
217
|
+
* @param {any} sessionId candidate member identity.
|
|
218
218
|
* @returns {boolean} whether the identity is one safe path segment.
|
|
219
219
|
*/
|
|
220
220
|
export function isSafeMemberId(sessionId) {
|
|
@@ -286,8 +286,8 @@ export async function measureDirectory(directory) {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
/**
|
|
289
|
-
* @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
|
|
290
|
-
* @param {{ sizeBytes: number, fileCount: number }} measured current metrics.
|
|
289
|
+
* @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
|
|
290
|
+
* @param {{ sizeBytes: number, fileCount: number, symlinkCount?: number }} measured current metrics.
|
|
291
291
|
* @returns {boolean} whether the recorded metrics are absent or match exactly.
|
|
292
292
|
*/
|
|
293
293
|
export function metricsMatch(entry, measured) {
|
|
@@ -303,7 +303,7 @@ export function metricsMatch(entry, measured) {
|
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
/**
|
|
306
|
-
* @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
|
|
306
|
+
* @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
|
|
307
307
|
* @returns {boolean} whether the entry carries a complete move snapshot.
|
|
308
308
|
*/
|
|
309
309
|
export function hasMetrics(entry) {
|
|
@@ -339,7 +339,7 @@ export function createTrashId() {
|
|
|
339
339
|
* @param {string} destination final directory path.
|
|
340
340
|
* @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
|
|
341
341
|
* @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
|
|
342
|
-
* @param {(source: string, destination: string, options:
|
|
342
|
+
* @param {(source: string, destination: string, options: any) => Promise<void>} copy copy implementation for tests.
|
|
343
343
|
* @returns {Promise<void>} resolution after the destination is published.
|
|
344
344
|
*/
|
|
345
345
|
async function copyAcrossDevices(source, destination, expected, rename, copy) {
|
|
@@ -382,7 +382,7 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
|
|
|
382
382
|
* @param {{
|
|
383
383
|
* logger?: { warn: Function },
|
|
384
384
|
* rename?: (from: string, to: string) => Promise<void>,
|
|
385
|
-
* copy?: (source: string, destination: string, options:
|
|
385
|
+
* copy?: (source: string, destination: string, options: any) => Promise<void>,
|
|
386
386
|
* }} [options] diagnostics sink and test seams.
|
|
387
387
|
* @returns {Promise<void>} resolution after the directory lives at `destination`.
|
|
388
388
|
*/
|
package/lib/http.js
CHANGED
|
@@ -45,7 +45,7 @@ function headerBag(req) {
|
|
|
45
45
|
/**
|
|
46
46
|
* @param {import('node:http').ServerResponse} res response to complete.
|
|
47
47
|
* @param {number} status HTTP status.
|
|
48
|
-
* @param {
|
|
48
|
+
* @param {any} body JSON-serializable body.
|
|
49
49
|
* @returns {void}
|
|
50
50
|
*/
|
|
51
51
|
function sendJson(res, status, body) {
|
|
@@ -62,7 +62,7 @@ function sendJson(res, status, body) {
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* @param {import('node:http').ServerResponse} res response to complete.
|
|
65
|
-
* @param {
|
|
65
|
+
* @param {any} error thrown failure.
|
|
66
66
|
* @returns {void}
|
|
67
67
|
*/
|
|
68
68
|
function sendError(res, error) {
|
|
@@ -151,20 +151,24 @@ function requireSessionId(body) {
|
|
|
151
151
|
* @param {{
|
|
152
152
|
* ctx: import('@deepseek-ai/cordis').Context,
|
|
153
153
|
* eligibility: { evaluate: Function, invalidate: Function },
|
|
154
|
-
* stateService: { snapshot: () => Promise<
|
|
154
|
+
* stateService: { snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> },
|
|
155
155
|
* trashService: {
|
|
156
|
-
* trash: (sessionId: string) => Promise<
|
|
157
|
-
* restore: (sessionId: string, mode?: string) => Promise<
|
|
158
|
-
* purge: (sessionId: string) => Promise<
|
|
159
|
-
* purgeAll: () => Promise<
|
|
160
|
-
* forget: (sessionId: string) => Promise<
|
|
161
|
-
* reveal: (sessionId: string, signal?: AbortSignal) => Promise<
|
|
162
|
-
* rebuild: () => Promise<
|
|
156
|
+
* trash: (sessionId: string) => Promise<any>,
|
|
157
|
+
* restore: (sessionId: string, mode?: string) => Promise<any>,
|
|
158
|
+
* purge: (sessionId: string) => Promise<any>,
|
|
159
|
+
* purgeAll: () => Promise<any>,
|
|
160
|
+
* forget: (sessionId: string) => Promise<any>,
|
|
161
|
+
* reveal: (sessionId: string, signal?: AbortSignal) => Promise<any>,
|
|
162
|
+
* rebuild: () => Promise<any>,
|
|
163
163
|
* },
|
|
164
|
-
* cleanupService?: {
|
|
164
|
+
* cleanupService?: {
|
|
165
|
+
runNow: (options?: any) => Promise<any>,
|
|
166
|
+
maybeSweepOnPageOpen: () => void,
|
|
167
|
+
diagnostics?: () => any,
|
|
168
|
+
},
|
|
165
169
|
* ready: Promise<unknown>,
|
|
166
|
-
* config:
|
|
167
|
-
* logger:
|
|
170
|
+
* config: any,
|
|
171
|
+
* logger: any,
|
|
168
172
|
* }} deps host services.
|
|
169
173
|
* @returns {void}
|
|
170
174
|
*/
|
|
@@ -292,7 +296,11 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
|
|
|
292
296
|
route('restore', 'POST', async (req, res) => {
|
|
293
297
|
const body = await readJsonRequest(req)
|
|
294
298
|
const sessionId = requireSessionId(body)
|
|
295
|
-
const
|
|
299
|
+
const rawMode = body.mode
|
|
300
|
+
if (rawMode !== undefined && typeof rawMode !== 'string') {
|
|
301
|
+
throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
|
|
302
|
+
}
|
|
303
|
+
const mode = typeof rawMode === 'string' ? rawMode : undefined
|
|
296
304
|
if (mode !== undefined && mode !== 'normal' && mode !== 'orphan') {
|
|
297
305
|
throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
|
|
298
306
|
}
|
package/lib/inventory.js
CHANGED
|
@@ -17,7 +17,7 @@ const META_VERSION = 2
|
|
|
17
17
|
const MAX_MEMBERS = 64
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* @param {
|
|
20
|
+
* @param {any} value candidate timestamp.
|
|
21
21
|
* @returns {boolean} whether the value is a finite number.
|
|
22
22
|
*/
|
|
23
23
|
function isFiniteNumber(value) {
|
|
@@ -25,7 +25,7 @@ function isFiniteNumber(value) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* @param {
|
|
28
|
+
* @param {any} value candidate metric.
|
|
29
29
|
* @returns {boolean} whether the value is a usable number or absent.
|
|
30
30
|
*/
|
|
31
31
|
function isMetric(value) {
|
|
@@ -36,7 +36,7 @@ function isMetric(value) {
|
|
|
36
36
|
* Validate one v2 member's absolute paths. The parent's sidecar is validated
|
|
37
37
|
* field by field so a hand-edited file is rejected instead of guessed at.
|
|
38
38
|
*
|
|
39
|
-
* @param {
|
|
39
|
+
* @param {any} member candidate member.
|
|
40
40
|
* @returns {boolean} whether the member names safe, coherent paths.
|
|
41
41
|
*/
|
|
42
42
|
function isValidMemberPaths(member) {
|
|
@@ -72,7 +72,7 @@ function isValidMemberPaths(member) {
|
|
|
72
72
|
/**
|
|
73
73
|
* Validate the v2 member list of one sidecar.
|
|
74
74
|
*
|
|
75
|
-
* @param {
|
|
75
|
+
* @param {any} meta candidate sidecar.
|
|
76
76
|
* @returns {boolean} whether the list is coherent with its parent fields.
|
|
77
77
|
*/
|
|
78
78
|
function isValidMetaMembers(meta) {
|
|
@@ -100,7 +100,7 @@ function isValidMetaMembers(meta) {
|
|
|
100
100
|
* entry. A malformed sidecar is reported as an unindexed object instead of
|
|
101
101
|
* being guessed at.
|
|
102
102
|
*
|
|
103
|
-
* @param {
|
|
103
|
+
* @param {any} meta parsed `meta.json`.
|
|
104
104
|
* @param {string} trashId object directory identity.
|
|
105
105
|
* @returns {boolean} whether the sidecar can rebuild one entry.
|
|
106
106
|
*/
|
|
@@ -132,9 +132,9 @@ export function isValidMeta(meta, trashId) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* @param {
|
|
135
|
+
* @param {any} member raw member fields.
|
|
136
136
|
* @param {string} fallbackTitle title used when the member carries none.
|
|
137
|
-
* @returns {
|
|
137
|
+
* @returns {any} one normalized v2 member.
|
|
138
138
|
*/
|
|
139
139
|
function toMember(member, fallbackTitle) {
|
|
140
140
|
return {
|
|
@@ -151,8 +151,8 @@ function toMember(member, fallbackTitle) {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/**
|
|
154
|
-
* @param {
|
|
155
|
-
* @returns {
|
|
154
|
+
* @param {any} meta valid sidecar.
|
|
155
|
+
* @returns {any} a clean index entry with defaulted optional fields.
|
|
156
156
|
*/
|
|
157
157
|
export function toEntry(meta) {
|
|
158
158
|
const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
|
|
@@ -201,7 +201,7 @@ export function toEntry(meta) {
|
|
|
201
201
|
* Which of one entry's member directories currently exist inside its object.
|
|
202
202
|
*
|
|
203
203
|
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
204
|
-
* @param {
|
|
204
|
+
* @param {any} entry index entry, v1 or v2 shaped.
|
|
205
205
|
* @returns {Promise<{ total: number, present: number, missing: string[] }>} presence summary.
|
|
206
206
|
*/
|
|
207
207
|
export async function memberPresence(store, entry) {
|
|
@@ -229,8 +229,8 @@ export async function memberPresence(store, entry) {
|
|
|
229
229
|
* Whether two member lists name the same directories in the same order, so a
|
|
230
230
|
* presence probe taken for one can be reused for the other.
|
|
231
231
|
*
|
|
232
|
-
* @param {readonly
|
|
233
|
-
* @param {readonly
|
|
232
|
+
* @param {readonly any[]} left first member list.
|
|
233
|
+
* @param {readonly any[]} right second member list.
|
|
234
234
|
* @returns {boolean} whether both lists describe the same members.
|
|
235
235
|
*/
|
|
236
236
|
function sameMembers(left, right) {
|
|
@@ -244,10 +244,10 @@ function sameMembers(left, right) {
|
|
|
244
244
|
/**
|
|
245
245
|
* Reconcile a rebuilt entry with the object directory observed on disk.
|
|
246
246
|
*
|
|
247
|
-
* @param {
|
|
247
|
+
* @param {any} entry index-shaped entry.
|
|
248
248
|
* @param {{ total: number, present: number, missing: string[] }} presence member presence.
|
|
249
249
|
* @param {number} now current timestamp.
|
|
250
|
-
* @returns {
|
|
250
|
+
* @returns {any} the entry whose state matches the filesystem.
|
|
251
251
|
*/
|
|
252
252
|
export function deriveEntry(entry, presence, now) {
|
|
253
253
|
const complete = presence.total > 0 && presence.present === presence.total
|
|
@@ -282,7 +282,7 @@ export function deriveEntry(entry, presence, now) {
|
|
|
282
282
|
*
|
|
283
283
|
* @param {import('./store.js').TrashStore} store trash store owning `objects/`.
|
|
284
284
|
* @param {{ warn: Function }} logger diagnostics sink.
|
|
285
|
-
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta:
|
|
285
|
+
* @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: any | undefined, metaValid: boolean, entry?: any, presence?: object }[], unindexed: number }>} the inventory.
|
|
286
286
|
*/
|
|
287
287
|
export async function scanObjects(store, logger) {
|
|
288
288
|
let entries
|
|
@@ -342,7 +342,7 @@ export async function scanObjects(store, logger) {
|
|
|
342
342
|
* directory.
|
|
343
343
|
*
|
|
344
344
|
* @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
|
|
345
|
-
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string,
|
|
345
|
+
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, any> }>} reconcile summary plus the presence probe per object.
|
|
346
346
|
*/
|
|
347
347
|
export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
348
348
|
if (store.isProtected()) {
|