@wangxiaocuo/dsh-session-trash 0.6.5 → 0.6.6
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 +13 -0
- package/README.en.md +5 -2
- package/README.md +5 -3
- package/client.js +368 -12
- package/lib/members.js +70 -28
- package/lib/state.js +53 -11
- package/lib/trash-service.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.6.6] - 2026-11-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 回收站页支持按移入时间、标题、大小、到期时间排序,可勾选多个条目批量恢复(只含工作区仍存在的条目),并支持全选当前页。
|
|
12
|
+
- 含子会话的条目可以展开查看成员列表(父/子会话 ID 与大小)。
|
|
13
|
+
- 同源浏览器标签页通过 `BroadcastChannel` 实时同步回收站变更,不再需要切换焦点或手动刷新。
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- 类型收紧:`members.js` 的 `Member`/`TrashEntry` 与 `state.js` 的状态投影形状改为具体类型。
|
|
18
|
+
|
|
7
19
|
## [0.6.5] - 2026-11-11
|
|
8
20
|
|
|
9
21
|
### Added
|
|
@@ -112,6 +124,7 @@
|
|
|
112
124
|
|
|
113
125
|
- 回收站工具栏的「永久删除全部」与二次确认;`missing` 记录只移出索引,不删除会话文件。
|
|
114
126
|
|
|
127
|
+
[0.6.6]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.5...v0.6.6
|
|
115
128
|
[0.6.5]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.4...v0.6.5
|
|
116
129
|
[0.6.4]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.3...v0.6.4
|
|
117
130
|
[0.6.3]: https://github.com/wangxiaocuo/dsh-session-trash/compare/v0.6.2...v0.6.3
|
package/README.en.md
CHANGED
|
@@ -15,7 +15,9 @@ Status: feature complete. CI (GitHub Actions) runs the unit tests, the Host smok
|
|
|
15
15
|
- Right-click a session in the sidebar and choose "Move to trash". It is available for cold sessions with no running turn, job, subagent, or scheduled task; hidden subagent children move with their parent and are restored together.
|
|
16
16
|
- For sessions that are still open, still running work, or own visible child sessions, the menu keeps a labelled explanatory "Move to trash" item that states why the move is unavailable instead of hiding the entry entirely.
|
|
17
17
|
- After the move, the shared DSH Toast offers one action: Undo. Undo tries a normal restore first and falls back to an orphan restore when the original workspace is gone.
|
|
18
|
-
- The Trash page in Settings shows title, Session ID, original workspace, trashed date, remaining days, size, and status. The list supports search, filters, and paging at 50 rows per page.
|
|
18
|
+
- The Trash page in Settings shows title, Session ID, original workspace, trashed date, remaining days, size, and status. The list supports search, filters, sorting by trashed date/title/size/expiry, and paging at 50 rows per page.
|
|
19
|
+
- Entries with hidden sub-sessions show a count badge and can expand to list every member (parent/child session IDs and sizes); restore and permanent deletion still operate on the whole tree.
|
|
20
|
+
- Select multiple entries to restore them as a batch (only rows whose workspace still exists), including a select-all-page checkbox.
|
|
19
21
|
- Row actions include restore, restore as orphan, permanent deletion, removal from the index, copying the Session ID, and revealing the original workspace in Finder or Explorer.
|
|
20
22
|
- The toolbar's "Delete all permanently" empties the whole trash after one confirmation: it deletes every trashed session log and drops file-missing records from the index. Attachments, images, and search-index rows are not deleted.
|
|
21
23
|
- Cleanup keeps entries for 30 days by default and runs every 6 hours. It also runs shortly after activation and when the Trash page opens. Failed cleanup runs are retried with exponential backoff, and the page shows the failure.
|
|
@@ -171,7 +173,8 @@ The index schema is v2; a v1 index is upgraded automatically on startup and pres
|
|
|
171
173
|
- Permanent deletion removes the session log only. Attachments, images, projection-cache rows, and search-index rows are not deleted.
|
|
172
174
|
- Unindexed objects (object directories whose `meta.json` is damaged or unreadable) are never deleted automatically; the page can only clean empty leftovers that hold no session log, and anything that may contain logs is left for manual inspection.
|
|
173
175
|
- `/state` returns all entry summaries in one response. Search, filtering, and paging happen in the Client.
|
|
174
|
-
-
|
|
176
|
+
- Same-origin tabs in the same browser sync index changes through `BroadcastChannel`; different browsers or machines still need a refresh.
|
|
177
|
+
- Batch restore only covers rows whose workspace still exists; entries with a missing workspace need an individual orphan restore.
|
|
175
178
|
- 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).
|
|
176
179
|
- macOS path comparison is case-insensitive. On a case-sensitive APFS volume, paths that differ only by case are treated as the same location.
|
|
177
180
|
- Multiple DSH processes sharing one `DSH_HOME` serialize trash operations through `.lock`. The Host retries briefly before reporting `LOCKED`, and the Client retries once automatically; a process paused for more than 10 minutes can lose the lock to another process.
|
package/README.md
CHANGED
|
@@ -15,8 +15,9 @@ DeepSeek Harness 的会话回收站插件。冷会话可以从侧边栏移出,
|
|
|
15
15
|
- 侧边栏右键会话,选择「移入回收站」。只有冷会话、没有运行中的 turn、job、subagent 或定时任务时才能移入;会话跑过的隐藏 subagent 子会话会随父会话一起移入、一起恢复。
|
|
16
16
|
- 对仍打开的会话、正在运行的会话或含可见子会话的会话,菜单保留一个带原因的「移入回收站」说明项:点开解释为什么暂时不能移入,而不是把菜单项直接隐藏。
|
|
17
17
|
- 移入后显示 DSH 共享 Toast,只有「撤销」一个动作。撤销先尝试正常恢复,原工作区不存在时改为孤儿恢复。
|
|
18
|
-
- 设置页「回收站」显示标题、Session ID
|
|
19
|
-
- 含子会话的条目显示「含 N
|
|
18
|
+
- 设置页「回收站」显示标题、Session ID、原工作区、移入时间、剩余天数、大小和状态。列表支持搜索、筛选、按移入时间/标题/大小/到期排序,每页 50 条。
|
|
19
|
+
- 含子会话的条目显示「含 N 个子会话」徽章,可展开查看每个成员(父/子会话 ID 与大小);恢复和永久删除按整棵树处理,不做部分恢复。
|
|
20
|
+
- 可勾选多个条目批量恢复(只对工作区仍存在的条目开放),也支持全选当前页。
|
|
20
21
|
- 行操作包括恢复、恢复为孤儿会话、永久删除、从索引移除、复制 Session ID,以及在 Finder 或资源管理器中显示原工作区。
|
|
21
22
|
- 工具栏「永久删除全部」在二次确认后清空回收站:删除所有已移入的会话日志,并把文件缺失的记录移出索引;附件、图片和搜索索引等衍生物不删除。
|
|
22
23
|
- 默认保留 30 天,每 6 小时扫描一次;插件激活后和打开回收站页面时也会检查。清理失败按指数退避重试,失败信息显示在页面上。
|
|
@@ -170,7 +171,8 @@ npm 安装的插件在「插件」页打开「会话回收站」的详情,展
|
|
|
170
171
|
- 永久删除只删除会话日志。附件、图片、投影缓存和搜索索引不会被删除。
|
|
171
172
|
- 无主对象(`meta.json` 损坏或无法识别的对象目录)不会自动删除;页面只能清理其中不含会话日志的空目录,可能含日志的需要人工处理。
|
|
172
173
|
- `/state` 一次返回全部条目摘要,页面在客户端搜索、筛选和分页。
|
|
173
|
-
-
|
|
174
|
+
- 同一浏览器的同源标签页通过 `BroadcastChannel` 实时同步索引变更;不同浏览器或不同设备之间仍需刷新页面。
|
|
175
|
+
- 批量恢复只包含工作区仍存在的条目;工作区缺失的条目需要逐条使用「恢复为孤儿会话」。
|
|
174
176
|
- Windows/Linux 的桌面版尚未手动验证;CI 用 npm 安装的 `dsh web` 覆盖真实 Host 行为,但桌面壳 UI 与桌面专属能力(例如「在文件管理器中显示」)仍只在 macOS 上验证。Windows 上依赖 POSIX 权限位的用例会跳过;超过 260 字符的长路径和符号链接复制受系统设置影响,对应用例在不支持时自动跳过。
|
|
175
177
|
- macOS 的路径比较按大小写不敏感处理。在大小写敏感的 APFS 卷上,仅大小写不同的两个路径会被视为同一位置。
|
|
176
178
|
- 多个 DSH 进程共享同一个 `DSH_HOME` 时,回收站操作通过 `.lock` 串行;Host 在报 `LOCKED` 前会短暂重试,客户端也会自动重试一次;进程暂停超过 10 分钟后,锁可能被另一个进程接管。
|
package/client.js
CHANGED
|
@@ -21,6 +21,13 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
const EXPIRING_DAYS = 3
|
|
22
22
|
/** Filter identifiers in their sidebar order. */
|
|
23
23
|
const FILTERS = ['all', 'workspace-exists', 'workspace-missing', 'expiring']
|
|
24
|
+
/** Sort options in their menu order. */
|
|
25
|
+
const SORT_OPTIONS = [
|
|
26
|
+
{ value: 'trashed', key: 'sortTrashed' },
|
|
27
|
+
{ value: 'title', key: 'sortTitle' },
|
|
28
|
+
{ value: 'size', key: 'sortSize' },
|
|
29
|
+
{ value: 'expires', key: 'sortExpires' },
|
|
30
|
+
]
|
|
24
31
|
/** Locale keys for each filter label. */
|
|
25
32
|
const FILTER_LABEL_KEYS = {
|
|
26
33
|
all: 'filterAll',
|
|
@@ -56,12 +63,38 @@ window.__ModuleLoader__.load({
|
|
|
56
63
|
|
|
57
64
|
/** Open trash views subscribe here so one successful mutation refreshes them all. */
|
|
58
65
|
const revisionListeners = new Set()
|
|
66
|
+
/** Cross-window channel name; same-origin tabs refresh each other. */
|
|
67
|
+
const REVISION_CHANNEL_NAME = 'dsh-session-trash/revision'
|
|
68
|
+
/** Lazily created BroadcastChannel shared by every view in this tab. */
|
|
69
|
+
let revisionChannel = null
|
|
70
|
+
let revisionChannelTried = false
|
|
59
71
|
|
|
60
72
|
/**
|
|
61
|
-
*
|
|
73
|
+
* Open the cross-window revision channel once, when the platform supports it.
|
|
62
74
|
* @returns {void}
|
|
63
75
|
*/
|
|
64
|
-
function
|
|
76
|
+
function ensureRevisionChannel() {
|
|
77
|
+
if (revisionChannelTried) return
|
|
78
|
+
revisionChannelTried = true
|
|
79
|
+
const Ctor = typeof window === 'undefined' ? undefined : window.BroadcastChannel
|
|
80
|
+
if (typeof Ctor !== 'function') return
|
|
81
|
+
try {
|
|
82
|
+
revisionChannel = new Ctor(REVISION_CHANNEL_NAME)
|
|
83
|
+
revisionChannel.addEventListener('message', (event) => {
|
|
84
|
+
if (event === null || event === undefined) return
|
|
85
|
+
const data = event.data
|
|
86
|
+
if (data === null || typeof data !== 'object') return
|
|
87
|
+
if (data.type === 'revision') notifyRevisionListeners()
|
|
88
|
+
})
|
|
89
|
+
} catch {
|
|
90
|
+
revisionChannel = null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*/
|
|
97
|
+
function notifyRevisionListeners() {
|
|
65
98
|
for (const listener of [...revisionListeners]) {
|
|
66
99
|
try {
|
|
67
100
|
listener()
|
|
@@ -71,11 +104,27 @@ window.__ModuleLoader__.load({
|
|
|
71
104
|
}
|
|
72
105
|
}
|
|
73
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Notify every mounted trash view in this tab and across same-origin tabs.
|
|
109
|
+
* @returns {void}
|
|
110
|
+
*/
|
|
111
|
+
function bumpRevision() {
|
|
112
|
+
ensureRevisionChannel()
|
|
113
|
+
notifyRevisionListeners()
|
|
114
|
+
if (revisionChannel === null) return
|
|
115
|
+
try {
|
|
116
|
+
revisionChannel.postMessage({ type: 'revision' })
|
|
117
|
+
} catch {
|
|
118
|
+
// A closed channel must never break the mutation that triggered it.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
74
122
|
/**
|
|
75
123
|
* @param {() => void} listener refresh callback.
|
|
76
124
|
* @returns {() => void} unsubscribe.
|
|
77
125
|
*/
|
|
78
126
|
function subscribeRevision(listener) {
|
|
127
|
+
ensureRevisionChannel()
|
|
79
128
|
revisionListeners.add(listener)
|
|
80
129
|
return () => {
|
|
81
130
|
revisionListeners.delete(listener)
|
|
@@ -97,6 +146,24 @@ window.__ModuleLoader__.load({
|
|
|
97
146
|
searchPlaceholder: '搜索标题、Session ID 或原始工作区',
|
|
98
147
|
searchClear: '清除搜索',
|
|
99
148
|
resultsCount: '显示 {shown} / {total} 个会话',
|
|
149
|
+
selectRow: '选择「{title}」',
|
|
150
|
+
selectAll: '全选本页可选会话',
|
|
151
|
+
selectedCount: '已选择 {count} 个会话',
|
|
152
|
+
clearSelection: '取消选择',
|
|
153
|
+
batchRestore: '恢复选中',
|
|
154
|
+
batchRestoring: '正在恢复…',
|
|
155
|
+
batchRestoreDoneOne: '已恢复 {count} 个会话',
|
|
156
|
+
batchRestoreDoneOther: '已恢复 {count} 个会话',
|
|
157
|
+
batchRestorePartial: '已恢复 {restored} 个会话,{failed} 个失败',
|
|
158
|
+
sortLabel: '排序方式',
|
|
159
|
+
sortTrashed: '按移入时间(最新在前)',
|
|
160
|
+
sortTitle: '按标题',
|
|
161
|
+
sortSize: '按大小(最大在前)',
|
|
162
|
+
sortExpires: '按到期时间(最早在前)',
|
|
163
|
+
expandMembers: '展开子会话',
|
|
164
|
+
collapseMembers: '收起子会话',
|
|
165
|
+
memberParent: '父会话',
|
|
166
|
+
memberSubagent: '子会话',
|
|
100
167
|
filterLabel: '筛选回收站',
|
|
101
168
|
filterAll: '全部',
|
|
102
169
|
filterWorkspaceExists: '工作区仍存在',
|
|
@@ -259,6 +326,24 @@ window.__ModuleLoader__.load({
|
|
|
259
326
|
searchPlaceholder: 'Search title, Session ID, or original workspace',
|
|
260
327
|
searchClear: 'Clear search',
|
|
261
328
|
resultsCount: 'Showing {shown} of {total} sessions',
|
|
329
|
+
selectRow: 'Select “{title}”',
|
|
330
|
+
selectAll: 'Select all selectable sessions on this page',
|
|
331
|
+
selectedCount: 'Selected: {count} sessions',
|
|
332
|
+
clearSelection: 'Clear selection',
|
|
333
|
+
batchRestore: 'Restore selected',
|
|
334
|
+
batchRestoring: 'Restoring…',
|
|
335
|
+
batchRestoreDoneOne: 'Restored {count} session',
|
|
336
|
+
batchRestoreDoneOther: 'Restored {count} sessions',
|
|
337
|
+
batchRestorePartial: 'Restored {restored}; {failed} failed',
|
|
338
|
+
sortLabel: 'Sort by',
|
|
339
|
+
sortTrashed: 'Newest trashed first',
|
|
340
|
+
sortTitle: 'Title',
|
|
341
|
+
sortSize: 'Largest first',
|
|
342
|
+
sortExpires: 'Expiring soonest first',
|
|
343
|
+
expandMembers: 'Show sub-sessions',
|
|
344
|
+
collapseMembers: 'Hide sub-sessions',
|
|
345
|
+
memberParent: 'Parent',
|
|
346
|
+
memberSubagent: 'Sub-session',
|
|
262
347
|
filterLabel: 'Filter the trash',
|
|
263
348
|
filterAll: 'All',
|
|
264
349
|
filterWorkspaceExists: 'Workspace exists',
|
|
@@ -433,6 +518,27 @@ window.__ModuleLoader__.load({
|
|
|
433
518
|
return format(template, { ...values, count })
|
|
434
519
|
}
|
|
435
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Compare two trash entries for the selected sort order.
|
|
523
|
+
*
|
|
524
|
+
* @param {any} left first entry.
|
|
525
|
+
* @param {any} right second entry.
|
|
526
|
+
* @param {string} sortKey active sort identifier.
|
|
527
|
+
* @param {string | undefined} locale active locale.
|
|
528
|
+
* @returns {number} comparator result.
|
|
529
|
+
*/
|
|
530
|
+
function compareEntries(left, right, sortKey, locale) {
|
|
531
|
+
const titleOf = (entry) => (typeof entry.title === 'string' && entry.title !== '' ? entry.title : entry.sessionId)
|
|
532
|
+
if (sortKey === 'title') return String(titleOf(left)).localeCompare(String(titleOf(right)), locale)
|
|
533
|
+
if (sortKey === 'size') return (Number(right.sizeBytes) || 0) - (Number(left.sizeBytes) || 0)
|
|
534
|
+
if (sortKey === 'expires') {
|
|
535
|
+
const leftAt = typeof left.expiresAt === 'number' ? left.expiresAt : Number.POSITIVE_INFINITY
|
|
536
|
+
const rightAt = typeof right.expiresAt === 'number' ? right.expiresAt : Number.POSITIVE_INFINITY
|
|
537
|
+
return leftAt - rightAt
|
|
538
|
+
}
|
|
539
|
+
return (right.trashedAt ?? 0) - (left.trashedAt ?? 0)
|
|
540
|
+
}
|
|
541
|
+
|
|
436
542
|
/**
|
|
437
543
|
* @param {number | null | undefined} bytes raw byte count.
|
|
438
544
|
* @returns {string} a compact human-readable size, or an em dash when unknown.
|
|
@@ -848,6 +954,26 @@ window.__ModuleLoader__.load({
|
|
|
848
954
|
.dsh-trash__dialogBody { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
|
|
849
955
|
.dsh-trash__dialogActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
|
850
956
|
.dsh-trash__srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
957
|
+
.dsh-trash__controlsRight { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; margin-left: auto; }
|
|
958
|
+
.dsh-trash__selectAll { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); cursor: pointer; white-space: nowrap; }
|
|
959
|
+
.dsh-trash__selectAllInput { width: 14px; height: 14px; margin: 0; flex: none; accent-color: var(--dsw-alias-state-business-primary); }
|
|
960
|
+
.dsh-trash__sort { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
|
|
961
|
+
.dsh-trash__sortLabel { white-space: nowrap; }
|
|
962
|
+
.dsh-trash__sortSelect { height: 28px; padding: 0 8px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; }
|
|
963
|
+
.dsh-trash__sortSelect:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
|
|
964
|
+
.dsh-trash__rowCheck { width: 15px; height: 15px; margin: 3px 0 0; flex: none; accent-color: var(--dsw-alias-state-business-primary); }
|
|
965
|
+
.dsh-trash__batch { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-2); }
|
|
966
|
+
.dsh-trash__batchCount { margin-right: auto; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
|
|
967
|
+
.dsh-trash__membersWrap { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
|
|
968
|
+
.dsh-trash__membersToggle { appearance: none; align-self: flex-start; border: none; background: none; padding: 0; color: var(--dsw-alias-state-business-primary); font: inherit; font-size: 12.5px; line-height: 1.5; cursor: pointer; }
|
|
969
|
+
.dsh-trash__membersToggle:hover { text-decoration: underline; }
|
|
970
|
+
.dsh-trash__membersToggle:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
|
971
|
+
.dsh-trash__members { list-style: none; margin: 0; padding: 0 0 0 10px; display: flex; flex-direction: column; gap: 4px; border-left: 2px solid var(--dsw-alias-border-l1); }
|
|
972
|
+
.dsh-trash__member { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
|
|
973
|
+
.dsh-trash__memberKind { color: var(--dsw-alias-label-tertiary); }
|
|
974
|
+
.dsh-trash__memberId { font-family: var(--dsw-font-mono, monospace); word-break: break-all; }
|
|
975
|
+
.dsh-trash__memberSize { margin-left: auto; color: var(--dsw-alias-label-tertiary); }
|
|
976
|
+
|
|
851
977
|
@media (max-width: 560px) {
|
|
852
978
|
.dsh-trash__row { flex-direction: column; }
|
|
853
979
|
.dsh-trash__rowActions { align-self: flex-end; }
|
|
@@ -1188,6 +1314,10 @@ window.__ModuleLoader__.load({
|
|
|
1188
1314
|
const [query, setQuery] = React.useState('')
|
|
1189
1315
|
const [filter, setFilter] = React.useState('all')
|
|
1190
1316
|
const [page, setPage] = React.useState(1)
|
|
1317
|
+
const [selectedIds, setSelectedIds] = React.useState(() => new Set())
|
|
1318
|
+
const [sortKey, setSortKey] = React.useState('trashed')
|
|
1319
|
+
const [expandedRows, setExpandedRows] = React.useState(() => new Set())
|
|
1320
|
+
const [batchRestoring, setBatchRestoring] = React.useState(false)
|
|
1191
1321
|
const abortRef = React.useRef(null)
|
|
1192
1322
|
const searchRef = React.useRef(null)
|
|
1193
1323
|
const panelRef = React.useRef(null)
|
|
@@ -1434,7 +1564,7 @@ window.__ModuleLoader__.load({
|
|
|
1434
1564
|
|
|
1435
1565
|
const data = view.data
|
|
1436
1566
|
const entries = data !== null && Array.isArray(data.entries) ? [...data.entries] : []
|
|
1437
|
-
entries.sort((left, right) => (right
|
|
1567
|
+
entries.sort((left, right) => compareEntries(left, right, sortKey, locale))
|
|
1438
1568
|
const retentionDays =
|
|
1439
1569
|
data !== null && data.config !== undefined && typeof data.config.retentionDays === 'number'
|
|
1440
1570
|
? data.config.retentionDays
|
|
@@ -1467,7 +1597,12 @@ window.__ModuleLoader__.load({
|
|
|
1467
1597
|
const refreshing = view.status === 'refreshing'
|
|
1468
1598
|
const readsBusy = loading || refreshing
|
|
1469
1599
|
const pendingCount =
|
|
1470
|
-
pendingRows.size +
|
|
1600
|
+
pendingRows.size +
|
|
1601
|
+
(cleaning ? 1 : 0) +
|
|
1602
|
+
(purgingAll ? 1 : 0) +
|
|
1603
|
+
(rebuilding ? 1 : 0) +
|
|
1604
|
+
(forgettingOrphans ? 1 : 0) +
|
|
1605
|
+
(batchRestoring ? 1 : 0)
|
|
1471
1606
|
/** Any write (row action or maintenance) is in flight. */
|
|
1472
1607
|
const mutationBusy = pendingCount > 0
|
|
1473
1608
|
const corrupt = data !== null && data.index !== undefined && data.index.status === 'corrupt'
|
|
@@ -1512,6 +1647,10 @@ window.__ModuleLoader__.load({
|
|
|
1512
1647
|
const pageStart = (currentPage - 1) * PAGE_SIZE
|
|
1513
1648
|
const visibleEntries = filteredEntries.slice(pageStart, pageStart + PAGE_SIZE)
|
|
1514
1649
|
const filtersActive = normalizedQuery !== '' || filter !== 'all'
|
|
1650
|
+
const pageSelectable = visibleEntries.filter((entry) => isSelectable(entry))
|
|
1651
|
+
const selectedEntries = entries.filter((entry) => selectedIds.has(entry.sessionId) && isSelectable(entry))
|
|
1652
|
+
const allPageSelected =
|
|
1653
|
+
pageSelectable.length > 0 && pageSelectable.every((entry) => selectedIds.has(entry.sessionId))
|
|
1515
1654
|
|
|
1516
1655
|
/**
|
|
1517
1656
|
* @returns {void} focus the search field when it is mounted.
|
|
@@ -1546,13 +1685,118 @@ window.__ModuleLoader__.load({
|
|
|
1546
1685
|
}
|
|
1547
1686
|
}
|
|
1548
1687
|
|
|
1688
|
+
/**
|
|
1689
|
+
* @param {object} entry wire entry.
|
|
1690
|
+
* @returns {boolean} whether this row can join a batch restore.
|
|
1691
|
+
*/
|
|
1692
|
+
function isSelectable(entry) {
|
|
1693
|
+
return entry.canRestore === true && entry.requiresOrphanRestore !== true
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* @param {string} sessionId selected row identity.
|
|
1698
|
+
* @returns {void}
|
|
1699
|
+
*/
|
|
1700
|
+
function toggleSelected(sessionId) {
|
|
1701
|
+
setSelectedIds((previous) => {
|
|
1702
|
+
const next = new Set(previous)
|
|
1703
|
+
if (next.has(sessionId)) next.delete(sessionId)
|
|
1704
|
+
else next.add(sessionId)
|
|
1705
|
+
return next
|
|
1706
|
+
})
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* @param {boolean} checked whether the page should be selected.
|
|
1711
|
+
* @returns {void}
|
|
1712
|
+
*/
|
|
1713
|
+
function togglePageSelection(checked) {
|
|
1714
|
+
setSelectedIds((previous) => {
|
|
1715
|
+
const next = new Set(previous)
|
|
1716
|
+
for (const entry of pageSelectable) {
|
|
1717
|
+
if (checked) next.add(entry.sessionId)
|
|
1718
|
+
else next.delete(entry.sessionId)
|
|
1719
|
+
}
|
|
1720
|
+
return next
|
|
1721
|
+
})
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* @returns {void}
|
|
1726
|
+
*/
|
|
1727
|
+
function clearSelection() {
|
|
1728
|
+
setSelectedIds(new Set())
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/**
|
|
1732
|
+
* @param {string} sessionId row whose member list is toggled.
|
|
1733
|
+
* @returns {void}
|
|
1734
|
+
*/
|
|
1735
|
+
function toggleExpanded(sessionId) {
|
|
1736
|
+
setExpandedRows((previous) => {
|
|
1737
|
+
const next = new Set(previous)
|
|
1738
|
+
if (next.has(sessionId)) next.delete(sessionId)
|
|
1739
|
+
else next.add(sessionId)
|
|
1740
|
+
return next
|
|
1741
|
+
})
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Restore every selected row, one Host request at a time. Failures are
|
|
1746
|
+
* collected per row and summarized in one Toast.
|
|
1747
|
+
*
|
|
1748
|
+
* @returns {Promise<void>} completion of the operation.
|
|
1749
|
+
*/
|
|
1750
|
+
async function performBatchRestore() {
|
|
1751
|
+
if (!beginWrite()) return
|
|
1752
|
+
const targets = selectedEntries.slice()
|
|
1753
|
+
setBatchRestoring(true)
|
|
1754
|
+
let restored = 0
|
|
1755
|
+
let failed = 0
|
|
1756
|
+
try {
|
|
1757
|
+
for (const entry of targets) {
|
|
1758
|
+
if (!aliveRef.current) return
|
|
1759
|
+
const outcome = await sendMutation('restore', { sessionId: entry.sessionId, mode: 'normal' })
|
|
1760
|
+
if (!aliveRef.current || outcome.kind === 'cancelled') return
|
|
1761
|
+
if (outcome.kind !== 'response') {
|
|
1762
|
+
failed += 1
|
|
1763
|
+
recordRowError(entry, transportMessage(outcome.kind))
|
|
1764
|
+
continue
|
|
1765
|
+
}
|
|
1766
|
+
if (!outcome.ok) {
|
|
1767
|
+
failed += 1
|
|
1768
|
+
recordRowError(entry, describeActionFailure(outcome.status, outcome.payload, t))
|
|
1769
|
+
continue
|
|
1770
|
+
}
|
|
1771
|
+
restored += 1
|
|
1772
|
+
clearRowError(entry.sessionId)
|
|
1773
|
+
}
|
|
1774
|
+
if (!aliveRef.current) return
|
|
1775
|
+
bumpRevision()
|
|
1776
|
+
clearSelection()
|
|
1777
|
+
const message =
|
|
1778
|
+
failed === 0
|
|
1779
|
+
? countText(t, 'batchRestoreDone', restored)
|
|
1780
|
+
: format(t('batchRestorePartial'), { restored, failed })
|
|
1781
|
+
ctx.contextMenus.notify(message, failed === 0)
|
|
1782
|
+
announce(message)
|
|
1783
|
+
} finally {
|
|
1784
|
+
endWrite()
|
|
1785
|
+
if (aliveRef.current) setBatchRestoring(false)
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1549
1789
|
/**
|
|
1550
1790
|
* @param {object} entry wire entry.
|
|
1551
1791
|
* @param {string} message localized failure text.
|
|
1552
1792
|
* @returns {void}
|
|
1553
1793
|
*/
|
|
1554
|
-
function
|
|
1794
|
+
function recordRowError(entry, message) {
|
|
1555
1795
|
setRowErrors((previous) => ({ ...previous, [entry.sessionId]: message }))
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function fail(entry, message) {
|
|
1799
|
+
recordRowError(entry, message)
|
|
1556
1800
|
ctx.contextMenus.notify(message, false)
|
|
1557
1801
|
}
|
|
1558
1802
|
|
|
@@ -2178,13 +2422,48 @@ window.__ModuleLoader__.load({
|
|
|
2178
2422
|
h(CloseGlyph, { size: 11 }),
|
|
2179
2423
|
),
|
|
2180
2424
|
),
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2425
|
+
h(
|
|
2426
|
+
'div',
|
|
2427
|
+
{ className: 'dsh-trash__controlsRight' },
|
|
2428
|
+
filtersActive
|
|
2429
|
+
? h(
|
|
2430
|
+
'span',
|
|
2431
|
+
{ className: 'dsh-trash__resultsCount' },
|
|
2432
|
+
format(t('resultsCount'), { shown: filteredEntries.length, total: entries.length }),
|
|
2433
|
+
)
|
|
2434
|
+
: null,
|
|
2435
|
+
h(
|
|
2436
|
+
'label',
|
|
2437
|
+
{ className: 'dsh-trash__selectAll' },
|
|
2438
|
+
h('input', {
|
|
2439
|
+
type: 'checkbox',
|
|
2440
|
+
className: 'dsh-trash__selectAllInput',
|
|
2441
|
+
checked: allPageSelected,
|
|
2442
|
+
'aria-label': t('selectAll'),
|
|
2443
|
+
disabled: pageSelectable.length === 0 || loading || mutationBusy || corrupt,
|
|
2444
|
+
onChange: (event) => togglePageSelection(event.target.checked),
|
|
2445
|
+
}),
|
|
2446
|
+
h('span', null, t('selectAll')),
|
|
2447
|
+
),
|
|
2448
|
+
h(
|
|
2449
|
+
'label',
|
|
2450
|
+
{ className: 'dsh-trash__sort' },
|
|
2451
|
+
h('span', { className: 'dsh-trash__sortLabel' }, t('sortLabel')),
|
|
2452
|
+
h(
|
|
2453
|
+
'select',
|
|
2454
|
+
{
|
|
2455
|
+
className: 'dsh-trash__sortSelect',
|
|
2456
|
+
value: sortKey,
|
|
2457
|
+
'aria-label': t('sortLabel'),
|
|
2458
|
+
onChange: (event) => {
|
|
2459
|
+
setSortKey(event.target.value)
|
|
2460
|
+
setPage(1)
|
|
2461
|
+
},
|
|
2462
|
+
},
|
|
2463
|
+
SORT_OPTIONS.map((option) => h('option', { value: option.value, key: option.value }, t(option.key))),
|
|
2464
|
+
),
|
|
2465
|
+
),
|
|
2466
|
+
),
|
|
2188
2467
|
),
|
|
2189
2468
|
h(
|
|
2190
2469
|
'div',
|
|
@@ -2212,6 +2491,37 @@ window.__ModuleLoader__.load({
|
|
|
2212
2491
|
),
|
|
2213
2492
|
)
|
|
2214
2493
|
|
|
2494
|
+
const batchBar =
|
|
2495
|
+
selectedEntries.length === 0
|
|
2496
|
+
? null
|
|
2497
|
+
: h(
|
|
2498
|
+
'div',
|
|
2499
|
+
{ className: 'dsh-trash__batch', key: 'batch', role: 'region', 'aria-label': format(t('selectedCount'), { count: selectedEntries.length }) },
|
|
2500
|
+
h('span', { className: 'dsh-trash__batchCount' }, format(t('selectedCount'), { count: selectedEntries.length })),
|
|
2501
|
+
h(
|
|
2502
|
+
'button',
|
|
2503
|
+
{
|
|
2504
|
+
type: 'button',
|
|
2505
|
+
className: 'dsh-trash__button',
|
|
2506
|
+
onClick: () => {
|
|
2507
|
+
void performBatchRestore()
|
|
2508
|
+
},
|
|
2509
|
+
disabled: loading || mutationBusy || corrupt,
|
|
2510
|
+
},
|
|
2511
|
+
batchRestoring ? t('batchRestoring') : t('batchRestore'),
|
|
2512
|
+
),
|
|
2513
|
+
h(
|
|
2514
|
+
'button',
|
|
2515
|
+
{
|
|
2516
|
+
type: 'button',
|
|
2517
|
+
className: 'dsh-trash__button',
|
|
2518
|
+
onClick: clearSelection,
|
|
2519
|
+
disabled: batchRestoring,
|
|
2520
|
+
},
|
|
2521
|
+
t('clearSelection'),
|
|
2522
|
+
),
|
|
2523
|
+
)
|
|
2524
|
+
|
|
2215
2525
|
let panelBody
|
|
2216
2526
|
if (view.status === 'loading') {
|
|
2217
2527
|
panelBody = h(
|
|
@@ -2259,6 +2569,16 @@ window.__ModuleLoader__.load({
|
|
|
2259
2569
|
return h(
|
|
2260
2570
|
'li',
|
|
2261
2571
|
{ className: 'dsh-trash__row', key: entry.sessionId, 'aria-busy': rowBusy ? 'true' : 'false' },
|
|
2572
|
+
isSelectable(entry)
|
|
2573
|
+
? h('input', {
|
|
2574
|
+
type: 'checkbox',
|
|
2575
|
+
className: 'dsh-trash__rowCheck',
|
|
2576
|
+
checked: selectedIds.has(entry.sessionId),
|
|
2577
|
+
'aria-label': format(t('selectRow'), { title }),
|
|
2578
|
+
disabled: rowBusy || mutationBusy || corrupt || loading,
|
|
2579
|
+
onChange: () => toggleSelected(entry.sessionId),
|
|
2580
|
+
})
|
|
2581
|
+
: null,
|
|
2262
2582
|
h(
|
|
2263
2583
|
'div',
|
|
2264
2584
|
{ className: 'dsh-trash__rowMain' },
|
|
@@ -2293,6 +2613,41 @@ window.__ModuleLoader__.load({
|
|
|
2293
2613
|
h('span', { className: 'dsh-trash__dot', 'aria-hidden': true }, '·'),
|
|
2294
2614
|
h('span', null, formatBytes(entry.sizeBytes)),
|
|
2295
2615
|
),
|
|
2616
|
+
entry.subagentCount > 0
|
|
2617
|
+
? h(
|
|
2618
|
+
'div',
|
|
2619
|
+
{ className: 'dsh-trash__membersWrap' },
|
|
2620
|
+
h(
|
|
2621
|
+
'button',
|
|
2622
|
+
{
|
|
2623
|
+
type: 'button',
|
|
2624
|
+
className: 'dsh-trash__membersToggle',
|
|
2625
|
+
'aria-expanded': expandedRows.has(entry.sessionId) ? 'true' : 'false',
|
|
2626
|
+
onClick: () => toggleExpanded(entry.sessionId),
|
|
2627
|
+
},
|
|
2628
|
+
expandedRows.has(entry.sessionId) ? t('collapseMembers') : t('expandMembers'),
|
|
2629
|
+
),
|
|
2630
|
+
expandedRows.has(entry.sessionId)
|
|
2631
|
+
? h(
|
|
2632
|
+
'ul',
|
|
2633
|
+
{ className: 'dsh-trash__members' },
|
|
2634
|
+
(Array.isArray(entry.members) ? entry.members : []).map((member) =>
|
|
2635
|
+
h(
|
|
2636
|
+
'li',
|
|
2637
|
+
{ className: 'dsh-trash__member', key: member.sessionId },
|
|
2638
|
+
h(
|
|
2639
|
+
'span',
|
|
2640
|
+
{ className: 'dsh-trash__memberKind' },
|
|
2641
|
+
member.origin === 'subagent' ? t('memberSubagent') : t('memberParent'),
|
|
2642
|
+
),
|
|
2643
|
+
h('code', { className: 'dsh-trash__memberId' }, member.sessionId),
|
|
2644
|
+
h('span', { className: 'dsh-trash__memberSize' }, formatBytes(member.sizeBytes)),
|
|
2645
|
+
),
|
|
2646
|
+
),
|
|
2647
|
+
)
|
|
2648
|
+
: null,
|
|
2649
|
+
)
|
|
2650
|
+
: null,
|
|
2296
2651
|
error !== null && error !== undefined && error !== ''
|
|
2297
2652
|
? h(
|
|
2298
2653
|
'div',
|
|
@@ -2418,6 +2773,7 @@ window.__ModuleLoader__.load({
|
|
|
2418
2773
|
banners,
|
|
2419
2774
|
toolbar,
|
|
2420
2775
|
controls,
|
|
2776
|
+
batchBar,
|
|
2421
2777
|
panel,
|
|
2422
2778
|
footnote,
|
|
2423
2779
|
h('div', { className: 'dsh-trash__srOnly', role: 'status', 'aria-live': 'polite' }, announcement),
|
package/lib/members.js
CHANGED
|
@@ -8,39 +8,74 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* @
|
|
12
|
-
* @
|
|
11
|
+
* @typedef {object} Member
|
|
12
|
+
* @property {string} sessionId
|
|
13
|
+
* @property {'subagent' | null} [origin]
|
|
14
|
+
* @property {string} [title]
|
|
15
|
+
* @property {string} [originalSessionDir]
|
|
16
|
+
* @property {string} [originalLogPath]
|
|
17
|
+
* @property {string} [persistenceProjectDirHint]
|
|
18
|
+
* @property {string} [formatStatus]
|
|
19
|
+
* @property {number | null} [sizeBytes]
|
|
20
|
+
* @property {number | null} [fileCount]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} TrashEntry
|
|
25
|
+
* @property {string} sessionId
|
|
26
|
+
* @property {string} [trashId]
|
|
27
|
+
* @property {string} [state]
|
|
28
|
+
* @property {string} [title]
|
|
29
|
+
* @property {number} [trashedAt]
|
|
30
|
+
* @property {number} [startedAt]
|
|
31
|
+
* @property {string | null} [originalCwd]
|
|
32
|
+
* @property {boolean} [workspaceExistsAtDeletion]
|
|
33
|
+
* @property {string} [originalSessionDir]
|
|
34
|
+
* @property {string} [originalLogPath]
|
|
35
|
+
* @property {string} [persistenceProjectDirHint]
|
|
36
|
+
* @property {string} [formatStatus]
|
|
37
|
+
* @property {number | null} [sizeBytes]
|
|
38
|
+
* @property {number | null} [fileCount]
|
|
39
|
+
* @property {string | null} [lastError]
|
|
40
|
+
* @property {Member[]} [members]
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {unknown} entry index entry (v1 or v2 shape).
|
|
45
|
+
* @returns {Member | null} the parent member, or null when the entry has no directory data.
|
|
13
46
|
*/
|
|
14
47
|
export function parentMember(entry) {
|
|
15
48
|
if (entry === null || typeof entry !== 'object') return null
|
|
16
|
-
|
|
49
|
+
const record = /** @type {Record<string, any>} */ (entry)
|
|
50
|
+
if (typeof record.sessionId !== 'string' || record.sessionId === '') return null
|
|
17
51
|
return {
|
|
18
|
-
sessionId:
|
|
52
|
+
sessionId: record.sessionId,
|
|
19
53
|
origin: null,
|
|
20
|
-
title: typeof
|
|
21
|
-
originalSessionDir:
|
|
22
|
-
originalLogPath:
|
|
23
|
-
persistenceProjectDirHint:
|
|
24
|
-
formatStatus:
|
|
25
|
-
sizeBytes:
|
|
26
|
-
fileCount:
|
|
54
|
+
title: typeof record.title === 'string' ? record.title : record.sessionId,
|
|
55
|
+
originalSessionDir: record.originalSessionDir,
|
|
56
|
+
originalLogPath: record.originalLogPath,
|
|
57
|
+
persistenceProjectDirHint: record.persistenceProjectDirHint,
|
|
58
|
+
formatStatus: record.formatStatus,
|
|
59
|
+
sizeBytes: record.sizeBytes,
|
|
60
|
+
fileCount: record.fileCount,
|
|
27
61
|
}
|
|
28
62
|
}
|
|
29
63
|
|
|
30
64
|
/**
|
|
31
|
-
* @param {
|
|
32
|
-
* @returns {
|
|
65
|
+
* @param {unknown} entry index entry (v1 or v2 shape).
|
|
66
|
+
* @returns {Member[]} every owned directory, parent first.
|
|
33
67
|
*/
|
|
34
68
|
export function memberList(entry) {
|
|
35
|
-
if (entry !== null && typeof entry === 'object'
|
|
36
|
-
|
|
69
|
+
if (entry !== null && typeof entry === 'object') {
|
|
70
|
+
const record = /** @type {Record<string, any>} */ (entry)
|
|
71
|
+
if (Array.isArray(record.members) && record.members.length > 0) return record.members
|
|
37
72
|
}
|
|
38
73
|
const parent = parentMember(entry)
|
|
39
74
|
return parent === null ? [] : [parent]
|
|
40
75
|
}
|
|
41
76
|
|
|
42
77
|
/**
|
|
43
|
-
* @param {
|
|
78
|
+
* @param {ReadonlyArray<Partial<Member> | null | undefined>} members member list.
|
|
44
79
|
* @returns {{ sizeBytes: number, fileCount: number }} summed measurable totals.
|
|
45
80
|
*/
|
|
46
81
|
export function aggregateMetrics(members) {
|
|
@@ -57,21 +92,26 @@ export function aggregateMetrics(members) {
|
|
|
57
92
|
* Shape-level validation shared by the durable index and object sidecars.
|
|
58
93
|
* Path strictness belongs to the sidecar validator.
|
|
59
94
|
*
|
|
60
|
-
* @param {
|
|
95
|
+
* @param {unknown} member candidate member.
|
|
61
96
|
* @returns {boolean} whether the member carries the required v2 identity.
|
|
62
97
|
*/
|
|
63
98
|
export function isValidMemberShape(member) {
|
|
64
99
|
if (member === null || typeof member !== 'object' || Array.isArray(member)) return false
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
if (
|
|
68
|
-
if (
|
|
100
|
+
const record = /** @type {Record<string, any>} */ (member)
|
|
101
|
+
if (typeof record.sessionId !== 'string' || record.sessionId === '' || record.sessionId.length > 256) return false
|
|
102
|
+
if (record.origin !== undefined && record.origin !== null && record.origin !== 'subagent') return false
|
|
103
|
+
if (record.title !== undefined && typeof record.title !== 'string') return false
|
|
104
|
+
if (
|
|
105
|
+
record.formatStatus !== undefined &&
|
|
106
|
+
record.formatStatus !== 'current' &&
|
|
107
|
+
record.formatStatus !== 'migration-required'
|
|
108
|
+
) {
|
|
69
109
|
return false
|
|
70
110
|
}
|
|
71
|
-
if (
|
|
72
|
-
if (
|
|
111
|
+
if (record.sizeBytes !== undefined && record.sizeBytes !== null && typeof record.sizeBytes !== 'number') return false
|
|
112
|
+
if (record.fileCount !== undefined && record.fileCount !== null && typeof record.fileCount !== 'number') return false
|
|
73
113
|
for (const key of ['originalSessionDir', 'originalLogPath', 'persistenceProjectDirHint']) {
|
|
74
|
-
if (
|
|
114
|
+
if (record[key] !== undefined && record[key] !== null && typeof record[key] !== 'string') return false
|
|
75
115
|
}
|
|
76
116
|
return true
|
|
77
117
|
}
|
|
@@ -79,17 +119,19 @@ export function isValidMemberShape(member) {
|
|
|
79
119
|
/**
|
|
80
120
|
* Whether the v2 member list is internally consistent with its parent entry.
|
|
81
121
|
*
|
|
82
|
-
* @param {
|
|
122
|
+
* @param {unknown} entry index entry carrying `members`.
|
|
83
123
|
* @returns {boolean} whether the list is acceptable.
|
|
84
124
|
*/
|
|
85
125
|
export function isValidMemberList(entry) {
|
|
86
126
|
if (entry === null || typeof entry !== 'object') return false
|
|
87
|
-
|
|
127
|
+
const record = /** @type {Record<string, any>} */ (entry)
|
|
128
|
+
const members = record.members
|
|
129
|
+
if (!Array.isArray(members) || members.length === 0 || members.length > 64) return false
|
|
88
130
|
const seen = new Set()
|
|
89
|
-
for (const member of
|
|
131
|
+
for (const member of members) {
|
|
90
132
|
if (!isValidMemberShape(member)) return false
|
|
91
133
|
if (seen.has(member.sessionId)) return false
|
|
92
134
|
seen.add(member.sessionId)
|
|
93
135
|
}
|
|
94
|
-
return
|
|
136
|
+
return members[0].sessionId === record.sessionId
|
|
95
137
|
}
|
package/lib/state.js
CHANGED
|
@@ -48,18 +48,60 @@ async function directoryExists(directory) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} TrashConfig
|
|
53
|
+
* @property {number} retentionDays
|
|
54
|
+
* @property {number} sweepIntervalHours
|
|
55
|
+
* @property {boolean} enableAutoCleanup
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} CleanupDiagnostics
|
|
60
|
+
* @property {boolean} enabled
|
|
61
|
+
* @property {boolean} timerBacked
|
|
62
|
+
* @property {number | null} lastAttemptAt
|
|
63
|
+
* @property {number | null} lastSuccessAt
|
|
64
|
+
* @property {number | null} nextSweepAt
|
|
65
|
+
* @property {number | null} retryAt
|
|
66
|
+
* @property {number} consecutiveFailures
|
|
67
|
+
* @property {object | null} lastRun
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} StateEntry
|
|
72
|
+
* @property {string} sessionId
|
|
73
|
+
* @property {string | undefined} title
|
|
74
|
+
* @property {number | undefined} trashedAt
|
|
75
|
+
* @property {number | null} expiresAt
|
|
76
|
+
* @property {string | null} originalCwd
|
|
77
|
+
* @property {boolean | null} workspaceExistsAtDeletion
|
|
78
|
+
* @property {boolean | null} workspaceNowExists
|
|
79
|
+
* @property {number | null} sizeBytes
|
|
80
|
+
* @property {number | null} fileCount
|
|
81
|
+
* @property {string | null} formatStatus
|
|
82
|
+
* @property {string | undefined} state
|
|
83
|
+
* @property {boolean} canRestore
|
|
84
|
+
* @property {boolean} canPurge
|
|
85
|
+
* @property {boolean} canForget
|
|
86
|
+
* @property {boolean} requiresOrphanRestore
|
|
87
|
+
* @property {string | null} lastError
|
|
88
|
+
* @property {string | null} lastErrorCode
|
|
89
|
+
* @property {number} subagentCount
|
|
90
|
+
* @property {{ sessionId: string, origin: 'subagent' | null, sizeBytes: number | null }[]} members
|
|
91
|
+
*/
|
|
92
|
+
|
|
51
93
|
/**
|
|
52
94
|
* Build the read-only `/state` projection from the committed index.
|
|
53
95
|
*
|
|
54
96
|
* @param {{
|
|
55
97
|
* store: import('./store.js').TrashStore,
|
|
56
|
-
* config:
|
|
98
|
+
* config: TrashConfig,
|
|
57
99
|
* trashRootDisplay: string,
|
|
58
100
|
* nextSweepAt?: () => number | null,
|
|
59
|
-
* cleanup?: () =>
|
|
60
|
-
* directoryExists?: (directory?:
|
|
101
|
+
* cleanup?: () => CleanupDiagnostics | null,
|
|
102
|
+
* directoryExists?: (directory?: string | null) => Promise<boolean>,
|
|
61
103
|
* }} deps state inputs.
|
|
62
|
-
* @returns {{ snapshot: () => Promise<
|
|
104
|
+
* @returns {{ snapshot: () => Promise<object>, describe: (entry: import('./members.js').TrashEntry) => Promise<StateEntry> }} the state service.
|
|
63
105
|
*/
|
|
64
106
|
export function createStateService({
|
|
65
107
|
store,
|
|
@@ -70,13 +112,13 @@ export function createStateService({
|
|
|
70
112
|
directoryExists: probeDirectory = directoryExists,
|
|
71
113
|
}) {
|
|
72
114
|
/**
|
|
73
|
-
* @returns {
|
|
115
|
+
* @returns {CleanupDiagnostics} cleanup diagnostics, or a neutral fallback.
|
|
74
116
|
*/
|
|
75
117
|
function cleanupDiagnostics() {
|
|
76
118
|
if (typeof cleanup === 'function') {
|
|
77
119
|
try {
|
|
78
120
|
const value = cleanup()
|
|
79
|
-
if (value !== null && typeof value === 'object') return value
|
|
121
|
+
if (value !== null && typeof value === 'object') return /** @type {CleanupDiagnostics} */ (value)
|
|
80
122
|
} catch {
|
|
81
123
|
// Diagnostics must never break the read-only state projection.
|
|
82
124
|
}
|
|
@@ -94,10 +136,10 @@ export function createStateService({
|
|
|
94
136
|
}
|
|
95
137
|
|
|
96
138
|
/**
|
|
97
|
-
* @param {
|
|
139
|
+
* @param {import('./members.js').TrashEntry} entry committed index entry.
|
|
98
140
|
* @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
|
|
99
141
|
* started for this snapshot, so entries sharing a cwd probe it once.
|
|
100
|
-
* @returns {Promise<
|
|
142
|
+
* @returns {Promise<StateEntry>} the wire entry.
|
|
101
143
|
*/
|
|
102
144
|
async function describeEntry(entry, probeCache = new Map()) {
|
|
103
145
|
const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
|
|
@@ -140,12 +182,12 @@ export function createStateService({
|
|
|
140
182
|
|
|
141
183
|
return {
|
|
142
184
|
/**
|
|
143
|
-
* @param {
|
|
144
|
-
* @returns {Promise<
|
|
185
|
+
* @param {import('./members.js').TrashEntry} entry committed index entry.
|
|
186
|
+
* @returns {Promise<StateEntry>} the wire entry.
|
|
145
187
|
*/
|
|
146
188
|
describe: describeEntry,
|
|
147
189
|
/**
|
|
148
|
-
* @returns {Promise<
|
|
190
|
+
* @returns {Promise<object>} the complete `/state` response body.
|
|
149
191
|
*/
|
|
150
192
|
async snapshot() {
|
|
151
193
|
const probeCache = new Map()
|
package/lib/trash-service.js
CHANGED
|
@@ -1036,6 +1036,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1036
1036
|
|
|
1037
1037
|
const plan = Array.isArray(verdict.members) ? verdict.members : []
|
|
1038
1038
|
if (plan.length === 0) throw new TrashError('SESSION_NOT_FOUND', 'This session is not in session storage.')
|
|
1039
|
+
/** @type {import('./members.js').Member[]} */
|
|
1039
1040
|
const members = []
|
|
1040
1041
|
let parentSnapshot
|
|
1041
1042
|
for (let index = 0; index < plan.length; index += 1) {
|
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.6",
|
|
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",
|