dsh-conversation-recycle-bin 0.3.6 → 0.3.8
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 +39 -0
- package/README.md +3 -0
- package/client.js +8 -2
- package/config.schema.json +1 -0
- package/lib/core/cascade.js +95 -11
- package/lib/core/config.js +7 -0
- package/lib/core/errors.js +5 -0
- package/lib/core/file-lock.js +54 -12
- package/lib/core/memory/registry.js +50 -2
- package/lib/core/policy.js +7 -0
- package/lib/core/trash.js +5 -1
- package/lib/host/api.js +134 -20
- package/lib/host/gateway.js +26 -7
- package/lib/host/lifecycle.js +7 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
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.8] — 2026-10-26
|
|
7
|
+
|
|
8
|
+
技术方案评审团(架构 / 安全 / 验收三方)对 0.3.7 的复核结论是 15 条问题、142 条测试全绿但 **NEEDS WORK**。本版把它们全部修掉,测试 142 → **152**。
|
|
9
|
+
|
|
10
|
+
### Fixed — 数据安全
|
|
11
|
+
|
|
12
|
+
- **ARCH-1 删除崩溃后记忆永久孤儿**:`lib/core/cascade.js` 的记忆步骤只改内存里的 `entry.memory`,从不落盘。若在"记忆已导出"与"manifest 写入 `status: 'trashed'`"之间崩溃,条目会停在 `pending` 且 `memory.total = 0`,而真实 bundle 只存在于 `<trashId>/memory/<adapter>/bundle.json`;此后 restore("nothing left to restore")、discard("still holds memory files")、purge("cannot be purged")三条路全部拒绝,用户没有任何出口。现在导出后立即 `trash.update(entry.trashId, { memory: bundle })`(失败先 `memory.restoreEntry(bundle)` 补偿再抛);恢复路径在 `includeMemory` 为真但 manifest 说没有记忆时,用新增的 `memory.readExported()` 从磁盘重建摘要。
|
|
13
|
+
- **ARCH-1 次生风险**:恢复成功后原本无条件 `trash.remove()`,记忆未回灌时会删掉唯一副本。现在只有 `memory/` 目录确实为空(`ops.hasFiles` 探测,异常按"仍有文件"处理)才删条目,返回值新增 `memoryLeftOnDisk`。
|
|
14
|
+
- **R-02 缺 `sessions` 服务时硬删运行中会话**:`lib/host/gateway.js` 的 `isLive()` 原先只看 `sessions`,而宿主的运行判定看 `agents`。现在两个信号任一为真即视为 live,不再跳过 quiesce。
|
|
15
|
+
- **ARCH-3 运行态未知被当成"没在跑"**:`lib/host/lifecycle.js` 的 `inspectRunning()` 现在对"探针与 inspect 都给不出布尔值"的情形 fail-closed 返回 `true`,由 `liveDrainMs` 的 deadline 兜底并回滚归档。
|
|
16
|
+
|
|
17
|
+
### Fixed — 可用性
|
|
18
|
+
|
|
19
|
+
- **ARCH-2 / R-01 请求永久挂起(P0)**:两层都有问题,一并修。
|
|
20
|
+
- 跨进程锁 `lib/core/file-lock.js`:stale 分支删不掉锁文件时**无 sleep、无 deadline**,实测 3 秒内自旋 3235 次、永不返回。现在每轮都 `sleep`、循环顶部检查预算,连续 3 次删不掉即抛 `E_LOCK_BUSY`;stale 判定改为"pid 可证明已死 → 立即破锁 / 无 owner 只按 age / **持有者活着绝不按 age 破**"(长 GC 不再放第二个写者进来)。
|
|
21
|
+
- 进程内队列 `lib/host/api.js` 的 `serialize()`:`mutationChain.then` 没有任何预算,客户端中止后 Host 照样执行。现在队列预算 = `lockTimeoutMs + 60s` 宽限,超时抛新增的 `E_QUEUE_TIMEOUT`(503);并在"开跑前"与"拿到跨进程锁后"各查一次客户端是否已断开(`clientGone()`),断开则抛新增的 `E_ABANDONED`(409),绝不落盘。
|
|
22
|
+
- **R-07 软删静默掐断当前回合**:软删同样 `archiveSession(id, { stopActivity: true })`,会中断正在跑的回合。`lib/core/policy.js` 现在对 `mode === 'soft' && live` 给出明确 warning,预览页在点击前就会显示。
|
|
23
|
+
- **R-03 恢复后把"已归档"变成"可见"**:`inspect()` 原先 `archived: fromList?.archived ?? false`,而 `controller.list()` 不保证携带归档态。现在以 `workspaceRegistry` 为权威(`state.archived.has(id) || fromList?.archived === true`,pinned 同理),恢复才真正"回到删除前的状态"。
|
|
24
|
+
- **ARCH-5 能力判定口径分裂**:新增 `entryCapabilities(entry, disk)` 统一 restore/purge/discard 的判定,`serializeEntry()` 额外输出 `capabilities`,避免再出现"UI 给按钮、三个接口全拒"的死胡同。
|
|
25
|
+
|
|
26
|
+
### Fixed — 安全加固
|
|
27
|
+
|
|
28
|
+
- **S1 鉴权层异常时 fail-open**:`connection.requestRejection()` 抛错原先被 `catch { rejection = undefined }` 吞掉并放行。现在记 `logger.error` 并返回 503 `E_INTERNAL`;任何非 `undefined/null` 的拒绝值一律拒绝(`401`→401、`403`→403、其它→503)。
|
|
29
|
+
- **S5 无 connection 服务时本机任意进程可删**:新增 `config.requireConnection`(**默认 `true`**)。组合里没有 `connection` 服务时,所有路由返回 401 `E_UNAUTHORIZED` 并提示如何显式降级;`config.schema.json` 已同步。
|
|
30
|
+
- **S2 非法回收站 id 变成目录存在性探测**:`discard` 入口先过 `isTrashId()`,`trash.entryDir()` 也改抛 `DeleteError('E_BAD_REQUEST')` 而不是普通 `Error`(原先逃逸成 500,且与 404 可区分)。
|
|
31
|
+
- **S3 manifest 可指向任意路径**:恢复前校验 `sessionLog.from` 必须在 `sessionsRoot` 内、`sessionLog.to` 必须在回收站目录内,否则 `E_TRASH_CONFLICT`。
|
|
32
|
+
- **S4 彻底删除后标题仍留在审计里**:purge / discard 的审计记录不再写 `title`;`/trash/audit` 的返回统一过 `redactDetails()`,绝对路径不上线。
|
|
33
|
+
|
|
34
|
+
### Maintenance
|
|
35
|
+
|
|
36
|
+
- 测试 142 → **152**:新增"stale 锁删不掉必须报错而不是自旋"、"非法 id 必须 400"、"鉴权层抛错必须 503"、"客户端已断开不得执行"、"记忆 bundle 必须落盘"、"pending 条目可从磁盘恢复"、"manifest 越界必须拒绝"、"purge 审计不含标题"、"软删运行中会话必须有 warning"、"无 connection 服务默认被拒"。
|
|
37
|
+
- `lib/version.js` 与 `package.json` 同步到 0.3.8。
|
|
38
|
+
|
|
39
|
+
## [0.3.7] — 2026-10-24
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **深色主题下主按钮文字不可见(「确认按钮消失」)**:`.dc-btn-primary` 用 `--dsw-alias-brand-primary` 作填充、却把文字写死成 `#fff`。该 token 是**会随主题反转的填充色**:浅色主题为 `neutral-bluish-1000`(rgb(15,17,21),近黑),深色主题为 `neutral-bluish-50`(rgb(249,250,251),近白)。于是深色主题下是「近白底 + 白字」,对比度约 1.04:1,删除成功对话框右下角只剩一个白块。改为填充用 `--dsw-alias-button-primary-fill`、文字用 `--dsw-alias-label-primary-inverted`(浅色 = 白、深色 = `neutral-bluish-800` rgb(53,54,56)),悬停改用 `--dsw-alias-button-primary-hover` 并去掉 `filter:brightness(.94)`。新增用例把这条配对钉住,防止再用字面色值。
|
|
44
|
+
|
|
6
45
|
## [0.3.6] — 2026-10-24
|
|
7
46
|
|
|
8
47
|
对应 `AUDIT-2026-10-24-desktop-web-rc3.md` §7 的四条「建议动作」(A2 / A3 / A5 / A6)。同一节的 A1、A4、A7、A8 复核后判定为产品取舍(不申报未直接消费的 client 依赖、不导出 `Config` 以免设置页脱离非表单语义、归档读保留双源缓存、`missingSessionPaths` 保持成员判定),本版不改。
|
package/README.md
CHANGED
|
@@ -101,6 +101,8 @@ memory → messages → session/catalog
|
|
|
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
|
+
- 队列与锁都有预算,不会无限等待:排队超过 `lockTimeoutMs + 60s` 抛 `E_QUEUE_TIMEOUT`(503,**未执行任何动作**,可安全重试);客户端在排队或取锁期间断开(关标签页/刷新)抛 `E_ABANDONED`(409,同样未执行)。两者都表示"什么都没发生",与 `E_TRANSACTION_FAILED`(可能已部分执行)区分开;
|
|
105
|
+
- 锁文件 stale 判定:pid 可证明已死才立即破锁;无 owner 的锁只按 age 破;**持有者活着时绝不按 age 破**(否则长 GC 会放进第二个写者)。连续 3 次删不掉 stale 锁即 `E_LOCK_BUSY`,不空转;
|
|
104
106
|
- 返回 `{ok: true, partial, results, failures}`:`ok` 表示计划已执行,`partial` 表示有会话失败;只有**全部**失败时才抛首个错误(HTTP 状态来自 `ERROR_STATUS`,错误码保留真实策略码)。
|
|
105
107
|
3. `GET /trash`(返回 `entries`(可恢复)与 `incomplete`(failed/pending/restoring/purging))、`POST /trash/restore`、`POST /trash/purge`(必须输入 `DELETE`)、`POST /trash/discard`(清理未完成条目,必须输入 `DELETE`;仍保存会话日志的条目应走「恢复」而不是 discard)。未完成条目若还留着会话日志,设置页会给出「恢复」按钮,可以直接把它恢复回会话列表。
|
|
106
108
|
4. `GET /audit?limit=N`。
|
|
@@ -119,6 +121,7 @@ memory → messages → session/catalog
|
|
|
119
121
|
| 失败半删 | 每会话事务 + 逆序回滚;回滚失败进入错误详情和审计 |
|
|
120
122
|
| 不可追溯 | `<DSH_HOME>/delete-conversation/audit.jsonl` 追加成功/失败/回滚/拒绝/恢复/清理记录;设置页可查看 |
|
|
121
123
|
| 外部记忆存储损坏 | 灵枢适配器持有其 `_index.json.lock`,先写 restore bundle 再原子改写索引;任何一步失败先还原正文与索引;详情见 §5.3 |
|
|
124
|
+
| 组合里没有 `connection` 服务 | 默认 **`requireConnection: true`**:探测不到 `connection.requestRejection` 时所有路由返回 401 `E_UNAUTHORIZED`,因为本路由自己的 loopback/Origin 栅栏**分不清本机上的两个进程**(任何本机进程都能伪造 `Host: 127.0.0.1` 与私有 header)。确实要跑无 `connection` 的裁剪组合时才设 `requireConnection: false`,并自行承担"本机任意进程可调用删除接口"的风险 |
|
|
122
125
|
|
|
123
126
|
反代部署(`dsh web --public-url https://host/ui/`)时,代理会把外部主机名放进 `Host`,插件的回环校验会以 `E_FORBIDDEN_HOST` 拒绝。用 `allowedHosts` 精确放行,而不是把 `allowNonLoopback` 整体打开:
|
|
124
127
|
|
package/client.js
CHANGED
|
@@ -1984,8 +1984,14 @@ window.__ModuleLoader__.load({
|
|
|
1984
1984
|
.dc-btn{height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
|
|
1985
1985
|
.dc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,#eff0f2)}
|
|
1986
1986
|
.dc-btn:disabled{opacity:.5;cursor:not-allowed}
|
|
1987
|
-
|
|
1988
|
-
|
|
1987
|
+
/* --dsw-alias-brand-primary is a FILL that flips with the theme: near-black
|
|
1988
|
+
(#0f1115) in light, near-white (#f9fafb) in dark. Pairing it with a hard-coded
|
|
1989
|
+
white label made the primary button's text invisible on dark (contrast
|
|
1990
|
+
~1.04:1) — that is the "确认按钮消失" report. The design system's counterpart
|
|
1991
|
+
for text on a primary fill is --dsw-alias-label-primary-inverted: white in
|
|
1992
|
+
light, neutral-800 (#353638) in dark. */
|
|
1993
|
+
.dc-btn-primary{background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4d6bfe));border-color:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4d6bfe));color:var(--dsw-alias-label-primary-inverted,#fff)}
|
|
1994
|
+
.dc-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-brand-primary,#4d6bfe))}
|
|
1989
1995
|
.dc-btn-danger{color:var(--dsw-alias-state-error-primary,#d93026);border-color:var(--dsw-alias-state-error-primary,#d93026);background:transparent}
|
|
1990
1996
|
.dc-btn-danger:hover:not(:disabled){background:rgba(217,48,38,.08)}
|
|
1991
1997
|
.dc-error{font-size:12px;line-height:1.55;padding:8px 10px;border-radius:8px;margin:8px 0;color:var(--dsw-alias-state-error-primary,#d93026);background:rgba(217,48,38,.08);border:1px solid rgba(217,48,38,.28)}
|
package/config.schema.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"properties": {
|
|
9
9
|
"enabled": { "type": "boolean", "default": true, "description": "Master switch. When false the route answers 503 and the UI hides its entries." },
|
|
10
10
|
"requireSameOrigin": { "type": "boolean", "default": true, "description": "Reject requests whose Origin does not match the request's full authority (hostname AND effective port; a missing port defaults by the Origin's own scheme). The desktop shell source dsh-app://app is trusted, matching DSH's own fence." },
|
|
11
|
+
"requireConnection": { "type": "boolean", "default": true, "description": "Refuse requests when the Host exposes no `connection` service. The loopback/origin fence cannot tell two local processes apart, so without a connection layer any process on this machine could delete a conversation. Set false only for a trusted composition that has no connection service." },
|
|
11
12
|
"allowNonLoopback": { "type": "boolean", "default": false, "description": "Allow requests from any authority, not just loopback. Prefer allowedHosts when a reverse proxy presents a known hostname." },
|
|
12
13
|
"allowedHosts": {
|
|
13
14
|
"type": "array", "items": { "type": "string" }, "maxItems": 32, "default": [],
|
package/lib/core/cascade.js
CHANGED
|
@@ -129,10 +129,22 @@ export async function performSessionDelete({
|
|
|
129
129
|
name: 'memory',
|
|
130
130
|
run: async () => {
|
|
131
131
|
const bundle = await ops.memory.exportForSession(session.id, dir)
|
|
132
|
+
try {
|
|
133
|
+
// Persist the summary immediately. The bundle files are already on
|
|
134
|
+
// disk at this point; without this write a crash before the final
|
|
135
|
+
// manifest left a `pending` entry whose manifest claimed
|
|
136
|
+
// `memory.total: 0`, and restore/purge/discard then all refused it —
|
|
137
|
+
// the memory stayed stranded in the entry directory forever.
|
|
138
|
+
await trash.update(entry.trashId, { memory: bundle })
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await ops.memory.restoreEntry(bundle).catch(() => {})
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
132
143
|
entry.memory = bundle
|
|
133
144
|
return async () => {
|
|
134
145
|
await ops.memory.restoreEntry(bundle)
|
|
135
146
|
entry.memory = emptyMemorySummary()
|
|
147
|
+
await trash.update(entry.trashId, { memory: emptyMemorySummary() }).catch(() => {})
|
|
136
148
|
}
|
|
137
149
|
},
|
|
138
150
|
})
|
|
@@ -258,6 +270,36 @@ export async function performSessionDelete({
|
|
|
258
270
|
*/
|
|
259
271
|
const RESTORABLE_STATUSES = new Set(['trashed', 'pending', 'failed', 'restoring', 'purging'])
|
|
260
272
|
|
|
273
|
+
/** Statuses a purge may start from (`restored` = an interrupted restore). */
|
|
274
|
+
const PURGEABLE_STATUSES = new Set(['trashed', 'restored'])
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The single answer to "what may be done with this entry".
|
|
278
|
+
*
|
|
279
|
+
* The UI used to decide from `status !== 'trashed'` while restore/purge/discard
|
|
280
|
+
* each re-derived their own predicate, which is how a row could be rendered
|
|
281
|
+
* with a button the route then refused. Status-only questions live here; the
|
|
282
|
+
* disk-dependent half (`canDiscard` while payload is still present) stays with
|
|
283
|
+
* the caller, which is the only side that can stat the entry directory.
|
|
284
|
+
*
|
|
285
|
+
* @param {{ status?: unknown }} entry
|
|
286
|
+
* @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null }} [disk]
|
|
287
|
+
*/
|
|
288
|
+
export function entryCapabilities(entry, disk = {}) {
|
|
289
|
+
const status = typeof entry?.status === 'string' && entry.status !== '' ? entry.status : 'unknown'
|
|
290
|
+
const hasLogFiles = disk.hasLogFiles ?? null
|
|
291
|
+
const hasMemoryFiles = disk.hasMemoryFiles ?? null
|
|
292
|
+
return {
|
|
293
|
+
status,
|
|
294
|
+
canRestore: RESTORABLE_STATUSES.has(status),
|
|
295
|
+
canPurge: PURGEABLE_STATUSES.has(status),
|
|
296
|
+
// Discard is the escape hatch for entries that never committed: it is
|
|
297
|
+
// refused while a payload copy still exists, because deleting the folder
|
|
298
|
+
// would destroy the only copy of a log or a memory bundle.
|
|
299
|
+
canDiscard: status !== 'trashed' && hasLogFiles !== true && hasMemoryFiles !== true,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
261
303
|
/**
|
|
262
304
|
* Restore ONE recycle-bin entry.
|
|
263
305
|
*
|
|
@@ -277,15 +319,49 @@ const RESTORABLE_STATUSES = new Set(['trashed', 'pending', 'failed', 'restoring'
|
|
|
277
319
|
*/
|
|
278
320
|
export async function performRestore({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
|
|
279
321
|
const previousStatus = entry.status
|
|
280
|
-
if (!
|
|
322
|
+
if (!entryCapabilities(entry).canRestore) {
|
|
281
323
|
throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry is not in a restorable state.', { trashId: entry.trashId, status: previousStatus })
|
|
282
324
|
}
|
|
283
325
|
const sessionLog = entry.sessionLog !== null && entry.sessionLog !== undefined && typeof entry.sessionLog === 'object'
|
|
284
326
|
? entry.sessionLog
|
|
285
327
|
: null
|
|
286
328
|
const catalog = entry.catalog !== null && entry.catalog !== undefined && typeof entry.catalog === 'object' ? entry.catalog : null
|
|
287
|
-
|
|
329
|
+
let memorySummary = entry.memory !== null && entry.memory !== undefined && typeof entry.memory === 'object' ? entry.memory : null
|
|
330
|
+
let hasMemory = entry.includeMemory === true && memorySummary !== null && (memorySummary.total ?? 0) > 0
|
|
331
|
+
// A delete that crashed between "memory bundle exported" and "manifest
|
|
332
|
+
// written" leaves the bundles on disk while the manifest still says
|
|
333
|
+
// `total: 0`. Rebuild the summary from the directory instead of claiming
|
|
334
|
+
// there is nothing to restore (which made the entry impossible to finish).
|
|
335
|
+
if (!hasMemory && entry.includeMemory === true && typeof ops.memory?.readExported === 'function') {
|
|
336
|
+
const entryDir = typeof trash?.entryDir === 'function' ? trash.entryDir(entry.trashId) : null
|
|
337
|
+
if (typeof entryDir === 'string') {
|
|
338
|
+
const recovered = await ops.memory.readExported(entryDir).catch(() => null)
|
|
339
|
+
if (recovered !== null && (recovered.total ?? 0) > 0) {
|
|
340
|
+
memorySummary = recovered
|
|
341
|
+
hasMemory = true
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
288
345
|
const canStat = typeof ops.pathExists === 'function'
|
|
346
|
+
// Manifest paths are untrusted input: `restore` moves `from` → `to`, so a
|
|
347
|
+
// hand-edited manifest could otherwise make this plugin a confused proxy
|
|
348
|
+
// that stats and moves arbitrary directories. Both sides must stay inside
|
|
349
|
+
// their own storage roots.
|
|
350
|
+
if (sessionLog !== null) {
|
|
351
|
+
const fromOk = typeof ops.isInsideSessionsRoot === 'function'
|
|
352
|
+
? ops.isInsideSessionsRoot(sessionLog.from) === true
|
|
353
|
+
: true
|
|
354
|
+
const toOk = typeof ops.isInsideTrashDir === 'function'
|
|
355
|
+
? ops.isInsideTrashDir(sessionLog.to) === true
|
|
356
|
+
: true
|
|
357
|
+
if (!fromOk || !toOk) {
|
|
358
|
+
throw new DeleteError(
|
|
359
|
+
'E_TRASH_CONFLICT',
|
|
360
|
+
'This recycle-bin entry points outside its own storage; restore is unsafe.',
|
|
361
|
+
{ trashId: entry.trashId, fromInsideSessions: fromOk, toInsideTrash: toOk },
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
289
365
|
const logInTrash = sessionLog !== null && (!canStat || await ops.pathExists(sessionLog.to))
|
|
290
366
|
const logInPlace = sessionLog !== null && canStat && await ops.pathExists(sessionLog.from)
|
|
291
367
|
const logMissing = sessionLog !== null && !logInTrash && !logInPlace
|
|
@@ -326,7 +402,7 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
|
|
|
326
402
|
run: async () => {
|
|
327
403
|
// `restoreEntry` is a historical API that does not produce an undo;
|
|
328
404
|
// calling an undefined undo during a later rollback used to throw.
|
|
329
|
-
const undo = await ops.memory.restoreEntry(
|
|
405
|
+
const undo = await ops.memory.restoreEntry(memorySummary)
|
|
330
406
|
return async () => {
|
|
331
407
|
if (typeof undo === 'function') await undo()
|
|
332
408
|
}
|
|
@@ -383,8 +459,15 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
|
|
|
383
459
|
durationMs: now() - startedAt,
|
|
384
460
|
source,
|
|
385
461
|
})
|
|
386
|
-
|
|
387
|
-
|
|
462
|
+
// Only clear the entry directory once its memory bundles are actually gone.
|
|
463
|
+
// `restoreEntry` removes the bundles it consumed; if memory files are still
|
|
464
|
+
// there (an adapter was uninstalled, a bundle was unreadable) removing the
|
|
465
|
+
// directory here would silently destroy the only remaining copy.
|
|
466
|
+
const memoryFilesLeft = typeof ops.hasFiles === 'function' && typeof trash?.entryDir === 'function'
|
|
467
|
+
? await ops.hasFiles(join(trash.entryDir(entry.trashId), 'memory')).catch(() => true)
|
|
468
|
+
: false
|
|
469
|
+
if (!memoryFilesLeft) await trash.remove(entry.trashId).catch(() => {})
|
|
470
|
+
return { trashId: entry.trashId, sessionId: entry.sessionId, memoryLeftOnDisk: memoryFilesLeft }
|
|
388
471
|
}
|
|
389
472
|
|
|
390
473
|
/**
|
|
@@ -405,7 +488,7 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
|
|
|
405
488
|
* @param {string} [input.requestId]
|
|
406
489
|
*/
|
|
407
490
|
export async function performPurge({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
|
|
408
|
-
if (!
|
|
491
|
+
if (!entryCapabilities(entry).canPurge) {
|
|
409
492
|
throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry cannot be purged in its current state.', { trashId: entry.trashId, status: entry.status })
|
|
410
493
|
}
|
|
411
494
|
const startedAt = now()
|
|
@@ -473,7 +556,6 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
|
|
|
473
556
|
action: 'purge',
|
|
474
557
|
result: result.rolledBack ? 'rolled-back' : 'rollback-incomplete',
|
|
475
558
|
sessionId: entry.sessionId,
|
|
476
|
-
title: entry.title,
|
|
477
559
|
trashId: entry.trashId,
|
|
478
560
|
actor,
|
|
479
561
|
requestId,
|
|
@@ -490,7 +572,6 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
|
|
|
490
572
|
action: 'purge',
|
|
491
573
|
result: 'success',
|
|
492
574
|
sessionId: entry.sessionId,
|
|
493
|
-
title: entry.title,
|
|
494
575
|
trashId: entry.trashId,
|
|
495
576
|
actor,
|
|
496
577
|
requestId,
|
|
@@ -538,8 +619,12 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
|
|
|
538
619
|
})
|
|
539
620
|
}
|
|
540
621
|
const holdsMemory = typeof ops.hasFiles === 'function' ? await ops.hasFiles(join(entryDir, 'memory')) : false
|
|
541
|
-
|
|
542
|
-
|
|
622
|
+
// The status half of the decision is shared with the UI; the two refusals
|
|
623
|
+
// below stay separate because their advice differs (purge vs restore).
|
|
624
|
+
const capabilities = entryCapabilities(entry, { hasLogFiles: holdsLog, hasMemoryFiles: holdsMemory })
|
|
625
|
+
if (holdsMemory || !capabilities.canDiscard) {
|
|
626
|
+
const which = holdsMemory ? 'memory files' : 'payload'
|
|
627
|
+
throw new DeleteError('E_TRASH_CONFLICT', `This incomplete entry still holds ${which}; restore or purge it instead of discarding it.`, {
|
|
543
628
|
trashId: entry.trashId,
|
|
544
629
|
status: entry.status,
|
|
545
630
|
})
|
|
@@ -552,7 +637,6 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
|
|
|
552
637
|
action: 'discard',
|
|
553
638
|
result: 'success',
|
|
554
639
|
sessionId: entry.sessionId,
|
|
555
|
-
title: entry.title,
|
|
556
640
|
trashId: entry.trashId,
|
|
557
641
|
status: entry.status,
|
|
558
642
|
actor,
|
package/lib/core/config.js
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
export const DEFAULTS = deepFreeze({
|
|
13
13
|
enabled: true,
|
|
14
14
|
requireSameOrigin: true,
|
|
15
|
+
// Refuse every request when the Host has no `connection` service. The
|
|
16
|
+
// loopback/origin fence below cannot distinguish two local processes, so
|
|
17
|
+
// without a connection layer any process on this machine could delete a
|
|
18
|
+
// conversation. Set false only for a composition that genuinely has no
|
|
19
|
+
// connection service and is trusted end to end.
|
|
20
|
+
requireConnection: true,
|
|
15
21
|
allowNonLoopback: false,
|
|
16
22
|
// Exact authorities a prefix-stripping reverse proxy may present in `Host`
|
|
17
23
|
// (`app.example`, `app.example:8443`, `[::1]:8443`). Empty = loopback only.
|
|
@@ -240,6 +246,7 @@ export function normalizeConfig(raw) {
|
|
|
240
246
|
const value = {
|
|
241
247
|
enabled: readBool(source.enabled, DEFAULTS.enabled, 'enabled', warnings),
|
|
242
248
|
requireSameOrigin: readBool(source.requireSameOrigin, DEFAULTS.requireSameOrigin, 'requireSameOrigin', warnings),
|
|
249
|
+
requireConnection: readBool(source.requireConnection, DEFAULTS.requireConnection, 'requireConnection', warnings),
|
|
243
250
|
allowNonLoopback: readBool(source.allowNonLoopback, DEFAULTS.allowNonLoopback, 'allowNonLoopback', warnings),
|
|
244
251
|
allowedHosts: readHostList(source.allowedHosts, 'allowedHosts', warnings),
|
|
245
252
|
allowOrphanSessions: readBool(source.allowOrphanSessions, DEFAULTS.allowOrphanSessions, 'allowOrphanSessions', warnings),
|
package/lib/core/errors.js
CHANGED
|
@@ -32,6 +32,11 @@ export const ERROR_STATUS = Object.freeze({
|
|
|
32
32
|
E_TRASH_CONFLICT: 409,
|
|
33
33
|
E_MEMORY_FAILED: 500,
|
|
34
34
|
E_LOCK_BUSY: 409,
|
|
35
|
+
// A mutation that never got its turn, and one whose client hung up first.
|
|
36
|
+
// Both mean "no work was done", which is exactly what the Client needs to
|
|
37
|
+
// know before it retries or refreshes.
|
|
38
|
+
E_QUEUE_TIMEOUT: 503,
|
|
39
|
+
E_ABANDONED: 409,
|
|
35
40
|
// Feedback channel: its own codes, so the Client can distinguish "this Host
|
|
36
41
|
// has no mail transport" from "the transport refused the message".
|
|
37
42
|
E_FEEDBACK_DISABLED: 503,
|
package/lib/core/file-lock.js
CHANGED
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
* - acquisition creates `<lockPath>` with `wx`, writes `{pid, nonce, at}`;
|
|
13
13
|
* - a holder refreshes the file mtime on a heartbeat, so a long operation is
|
|
14
14
|
* never mistaken for a crashed one;
|
|
15
|
-
* - a lock
|
|
16
|
-
*
|
|
15
|
+
* - a lock whose recorded pid is provably gone is stale and is broken at
|
|
16
|
+
* once; an ownerless (foreign/legacy) lock is stale only by age;
|
|
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;
|
|
17
19
|
* - release only removes the file when it still carries this owner's nonce,
|
|
18
20
|
* so a lock broken by a peer cannot make us delete the peer's lock.
|
|
19
21
|
*
|
|
20
|
-
* Every
|
|
21
|
-
*
|
|
22
|
+
* Every iteration sleeps and re-checks the wait budget, including the
|
|
23
|
+
* stale-break path: a lock file that cannot be removed (Windows exclusive
|
|
24
|
+
* handle, AV/indexer, read-only volume) used to spin without ever sleeping or
|
|
25
|
+
* throwing, which hung the whole mutation chain behind it.
|
|
22
26
|
*/
|
|
23
27
|
import * as defaultFs from 'node:fs/promises'
|
|
24
28
|
import { randomBytes } from 'node:crypto'
|
|
@@ -55,6 +59,10 @@ function parseOwner(raw) {
|
|
|
55
59
|
* @param {number} [options.staleMs] age after which an ownerless lock is broken
|
|
56
60
|
* @param {number} [options.heartbeatMs] mtime refresh interval while held
|
|
57
61
|
* @param {number} [options.retryMs] poll interval
|
|
62
|
+
* @param {number} [options.maxBreakFailures] consecutive failed stale-lock
|
|
63
|
+
* removals tolerated before giving up with `E_LOCK_BUSY`
|
|
64
|
+
* @param {(message: string) => void} [options.warn] receives lock files that
|
|
65
|
+
* could not be cleaned up (never silently swallowed)
|
|
58
66
|
* @param {() => number} [options.now]
|
|
59
67
|
* @param {(ms: number) => Promise<void>} [options.sleep]
|
|
60
68
|
* @param {(pid: number) => boolean} [options.isProcessAlive]
|
|
@@ -67,6 +75,8 @@ export function createFileLock({
|
|
|
67
75
|
staleMs = 120_000,
|
|
68
76
|
heartbeatMs = 5_000,
|
|
69
77
|
retryMs = 50,
|
|
78
|
+
maxBreakFailures = 3,
|
|
79
|
+
warn = () => {},
|
|
70
80
|
now = Date.now,
|
|
71
81
|
sleep = sleepDefault,
|
|
72
82
|
isProcessAlive = defaultIsProcessAlive,
|
|
@@ -84,9 +94,20 @@ export function createFileLock({
|
|
|
84
94
|
async function acquire({ timeoutMs: waitBudget = timeoutMs } = {}) {
|
|
85
95
|
const nonce = randomBytes(8).toString('hex')
|
|
86
96
|
const started = now()
|
|
97
|
+
const deadline = started + Math.max(0, Number(waitBudget) || 0)
|
|
87
98
|
let handle = null
|
|
99
|
+
let breakFailures = 0
|
|
100
|
+
|
|
101
|
+
const busyError = () => {
|
|
102
|
+
const busy = new Error(`The lock ${lockPath} is held by another process.`)
|
|
103
|
+
busy.code = 'E_LOCK_BUSY'
|
|
104
|
+
return busy
|
|
105
|
+
}
|
|
88
106
|
|
|
89
107
|
while (handle === null) {
|
|
108
|
+
// Checked before every attempt, including the stale-break path below:
|
|
109
|
+
// a lock that exists but cannot be removed must still end the wait.
|
|
110
|
+
if (now() >= deadline) throw busyError()
|
|
90
111
|
try {
|
|
91
112
|
handle = await fsApi.open(lockPath, 'wx')
|
|
92
113
|
} catch (error) {
|
|
@@ -106,15 +127,27 @@ export function createFileLock({
|
|
|
106
127
|
// Unreadable owner text is treated as ownerless, not as a crash.
|
|
107
128
|
}
|
|
108
129
|
const deadOwner = owner.pid !== null && !isProcessAlive(owner.pid)
|
|
109
|
-
|
|
110
|
-
|
|
130
|
+
// A provably dead owner is always breakable. An ownerless file (foreign
|
|
131
|
+
// 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)
|
|
134
|
+
if (stale) {
|
|
135
|
+
try {
|
|
136
|
+
await fsApi.rm(lockPath, { force: true })
|
|
137
|
+
breakFailures = 0
|
|
138
|
+
} catch (rmError) {
|
|
139
|
+
breakFailures += 1
|
|
140
|
+
if (breakFailures >= maxBreakFailures) {
|
|
141
|
+
const stuck = new Error(
|
|
142
|
+
`The lock ${lockPath} is stale but could not be removed (${rmError?.code ?? 'unknown'}).`,
|
|
143
|
+
)
|
|
144
|
+
stuck.code = 'E_LOCK_BUSY'
|
|
145
|
+
throw stuck
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
await sleep(retryMs)
|
|
111
149
|
continue
|
|
112
150
|
}
|
|
113
|
-
if (now() - started >= waitBudget) {
|
|
114
|
-
const busy = new Error(`The lock ${lockPath} is held by another process.`)
|
|
115
|
-
busy.code = 'E_LOCK_BUSY'
|
|
116
|
-
throw busy
|
|
117
|
-
}
|
|
118
151
|
await sleep(retryMs)
|
|
119
152
|
continue
|
|
120
153
|
}
|
|
@@ -142,7 +175,16 @@ export function createFileLock({
|
|
|
142
175
|
clearInterval(heartbeat)
|
|
143
176
|
try {
|
|
144
177
|
const owner = parseOwner(await fsApi.readFile(lockPath, 'utf8').catch(() => ''))
|
|
145
|
-
if (owner.nonce === nonce)
|
|
178
|
+
if (owner.nonce === nonce) {
|
|
179
|
+
try {
|
|
180
|
+
await fsApi.rm(lockPath, { force: true })
|
|
181
|
+
} 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'})`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
146
188
|
} finally {
|
|
147
189
|
await handle?.close().catch(() => {})
|
|
148
190
|
}
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* store path, so a "generic" adapter configured at the 灵枢 index cannot make
|
|
14
14
|
* the preview count the same entries twice.
|
|
15
15
|
*/
|
|
16
|
-
import { readFile, rm } from 'node:fs/promises'
|
|
17
|
-
import { dirname } from 'node:path'
|
|
16
|
+
import { readFile, readdir, rm } from 'node:fs/promises'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
18
|
import { DeleteError } from '../errors.js'
|
|
19
19
|
|
|
20
20
|
function emptySummary() {
|
|
@@ -185,5 +185,53 @@ export function createMemoryService({ adapters, config, now = Date.now }) {
|
|
|
185
185
|
await rm(dirname(bundlePath), { recursive: true, force: true }).catch(() => {})
|
|
186
186
|
}
|
|
187
187
|
},
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Rebuild a memory summary from the bundles a crashed delete left on disk.
|
|
191
|
+
*
|
|
192
|
+
* `exportForSession` writes `<entryDir>/memory/<adapter>/bundle.json` and
|
|
193
|
+
* only then returns; the caller writes the manifest afterwards. A crash in
|
|
194
|
+
* between used to strand those files: the manifest still said `total: 0`,
|
|
195
|
+
* restore answered "nothing left to restore", and the only copy of the
|
|
196
|
+
* user's memory became unreachable. Reading the directory back lets restore
|
|
197
|
+
* finish the job instead.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} entryDir the recycle-bin entry directory
|
|
200
|
+
* @returns {Promise<{adapters: object[], total: number, protected: number}>}
|
|
201
|
+
*/
|
|
202
|
+
async readExported(entryDir) {
|
|
203
|
+
if (typeof entryDir !== 'string' || entryDir === '') return emptySummary()
|
|
204
|
+
const root = join(entryDir, 'memory')
|
|
205
|
+
let dirents = []
|
|
206
|
+
try {
|
|
207
|
+
dirents = await readdir(root, { withFileTypes: true })
|
|
208
|
+
} catch {
|
|
209
|
+
return emptySummary()
|
|
210
|
+
}
|
|
211
|
+
const adapters = []
|
|
212
|
+
for (const dirent of dirents) {
|
|
213
|
+
if (!dirent.isDirectory()) continue
|
|
214
|
+
const bundlePath = join(root, dirent.name, 'bundle.json')
|
|
215
|
+
let parsed
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(await readFile(bundlePath, 'utf8'))
|
|
218
|
+
} catch {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
const count = typeof parsed?.count === 'number'
|
|
222
|
+
? parsed.count
|
|
223
|
+
: Array.isArray(parsed?.entries)
|
|
224
|
+
? parsed.entries.length
|
|
225
|
+
: 0
|
|
226
|
+
if (count <= 0) continue
|
|
227
|
+
adapters.push({
|
|
228
|
+
id: dirent.name,
|
|
229
|
+
label: byId.get(dirent.name)?.label ?? dirent.name,
|
|
230
|
+
count,
|
|
231
|
+
bundlePath,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
return { adapters, total: adapters.reduce((sum, adapter) => sum + adapter.count, 0), protected: 0 }
|
|
235
|
+
},
|
|
188
236
|
}
|
|
189
237
|
}
|
package/lib/core/policy.js
CHANGED
|
@@ -106,6 +106,13 @@ export function evaluateSessionPolicy({ session, workspace, live, mode, config }
|
|
|
106
106
|
warnings.push('The conversation is open in a live agent; it will be stopped before its log is removed.')
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// A soft delete also quiesces the session (`archiveSession(id, { stopActivity:
|
|
110
|
+
// true })`), so it interrupts a running turn exactly like a hard delete does.
|
|
111
|
+
// Say so before the click instead of after the round died.
|
|
112
|
+
if (mode === 'soft' && live) {
|
|
113
|
+
warnings.push('The conversation is running right now; a soft delete stops the current turn before moving it to the recycle bin.')
|
|
114
|
+
}
|
|
115
|
+
|
|
109
116
|
if (session?.blank === true) warnings.push('This is an empty conversation.')
|
|
110
117
|
|
|
111
118
|
return { ok: true, warnings }
|
package/lib/core/trash.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
18
18
|
import { join } from 'node:path'
|
|
19
|
+
import { DeleteError } from './errors.js'
|
|
19
20
|
|
|
20
21
|
const MANIFEST = 'manifest.json'
|
|
21
22
|
const TRASH_ID_PATTERN = /^[0-9a-z]{2,40}-[0-9a-z]{2,20}$/
|
|
@@ -66,7 +67,10 @@ export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkd
|
|
|
66
67
|
|
|
67
68
|
function entryDir(trashId) {
|
|
68
69
|
if (!isTrashId(trashId)) {
|
|
69
|
-
|
|
70
|
+
// A plain Error here escaped as `E_INTERNAL 500` and, worse, was
|
|
71
|
+
// distinguishable from `E_TRASH_NOT_FOUND 404`, which made malformed ids
|
|
72
|
+
// an existence probe. Keep it a typed, user-facing refusal.
|
|
73
|
+
throw new DeleteError('E_BAD_REQUEST', 'The recycle-bin id is malformed.', {})
|
|
70
74
|
}
|
|
71
75
|
return join(rootDir, trashId)
|
|
72
76
|
}
|
package/lib/host/api.js
CHANGED
|
@@ -42,11 +42,12 @@ import { randomBytes } from 'node:crypto'
|
|
|
42
42
|
import { mkdir, readdir, stat } from 'node:fs/promises'
|
|
43
43
|
import { join } from 'node:path'
|
|
44
44
|
import { DeleteError, ERROR_STATUS, asDeleteError, toWireError } from '../core/errors.js'
|
|
45
|
+
import { redactDetails } from '../core/redact.js'
|
|
45
46
|
import { createAuditLog } from '../core/audit.js'
|
|
46
|
-
import { createTrashStore } from '../core/trash.js'
|
|
47
|
+
import { createTrashStore, isTrashId } from '../core/trash.js'
|
|
47
48
|
import { createMemoryService } from '../core/memory/registry.js'
|
|
48
|
-
import { performSessionDelete, performPurge, performRestore, performDiscard } from '../core/cascade.js'
|
|
49
|
-
import { evaluateSessionPolicy } from '../core/policy.js'
|
|
49
|
+
import { performSessionDelete, performPurge, performRestore, performDiscard, entryCapabilities } from '../core/cascade.js'
|
|
50
|
+
import { evaluateSessionPolicy, isPathInside } from '../core/policy.js'
|
|
50
51
|
import { createSlidingWindowLimiter } from '../core/rate-limit.js'
|
|
51
52
|
import { createFileLock } from '../core/file-lock.js'
|
|
52
53
|
import { createCatalogOps, createSessionLogOps } from './catalog.js'
|
|
@@ -366,6 +367,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
366
367
|
lockPath: join(paths.dataDir, 'mutation.lock'),
|
|
367
368
|
timeoutMs: 30_000,
|
|
368
369
|
staleMs: 120_000,
|
|
370
|
+
// A lock file we cannot delete used to vanish into a silent `.catch`: the
|
|
371
|
+
// next acquire then found it stale, failed to break it, and spun forever.
|
|
372
|
+
warn: (message) => logger.warn?.(`[conversation-recycle-bin] ${message}`),
|
|
369
373
|
})
|
|
370
374
|
// Which UI asked: the desktop Electron shell and `dsh web` share this code,
|
|
371
375
|
// and the audit trail used to record both as `web-ui`.
|
|
@@ -392,6 +396,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
392
396
|
return false
|
|
393
397
|
}
|
|
394
398
|
},
|
|
399
|
+
// Manifest paths are data, not truth: a hand-edited manifest must not turn
|
|
400
|
+
// "restore" into a proxy for stat/move on arbitrary directories.
|
|
401
|
+
isInsideSessionsRoot: (target) => typeof target === 'string' && target !== '' && isPathInside(paths.sessionsRoot, target),
|
|
402
|
+
isInsideTrashDir: (target) => typeof target === 'string' && target !== '' && isPathInside(paths.trashDir, target),
|
|
395
403
|
}
|
|
396
404
|
|
|
397
405
|
/** @type {Map<string, { planKey: string, sessionIds: string[], includeMemory: boolean, mode: 'soft'|'hard', phrase: string, issuedAt: number, expiresAt: number, used: boolean }>} */
|
|
@@ -413,9 +421,29 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
413
421
|
* Serialize one mutation: in-process first (so two HTTP requests never
|
|
414
422
|
* interleave their transactions), then across processes (so a second Host
|
|
415
423
|
* sharing this DSH_HOME cannot write the same manifest concurrently).
|
|
424
|
+
*
|
|
425
|
+
* The in-process queue is bounded too. It used to be an unbounded promise
|
|
426
|
+
* chain: a request whose client had already given up still ran minutes
|
|
427
|
+
* later, and one stuck holder hung every later mutation forever. Now:
|
|
428
|
+
* - `shouldAbort` is checked before the operation runs (twice: when the
|
|
429
|
+
* chain reaches us and again after the cross-process lock), so a client
|
|
430
|
+
* that disconnected changes nothing;
|
|
431
|
+
* - the wait has a budget derived from the operation's own lock budget
|
|
432
|
+
* plus a grace period, so it can never fire before a legitimate holder
|
|
433
|
+
* finishes, and never waits forever either.
|
|
416
434
|
*/
|
|
417
|
-
function serialize(operation, { lockTimeoutMs } = {}) {
|
|
435
|
+
function serialize(operation, { lockTimeoutMs, shouldAbort } = {}) {
|
|
436
|
+
const graceMs = 60_000
|
|
437
|
+
const queueBudgetMs = lockTimeoutMs === undefined ? 30_000 + graceMs : lockTimeoutMs + graceMs
|
|
438
|
+
let abandoned = false
|
|
439
|
+
const aborted = () => typeof shouldAbort === 'function' && shouldAbort() === true
|
|
418
440
|
const next = mutationChain.then(async () => {
|
|
441
|
+
if (abandoned) {
|
|
442
|
+
throw new DeleteError('E_QUEUE_TIMEOUT', 'The recycle bin was busy; this request was not applied. Retry.', {})
|
|
443
|
+
}
|
|
444
|
+
if (aborted()) {
|
|
445
|
+
throw new DeleteError('E_ABANDONED', 'The client disconnected before this request ran; nothing was changed.', {})
|
|
446
|
+
}
|
|
419
447
|
// The lock lives in the data dir, which a fresh install has not created
|
|
420
448
|
// yet (the trash store and audit log mkdir their own targets lazily).
|
|
421
449
|
await mkdir(paths.dataDir, { recursive: true }).catch(() => {})
|
|
@@ -429,13 +457,39 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
429
457
|
throw error
|
|
430
458
|
}
|
|
431
459
|
try {
|
|
460
|
+
// Queued behind the lock: the caller may have gone in the meantime.
|
|
461
|
+
if (aborted()) {
|
|
462
|
+
throw new DeleteError('E_ABANDONED', 'The client disconnected before this request ran; nothing was changed.', {})
|
|
463
|
+
}
|
|
432
464
|
return await operation()
|
|
433
465
|
} finally {
|
|
434
466
|
await release()
|
|
435
467
|
}
|
|
436
468
|
})
|
|
437
469
|
mutationChain = next.then(() => undefined, () => undefined)
|
|
438
|
-
return
|
|
470
|
+
return new Promise((resolve, reject) => {
|
|
471
|
+
const timer = setTimeout(() => {
|
|
472
|
+
abandoned = true
|
|
473
|
+
reject(
|
|
474
|
+
new DeleteError(
|
|
475
|
+
'E_QUEUE_TIMEOUT',
|
|
476
|
+
'The recycle bin stayed busy for too long; this request was not applied. Retry.',
|
|
477
|
+
{ waitedMs: queueBudgetMs },
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
}, queueBudgetMs)
|
|
481
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
482
|
+
next.then(
|
|
483
|
+
(value) => {
|
|
484
|
+
clearTimeout(timer)
|
|
485
|
+
resolve(value)
|
|
486
|
+
},
|
|
487
|
+
(error) => {
|
|
488
|
+
clearTimeout(timer)
|
|
489
|
+
reject(error)
|
|
490
|
+
},
|
|
491
|
+
)
|
|
492
|
+
})
|
|
439
493
|
}
|
|
440
494
|
|
|
441
495
|
function requestId() {
|
|
@@ -443,6 +497,20 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
443
497
|
return `dc-${now().toString(36)}-${requestCounter.toString(36)}-${randomBytes(2).toString('hex')}`
|
|
444
498
|
}
|
|
445
499
|
|
|
500
|
+
/**
|
|
501
|
+
* True once the HTTP client has gone away. Mutations consult this before they
|
|
502
|
+
* touch disk: a request the user cancelled (or a tab that closed) must not
|
|
503
|
+
* delete a conversation afterwards.
|
|
504
|
+
*/
|
|
505
|
+
function clientGone(req, res) {
|
|
506
|
+
if (res?.destroyed === true) return true
|
|
507
|
+
if (res?.writableEnded === true) return true
|
|
508
|
+
if (res?.socket && res.socket.destroyed === true) return true
|
|
509
|
+
if (req?.destroyed === true) return true
|
|
510
|
+
if (req?.aborted === true) return true
|
|
511
|
+
return false
|
|
512
|
+
}
|
|
513
|
+
|
|
446
514
|
/**
|
|
447
515
|
* Effective batch ceiling for this moment: the configured batch size capped
|
|
448
516
|
* by the remaining per-minute budget. Without this, a batch of 21–50 always
|
|
@@ -765,6 +833,11 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
765
833
|
*/
|
|
766
834
|
async function discardEntry(trashId, confirmPhrase) {
|
|
767
835
|
if (typeof trashId !== 'string' || trashId === '') throw new DeleteError('E_BAD_REQUEST', 'A recycle-bin id is required.', {})
|
|
836
|
+
// Validate before anything joins it into a path. `trash.read` answers null
|
|
837
|
+
// for a malformed id, which used to fall through to the "directory may
|
|
838
|
+
// still be on disk" branch below and turn this into an arbitrary-path
|
|
839
|
+
// existence probe.
|
|
840
|
+
if (!isTrashId(trashId)) throw new DeleteError('E_BAD_REQUEST', 'The recycle-bin id is malformed.', {})
|
|
768
841
|
if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() !== 'DELETE') {
|
|
769
842
|
throw new DeleteError('E_CONFIRM_PHRASE', 'Type “DELETE” to discard this incomplete entry.', { expected: 'DELETE' })
|
|
770
843
|
}
|
|
@@ -869,6 +942,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
869
942
|
memoryAdapters: Array.isArray(entry.memory?.adapters) ? entry.memory.adapters : [],
|
|
870
943
|
hasSessionLog: entry.sessionLog !== null && entry.sessionLog !== undefined,
|
|
871
944
|
status: entry.status,
|
|
945
|
+
// The UI renders buttons from the same predicate the routes enforce, so a
|
|
946
|
+
// row can no longer offer an action that will be refused.
|
|
947
|
+
capabilities: entryCapabilities(entry),
|
|
872
948
|
error: entry.error ?? null,
|
|
873
949
|
}
|
|
874
950
|
}
|
|
@@ -913,7 +989,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
913
989
|
const parsed = Number.parseInt(String(limit ?? '50'), 10)
|
|
914
990
|
const safe = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 200) : 50
|
|
915
991
|
const result = await audit.tail(safe)
|
|
916
|
-
|
|
992
|
+
// The audit file is on disk in full; the wire copy still must not carry
|
|
993
|
+
// absolute host paths. `redactDetails` shortens path-like keys and strips
|
|
994
|
+
// absolute paths out of prose, leaving ids/codes/counts intact.
|
|
995
|
+
return { records: redactDetails(result.records), malformed: result.malformed }
|
|
917
996
|
}
|
|
918
997
|
|
|
919
998
|
/**
|
|
@@ -1012,7 +1091,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1012
1091
|
: []
|
|
1013
1092
|
const batchCount = Math.max(1, rawIds.length)
|
|
1014
1093
|
const lockBudget = Math.min(900_000, 15_000 + batchCount * 16_000 + batchCount * config.liveDrainMs)
|
|
1015
|
-
const result = await serialize(() => deletePlan(body), {
|
|
1094
|
+
const result = await serialize(() => deletePlan(body), {
|
|
1095
|
+
lockTimeoutMs: lockBudget,
|
|
1096
|
+
shouldAbort: () => clientGone(req, res),
|
|
1097
|
+
})
|
|
1016
1098
|
// `ok` means "the plan was executed", not "every item succeeded".
|
|
1017
1099
|
// A partial batch carries `results` + `failures`; marking the whole
|
|
1018
1100
|
// response as an error hid those results and made the Client show
|
|
@@ -1021,17 +1103,26 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1021
1103
|
return
|
|
1022
1104
|
}
|
|
1023
1105
|
if (path === '/trash/restore') {
|
|
1024
|
-
const result = await serialize(() => restoreEntry(body?.trashId), {
|
|
1106
|
+
const result = await serialize(() => restoreEntry(body?.trashId), {
|
|
1107
|
+
lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
|
|
1108
|
+
shouldAbort: () => clientGone(req, res),
|
|
1109
|
+
})
|
|
1025
1110
|
sendJson(res, 200, { ok: true, ...result })
|
|
1026
1111
|
return
|
|
1027
1112
|
}
|
|
1028
1113
|
if (path === '/trash/purge') {
|
|
1029
|
-
const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase), {
|
|
1114
|
+
const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase), {
|
|
1115
|
+
lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
|
|
1116
|
+
shouldAbort: () => clientGone(req, res),
|
|
1117
|
+
})
|
|
1030
1118
|
sendJson(res, 200, { ok: true, ...result })
|
|
1031
1119
|
return
|
|
1032
1120
|
}
|
|
1033
1121
|
if (path === '/trash/discard') {
|
|
1034
|
-
const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase), {
|
|
1122
|
+
const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase), {
|
|
1123
|
+
lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
|
|
1124
|
+
shouldAbort: () => clientGone(req, res),
|
|
1125
|
+
})
|
|
1035
1126
|
sendJson(res, 200, { ok: true, ...result })
|
|
1036
1127
|
return
|
|
1037
1128
|
}
|
|
@@ -1055,24 +1146,47 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1055
1146
|
* an update or a quit could kill a Host in the middle of a deletion
|
|
1056
1147
|
* transaction.
|
|
1057
1148
|
*
|
|
1058
|
-
* The service is probed, not injected
|
|
1059
|
-
*
|
|
1149
|
+
* The service is probed, not injected. With `requireConnection` (the default)
|
|
1150
|
+
* a composition that has no `connection` service refuses every mutation
|
|
1151
|
+
* instead of silently degrading to the loopback/origin fence alone — that
|
|
1152
|
+
* fence cannot tell one local process from another, so "no connection layer"
|
|
1153
|
+
* means "any process on this machine can delete". Operators who deliberately
|
|
1154
|
+
* run such a composition set `requireConnection: false`.
|
|
1060
1155
|
*/
|
|
1061
1156
|
const handler = (req, res) => {
|
|
1062
1157
|
void (async () => {
|
|
1063
1158
|
const connection = connectionService()
|
|
1064
|
-
|
|
1159
|
+
const hasConnectionFence = connection !== undefined && typeof connection.requestRejection === 'function'
|
|
1160
|
+
if (!hasConnectionFence && config.requireConnection !== false) {
|
|
1161
|
+
logger.error?.('[conversation-recycle-bin] no connection service; refusing request because requireConnection is enabled')
|
|
1162
|
+
sendJson(res, 401, toWireError(new DeleteError('E_UNAUTHORIZED', 'This Host has no connection layer to authenticate the request; set requireConnection: false to allow it.', {})))
|
|
1163
|
+
return
|
|
1164
|
+
}
|
|
1165
|
+
if (hasConnectionFence) {
|
|
1065
1166
|
let rejection
|
|
1066
1167
|
try {
|
|
1067
1168
|
rejection = connection.requestRejection(req)
|
|
1068
|
-
} catch {
|
|
1069
|
-
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
// Fail closed. A connection layer that throws cannot be read as
|
|
1171
|
+
// "no opinion": letting the request through here would turn any
|
|
1172
|
+
// auth-layer fault into an unauthenticated delete.
|
|
1173
|
+
logger.error?.('[conversation-recycle-bin] connection check failed:', error)
|
|
1174
|
+
sendJson(res, 503, toWireError(new DeleteError('E_INTERNAL', 'The Host could not verify this request.', {})))
|
|
1175
|
+
return
|
|
1070
1176
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1177
|
+
// The documented contract is `401`, `403`, or undefined for "allowed".
|
|
1178
|
+
// Anything else (including a future `503`-style value) is a refusal:
|
|
1179
|
+
// only an explicit absence of a verdict lets the request through.
|
|
1180
|
+
if (rejection !== undefined && rejection !== null) {
|
|
1181
|
+
if (rejection === 401) {
|
|
1182
|
+
sendJson(res, 401, toWireError(new DeleteError('E_UNAUTHORIZED', 'This request is not authenticated with the Host.', {})))
|
|
1183
|
+
return
|
|
1184
|
+
}
|
|
1185
|
+
if (rejection === 403) {
|
|
1186
|
+
sendJson(res, 403, toWireError(new DeleteError('E_FORBIDDEN_ORIGIN', 'The Host refused this request at the connection layer.', {})))
|
|
1187
|
+
return
|
|
1188
|
+
}
|
|
1189
|
+
sendJson(res, 503, toWireError(new DeleteError('E_INTERNAL', 'The Host could not verify this request.', { verdict: rejection })))
|
|
1076
1190
|
return
|
|
1077
1191
|
}
|
|
1078
1192
|
}
|
package/lib/host/gateway.js
CHANGED
|
@@ -169,13 +169,27 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function isLive(sessionId) {
|
|
172
|
+
// Two independent signals: the in-memory session table, and the agent
|
|
173
|
+
// registry the Host itself uses to compute a row's `running` flag. A
|
|
174
|
+
// composition that publishes only `agents` used to look "not live" here,
|
|
175
|
+
// which skipped quiescing and moved a running conversation's log.
|
|
172
176
|
const sessions = service('sessions')
|
|
173
|
-
if (sessions
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
if (sessions !== undefined && typeof sessions.get === 'function') {
|
|
178
|
+
try {
|
|
179
|
+
if (sessions.get(sessionId) !== undefined) return true
|
|
180
|
+
} catch {
|
|
181
|
+
// fall through to the agent registry
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const agents = service('agents')
|
|
185
|
+
if (agents !== undefined && typeof agents.get === 'function') {
|
|
186
|
+
try {
|
|
187
|
+
if (agents.get(sessionId) !== undefined && agents.get(sessionId) !== null) return true
|
|
188
|
+
} catch {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
178
191
|
}
|
|
192
|
+
return false
|
|
179
193
|
}
|
|
180
194
|
|
|
181
195
|
/**
|
|
@@ -279,6 +293,11 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
279
293
|
const entity = workspace !== null && typeof workspace.sessionIds?.indexOf === 'function'
|
|
280
294
|
? workspace.sessionIds.indexOf(sessionId)
|
|
281
295
|
: -1
|
|
296
|
+
// `controller.list()` does not have to carry archive state, and treating
|
|
297
|
+
// its silence as "not archived" made restore un-hide conversations the
|
|
298
|
+
// user had archived. The workspace registry is the authoritative record,
|
|
299
|
+
// so it wins when it knows about the session.
|
|
300
|
+
const state = await readWorkspaceState()
|
|
282
301
|
return {
|
|
283
302
|
id: sessionId,
|
|
284
303
|
title: projection?.title || fromList?.title || '',
|
|
@@ -287,8 +306,8 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
|
|
|
287
306
|
blank: projection?.blank ?? fromList?.blank ?? false,
|
|
288
307
|
running: fromList?.running ?? false,
|
|
289
308
|
live: isLive(sessionId),
|
|
290
|
-
archived: fromList?.archived
|
|
291
|
-
pinned: fromList?.pinned
|
|
309
|
+
archived: state.archived.has(sessionId) || fromList?.archived === true,
|
|
310
|
+
pinned: state.pinned.has(sessionId) || fromList?.pinned === true,
|
|
292
311
|
createdAt: snapshot?.header?.createdAt ?? null,
|
|
293
312
|
updatedAt: fromList?.updatedAt ?? null,
|
|
294
313
|
events: eventCountFor(sessionId, snapshot),
|
package/lib/host/lifecycle.js
CHANGED
|
@@ -55,7 +55,13 @@ export function createLifecycleOps({ ctx, gateway, config, now = Date.now, delay
|
|
|
55
55
|
if (probe !== undefined) return probe === true
|
|
56
56
|
gateway.invalidate()
|
|
57
57
|
const current = await gateway.inspect(sessionId)
|
|
58
|
-
|
|
58
|
+
// Gone from every source: nothing left to wait for.
|
|
59
|
+
if (current === null) return false
|
|
60
|
+
if (typeof current.running === 'boolean') return current.running
|
|
61
|
+
// Unknown is NOT "stopped". Treating it that way moved the log out from
|
|
62
|
+
// under a possibly-running turn; the bounded drain below is what keeps this
|
|
63
|
+
// from waiting forever, and its refusal rolls the archive back.
|
|
64
|
+
return true
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
return {
|
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.8",
|
|
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",
|