dsh-conversation-recycle-bin 0.3.18 → 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 CHANGED
@@ -3,6 +3,32 @@
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
+
6
32
  ## [0.3.18] — 2026-11-04
7
33
 
8
34
  一次针对「失败之后再失败」链路的修补,加上客户端与宿主队列预算的统一。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.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)。
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.`, {
@@ -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,
package/lib/host/api.js CHANGED
@@ -41,6 +41,16 @@
41
41
  import { randomBytes } from 'node:crypto'
42
42
  import { mkdir, readdir, stat } from 'node:fs/promises'
43
43
  import { join } from 'node:path'
44
+
45
+ /** True when the path exists (any type). Used by the stranded-memory probe. */
46
+ async function exists(target) {
47
+ try {
48
+ await stat(target)
49
+ return true
50
+ } catch {
51
+ return false
52
+ }
53
+ }
44
54
  import { DeleteError, ERROR_STATUS, asDeleteError, toWireError } from '../core/errors.js'
45
55
  import { redactDetails } from '../core/redact.js'
46
56
  import { createAuditLog } from '../core/audit.js'
@@ -440,13 +450,51 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
440
450
  if (typeof entry?.sessionId !== 'string' || !SESSION_ID_PATTERN.test(entry.sessionId)) return null
441
451
  if (typeof entry?.trashId !== 'string' || !isTrashId(entry.trashId)) return null
442
452
  const sessionDir = join(trash.entryDir(entry.trashId), 'session')
443
- if (!(await readdir(sessionDir).catch(() => null))) return null
453
+ let names
454
+ try {
455
+ names = await readdir(sessionDir)
456
+ } catch (error) {
457
+ // "No session directory" is a definitive answer; a lock or a permission
458
+ // error is not. Only the first one may be reported as unrecoverable.
459
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null
460
+ throw error
461
+ }
462
+ if (!Array.isArray(names) || names.length === 0) return null
444
463
  const cwd = await readSessionLogCwd(sessionDir)
445
464
  if (cwd === null) return null
446
465
  const from = join(paths.sessionsRoot, projectKey(cwd), encodeSessionSegment(entry.sessionId))
447
466
  if (!isPathInside(paths.sessionsRoot, from) || !isPathInside(paths.trashDir, sessionDir)) return null
448
467
  return { sessionId: entry.sessionId, from, to: sessionDir }
449
468
  },
469
+ /**
470
+ * Files under `<entry>/memory/<adapter>/` with no readable `bundle.json`.
471
+ *
472
+ * Such an adapter can never be restored: `readExported` only speaks bundle
473
+ * files, and discard refuses to delete files it cannot account for. This is
474
+ * what lets the panel offer the one remaining exit instead of a dead row.
475
+ */
476
+ hasStrandedMemory: async (entryDir) => {
477
+ const root = join(entryDir, 'memory')
478
+ let dirents
479
+ try {
480
+ dirents = await readdir(root, { withFileTypes: true })
481
+ } catch {
482
+ return false
483
+ }
484
+ for (const dirent of dirents) {
485
+ if (!dirent.isDirectory()) continue
486
+ const bundle = join(root, dirent.name, 'bundle.json')
487
+ if (await exists(bundle)) continue
488
+ let files = []
489
+ try {
490
+ files = await readdir(join(root, dirent.name, 'files'), { recursive: true })
491
+ } catch {
492
+ files = []
493
+ }
494
+ if (files.length > 0 || await exists(join(root, dirent.name, 'ORPHANED.json'))) return true
495
+ }
496
+ return false
497
+ },
450
498
  // Manifest paths are data, not truth: a hand-edited manifest must not turn
451
499
  // "restore" into a proxy for stat/move on arbitrary directories.
452
500
  isInsideSessionsRoot: (target) => typeof target === 'string' && target !== '' && isPathInside(paths.sessionsRoot, target),
@@ -1085,10 +1133,25 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1085
1133
  // must say so instead of advertising a restore that always fails.
1086
1134
  let payloadRecoverable = null
1087
1135
  if (hasLogFiles === true && entry !== null && (entry.sessionLog === null || entry.sessionLog === undefined)) {
1088
- const rebuilt = await ops.recoverSessionLog(entry).catch(() => null)
1089
- payloadRecoverable = rebuilt !== null
1136
+ try {
1137
+ const rebuilt = await ops.recoverSessionLog(entry)
1138
+ payloadRecoverable = rebuilt !== null
1139
+ } catch (error) {
1140
+ // A locked file is not an unreadable one: EACCES/EBUSY means "ask again
1141
+ // later". Report the payload as UNKNOWN so the panel keeps offering
1142
+ // restore instead of advertising a forced discard that would delete
1143
+ // memory the user could still get back.
1144
+ logger.warn?.(`[conversation-recycle-bin] could not read the log of ${trashId} right now: ${error?.message ?? error}`)
1145
+ payloadRecoverable = null
1146
+ }
1147
+ }
1148
+ // Files under `memory/` with no readable bundle cannot be restored by
1149
+ // anything: the entry needs an explicit, phrase-confirmed exit for them.
1150
+ let strandedMemory = null
1151
+ if (hasMemoryFiles === true && entry !== null && entry.status !== 'trashed') {
1152
+ strandedMemory = await ops.hasStrandedMemory(dir).catch(() => null)
1090
1153
  }
1091
- return { hasLogFiles, hasMemoryFiles, payloadRecoverable }
1154
+ return { hasLogFiles, hasMemoryFiles, payloadRecoverable, strandedMemory }
1092
1155
  }
1093
1156
 
1094
1157
  async function serializeEntry(entry) {
package/lib/host/paths.js CHANGED
@@ -176,12 +176,18 @@ async function readZstdFirstLine(file) {
176
176
  * directory has no readable log. Used only to reconstruct the original path of
177
177
  * a log a crashed hard delete already moved into the recycle bin.
178
178
  */
179
+ /** Error codes that mean "ask again later", never "this will never work". */
180
+ const TRANSIENT_FS_CODES = new Set(['EACCES', 'EPERM', 'EBUSY', 'EMFILE', 'ENFILE', 'EAGAIN'])
181
+
179
182
  export async function readSessionLogCwd(sessionDir) {
180
183
  let names = []
181
184
  try {
182
185
  names = (await readdir(sessionDir)).filter((name) => /\.jsonl(?:\.zstd)?$/u.test(name))
183
- } catch {
184
- return null
186
+ } catch (error) {
187
+ // Only "not there" is a definitive answer. A locked or forbidden directory
188
+ // says nothing about the header, so it must not become "unrecoverable".
189
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null
190
+ throw error
185
191
  }
186
192
  const ranked = names.sort((left, right) => {
187
193
  const rank = (name) => (name.includes('v4') ? 0 : name.endsWith('.zstd') ? 1 : 2)
@@ -195,8 +201,10 @@ export async function readSessionLogCwd(sessionDir) {
195
201
  if (parsed !== null && typeof parsed === 'object' && typeof parsed.cwd === 'string' && parsed.cwd !== '') {
196
202
  return parsed.cwd
197
203
  }
198
- } catch {
199
- // Try the next generation/log file.
204
+ } catch (error) {
205
+ // A corrupt header is worth skipping; a transient lock is not, because
206
+ // the caller would report the log as permanently unreadable.
207
+ if (TRANSIENT_FS_CODES.has(error?.code) || TRANSIENT_FS_CODES.has(error?.cause?.code)) throw error
200
208
  }
201
209
  }
202
210
  return null
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.3.18'
8
+ export const PLUGIN_VERSION = '0.3.19'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
5
5
  "repository": {
6
6
  "type": "git",
package/test/api.test.mjs CHANGED
@@ -5,7 +5,7 @@ import { join } from 'node:path'
5
5
  import test from 'node:test'
6
6
  import { normalizeConfig } from '../lib/core/config.js'
7
7
  import { createFileLock } from '../lib/core/file-lock.js'
8
- import { createDeleteApi, QUEUE_GRACE_MS, ROUTE_PREFIX, TRASH_LOCK_BUDGET_MS } from '../lib/host/api.js'
8
+ import { createDeleteApi, QUEUE_GRACE_MS, ROUTE_PREFIX, TRASH_LOCK_BUDGET_MS, requestOriginVerdict } from '../lib/host/api.js'
9
9
  import { createSessionLogOps } from '../lib/host/catalog.js'
10
10
  import { createSessionGateway } from '../lib/host/gateway.js'
11
11
  import { createLifecycleOps } from '../lib/host/lifecycle.js'
@@ -1999,3 +1999,74 @@ test('gateway: an unavailable session list degrades to empty instead of failing
1999
1999
  await home.cleanup()
2000
2000
  }
2001
2001
  })
2002
+
2003
+ // The origin fence compares the authority literally. A proxy (or a shell) that
2004
+ // rewrites `Host` to 127.0.0.1 while the page keeps `localhost` — or drops the
2005
+ // header entirely — yields a 403 the user cannot explain, so the exact matrix
2006
+ // is pinned here.
2007
+ test('origin fence: loopback spellings, the desktop shell and a missing Host', () => {
2008
+ const verdict = (headers) => requestOriginVerdict(headers, { requireSameOrigin: true, allowNonLoopback: false, allowedHosts: [] })
2009
+ const ok = [
2010
+ { host: '127.0.0.1:1067', origin: 'http://127.0.0.1:1067' },
2011
+ { host: 'localhost:1067', origin: 'http://localhost:1067' },
2012
+ { host: '127.0.0.1:1067', origin: 'dsh-app://app' },
2013
+ { host: '127.0.0.1:1067' },
2014
+ ]
2015
+ for (const headers of ok) assert.equal(verdict(headers).ok, true, JSON.stringify(headers))
2016
+ const refused = [
2017
+ [{ host: '127.0.0.1:1067', origin: 'http://localhost:1067' }, 'E_FORBIDDEN_ORIGIN'],
2018
+ [{ host: 'localhost:1067', origin: 'http://127.0.0.1:1067' }, 'E_FORBIDDEN_ORIGIN'],
2019
+ [{ host: '127.0.0.1:1067', origin: 'http://127.0.0.1:1068' }, 'E_FORBIDDEN_ORIGIN'],
2020
+ [{ host: '127.0.0.1:1067', 'sec-fetch-site': 'cross-site', origin: 'http://127.0.0.1:1067' }, 'E_FORBIDDEN_ORIGIN'],
2021
+ [{ origin: 'http://127.0.0.1:1067' }, 'E_FORBIDDEN_HOST'],
2022
+ ]
2023
+ for (const [headers, code] of refused) {
2024
+ const result = verdict(headers)
2025
+ assert.equal(result.ok, false, JSON.stringify(headers))
2026
+ assert.equal(result.error.code, code, JSON.stringify(headers))
2027
+ }
2028
+ })
2029
+
2030
+ // Memory files whose bundle was never written are unrecoverable: the list must
2031
+ // say so, and the discard route must accept the forced (phrase-confirmed) exit
2032
+ // that the panel then offers.
2033
+ test('trash: stranded memory files offer, and accept, the forced discard', async () => {
2034
+ const fixture = await makeFixture()
2035
+ try {
2036
+ const trashId = `strand-${Date.now().toString(36)}`
2037
+ const entryDir = join(fixture.paths.trashDir, trashId)
2038
+ await mkdir(join(entryDir, 'memory', 'md', 'files'), { recursive: true })
2039
+ await writeFile(join(entryDir, 'memory', 'md', 'files', 'n1.content'), 'body', 'utf8')
2040
+ await writeFile(join(entryDir, 'memory', 'md', 'ORPHANED.json'), '{"format":1}', 'utf8')
2041
+ await writeFile(join(entryDir, 'manifest.json'), JSON.stringify({
2042
+ format: 1,
2043
+ trashId,
2044
+ sessionId: S1,
2045
+ title: 'stranded memory',
2046
+ mode: 'hard',
2047
+ includeMemory: true,
2048
+ deletedAt: Date.now(),
2049
+ createdAt: Date.now(),
2050
+ status: 'failed',
2051
+ sessionLog: null,
2052
+ catalog: null,
2053
+ memory: { adapters: [], total: 0 },
2054
+ }), 'utf8')
2055
+
2056
+ const listed = await call(fixture.api, '/trash', { method: 'GET' })
2057
+ const row = [...(listed.body.trash ?? []), ...(listed.body.incomplete ?? [])].find((item) => item.trashId === trashId)
2058
+ assert.ok(row !== undefined, 'the entry must be listed')
2059
+ assert.equal(row.capabilities.strandedMemory, true)
2060
+ assert.equal(row.capabilities.canForceDiscard, true)
2061
+
2062
+ const withoutForce = await call(fixture.api, '/trash/discard', { body: { trashId, confirmPhrase: 'DELETE' } })
2063
+ assert.equal(withoutForce.status, 409)
2064
+ assert.equal(await pathExists(entryDir), true)
2065
+
2066
+ const forced = await call(fixture.api, '/trash/discard', { body: { trashId, confirmPhrase: 'delete', force: true } })
2067
+ assert.equal(forced.status, 200)
2068
+ assert.equal(await pathExists(entryDir), false)
2069
+ } finally {
2070
+ await fixture.home.cleanup()
2071
+ }
2072
+ })
@@ -439,7 +439,7 @@ test('capabilities: an incomplete entry with no payload cannot be restored and c
439
439
  { status: 'failed', sessionLog: null, catalog: null, memory: { adapters: [], total: 0 } },
440
440
  { hasLogFiles: false, hasMemoryFiles: false, requirePayload: true },
441
441
  )
442
- assert.deepEqual(caps, { status: 'failed', canRestore: false, canPurge: false, canDiscard: true, canForceDiscard: false })
442
+ assert.deepEqual(caps, { status: 'failed', canRestore: false, canPurge: false, canDiscard: true, canForceDiscard: false, strandedMemory: false })
443
443
  })
444
444
 
445
445
  test('capabilities: a payload on disk makes restore possible and blocks discard', () => {
@@ -701,3 +701,128 @@ test('restore: a completed restore records its terminal state before the audit w
701
701
  assert.equal(stored.status, 'restored')
702
702
  assert.equal(stored.restoredAt, 7_000)
703
703
  })
704
+
705
+ // Memory files with no readable bundle cannot be restored by anything, so the
706
+ // entry must expose the forced, phrase-confirmed exit instead of three dead
707
+ // buttons (the stranded state that used to have no way out at all).
708
+ test('capabilities: stranded memory files open the forced-discard exit', () => {
709
+ const base = { status: 'failed', sessionLog: null, catalog: null, memory: { total: 0 } }
710
+ const stranded = entryCapabilities(base, { hasLogFiles: false, hasMemoryFiles: true, strandedMemory: true, requirePayload: true })
711
+ assert.equal(stranded.canForceDiscard, true)
712
+ assert.equal(stranded.strandedMemory, true)
713
+ assert.equal(stranded.canDiscard, false, 'plain discard still refuses files it cannot account for')
714
+
715
+ const healthy = entryCapabilities(base, { hasLogFiles: false, hasMemoryFiles: true, strandedMemory: false, requirePayload: true })
716
+ assert.equal(healthy.canForceDiscard, false, 'a readable bundle must not offer the destructive exit')
717
+ })
718
+
719
+ test('discard: force is accepted for stranded memory and still refused without it', async () => {
720
+ const home = await makeTempHome('stranded-')
721
+ try {
722
+ const trashId = 't5-strand'
723
+ const entryDir = join(home.root, 'trash', trashId)
724
+ await mkdir(join(entryDir, 'memory', 'md', 'files'), { recursive: true })
725
+ await writeFile(join(entryDir, 'memory', 'md', 'files', 'x.content'), 'body', 'utf8')
726
+ const entry = {
727
+ format: 1, trashId, sessionId: 'session-abc12345', title: 'stranded',
728
+ mode: 'hard', includeMemory: true, status: 'failed', sessionLog: null, catalog: null,
729
+ memory: { adapters: [], total: 0 },
730
+ }
731
+ const captured = new Map([[trashId, entry]])
732
+ const trash = {
733
+ async read(id) { return captured.get(id) ?? null },
734
+ entryDir(id) { return join(home.root, 'trash', id) },
735
+ async remove(id) { captured.delete(id) },
736
+ async update(id, patch) { Object.assign(captured.get(id), patch) },
737
+ async allocate() { throw new Error('not used') },
738
+ async write() { throw new Error('not used') },
739
+ async list() { return [...captured.values()] },
740
+ }
741
+ const ops = {
742
+ memory: { restoreEntry: async () => {} },
743
+ sessionLog: { restoreEntry: async () => {} },
744
+ catalog: { restore: async () => async () => {} },
745
+ session: { inspect: async () => null },
746
+ lifecycle: { quiesce: async () => async () => {} },
747
+ pathExists: async (target) => stat(target).then(() => true, () => false),
748
+ hasFiles: async (target) => (await readdir(target).catch(() => [])).length > 0,
749
+ hasStrandedMemory: async (dir) => {
750
+ const names = await readdir(join(dir, 'memory'), { withFileTypes: true }).catch(() => [])
751
+ for (const dirent of names) {
752
+ if (!dirent.isDirectory()) continue
753
+ if (await stat(join(dir, 'memory', dirent.name, 'bundle.json')).then(() => true, () => false)) continue
754
+ const files = await readdir(join(dir, 'memory', dirent.name, 'files'), { recursive: true }).catch(() => [])
755
+ if (files.length > 0) return true
756
+ }
757
+ return false
758
+ },
759
+ recoverSessionLog: async () => null,
760
+ isInsideSessionsRoot: () => true,
761
+ isInsideTrashDir: () => true,
762
+ }
763
+
764
+ // Without force: refused, and the refusal says it is forceable.
765
+ await assert.rejects(
766
+ performDiscard({ entry, ops, trash, audit: makeAudit() }),
767
+ (error) => error.code === 'E_TRASH_CONFLICT' && error.details?.forceable === true,
768
+ )
769
+ assert.equal(captured.has(trashId), true, 'a refused discard must not delete anything')
770
+
771
+ // With force (the phrase is checked by the route): accepted.
772
+ await performDiscard({ entry, ops, trash, audit: makeAudit(), force: true })
773
+ assert.equal(captured.has(trashId), false)
774
+ } finally {
775
+ await home.cleanup()
776
+ }
777
+ })
778
+
779
+ // A locked log is not an unreadable one: the entry must keep its restore path
780
+ // instead of advertising a discard that deletes the memory bundle.
781
+ test('restore: a transient log read failure asks to retry instead of declaring the log lost', async () => {
782
+ const home = await makeTempHome('transient-')
783
+ try {
784
+ const trashId = 't6-transient'
785
+ const entryDir = join(home.root, 'trash', trashId)
786
+ await mkdir(join(entryDir, 'session'), { recursive: true })
787
+ await writeFile(join(entryDir, 'session', 'session.jsonl'), '{"cwd":"/x"}\n', 'utf8')
788
+ const entry = {
789
+ format: 1, trashId, sessionId: 'session-abc12345', title: 'locked',
790
+ mode: 'hard', includeMemory: false, status: 'pending', sessionLog: null, catalog: null,
791
+ memory: { adapters: [], total: 0 },
792
+ }
793
+ const trash = {
794
+ async read() { return entry },
795
+ entryDir() { return entryDir },
796
+ async remove() {},
797
+ async update() {},
798
+ async allocate() { throw new Error('not used') },
799
+ async write() { throw new Error('not used') },
800
+ async list() { return [entry] },
801
+ }
802
+ const locked = Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' })
803
+ const ops = {
804
+ memory: { restoreEntry: async () => {} },
805
+ sessionLog: { restoreEntry: async () => {} },
806
+ catalog: { restore: async () => async () => {} },
807
+ session: { inspect: async () => null },
808
+ lifecycle: { quiesce: async () => async () => {} },
809
+ pathExists: async (target) => stat(target).then(() => true, () => false),
810
+ hasFiles: async () => false,
811
+ hasStrandedMemory: async () => false,
812
+ recoverSessionLog: async () => { throw locked },
813
+ isInsideSessionsRoot: () => true,
814
+ isInsideTrashDir: () => true,
815
+ }
816
+ await assert.rejects(
817
+ performRestore({ entry, ops, trash, audit: makeAudit() }),
818
+ (error) => error.code === 'E_TRASH_CONFLICT' && error.details?.reason === 'log-read-transient',
819
+ )
820
+ // Discard must not treat that same transient error as "permanently lost".
821
+ await assert.rejects(
822
+ performDiscard({ entry, ops, trash, audit: makeAudit(), force: true }),
823
+ (error) => error.code === 'E_TRASH_CONFLICT' && error.details?.forceable !== true,
824
+ )
825
+ } finally {
826
+ await home.cleanup()
827
+ }
828
+ })
@@ -1030,3 +1030,73 @@ test('client: every error code the Host can emit has a message in both locales',
1030
1030
  }
1031
1031
  }
1032
1032
  })
1033
+
1034
+ // A restored entry that still holds memory files used to render no action at
1035
+ // all: the Host allows purge, the panel only knew about restore and discard.
1036
+ test('client module: an incomplete entry offers purge when the Host allows it', async () => {
1037
+ const { exported } = await loadClient()
1038
+ const IncompleteEntries = exported.__components.IncompleteEntries
1039
+ const flatten = (node, out = []) => {
1040
+ if (node === null || node === undefined || node === false || typeof node !== 'object') return out
1041
+ if (Array.isArray(node)) {
1042
+ for (const child of node) flatten(child, out)
1043
+ return out
1044
+ }
1045
+ out.push(node)
1046
+ flatten(node.children, out)
1047
+ return out
1048
+ }
1049
+ const entry = {
1050
+ trashId: 't9-restored',
1051
+ sessionId: 'session-abc12345',
1052
+ title: 'restored but holding memory',
1053
+ status: 'restored',
1054
+ updatedAt: 1,
1055
+ memory: 0,
1056
+ capabilities: { canRestore: false, canPurge: true, canDiscard: false, canForceDiscard: false },
1057
+ }
1058
+ const tree = IncompleteEntries({
1059
+ entries: [entry],
1060
+ discardTarget: null,
1061
+ discardPhrase: '',
1062
+ onPhrase: () => {},
1063
+ onStart: () => {},
1064
+ onCancel: () => {},
1065
+ onDiscard: () => {},
1066
+ onRestore: () => {},
1067
+ restoreBusyId: null,
1068
+ discardBusyId: null,
1069
+ purgeTarget: null,
1070
+ purgePhrase: '',
1071
+ onPurgePhrase: () => {},
1072
+ onPurgeStart: () => {},
1073
+ onPurgeCancel: () => {},
1074
+ onPurge: () => {},
1075
+ purgeBusyId: null,
1076
+ })
1077
+ // The row has no restore (capability says no) and no plain discard (memory
1078
+ // occupies it), so the ONLY action left is the purge this test is about.
1079
+ const buttons = flatten(tree).filter((node) => typeof node?.type === 'function')
1080
+ assert.equal(buttons.length, 1, 'exactly the purge action should be rendered')
1081
+
1082
+ // Without the capability the destructive action must stay hidden.
1083
+ const withoutPurge = IncompleteEntries({
1084
+ entries: [{ ...entry, capabilities: { canRestore: false, canPurge: false, canDiscard: false, canForceDiscard: false } }],
1085
+ discardTarget: null, discardPhrase: '', onPhrase: () => {}, onStart: () => {}, onCancel: () => {},
1086
+ onDiscard: () => {}, onRestore: () => {}, restoreBusyId: null, discardBusyId: null,
1087
+ purgeTarget: null, purgePhrase: '', onPurgePhrase: () => {}, onPurgeStart: () => {},
1088
+ onPurgeCancel: () => {}, onPurge: () => {}, purgeBusyId: null,
1089
+ })
1090
+ const noButtons = flatten(withoutPurge).filter((node) => typeof node?.type === 'function')
1091
+ assert.equal(noButtons.length, 0, 'without canPurge the panel must not offer a destructive action')
1092
+ })
1093
+
1094
+ test('client module: the background-sweep notice carries how long ago it ran', async () => {
1095
+ const { exported } = await loadClient()
1096
+ const now = Date.now()
1097
+ assert.equal(exported.__formatAgo(now - 5_000, now), '刚刚')
1098
+ assert.match(exported.__formatAgo(now - 30 * 60_000, now), /分钟前|min ago/)
1099
+ assert.match(exported.__formatAgo(now - 3 * 3_600_000, now), /小时前|h ago/)
1100
+ assert.match(exported.__formatAgo(now - 2 * 86_400_000, now), /天前|d ago/)
1101
+ assert.equal(exported.__formatAgo(undefined, now), '')
1102
+ })
@@ -574,3 +574,63 @@ test('restoreEntry: a memory bundle outside the recycle bin is refused before an
574
574
  await store.cleanup()
575
575
  }
576
576
  })
577
+
578
+ // Export dies before `bundle.json` exists and the content cannot be moved back
579
+ // either. The content is then the only copy and there is no bundle to restore
580
+ // it from — so the adapter must leave a usable one, otherwise the entry can be
581
+ // neither restored nor cleaned up.
582
+ test('memory adapter: a failed export leaves a restorable rescue bundle', async () => {
583
+ const root = join(process.cwd(), 'test', '.tmp', `md-rescue-${Date.now()}`)
584
+ const store = join(root, 'store')
585
+ const trashDir = join(root, 'trash', 'entry-1')
586
+ try {
587
+ await mkdir(join(store, 'content'), { recursive: true })
588
+ await writeFile(join(store, 'content', 'a.md'), 'body-a', 'utf8')
589
+ await writeFile(join(store, '_index.json'), JSON.stringify({
590
+ schema: 2,
591
+ nodes: { n1: { session: SESSION_1, path: 'content/a.md' } },
592
+ }), 'utf8')
593
+
594
+ const realFs = await import('node:fs/promises')
595
+ let bundleWriteAttempts = 0
596
+ let moveBackBlocked = true
597
+ const fsApi = {
598
+ ...realFs,
599
+ async writeFile(file, data, encoding) {
600
+ if (String(file).endsWith('bundle.json')) {
601
+ bundleWriteAttempts += 1
602
+ // Only the export's own bundle write fails; the rescue one succeeds.
603
+ if (bundleWriteAttempts === 1) throw Object.assign(new Error('EPERM: not permitted'), { code: 'EPERM' })
604
+ }
605
+ return realFs.writeFile(file, data, encoding)
606
+ },
607
+ async rename(from, to) {
608
+ // Only the compensation's move-back fails (a backup holding the file);
609
+ // the later restore must be able to move it back for real.
610
+ if (moveBackBlocked && String(from).includes('files') && !String(to).includes('files')) {
611
+ moveBackBlocked = false
612
+ throw Object.assign(new Error('EBUSY: locked by a backup'), { code: 'EBUSY' })
613
+ }
614
+ return realFs.rename(from, to)
615
+ },
616
+ }
617
+
618
+ const adapter = createJsonIndexMemoryAdapter({
619
+ id: 'probe', label: 'probe', indexFile: join(store, '_index.json'),
620
+ rootDir: store, sessionField: 'session', nodesPointer: '/nodes', fsApi,
621
+ })
622
+ await assert.rejects(adapter.exportSession(SESSION_1, trashDir), (error) => error.code === 'E_MEMORY_FAILED')
623
+
624
+ // The rescue bundle exists and describes the file that stayed behind.
625
+ const rescue = JSON.parse(await readFile(join(trashDir, 'memory', 'probe', 'bundle.json'), 'utf8'))
626
+ assert.equal(rescue.entries.length, 1)
627
+ assert.equal(rescue.entries[0].nodeId, 'n1')
628
+ assert.equal(rescue.entries[0].storedFile, 'n1.content')
629
+
630
+ // A normal restore consumes it: the content file goes back to the store.
631
+ await adapter.restoreBundle(rescue)
632
+ assert.equal(await readFile(join(store, 'content', 'a.md'), 'utf8'), 'body-a')
633
+ } finally {
634
+ await rm(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 60 }).catch(() => {})
635
+ }
636
+ })