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 +26 -0
- package/README.md +1 -1
- package/client.js +95 -30
- package/lib/core/audit.js +9 -0
- package/lib/core/cascade.js +37 -8
- package/lib/core/memory/json-index-adapter.js +41 -0
- package/lib/host/api.js +67 -4
- package/lib/host/paths.js +12 -4
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/test/api.test.mjs +72 -1
- package/test/cascade.test.mjs +126 -1
- package/test/client-module.test.mjs +70 -0
- package/test/memory.test.mjs +60 -0
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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' },
|
|
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:
|
|
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 () => {
|
package/lib/core/cascade.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
// becoming a generic
|
|
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
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "0.3.
|
|
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
|
+
})
|
package/test/cascade.test.mjs
CHANGED
|
@@ -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
|
+
})
|
package/test/memory.test.mjs
CHANGED
|
@@ -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
|
+
})
|