dsh-conversation-recycle-bin 0.3.17 → 0.3.18
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 +32 -1
- package/README.md +33 -4
- package/client.js +60 -25
- package/config.schema.json +1 -1
- package/lib/core/audit.js +29 -0
- package/lib/core/cascade.js +51 -19
- package/lib/core/config.js +24 -6
- package/lib/core/memory/json-index-adapter.js +44 -7
- package/lib/core/memory/registry.js +17 -2
- package/lib/host/api.js +29 -8
- package/lib/host/gateway.js +23 -14
- package/lib/host/paths.js +1 -1
- 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 +2001 -0
- package/test/cascade.test.mjs +703 -0
- package/test/client-module.test.mjs +1032 -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 +576 -0
- package/test/retention.test.mjs +88 -0
- package/test/trash-audit.test.mjs +156 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,37 @@
|
|
|
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.18] — 2026-11-04
|
|
7
|
+
|
|
8
|
+
一次针对「失败之后再失败」链路的修补,加上客户端与宿主队列预算的统一。0.3.17 的评审结论是正常路径可信、错误处理链路第二层未被同等对待;这一版把 4 条可能导致数据不可逆丢失的路径、8 条一致性/健壮性缺口和文档漂移全部闭合。
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **记忆 bundle 的补偿失败会删掉唯一副本(高)**:`exportSession` 失败时先尝试把已移入 `files/` 的正文搬回原位,但 `rename` 的失败被 `.catch(() => {})` 吞掉,紧接着无条件删除整个 bundle 目录——此时 bundle 里的副本是唯一一份,索引已还原成指向不存在的路径。现在逐个记录搬回结果:全部成功才删 bundle;任一失败就保留 bundle 并写 `ORPHANED.json`(记录 adapter、sessionId、失败映射),既不丢数据也不留无法解释的残留。
|
|
13
|
+
- **`restore` 从不写终态,审计一失败条目永久卡 `restoring`(中高)**:`'restored'` 只在 `PURGEABLE_STATUSES` 里出现过,没有任何写入点。恢复成功现在先提交 `status: 'restored'`(连同 `restoredAt`),再写审计;审计写失败不再把已完成的恢复报成失败,条目也不会再被重复 `catalog.restore`。内存仍有残留文件时保留条目,`memoryLeftOnDisk` 记入审计。
|
|
14
|
+
- **快照缓存被就地修改(中)**:`snapshotCache` 与工作副本同一引用,`exportSession` 直接 `delete nodes[...]` / `node[field] = kept`;中途失败后缓存仍是"已删空"的样子——预览显示 0 条记忆,重试删除则静默跳过真实存在的记忆。现在在私有克隆上修改,缓存始终描述磁盘上的真实内容。
|
|
15
|
+
- **三路全拒的死条目(中)**:日志头不可读且 memory 目录仍有文件时,restore 拒绝日志、discard 拒绝记忆、purge 拒绝状态,UI 没有任何按钮。`entryCapabilities` 新增 `canForceDiscard`,设置页对这类条目显示「强制清理(会丢弃记忆)」,仍要求输入 `DELETE`,审计记录 `discardedMemory: true`。`force` 只在这一种组合下被接受。
|
|
16
|
+
- **审计半行会连带吞掉下一条(中低)**:`append` 直接追加,崩溃留下的半行没有换行,下一条记录与它粘成一行坏 JSON。现在追加前检查末字节,缺换行先补一个。
|
|
17
|
+
- **记忆 bundle 路径没有围栏(中低)**:`restoreEntry` 直接按 manifest 的 `bundlePath` 读文件并 `rm -rf` 其目录;同 manifest 的 `from/to` 在 Host 侧是双向校验的。现在服务层接收 `bundleRoot`,越界即 `E_TRASH_CONFLICT`。
|
|
18
|
+
- **客户端 195s 永远短于宿主 240s(中)**:宿主的回收站操作预算 = 锁预算 180s + 队列宽限 60s,客户端却固定 195s,浏览器先 abort,UI 报超时而后台仍在跑。四类 `/trash/*` 现在读 `/state` 下发的 `limits.queueTimeoutMs`,常量只作冷启动兜底(并改为 245s)。
|
|
19
|
+
- **保留期清扫无视 `protectedSessionIds`(中)**:受保护会话过去只挡手动删除,定时清扫仍会把它的 hard 条目清掉。现在 hard/soft 都先判保护名单,命中记 `E_POLICY_PROTECTED` 并退避。
|
|
20
|
+
- **会话列表读失败让整个面板 500(中)**:`loadList` 对 `controller.list`/`persistence.list` 没有异常处理,一次 5s 超时会让 `/sessions`、preview、delete 预检全部失效。现在降级为空列表并 warn。
|
|
21
|
+
- **`preview` 透传记忆库绝对路径(低)**:`stores` 现在只保留 `id/label/available/reason`。
|
|
22
|
+
- **配置修正静默(低)**:`clampInt` 截断小数、`memory.adapters` 非数组回退,过去都不留 warning;现在都进 `/state.warnings`。
|
|
23
|
+
- **客户端细节**:补 `E_SESSION_ALIVE` 中英文案;回收站行 Restore/Purge/Discard 互斥,进行中的请求会禁用其它行内动作。
|
|
24
|
+
|
|
25
|
+
### Docs
|
|
26
|
+
|
|
27
|
+
- README 新增 §4.4 配置项全表(22 键、默认值与安全含义),补齐此前零提及的 9 个键。
|
|
28
|
+
- 统一 `allowNonLoopback` 的说法:打开它会跳过整段 Host 校验并使 `allowedHosts` 失效,暴露到 loopback 之外应只配 `allowedHosts`。
|
|
29
|
+
- 删除 README 中「`GET /trash` 会触发后台清扫」的过时描述(0.3.17 起 GET 已无副作用)。
|
|
30
|
+
- `config.schema.json` 的保留期描述、`paths.js` 的 Node 版本注释、README 的宿主版本(0.2.2-alpha.3)与 `CHANGELOG` 对齐。
|
|
31
|
+
- `package.json` 的 `files` 补入 `scripts/` 与 `test/`:README 与 INSTALL 指导用户运行 `npm test` 与 `node scripts/verify-host-contract.mjs`,此前从 npm 安装后两者都不存在。
|
|
32
|
+
|
|
33
|
+
### Tests
|
|
34
|
+
|
|
35
|
+
- `capabilities` 断言补 `canForceDiscard`;新增强制清理、restore 终态、审计半行、bundle 越界、`limits.queueTimeoutMs` 与错误码完整性用例。
|
|
36
|
+
|
|
6
37
|
## [0.3.17] — 2026-11-03
|
|
7
38
|
|
|
8
39
|
针对 **dsh 0.2.2-alpha.3** 的适配性评审(安全 / 架构 / 验收三方独立只读复审)后的修复。评审本身的结论是「接口层 0 阻断、运行态未装载、行为层零证据」;这一版把其中所有能在代码里闭合的项做完,**运行态装载与破坏性链路仍需一次重启后的实测**(见 `scripts/verify-host-contract.mjs`)。
|
|
@@ -134,7 +165,7 @@ dsh-go(Android)适配性审计后的修复。审计结论:Host 半边开
|
|
|
134
165
|
- **锁毒化(P1)**:`release()` 删不掉锁文件时,过去只 `warn` 一句就走人 —— 残留锁的 `owner.pid` 是**自己这个活进程**,而 stale 判定对活 owner 永不放行,于是下一次 `acquire()` 必定失败,之后每一次 mutation 都是 409,只能重启。现在 release 失败会把锁体降级成 `{pid: null, at: 0}`(并把 mtime 拨到纪元),下一次 acquire 按"无主且超龄"立即破锁;实在写不进去才留 warn。
|
|
135
166
|
- **PID 复用**:心跳每 5 秒刷新一次 mtime,所以"十拍未刷新"(`staleMs * 10`)就按残留锁处理并告警,不再出现"这个 pid 恰好被别的进程复用、于是永远破不了锁"。
|
|
136
167
|
- **队列超时不再撒谎**:计时器过去从入队一直武装到结束,操作其实已经开跑时仍返回"没有执行"。现在操作一开始就 `clearTimeout`,只有真正还在排队时才报 `E_QUEUE_TIMEOUT`。
|
|
137
|
-
- **客户端预算不再比宿主早 60 秒**:`/preview` 与 `/state` 的 `limits` 新增 `queueTimeoutMs`(宿主实际使用的队列预算),客户端优先用它;过去客户端只按锁预算推算,宿主的队列预算比它多 60 秒,于是"还在跑"
|
|
168
|
+
- **客户端预算不再比宿主早 60 秒**:`/preview` 与 `/state` 的 `limits` 新增 `queueTimeoutMs`(宿主实际使用的队列预算),客户端优先用它;过去客户端只按锁预算推算,宿主的队列预算比它多 60 秒,于是"还在跑"看起来像超时失败。(0.3.18 更正:当时只有 `/delete` 真正读取了该字段,四类 `/trash/*` 仍用固定 `195s`,比宿主 `240s` 预算短——见 0.3.18。)
|
|
138
169
|
- **`running` 不再被兜成 `false`**:`gateway.inspect()` 的 `running: fromList?.running ?? false` 让"宿主没说"和"已停止"无法区分,`lifecycle.js` 的 fail-closed 分支成了死代码。现在保留 `undefined`,并且 `probeRunning()` 能回答时以它为准(`agents.get(id)?.status === 'running'`)。
|
|
139
170
|
- **空闲会话又能删了**:硬删闸门过去看 `live`("宿主还知道这个会话"),空闲会话也满足,于是 `stopLiveSessions: false` 时所有空闲对话都删不掉。现在看 `session.running`;宿主不报状态时才按严格读法(未知视为在跑)。
|
|
140
171
|
- **`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.18`,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
|
@@ -128,6 +128,8 @@ window.__ModuleLoader__.load({
|
|
|
128
128
|
'trash.discard': 'Discard',
|
|
129
129
|
'trash.discarded': 'Entry removed.',
|
|
130
130
|
'trash.confirmDiscard': 'Type DELETE to discard this incomplete entry.',
|
|
131
|
+
'trash.discardForce': 'Discard anyway (drops memory)',
|
|
132
|
+
'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
133
|
'trash.status.pending': 'pending',
|
|
132
134
|
'trash.status.failed': 'failed',
|
|
133
135
|
'trash.status.restoring': 'restoring',
|
|
@@ -197,6 +199,7 @@ window.__ModuleLoader__.load({
|
|
|
197
199
|
'error.E_POLICY_LIVE': 'The conversation could not be stopped. Close it and retry.',
|
|
198
200
|
'error.E_POLICY_ORPHAN': 'The conversation is not owned by a registered workspace.',
|
|
199
201
|
'error.E_POLICY_PROTECTED': 'The conversation is protected by configuration.',
|
|
202
|
+
'error.E_SESSION_ALIVE': 'The conversation is live again (revived or running), so the recycle-bin entry was left untouched.',
|
|
200
203
|
'error.E_POLICY_SUBAGENT': 'Subagent conversations cannot be deleted.',
|
|
201
204
|
'error.E_POLICY_ROOT': 'The conversation is outside the allowed workspace roots.',
|
|
202
205
|
'error.E_RATE_LIMITED': 'Too many deletions; wait a moment and retry.',
|
|
@@ -291,6 +294,8 @@ window.__ModuleLoader__.load({
|
|
|
291
294
|
'trash.discard': '清理',
|
|
292
295
|
'trash.discarded': '条目已清理。',
|
|
293
296
|
'trash.confirmDiscard': '输入 DELETE 清理该未完成条目。',
|
|
297
|
+
'trash.discardForce': '强制清理(会丢弃记忆)',
|
|
298
|
+
'trash.confirmDiscardForce': '该条目中的会话日志已无法读取,永远无法恢复。输入 DELETE 会连同它仍保存的记忆一起删除。',
|
|
294
299
|
'trash.status.pending': '待处理',
|
|
295
300
|
'trash.status.failed': '失败',
|
|
296
301
|
'trash.status.restoring': '恢复中断',
|
|
@@ -360,6 +365,7 @@ window.__ModuleLoader__.load({
|
|
|
360
365
|
'error.E_POLICY_LIVE': '无法停止该对话,请关闭后重试。',
|
|
361
366
|
'error.E_POLICY_ORPHAN': '该对话不属于任何已登记工作区。',
|
|
362
367
|
'error.E_POLICY_PROTECTED': '该对话受配置保护。',
|
|
368
|
+
'error.E_SESSION_ALIVE': '该对话已重新活跃(被恢复或正在运行),回收站条目未做处理。',
|
|
363
369
|
'error.E_POLICY_SUBAGENT': '子代理对话不能删除。',
|
|
364
370
|
'error.E_POLICY_ROOT': '该对话不在允许的工作区根目录内。',
|
|
365
371
|
'error.E_RATE_LIMITED': '删除过于频繁,请稍后重试。',
|
|
@@ -491,15 +497,14 @@ window.__ModuleLoader__.load({
|
|
|
491
497
|
const REQUEST_TIMEOUT_MS = 15_000
|
|
492
498
|
const MUTATION_TIMEOUT_MS = 180_000
|
|
493
499
|
/**
|
|
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.
|
|
500
|
+
* Recycle-bin writes wait for the Host's cross-process lock, and the Host's
|
|
501
|
+
* own budget for that wait is `TRASH_LOCK_BUDGET_MS` (180s) plus the 60s
|
|
502
|
+
* queue grace, published on `/state` as `limits.queueTimeoutMs` (240s).
|
|
503
|
+
* This constant is only the fallback for the window before the first
|
|
504
|
+
* `/state` read lands; it used to be 195s, i.e. 45s SHORTER than the Host,
|
|
505
|
+
* so the browser aborted an operation the Host went on to complete.
|
|
501
506
|
*/
|
|
502
|
-
const TRASH_MUTATION_TIMEOUT_MS =
|
|
507
|
+
const TRASH_MUTATION_TIMEOUT_MS = 245_000
|
|
503
508
|
/**
|
|
504
509
|
* How long a delete may run before the dialog says what it is waiting for.
|
|
505
510
|
* A hard delete of a conversation written moments ago spends
|
|
@@ -1663,7 +1668,7 @@ window.__ModuleLoader__.load({
|
|
|
1663
1668
|
* log) gets a Restore button: `performDiscard` refuses exactly those
|
|
1664
1669
|
* entries, so without this the only way out was a manual API call.
|
|
1665
1670
|
*/
|
|
1666
|
-
function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null }) {
|
|
1671
|
+
function IncompleteEntries({ entries, discardTarget, discardPhrase, discardForce = false, onPhrase, onStart, onForceStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null }) {
|
|
1667
1672
|
return h('div', { className: 'dc-panel dc-incomplete' },
|
|
1668
1673
|
h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
|
|
1669
1674
|
h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
|
|
@@ -1679,6 +1684,14 @@ window.__ModuleLoader__.load({
|
|
|
1679
1684
|
const canDiscard = entry.capabilities === undefined
|
|
1680
1685
|
? true
|
|
1681
1686
|
: entry.capabilities?.canDiscard !== false
|
|
1687
|
+
// The one state where the log can never be restored (its header is
|
|
1688
|
+
// unreadable) but a memory bundle still occupies the folder: plain
|
|
1689
|
+
// discard refuses it, restore refuses the log, purge refuses the
|
|
1690
|
+
// status. Offering nothing left the entry on disk forever.
|
|
1691
|
+
const canForceDiscard = entry.capabilities?.canForceDiscard === true
|
|
1692
|
+
const openDiscard = canDiscard === true
|
|
1693
|
+
? typeof onStart === 'function' ? onStart : null
|
|
1694
|
+
: canForceDiscard && typeof onForceStart === 'function' ? onForceStart : null
|
|
1682
1695
|
return h('div', { key: entry.trashId, className: 'dc-row' },
|
|
1683
1696
|
h('div', { className: 'dc-row-main' },
|
|
1684
1697
|
h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId || entry.trashId),
|
|
@@ -1693,7 +1706,7 @@ window.__ModuleLoader__.load({
|
|
|
1693
1706
|
title: t('trash.restoreIncompleteHint'),
|
|
1694
1707
|
}, t('action.restore'))
|
|
1695
1708
|
: null,
|
|
1696
|
-
|
|
1709
|
+
typeof openDiscard !== 'function'
|
|
1697
1710
|
? null
|
|
1698
1711
|
: discardTarget === entry.trashId
|
|
1699
1712
|
? h('span', { className: 'dc-inline-confirm' },
|
|
@@ -1714,12 +1727,13 @@ window.__ModuleLoader__.load({
|
|
|
1714
1727
|
variant: 'danger',
|
|
1715
1728
|
disabled: discardBusyId === entry.trashId || phraseMatches(discardPhrase, 'DELETE') !== true,
|
|
1716
1729
|
onClick: () => onDiscard(entry.trashId),
|
|
1717
|
-
}, t('trash.discard')),
|
|
1730
|
+
}, discardForce === true ? t('trash.discardForce') : t('trash.discard')),
|
|
1718
1731
|
h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
|
|
1719
1732
|
: h(Button, {
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1733
|
+
variant: canDiscard === true ? undefined : 'danger',
|
|
1734
|
+
onClick: () => openDiscard(entry.trashId),
|
|
1735
|
+
title: canDiscard === true ? t('trash.confirmDiscard') : t('trash.confirmDiscardForce'),
|
|
1736
|
+
}, canDiscard === true ? t('trash.discard') : t('trash.discardForce')))
|
|
1723
1737
|
)
|
|
1724
1738
|
}))
|
|
1725
1739
|
}
|
|
@@ -1775,6 +1789,7 @@ window.__ModuleLoader__.load({
|
|
|
1775
1789
|
const [purgePhrase, setPurgePhrase] = useState('')
|
|
1776
1790
|
const [discardTarget, setDiscardTarget] = useState(null)
|
|
1777
1791
|
const [discardPhrase, setDiscardPhrase] = useState('')
|
|
1792
|
+
const [discardForce, setDiscardForce] = useState(false)
|
|
1778
1793
|
const [restoreBusyId, setRestoreBusyId] = useState(null)
|
|
1779
1794
|
const [purgeBusy, setPurgeBusy] = useState(false)
|
|
1780
1795
|
const [discardBusyId, setDiscardBusyId] = useState(null)
|
|
@@ -1866,14 +1881,26 @@ window.__ModuleLoader__.load({
|
|
|
1866
1881
|
for (const name of which) loadedRef.current[name] = -1
|
|
1867
1882
|
}, [])
|
|
1868
1883
|
|
|
1884
|
+
/**
|
|
1885
|
+
* The Host's published budget for one trash mutation (lock + queue grace).
|
|
1886
|
+
* Using the published number keeps the browser from aborting 60s before
|
|
1887
|
+
* the Host gives up; the constant is a cold-start fallback only.
|
|
1888
|
+
*/
|
|
1889
|
+
function trashTimeoutMs() {
|
|
1890
|
+
const published = Number(state?.limits?.queueTimeoutMs)
|
|
1891
|
+
return Number.isFinite(published) && published > 0 ? Math.max(1_000, published) : TRASH_MUTATION_TIMEOUT_MS
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1869
1894
|
async function restore(trashId) {
|
|
1895
|
+
if (restoreBusyId !== null) return
|
|
1896
|
+
setRestoreBusyId(trashId)
|
|
1870
1897
|
setError(null)
|
|
1871
1898
|
const entry = trash.find((item) => item.trashId === trashId)
|
|
1872
1899
|
try {
|
|
1873
1900
|
await api('/trash/restore', {
|
|
1874
1901
|
method: 'POST',
|
|
1875
1902
|
body: { trashId },
|
|
1876
|
-
timeoutMs:
|
|
1903
|
+
timeoutMs: trashTimeoutMs(),
|
|
1877
1904
|
})
|
|
1878
1905
|
flashStore.show(t('trash.restored'))
|
|
1879
1906
|
markStale('trash', 'batch')
|
|
@@ -1885,6 +1912,8 @@ window.__ModuleLoader__.load({
|
|
|
1885
1912
|
})
|
|
1886
1913
|
} catch (caught) {
|
|
1887
1914
|
setError(caught)
|
|
1915
|
+
} finally {
|
|
1916
|
+
setRestoreBusyId(null)
|
|
1888
1917
|
}
|
|
1889
1918
|
}
|
|
1890
1919
|
|
|
@@ -1901,7 +1930,7 @@ window.__ModuleLoader__.load({
|
|
|
1901
1930
|
await api('/trash/restore', {
|
|
1902
1931
|
method: 'POST',
|
|
1903
1932
|
body: { trashId },
|
|
1904
|
-
timeoutMs:
|
|
1933
|
+
timeoutMs: trashTimeoutMs(),
|
|
1905
1934
|
})
|
|
1906
1935
|
flashStore.show(t('trash.restored'))
|
|
1907
1936
|
markStale('trash', 'batch')
|
|
@@ -1929,7 +1958,7 @@ window.__ModuleLoader__.load({
|
|
|
1929
1958
|
await api('/trash/purge', {
|
|
1930
1959
|
method: 'POST',
|
|
1931
1960
|
body: { trashId, confirmPhrase: phrase },
|
|
1932
|
-
timeoutMs:
|
|
1961
|
+
timeoutMs: trashTimeoutMs(),
|
|
1933
1962
|
})
|
|
1934
1963
|
flashStore.show(t('trash.purged'))
|
|
1935
1964
|
setPurgeTarget(null)
|
|
@@ -1949,15 +1978,15 @@ window.__ModuleLoader__.load({
|
|
|
1949
1978
|
}
|
|
1950
1979
|
}
|
|
1951
1980
|
|
|
1952
|
-
async function discard(trashId, phrase) {
|
|
1981
|
+
async function discard(trashId, phrase, force = false) {
|
|
1953
1982
|
if (discardBusyId !== null) return
|
|
1954
1983
|
setDiscardBusyId(trashId)
|
|
1955
1984
|
setError(null)
|
|
1956
1985
|
try {
|
|
1957
1986
|
await api('/trash/discard', {
|
|
1958
1987
|
method: 'POST',
|
|
1959
|
-
body: { trashId, confirmPhrase: phrase },
|
|
1960
|
-
timeoutMs:
|
|
1988
|
+
body: { trashId, confirmPhrase: phrase, force: force === true },
|
|
1989
|
+
timeoutMs: trashTimeoutMs(),
|
|
1961
1990
|
})
|
|
1962
1991
|
flashStore.show(t('trash.discarded'))
|
|
1963
1992
|
setDiscardTarget(null)
|
|
@@ -1989,6 +2018,10 @@ window.__ModuleLoader__.load({
|
|
|
1989
2018
|
// banner and the per-row hint come from `retentionInfo`, which reads the
|
|
1990
2019
|
// field where the Host actually sends it (`limits`).
|
|
1991
2020
|
const retentionDays = retentionInfo(state).days
|
|
2021
|
+
// One trash mutation at a time: the Host serializes them anyway, so a
|
|
2022
|
+
// second click only queued another request behind the first and its error
|
|
2023
|
+
// overwrote the first one's success flash.
|
|
2024
|
+
const trashActionBusy = restoreBusyId !== null || purgeBusy === true || discardBusyId !== null
|
|
1992
2025
|
const expiryHint = (entry) => {
|
|
1993
2026
|
const left = retentionInfo(state, entry).daysLeft
|
|
1994
2027
|
if (left === null) return ''
|
|
@@ -2072,7 +2105,7 @@ window.__ModuleLoader__.load({
|
|
|
2072
2105
|
// The Host already knows whether this entry can be restored;
|
|
2073
2106
|
// the panel used to offer the button and then show the refusal.
|
|
2074
2107
|
h(Button, {
|
|
2075
|
-
disabled: entry.capabilities?.canRestore === false,
|
|
2108
|
+
disabled: entry.capabilities?.canRestore === false || trashActionBusy,
|
|
2076
2109
|
title: entry.capabilities?.canRestore === false ? t('error.E_TRASH_CONFLICT') : t('action.restore'),
|
|
2077
2110
|
onClick: () => { void restore(entry.trashId) },
|
|
2078
2111
|
}, t('action.restore')),
|
|
@@ -2093,7 +2126,7 @@ window.__ModuleLoader__.load({
|
|
|
2093
2126
|
h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
|
|
2094
2127
|
: h(Button, {
|
|
2095
2128
|
variant: 'danger',
|
|
2096
|
-
disabled: entry.capabilities?.canPurge === false,
|
|
2129
|
+
disabled: entry.capabilities?.canPurge === false || trashActionBusy,
|
|
2097
2130
|
title: entry.capabilities?.canPurge === false ? t('error.E_TRASH_CONFLICT') : t('action.purge'),
|
|
2098
2131
|
onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') },
|
|
2099
2132
|
}, t('action.purge')))))) : null,
|
|
@@ -2102,10 +2135,12 @@ window.__ModuleLoader__.load({
|
|
|
2102
2135
|
entries: incomplete,
|
|
2103
2136
|
discardTarget,
|
|
2104
2137
|
discardPhrase,
|
|
2138
|
+
discardForce,
|
|
2105
2139
|
onPhrase: setDiscardPhrase,
|
|
2106
|
-
onStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase('') },
|
|
2107
|
-
|
|
2108
|
-
|
|
2140
|
+
onStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase(''); setDiscardForce(false) },
|
|
2141
|
+
onForceStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase(''); setDiscardForce(true) },
|
|
2142
|
+
onCancel: () => { setDiscardTarget(null); setDiscardPhrase(''); setDiscardForce(false) },
|
|
2143
|
+
onDiscard: (trashId) => { void discard(trashId, discardPhrase.trim(), discardForce) },
|
|
2109
2144
|
onRestore: (trashId) => { void restoreIncomplete(trashId) },
|
|
2110
2145
|
restoreBusyId,
|
|
2111
2146
|
discardBusyId,
|
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 },
|
package/lib/core/audit.js
CHANGED
|
@@ -57,6 +57,34 @@ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retention
|
|
|
57
57
|
await fsApi.rename(temporary, filePath)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A crash between `write` and `write` can leave the file ending in a partial
|
|
62
|
+
* line. Appending `line\n` on top of that fragment glues the fragment to the
|
|
63
|
+
* new record and BOTH become unparseable, so one torn tail used to cost the
|
|
64
|
+
* next record too. Terminate the fragment first: it is already unreadable, but
|
|
65
|
+
* it stays isolated on its own line.
|
|
66
|
+
*/
|
|
67
|
+
async function ensureTrailingNewline() {
|
|
68
|
+
let handle
|
|
69
|
+
try {
|
|
70
|
+
handle = await fsApi.open(filePath, 'r')
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.code === 'ENOENT') return
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const { size } = await handle.stat()
|
|
77
|
+
if (size === 0) return
|
|
78
|
+
const last = Buffer.alloc(1)
|
|
79
|
+
const read = await handle.read(last, 0, 1, size - 1)
|
|
80
|
+
if (read.bytesRead === 1 && last[0] !== 0x0a) {
|
|
81
|
+
await fsApi.appendFile(filePath, '\n', 'utf8')
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
await handle.close()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
return {
|
|
61
89
|
filePath,
|
|
62
90
|
|
|
@@ -71,6 +99,7 @@ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retention
|
|
|
71
99
|
const line = JSON.stringify({ ...record, at: record.at ?? now() })
|
|
72
100
|
await fsApi.mkdir(dirname(filePath), { recursive: true })
|
|
73
101
|
await compactIfNeeded(Buffer.byteLength(line, 'utf8') + 1)
|
|
102
|
+
await ensureTrailingNewline()
|
|
74
103
|
await fsApi.appendFile(filePath, `${line}\n`, 'utf8')
|
|
75
104
|
return line
|
|
76
105
|
})
|
package/lib/core/cascade.js
CHANGED
|
@@ -321,6 +321,11 @@ export function entryCapabilities(entry, disk = {}) {
|
|
|
321
321
|
// exception is a log whose header cannot be read — nothing can use it any
|
|
322
322
|
// more, so discarding is the only way to get the disk space back.
|
|
323
323
|
canDiscard: status !== 'trashed' && hasMemoryFiles !== true && (unrecoverable || hasLogFiles !== true),
|
|
324
|
+
// A log nobody can read AND a memory bundle still on disk used to lock the
|
|
325
|
+
// entry out of all three actions: restore refused the log, discard refused
|
|
326
|
+
// the memory, purge refused the status. This flag marks the one state where
|
|
327
|
+
// a forced discard (explicit, phrase-confirmed) is the remaining exit.
|
|
328
|
+
canForceDiscard: status !== 'trashed' && hasMemoryFiles === true && unrecoverable === true,
|
|
324
329
|
}
|
|
325
330
|
}
|
|
326
331
|
|
|
@@ -495,18 +500,6 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
|
|
|
495
500
|
throw toDeleteError(result, { sessionId: entry.sessionId, title: entry.title })
|
|
496
501
|
}
|
|
497
502
|
|
|
498
|
-
await audit.append({
|
|
499
|
-
id: `${entry.trashId}:restore`,
|
|
500
|
-
action: 'restore',
|
|
501
|
-
result: 'success',
|
|
502
|
-
sessionId: entry.sessionId,
|
|
503
|
-
title: entry.title,
|
|
504
|
-
trashId: entry.trashId,
|
|
505
|
-
actor,
|
|
506
|
-
requestId,
|
|
507
|
-
durationMs: now() - startedAt,
|
|
508
|
-
source,
|
|
509
|
-
})
|
|
510
503
|
// Only clear the entry directory once its memory bundles are actually gone.
|
|
511
504
|
// `restoreEntry` removes the bundles it consumed; if memory files are still
|
|
512
505
|
// there (an adapter was uninstalled, a bundle was unreadable) removing the
|
|
@@ -514,7 +507,33 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
|
|
|
514
507
|
const memoryFilesLeft = typeof ops.hasFiles === 'function' && typeof trash?.entryDir === 'function'
|
|
515
508
|
? await ops.hasFiles(join(trash.entryDir(entry.trashId), 'memory')).catch(() => true)
|
|
516
509
|
: false
|
|
517
|
-
|
|
510
|
+
// Commit the terminal state BEFORE the audit write. `restored` used to be
|
|
511
|
+
// declared in PURGEABLE_STATUSES but never written, so a successful restore
|
|
512
|
+
// left the manifest in `restoring`: the row could be restored again (a second
|
|
513
|
+
// catalog.restore) and could never be purged. A failed audit write used to
|
|
514
|
+
// keep it there as well — the data was already back on disk.
|
|
515
|
+
if (memoryFilesLeft) {
|
|
516
|
+
await trash.update(entry.trashId, { status: 'restored', restoredAt: now() }).catch(() => {})
|
|
517
|
+
} else {
|
|
518
|
+
await trash.remove(entry.trashId).catch(() => {})
|
|
519
|
+
}
|
|
520
|
+
await audit
|
|
521
|
+
.append({
|
|
522
|
+
id: `${entry.trashId}:restore`,
|
|
523
|
+
action: 'restore',
|
|
524
|
+
result: 'success',
|
|
525
|
+
sessionId: entry.sessionId,
|
|
526
|
+
title: entry.title,
|
|
527
|
+
trashId: entry.trashId,
|
|
528
|
+
memoryLeftOnDisk: memoryFilesLeft,
|
|
529
|
+
actor,
|
|
530
|
+
requestId,
|
|
531
|
+
durationMs: now() - startedAt,
|
|
532
|
+
source,
|
|
533
|
+
})
|
|
534
|
+
// The restore is already committed; reporting an audit-only failure as a
|
|
535
|
+
// failed restore would invite a retry that the `restored` state now refuses.
|
|
536
|
+
.catch(() => {})
|
|
518
537
|
return { trashId: entry.trashId, sessionId: entry.sessionId, memoryLeftOnDisk: memoryFilesLeft }
|
|
519
538
|
}
|
|
520
539
|
|
|
@@ -675,8 +694,10 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
|
|
|
675
694
|
* @param {() => number} [input.now]
|
|
676
695
|
* @param {string} [input.actor]
|
|
677
696
|
* @param {string} [input.requestId]
|
|
697
|
+
* @param {boolean} [input.force] allow discarding an unreadable log together with
|
|
698
|
+
* its memory bundle (see `entryCapabilities().canForceDiscard`)
|
|
678
699
|
*/
|
|
679
|
-
export async function performDiscard({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
|
|
700
|
+
export async function performDiscard({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null, force = false }) {
|
|
680
701
|
if (entry.status === 'trashed') {
|
|
681
702
|
throw new DeleteError('E_TRASH_CONFLICT', 'This entry is a committed deletion; restore or purge it instead of discarding it.', {
|
|
682
703
|
trashId: entry.trashId,
|
|
@@ -719,11 +740,19 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
|
|
|
719
740
|
payloadRecoverable: logUnrecoverable ? false : null,
|
|
720
741
|
})
|
|
721
742
|
if (holdsMemory || !capabilities.canDiscard) {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
743
|
+
// The exit for the all-refused state: a log whose header cannot be read can
|
|
744
|
+
// never be restored, and its memory bundle therefore cannot be restored
|
|
745
|
+
// either. Refusing `force` outside exactly that combination keeps this from
|
|
746
|
+
// becoming a generic "skip the safety check" switch.
|
|
747
|
+
const forceAllowed = force === true && logUnrecoverable === true
|
|
748
|
+
if (!forceAllowed) {
|
|
749
|
+
const which = holdsMemory ? 'memory files' : 'payload'
|
|
750
|
+
throw new DeleteError('E_TRASH_CONFLICT', `This incomplete entry still holds ${which}; restore or purge it instead of discarding it.`, {
|
|
751
|
+
trashId: entry.trashId,
|
|
752
|
+
status: entry.status,
|
|
753
|
+
forceable: capabilities.canForceDiscard === true,
|
|
754
|
+
})
|
|
755
|
+
}
|
|
727
756
|
}
|
|
728
757
|
const startedAt = now()
|
|
729
758
|
await trash.remove(entry.trashId)
|
|
@@ -736,6 +765,9 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
|
|
|
736
765
|
trashId: entry.trashId,
|
|
737
766
|
status: entry.status,
|
|
738
767
|
title: null,
|
|
768
|
+
// Record that the forced path destroyed a memory bundle the caller could
|
|
769
|
+
// not restore, so the audit trail explains an otherwise silent loss.
|
|
770
|
+
discardedMemory: force === true && holdsMemory === true,
|
|
739
771
|
actor,
|
|
740
772
|
requestId,
|
|
741
773
|
durationMs: now() - startedAt,
|
package/lib/core/config.js
CHANGED
|
@@ -112,6 +112,12 @@ function clampInt(raw, fallback, min, max, field, warnings) {
|
|
|
112
112
|
return fallback
|
|
113
113
|
}
|
|
114
114
|
const rounded = Math.round(number)
|
|
115
|
+
// A fractional value is a config mistake with a silent consequence: 0.4 days
|
|
116
|
+
// rounds to 0, and 0 is the documented "keep forever" spelling. Report the
|
|
117
|
+
// rounding instead of accepting a value that means something else.
|
|
118
|
+
if (rounded !== number) {
|
|
119
|
+
warnings.push(`${field}: ${number} is not an integer, rounded to ${rounded}`)
|
|
120
|
+
}
|
|
115
121
|
if (rounded < min || rounded > max) {
|
|
116
122
|
warnings.push(`${field}: ${rounded} outside [${min}, ${max}], clamped`)
|
|
117
123
|
return Math.min(max, Math.max(min, rounded))
|
|
@@ -242,13 +248,25 @@ export function normalizeConfig(raw) {
|
|
|
242
248
|
warnings.push(`defaultMode: expected soft|hard, using ${DEFAULTS.defaultMode}`)
|
|
243
249
|
}
|
|
244
250
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (Array.isArray(memoryRaw.adapters)
|
|
249
|
-
|
|
251
|
+
let adapters
|
|
252
|
+
if (memoryRaw.adapters === undefined) {
|
|
253
|
+
adapters = [...DEFAULTS.memory.adapters]
|
|
254
|
+
} else if (!Array.isArray(memoryRaw.adapters)) {
|
|
255
|
+
// A string here used to fall back to the default silently, so a typo such
|
|
256
|
+
// as `adapters: "lingshu"` left the operator believing only that adapter
|
|
257
|
+
// was in play.
|
|
258
|
+
warnings.push('memory.adapters: expected an array of adapter ids, using the default')
|
|
259
|
+
adapters = [...DEFAULTS.memory.adapters]
|
|
260
|
+
} else {
|
|
261
|
+
adapters = memoryRaw.adapters.filter((id) => ADAPTER_IDS.has(id))
|
|
262
|
+
if (adapters.length !== memoryRaw.adapters.length) {
|
|
263
|
+
warnings.push('memory.adapters: unsupported adapter ids were dropped')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (adapters.length === 0) {
|
|
267
|
+
warnings.push('memory.adapters: no usable adapter, using none')
|
|
268
|
+
adapters.push('none')
|
|
250
269
|
}
|
|
251
|
-
if (adapters.length === 0) adapters.push('none')
|
|
252
270
|
|
|
253
271
|
const deleteMode = MEMORY_DELETE_MODES.has(memoryRaw.deleteMode)
|
|
254
272
|
? memoryRaw.deleteMode
|