dsh-conversation-recycle-bin 0.3.2 → 0.3.4

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,52 @@
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.4] — 2026-10-20
7
+
8
+ 对应 `AUDIT-2026-10-20-last3-versions.md` 的四条非阻塞隐患 N1–N4,以及测试驱动下新发现的 P3 回归 N5。
9
+
10
+ ### Fixed
11
+
12
+ - **N1:批量删除中途限流会静默丢尾项**:`deletePlan` 循环里 `!reservation.ok` 分支的 `break` 改为 `continue`。0.3.3 之前当第二 Host 在 preflight 与 take 之间抢走额度、或 `sessions.length > limiter.remaining`(跨进程 / 时间窗滑动)时,第一条被拒之后的所有 session 既不进 `results` 也不进 `failures`——一次 50 项批量 + 20/min 上限下,第 22–50 项从 UI 反馈里凭空消失。`continue` 让每一项各得一条 `E_RATE_LIMITED` 记录;首个项目预算不足直接抛 429 的既有契约不变。新增 `test/api.test.mjs` 用例:monkey-patch limiter 让 take 从第二次起即失败,断言 3 项批量 → 1 结果 + 2 失败。
13
+ - **N2:审计 `tail()` 字节窗口对齐到 `\n` 时丢一条完整记录**:`start > 0` 分支不再无条件 `rawLines.slice(1)`——先读 `start-1` 一字节,`\n` 意味着窗口从行首开始,`rawLines[0]` 是完整 JSON 保留;否则才丢。同时 `CHUNK` 抽为 `createAuditLog({ tailWindowBytes })` 便于回归。新增 `test/trash-audit.test.mjs` 用例:对齐窗口断言三条完整记录全在,偏移 1 字节窗口断言撕裂片段被正确丢弃。
14
+ - **N5(连带发现):`tail()` 从未真正做窗口读**:Node `fs.readFile(path, {encoding,flag,signal})` 不接受 `start/end`,0.3.3 P3「只读尾部 256KB」的实现其实每次仍全量读盘。改用 `fsApi.open(filePath, 'r')` + `FileHandle.read(buffer, offset, length, position)` 的位置化读,`stat` 也走 handle;`fsApi` 类型签名加 `open`。修复后 5MB 审计文件 tail 只吃 256KB 内存,与 P3 原意一致。
15
+ - **N3:HTTP 反馈通道对自定义鉴权头也强加 `Bearer ` 前缀**:`deliverHttp` 现只在 `authHeader === 'Authorization'` 时前缀 `Bearer `(RFC 7235 语法的合法要求),其它如 `X-Api-Key` / `x-goog-api-key` 直接发送裸 token——多数非 `Authorization` 中转拒绝 Bearer 前缀,此前配任何自定义头都会 401。默认 `Authorization` 通道逐字节不变。新增 `test/feedback.test.mjs` 双 case 用例(Bearer vs 裸 token)。
16
+ - **N4:`visitorAsFrom: true` 的 UI 提示未说明与常见发信方冲突**:`feedback.emailHintAsFrom` 词条中英各扩写为完整句子,说明「QQ / 163 / Gmail 一类要求 From 与登录账号一致会拒收,仅允许任意 From 的中继才生效」。行为不变、配置不变。
17
+
18
+ ### Changed / Maintenance
19
+
20
+ - 回归测试:`api` 38 → 39、`feedback` 18 → 19、`trash-audit` 3 → 4;总 128 → **131 全绿**。
21
+
22
+ ## [0.3.3] — 2026-10-19
23
+
24
+ 本次修复对应 `AUDIT-2026-10-19-review.md` 的 S1–S3、P1–P5、R1–R4 与 M 项;每条均带回归测试。
25
+
26
+ ### Security
27
+
28
+ - **S1:`Host` 头不带端口时同源端口比较被恒真绕过**:`requestPort === ''` 时旧表达式退化为 `originPort === originPort`,任意端口的 Origin 都通过。现在缺失端口按 Origin 自身 scheme 的默认端口归一(http→80、https→443),`Host: 127.0.0.1` + `Origin: http://127.0.0.1:9999` 被拒为 `403 E_FORBIDDEN_ORIGIN`,而 `http://127.0.0.1`(无端口)仍放行。
29
+
30
+ ### Fixed
31
+
32
+ - **S2:未知 GET 路由误报 405**:路径不存在时 GET/POST 统一返回 `404 E_NOT_FOUND`,405 只保留给「路径存在但方法错误」。
33
+ - **S3:损坏/缺失 manifest 的回收站目录永久隐身**:新增 `trash.scanUnreadable()`(missing / corrupt / unreadable 三类),`/trash` 把它们列为 `status: 'unreadable'` 的 incomplete 项;`discard` 对目录存在但清单不可读的条目走 `performDiscard` 的磁盘检查——空目录可强制清理,仍持有 `session/` 日志或记忆文件则拒绝(`409 E_TRASH_CONFLICT`),不再只能手动删盘。
34
+ - **P1:live drain 轮询每轮全量重建会话列表**:`inspectRunning` 先走新增的 `gateway.isRunning(sessionId)` 内存存活表探针(`running/active/busy` 布尔),探针答不出才回退全量 `inspect()`,批量删除多个 live 会话时不再线性放大 `controller.list()` 与工作区扫描。
35
+ - **P2:`/trash` 每次全量读盘解析 manifest**:加 1.5s TTL 缓存,delete/restore/purge/discard 成功后主动失效。
36
+ - **P3:`/audit` 全量读 5MB 再解析**:`tail()` 只读尾部 256KB 块、丢弃首条残行,malformed 只统计窗口内。
37
+ - **P4:跨进程锁等待 30s 短于批量最坏耗时**:`/delete` 锁预算按 `min(900s, 15s + 16s×批量数 + 每 live 会话的 drain 窗口)` 估算,大批量不再被第二个 Host 误判 `E_LOCK_BUSY`。
38
+ - **P5:全部 blocked 时仍做全量记忆计数**:`blocked.length > 0` 时跳过 `countForSessions`,返回 `{total:0, skipped:true}` 形状。
39
+ - **R1:请求体超限后继续读完整个 body**:超限立即 `finish(413)`,慢速超大请求不再占着连接。
40
+ - **R2:反馈投递失败也扣每小时额度**:`takeSlot` 返回 `release()`,传输失败 catch 里先退还槽位再抛 `E_FEEDBACK_TRANSPORT`;成功投递才计数,SMTP/HTTP 临时故障不会把用户锁一小时。
41
+ - **R3:批量列表按 soft 固定裁决,与 `defaultMode` 脱节**:`/sessions` 每行裁决改用 `config.defaultMode`;`stopLiveSessions:false` + hard 默认时,不可删的 live 行直接标 `deletable:false`。
42
+ - **R4:失败的删除项也占每分钟额度**:`limiter.take(1)` 改为带 `release()` 的预留,单条失败在 catch 里退还槽位,只有真正成功的删除计入窗口;首个 item 预算不足直接抛 429 的既有契约不变。
43
+
44
+ ### Changed / Maintenance
45
+
46
+ - **M2:会话 ID 正则去重**:抽出 `lib/core/ids.js` 的 `SESSION_ID_PATTERN`,`api.js` / `gateway.js` 共享,删除两处本地副本。
47
+ - **M4:`/state` 不再外泄记忆适配器绝对路径**:只输出 `{id,label,available,reason}`,`storePath` 不离开 Host。
48
+ - **M1:删除无引用的死 CSS `.dc-mode-preferred`**。
49
+ - **M3:`npm run check` 改为 `scripts/check-syntax.mjs` 遍历 `lib/**/*.js` + `client.js` 逐个 `node --check`**(原链式清单只覆盖 3 个文件,新增/改名模块不会再漏检)。
50
+ - 回归测试:`feedback` 17 → 18、`core` 13 → 14、`api` 34 → 38;新增覆盖 S1 无端口 Host、S2 未知路由 404、S3 损坏清单列出/清理/冲突、R2 失败退额度、R3 默认模式裁决、R4 失败退额度。
51
+
6
52
  ## [0.3.2] — 2026-10-18
7
53
 
8
54
  ### Fixed
package/README.md CHANGED
@@ -190,7 +190,7 @@ dsh-conversation-recycle-bin/
190
190
  │ ├─ memory.js # 适配器装配
191
191
  │ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
192
192
  │ └─ api.js # 同源 HTTP API、确认 token、限流
193
- └─ test/ # 122 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
193
+ └─ test/ # 131 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
194
194
  ```
195
195
 
196
196
  ---
@@ -199,7 +199,7 @@ dsh-conversation-recycle-bin/
199
199
 
200
200
  ```powershell
201
201
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
202
- npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(122 个用例)
202
+ npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(131 个用例)
203
203
  npm run check # 语法 + manifest
204
204
  ```
205
205
 
@@ -258,8 +258,8 @@ npm run check # 语法 + manifest
258
258
  | `DSH_CRB_FEEDBACK_TO` | 收件人(优先于 `feedback.to`) |
259
259
  | `DSH_CRB_FEEDBACK_FROM` | 发件人(优先于 `feedback.from`) |
260
260
  | `DSH_CRB_MAIL_API_URL` | 邮件 HTTP API 地址(Resend 兼容形状,也可指向自建转发网关) |
261
- | `DSH_CRB_MAIL_API_TOKEN` | 该 API 的 Bearer token |
262
- | `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`,值为 `Bearer <token>` |
261
+ | `DSH_CRB_MAIL_API_TOKEN` | 该 API 的鉴权 token |
262
+ | `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`;`authorization` 时值形如 `Bearer <token>`(RFC 7235),其它自定义头(如 `x-api-key`)直接发送裸 token |
263
263
  | `DSH_CRB_SMTP_URL` | SMTP 连接串,如 `smtps://user:pass@smtp.example.com:465` |
264
264
 
265
265
  HTTP 通道发出的 JSON 固定为 `{from, to: [收件人], subject, text, reply_to}`,`text` 是纯文本(不生成 HTML);`reply_to` 取自表单里访客填写的邮箱(留空则不填)。SMTP 通道按需 `import('nodemailer')`:它已声明为 `optionalDependencies`(`^10.1.2`),正常安装会随包就位;以 `link:` 方式开发安装时插件从自身真实目录解析,需在**本仓库**跑一次 `npm install`。仍未安装时返回 `E_FEEDBACK_TRANSPORT` + `reason: nodemailer-missing`。
@@ -287,8 +287,8 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
287
287
  ```powershell
288
288
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
289
289
  npm run check # 语法 + manifest(含 feedback.js)
290
- node test/feedback.test.mjs # 17 个反馈用例
291
- npm test # 全量 122 个用例
290
+ node test/feedback.test.mjs # 19 个反馈用例
291
+ npm test # 全量 131 个用例
292
292
 
293
293
  # 端到端:用本机收件桩观察真实投递路径,不外发邮件
294
294
  $env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
package/client.js CHANGED
@@ -127,6 +127,7 @@ window.__ModuleLoader__.load({
127
127
  'trash.status.restoring': 'restoring',
128
128
  'trash.status.purging': 'purging',
129
129
  'trash.status.restored': 'restored',
130
+ 'trash.status.unreadable': 'unreadable manifest',
130
131
  'batch.title': 'Batch deletion',
131
132
  'batch.hint': 'Each conversation is its own transaction; a failure rolls only that conversation back.',
132
133
  'batch.selected': '{count} selected',
@@ -164,7 +165,7 @@ window.__ModuleLoader__.load({
164
165
  'feedback.emailLabel': 'Your email (optional)',
165
166
  'feedback.emailPlaceholder': 'you@example.com',
166
167
  'feedback.emailHint': 'Used as Reply-To so the maintainer can answer you. Leave it empty to stay anonymous.',
167
- 'feedback.emailHintAsFrom': 'Shown as the sender (From) of the mail.',
168
+ 'feedback.emailHintAsFrom': 'Sent as the mail\'s From address. Most relays (QQ, 163, Gmail) reject this unless the mailbox matches the login account — enable only when the relay accepts arbitrary From.',
168
169
  'feedback.contextLabel': 'Attached automatically',
169
170
  'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
170
171
  'feedback.contextVersion': 'Plugin version',
@@ -280,6 +281,7 @@ window.__ModuleLoader__.load({
280
281
  'trash.status.restoring': '恢复中断',
281
282
  'trash.status.purging': '清理中断',
282
283
  'trash.status.restored': '已恢复',
284
+ 'trash.status.unreadable': '清单不可读',
283
285
  'batch.title': '批量删除',
284
286
  'batch.hint': '每个对话独立事务;失败时仅回滚该对话。',
285
287
  'batch.selected': '已选 {count} 个',
@@ -317,7 +319,7 @@ window.__ModuleLoader__.load({
317
319
  'feedback.emailLabel': '你的邮箱(选填)',
318
320
  'feedback.emailPlaceholder': 'you@example.com',
319
321
  'feedback.emailHint': '作为 Reply-To,便于维护者直接回复你;留空即匿名。',
320
- 'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人。',
322
+ 'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人(From)。QQ / 163 / Gmail 一类会拒收非登录账号的 From,只有允许任意 From 的中继才生效。',
321
323
  'feedback.contextLabel': '自动附带',
322
324
  'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
323
325
  'feedback.contextVersion': '插件版本',
@@ -1447,9 +1449,10 @@ window.__ModuleLoader__.load({
1447
1449
  h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
1448
1450
  ...entries.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1449
1451
  h('div', { className: 'dc-row-main' },
1450
- h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId),
1451
- h('div', { className: 'dc-hint' },
1452
- `${t(`trash.status.${entry.status}`)} · ${formatTime(entry.updatedAt ?? entry.deletedAt)} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
1452
+ h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId || entry.trashId),
1453
+ h('div', { className: 'dc-hint' }, entry.status === 'unreadable' && entry.error?.message
1454
+ ? `${t('trash.status.unreadable')} · ${entry.error.message}`
1455
+ : `${t(`trash.status.${entry.status}`)} · ${formatTime(entry.updatedAt ?? entry.deletedAt)} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
1453
1456
  h('div', { className: 'dc-row-actions' },
1454
1457
  entry.hasSessionLog === true && typeof onRestore === 'function'
1455
1458
  ? h(Button, {
@@ -1819,7 +1822,6 @@ window.__ModuleLoader__.load({
1819
1822
  .dc-meta{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);margin-bottom:10px}
1820
1823
  .dc-modes{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
1821
1824
  .dc-mode{display:flex;gap:9px;align-items:flex-start;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;cursor:pointer;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
1822
- .dc-mode-preferred{border-color:var(--dsw-alias-brand-primary,#4d6bfe)}
1823
1825
  .dc-live-warning{margin-top:10px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-warn-label,#b26a00);background:var(--dsw-alias-state-warn-tertiary,#fff7e6)}
1824
1826
  .dc-mode input{margin-top:3px}
1825
1827
  .dc-mode-title{display:block;font-size:13px;font-weight:600}
package/lib/core/audit.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * actor (the local Web UI), session ids, mode, the memory checkbox, per-adapter
12
12
  * counts, and whether a failed transaction was rolled back.
13
13
  */
14
- import { appendFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
14
+ import { appendFile, mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises'
15
15
  import { dirname } from 'node:path'
16
16
 
17
17
  /**
@@ -19,10 +19,11 @@ import { dirname } from 'node:path'
19
19
  * @param {string} options.filePath absolute path of the JSONL file
20
20
  * @param {number} [options.maxBytes] compact threshold
21
21
  * @param {number} [options.retentionLines] lines kept after compaction
22
+ * @param {number} [options.tailWindowBytes] byte window tail() reads from the end (default 256KB)
22
23
  * @param {() => number} [options.now]
23
- * @param {Pick<typeof import('node:fs/promises'), 'readFile'|'writeFile'|'mkdir'|'appendFile'|'rename'|'stat'>} [options.fsApi]
24
+ * @param {Pick<typeof import('node:fs/promises'), 'readFile'|'writeFile'|'mkdir'|'appendFile'|'rename'|'stat'|'open'>} [options.fsApi]
24
25
  */
25
- export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retentionLines = 5_000, now = Date.now, fsApi = { readFile, writeFile, mkdir, appendFile, rename, stat } }) {
26
+ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retentionLines = 5_000, tailWindowBytes = 256 * 1024, now = Date.now, fsApi = { readFile, writeFile, mkdir, appendFile, rename, stat, open } }) {
26
27
  let pending = Promise.resolve()
27
28
 
28
29
  function serialize(operation) {
@@ -75,26 +76,61 @@ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retention
75
76
  })
76
77
  },
77
78
 
78
- /** Read the most recent `limit` records; malformed lines are counted, not thrown. */
79
+ /**
80
+ * Read the most recent `limit` records; malformed lines are counted, not
81
+ * thrown. Only the tail window (default 256KB) is read and parsed: the
82
+ * settings page asks for the last N rows on every open, and a full
83
+ * up-to-5MB parse on each request scaled with the whole trail, not with
84
+ * `limit`. `fs.readFile` does not honour `{start,end}`, so this uses a
85
+ * positional `FileHandle.read`; earlier revisions silently read the whole
86
+ * file while appearing to window. Malformed lines outside the window are
87
+ * not counted (the metric describes the displayed window).
88
+ */
79
89
  async tail(limit = 50) {
80
- let content = ''
90
+ const maxRecords = Math.max(1, Math.floor(limit))
91
+ const CHUNK = Math.max(1, Math.floor(tailWindowBytes))
92
+ let handle
81
93
  try {
82
- content = await fsApi.readFile(filePath, 'utf8')
94
+ handle = await fsApi.open(filePath, 'r')
83
95
  } catch (error) {
84
96
  if (error?.code === 'ENOENT') return { records: [], malformed: 0 }
85
97
  throw error
86
98
  }
87
- const lines = content.split('\n').filter((line) => line.trim() !== '')
88
- const records = []
89
- let malformed = 0
90
- for (const line of lines) {
91
- try {
92
- records.push(JSON.parse(line))
93
- } catch {
94
- malformed += 1
99
+ try {
100
+ const statInfo = await handle.stat()
101
+ const size = statInfo.size
102
+ const start = Math.max(0, size - CHUNK)
103
+ const windowLength = size - start
104
+ const buffer = Buffer.alloc(windowLength)
105
+ const windowRead = await handle.read(buffer, 0, windowLength, start)
106
+ const text = buffer.subarray(0, windowRead.bytesRead).toString('utf8')
107
+ // `rawLines[0]` is only a torn fragment when the window starts
108
+ // *inside* a line. Read the byte at `start-1`: `\n` means the window
109
+ // begins on a line boundary and rawLines[0] is a complete record;
110
+ // unconditionally dropping it (pre-0.3.4) silently lost one valid
111
+ // record whenever the byte window happened to align with a newline.
112
+ let dropFirst = false
113
+ if (start > 0) {
114
+ const peek = Buffer.alloc(1)
115
+ const peekRead = await handle.read(peek, 0, 1, start - 1)
116
+ dropFirst = !(peekRead.bytesRead === 1 && peek[0] === 0x0a)
117
+ }
118
+ const rawLines = text.split('\n')
119
+ const lines = (dropFirst ? rawLines.slice(1) : rawLines)
120
+ .filter((line) => line.trim() !== '')
121
+ const records = []
122
+ let malformed = 0
123
+ for (const line of lines) {
124
+ try {
125
+ records.push(JSON.parse(line))
126
+ } catch {
127
+ malformed += 1
128
+ }
95
129
  }
130
+ return { records: records.slice(-maxRecords), malformed }
131
+ } finally {
132
+ await handle.close()
96
133
  }
97
- return { records: records.slice(-limit), malformed }
98
134
  },
99
135
  }
100
136
  }
@@ -77,9 +77,11 @@ export function createFileLock({
77
77
 
78
78
  /**
79
79
  * Acquire the lock.
80
+ * @param {{ timeoutMs?: number }} [options] per-call wait budget override;
81
+ * defaults to the lock's configured timeoutMs
80
82
  * @returns {Promise<() => Promise<void>>} release
81
83
  */
82
- async function acquire() {
84
+ async function acquire({ timeoutMs: waitBudget = timeoutMs } = {}) {
83
85
  const nonce = randomBytes(8).toString('hex')
84
86
  const started = now()
85
87
  let handle = null
@@ -108,7 +110,7 @@ export function createFileLock({
108
110
  await fsApi.rm(lockPath, { force: true }).catch(() => {})
109
111
  continue
110
112
  }
111
- if (now() - started >= timeoutMs) {
113
+ if (now() - started >= waitBudget) {
112
114
  const busy = new Error(`The lock ${lockPath} is held by another process.`)
113
115
  busy.code = 'E_LOCK_BUSY'
114
116
  throw busy
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Shared identifier shapes used by the HTTP layer and the session gateway.
3
+ * Kept in one place so the route and the preflight cannot drift apart.
4
+ */
5
+
6
+ /** DSH session ids: letters, digits and `.`, `_`, `-`; 6–80 characters. */
7
+ export const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{6,80}$/
@@ -24,8 +24,10 @@ export function createSlidingWindowLimiter({ limit, windowMs, now = Date.now })
24
24
 
25
25
  return {
26
26
  /**
27
- * Try to reserve `cost` deletions.
28
- * @returns {{ ok: true, remaining: number } | { ok: false, retryAfterMs: number, limit: number }}
27
+ * Try to reserve `cost` deletions. A reservation that later fails to
28
+ * delete (rollback, writer busy, …) calls `release()` so the failed
29
+ * attempt does not spend the minute quota it never used.
30
+ * @returns {{ ok: true, remaining: number, release: () => void } | { ok: false, retryAfterMs: number, limit: number }}
29
31
  */
30
32
  take(cost = 1) {
31
33
  const current = now()
@@ -36,7 +38,19 @@ export function createSlidingWindowLimiter({ limit, windowMs, now = Date.now })
36
38
  return { ok: false, retryAfterMs, limit }
37
39
  }
38
40
  for (let index = 0; index < cost; index += 1) accepted.push(current)
39
- return { ok: true, remaining: limit - accepted.length }
41
+ return {
42
+ ok: true,
43
+ remaining: limit - accepted.length,
44
+ release() {
45
+ // Drop only the reservation we just made: remove `cost` stamps equal
46
+ // to this instant; other concurrent reservations at the same instant
47
+ // stay in place.
48
+ for (let index = 0; index < cost; index += 1) {
49
+ const position = accepted.lastIndexOf(current)
50
+ if (position !== -1) accepted.splice(position, 1)
51
+ }
52
+ },
53
+ }
40
54
  },
41
55
 
42
56
  /** Current budget without consuming it (used by the preview response). */
package/lib/core/trash.js CHANGED
@@ -122,6 +122,45 @@ export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkd
122
122
  return entries.filter((entry) => entry.status === 'trashed')
123
123
  },
124
124
 
125
+ /**
126
+ * Trash directories whose manifest cannot be read at all: missing
127
+ * (crashed between allocate and write), corrupt, or unparseable. These
128
+ * are invisible to restore/purge but must still be listable and
129
+ * discardable, otherwise a single bad manifest strands the directory on
130
+ * disk forever.
131
+ * @returns {Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }>}
132
+ */
133
+ async scanUnreadable() {
134
+ await ensureRoot()
135
+ const out = []
136
+ let dirents = []
137
+ try {
138
+ dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
139
+ } catch {
140
+ return out
141
+ }
142
+ for (const dirent of dirents) {
143
+ if (!dirent.isDirectory() || !isTrashId(dirent.name)) continue
144
+ let raw
145
+ let reason = 'missing-manifest'
146
+ try {
147
+ raw = await fsApi.readFile(join(rootDir, dirent.name, MANIFEST), 'utf8')
148
+ } catch (error) {
149
+ if (error?.code !== 'ENOENT') reason = 'unreadable-manifest'
150
+ out.push({ trashId: dirent.name, reason })
151
+ continue
152
+ }
153
+ try {
154
+ const parsed = JSON.parse(raw)
155
+ const readable = parsed !== null && typeof parsed === 'object' && parsed.format === 1 && TRASH_STATUS.includes(parsed.status)
156
+ if (!readable) out.push({ trashId: dirent.name, reason: 'corrupt-manifest' })
157
+ } catch {
158
+ out.push({ trashId: dirent.name, reason: 'corrupt-manifest' })
159
+ }
160
+ }
161
+ return out
162
+ },
163
+
125
164
  async remove(trashId) {
126
165
  await fsApi.rm(entryDir(trashId), { recursive: true, force: true })
127
166
  },
package/lib/host/api.js CHANGED
@@ -55,13 +55,11 @@ import { createSessionGateway } from './gateway.js'
55
55
  import { createMemoryAdapters } from './memory.js'
56
56
  import { createFeedbackService } from './feedback.js'
57
57
  import { PLUGIN_VERSION } from '../version.js'
58
+ import { SESSION_ID_PATTERN } from '../core/ids.js'
58
59
 
59
60
  export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
60
61
  export const REQUIRED_HEADER = 'x-conversation-recycle-bin'
61
62
  const MAX_BODY_BYTES = 256 * 1024
62
- // DSH's `encodeSegment` allows `.`, `_`, `-`; forked/custom ids can contain a
63
- // dot, so refusing one made those conversations undeletable.
64
- const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{6,80}$/
65
63
 
66
64
  function isLoopbackHostname(hostname) {
67
65
  const value = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
@@ -155,9 +153,16 @@ export function requestOriginVerdict(headers, { requireSameOrigin, allowNonLoopb
155
153
  const trustedAppShell = parsed !== null && parsed.protocol === 'dsh-app:' && parsed.hostname === 'app'
156
154
  if (trustedAppShell !== true) {
157
155
  const requestPort = portOf(read('host'))
158
- const originPort = parsed === null ? '' : (parsed.port === '' ? defaultPortFor(parsed.protocol) : parsed.port)
159
- const hostnameMatches = parsed !== null && parsed.hostname !== '' && sameHostname(parsed.hostname, requestHostname)
160
- const portMatches = originPort !== '' && originPort === (requestPort === '' ? originPort : requestPort)
156
+ let hostnameMatches = false
157
+ let portMatches = false
158
+ if (parsed !== null) {
159
+ hostnameMatches = parsed.hostname !== '' && sameHostname(parsed.hostname, requestHostname)
160
+ // A missing request port falls back to the Origin's scheme default
161
+ // (80/443) instead of matching every Origin port.
162
+ const effectiveRequestPort = requestPort === '' ? defaultPortFor(parsed.protocol) : requestPort
163
+ const originPort = parsed.port === '' ? defaultPortFor(parsed.protocol) : parsed.port
164
+ portMatches = originPort !== '' && originPort === effectiveRequestPort
165
+ }
161
166
  if (!hostnameMatches || !portMatches) {
162
167
  return { ok: false, error: new DeleteError('E_FORBIDDEN_ORIGIN', 'The request origin does not match this Host.', { origin }) }
163
168
  }
@@ -232,6 +237,9 @@ function readJsonBody(req) {
232
237
  if (bytes > MAX_BODY_BYTES) {
233
238
  overflow = true
234
239
  chunks.length = 0
240
+ // Respond at once instead of draining the whole oversized body; the
241
+ // settled flag keeps the later `end` handler from double-settling.
242
+ finish(new DeleteError('E_BAD_REQUEST', 'Request body exceeds the size limit.', { limit: MAX_BODY_BYTES }, { httpStatus: 413 }))
235
243
  return
236
244
  }
237
245
  chunks.push(chunk)
@@ -355,20 +363,30 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
355
363
  const confirmations = new Map()
356
364
  let mutationChain = Promise.resolve()
357
365
  let requestCounter = 0
366
+ // `/trash` reads and parses every manifest on disk; a short TTL cache keeps
367
+ // the settings page responsive while every mutation invalidates it, so the
368
+ // user never sees a stale list after their own action.
369
+ const TRASH_CACHE_TTL_MS = 1_500
370
+ let trashCache = null
371
+ let trashCachedAt = 0
372
+ function invalidateTrashCache() {
373
+ trashCache = null
374
+ trashCachedAt = 0
375
+ }
358
376
 
359
377
  /**
360
378
  * Serialize one mutation: in-process first (so two HTTP requests never
361
379
  * interleave their transactions), then across processes (so a second Host
362
380
  * sharing this DSH_HOME cannot write the same manifest concurrently).
363
381
  */
364
- function serialize(operation) {
382
+ function serialize(operation, { lockTimeoutMs } = {}) {
365
383
  const next = mutationChain.then(async () => {
366
384
  // The lock lives in the data dir, which a fresh install has not created
367
385
  // yet (the trash store and audit log mkdir their own targets lazily).
368
386
  await mkdir(paths.dataDir, { recursive: true }).catch(() => {})
369
387
  let release
370
388
  try {
371
- release = await mutationLockInstance.acquire()
389
+ release = await mutationLockInstance.acquire(lockTimeoutMs === undefined ? {} : { timeoutMs: lockTimeoutMs })
372
390
  } catch (error) {
373
391
  if (error?.code === 'E_LOCK_BUSY') {
374
392
  throw new DeleteError('E_LOCK_BUSY', 'Another DSH instance is using this recycle bin; retry in a moment.', {})
@@ -502,9 +520,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
502
520
  if (!policy.ok) blocked.push({ sessionId: id, policy: item.policy })
503
521
  }
504
522
 
505
- const memory = config.memory.enabled
506
- ? await memoryService.countForSessions(sessionIds)
507
- : { total: 0, bySession: Object.fromEntries(sessionIds.map((id) => [id, 0])), protected: 0, adapters: [], disabled: true }
523
+ // When every session is blocked there is no plan to execute, so the memory
524
+ // count (index reads + parsing) is skipped rather than computed for nothing.
525
+ const emptyMemory = { total: 0, bySession: Object.fromEntries(sessionIds.map((id) => [id, 0])), protected: 0, adapters: [] }
526
+ const memory = !config.memory.enabled
527
+ ? { ...emptyMemory, disabled: true }
528
+ : blocked.length === 0
529
+ ? await memoryService.countForSessions(sessionIds)
530
+ : { ...emptyMemory, skipped: true }
508
531
  const adapterStates = await memoryService.status()
509
532
  const confirmation = blocked.length === 0 ? issueConfirmation({ sessionIds, includeMemory, mode }) : null
510
533
 
@@ -608,19 +631,31 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
608
631
  })
609
632
  }
610
633
 
611
- const budget = limiter.take(sessions.length)
612
- if (!budget.ok) {
613
- throw new DeleteError('E_RATE_LIMITED', `Too many deletions; retry in ${Math.ceil(budget.retryAfterMs / 1000)}s.`, {
614
- retryAfterMs: budget.retryAfterMs,
615
- limit: budget.limit,
616
- })
617
- }
618
- markConfirmationUsed(validated)
619
-
634
+ // Budget is consumed per actually-attempted deletion: a failed item must
635
+ // not silently spend the minute quota it never used. The very first item
636
+ // still refuses outright with 429 (the existing contract); once a plan
637
+ // has started, later exhausted items are recorded as failures.
638
+ //
639
+ // A refusal inside the loop uses `continue`, never `break`: `break` used
640
+ // to drop every session after the exhausted one from both `results` and
641
+ // `failures`, so a batch of 50 with a 20/min budget left items 22-50
642
+ // looking like the plan never mentioned them. Under the per-plan
643
+ // serialize this is still visible via `maxBatchSessions` > budget.
620
644
  const request = requestId()
621
645
  const results = []
622
646
  const failures = []
623
647
  for (const session of sessions) {
648
+ const reservation = limiter.take(1)
649
+ if (!reservation.ok) {
650
+ const rateError = new DeleteError('E_RATE_LIMITED', `Too many deletions; retry in ${Math.ceil(reservation.retryAfterMs / 1000)}s.`, {
651
+ retryAfterMs: reservation.retryAfterMs,
652
+ limit: reservation.limit,
653
+ })
654
+ if (results.length === 0 && failures.length === 0) throw rateError
655
+ failures.push({ sessionId: session.id, error: rateError.toJSON() })
656
+ continue
657
+ }
658
+ if (!validated.record.used) markConfirmationUsed(validated)
624
659
  try {
625
660
  const outcome = await performSessionDelete({
626
661
  session,
@@ -644,12 +679,16 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
644
679
  durationMs: outcome.durationMs,
645
680
  })
646
681
  } catch (error) {
682
+ // The deletion never happened: refund the slot so a failed item does
683
+ // not consume the per-minute quota of a successful one.
684
+ reservation.release()
647
685
  const normalized = asDeleteError(error)
648
686
  failures.push({ sessionId: session.id, error: normalized.toJSON() })
649
687
  logger.error?.(`[conversation-recycle-bin] delete failed for ${session.id}: ${normalized.message}`)
650
688
  }
651
689
  }
652
690
  gateway.invalidate()
691
+ invalidateTrashCache()
653
692
 
654
693
  if (results.length === 0 && failures.length > 0) {
655
694
  const first = failures[0].error
@@ -664,6 +703,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
664
703
  if (entry === null) throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
665
704
  const result = await performRestore({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
666
705
  gateway.invalidate()
706
+ invalidateTrashCache()
667
707
  return result
668
708
  }
669
709
 
@@ -679,6 +719,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
679
719
  // cannot be stopped within liveDrainMs, the purge fails and rolls back.
680
720
  const result = await performPurge({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
681
721
  gateway.invalidate()
722
+ invalidateTrashCache()
682
723
  return result
683
724
  }
684
725
 
@@ -692,13 +733,40 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
692
733
  if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() !== 'DELETE') {
693
734
  throw new DeleteError('E_CONFIRM_PHRASE', 'Type “DELETE” to discard this incomplete entry.', { expected: 'DELETE' })
694
735
  }
695
- const entry = await trash.read(trashId)
696
- if (entry === null) throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
697
- return performDiscard({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
736
+ let entry = await trash.read(trashId)
737
+ if (entry === null) {
738
+ // Missing/corrupt manifest: the directory itself may still be on disk.
739
+ // Verify it exists, then discard against the actual payload; the disk
740
+ // checks inside performDiscard still refuse a folder holding a log or
741
+ // memory files, so a stranded directory can be cleaned without ever
742
+ // trusting the unreadable manifest.
743
+ const dir = join(paths.trashDir, trashId)
744
+ if (!(await ops.pathExists(dir))) {
745
+ throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
746
+ }
747
+ entry = {
748
+ trashId,
749
+ sessionId: null,
750
+ title: null,
751
+ mode: null,
752
+ includeMemory: false,
753
+ deletedAt: 0,
754
+ updatedAt: 0,
755
+ status: 'unreadable',
756
+ memory: { adapters: [], total: 0 },
757
+ sessionLog: null,
758
+ catalog: null,
759
+ }
760
+ }
761
+ const result = await performDiscard({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
762
+ invalidateTrashCache()
763
+ return result
698
764
  }
699
765
 
700
766
  async function state() {
701
- const adapters = await memoryService.status()
767
+ // Only wire fields the Client renders; adapter `storePath` is an absolute
768
+ // filesystem handle and must not leave the Host.
769
+ const adapters = (await memoryService.status()).map(({ id, label, available, reason }) => ({ id, label, available, reason }))
702
770
  return {
703
771
  enabled: config.enabled,
704
772
  version: PLUGIN_VERSION,
@@ -727,10 +795,12 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
727
795
  // Every row carries its own policy verdict: "select all" used to include
728
796
  // subagent/orphan/out-of-root conversations that `/preview` then refuses
729
797
  // as a whole batch, leaving the user with a disabled button and no hint
730
- // which row to uncheck. Soft mode is the widest verdict (hard adds only
731
- // the live-session stop), so a row marked deletable here stays deletable.
798
+ // which row to uncheck. Judge with the configured default mode (the mode
799
+ // the dialog opens in): with `stopLiveSessions: false`, hard is stricter
800
+ // than soft for live sessions, so a fixed soft verdict would mark those
801
+ // rows deletable only for the server to refuse later.
732
802
  return sessions.map((session) => {
733
- const verdict = evaluateSessionPolicy({ session, workspace: session.workspace, live: session.live, mode: 'soft', config })
803
+ const verdict = evaluateSessionPolicy({ session, workspace: session.workspace, live: session.live, mode: config.defaultMode, config })
734
804
  return {
735
805
  id: session.id,
736
806
  title: session.title,
@@ -769,14 +839,39 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
769
839
  }
770
840
 
771
841
  async function trashList() {
772
- // Every manifest is returned: `trashed` entries are the usable recycle bin,
773
- // while failed/pending/restoring/purging entries used to be invisible and
774
- // impossible to clean up at all. The Client renders them separately.
775
- const all = await trash.listAll()
776
- return {
842
+ if (trashCache !== null && now() - trashCachedAt < TRASH_CACHE_TTL_MS) return trashCache
843
+ // Every readable manifest is returned: `trashed` entries are the usable
844
+ // recycle bin, while failed/pending/restoring/purging entries used to be
845
+ // invisible and impossible to clean up at all. Directories whose manifest
846
+ // is missing/corrupt are surfaced as `unreadable` so they are not stranded
847
+ // on disk; the Client renders both groups separately.
848
+ const [all, unreadable] = await Promise.all([trash.listAll(), trash.scanUnreadable()])
849
+ const result = {
777
850
  entries: all.filter((entry) => entry.status === 'trashed').map(serializeEntry),
778
- incomplete: all.filter((entry) => entry.status !== 'trashed').map(serializeEntry),
851
+ incomplete: [
852
+ ...all.filter((entry) => entry.status !== 'trashed').map(serializeEntry),
853
+ ...unreadable.map((item) => ({
854
+ trashId: item.trashId,
855
+ sessionId: null,
856
+ title: null,
857
+ mode: null,
858
+ includeMemory: false,
859
+ deletedAt: 0,
860
+ updatedAt: 0,
861
+ memory: 0,
862
+ memoryAdapters: [],
863
+ hasSessionLog: false,
864
+ status: 'unreadable',
865
+ error: {
866
+ code: item.reason,
867
+ message: 'The manifest could not be read; discard this folder only after confirming it holds no log or memory files.',
868
+ },
869
+ })),
870
+ ],
779
871
  }
872
+ trashCache = result
873
+ trashCachedAt = now()
874
+ return result
780
875
  }
781
876
 
782
877
  async function auditTail(limit) {
@@ -823,72 +918,88 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
823
918
  }
824
919
 
825
920
  try {
826
- if (method === 'GET' && (path === '' || path === '/state')) {
827
- sendJson(res, 200, { ok: true, state: await state() })
828
- return
829
- }
830
- if (method === 'GET' && path === '/sessions') {
831
- sendJson(res, 200, { ok: true, sessions: await sessionsForUi() })
832
- return
833
- }
834
- if (method === 'GET' && path === '/trash') {
835
- sendJson(res, 200, { ok: true, ...(await trashList()) })
836
- return
837
- }
838
- if (method === 'GET' && path === '/audit') {
839
- sendJson(res, 200, { ok: true, ...(await auditTail(url.searchParams.get('limit'))) })
840
- return
841
- }
842
- if (method !== 'POST') {
843
- throw new DeleteError('E_BAD_REQUEST', 'This endpoint accepts POST requests only.', {}, { httpStatus: 405 })
844
- }
845
- const header = req.headers?.[REQUIRED_HEADER]
846
- if (header !== '1') {
847
- throw new DeleteError('E_FORBIDDEN_ORIGIN', 'Missing the plugin request header.', {})
848
- }
849
- if (!config.enabled && path !== '/feedback') throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
850
- const body = await readJsonBody(req)
851
-
852
- // Feedback is available even when deletion is switched off: the two
853
- // switches answer different questions, and a broken mail transport
854
- // must not be reported as "deletion disabled".
855
- if (path === '/feedback') {
856
- const result = await feedbackService.submit(body)
857
- sendJson(res, 200, { ok: true, ...result })
858
- return
859
- }
921
+ if (method === 'GET' && (path === '' || path === '/state')) {
922
+ sendJson(res, 200, { ok: true, state: await state() })
923
+ return
924
+ }
925
+ if (method === 'GET' && path === '/sessions') {
926
+ sendJson(res, 200, { ok: true, sessions: await sessionsForUi() })
927
+ return
928
+ }
929
+ if (method === 'GET' && path === '/trash') {
930
+ sendJson(res, 200, { ok: true, ...(await trashList()) })
931
+ return
932
+ }
933
+ if (method === 'GET' && path === '/audit') {
934
+ sendJson(res, 200, { ok: true, ...(await auditTail(url.searchParams.get('limit'))) })
935
+ return
936
+ }
937
+ // A GET that matched no route is an unknown endpoint (404), not a 405:
938
+ // 405 is reserved for non-POST verbs on a mounted prefix.
939
+ if (method === 'GET') {
940
+ throw new DeleteError('E_NOT_FOUND', 'Unknown endpoint.', { path: path.slice(0, 120) })
941
+ }
942
+ if (method !== 'POST') {
943
+ throw new DeleteError('E_BAD_REQUEST', 'This endpoint accepts POST requests only.', {}, { httpStatus: 405 })
944
+ }
945
+ const header = req.headers?.[REQUIRED_HEADER]
946
+ if (header !== '1') {
947
+ throw new DeleteError('E_FORBIDDEN_ORIGIN', 'Missing the plugin request header.', {})
948
+ }
949
+ if (!config.enabled && path !== '/feedback') throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
950
+ const body = await readJsonBody(req)
860
951
 
861
- if (path === '/preview') {
862
- const sessionIds = normalizeSessionIds(body)
863
- const mode = normalizeMode(body, config)
864
- const includeMemory = normalizeBoolean(body, 'includeMemory')
865
- sendJson(res, 200, { ok: true, preview: await preview({ sessionIds, includeMemory, mode }) })
866
- return
867
- }
868
- if (path === '/delete') {
869
- const result = await serialize(() => deletePlan(body))
870
- // `ok` means "the plan was executed", not "every item succeeded".
871
- // A partial batch carries `results` + `failures`; marking the whole
872
- // response as an error hid those results and made the Client show
873
- // "The Host refused the request" while rows had already been deleted.
874
- sendJson(res, 200, { ok: true, partial: result.failures.length > 0, ...result })
875
- return
876
- }
877
- if (path === '/trash/restore') {
878
- const result = await serialize(() => restoreEntry(body?.trashId))
879
- sendJson(res, 200, { ok: true, ...result })
880
- return
881
- }
882
- if (path === '/trash/purge') {
883
- const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase))
884
- sendJson(res, 200, { ok: true, ...result })
885
- return
886
- }
887
- if (path === '/trash/discard') {
888
- const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase))
889
- sendJson(res, 200, { ok: true, ...result })
890
- return
891
- }
952
+ // Feedback is available even when deletion is switched off: the two
953
+ // switches answer different questions, and a broken mail transport
954
+ // must not be reported as "deletion disabled".
955
+ if (path === '/feedback') {
956
+ const result = await feedbackService.submit(body)
957
+ sendJson(res, 200, { ok: true, ...result })
958
+ return
959
+ }
960
+
961
+ if (path === '/preview') {
962
+ const sessionIds = normalizeSessionIds(body)
963
+ const mode = normalizeMode(body, config)
964
+ const includeMemory = normalizeBoolean(body, 'includeMemory')
965
+ sendJson(res, 200, { ok: true, preview: await preview({ sessionIds, includeMemory, mode }) })
966
+ return
967
+ }
968
+ if (path === '/delete') {
969
+ // Upper-bound lock wait mirroring preview's planTimeoutMs: a 50-item
970
+ // batch legitimately outlives the default 30s, so a second Host waiting
971
+ // on the lock should not be told "busy" while the first is still
972
+ // working. Per-session export/restore plus a bounded live drain each.
973
+ const rawIds = Array.isArray(body?.sessionIds)
974
+ ? body.sessionIds
975
+ : typeof body?.sessionId === 'string'
976
+ ? [body.sessionId]
977
+ : []
978
+ const batchCount = Math.max(1, rawIds.length)
979
+ const lockBudget = Math.min(900_000, 15_000 + batchCount * 16_000 + batchCount * config.liveDrainMs)
980
+ const result = await serialize(() => deletePlan(body), { lockTimeoutMs: lockBudget })
981
+ // `ok` means "the plan was executed", not "every item succeeded".
982
+ // A partial batch carries `results` + `failures`; marking the whole
983
+ // response as an error hid those results and made the Client show
984
+ // "The Host refused the request" while rows had already been deleted.
985
+ sendJson(res, 200, { ok: true, partial: result.failures.length > 0, ...result })
986
+ return
987
+ }
988
+ if (path === '/trash/restore') {
989
+ const result = await serialize(() => restoreEntry(body?.trashId))
990
+ sendJson(res, 200, { ok: true, ...result })
991
+ return
992
+ }
993
+ if (path === '/trash/purge') {
994
+ const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase))
995
+ sendJson(res, 200, { ok: true, ...result })
996
+ return
997
+ }
998
+ if (path === '/trash/discard') {
999
+ const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase))
1000
+ sendJson(res, 200, { ok: true, ...result })
1001
+ return
1002
+ }
892
1003
  throw new DeleteError('E_NOT_FOUND', 'Unknown endpoint.', { path: path.slice(0, 120) })
893
1004
  } catch (error) {
894
1005
  const normalized = asDeleteError(error)
@@ -321,7 +321,16 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
321
321
  retryAfterMs: Math.max(0, windowMs - (now() - deliveries[0])),
322
322
  })
323
323
  }
324
- deliveries.push(now())
324
+ const stamp = now()
325
+ deliveries.push(stamp)
326
+ // A transport failure must not spend the hourly quota: the caller can
327
+ // release the slot instead of locking the visitor out for an hour.
328
+ return {
329
+ release() {
330
+ const at = deliveries.indexOf(stamp)
331
+ if (at !== -1) deliveries.splice(at, 1)
332
+ },
333
+ }
325
334
  }
326
335
 
327
336
  async function deliverHttp(message, submission) {
@@ -329,7 +338,14 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
329
338
  throw new DeleteError('E_FEEDBACK_TRANSPORT', 'This Host runtime provides no fetch() to deliver feedback.', { transport: 'http' })
330
339
  }
331
340
  const headers = { 'content-type': 'application/json' }
332
- if (settings.apiToken !== '') headers[settings.authHeader.toLowerCase()] = `Bearer ${settings.apiToken}`
341
+ if (settings.apiToken !== '') {
342
+ // `Authorization` is the one header whose RFC 7235 grammar mandates an
343
+ // auth-scheme token; a custom header (X-Api-Key, x-goog-api-key, …)
344
+ // expects the bare secret. Prefixing `Bearer` unconditionally 401'd
345
+ // every non-Authorization relay the user pointed us at.
346
+ const scheme = settings.authHeader.toLowerCase() === 'authorization' ? `Bearer ${settings.apiToken}` : settings.apiToken
347
+ headers[settings.authHeader.toLowerCase()] = scheme
348
+ }
333
349
  const body = {
334
350
  from: (settings.visitorAsFrom === true && submission.email) ? submission.email : (settings.from === '' ? undefined : settings.from),
335
351
  to: [settings.to],
@@ -404,13 +420,16 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
404
420
  reason: settings.reason,
405
421
  })
406
422
  }
407
- takeSlot()
423
+ const slot = takeSlot()
408
424
  const message = renderFeedbackMessage(submission, { at: now() })
409
425
  const startedAt = now()
410
426
  try {
411
427
  if (settings.transport === 'http') await deliverHttp(message, submission)
412
428
  else await deliverSmtp(message, submission)
413
429
  } catch (error) {
430
+ // A failed delivery refunds the slot: a temporary provider outage must
431
+ // not consume the hourly cap and lock the visitor out for an hour.
432
+ slot.release()
414
433
  const normalized = error instanceof DeleteError
415
434
  ? error
416
435
  : new DeleteError('E_FEEDBACK_TRANSPORT', 'The feedback message could not be delivered.', {
@@ -16,6 +16,7 @@
16
16
  import { findSessionDir, readJsonFile } from './paths.js'
17
17
  import { join } from 'node:path'
18
18
  import { isPathInside } from '../core/policy.js'
19
+ import { SESSION_ID_PATTERN } from '../core/ids.js'
19
20
 
20
21
  const LIST_TTL_MS = 1_500
21
22
  const STATE_TTL_MS = 1_500
@@ -177,6 +178,30 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
177
178
  }
178
179
  }
179
180
 
181
+ /**
182
+ * Lightweight running probe for the bounded drain: reads only the in-memory
183
+ * live-sessions table instead of rebuilding the whole session list on every
184
+ * poll. Returns `undefined` when the service is absent or the entry shape is
185
+ * unknown, so the caller falls back to a full `inspect()`.
186
+ * @returns {boolean|undefined}
187
+ */
188
+ function probeRunning(sessionId) {
189
+ const sessions = service('sessions')
190
+ if (sessions === undefined || typeof sessions.get !== 'function') return undefined
191
+ let current
192
+ try {
193
+ current = sessions.get(sessionId)
194
+ } catch {
195
+ return undefined
196
+ }
197
+ if (current === null || current === undefined) return false
198
+ if (typeof current !== 'object') return undefined
199
+ if (typeof current.running === 'boolean') return current.running
200
+ if (typeof current.active === 'boolean') return current.active
201
+ if (typeof current.busy === 'boolean') return current.busy
202
+ return undefined
203
+ }
204
+
180
205
  async function workspaceFor(session) {
181
206
  const workspaces = await workspaceEntities()
182
207
  const byId = workspaces.find((workspace) => workspace.id === session.workspaceId)
@@ -193,10 +218,11 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
193
218
  return {
194
219
  workspaceState: readWorkspaceState,
195
220
  list: loadList,
221
+ isRunning: probeRunning,
196
222
 
197
223
  /** Normalize one session; throws E_NOT_FOUND-shaped data as `null`. */
198
224
  async inspect(sessionId) {
199
- if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{6,80}$/.test(sessionId)) return null
225
+ if (typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId)) return null
200
226
  const persistence = service('sessionPersistence')
201
227
  let snapshot
202
228
  if (persistence !== undefined && typeof persistence.stat === 'function') {
@@ -34,6 +34,12 @@ export function createLifecycleOps({ ctx, gateway, config, now = Date.now, delay
34
34
  }
35
35
 
36
36
  async function inspectRunning(sessionId) {
37
+ // Cheap path: read the in-memory live table without invalidating and
38
+ // rebuilding the whole session list on every poll. Only fall back to a
39
+ // full inspect when the probe cannot answer (absent service, unknown shape).
40
+ const raw = typeof gateway.isRunning === 'function' ? gateway.isRunning(sessionId) : undefined
41
+ const probe = raw instanceof Promise ? await raw : raw
42
+ if (probe !== undefined) return probe === true
37
43
  gateway.invalidate()
38
44
  const current = await gateway.inspect(sessionId)
39
45
  return current !== null && current.running === true
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.2'
8
+ export const PLUGIN_VERSION = '0.3.4'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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",
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "scripts": {
59
59
  "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
60
- "check": "node --check lib/index.js && node --check lib/host/feedback.js && node --check client.js && node test/manifest.test.mjs"
60
+ "check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs"
61
61
  },
62
62
  "dsh": {
63
63
  "manifestVersion": 1,