dsh-conversation-recycle-bin 0.3.11 → 0.3.13

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
4
4
 
5
- > 状态:`0.3.5`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
5
+ > 状态:`0.3.13`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
6
6
  > 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
7
7
  > 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
8
8
 
@@ -97,14 +97,14 @@ memory → messages → session/catalog
97
97
  1. `POST /preview`:校验 session 存在、工作区归属、是否受保护/子代理/超出允许根;hard 模式下活跃会话不再拒绝,而是给出「会先停止该会话」的 warning(`stopLiveSessions:false` 时仍拒绝)。随后统计日志条数与记忆数,签发**单次使用**确认 token(绑定排序后的 sessionIds + includeMemory + mode,TTL 默认 120s)。
98
98
  2. `POST /delete`:
99
99
  - 传输层依次过三道闸:本路由自身的 loopback/Origin(比较完整 authority:主机名**和**有效端口,桌面 `dsh-app://app` 外壳源按 DSH 自己的口径豁免)/私有 header 校验 → DSH `connection.requestRejection`(401/403 短路)→ `ctx.waterfall('connection/request', ...)`(桌面更新任务借此在安装更新时返回 503 并排空在途请求,删除事务不会被杀在半路);
100
- - token(彻底删除无需短语;批量软删除校验数量)、**有效批量上限** `min(maxBatchSessions, 本分钟剩余额度)`、限流(默认 20 次/分钟,滑动窗口)全部通过后才消费 token;
100
+ - token(彻底删除无需短语;批量软删除校验数量)、**有效批量上限** `min(maxBatchSessions, 本分钟剩余额度)`、限流(默认 20 次/分钟,滑动窗口)全部通过后才执行;token 只在**整批零失败**时作废,部分或全部失败都保留(重放只能重跑同一计划),避免用户为同一批失败项重新预览;
101
101
  - 先对**全部**会话做策略预检,任一被拒绝则整批不执行(不会半成功);
102
102
  - 逐会话执行 `quiesce(live) → memory → messages → session/catalog` 事务;
103
103
  - 所有 mutation(delete/restore/purge/discard)串行经过进程内链 + `<dataDir>/mutation.lock` 跨进程文件锁:桌面端与 `dsh web` 共享一个 `DSH_HOME` 时,第二个实例的并发删除会拿到 `E_LOCK_BUSY` 而不是写坏同一份 manifest;
104
104
  - 队列与锁都有预算,不会无限等待:排队超过 `lockTimeoutMs + 60s` 抛 `E_QUEUE_TIMEOUT`(503,**未执行任何动作**,可安全重试);客户端在排队或取锁期间断开(关标签页/刷新)抛 `E_ABANDONED`(409,同样未执行)。两者都表示"什么都没发生",与 `E_TRANSACTION_FAILED`(可能已部分执行)区分开;
105
105
  - 锁文件 stale 判定:pid 可证明已死才立即破锁;无 owner 的锁只按 age 破;**持有者活着时绝不按 age 破**(否则长 GC 会放进第二个写者)。连续 3 次删不掉 stale 锁即 `E_LOCK_BUSY`,不空转;
106
106
  - 返回 `{ok: true, partial, results, failures}`:`ok` 表示计划已执行,`partial` 表示有会话失败;只有**全部**失败时才抛首个错误(HTTP 状态来自 `ERROR_STATUS`,错误码保留真实策略码)。
107
- 3. `GET /trash`(返回 `entries`(可恢复)与 `incomplete`(failed/pending/restoring/purging))、`POST /trash/restore`、`POST /trash/purge`(必须输入 `DELETE`)、`POST /trash/discard`(清理未完成条目,必须输入 `DELETE`;仍保存会话日志的条目应走「恢复」而不是 discard)。未完成条目若还留着会话日志,设置页会给出「恢复」按钮,可以直接把它恢复回会话列表。
107
+ 3. `GET /trash`(返回 `entries`(可恢复)与 `incomplete`(failed/pending/restoring/purging);每行带 `capabilities`,其 `canRestore`/`canDiscard` 同时看 manifest 与磁盘载荷)、`POST /trash/restore`、`POST /trash/purge`(必须输入 `DELETE`)、`POST /trash/discard`(清理未完成条目,必须输入 `DELETE`;仍保存会话日志或记忆文件的条目应走「恢复」而不是 discard)。未完成条目若还留着会话日志或记忆包,设置页会按 `capabilities.canRestore` 给出「恢复」按钮,可以直接把它恢复回会话列表;hard 删除在「日志已移入回收站、manifest 尚未记录」的崩溃窗口里,restore 会从日志头部的 `cwd` 重建原路径并把日志移回,不再出现三路全拒的死条目。
108
108
  4. `GET /audit?limit=N`。
109
109
 
110
110
  ### 4.2 安全清单
@@ -149,7 +149,7 @@ memory → messages → session/catalog
149
149
  files/<nodeId>.content
150
150
  ```
151
151
 
152
- **保留期(自动清理)**:`trashRetentionDays`(默认 **30**)现在真的生效 —— Host 每 6 小时清扫一次(启动后 60 秒先扫一次),并且**每次打开回收站列表时在后台触发一次**(5 分钟内不重复;列表本身不等清扫,所以锁被占用也不会出现空白页)。只有 `status: 'trashed'` 且 `deletedAt` 可解析、且早于 `now - trashRetentionDays` 的条目会被清走,走的是与「永久清除」**完全相同**的 `performPurge` 路径(重新读 manifest、复判 `capabilities.canPurge`、写审计 `action: 'purge'` + `source: 'retention'`),单次最多 20 条、最旧优先,失败按指数退避重试(5 分钟起,上限 6 小时)。`pending`/`restoring`/`restored`/`purging` 与"从未提交"的残留条目**永不自动清**(那是 bug 现场,留给设置页的「恢复」/「discard」出口);`mode: 'soft'` 且会话又能被解析的条目也会跳过(无人值守时不去碰"复活"的对话)。`trashRetentionDays: 0` 或 `enabled: false` 都会关闭清扫(后者连定时器都不注册,读接口仍然可用),条目一直留到你自己删。回收站列表里会写明"删除 N 天后自动清理",每条显示 Host 下发的剩余天数,后台刚清过东西时还会提示清了几条。
152
+ **保留期(自动清理)**:`trashRetentionDays`(默认 **30**)现在真的生效 —— Host 每 6 小时清扫一次(启动后 60 秒先扫一次),并且**每次打开回收站列表时在后台触发一次**(5 分钟内不重复;列表本身不等清扫,所以锁被占用也不会出现空白页)。只有 `status: 'trashed'` 且 `deletedAt` 可解析、且早于 `now - trashRetentionDays` 的条目会被清走,走的是与「永久清除」**完全相同**的 `performPurge` 路径(重新读 manifest、复判 `capabilities.canPurge`、写审计 `action: 'purge'` + `source: 'retention'`),单次最多 20 条、最旧优先,失败按指数退避重试(5 分钟起,上限 6 小时)。`pending`/`restoring`/`restored`/`purging` 与"从未提交"的残留条目**永不自动清**(那是 bug 现场,留给设置页的「恢复」/「discard」出口);`mode: 'soft'` 且会话已被用户复活(未归档)或仍在运行的条目会跳过(无人值守时不去碰正在使用的对话),而仍处于归档状态的 soft 条目会照常清理——soft 删除本身保留日志,不再因为"日志还在"而永久逃过保留期。`trashRetentionDays: 0` 或 `enabled: false` 都会关闭清扫(后者连定时器都不注册,读接口仍然可用),条目一直留到你自己删。回收站列表里会写明"删除 N 天后自动清理",每条显示 Host 下发的剩余天数,后台刚清过东西时还会提示清了几条。
153
153
 
154
154
  `manifest.status` 只允许 `pending → trashed → restoring/restored / purging`。`GET /trash` 把**所有**条目都返回:`trashed` 进 `entries`(回收站 UI,可恢复/彻底删除),其余状态进 `incomplete`(设置页单独列出,提供「恢复」与「discard」两个出口,不再出现"失败了却没有任何清理入口"的死条目)。恢复顺序是删除的逆序:日志 → catalog → 记忆。
155
155
 
@@ -206,7 +206,7 @@ dsh-conversation-recycle-bin/
206
206
  │ ├─ memory.js # 适配器装配
207
207
  │ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
208
208
  │ └─ api.js # 同源 HTTP API、确认 token、限流
209
- └─ test/ # 138 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
209
+ └─ test/ # 186 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
210
210
  ```
211
211
 
212
212
  ---
@@ -215,7 +215,7 @@ dsh-conversation-recycle-bin/
215
215
 
216
216
  ```powershell
217
217
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
218
- npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(138 个用例)
218
+ npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(186 个用例)
219
219
  npm run check # 语法 + manifest
220
220
  ```
221
221
 
@@ -303,8 +303,8 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
303
303
  ```powershell
304
304
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
305
305
  npm run check # 语法 + manifest(含 feedback.js)
306
- node test/feedback.test.mjs # 19 个反馈用例
307
- npm test # 全量 138 个用例
306
+ node test/feedback.test.mjs # 20 个反馈用例
307
+ npm test # 全量 186 个用例
308
308
 
309
309
  # 端到端:用本机收件桩观察真实投递路径,不外发邮件
310
310
  $env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
package/client.js CHANGED
@@ -216,6 +216,8 @@ window.__ModuleLoader__.load({
216
216
  'error.E_FORBIDDEN_ORIGIN': 'The Host rejected this page as an untrusted origin.',
217
217
  'error.E_POLICY_FOREIGN_WRITER': 'Another DSH instance is still writing this conversation; close it there and retry.',
218
218
  'error.E_LOCK_BUSY': 'Another DSH instance is using this recycle bin; retry in a moment.',
219
+ 'error.E_QUEUE_TIMEOUT': 'The Host was still busy with another change, so this request was not applied. Retry in a moment.',
220
+ 'error.E_ABANDONED': 'The page gave up waiting before the Host started; nothing was changed. Retry.',
219
221
  'error.E_UNAUTHORIZED': 'This request is not authenticated with the Host.',
220
222
  'adapter.lingshu': 'Lingshu cognitive graph (md_cg)',
221
223
  'adapter.generic-json': 'Generic JSON memory store',
@@ -376,6 +378,8 @@ window.__ModuleLoader__.load({
376
378
  'error.E_FORBIDDEN_ORIGIN': '宿主拒绝了该页面的请求(来源校验未通过)。',
377
379
  'error.E_POLICY_FOREIGN_WRITER': '另一个 DSH 实例仍在写这个对话,已中止删除;请先关闭那边的对话再重试。',
378
380
  'error.E_LOCK_BUSY': '另一个 DSH 实例正在使用回收站,请稍后重试。',
381
+ 'error.E_QUEUE_TIMEOUT': '宿主还在处理另一个变更,这次请求没有执行,请稍后重试。',
382
+ 'error.E_ABANDONED': '页面在宿主开始处理前就放弃了等待,本次没有做任何改动,请重试。',
379
383
  'error.E_UNAUTHORIZED': '该请求未通过宿主的鉴权。',
380
384
  'adapter.lingshu': '灵枢认知图 (md_cg)',
381
385
  'adapter.generic-json': '通用 JSON 记忆存储',
@@ -1109,9 +1113,15 @@ window.__ModuleLoader__.load({
1109
1113
  // memory locks, per-item moves) replaces the client's flat guess:
1110
1114
  // aborting while the Host keeps deleting left a consumed token and a
1111
1115
  // half-changed list behind.
1112
- const planTimeout = Number.isFinite(preview?.limits?.planTimeoutMs)
1113
- ? Math.max(1_000, preview.limits.planTimeoutMs)
1114
- : mutationTimeoutMs(sessionIds.length)
1116
+ // The Host publishes the budget it actually enforces: the lock wait
1117
+ // plus the queue grace period. The lock-only estimate used to abort
1118
+ // 60 s before the Host gave up, so a busy bin looked like a failure
1119
+ // while the deletion was still running.
1120
+ const planTimeout = Number.isFinite(preview?.limits?.queueTimeoutMs)
1121
+ ? Math.max(1_000, preview.limits.queueTimeoutMs)
1122
+ : Number.isFinite(preview?.limits?.planTimeoutMs)
1123
+ ? Math.max(1_000, preview.limits.planTimeoutMs)
1124
+ : mutationTimeoutMs(sessionIds.length)
1115
1125
  const result = await api('/delete', {
1116
1126
  method: 'POST',
1117
1127
  timeoutMs: planTimeout,
@@ -1549,39 +1559,55 @@ window.__ModuleLoader__.load({
1549
1559
  return h('div', { className: 'dc-panel dc-incomplete' },
1550
1560
  h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
1551
1561
  h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
1552
- ...entries.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1553
- h('div', { className: 'dc-row-main' },
1554
- h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId || entry.trashId),
1555
- h('div', { className: 'dc-hint' }, entry.status === 'unreadable' && entry.error?.message
1556
- ? `${t('trash.status.unreadable')} · ${entry.error.message}`
1557
- : `${t(`trash.status.${entry.status}`)} · ${formatTime(entry.updatedAt ?? entry.deletedAt)} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
1558
- h('div', { className: 'dc-row-actions' },
1559
- entry.hasSessionLog === true && typeof onRestore === 'function'
1560
- ? h(Button, {
1561
- onClick: () => onRestore(entry.trashId),
1562
- disabled: restoreBusyId === entry.trashId,
1563
- title: t('trash.restoreIncompleteHint'),
1564
- }, t('action.restore'))
1565
- : null,
1566
- discardTarget === entry.trashId
1567
- ? h('span', { className: 'dc-inline-confirm' },
1568
- h('input', {
1569
- type: 'text',
1570
- value: discardPhrase,
1571
- placeholder: 'DELETE',
1572
- className: 'dc-input dc-input-small',
1573
- onChange: (event) => onPhrase(event.target.value),
1574
- }),
1575
- h(Button, {
1576
- variant: 'danger',
1577
- disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
1578
- onClick: () => onDiscard(entry.trashId),
1579
- }, t('trash.discard')),
1580
- h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
1581
- : h(Button, {
1582
- onClick: () => onStart(entry.trashId),
1583
- title: t('trash.confirmDiscard'),
1584
- }, t('trash.discard'))))))
1562
+ ...entries.map((entry) => {
1563
+ // The Host knows what is actually recoverable (a moved log, a memory
1564
+ // bundle, or nothing at all). Falling back to `hasSessionLog` keeps
1565
+ // older Hosts working, but a memory-only entry used to show Discard
1566
+ // only — the route refuses that while memory files remain — so the
1567
+ // one action the Host offered was its API, not the UI.
1568
+ const canRestore = entry.capabilities === undefined
1569
+ ? entry.hasSessionLog === true
1570
+ : entry.capabilities?.canRestore === true
1571
+ const canDiscard = entry.capabilities === undefined
1572
+ ? true
1573
+ : entry.capabilities?.canDiscard !== false
1574
+ return h('div', { key: entry.trashId, className: 'dc-row' },
1575
+ h('div', { className: 'dc-row-main' },
1576
+ h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId || entry.trashId),
1577
+ h('div', { className: 'dc-hint' }, entry.status === 'unreadable' && entry.error?.message
1578
+ ? `${t('trash.status.unreadable')} · ${entry.error.message}`
1579
+ : `${t(`trash.status.${entry.status}`)} · ${formatTime(entry.updatedAt ?? entry.deletedAt)} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
1580
+ h('div', { className: 'dc-row-actions' },
1581
+ canRestore === true && typeof onRestore === 'function'
1582
+ ? h(Button, {
1583
+ onClick: () => onRestore(entry.trashId),
1584
+ disabled: restoreBusyId === entry.trashId,
1585
+ title: t('trash.restoreIncompleteHint'),
1586
+ }, t('action.restore'))
1587
+ : null,
1588
+ canDiscard !== true
1589
+ ? null
1590
+ : discardTarget === entry.trashId
1591
+ ? h('span', { className: 'dc-inline-confirm' },
1592
+ h('input', {
1593
+ type: 'text',
1594
+ value: discardPhrase,
1595
+ placeholder: 'DELETE',
1596
+ className: 'dc-input dc-input-small',
1597
+ onChange: (event) => onPhrase(event.target.value),
1598
+ }),
1599
+ h(Button, {
1600
+ variant: 'danger',
1601
+ disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
1602
+ onClick: () => onDiscard(entry.trashId),
1603
+ }, t('trash.discard')),
1604
+ h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
1605
+ : h(Button, {
1606
+ onClick: () => onStart(entry.trashId),
1607
+ title: t('trash.confirmDiscard'),
1608
+ }, t('trash.discard')))
1609
+ )
1610
+ }))
1585
1611
  }
1586
1612
 
1587
1613
  /**
@@ -1928,7 +1954,13 @@ window.__ModuleLoader__.load({
1928
1954
  h('div', { className: 'dc-hint' },
1929
1955
  `${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}${expiryHint(entry)}`)),
1930
1956
  h('div', { className: 'dc-row-actions' },
1931
- h(Button, { onClick: () => { void restore(entry.trashId) } }, t('action.restore')),
1957
+ // The Host already knows whether this entry can be restored;
1958
+ // the panel used to offer the button and then show the refusal.
1959
+ h(Button, {
1960
+ disabled: entry.capabilities?.canRestore === false,
1961
+ title: entry.capabilities?.canRestore === false ? t('error.E_TRASH_CONFLICT') : t('action.restore'),
1962
+ onClick: () => { void restore(entry.trashId) },
1963
+ }, t('action.restore')),
1932
1964
  purgeTarget === entry.trashId
1933
1965
  ? h('span', { className: 'dc-inline-confirm' },
1934
1966
  h('input', {
@@ -1941,7 +1973,12 @@ window.__ModuleLoader__.load({
1941
1973
  onClick: () => { void purge(entry.trashId, purgePhrase.trim()) },
1942
1974
  }, t('action.purge')),
1943
1975
  h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
1944
- : h(Button, { variant: 'danger', onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') } }, t('action.purge')))))) : null,
1976
+ : h(Button, {
1977
+ variant: 'danger',
1978
+ disabled: entry.capabilities?.canPurge === false,
1979
+ title: entry.capabilities?.canPurge === false ? t('error.E_TRASH_CONFLICT') : t('action.purge'),
1980
+ onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') },
1981
+ }, t('action.purge')))))) : null,
1945
1982
 
1946
1983
  tab === 'trash' && incomplete.length > 0 ? h(IncompleteEntries, {
1947
1984
  entries: incomplete,
@@ -154,17 +154,19 @@ export async function performSessionDelete({
154
154
  steps.push({
155
155
  name: 'messages',
156
156
  run: async () => {
157
- const moved = await ops.sessionLog.moveToTrash(session, dir)
158
- try {
159
- // Record the move immediately. A crash between the move and the
160
- // final manifest write used to leave a `pending` entry that knew
161
- // nothing about the log sitting in its directory: discard refused
162
- // it, restore had nothing to restore, and the payload was stranded.
163
- await trash.update(entry.trashId, { sessionLog: moved })
164
- } catch (error) {
165
- await ops.sessionLog.restoreEntry(moved).catch(() => {})
166
- throw error
167
- }
157
+ // Persist the mapping BEFORE the rename. A crash between the move and
158
+ // the final manifest write used to leave a `pending` entry whose
159
+ // `sessionLog` was null while `session/` held the only copy: discard
160
+ // refused it, restore had "nothing" to restore, and the log was
161
+ // stranded. With the mapping recorded first, either crash side is
162
+ // recoverable (log still in place, or log in the bin with a known
163
+ // destination).
164
+ const moved = await ops.sessionLog.moveToTrash(session, dir, {
165
+ beforeMove: async (mapping) => {
166
+ await trash.update(entry.trashId, { sessionLog: mapping })
167
+ entry.sessionLog = mapping
168
+ },
169
+ })
168
170
  entry.sessionLog = moved
169
171
  return async () => {
170
172
  await ops.sessionLog.restoreEntry(moved)
@@ -178,8 +180,11 @@ export async function performSessionDelete({
178
180
  steps.push({
179
181
  name: 'session',
180
182
  run: async () => {
183
+ // Capture is read-only; persist it before the first catalog mutation so
184
+ // a crash after a detach can still restore the workspace position.
181
185
  const snapshot = await ops.catalog.capture(session)
182
186
  entry.catalog = snapshot
187
+ await trash.update(entry.trashId, { catalog: snapshot })
183
188
  const undo = await ops.catalog.remove(session, mode, snapshot)
184
189
  return async () => {
185
190
  await undo()
@@ -283,15 +288,26 @@ const PURGEABLE_STATUSES = new Set(['trashed', 'restored'])
283
288
  * the caller, which is the only side that can stat the entry directory.
284
289
  *
285
290
  * @param {{ status?: unknown }} entry
286
- * @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null }} [disk]
291
+ * @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null, requirePayload?: boolean }} [disk]
292
+ * `requirePayload` makes `canRestore` false for entry states that have no
293
+ * payload at all (nothing in the manifest and nothing on disk), which is the
294
+ * answer the list endpoint publishes to the Client.
287
295
  */
288
296
  export function entryCapabilities(entry, disk = {}) {
289
297
  const status = typeof entry?.status === 'string' && entry.status !== '' ? entry.status : 'unknown'
290
298
  const hasLogFiles = disk.hasLogFiles ?? null
291
299
  const hasMemoryFiles = disk.hasMemoryFiles ?? null
300
+ const hasSessionLog = entry?.sessionLog !== null && entry?.sessionLog !== undefined
301
+ const hasCatalog = entry?.catalog !== null && entry?.catalog !== undefined
302
+ const memoryTotal = typeof entry?.memory?.total === 'number' ? entry.memory.total : 0
303
+ // `requirePayload` is set by the list endpoint, which is allowed to stat the
304
+ // entry directory. `performRestore` itself keeps the status-only gate: it can
305
+ // rebuild an unrecorded memory bundle or a moved log from disk, so refusing
306
+ // that state at the door would break the very recovery it implements.
307
+ const hasPayload = hasSessionLog || hasCatalog || memoryTotal > 0 || hasLogFiles === true || hasMemoryFiles === true
292
308
  return {
293
309
  status,
294
- canRestore: RESTORABLE_STATUSES.has(status),
310
+ canRestore: RESTORABLE_STATUSES.has(status) && (disk.requirePayload !== true || hasPayload),
295
311
  canPurge: PURGEABLE_STATUSES.has(status),
296
312
  // Discard is the escape hatch for entries that never committed: it is
297
313
  // refused while a payload copy still exists, because deleting the folder
@@ -322,9 +338,17 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
322
338
  if (!entryCapabilities(entry).canRestore) {
323
339
  throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry is not in a restorable state.', { trashId: entry.trashId, status: previousStatus })
324
340
  }
325
- const sessionLog = entry.sessionLog !== null && entry.sessionLog !== undefined && typeof entry.sessionLog === 'object'
341
+ let sessionLog = entry.sessionLog !== null && entry.sessionLog !== undefined && typeof entry.sessionLog === 'object'
326
342
  ? entry.sessionLog
327
343
  : null
344
+ // A crash between the log move and its manifest write leaves the log in
345
+ // `session/` while the manifest still says `null`. Rebuild the original path
346
+ // from the log header so restore can put it back instead of answering
347
+ // "nothing left to restore" (the discard route refuses that folder too, so
348
+ // without this the only copy was stranded).
349
+ if (sessionLog === null && typeof ops.recoverSessionLog === 'function') {
350
+ sessionLog = await ops.recoverSessionLog(entry).catch(() => null)
351
+ }
328
352
  const catalog = entry.catalog !== null && entry.catalog !== undefined && typeof entry.catalog === 'object' ? entry.catalog : null
329
353
  let memorySummary = entry.memory !== null && entry.memory !== undefined && typeof entry.memory === 'object' ? entry.memory : null
330
354
  let hasMemory = entry.includeMemory === true && memorySummary !== null && (memorySummary.total ?? 0) > 0
@@ -348,12 +372,15 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
348
372
  // that stats and moves arbitrary directories. Both sides must stay inside
349
373
  // their own storage roots.
350
374
  if (sessionLog !== null) {
375
+ // A caller that cannot answer "is this inside our own storage?" gets a
376
+ // refusal, not the benefit of the doubt: the default used to be `true`,
377
+ // which made the whole guard optional.
351
378
  const fromOk = typeof ops.isInsideSessionsRoot === 'function'
352
379
  ? ops.isInsideSessionsRoot(sessionLog.from) === true
353
- : true
380
+ : false
354
381
  const toOk = typeof ops.isInsideTrashDir === 'function'
355
382
  ? ops.isInsideTrashDir(sessionLog.to) === true
356
- : true
383
+ : false
357
384
  if (!fromOk || !toOk) {
358
385
  throw new DeleteError(
359
386
  'E_TRASH_CONFLICT',
@@ -512,13 +539,12 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
512
539
  steps.push({
513
540
  name: 'messages',
514
541
  run: async () => {
515
- const moved = await ops.sessionLog.moveToTrash(session, trash.entryDir(entry.trashId))
516
- try {
517
- await trash.update(entry.trashId, { sessionLog: moved })
518
- } catch (error) {
519
- await ops.sessionLog.restoreEntry(moved).catch(() => {})
520
- throw error
521
- }
542
+ const moved = await ops.sessionLog.moveToTrash(session, trash.entryDir(entry.trashId), {
543
+ beforeMove: async (mapping) => {
544
+ await trash.update(entry.trashId, { sessionLog: mapping })
545
+ entry.sessionLog = mapping
546
+ },
547
+ })
522
548
  entry.sessionLog = moved
523
549
  return async () => {
524
550
  await ops.sessionLog.restoreEntry(moved)
@@ -531,6 +557,7 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
531
557
  name: 'session',
532
558
  run: async () => {
533
559
  const snapshot = await ops.catalog.capture(session)
560
+ await trash.update(entry.trashId, { catalog: snapshot }).catch(() => {})
534
561
  const undo = await ops.catalog.remove(session, 'hard', snapshot)
535
562
  return async () => {
536
563
  await undo()
@@ -557,6 +584,10 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
557
584
  result: result.rolledBack ? 'rolled-back' : 'rollback-incomplete',
558
585
  sessionId: entry.sessionId,
559
586
  trashId: entry.trashId,
587
+ // Terminal actions drop the title on purpose (a purged conversation
588
+ // should not live on in the audit trail), but the key stays present so
589
+ // consumers do not have to special-case `delete` versus `purge`.
590
+ title: null,
560
591
  actor,
561
592
  requestId,
562
593
  failedStep: result.failedStep,
@@ -573,6 +604,7 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
573
604
  result: 'success',
574
605
  sessionId: entry.sessionId,
575
606
  trashId: entry.trashId,
607
+ title: null,
576
608
  actor,
577
609
  requestId,
578
610
  durationMs: now() - startedAt,
@@ -639,6 +671,7 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
639
671
  sessionId: entry.sessionId,
640
672
  trashId: entry.trashId,
641
673
  status: entry.status,
674
+ title: null,
642
675
  actor,
643
676
  requestId,
644
677
  durationMs: now() - startedAt,
@@ -15,9 +15,13 @@
15
15
  * - a lock whose recorded pid is provably gone is stale and is broken at
16
16
  * once; an ownerless (foreign/legacy) lock is stale only by age;
17
17
  * - a lock whose owner is still alive is NEVER broken by age alone: a long
18
- * GC pause or a suspended process must not let a second writer in;
18
+ * GC pause or a suspended process must not let a second writer in. The one
19
+ * exception is a lock that has not been heartbeated for ten stale windows
20
+ * (a recycled pid or a wedged holder), which is broken with a warning;
19
21
  * - release only removes the file when it still carries this owner's nonce,
20
- * so a lock broken by a peer cannot make us delete the peer's lock.
22
+ * so a lock broken by a peer cannot make us delete the peer's lock. If the
23
+ * removal fails, the lock is demoted to ownerless (pid: null) rather than
24
+ * left recording a live pid nobody can break.
21
25
  *
22
26
  * Every iteration sleeps and re-checks the wait budget, including the
23
27
  * stale-break path: a lock file that cannot be removed (Windows exclusive
@@ -129,8 +133,22 @@ export function createFileLock({
129
133
  const deadOwner = owner.pid !== null && !isProcessAlive(owner.pid)
130
134
  // A provably dead owner is always breakable. An ownerless file (foreign
131
135
  // or legacy lock) is only breakable by age. A live owner is never
132
- // broken by age: heartbeat refresh can lag behind a long GC pause.
133
- const stale = deadOwner || (owner.pid === null && age > staleMs)
136
+ // broken by age alone: heartbeat refresh can lag behind a long GC pause.
137
+ //
138
+ // The one exception is a lock that has not been refreshed for ten stale
139
+ // windows. That cannot be a live holder (the heartbeat runs every few
140
+ // seconds), so it means a wedged process or — the common Windows case —
141
+ // a dead pid the OS has since handed to an unrelated process. Waiting
142
+ // forever on a recycled pid is worse than breaking a lock nobody owns,
143
+ // so it is broken loudly.
144
+ const recycledPid = owner.pid !== null && !deadOwner && age > staleMs * 10
145
+ if (recycledPid) {
146
+ warn(
147
+ `file-lock: breaking ${lockPath} — pid ${owner.pid} is alive but the lock was not refreshed for ` +
148
+ `${Math.round(age / 1000)}s (recycled pid or wedged holder)`,
149
+ )
150
+ }
151
+ const stale = deadOwner || recycledPid || (owner.pid === null && age > staleMs)
134
152
  if (stale) {
135
153
  try {
136
154
  await fsApi.rm(lockPath, { force: true })
@@ -179,10 +197,27 @@ export function createFileLock({
179
197
  try {
180
198
  await fsApi.rm(lockPath, { force: true })
181
199
  } catch (error) {
182
- // A lock we could not remove stays behind; the next acquire may
183
- // find it stale and also fail to break it. Say so instead of
184
- // leaving a silent trap for the next mutation.
185
- warn(`file-lock: could not remove ${lockPath} (${error?.code ?? 'unknown'})`)
200
+ // A lock we could not remove stays behind, and it records OUR pid —
201
+ // which is alive — so every later acquire would find a live owner,
202
+ // refuse to break it, and answer E_LOCK_BUSY until the process
203
+ // restarts. Demote it to an ownerless lock instead: the next acquire
204
+ // may still fail to delete it, but at least it can try, and a lock
205
+ // that keeps resisting is reported rather than silently poisoning
206
+ // the whole mutation path.
207
+ const canRewrite = typeof fsApi.writeFile === 'function'
208
+ const demoted = canRewrite
209
+ ? await fsApi
210
+ .writeFile(lockPath, `${JSON.stringify({ pid: null, nonce: null, at: 0 })}\n`, 'utf8')
211
+ .then(() => true)
212
+ .catch(() => false)
213
+ : false
214
+ if (demoted === true && typeof fsApi.utimes === 'function') {
215
+ await fsApi.utimes(lockPath, new Date(0), new Date(0)).catch(() => {})
216
+ }
217
+ warn(
218
+ `file-lock: could not remove ${lockPath} (${error?.code ?? 'unknown'}); ` +
219
+ (demoted === true ? 'marked it ownerless so the next acquire can break it' : 'it stays owned by this process'),
220
+ )
186
221
  }
187
222
  }
188
223
  } finally {