dsh-conversation-recycle-bin 0.3.17 → 0.3.19
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 +58 -1
- package/README.md +33 -4
- package/client.js +154 -54
- package/config.schema.json +1 -1
- package/lib/core/audit.js +38 -0
- package/lib/core/cascade.js +82 -21
- package/lib/core/config.js +24 -6
- package/lib/core/memory/json-index-adapter.js +85 -7
- package/lib/core/memory/registry.js +17 -2
- package/lib/host/api.js +96 -12
- package/lib/host/gateway.js +23 -14
- package/lib/host/paths.js +13 -5
- package/lib/version.js +1 -1
- package/package.json +4 -2
- package/scripts/check-syntax.mjs +53 -0
- package/scripts/run-tests.mjs +59 -0
- package/scripts/verify-host-contract.mjs +106 -0
- package/test/api.test.mjs +2072 -0
- package/test/cascade.test.mjs +828 -0
- package/test/client-module.test.mjs +1102 -0
- package/test/contract.test.mjs +100 -0
- package/test/core.test.mjs +260 -0
- package/test/feedback.test.mjs +532 -0
- package/test/helpers.mjs +205 -0
- package/test/manifest.test.mjs +121 -0
- package/test/memory.test.mjs +636 -0
- package/test/retention.test.mjs +88 -0
- package/test/trash-audit.test.mjs +156 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,63 @@
|
|
|
3
3
|
All notable changes to `dsh-conversation-recycle-bin` are documented here.
|
|
4
4
|
The format follows Keep a Changelog; the project uses Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.3.19] — 2026-11-05
|
|
7
|
+
|
|
8
|
+
平台审计(架构 / 安全 / 验收三方独立复审 v0.3.18)之后的修复。三方从不同角度撞到同一处:**记忆残留的条目在 UI/API 上没有任何出口**;另有一条把瞬时读失败当永久不可恢复。
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **失败导出的残留变成可恢复**(架构 P2 / 验收 R-21-01):`bundle.json` 过去只在整个搬移循环结束后才写,若在此之前失败且补偿搬回也失败,目录只剩 `files/` 与 `ORPHANED.json` —— `readExported` 只认 bundle,restore 无物可读、discard 拒绝文件、force 又要求"日志不可读",条目永久留盘。现在补偿决定保留时**同时写一份 rescue bundle**(用仍留在 `files/` 的条目重建),让恢复重新有路。
|
|
13
|
+
- **索引已有节点、内容仍在 bundle 时不再拒绝恢复**(同一根因的另一半):失败导出会把索引还原成 pristine(节点还在),旧逻辑看到"节点已存在"就直接 `E_TRASH_CONFLICT`;现在若内容文件仍在 bundle 里,就把它搬回并视为已恢复。
|
|
14
|
+
- **`restored` 残留条目有了 purge 入口**(架构 ARCH-02 / 验收 R-21-02):恢复成功但 `memory/` 仍有文件时,条目落在"未完成条目"面板,Restore/Discard/Force 全不可用,而 API 的 purge 其实允许。`IncompleteEntries` 现在对 `canPurge === true` 渲染 purge(沿用 `DELETE` 短语确认)。
|
|
15
|
+
- **瞬时读失败不再被当成永久不可恢复**(安全 S-01):`recoverSessionLog`/`readSessionLogCwd` 过去把所有错误吞成 `null`,于是 EACCES/EBUSY(杀软、备份锁)也会让列表隐藏 Restore 并提供"强制清理(会丢弃记忆)"。现在只有 `ENOENT`/`ENOTDIR` 与解析失败算"确实读不出",`EACCES`/`EPERM`/`EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN` 一律按瞬时处理:能力位保持 `canRestore`、不给 force,恢复时提示"稍后重试"。
|
|
16
|
+
- **stranded 记忆成为第三种 force 场景**(架构 ARCH-01 的出口):`entryCapabilities` 新增 `strandedMemory`,与"日志不可读"同级允许 `canForceDiscard`(仍需 `DELETE` 短语)。
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **内联确认框改为订阅 seat 状态**:`overlaySeatMounted` 布尔换成 `overlaySeatStore`(订阅式),`DialogHost` 挂载即发布,面板同帧让位;不再依赖 250ms 轮询,消除了双渲染窗口(架构 ARCH-06 / 安全 S-03)。
|
|
21
|
+
- **`lastSweep` 显示相对时间**:"Host 已在后台自动清理 N 条"补上 `{when}`(刚刚 / N 分钟前 / N 小时前 / N 天前),避免把 6 小时前的结果当成刚刚发生。
|
|
22
|
+
- `audit.append` 的文档补上契约:**调用方必须已持有跨进程 mutation 锁**(`serialize()` 只排本进程的顺序,补换行读末字节与另一 Host 的追加会交错)。
|
|
23
|
+
|
|
24
|
+
### Tests
|
|
25
|
+
|
|
26
|
+
- `test/cascade.test.mjs`(30 → 32):stranded 记忆开启 force 出口 / 无 stranded 时不开启;force 在 stranded 下被接受、无 stranded 时拒绝且 `forceable` 不置位;瞬时 `EBUSY` 时 restore 提示重试、discard 不把 force 打开。
|
|
27
|
+
- `test/memory.test.mjs`(18 → 19):导出失败 + 补偿搬回失败 → 留下可用的 rescue bundle,且 `restoreBundle` 能把内容搬回。
|
|
28
|
+
- `test/client-module.test.mjs`(29 → 31):`canPurge` 时渲染 purge(无该能力时不渲染任何破坏性动作);`formatAgo` 的相对时间文案。
|
|
29
|
+
- `test/api.test.mjs`(68 → 70):Origin 栅栏矩阵(127/localhost 混用 403、缺 Host 403、`dsh-app://app` 豁免、跨站拒绝);端到端验证 stranded 条目被列出为 `strandedMemory` 且 `force:true` 的 discard 被接受(不带 force 仍 409)。
|
|
30
|
+
- 合计 213 → **221**。
|
|
31
|
+
|
|
32
|
+
## [0.3.18] — 2026-11-04
|
|
33
|
+
|
|
34
|
+
一次针对「失败之后再失败」链路的修补,加上客户端与宿主队列预算的统一。0.3.17 的评审结论是正常路径可信、错误处理链路第二层未被同等对待;这一版把 4 条可能导致数据不可逆丢失的路径、8 条一致性/健壮性缺口和文档漂移全部闭合。
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **记忆 bundle 的补偿失败会删掉唯一副本(高)**:`exportSession` 失败时先尝试把已移入 `files/` 的正文搬回原位,但 `rename` 的失败被 `.catch(() => {})` 吞掉,紧接着无条件删除整个 bundle 目录——此时 bundle 里的副本是唯一一份,索引已还原成指向不存在的路径。现在逐个记录搬回结果:全部成功才删 bundle;任一失败就保留 bundle 并写 `ORPHANED.json`(记录 adapter、sessionId、失败映射),既不丢数据也不留无法解释的残留。
|
|
39
|
+
- **`restore` 从不写终态,审计一失败条目永久卡 `restoring`(中高)**:`'restored'` 只在 `PURGEABLE_STATUSES` 里出现过,没有任何写入点。恢复成功现在先提交 `status: 'restored'`(连同 `restoredAt`),再写审计;审计写失败不再把已完成的恢复报成失败,条目也不会再被重复 `catalog.restore`。内存仍有残留文件时保留条目,`memoryLeftOnDisk` 记入审计。
|
|
40
|
+
- **快照缓存被就地修改(中)**:`snapshotCache` 与工作副本同一引用,`exportSession` 直接 `delete nodes[...]` / `node[field] = kept`;中途失败后缓存仍是"已删空"的样子——预览显示 0 条记忆,重试删除则静默跳过真实存在的记忆。现在在私有克隆上修改,缓存始终描述磁盘上的真实内容。
|
|
41
|
+
- **三路全拒的死条目(中)**:日志头不可读且 memory 目录仍有文件时,restore 拒绝日志、discard 拒绝记忆、purge 拒绝状态,UI 没有任何按钮。`entryCapabilities` 新增 `canForceDiscard`,设置页对这类条目显示「强制清理(会丢弃记忆)」,仍要求输入 `DELETE`,审计记录 `discardedMemory: true`。`force` 只在这一种组合下被接受。
|
|
42
|
+
- **审计半行会连带吞掉下一条(中低)**:`append` 直接追加,崩溃留下的半行没有换行,下一条记录与它粘成一行坏 JSON。现在追加前检查末字节,缺换行先补一个。
|
|
43
|
+
- **记忆 bundle 路径没有围栏(中低)**:`restoreEntry` 直接按 manifest 的 `bundlePath` 读文件并 `rm -rf` 其目录;同 manifest 的 `from/to` 在 Host 侧是双向校验的。现在服务层接收 `bundleRoot`,越界即 `E_TRASH_CONFLICT`。
|
|
44
|
+
- **客户端 195s 永远短于宿主 240s(中)**:宿主的回收站操作预算 = 锁预算 180s + 队列宽限 60s,客户端却固定 195s,浏览器先 abort,UI 报超时而后台仍在跑。四类 `/trash/*` 现在读 `/state` 下发的 `limits.queueTimeoutMs`,常量只作冷启动兜底(并改为 245s)。
|
|
45
|
+
- **保留期清扫无视 `protectedSessionIds`(中)**:受保护会话过去只挡手动删除,定时清扫仍会把它的 hard 条目清掉。现在 hard/soft 都先判保护名单,命中记 `E_POLICY_PROTECTED` 并退避。
|
|
46
|
+
- **会话列表读失败让整个面板 500(中)**:`loadList` 对 `controller.list`/`persistence.list` 没有异常处理,一次 5s 超时会让 `/sessions`、preview、delete 预检全部失效。现在降级为空列表并 warn。
|
|
47
|
+
- **`preview` 透传记忆库绝对路径(低)**:`stores` 现在只保留 `id/label/available/reason`。
|
|
48
|
+
- **配置修正静默(低)**:`clampInt` 截断小数、`memory.adapters` 非数组回退,过去都不留 warning;现在都进 `/state.warnings`。
|
|
49
|
+
- **客户端细节**:补 `E_SESSION_ALIVE` 中英文案;回收站行 Restore/Purge/Discard 互斥,进行中的请求会禁用其它行内动作。
|
|
50
|
+
|
|
51
|
+
### Docs
|
|
52
|
+
|
|
53
|
+
- README 新增 §4.4 配置项全表(22 键、默认值与安全含义),补齐此前零提及的 9 个键。
|
|
54
|
+
- 统一 `allowNonLoopback` 的说法:打开它会跳过整段 Host 校验并使 `allowedHosts` 失效,暴露到 loopback 之外应只配 `allowedHosts`。
|
|
55
|
+
- 删除 README 中「`GET /trash` 会触发后台清扫」的过时描述(0.3.17 起 GET 已无副作用)。
|
|
56
|
+
- `config.schema.json` 的保留期描述、`paths.js` 的 Node 版本注释、README 的宿主版本(0.2.2-alpha.3)与 `CHANGELOG` 对齐。
|
|
57
|
+
- `package.json` 的 `files` 补入 `scripts/` 与 `test/`:README 与 INSTALL 指导用户运行 `npm test` 与 `node scripts/verify-host-contract.mjs`,此前从 npm 安装后两者都不存在。
|
|
58
|
+
|
|
59
|
+
### Tests
|
|
60
|
+
|
|
61
|
+
- `capabilities` 断言补 `canForceDiscard`;新增强制清理、restore 终态、审计半行、bundle 越界、`limits.queueTimeoutMs` 与错误码完整性用例。
|
|
62
|
+
|
|
6
63
|
## [0.3.17] — 2026-11-03
|
|
7
64
|
|
|
8
65
|
针对 **dsh 0.2.2-alpha.3** 的适配性评审(安全 / 架构 / 验收三方独立只读复审)后的修复。评审本身的结论是「接口层 0 阻断、运行态未装载、行为层零证据」;这一版把其中所有能在代码里闭合的项做完,**运行态装载与破坏性链路仍需一次重启后的实测**(见 `scripts/verify-host-contract.mjs`)。
|
|
@@ -134,7 +191,7 @@ dsh-go(Android)适配性审计后的修复。审计结论:Host 半边开
|
|
|
134
191
|
- **锁毒化(P1)**:`release()` 删不掉锁文件时,过去只 `warn` 一句就走人 —— 残留锁的 `owner.pid` 是**自己这个活进程**,而 stale 判定对活 owner 永不放行,于是下一次 `acquire()` 必定失败,之后每一次 mutation 都是 409,只能重启。现在 release 失败会把锁体降级成 `{pid: null, at: 0}`(并把 mtime 拨到纪元),下一次 acquire 按"无主且超龄"立即破锁;实在写不进去才留 warn。
|
|
135
192
|
- **PID 复用**:心跳每 5 秒刷新一次 mtime,所以"十拍未刷新"(`staleMs * 10`)就按残留锁处理并告警,不再出现"这个 pid 恰好被别的进程复用、于是永远破不了锁"。
|
|
136
193
|
- **队列超时不再撒谎**:计时器过去从入队一直武装到结束,操作其实已经开跑时仍返回"没有执行"。现在操作一开始就 `clearTimeout`,只有真正还在排队时才报 `E_QUEUE_TIMEOUT`。
|
|
137
|
-
- **客户端预算不再比宿主早 60 秒**:`/preview` 与 `/state` 的 `limits` 新增 `queueTimeoutMs`(宿主实际使用的队列预算),客户端优先用它;过去客户端只按锁预算推算,宿主的队列预算比它多 60 秒,于是"还在跑"
|
|
194
|
+
- **客户端预算不再比宿主早 60 秒**:`/preview` 与 `/state` 的 `limits` 新增 `queueTimeoutMs`(宿主实际使用的队列预算),客户端优先用它;过去客户端只按锁预算推算,宿主的队列预算比它多 60 秒,于是"还在跑"看起来像超时失败。(0.3.18 更正:当时只有 `/delete` 真正读取了该字段,四类 `/trash/*` 仍用固定 `195s`,比宿主 `240s` 预算短——见 0.3.18。)
|
|
138
195
|
- **`running` 不再被兜成 `false`**:`gateway.inspect()` 的 `running: fromList?.running ?? false` 让"宿主没说"和"已停止"无法区分,`lifecycle.js` 的 fail-closed 分支成了死代码。现在保留 `undefined`,并且 `probeRunning()` 能回答时以它为准(`agents.get(id)?.status === 'running'`)。
|
|
139
196
|
- **空闲会话又能删了**:硬删闸门过去看 `live`("宿主还知道这个会话"),空闲会话也满足,于是 `stopLiveSessions: false` 时所有空闲对话都删不掉。现在看 `session.running`;宿主不报状态时才按严格读法(未知视为在跑)。
|
|
140
197
|
- **`capabilities` 不再是死字段**:回收站行的「恢复」/「彻底删除」按 `entry.capabilities.canRestore/canPurge` 置灰,不再让用户点了才知道会被拒。
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
|
|
4
4
|
|
|
5
|
-
> 状态:`0.3.
|
|
5
|
+
> 状态:`0.3.19`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
|
|
6
6
|
> 运行要求:**Node.js ≥ 22.15**(Host 侧用到 `readdir(..., { recursive: true })`、`AbortSignal` 等 22.x 行为),DSH `>=0.2.1-rc.2 <0.3.0-0`(`package.json` 的 `engines.dsh` 已如实声明,DSH 安装校验会据此拒绝范围外版本;上界是刻意的:0.2.2 与 0.3.x 之间没有承诺兼容)。
|
|
7
7
|
> 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
|
|
8
8
|
> 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
## 1. 宿主平台规范确认(第一步)
|
|
13
13
|
|
|
14
|
-
以下结论来自对当前安装的 DSH `0.2.
|
|
14
|
+
以下结论来自对当前安装的 DSH `0.2.2-alpha.3`(桌面端 desktop profile;`0.2.1-rc.2` 起成立)的 `cordis_inspect_query` 与实现源码核对,不是假设:
|
|
15
15
|
|
|
16
16
|
| 项目 | DSH 事实 | 本插件的落点 |
|
|
17
17
|
|---|---|---|
|
|
@@ -150,7 +150,36 @@ memory → messages → session/catalog
|
|
|
150
150
|
files/<nodeId>.content
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
**保留期(自动清理)**:`trashRetentionDays`(默认 **30**)现在真的生效 —— Host 每 6 小时清扫一次(启动后 60
|
|
153
|
+
**保留期(自动清理)**:`trashRetentionDays`(默认 **30**)现在真的生效 —— Host 每 6 小时清扫一次(启动后 60 秒先扫一次),只有 `status: 'trashed'` 且 `deletedAt` 可解析、且早于 `now - trashRetentionDays` 的条目会被清走,走的是与「永久清除」**完全相同**的 `performPurge` 路径(重新读 manifest、复判 `capabilities.canPurge`、写审计 `action: 'purge'` + `source: 'retention'`),单次最多 20 条、最旧优先,失败按指数退避重试(5 分钟起,上限 6 小时)。`pending`/`restoring`/`restored`/`purging` 与"从未提交"的残留条目**永不自动清**(那是 bug 现场,留给设置页的「恢复」/「discard」出口);`mode: 'soft'` 且会话已被用户复活(未归档)或仍在运行的条目会跳过(无人值守时不去碰正在使用的对话),而仍处于归档状态的 soft 条目会照常清理——soft 删除本身保留日志,不再因为"日志还在"而永久逃过保留期。`trashRetentionDays: 0` 或 `enabled: false` 都会关闭清扫(后者连定时器都不注册,读接口仍然可用),条目一直留到你自己删。回收站列表里会写明"删除 N 天后自动清理",每条显示 Host 下发的剩余天数,后台刚清过东西时还会提示清了几条。
|
|
154
|
+
|
|
155
|
+
### 4.4 配置项全表
|
|
156
|
+
|
|
157
|
+
`cordis.patch.yml` 的 `config` 段共 22 个键;全部可省略,省略即取默认值。`normalizeConfig` 从不抛错:越界值被钳制、类型错误回落默认,并把每一条修正写进 `/state` 的 `warnings`(`enabled` 例外,它 fail-closed)。
|
|
158
|
+
|
|
159
|
+
| 键 | 默认 | 说明 |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `enabled` | `true` | 总开关。`false` 时不注册保留期定时器,且所有**写**操作返回 `E_DISABLED`;读接口仍可用,便于查看并手动清理残留条目。 |
|
|
162
|
+
| `requireConnection` | `true` | 必须拿到宿主的 `connection.requestRejection` 才接受路由,否则一律 401 `E_UNAUTHORIZED`。见 §4.2 最后一行。 |
|
|
163
|
+
| `requireSameOrigin` | `true` | 校验 `Origin`/`Sec-Fetch-Site`。 |
|
|
164
|
+
| `allowNonLoopback` | `false` | 跳过 Host 回环校验。**打开它会让 `allowedHosts` 失效**,只在完全信任本机所有进程时使用。 |
|
|
165
|
+
| `allowedHosts` | `[]` | 反代/自定义主机名白名单(`host` 或 `host:port`,最多 32 条)。暴露到 loopback 之外应只配置这一项。 |
|
|
166
|
+
| `allowOrphanSessions` | `false` | 允许删除未登记到任何工作区的会话(默认按 `E_POLICY_ORPHAN` 拒绝)。 |
|
|
167
|
+
| `allowSubagentSessions` | `false` | 允许删除子代理会话(默认按 `E_POLICY_SUBAGENT` 拒绝,避免拆掉正在工作的子会话)。 |
|
|
168
|
+
| `protectedSessionIds` | `[]` | 永不删除、**也不被保留期自动清理**的会话 id 列表。 |
|
|
169
|
+
| `allowedWorkspaceRoots` | `[]` | 非空时,会话的 workspace 路径必须落在其中一个根目录内才允许删除。 |
|
|
170
|
+
| `defaultMode` | `"soft"` | 确认框初始选中的模式。 |
|
|
171
|
+
| `stopLiveSessions` | `true` | `true` 时 hard 删除对 live 会话先走 `quiesce`;`false` 时直接拒绝。 |
|
|
172
|
+
| `liveDrainMs` | `2000` | quiesce 等待 `running=false` 的上限,超时报 `E_POLICY_LIVE` 并回滚。 |
|
|
173
|
+
| `foreignWriterGraceMs` | `1500` | 移动前复检日志 mtime 的窗口;仍在增长则报 `E_POLICY_FOREIGN_WRITER`。 |
|
|
174
|
+
| `trashRetentionDays` | `30` | 回收站保留天数,`0` 关闭清扫(清扫只由定时器驱动,`GET /trash` 无副作用)。 |
|
|
175
|
+
| `maxBatchSessions` | `50` | 单次批量上限;实际值还会被本分钟剩余额度收紧。 |
|
|
176
|
+
| `maxDeletesPerMinute` | `20` | 滑动窗口限流,单删与批量逐项计数。 |
|
|
177
|
+
| `confirmTokenTtlMs` | `120000` | `/preview` 签发的确认 token 有效期(5s–1h)。 |
|
|
178
|
+
| `auditRetentionLines` | `5000` | `audit.jsonl` 保留行数,超出后压缩。 |
|
|
179
|
+
| `auditMaxBytes` | `5242880`(5 MiB) | 审计文件字节上限,超出后压缩(64KB–1GB)。 |
|
|
180
|
+
| `memory` | `enabled:true`、`adapters:["lingshu","generic-json","none"]`、`deleteMode:"trash"` | 记忆适配器段:`enabled`、`adapters`、`lessons`(灵枢)等。 |
|
|
181
|
+
| `ui` | `showRowAction/showHeaderAction/batchDoubleConfirm` 均 `true` | 前端座位开关与批量二次确认。 |
|
|
182
|
+
| `feedback` | 见 §9.2 | 反馈邮件段(非机密)。 |
|
|
154
183
|
|
|
155
184
|
`manifest.status` 只允许 `pending → trashed → restoring/restored / purging`。`GET /trash` 把**所有**条目都返回:`trashed` 进 `entries`(回收站 UI,可恢复/彻底删除),其余状态进 `incomplete`(设置页单独列出,提供「恢复」与「discard」两个出口,不再出现"失败了却没有任何清理入口"的死条目)。恢复顺序是删除的逆序:日志 → catalog → 记忆。
|
|
156
185
|
|
|
@@ -248,7 +277,7 @@ node scripts/verify-host-contract.mjs http://127.0.0.1:3082 --token=<token> #
|
|
|
248
277
|
|
|
249
278
|
8. 路径围栏是**词法**判断(折叠 `.`/`..`、按段比较),不解析符号链接;真正的归属权威是宿主的 `workspaceRegistry`,围栏只用于「属于哪个工作区」的展示与删除前提示。若 `workspace.path` 或会话 `cwd` 本身是符号链接,判定结果可能与 `realpath` 不同。
|
|
250
279
|
9. 回收站页面的保留期清理不是即时的:`GET /trash` 无副作用,过期条目由定时器(启动后 60s 首扫、之后每 6h)或用户显式 purge 移除,因此最多可能比 `trashRetentionDays` 多存活 6 小时。
|
|
251
|
-
10. 宿主不校验插件前缀路由的 Origin/CSRF(0.2.2-alpha.3 实测:带 cookie 的跨站请求返回 200),鉴权由插件自己完成——cookie 会话 + 私有请求头 + `requireSameOrigin`。把 Web 暴露到 loopback
|
|
280
|
+
10. 宿主不校验插件前缀路由的 Origin/CSRF(0.2.2-alpha.3 实测:带 cookie 的跨站请求返回 200),鉴权由插件自己完成——cookie 会话 + 私有请求头 + `requireSameOrigin`。把 Web 暴露到 loopback 之外时用 `allowedHosts` 精确放行(见 §4.2 的反代表);`allowNonLoopback: true` 会跳过整段 Host 校验、使 `allowedHosts` 失效,只应作为"我完全信任本机所有进程"的显式选择。
|
|
252
281
|
11. `package.json` 的 `dsh.manifestVersion` 与 `config.schema.json` 是文档性声明,当前宿主不读取它们(全仓 grep 零命中);`engines.dsh` 才是唯一被执行的兼容门。
|
|
253
282
|
|
|
254
283
|
---
|
package/client.js
CHANGED
|
@@ -116,6 +116,11 @@ window.__ModuleLoader__.load({
|
|
|
116
116
|
'trash.expiresIn': 'auto-purged in {days} d',
|
|
117
117
|
'trash.expiresSoon': 'auto-purged on the next check',
|
|
118
118
|
'trash.swept': 'The Host cleaned up {count} expired entries in the background.',
|
|
119
|
+
'trash.sweptAgo': 'The Host cleaned up {count} expired entries {when}.',
|
|
120
|
+
'time.justNow': 'just now',
|
|
121
|
+
'time.minutesAgo': '{count} min ago',
|
|
122
|
+
'time.hoursAgo': '{count} h ago',
|
|
123
|
+
'time.daysAgo': '{count} d ago',
|
|
119
124
|
'trash.modeSoft': 'soft',
|
|
120
125
|
'trash.modeHard': 'permanent',
|
|
121
126
|
'trash.items': '{sessions} conversation(s) · {memories} memory entries',
|
|
@@ -125,9 +130,12 @@ window.__ModuleLoader__.load({
|
|
|
125
130
|
'trash.incompleteTitle': 'Incomplete entries',
|
|
126
131
|
'trash.incompleteHint': 'Entries that never committed. Discard them to free the space; an entry that still holds a conversation log must be restored or purged instead.',
|
|
127
132
|
'trash.restoreIncompleteHint': 'Put the conversation log back and re-attach it to its workspace.',
|
|
133
|
+
'trash.purgeIncompleteHint': 'Delete this entry and everything still inside it. Type DELETE to confirm.',
|
|
128
134
|
'trash.discard': 'Discard',
|
|
129
135
|
'trash.discarded': 'Entry removed.',
|
|
130
136
|
'trash.confirmDiscard': 'Type DELETE to discard this incomplete entry.',
|
|
137
|
+
'trash.discardForce': 'Discard anyway (drops memory)',
|
|
138
|
+
'trash.confirmDiscardForce': 'The conversation log cannot be read, so this entry can never be restored. Typing DELETE also deletes the memory bundle it still holds.',
|
|
131
139
|
'trash.status.pending': 'pending',
|
|
132
140
|
'trash.status.failed': 'failed',
|
|
133
141
|
'trash.status.restoring': 'restoring',
|
|
@@ -197,6 +205,7 @@ window.__ModuleLoader__.load({
|
|
|
197
205
|
'error.E_POLICY_LIVE': 'The conversation could not be stopped. Close it and retry.',
|
|
198
206
|
'error.E_POLICY_ORPHAN': 'The conversation is not owned by a registered workspace.',
|
|
199
207
|
'error.E_POLICY_PROTECTED': 'The conversation is protected by configuration.',
|
|
208
|
+
'error.E_SESSION_ALIVE': 'The conversation is live again (revived or running), so the recycle-bin entry was left untouched.',
|
|
200
209
|
'error.E_POLICY_SUBAGENT': 'Subagent conversations cannot be deleted.',
|
|
201
210
|
'error.E_POLICY_ROOT': 'The conversation is outside the allowed workspace roots.',
|
|
202
211
|
'error.E_RATE_LIMITED': 'Too many deletions; wait a moment and retry.',
|
|
@@ -279,6 +288,11 @@ window.__ModuleLoader__.load({
|
|
|
279
288
|
'trash.expiresIn': '{days} 天后自动清理',
|
|
280
289
|
'trash.expiresSoon': '下次检查时自动清理',
|
|
281
290
|
'trash.swept': 'Host 已在后台自动清理 {count} 条过期条目。',
|
|
291
|
+
'trash.sweptAgo': 'Host 已在{when}自动清理 {count} 条过期条目。',
|
|
292
|
+
'time.justNow': '刚刚',
|
|
293
|
+
'time.minutesAgo': '{count} 分钟前',
|
|
294
|
+
'time.hoursAgo': '{count} 小时前',
|
|
295
|
+
'time.daysAgo': '{count} 天前',
|
|
282
296
|
'trash.modeSoft': '软删除',
|
|
283
297
|
'trash.modeHard': '彻底',
|
|
284
298
|
'trash.items': '{sessions} 个对话 · {memories} 条记忆',
|
|
@@ -288,9 +302,12 @@ window.__ModuleLoader__.load({
|
|
|
288
302
|
'trash.incompleteTitle': '未完成条目',
|
|
289
303
|
'trash.incompleteHint': '这些条目没有提交成功,可直接清理以释放空间;仍保存着会话日志的条目请先恢复或彻底删除。',
|
|
290
304
|
'trash.restoreIncompleteHint': '把会话日志放回原位,并重新挂回它所属的工作区。',
|
|
305
|
+
'trash.purgeIncompleteHint': '彻底删除该条目及其中残留的内容,需要输入 DELETE 确认。',
|
|
291
306
|
'trash.discard': '清理',
|
|
292
307
|
'trash.discarded': '条目已清理。',
|
|
293
308
|
'trash.confirmDiscard': '输入 DELETE 清理该未完成条目。',
|
|
309
|
+
'trash.discardForce': '强制清理(会丢弃记忆)',
|
|
310
|
+
'trash.confirmDiscardForce': '该条目中的会话日志已无法读取,永远无法恢复。输入 DELETE 会连同它仍保存的记忆一起删除。',
|
|
294
311
|
'trash.status.pending': '待处理',
|
|
295
312
|
'trash.status.failed': '失败',
|
|
296
313
|
'trash.status.restoring': '恢复中断',
|
|
@@ -360,6 +377,7 @@ window.__ModuleLoader__.load({
|
|
|
360
377
|
'error.E_POLICY_LIVE': '无法停止该对话,请关闭后重试。',
|
|
361
378
|
'error.E_POLICY_ORPHAN': '该对话不属于任何已登记工作区。',
|
|
362
379
|
'error.E_POLICY_PROTECTED': '该对话受配置保护。',
|
|
380
|
+
'error.E_SESSION_ALIVE': '该对话已重新活跃(被恢复或正在运行),回收站条目未做处理。',
|
|
363
381
|
'error.E_POLICY_SUBAGENT': '子代理对话不能删除。',
|
|
364
382
|
'error.E_POLICY_ROOT': '该对话不在允许的工作区根目录内。',
|
|
365
383
|
'error.E_RATE_LIMITED': '删除过于频繁,请稍后重试。',
|
|
@@ -491,15 +509,14 @@ window.__ModuleLoader__.load({
|
|
|
491
509
|
const REQUEST_TIMEOUT_MS = 15_000
|
|
492
510
|
const MUTATION_TIMEOUT_MS = 180_000
|
|
493
511
|
/**
|
|
494
|
-
* Recycle-bin writes wait for the Host's cross-process lock, and
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
* just above it so the Host's own answer always wins the race.
|
|
512
|
+
* Recycle-bin writes wait for the Host's cross-process lock, and the Host's
|
|
513
|
+
* own budget for that wait is `TRASH_LOCK_BUDGET_MS` (180s) plus the 60s
|
|
514
|
+
* queue grace, published on `/state` as `limits.queueTimeoutMs` (240s).
|
|
515
|
+
* This constant is only the fallback for the window before the first
|
|
516
|
+
* `/state` read lands; it used to be 195s, i.e. 45s SHORTER than the Host,
|
|
517
|
+
* so the browser aborted an operation the Host went on to complete.
|
|
501
518
|
*/
|
|
502
|
-
const TRASH_MUTATION_TIMEOUT_MS =
|
|
519
|
+
const TRASH_MUTATION_TIMEOUT_MS = 245_000
|
|
503
520
|
/**
|
|
504
521
|
* How long a delete may run before the dialog says what it is waiting for.
|
|
505
522
|
* A hard delete of a conversation written moments ago spends
|
|
@@ -699,34 +716,28 @@ window.__ModuleLoader__.load({
|
|
|
699
716
|
* only confirmation dialog the delete flow had. Settings still gets a page
|
|
700
717
|
* island, so the panel can render that same dialog inline instead of
|
|
701
718
|
* pretending the button did nothing.
|
|
719
|
+
*
|
|
720
|
+
* A store, not a poll: the panel and the host must flip in the same frame,
|
|
721
|
+
* otherwise both copies of the dialog render for a tick.
|
|
702
722
|
*/
|
|
703
|
-
|
|
723
|
+
const overlaySeatStore = {
|
|
724
|
+
mounted: false,
|
|
725
|
+
listeners: new Set(),
|
|
726
|
+
subscribe(listener) {
|
|
727
|
+
this.listeners.add(listener)
|
|
728
|
+
return () => this.listeners.delete(listener)
|
|
729
|
+
},
|
|
730
|
+
set(mounted) {
|
|
731
|
+
const next = mounted === true
|
|
732
|
+
if (this.mounted === next) return
|
|
733
|
+
this.mounted = next
|
|
734
|
+
for (const listener of this.listeners) listener(next)
|
|
735
|
+
},
|
|
736
|
+
}
|
|
704
737
|
|
|
705
738
|
function useOverlaySeatPresent() {
|
|
706
|
-
const [present, setPresent] = useState(
|
|
707
|
-
useEffect(() =>
|
|
708
|
-
if (overlaySeatMounted === true) {
|
|
709
|
-
setPresent(true)
|
|
710
|
-
return undefined
|
|
711
|
-
}
|
|
712
|
-
// The overlay host mounts with the shell, but nothing guarantees it is
|
|
713
|
-
// already there when this panel first renders: poll briefly so a late
|
|
714
|
-
// mount still wins over the inline copy instead of rendering both.
|
|
715
|
-
let timer = null
|
|
716
|
-
const tick = () => {
|
|
717
|
-
if (overlaySeatMounted !== true) return
|
|
718
|
-
if (timer !== null) clearInterval(timer)
|
|
719
|
-
setPresent(true)
|
|
720
|
-
}
|
|
721
|
-
timer = setInterval(tick, 250)
|
|
722
|
-
const stop = setTimeout(() => {
|
|
723
|
-
if (timer !== null) clearInterval(timer)
|
|
724
|
-
}, 10_000)
|
|
725
|
-
return () => {
|
|
726
|
-
if (timer !== null) clearInterval(timer)
|
|
727
|
-
clearTimeout(stop)
|
|
728
|
-
}
|
|
729
|
-
}, [])
|
|
739
|
+
const [present, setPresent] = useState(overlaySeatStore.mounted)
|
|
740
|
+
useEffect(() => overlaySeatStore.subscribe(setPresent), [])
|
|
730
741
|
return present
|
|
731
742
|
}
|
|
732
743
|
|
|
@@ -1023,6 +1034,25 @@ window.__ModuleLoader__.load({
|
|
|
1023
1034
|
}
|
|
1024
1035
|
}
|
|
1025
1036
|
|
|
1037
|
+
/**
|
|
1038
|
+
* "3 hours ago"-style label for the last background sweep.
|
|
1039
|
+
*
|
|
1040
|
+
* The sweep only runs on the Host's own timer, so the same result stays in
|
|
1041
|
+
* `lastSweep` for up to six hours. Rendering it without a time made users
|
|
1042
|
+
* think the entries had just been deleted on the refresh they had in front
|
|
1043
|
+
* of them.
|
|
1044
|
+
*/
|
|
1045
|
+
function formatAgo(value, nowMs = Date.now()) {
|
|
1046
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return ''
|
|
1047
|
+
const seconds = Math.max(0, Math.round((nowMs - value) / 1000))
|
|
1048
|
+
if (seconds < 60) return t('time.justNow')
|
|
1049
|
+
const minutes = Math.round(seconds / 60)
|
|
1050
|
+
if (minutes < 60) return t('time.minutesAgo', { count: minutes })
|
|
1051
|
+
const hours = Math.round(minutes / 60)
|
|
1052
|
+
if (hours < 24) return t('time.hoursAgo', { count: hours })
|
|
1053
|
+
return t('time.daysAgo', { count: Math.round(hours / 24) })
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1026
1056
|
/**
|
|
1027
1057
|
* The recycle-bin retention window, read from the Host payload.
|
|
1028
1058
|
*
|
|
@@ -1583,9 +1613,11 @@ window.__ModuleLoader__.load({
|
|
|
1583
1613
|
const payload = useDialogState()
|
|
1584
1614
|
const flash = useFlashMessage()
|
|
1585
1615
|
useEffect(() => {
|
|
1586
|
-
|
|
1616
|
+
// Publishing synchronously on mount is what keeps the inline copy from
|
|
1617
|
+
// rendering alongside this one: the panel is subscribed, not polling.
|
|
1618
|
+
overlaySeatStore.set(true)
|
|
1587
1619
|
return () => {
|
|
1588
|
-
|
|
1620
|
+
overlaySeatStore.set(false)
|
|
1589
1621
|
}
|
|
1590
1622
|
}, [])
|
|
1591
1623
|
// `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
|
|
@@ -1663,7 +1695,7 @@ window.__ModuleLoader__.load({
|
|
|
1663
1695
|
* log) gets a Restore button: `performDiscard` refuses exactly those
|
|
1664
1696
|
* entries, so without this the only way out was a manual API call.
|
|
1665
1697
|
*/
|
|
1666
|
-
function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null }) {
|
|
1698
|
+
function IncompleteEntries({ entries, discardTarget, discardPhrase, discardForce = false, onPhrase, onStart, onForceStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null, purgeTarget = null, purgePhrase = '', onPurgePhrase, onPurgeStart, onPurgeCancel, onPurge, purgeBusyId = null }) {
|
|
1667
1699
|
return h('div', { className: 'dc-panel dc-incomplete' },
|
|
1668
1700
|
h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
|
|
1669
1701
|
h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
|
|
@@ -1679,6 +1711,14 @@ window.__ModuleLoader__.load({
|
|
|
1679
1711
|
const canDiscard = entry.capabilities === undefined
|
|
1680
1712
|
? true
|
|
1681
1713
|
: entry.capabilities?.canDiscard !== false
|
|
1714
|
+
// The one state where the log can never be restored (its header is
|
|
1715
|
+
// unreadable) but a memory bundle still occupies the folder: plain
|
|
1716
|
+
// discard refuses it, restore refuses the log, purge refuses the
|
|
1717
|
+
// status. Offering nothing left the entry on disk forever.
|
|
1718
|
+
const canForceDiscard = entry.capabilities?.canForceDiscard === true
|
|
1719
|
+
const openDiscard = canDiscard === true
|
|
1720
|
+
? typeof onStart === 'function' ? onStart : null
|
|
1721
|
+
: canForceDiscard && typeof onForceStart === 'function' ? onForceStart : null
|
|
1682
1722
|
return h('div', { key: entry.trashId, className: 'dc-row' },
|
|
1683
1723
|
h('div', { className: 'dc-row-main' },
|
|
1684
1724
|
h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId || entry.trashId),
|
|
@@ -1693,7 +1733,35 @@ window.__ModuleLoader__.load({
|
|
|
1693
1733
|
title: t('trash.restoreIncompleteHint'),
|
|
1694
1734
|
}, t('action.restore'))
|
|
1695
1735
|
: null,
|
|
1696
|
-
|
|
1736
|
+
// A restored entry that still holds memory files is purgeable by
|
|
1737
|
+
// the API but was not reachable from this panel: it rendered no
|
|
1738
|
+
// action at all. Offer the same confirmed purge the trash rows use.
|
|
1739
|
+
entry.capabilities?.canPurge === true && typeof onPurge === 'function'
|
|
1740
|
+
? purgeTarget === entry.trashId
|
|
1741
|
+
? h('span', { className: 'dc-inline-confirm' },
|
|
1742
|
+
h('input', {
|
|
1743
|
+
type: 'text',
|
|
1744
|
+
value: purgePhrase,
|
|
1745
|
+
placeholder: 'DELETE',
|
|
1746
|
+
className: 'dc-input dc-input-small',
|
|
1747
|
+
onChange: (event) => onPurgePhrase(event.target.value),
|
|
1748
|
+
autoCapitalize: 'characters',
|
|
1749
|
+
autoCorrect: 'off',
|
|
1750
|
+
spellCheck: false,
|
|
1751
|
+
}),
|
|
1752
|
+
h(Button, {
|
|
1753
|
+
variant: 'danger',
|
|
1754
|
+
disabled: purgeBusyId === entry.trashId || phraseMatches(purgePhrase, 'DELETE') !== true,
|
|
1755
|
+
onClick: () => onPurge(entry.trashId),
|
|
1756
|
+
}, t('action.purge')),
|
|
1757
|
+
h(Button, { disabled: purgeBusyId === entry.trashId, onClick: onPurgeCancel }, t('action.cancel')))
|
|
1758
|
+
: h(Button, {
|
|
1759
|
+
variant: 'danger',
|
|
1760
|
+
onClick: () => onPurgeStart(entry.trashId),
|
|
1761
|
+
title: t('trash.purgeIncompleteHint'),
|
|
1762
|
+
}, t('action.purge'))
|
|
1763
|
+
: null,
|
|
1764
|
+
typeof openDiscard !== 'function'
|
|
1697
1765
|
? null
|
|
1698
1766
|
: discardTarget === entry.trashId
|
|
1699
1767
|
? h('span', { className: 'dc-inline-confirm' },
|
|
@@ -1714,12 +1782,13 @@ window.__ModuleLoader__.load({
|
|
|
1714
1782
|
variant: 'danger',
|
|
1715
1783
|
disabled: discardBusyId === entry.trashId || phraseMatches(discardPhrase, 'DELETE') !== true,
|
|
1716
1784
|
onClick: () => onDiscard(entry.trashId),
|
|
1717
|
-
}, t('trash.discard')),
|
|
1785
|
+
}, discardForce === true ? t('trash.discardForce') : t('trash.discard')),
|
|
1718
1786
|
h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
|
|
1719
1787
|
: h(Button, {
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1788
|
+
variant: canDiscard === true ? undefined : 'danger',
|
|
1789
|
+
onClick: () => openDiscard(entry.trashId),
|
|
1790
|
+
title: canDiscard === true ? t('trash.confirmDiscard') : t('trash.confirmDiscardForce'),
|
|
1791
|
+
}, canDiscard === true ? t('trash.discard') : t('trash.discardForce')))
|
|
1723
1792
|
)
|
|
1724
1793
|
}))
|
|
1725
1794
|
}
|
|
@@ -1775,6 +1844,7 @@ window.__ModuleLoader__.load({
|
|
|
1775
1844
|
const [purgePhrase, setPurgePhrase] = useState('')
|
|
1776
1845
|
const [discardTarget, setDiscardTarget] = useState(null)
|
|
1777
1846
|
const [discardPhrase, setDiscardPhrase] = useState('')
|
|
1847
|
+
const [discardForce, setDiscardForce] = useState(false)
|
|
1778
1848
|
const [restoreBusyId, setRestoreBusyId] = useState(null)
|
|
1779
1849
|
const [purgeBusy, setPurgeBusy] = useState(false)
|
|
1780
1850
|
const [discardBusyId, setDiscardBusyId] = useState(null)
|
|
@@ -1866,14 +1936,26 @@ window.__ModuleLoader__.load({
|
|
|
1866
1936
|
for (const name of which) loadedRef.current[name] = -1
|
|
1867
1937
|
}, [])
|
|
1868
1938
|
|
|
1939
|
+
/**
|
|
1940
|
+
* The Host's published budget for one trash mutation (lock + queue grace).
|
|
1941
|
+
* Using the published number keeps the browser from aborting 60s before
|
|
1942
|
+
* the Host gives up; the constant is a cold-start fallback only.
|
|
1943
|
+
*/
|
|
1944
|
+
function trashTimeoutMs() {
|
|
1945
|
+
const published = Number(state?.limits?.queueTimeoutMs)
|
|
1946
|
+
return Number.isFinite(published) && published > 0 ? Math.max(1_000, published) : TRASH_MUTATION_TIMEOUT_MS
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1869
1949
|
async function restore(trashId) {
|
|
1950
|
+
if (restoreBusyId !== null) return
|
|
1951
|
+
setRestoreBusyId(trashId)
|
|
1870
1952
|
setError(null)
|
|
1871
1953
|
const entry = trash.find((item) => item.trashId === trashId)
|
|
1872
1954
|
try {
|
|
1873
1955
|
await api('/trash/restore', {
|
|
1874
1956
|
method: 'POST',
|
|
1875
1957
|
body: { trashId },
|
|
1876
|
-
timeoutMs:
|
|
1958
|
+
timeoutMs: trashTimeoutMs(),
|
|
1877
1959
|
})
|
|
1878
1960
|
flashStore.show(t('trash.restored'))
|
|
1879
1961
|
markStale('trash', 'batch')
|
|
@@ -1885,6 +1967,8 @@ window.__ModuleLoader__.load({
|
|
|
1885
1967
|
})
|
|
1886
1968
|
} catch (caught) {
|
|
1887
1969
|
setError(caught)
|
|
1970
|
+
} finally {
|
|
1971
|
+
setRestoreBusyId(null)
|
|
1888
1972
|
}
|
|
1889
1973
|
}
|
|
1890
1974
|
|
|
@@ -1901,7 +1985,7 @@ window.__ModuleLoader__.load({
|
|
|
1901
1985
|
await api('/trash/restore', {
|
|
1902
1986
|
method: 'POST',
|
|
1903
1987
|
body: { trashId },
|
|
1904
|
-
timeoutMs:
|
|
1988
|
+
timeoutMs: trashTimeoutMs(),
|
|
1905
1989
|
})
|
|
1906
1990
|
flashStore.show(t('trash.restored'))
|
|
1907
1991
|
markStale('trash', 'batch')
|
|
@@ -1929,7 +2013,7 @@ window.__ModuleLoader__.load({
|
|
|
1929
2013
|
await api('/trash/purge', {
|
|
1930
2014
|
method: 'POST',
|
|
1931
2015
|
body: { trashId, confirmPhrase: phrase },
|
|
1932
|
-
timeoutMs:
|
|
2016
|
+
timeoutMs: trashTimeoutMs(),
|
|
1933
2017
|
})
|
|
1934
2018
|
flashStore.show(t('trash.purged'))
|
|
1935
2019
|
setPurgeTarget(null)
|
|
@@ -1949,15 +2033,15 @@ window.__ModuleLoader__.load({
|
|
|
1949
2033
|
}
|
|
1950
2034
|
}
|
|
1951
2035
|
|
|
1952
|
-
async function discard(trashId, phrase) {
|
|
2036
|
+
async function discard(trashId, phrase, force = false) {
|
|
1953
2037
|
if (discardBusyId !== null) return
|
|
1954
2038
|
setDiscardBusyId(trashId)
|
|
1955
2039
|
setError(null)
|
|
1956
2040
|
try {
|
|
1957
2041
|
await api('/trash/discard', {
|
|
1958
2042
|
method: 'POST',
|
|
1959
|
-
body: { trashId, confirmPhrase: phrase },
|
|
1960
|
-
timeoutMs:
|
|
2043
|
+
body: { trashId, confirmPhrase: phrase, force: force === true },
|
|
2044
|
+
timeoutMs: trashTimeoutMs(),
|
|
1961
2045
|
})
|
|
1962
2046
|
flashStore.show(t('trash.discarded'))
|
|
1963
2047
|
setDiscardTarget(null)
|
|
@@ -1989,6 +2073,10 @@ window.__ModuleLoader__.load({
|
|
|
1989
2073
|
// banner and the per-row hint come from `retentionInfo`, which reads the
|
|
1990
2074
|
// field where the Host actually sends it (`limits`).
|
|
1991
2075
|
const retentionDays = retentionInfo(state).days
|
|
2076
|
+
// One trash mutation at a time: the Host serializes them anyway, so a
|
|
2077
|
+
// second click only queued another request behind the first and its error
|
|
2078
|
+
// overwrote the first one's success flash.
|
|
2079
|
+
const trashActionBusy = restoreBusyId !== null || purgeBusy === true || discardBusyId !== null
|
|
1992
2080
|
const expiryHint = (entry) => {
|
|
1993
2081
|
const left = retentionInfo(state, entry).daysLeft
|
|
1994
2082
|
if (left === null) return ''
|
|
@@ -2060,7 +2148,9 @@ window.__ModuleLoader__.load({
|
|
|
2060
2148
|
tab === 'trash' ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
|
|
2061
2149
|
retentionDays > 0 ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.retention', { days: retentionDays })) : null,
|
|
2062
2150
|
Number(lastSweep?.purged) > 0
|
|
2063
|
-
? h('div', { className: 'dc-hint dc-hint-block' },
|
|
2151
|
+
? h('div', { className: 'dc-hint dc-hint-block' }, typeof lastSweep?.at === 'number'
|
|
2152
|
+
? t('trash.sweptAgo', { count: Number(lastSweep.purged), when: formatAgo(lastSweep.at) })
|
|
2153
|
+
: t('trash.swept', { count: Number(lastSweep.purged) }))
|
|
2064
2154
|
: null,
|
|
2065
2155
|
trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
|
|
2066
2156
|
...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
|
|
@@ -2072,7 +2162,7 @@ window.__ModuleLoader__.load({
|
|
|
2072
2162
|
// The Host already knows whether this entry can be restored;
|
|
2073
2163
|
// the panel used to offer the button and then show the refusal.
|
|
2074
2164
|
h(Button, {
|
|
2075
|
-
disabled: entry.capabilities?.canRestore === false,
|
|
2165
|
+
disabled: entry.capabilities?.canRestore === false || trashActionBusy,
|
|
2076
2166
|
title: entry.capabilities?.canRestore === false ? t('error.E_TRASH_CONFLICT') : t('action.restore'),
|
|
2077
2167
|
onClick: () => { void restore(entry.trashId) },
|
|
2078
2168
|
}, t('action.restore')),
|
|
@@ -2093,7 +2183,7 @@ window.__ModuleLoader__.load({
|
|
|
2093
2183
|
h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
|
|
2094
2184
|
: h(Button, {
|
|
2095
2185
|
variant: 'danger',
|
|
2096
|
-
disabled: entry.capabilities?.canPurge === false,
|
|
2186
|
+
disabled: entry.capabilities?.canPurge === false || trashActionBusy,
|
|
2097
2187
|
title: entry.capabilities?.canPurge === false ? t('error.E_TRASH_CONFLICT') : t('action.purge'),
|
|
2098
2188
|
onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') },
|
|
2099
2189
|
}, t('action.purge')))))) : null,
|
|
@@ -2102,13 +2192,22 @@ window.__ModuleLoader__.load({
|
|
|
2102
2192
|
entries: incomplete,
|
|
2103
2193
|
discardTarget,
|
|
2104
2194
|
discardPhrase,
|
|
2195
|
+
discardForce,
|
|
2105
2196
|
onPhrase: setDiscardPhrase,
|
|
2106
|
-
onStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase('') },
|
|
2107
|
-
|
|
2108
|
-
|
|
2197
|
+
onStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase(''); setDiscardForce(false) },
|
|
2198
|
+
onForceStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase(''); setDiscardForce(true) },
|
|
2199
|
+
onCancel: () => { setDiscardTarget(null); setDiscardPhrase(''); setDiscardForce(false) },
|
|
2200
|
+
onDiscard: (trashId) => { void discard(trashId, discardPhrase.trim(), discardForce) },
|
|
2109
2201
|
onRestore: (trashId) => { void restoreIncomplete(trashId) },
|
|
2110
2202
|
restoreBusyId,
|
|
2111
2203
|
discardBusyId,
|
|
2204
|
+
purgeTarget,
|
|
2205
|
+
purgePhrase,
|
|
2206
|
+
purgeBusyId: purgeBusy === true ? purgeTarget : null,
|
|
2207
|
+
onPurgePhrase: setPurgePhrase,
|
|
2208
|
+
onPurgeStart: (trashId) => { setPurgeTarget(trashId); setPurgePhrase('') },
|
|
2209
|
+
onPurgeCancel: () => { setPurgeTarget(null); setPurgePhrase('') },
|
|
2210
|
+
onPurge: (trashId) => { void purge(trashId, purgePhrase.trim()) },
|
|
2112
2211
|
}) : null,
|
|
2113
2212
|
|
|
2114
2213
|
tab === 'batch' ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
|
|
@@ -2369,7 +2468,8 @@ window.__ModuleLoader__.load({
|
|
|
2369
2468
|
// guard changes the outcome.
|
|
2370
2469
|
exports.__phraseMatches = phraseMatches
|
|
2371
2470
|
exports.__httpFallbackError = httpFallbackError
|
|
2372
|
-
exports.__overlaySeatState = () => ({ mounted:
|
|
2471
|
+
exports.__overlaySeatState = () => ({ mounted: overlaySeatStore.mounted })
|
|
2472
|
+
exports.__formatAgo = formatAgo
|
|
2373
2473
|
return module.exports
|
|
2374
2474
|
},
|
|
2375
2475
|
})
|
package/config.schema.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"description": "When non-empty, a session may only be deleted when its workspace path is inside one of these roots."
|
|
29
29
|
},
|
|
30
30
|
"defaultMode": { "enum": ["soft", "hard"], "default": "soft", "description": "Initial mode selected in the confirmation dialog." },
|
|
31
|
-
"trashRetentionDays": { "type": "number", "minimum": 0, "maximum": 3650, "default": 30, "description": "Days a committed recycle-bin entry is kept before the Host purges it automatically (checked
|
|
31
|
+
"trashRetentionDays": { "type": "number", "minimum": 0, "maximum": 3650, "default": 30, "description": "Days a committed recycle-bin entry is kept before the Host purges it automatically (checked by a background timer: once 60s after start, then every six hours). 0 disables the sweep, leaving entries until the user purges them." },
|
|
32
32
|
"maxBatchSessions": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Upper bound on one batch. The effective limit is min(this, remaining deletions in the current minute), so a batch can never exceed the rate budget." },
|
|
33
33
|
"maxDeletesPerMinute": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Sliding-window limit for conversation deletions (single and batch items count individually)." },
|
|
34
34
|
"confirmTokenTtlMs": { "type": "integer", "minimum": 5000, "maximum": 3600000, "default": 120000 },
|