dsh-conversation-recycle-bin 0.3.18 → 0.3.20

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 CHANGED
@@ -3,6 +3,66 @@
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.20] — 2026-11-09
7
+
8
+ 一次只改工程效率、不改删除语义的整理:把测试墙钟从 **60.5s 压到 4.6s**,堵住「跑过测试再打包」会把本机测试残留塞进 npm 包的路径,删掉三处零引用导出,并把两个确认前的**读**循环改成有界并发。生产路径的锁预算、超时与策略判定全部不变。
9
+
10
+ ### Changed
11
+
12
+ - **`/delete` 的锁预算改为可注入、且与 `preview` 共用一份计算**(`PLAN_LOCK_BUDGET` + `planLockBudgetMs()`):这两处过去是同一算式抄了两份(`preview` 发布 `limits.planTimeoutMs`,`/delete` 用它当跨进程锁超时),一处调参另一处很容易漏。生产默认值不变(15s 基数 + 每会话 16s + 每个 live 会话一个 drain 窗口,上限 900s);测试可传 `planBudget` 把预算降到毫秒级。
13
+ - **`preview` / `deletePlan` 的预检改为有界并发(8)**:`inspect()` 只读(persistence stat + 投影 + 缓存列表),50 项批量过去要串行付 50 次往返。**执行**循环仍是严格串行——只有读并发。
14
+ - **`gateway.loadList()` 加单飞 + 代际号**:预检并发后,冷启动时多个 `inspect()` 会同时触发列表构建;现在只有第一个真正构建,其余复用同一个 Promise。`invalidate()` 会递增代际并丢弃在飞的构建结果,保证「1.5s 缓存窗口内 pin 后删除」的既有修复不因并发回退。
15
+ - **`/sessions` 去掉第二趟投影读**:`enrichTitles()` 会对「标题仍为空」的行再读一遍 `buildList()` 刚读过的同一批投影文件,且只补前 80 行。`createdAt` 现在由 `buildList()` 在唯一那次读里补齐,`enrichTitles()` 整个删除。
16
+ - **`files` 收紧为 `test/*.mjs`**:过去是整个 `test/` 目录,而 npm 的 `files` 白名单优先于 `.gitignore`,于是 `npm test` 之后 `npm pack` 会把 `test/.tmp/` 里的会话日志、记忆索引和 `mutation-busy-*.lock` 打进包——同一个 commit 打两次包 shasum 不同。新增 `scripts/clean-tmp.mjs`(`prepack` 钩子)作为第二道防线,失败只告警不阻断发布。
17
+ - **`config.schema.json` / 语义不变**:本版没有新增或改动任何配置键。
18
+
19
+ ### Removed
20
+
21
+ | 符号 | 位置 | 状态 |
22
+ |---|---|---|
23
+ | `ok()` | `lib/core/errors.js` | 零引用(`toWireError` 才是失败态唯一出口) |
24
+ | `defaultConfig()` | `lib/core/config.js` | 零引用;连带删除只被它使用的 `clone()` |
25
+ | `writeJsonAtomic()` | `lib/host/paths.js` | 零引用;连带从 import 里去掉 `writeFile` |
26
+ | `enrichTitles()` | `lib/host/gateway.js` | 功能已并入 `buildList()`,见上 |
27
+
28
+ ### Added
29
+
30
+ - **`.github/workflows/ci.yml`**:此前仓库没有任何 CI,所有审计都是手工 `npm test`。现在 push/PR 会在 ubuntu + windows × Node 22.15/24 上跑 `npm run check`、`npm test`,并额外断言打包产物不含 `test/.tmp`。包无任何依赖,因此没有安装步骤。
31
+
32
+ ### Tests
33
+
34
+ - 用例总数不变(**221**,10 个文件),`api.test.mjs` 70/70 全过。
35
+ - **全量墙钟 60.5s → 4.6s;`api.test.mjs` 59.3s → 3.5s**,来自两处固定等待被消除:
36
+ - 「busy cross-process lock」用例过去被 `/delete` 的 33s 预算卡住(用例自己注入的 60ms `timeoutMs` 被路由参数覆盖)→ 现在注入 `planBudget` + `liveDrainMs: 0`,压到毫秒级;
37
+ - fixture 默认 `foreignWriterGraceMs: 0`,9 个 hard-delete 用例不再各付 1.5s 固定宽限。真正验证该窗口的两条用例仍直接构造 `createSessionLogOps({ foreignWriterGraceMs: 120/40, delay })`,语义未被稀释。
38
+ - README 的三处用例数(188 / 200 / 200)统一为实际的 **221**。
39
+
40
+ ## [0.3.19] — 2026-11-05
41
+
42
+ 平台审计(架构 / 安全 / 验收三方独立复审 v0.3.18)之后的修复。三方从不同角度撞到同一处:**记忆残留的条目在 UI/API 上没有任何出口**;另有一条把瞬时读失败当永久不可恢复。
43
+
44
+ ### Fixed
45
+
46
+ - **失败导出的残留变成可恢复**(架构 P2 / 验收 R-21-01):`bundle.json` 过去只在整个搬移循环结束后才写,若在此之前失败且补偿搬回也失败,目录只剩 `files/` 与 `ORPHANED.json` —— `readExported` 只认 bundle,restore 无物可读、discard 拒绝文件、force 又要求"日志不可读",条目永久留盘。现在补偿决定保留时**同时写一份 rescue bundle**(用仍留在 `files/` 的条目重建),让恢复重新有路。
47
+ - **索引已有节点、内容仍在 bundle 时不再拒绝恢复**(同一根因的另一半):失败导出会把索引还原成 pristine(节点还在),旧逻辑看到"节点已存在"就直接 `E_TRASH_CONFLICT`;现在若内容文件仍在 bundle 里,就把它搬回并视为已恢复。
48
+ - **`restored` 残留条目有了 purge 入口**(架构 ARCH-02 / 验收 R-21-02):恢复成功但 `memory/` 仍有文件时,条目落在"未完成条目"面板,Restore/Discard/Force 全不可用,而 API 的 purge 其实允许。`IncompleteEntries` 现在对 `canPurge === true` 渲染 purge(沿用 `DELETE` 短语确认)。
49
+ - **瞬时读失败不再被当成永久不可恢复**(安全 S-01):`recoverSessionLog`/`readSessionLogCwd` 过去把所有错误吞成 `null`,于是 EACCES/EBUSY(杀软、备份锁)也会让列表隐藏 Restore 并提供"强制清理(会丢弃记忆)"。现在只有 `ENOENT`/`ENOTDIR` 与解析失败算"确实读不出",`EACCES`/`EPERM`/`EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN` 一律按瞬时处理:能力位保持 `canRestore`、不给 force,恢复时提示"稍后重试"。
50
+ - **stranded 记忆成为第三种 force 场景**(架构 ARCH-01 的出口):`entryCapabilities` 新增 `strandedMemory`,与"日志不可读"同级允许 `canForceDiscard`(仍需 `DELETE` 短语)。
51
+
52
+ ### Changed
53
+
54
+ - **内联确认框改为订阅 seat 状态**:`overlaySeatMounted` 布尔换成 `overlaySeatStore`(订阅式),`DialogHost` 挂载即发布,面板同帧让位;不再依赖 250ms 轮询,消除了双渲染窗口(架构 ARCH-06 / 安全 S-03)。
55
+ - **`lastSweep` 显示相对时间**:"Host 已在后台自动清理 N 条"补上 `{when}`(刚刚 / N 分钟前 / N 小时前 / N 天前),避免把 6 小时前的结果当成刚刚发生。
56
+ - `audit.append` 的文档补上契约:**调用方必须已持有跨进程 mutation 锁**(`serialize()` 只排本进程的顺序,补换行读末字节与另一 Host 的追加会交错)。
57
+
58
+ ### Tests
59
+
60
+ - `test/cascade.test.mjs`(30 → 32):stranded 记忆开启 force 出口 / 无 stranded 时不开启;force 在 stranded 下被接受、无 stranded 时拒绝且 `forceable` 不置位;瞬时 `EBUSY` 时 restore 提示重试、discard 不把 force 打开。
61
+ - `test/memory.test.mjs`(18 → 19):导出失败 + 补偿搬回失败 → 留下可用的 rescue bundle,且 `restoreBundle` 能把内容搬回。
62
+ - `test/client-module.test.mjs`(29 → 31):`canPurge` 时渲染 purge(无该能力时不渲染任何破坏性动作);`formatAgo` 的相对时间文案。
63
+ - `test/api.test.mjs`(68 → 70):Origin 栅栏矩阵(127/localhost 混用 403、缺 Host 403、`dsh-app://app` 豁免、跨站拒绝);端到端验证 stranded 条目被列出为 `strandedMemory` 且 `force:true` 的 discard 被接受(不带 force 仍 409)。
64
+ - 合计 213 → **221**。
65
+
6
66
  ## [0.3.18] — 2026-11-04
7
67
 
8
68
  一次针对「失败之后再失败」链路的修补,加上客户端与宿主队列预算的统一。0.3.17 的评审结论是正常路径可信、错误处理链路第二层未被同等对待;这一版把 4 条可能导致数据不可逆丢失的路径、8 条一致性/健壮性缺口和文档漂移全部闭合。
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
4
4
 
5
- > 状态:`0.3.18`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
5
+ > 状态:`0.3.20`,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)。
@@ -236,7 +236,7 @@ dsh-conversation-recycle-bin/
236
236
  │ ├─ memory.js # 适配器装配
237
237
  │ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
238
238
  │ └─ api.js # 同源 HTTP API、确认 token、限流
239
- └─ test/ # 188 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
239
+ └─ test/ # 221 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
240
240
  ```
241
241
 
242
242
  ---
@@ -245,7 +245,7 @@ dsh-conversation-recycle-bin/
245
245
 
246
246
  ```powershell
247
247
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
248
- npm test # 依次跑 test/*.test.mjs(10 个文件、200 个用例);任一失败仍会跑完全部再汇总
248
+ npm test # 依次跑 test/*.test.mjs(10 个文件、221 个用例);任一失败仍会跑完全部再汇总
249
249
  npm run check # 语法 + manifest
250
250
  node scripts/verify-host-contract.mjs http://127.0.0.1:3082 --token=<token> # 宿主已启动时的运行时探针(boot 图 + /api/state)
251
251
  ```
@@ -362,7 +362,7 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
362
362
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
363
363
  npm run check # 语法 + manifest(含 feedback.js)
364
364
  node test/feedback.test.mjs # 20 个反馈用例
365
- npm test # 全量 200 个用例
365
+ npm test # 全量 221 个用例
366
366
 
367
367
  # 端到端:用本机收件桩观察真实投递路径,不外发邮件
368
368
  $env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
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,6 +130,7 @@ 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.',
@@ -282,6 +288,11 @@ window.__ModuleLoader__.load({
282
288
  'trash.expiresIn': '{days} 天后自动清理',
283
289
  'trash.expiresSoon': '下次检查时自动清理',
284
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} 天前',
285
296
  'trash.modeSoft': '软删除',
286
297
  'trash.modeHard': '彻底',
287
298
  'trash.items': '{sessions} 个对话 · {memories} 条记忆',
@@ -291,6 +302,7 @@ window.__ModuleLoader__.load({
291
302
  'trash.incompleteTitle': '未完成条目',
292
303
  'trash.incompleteHint': '这些条目没有提交成功,可直接清理以释放空间;仍保存着会话日志的条目请先恢复或彻底删除。',
293
304
  'trash.restoreIncompleteHint': '把会话日志放回原位,并重新挂回它所属的工作区。',
305
+ 'trash.purgeIncompleteHint': '彻底删除该条目及其中残留的内容,需要输入 DELETE 确认。',
294
306
  'trash.discard': '清理',
295
307
  'trash.discarded': '条目已清理。',
296
308
  'trash.confirmDiscard': '输入 DELETE 清理该未完成条目。',
@@ -704,34 +716,28 @@ window.__ModuleLoader__.load({
704
716
  * only confirmation dialog the delete flow had. Settings still gets a page
705
717
  * island, so the panel can render that same dialog inline instead of
706
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.
707
722
  */
708
- let overlaySeatMounted = false
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
+ }
709
737
 
710
738
  function useOverlaySeatPresent() {
711
- const [present, setPresent] = useState(overlaySeatMounted === true)
712
- useEffect(() => {
713
- if (overlaySeatMounted === true) {
714
- setPresent(true)
715
- return undefined
716
- }
717
- // The overlay host mounts with the shell, but nothing guarantees it is
718
- // already there when this panel first renders: poll briefly so a late
719
- // mount still wins over the inline copy instead of rendering both.
720
- let timer = null
721
- const tick = () => {
722
- if (overlaySeatMounted !== true) return
723
- if (timer !== null) clearInterval(timer)
724
- setPresent(true)
725
- }
726
- timer = setInterval(tick, 250)
727
- const stop = setTimeout(() => {
728
- if (timer !== null) clearInterval(timer)
729
- }, 10_000)
730
- return () => {
731
- if (timer !== null) clearInterval(timer)
732
- clearTimeout(stop)
733
- }
734
- }, [])
739
+ const [present, setPresent] = useState(overlaySeatStore.mounted)
740
+ useEffect(() => overlaySeatStore.subscribe(setPresent), [])
735
741
  return present
736
742
  }
737
743
 
@@ -1028,6 +1034,25 @@ window.__ModuleLoader__.load({
1028
1034
  }
1029
1035
  }
1030
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
+
1031
1056
  /**
1032
1057
  * The recycle-bin retention window, read from the Host payload.
1033
1058
  *
@@ -1588,9 +1613,11 @@ window.__ModuleLoader__.load({
1588
1613
  const payload = useDialogState()
1589
1614
  const flash = useFlashMessage()
1590
1615
  useEffect(() => {
1591
- overlaySeatMounted = true
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)
1592
1619
  return () => {
1593
- overlaySeatMounted = false
1620
+ overlaySeatStore.set(false)
1594
1621
  }
1595
1622
  }, [])
1596
1623
  // `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
@@ -1668,7 +1695,7 @@ window.__ModuleLoader__.load({
1668
1695
  * log) gets a Restore button: `performDiscard` refuses exactly those
1669
1696
  * entries, so without this the only way out was a manual API call.
1670
1697
  */
1671
- function IncompleteEntries({ entries, discardTarget, discardPhrase, discardForce = false, onPhrase, onStart, onForceStart, 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 }) {
1672
1699
  return h('div', { className: 'dc-panel dc-incomplete' },
1673
1700
  h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
1674
1701
  h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
@@ -1706,6 +1733,34 @@ window.__ModuleLoader__.load({
1706
1733
  title: t('trash.restoreIncompleteHint'),
1707
1734
  }, t('action.restore'))
1708
1735
  : null,
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,
1709
1764
  typeof openDiscard !== 'function'
1710
1765
  ? null
1711
1766
  : discardTarget === entry.trashId
@@ -2093,7 +2148,9 @@ window.__ModuleLoader__.load({
2093
2148
  tab === 'trash' ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
2094
2149
  retentionDays > 0 ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.retention', { days: retentionDays })) : null,
2095
2150
  Number(lastSweep?.purged) > 0
2096
- ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.swept', { count: Number(lastSweep.purged) }))
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) }))
2097
2154
  : null,
2098
2155
  trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
2099
2156
  ...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
@@ -2144,6 +2201,13 @@ window.__ModuleLoader__.load({
2144
2201
  onRestore: (trashId) => { void restoreIncomplete(trashId) },
2145
2202
  restoreBusyId,
2146
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()) },
2147
2211
  }) : null,
2148
2212
 
2149
2213
  tab === 'batch' ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
@@ -2404,7 +2468,8 @@ window.__ModuleLoader__.load({
2404
2468
  // guard changes the outcome.
2405
2469
  exports.__phraseMatches = phraseMatches
2406
2470
  exports.__httpFallbackError = httpFallbackError
2407
- exports.__overlaySeatState = () => ({ mounted: overlaySeatMounted })
2471
+ exports.__overlaySeatState = () => ({ mounted: overlaySeatStore.mounted })
2472
+ exports.__formatAgo = formatAgo
2408
2473
  return module.exports
2409
2474
  },
2410
2475
  })
package/lib/core/audit.js CHANGED
@@ -93,6 +93,15 @@ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retention
93
93
  * deletion whose audit write fails must be reported as a failed operation,
94
94
  * because "traceable" is part of the contract. Callers run the audit write
95
95
  * inside the same transaction step so a failure rolls the deletion back.
96
+ *
97
+ * CONTRACT: every caller must already hold the cross-process mutation lock
98
+ * (`<dataDir>/mutation.lock`). `serialize()` only orders appends inside THIS
99
+ * process; the trailing-newline repair below reads the last byte and then
100
+ * appends, which interleaves with a second Host (desktop + `dsh web` share
101
+ * one DSH_HOME) unless that lock is held. Every current call site (delete,
102
+ * restore, purge, discard, retention sweep) is inside it — a new caller that
103
+ * is not must take the lock first, or its record can be glued to another
104
+ * process's torn tail line.
96
105
  */
97
106
  append(record) {
98
107
  return serialize(async () => {
@@ -303,6 +303,10 @@ export function entryCapabilities(entry, disk = {}) {
303
303
  // Unreadable log ≠ restorable log. Offering restore here produced a button
304
304
  // that always failed with "discard it", while discard itself refused.
305
305
  const unrecoverable = disk.payloadRecoverable === false
306
+ // `memory/<adapter>/` holds files but no readable bundle: the export died
307
+ // before it could write one. Nothing can restore that adapter, so the entry
308
+ // needs the same explicit exit as an unreadable log.
309
+ const stranded = disk.strandedMemory === true
306
310
  const hasSessionLog = entry?.sessionLog !== null && entry?.sessionLog !== undefined
307
311
  const hasCatalog = entry?.catalog !== null && entry?.catalog !== undefined
308
312
  const memoryTotal = typeof entry?.memory?.total === 'number' ? entry.memory.total : 0
@@ -325,7 +329,9 @@ export function entryCapabilities(entry, disk = {}) {
325
329
  // entry out of all three actions: restore refused the log, discard refused
326
330
  // the memory, purge refused the status. This flag marks the one state where
327
331
  // a forced discard (explicit, phrase-confirmed) is the remaining exit.
328
- canForceDiscard: status !== 'trashed' && hasMemoryFiles === true && unrecoverable === true,
332
+ canForceDiscard: status !== 'trashed' && hasMemoryFiles === true && (unrecoverable === true || stranded === true),
333
+ // Surfaced so the panel can explain WHY the forced exit is offered.
334
+ strandedMemory: stranded,
329
335
  }
330
336
  }
331
337
 
@@ -360,7 +366,17 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
360
366
  // "nothing left to restore" (the discard route refuses that folder too, so
361
367
  // without this the only copy was stranded).
362
368
  if (sessionLog === null && typeof ops.recoverSessionLog === 'function') {
363
- sessionLog = await ops.recoverSessionLog(entry).catch(() => null)
369
+ try {
370
+ sessionLog = await ops.recoverSessionLog(entry)
371
+ } catch (error) {
372
+ // A locked or briefly unreadable log is not a missing one: the right
373
+ // answer is "try again", not "this can never be restored".
374
+ throw new DeleteError('E_TRASH_CONFLICT', 'The conversation log could not be read right now (another process may hold it); retry in a moment.', {
375
+ trashId: entry.trashId,
376
+ reason: 'log-read-transient',
377
+ cause: error instanceof Error ? error.message : String(error),
378
+ })
379
+ }
364
380
  }
365
381
  // If the log copy is on disk but its header cannot be read (truncated, wrong
366
382
  // codec, missing `cwd`), there is no path to put it back to and no state it
@@ -716,7 +732,13 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
716
732
  // it left the entry stuck in `pending` forever.
717
733
  let logUnrecoverable = false
718
734
  if (holdsLog === true && (entry.sessionLog === null || entry.sessionLog === undefined) && typeof ops.recoverSessionLog === 'function') {
719
- logUnrecoverable = (await ops.recoverSessionLog(entry).catch(() => null)) === null
735
+ try {
736
+ logUnrecoverable = (await ops.recoverSessionLog(entry)) === null
737
+ } catch {
738
+ // Transient read failure: the log may still be restorable, so this must
739
+ // not open the forced-discard exit (which deletes the memory bundle).
740
+ logUnrecoverable = false
741
+ }
720
742
  }
721
743
  if (holdsLog === true && !logUnrecoverable) {
722
744
  throw new DeleteError('E_TRASH_CONFLICT', 'This incomplete entry still holds the conversation log; restore or purge it instead of discarding it.', {
@@ -732,19 +754,26 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
732
754
  } else {
733
755
  holdsMemory = entry.includeMemory === true && (entry.memory?.total ?? 0) > 0
734
756
  }
757
+ // Files without a readable bundle cannot be restored by anything: that is the
758
+ // second state where a forced, phrase-confirmed discard is the only exit.
759
+ let strandedMemory = false
760
+ if (holdsMemory === true && typeof ops.hasStrandedMemory === 'function') {
761
+ strandedMemory = await ops.hasStrandedMemory(entryDir).catch(() => false) === true
762
+ }
735
763
  // The status half of the decision is shared with the UI; the two refusals
736
764
  // below stay separate because their advice differs (purge vs restore).
737
765
  const capabilities = entryCapabilities(entry, {
738
766
  hasLogFiles: holdsLog === true && !logUnrecoverable,
739
767
  hasMemoryFiles: holdsMemory,
740
768
  payloadRecoverable: logUnrecoverable ? false : null,
769
+ strandedMemory,
741
770
  })
742
771
  if (holdsMemory || !capabilities.canDiscard) {
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
772
+ // The exit for the all-refused states: a log whose header cannot be read,
773
+ // or memory files whose bundle was never written. Both can never be
774
+ // restored, so the only remaining action is a confirmed discard. Refusing
775
+ // `force` anywhere else keeps this from becoming a generic skip switch.
776
+ const forceAllowed = force === true && (logUnrecoverable === true || strandedMemory === true)
748
777
  if (!forceAllowed) {
749
778
  const which = holdsMemory ? 'memory files' : 'payload'
750
779
  throw new DeleteError('E_TRASH_CONFLICT', `This incomplete entry still holds ${which}; restore or purge it instead of discarding it.`, {
@@ -100,10 +100,6 @@ function isPlainObject(value) {
100
100
  return value !== null && typeof value === 'object' && !Array.isArray(value)
101
101
  }
102
102
 
103
- function clone(value) {
104
- return JSON.parse(JSON.stringify(value))
105
- }
106
-
107
103
  function clampInt(raw, fallback, min, max, field, warnings) {
108
104
  if (raw === undefined) return fallback
109
105
  const number = typeof raw === 'number' ? raw : Number.NaN
@@ -345,8 +341,3 @@ export function resolveJsonPointer(document, pointer) {
345
341
  }
346
342
  return cursor
347
343
  }
348
-
349
- /** Deep clone of the defaults; exported for tests and documentation. */
350
- export function defaultConfig() {
351
- return clone(DEFAULTS)
352
- }
@@ -91,8 +91,3 @@ export function toWireError(error) {
91
91
  const normalized = asDeleteError(error)
92
92
  return { ok: false, error: redactDetails(normalized.toJSON()) }
93
93
  }
94
-
95
- /** Wire shape for a successful call. */
96
- export function ok(value) {
97
- return { ok: true, ...value }
98
- }
@@ -477,6 +477,31 @@ export function createJsonIndexMemoryAdapter(options) {
477
477
  // copy of it, and the restored index no longer points at it.
478
478
  // Removing the bundle here would destroy memory content outright,
479
479
  // so keep it and leave a marker explaining the leftover.
480
+ // Keep the content AND its recoverability. `bundle.json` is written
481
+ // only after every file moved, so a failure inside the move loop
482
+ // used to leave `files/` with no manifest: `readExported` saw
483
+ // nothing, restore refused, discard refused the files it found, and
484
+ // the entry could never be cleaned up. Write a rescue bundle for the
485
+ // files that are still here.
486
+ const strandedEntries = []
487
+ for (const moved of failedMoves) {
488
+ const name = basename(moved.to)
489
+ const original = bundle.entries.find((item) => item.storedFile === name)
490
+ if (original !== undefined) strandedEntries.push(original)
491
+ }
492
+ if (strandedEntries.length > 0) {
493
+ const rescue = {
494
+ ...bundle,
495
+ bundlePath: join(bundleDir, bundleFileName),
496
+ count: strandedEntries.length,
497
+ entries: strandedEntries,
498
+ links: [],
499
+ rescuedAt: now(),
500
+ }
501
+ await fsApi
502
+ .writeFile(join(bundleDir, bundleFileName), `${JSON.stringify(rescue, null, 2)}\n`, 'utf8')
503
+ .catch(() => {})
504
+ }
480
505
  await fsApi
481
506
  .writeFile(
482
507
  join(bundleDir, ORPHAN_MARKER),
@@ -534,6 +559,22 @@ export function createJsonIndexMemoryAdapter(options) {
534
559
  alreadyRestored.add(entry.nodeId)
535
560
  continue
536
561
  }
562
+ // The index still lists the node (a failed export restored the
563
+ // pristine snapshot) while its content file is only in the bundle.
564
+ // Putting the file back finishes the job; refusing here left the
565
+ // entry with no usable action at all.
566
+ if (destination !== null && isPlainFileName(entry.storedFile) && typeof bundle.bundlePath === 'string') {
567
+ const stored = join(dirname(bundle.bundlePath), 'files', entry.storedFile)
568
+ try {
569
+ await fsApi.mkdir(dirname(destination), { recursive: true })
570
+ await fsApi.rename(stored, destination)
571
+ alreadyRestored.add(entry.nodeId)
572
+ continue
573
+ } catch {
574
+ // Fall through to the refusal: the content is neither in place
575
+ // nor where the bundle says it is.
576
+ }
577
+ }
537
578
  throw new DeleteError('E_TRASH_CONFLICT', 'A memory entry with the same id already exists; restore aborted.', {
538
579
  adapter: id,
539
580
  nodeId: entry.nodeId,