dsh-conversation-recycle-bin 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,53 @@
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.9] — 2026-10-27
7
+
8
+ 0.3.8 的紧急修复:**所有删除都会被拒绝**,返回 `409 E_ABANDONED`("The client disconnected before this request ran; nothing was changed.")。0.3.8 发布后不到一天即被用户在使用中撞到。
9
+
10
+ ### Fixed
11
+
12
+ - **`clientGone()` 把每一个正常请求都当成"客户端已断开"**:0.3.8 为了修 R-01(客户端中止后 Host 照样执行),在 `lib/host/api.js` 里加了断连检测,其中一条是 `req.destroyed === true`。但 Node 的 `IncomingMessage` 在请求体被读完后就自动 destroy 可读侧,所以**任何一次正常 POST 在读完 body 之后 `req.destroyed` 都是 `true`**(Node 24 实测:`{req.destroyed:true, req.aborted:false, req.complete:true, res.destroyed:false, res.socket.destroyed:false}`)。结果是删除、恢复、彻底删除、丢弃四条路由全部在开跑前被判为"客户端已断开"。
13
+ 现在只用**能在正常请求中存活**的信号:`res.destroyed`、`res.writableEnded`、`res.socket`/`req.socket` 的 `destroyed`。`req.destroyed` 与已废弃的 `req.aborted` 不再参与判定,代码注释里记了实测值,避免以后有人再加回去。
14
+ - 新增回归用例 **"queue: a fully read body is not a disconnect"**:构造一个 `req.destroyed/readableEnded/complete` 全为 `true` 的请求,断言删除仍然成功、会话确实进了回收站。0.3.8 的写法会让这条用例失败。
15
+
16
+ ### Maintenance
17
+
18
+ - 测试 152 → **153**;`lib/version.js` 与 `package.json` 同步到 0.3.9。
19
+
20
+ ## [0.3.8] — 2026-10-26
21
+
22
+ 技术方案评审团(架构 / 安全 / 验收三方)对 0.3.7 的复核结论是 15 条问题、142 条测试全绿但 **NEEDS WORK**。本版把它们全部修掉,测试 142 → **152**。
23
+
24
+ ### Fixed — 数据安全
25
+
26
+ - **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()` 从磁盘重建摘要。
27
+ - **ARCH-1 次生风险**:恢复成功后原本无条件 `trash.remove()`,记忆未回灌时会删掉唯一副本。现在只有 `memory/` 目录确实为空(`ops.hasFiles` 探测,异常按"仍有文件"处理)才删条目,返回值新增 `memoryLeftOnDisk`。
28
+ - **R-02 缺 `sessions` 服务时硬删运行中会话**:`lib/host/gateway.js` 的 `isLive()` 原先只看 `sessions`,而宿主的运行判定看 `agents`。现在两个信号任一为真即视为 live,不再跳过 quiesce。
29
+ - **ARCH-3 运行态未知被当成"没在跑"**:`lib/host/lifecycle.js` 的 `inspectRunning()` 现在对"探针与 inspect 都给不出布尔值"的情形 fail-closed 返回 `true`,由 `liveDrainMs` 的 deadline 兜底并回滚归档。
30
+
31
+ ### Fixed — 可用性
32
+
33
+ - **ARCH-2 / R-01 请求永久挂起(P0)**:两层都有问题,一并修。
34
+ - 跨进程锁 `lib/core/file-lock.js`:stale 分支删不掉锁文件时**无 sleep、无 deadline**,实测 3 秒内自旋 3235 次、永不返回。现在每轮都 `sleep`、循环顶部检查预算,连续 3 次删不掉即抛 `E_LOCK_BUSY`;stale 判定改为"pid 可证明已死 → 立即破锁 / 无 owner 只按 age / **持有者活着绝不按 age 破**"(长 GC 不再放第二个写者进来)。
35
+ - 进程内队列 `lib/host/api.js` 的 `serialize()`:`mutationChain.then` 没有任何预算,客户端中止后 Host 照样执行。现在队列预算 = `lockTimeoutMs + 60s` 宽限,超时抛新增的 `E_QUEUE_TIMEOUT`(503);并在"开跑前"与"拿到跨进程锁后"各查一次客户端是否已断开(`clientGone()`),断开则抛新增的 `E_ABANDONED`(409),绝不落盘。
36
+ - **R-07 软删静默掐断当前回合**:软删同样 `archiveSession(id, { stopActivity: true })`,会中断正在跑的回合。`lib/core/policy.js` 现在对 `mode === 'soft' && live` 给出明确 warning,预览页在点击前就会显示。
37
+ - **R-03 恢复后把"已归档"变成"可见"**:`inspect()` 原先 `archived: fromList?.archived ?? false`,而 `controller.list()` 不保证携带归档态。现在以 `workspaceRegistry` 为权威(`state.archived.has(id) || fromList?.archived === true`,pinned 同理),恢复才真正"回到删除前的状态"。
38
+ - **ARCH-5 能力判定口径分裂**:新增 `entryCapabilities(entry, disk)` 统一 restore/purge/discard 的判定,`serializeEntry()` 额外输出 `capabilities`,避免再出现"UI 给按钮、三个接口全拒"的死胡同。
39
+
40
+ ### Fixed — 安全加固
41
+
42
+ - **S1 鉴权层异常时 fail-open**:`connection.requestRejection()` 抛错原先被 `catch { rejection = undefined }` 吞掉并放行。现在记 `logger.error` 并返回 503 `E_INTERNAL`;任何非 `undefined/null` 的拒绝值一律拒绝(`401`→401、`403`→403、其它→503)。
43
+ - **S5 无 connection 服务时本机任意进程可删**:新增 `config.requireConnection`(**默认 `true`**)。组合里没有 `connection` 服务时,所有路由返回 401 `E_UNAUTHORIZED` 并提示如何显式降级;`config.schema.json` 已同步。
44
+ - **S2 非法回收站 id 变成目录存在性探测**:`discard` 入口先过 `isTrashId()`,`trash.entryDir()` 也改抛 `DeleteError('E_BAD_REQUEST')` 而不是普通 `Error`(原先逃逸成 500,且与 404 可区分)。
45
+ - **S3 manifest 可指向任意路径**:恢复前校验 `sessionLog.from` 必须在 `sessionsRoot` 内、`sessionLog.to` 必须在回收站目录内,否则 `E_TRASH_CONFLICT`。
46
+ - **S4 彻底删除后标题仍留在审计里**:purge / discard 的审计记录不再写 `title`;`/trash/audit` 的返回统一过 `redactDetails()`,绝对路径不上线。
47
+
48
+ ### Maintenance
49
+
50
+ - 测试 142 → **152**:新增"stale 锁删不掉必须报错而不是自旋"、"非法 id 必须 400"、"鉴权层抛错必须 503"、"客户端已断开不得执行"、"记忆 bundle 必须落盘"、"pending 条目可从磁盘恢复"、"manifest 越界必须拒绝"、"purge 审计不含标题"、"软删运行中会话必须有 warning"、"无 connection 服务默认被拒"。
51
+ - `lib/version.js` 与 `package.json` 同步到 0.3.8。
52
+
6
53
  ## [0.3.7] — 2026-10-24
7
54
 
8
55
  ### Fixed
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
 
@@ -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": [],
@@ -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 (!RESTORABLE_STATUSES.has(previousStatus)) {
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
- const hasMemory = entry.includeMemory === true && entry.memory !== null && typeof entry.memory === 'object' && (entry.memory.total ?? 0) > 0
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(entry.memory)
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
- await trash.remove(entry.trashId).catch(() => {})
387
- return { trashId: entry.trashId, sessionId: entry.sessionId }
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 (!['trashed', 'restored'].includes(entry.status)) {
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
- if (holdsMemory) {
542
- throw new DeleteError('E_TRASH_CONFLICT', 'This incomplete entry still holds memory files; restore or purge it instead of discarding it.', {
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,
@@ -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),
@@ -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,
@@ -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 is stale when its mtime is older than `staleMs`, or when the
16
- * recorded pid is provably gone; a stale lock is removed and retried;
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 wait sleeps, and the wait budget is checked on every iteration: a
21
- * held lock can never spin forever (the bug this module replaces).
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
- if (age > staleMs || deadOwner) {
110
- await fsApi.rm(lockPath, { force: true }).catch(() => {})
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) await fsApi.rm(lockPath, { force: true }).catch(() => {})
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
  }
@@ -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
- throw new Error(`invalid trash id: ${String(trashId)}`)
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 next
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,26 @@ 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
+ // Only signals that survive a normal, fully-read request count here.
507
+ // `req.destroyed` is NOT one of them: Node's IncomingMessage auto-destroys
508
+ // its readable side once the body is consumed, so it is `true` on every
509
+ // healthy POST after the body has been read (measured on Node 24:
510
+ // `{req.destroyed:true, req.aborted:false, req.complete:true,
511
+ // res.destroyed:false, res.socket.destroyed:false}`). Using it rejected
512
+ // every deletion with E_ABANDONED in 0.3.8.
513
+ if (res?.destroyed === true) return true
514
+ if (res?.writableEnded === true) return true
515
+ const socket = res?.socket ?? req?.socket
516
+ if (socket !== null && socket !== undefined && socket.destroyed === true) return true
517
+ return false
518
+ }
519
+
446
520
  /**
447
521
  * Effective batch ceiling for this moment: the configured batch size capped
448
522
  * by the remaining per-minute budget. Without this, a batch of 21–50 always
@@ -765,6 +839,11 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
765
839
  */
766
840
  async function discardEntry(trashId, confirmPhrase) {
767
841
  if (typeof trashId !== 'string' || trashId === '') throw new DeleteError('E_BAD_REQUEST', 'A recycle-bin id is required.', {})
842
+ // Validate before anything joins it into a path. `trash.read` answers null
843
+ // for a malformed id, which used to fall through to the "directory may
844
+ // still be on disk" branch below and turn this into an arbitrary-path
845
+ // existence probe.
846
+ if (!isTrashId(trashId)) throw new DeleteError('E_BAD_REQUEST', 'The recycle-bin id is malformed.', {})
768
847
  if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() !== 'DELETE') {
769
848
  throw new DeleteError('E_CONFIRM_PHRASE', 'Type “DELETE” to discard this incomplete entry.', { expected: 'DELETE' })
770
849
  }
@@ -869,6 +948,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
869
948
  memoryAdapters: Array.isArray(entry.memory?.adapters) ? entry.memory.adapters : [],
870
949
  hasSessionLog: entry.sessionLog !== null && entry.sessionLog !== undefined,
871
950
  status: entry.status,
951
+ // The UI renders buttons from the same predicate the routes enforce, so a
952
+ // row can no longer offer an action that will be refused.
953
+ capabilities: entryCapabilities(entry),
872
954
  error: entry.error ?? null,
873
955
  }
874
956
  }
@@ -913,7 +995,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
913
995
  const parsed = Number.parseInt(String(limit ?? '50'), 10)
914
996
  const safe = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 200) : 50
915
997
  const result = await audit.tail(safe)
916
- return { records: result.records, malformed: result.malformed }
998
+ // The audit file is on disk in full; the wire copy still must not carry
999
+ // absolute host paths. `redactDetails` shortens path-like keys and strips
1000
+ // absolute paths out of prose, leaving ids/codes/counts intact.
1001
+ return { records: redactDetails(result.records), malformed: result.malformed }
917
1002
  }
918
1003
 
919
1004
  /**
@@ -1012,7 +1097,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1012
1097
  : []
1013
1098
  const batchCount = Math.max(1, rawIds.length)
1014
1099
  const lockBudget = Math.min(900_000, 15_000 + batchCount * 16_000 + batchCount * config.liveDrainMs)
1015
- const result = await serialize(() => deletePlan(body), { lockTimeoutMs: lockBudget })
1100
+ const result = await serialize(() => deletePlan(body), {
1101
+ lockTimeoutMs: lockBudget,
1102
+ shouldAbort: () => clientGone(req, res),
1103
+ })
1016
1104
  // `ok` means "the plan was executed", not "every item succeeded".
1017
1105
  // A partial batch carries `results` + `failures`; marking the whole
1018
1106
  // response as an error hid those results and made the Client show
@@ -1021,17 +1109,26 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1021
1109
  return
1022
1110
  }
1023
1111
  if (path === '/trash/restore') {
1024
- const result = await serialize(() => restoreEntry(body?.trashId), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
1112
+ const result = await serialize(() => restoreEntry(body?.trashId), {
1113
+ lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
1114
+ shouldAbort: () => clientGone(req, res),
1115
+ })
1025
1116
  sendJson(res, 200, { ok: true, ...result })
1026
1117
  return
1027
1118
  }
1028
1119
  if (path === '/trash/purge') {
1029
- const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
1120
+ const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase), {
1121
+ lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
1122
+ shouldAbort: () => clientGone(req, res),
1123
+ })
1030
1124
  sendJson(res, 200, { ok: true, ...result })
1031
1125
  return
1032
1126
  }
1033
1127
  if (path === '/trash/discard') {
1034
- const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase), { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
1128
+ const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase), {
1129
+ lockTimeoutMs: TRASH_LOCK_BUDGET_MS,
1130
+ shouldAbort: () => clientGone(req, res),
1131
+ })
1035
1132
  sendJson(res, 200, { ok: true, ...result })
1036
1133
  return
1037
1134
  }
@@ -1055,24 +1152,47 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1055
1152
  * an update or a quit could kill a Host in the middle of a deletion
1056
1153
  * transaction.
1057
1154
  *
1058
- * The service is probed, not injected: a composition without `connection`
1059
- * keeps working behind this route's own loopback/origin fence.
1155
+ * The service is probed, not injected. With `requireConnection` (the default)
1156
+ * a composition that has no `connection` service refuses every mutation
1157
+ * instead of silently degrading to the loopback/origin fence alone — that
1158
+ * fence cannot tell one local process from another, so "no connection layer"
1159
+ * means "any process on this machine can delete". Operators who deliberately
1160
+ * run such a composition set `requireConnection: false`.
1060
1161
  */
1061
1162
  const handler = (req, res) => {
1062
1163
  void (async () => {
1063
1164
  const connection = connectionService()
1064
- if (connection !== undefined && typeof connection.requestRejection === 'function') {
1165
+ const hasConnectionFence = connection !== undefined && typeof connection.requestRejection === 'function'
1166
+ if (!hasConnectionFence && config.requireConnection !== false) {
1167
+ logger.error?.('[conversation-recycle-bin] no connection service; refusing request because requireConnection is enabled')
1168
+ sendJson(res, 401, toWireError(new DeleteError('E_UNAUTHORIZED', 'This Host has no connection layer to authenticate the request; set requireConnection: false to allow it.', {})))
1169
+ return
1170
+ }
1171
+ if (hasConnectionFence) {
1065
1172
  let rejection
1066
1173
  try {
1067
1174
  rejection = connection.requestRejection(req)
1068
- } catch {
1069
- rejection = undefined
1175
+ } catch (error) {
1176
+ // Fail closed. A connection layer that throws cannot be read as
1177
+ // "no opinion": letting the request through here would turn any
1178
+ // auth-layer fault into an unauthenticated delete.
1179
+ logger.error?.('[conversation-recycle-bin] connection check failed:', error)
1180
+ sendJson(res, 503, toWireError(new DeleteError('E_INTERNAL', 'The Host could not verify this request.', {})))
1181
+ return
1070
1182
  }
1071
- if (rejection === 401 || rejection === 403) {
1072
- const error = rejection === 401
1073
- ? new DeleteError('E_UNAUTHORIZED', 'This request is not authenticated with the Host.', {})
1074
- : new DeleteError('E_FORBIDDEN_ORIGIN', 'The Host refused this request at the connection layer.', {})
1075
- sendJson(res, rejection, toWireError(error))
1183
+ // The documented contract is `401`, `403`, or undefined for "allowed".
1184
+ // Anything else (including a future `503`-style value) is a refusal:
1185
+ // only an explicit absence of a verdict lets the request through.
1186
+ if (rejection !== undefined && rejection !== null) {
1187
+ if (rejection === 401) {
1188
+ sendJson(res, 401, toWireError(new DeleteError('E_UNAUTHORIZED', 'This request is not authenticated with the Host.', {})))
1189
+ return
1190
+ }
1191
+ if (rejection === 403) {
1192
+ sendJson(res, 403, toWireError(new DeleteError('E_FORBIDDEN_ORIGIN', 'The Host refused this request at the connection layer.', {})))
1193
+ return
1194
+ }
1195
+ sendJson(res, 503, toWireError(new DeleteError('E_INTERNAL', 'The Host could not verify this request.', { verdict: rejection })))
1076
1196
  return
1077
1197
  }
1078
1198
  }
@@ -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 === undefined || typeof sessions.get !== 'function') return false
174
- try {
175
- return sessions.get(sessionId) !== undefined
176
- } catch {
177
- return false
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 ?? false,
291
- pinned: fromList?.pinned ?? false,
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),
@@ -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
- return current !== null && current.running === true
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
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.3.7'
8
+ export const PLUGIN_VERSION = '0.3.9'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
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",