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/CHANGELOG.md +454 -412
- package/README.md +8 -8
- package/client.js +75 -38
- package/lib/core/cascade.js +56 -23
- package/lib/core/file-lock.js +43 -8
- package/lib/core/policy.js +126 -119
- package/lib/core/trash.js +245 -227
- package/lib/host/api.js +182 -37
- package/lib/host/catalog.js +9 -2
- package/lib/host/gateway.js +368 -351
- package/lib/host/paths.js +136 -1
- package/lib/version.js +8 -8
- package/package.json +81 -81
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
|
|
4
4
|
|
|
5
|
-
> 状态:`0.3.
|
|
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
|
|
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
|
|
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'`
|
|
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/ #
|
|
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(
|
|
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 #
|
|
307
|
-
npm test # 全量
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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) =>
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
entry.hasSessionLog === true
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
-
|
|
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, {
|
|
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,
|
package/lib/core/cascade.js
CHANGED
|
@@ -154,17 +154,19 @@ export async function performSessionDelete({
|
|
|
154
154
|
steps.push({
|
|
155
155
|
name: 'messages',
|
|
156
156
|
run: async () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
380
|
+
: false
|
|
354
381
|
const toOk = typeof ops.isInsideTrashDir === 'function'
|
|
355
382
|
? ops.isInsideTrashDir(sessionLog.to) === true
|
|
356
|
-
:
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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,
|
package/lib/core/file-lock.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
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 {
|