dsh-conversation-recycle-bin 0.3.1 → 0.3.3

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,43 @@
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.3] — 2026-10-19
7
+
8
+ 本次修复对应 `AUDIT-2026-10-19-review.md` 的 S1–S3、P1–P5、R1–R4 与 M 项;每条均带回归测试。
9
+
10
+ ### Security
11
+
12
+ - **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`(无端口)仍放行。
13
+
14
+ ### Fixed
15
+
16
+ - **S2:未知 GET 路由误报 405**:路径不存在时 GET/POST 统一返回 `404 E_NOT_FOUND`,405 只保留给「路径存在但方法错误」。
17
+ - **S3:损坏/缺失 manifest 的回收站目录永久隐身**:新增 `trash.scanUnreadable()`(missing / corrupt / unreadable 三类),`/trash` 把它们列为 `status: 'unreadable'` 的 incomplete 项;`discard` 对目录存在但清单不可读的条目走 `performDiscard` 的磁盘检查——空目录可强制清理,仍持有 `session/` 日志或记忆文件则拒绝(`409 E_TRASH_CONFLICT`),不再只能手动删盘。
18
+ - **P1:live drain 轮询每轮全量重建会话列表**:`inspectRunning` 先走新增的 `gateway.isRunning(sessionId)` 内存存活表探针(`running/active/busy` 布尔),探针答不出才回退全量 `inspect()`,批量删除多个 live 会话时不再线性放大 `controller.list()` 与工作区扫描。
19
+ - **P2:`/trash` 每次全量读盘解析 manifest**:加 1.5s TTL 缓存,delete/restore/purge/discard 成功后主动失效。
20
+ - **P3:`/audit` 全量读 5MB 再解析**:`tail()` 只读尾部 256KB 块、丢弃首条残行,malformed 只统计窗口内。
21
+ - **P4:跨进程锁等待 30s 短于批量最坏耗时**:`/delete` 锁预算按 `min(900s, 15s + 16s×批量数 + 每 live 会话的 drain 窗口)` 估算,大批量不再被第二个 Host 误判 `E_LOCK_BUSY`。
22
+ - **P5:全部 blocked 时仍做全量记忆计数**:`blocked.length > 0` 时跳过 `countForSessions`,返回 `{total:0, skipped:true}` 形状。
23
+ - **R1:请求体超限后继续读完整个 body**:超限立即 `finish(413)`,慢速超大请求不再占着连接。
24
+ - **R2:反馈投递失败也扣每小时额度**:`takeSlot` 返回 `release()`,传输失败 catch 里先退还槽位再抛 `E_FEEDBACK_TRANSPORT`;成功投递才计数,SMTP/HTTP 临时故障不会把用户锁一小时。
25
+ - **R3:批量列表按 soft 固定裁决,与 `defaultMode` 脱节**:`/sessions` 每行裁决改用 `config.defaultMode`;`stopLiveSessions:false` + hard 默认时,不可删的 live 行直接标 `deletable:false`。
26
+ - **R4:失败的删除项也占每分钟额度**:`limiter.take(1)` 改为带 `release()` 的预留,单条失败在 catch 里退还槽位,只有真正成功的删除计入窗口;首个 item 预算不足直接抛 429 的既有契约不变。
27
+
28
+ ### Changed / Maintenance
29
+
30
+ - **M2:会话 ID 正则去重**:抽出 `lib/core/ids.js` 的 `SESSION_ID_PATTERN`,`api.js` / `gateway.js` 共享,删除两处本地副本。
31
+ - **M4:`/state` 不再外泄记忆适配器绝对路径**:只输出 `{id,label,available,reason}`,`storePath` 不离开 Host。
32
+ - **M1:删除无引用的死 CSS `.dc-mode-preferred`**。
33
+ - **M3:`npm run check` 改为 `scripts/check-syntax.mjs` 遍历 `lib/**/*.js` + `client.js` 逐个 `node --check`**(原链式清单只覆盖 3 个文件,新增/改名模块不会再漏检)。
34
+ - 回归测试:`feedback` 17 → 18、`core` 13 → 14、`api` 34 → 38;新增覆盖 S1 无端口 Host、S2 未知路由 404、S3 损坏清单列出/清理/冲突、R2 失败退额度、R3 默认模式裁决、R4 失败退额度。
35
+
36
+ ## [0.3.2] — 2026-10-18
37
+
38
+ ### Fixed
39
+
40
+ - **`feedback.visitorAsFrom` 在 HTTP 通道静默失效**:HTTP 通道此前固定发送配置的 `from`(未配置则省略字段),`visitorAsFrom: true` 只在 SMTP 通道生效。现在 HTTP 通道同样在显式开启且访客填写了邮箱时把访客邮箱作为 `From`;默认配置下发送的请求体逐字节不变。
41
+ - **邮件正文把访客邮箱标注为发件人**:正文上下文里的 `from / 发件人` 行改为如实的 `visitor / 访客邮箱`。此前无论 `visitorAsFrom` 是否开启,该行都把访客邮箱标成发件人,与真实 `From`(发信账号或访客邮箱)不符;`reply-to` 行保留不变。
42
+
6
43
  ## [0.3.1] — 2026-10-18
7
44
 
8
45
  ### Added
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',
@@ -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} 个',
@@ -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
@@ -75,16 +75,40 @@ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retention
75
75
  })
76
76
  },
77
77
 
78
- /** Read the most recent `limit` records; malformed lines are counted, not thrown. */
78
+ /**
79
+ * Read the most recent `limit` records; malformed lines are counted, not
80
+ * thrown. Only the tail window (≤256KB) is read and parsed: the settings
81
+ * page asks for the last N rows on every open, and a full up-to-5MB parse
82
+ * on each request scaled with the whole trail, not with `limit`. Malformed
83
+ * lines outside the window are not counted (the metric describes the
84
+ * displayed window).
85
+ */
79
86
  async tail(limit = 50) {
80
- let content = ''
87
+ const maxRecords = Math.max(1, Math.floor(limit))
88
+ const CHUNK = 256 * 1024
89
+ let size = 0
90
+ try {
91
+ size = (await fsApi.stat(filePath)).size
92
+ } catch (error) {
93
+ if (error?.code === 'ENOENT') return { records: [], malformed: 0 }
94
+ throw error
95
+ }
96
+ const start = Math.max(0, size - CHUNK)
97
+ let buffer
81
98
  try {
82
- content = await fsApi.readFile(filePath, 'utf8')
99
+ buffer = start > 0
100
+ ? await fsApi.readFile(filePath, { start, end: size })
101
+ : await fsApi.readFile(filePath, 'utf8')
83
102
  } catch (error) {
84
103
  if (error?.code === 'ENOENT') return { records: [], malformed: 0 }
85
104
  throw error
86
105
  }
87
- const lines = content.split('\n').filter((line) => line.trim() !== '')
106
+ const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer)
107
+ const rawLines = text.split('\n')
108
+ // A chunk starts mid-line; drop the partial first line instead of
109
+ // parsing a torn fragment as malformed.
110
+ const lines = (start > 0 ? rawLines.slice(1) : rawLines)
111
+ .filter((line) => line.trim() !== '')
88
112
  const records = []
89
113
  let malformed = 0
90
114
  for (const line of lines) {
@@ -94,7 +118,7 @@ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retention
94
118
  malformed += 1
95
119
  }
96
120
  }
97
- return { records: records.slice(-limit), malformed }
121
+ return { records: records.slice(-maxRecords), malformed }
98
122
  },
99
123
  }
100
124
  }
@@ -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,25 @@ 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 instead.
620
638
  const request = requestId()
621
639
  const results = []
622
640
  const failures = []
623
641
  for (const session of sessions) {
642
+ const reservation = limiter.take(1)
643
+ if (!reservation.ok) {
644
+ const rateError = new DeleteError('E_RATE_LIMITED', `Too many deletions; retry in ${Math.ceil(reservation.retryAfterMs / 1000)}s.`, {
645
+ retryAfterMs: reservation.retryAfterMs,
646
+ limit: reservation.limit,
647
+ })
648
+ if (results.length === 0 && failures.length === 0) throw rateError
649
+ failures.push({ sessionId: session.id, error: rateError.toJSON() })
650
+ break
651
+ }
652
+ if (!validated.record.used) markConfirmationUsed(validated)
624
653
  try {
625
654
  const outcome = await performSessionDelete({
626
655
  session,
@@ -644,12 +673,16 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
644
673
  durationMs: outcome.durationMs,
645
674
  })
646
675
  } catch (error) {
676
+ // The deletion never happened: refund the slot so a failed item does
677
+ // not consume the per-minute quota of a successful one.
678
+ reservation.release()
647
679
  const normalized = asDeleteError(error)
648
680
  failures.push({ sessionId: session.id, error: normalized.toJSON() })
649
681
  logger.error?.(`[conversation-recycle-bin] delete failed for ${session.id}: ${normalized.message}`)
650
682
  }
651
683
  }
652
684
  gateway.invalidate()
685
+ invalidateTrashCache()
653
686
 
654
687
  if (results.length === 0 && failures.length > 0) {
655
688
  const first = failures[0].error
@@ -664,6 +697,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
664
697
  if (entry === null) throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
665
698
  const result = await performRestore({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
666
699
  gateway.invalidate()
700
+ invalidateTrashCache()
667
701
  return result
668
702
  }
669
703
 
@@ -679,6 +713,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
679
713
  // cannot be stopped within liveDrainMs, the purge fails and rolls back.
680
714
  const result = await performPurge({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
681
715
  gateway.invalidate()
716
+ invalidateTrashCache()
682
717
  return result
683
718
  }
684
719
 
@@ -692,13 +727,40 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
692
727
  if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() !== 'DELETE') {
693
728
  throw new DeleteError('E_CONFIRM_PHRASE', 'Type “DELETE” to discard this incomplete entry.', { expected: 'DELETE' })
694
729
  }
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() })
730
+ let entry = await trash.read(trashId)
731
+ if (entry === null) {
732
+ // Missing/corrupt manifest: the directory itself may still be on disk.
733
+ // Verify it exists, then discard against the actual payload; the disk
734
+ // checks inside performDiscard still refuse a folder holding a log or
735
+ // memory files, so a stranded directory can be cleaned without ever
736
+ // trusting the unreadable manifest.
737
+ const dir = join(paths.trashDir, trashId)
738
+ if (!(await ops.pathExists(dir))) {
739
+ throw new DeleteError('E_TRASH_NOT_FOUND', 'The recycle-bin entry was not found.', { trashId })
740
+ }
741
+ entry = {
742
+ trashId,
743
+ sessionId: null,
744
+ title: null,
745
+ mode: null,
746
+ includeMemory: false,
747
+ deletedAt: 0,
748
+ updatedAt: 0,
749
+ status: 'unreadable',
750
+ memory: { adapters: [], total: 0 },
751
+ sessionLog: null,
752
+ catalog: null,
753
+ }
754
+ }
755
+ const result = await performDiscard({ entry, ops, trash, audit, now, actor: 'local-user', source: auditSource, requestId: requestId() })
756
+ invalidateTrashCache()
757
+ return result
698
758
  }
699
759
 
700
760
  async function state() {
701
- const adapters = await memoryService.status()
761
+ // Only wire fields the Client renders; adapter `storePath` is an absolute
762
+ // filesystem handle and must not leave the Host.
763
+ const adapters = (await memoryService.status()).map(({ id, label, available, reason }) => ({ id, label, available, reason }))
702
764
  return {
703
765
  enabled: config.enabled,
704
766
  version: PLUGIN_VERSION,
@@ -727,10 +789,12 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
727
789
  // Every row carries its own policy verdict: "select all" used to include
728
790
  // subagent/orphan/out-of-root conversations that `/preview` then refuses
729
791
  // 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.
792
+ // which row to uncheck. Judge with the configured default mode (the mode
793
+ // the dialog opens in): with `stopLiveSessions: false`, hard is stricter
794
+ // than soft for live sessions, so a fixed soft verdict would mark those
795
+ // rows deletable only for the server to refuse later.
732
796
  return sessions.map((session) => {
733
- const verdict = evaluateSessionPolicy({ session, workspace: session.workspace, live: session.live, mode: 'soft', config })
797
+ const verdict = evaluateSessionPolicy({ session, workspace: session.workspace, live: session.live, mode: config.defaultMode, config })
734
798
  return {
735
799
  id: session.id,
736
800
  title: session.title,
@@ -769,14 +833,39 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
769
833
  }
770
834
 
771
835
  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 {
836
+ if (trashCache !== null && now() - trashCachedAt < TRASH_CACHE_TTL_MS) return trashCache
837
+ // Every readable manifest is returned: `trashed` entries are the usable
838
+ // recycle bin, while failed/pending/restoring/purging entries used to be
839
+ // invisible and impossible to clean up at all. Directories whose manifest
840
+ // is missing/corrupt are surfaced as `unreadable` so they are not stranded
841
+ // on disk; the Client renders both groups separately.
842
+ const [all, unreadable] = await Promise.all([trash.listAll(), trash.scanUnreadable()])
843
+ const result = {
777
844
  entries: all.filter((entry) => entry.status === 'trashed').map(serializeEntry),
778
- incomplete: all.filter((entry) => entry.status !== 'trashed').map(serializeEntry),
845
+ incomplete: [
846
+ ...all.filter((entry) => entry.status !== 'trashed').map(serializeEntry),
847
+ ...unreadable.map((item) => ({
848
+ trashId: item.trashId,
849
+ sessionId: null,
850
+ title: null,
851
+ mode: null,
852
+ includeMemory: false,
853
+ deletedAt: 0,
854
+ updatedAt: 0,
855
+ memory: 0,
856
+ memoryAdapters: [],
857
+ hasSessionLog: false,
858
+ status: 'unreadable',
859
+ error: {
860
+ code: item.reason,
861
+ message: 'The manifest could not be read; discard this folder only after confirming it holds no log or memory files.',
862
+ },
863
+ })),
864
+ ],
779
865
  }
866
+ trashCache = result
867
+ trashCachedAt = now()
868
+ return result
780
869
  }
781
870
 
782
871
  async function auditTail(limit) {
@@ -823,72 +912,88 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
823
912
  }
824
913
 
825
914
  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
- }
915
+ if (method === 'GET' && (path === '' || path === '/state')) {
916
+ sendJson(res, 200, { ok: true, state: await state() })
917
+ return
918
+ }
919
+ if (method === 'GET' && path === '/sessions') {
920
+ sendJson(res, 200, { ok: true, sessions: await sessionsForUi() })
921
+ return
922
+ }
923
+ if (method === 'GET' && path === '/trash') {
924
+ sendJson(res, 200, { ok: true, ...(await trashList()) })
925
+ return
926
+ }
927
+ if (method === 'GET' && path === '/audit') {
928
+ sendJson(res, 200, { ok: true, ...(await auditTail(url.searchParams.get('limit'))) })
929
+ return
930
+ }
931
+ // A GET that matched no route is an unknown endpoint (404), not a 405:
932
+ // 405 is reserved for non-POST verbs on a mounted prefix.
933
+ if (method === 'GET') {
934
+ throw new DeleteError('E_NOT_FOUND', 'Unknown endpoint.', { path: path.slice(0, 120) })
935
+ }
936
+ if (method !== 'POST') {
937
+ throw new DeleteError('E_BAD_REQUEST', 'This endpoint accepts POST requests only.', {}, { httpStatus: 405 })
938
+ }
939
+ const header = req.headers?.[REQUIRED_HEADER]
940
+ if (header !== '1') {
941
+ throw new DeleteError('E_FORBIDDEN_ORIGIN', 'Missing the plugin request header.', {})
942
+ }
943
+ if (!config.enabled && path !== '/feedback') throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
944
+ const body = await readJsonBody(req)
860
945
 
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
- }
946
+ // Feedback is available even when deletion is switched off: the two
947
+ // switches answer different questions, and a broken mail transport
948
+ // must not be reported as "deletion disabled".
949
+ if (path === '/feedback') {
950
+ const result = await feedbackService.submit(body)
951
+ sendJson(res, 200, { ok: true, ...result })
952
+ return
953
+ }
954
+
955
+ if (path === '/preview') {
956
+ const sessionIds = normalizeSessionIds(body)
957
+ const mode = normalizeMode(body, config)
958
+ const includeMemory = normalizeBoolean(body, 'includeMemory')
959
+ sendJson(res, 200, { ok: true, preview: await preview({ sessionIds, includeMemory, mode }) })
960
+ return
961
+ }
962
+ if (path === '/delete') {
963
+ // Upper-bound lock wait mirroring preview's planTimeoutMs: a 50-item
964
+ // batch legitimately outlives the default 30s, so a second Host waiting
965
+ // on the lock should not be told "busy" while the first is still
966
+ // working. Per-session export/restore plus a bounded live drain each.
967
+ const rawIds = Array.isArray(body?.sessionIds)
968
+ ? body.sessionIds
969
+ : typeof body?.sessionId === 'string'
970
+ ? [body.sessionId]
971
+ : []
972
+ const batchCount = Math.max(1, rawIds.length)
973
+ const lockBudget = Math.min(900_000, 15_000 + batchCount * 16_000 + batchCount * config.liveDrainMs)
974
+ const result = await serialize(() => deletePlan(body), { lockTimeoutMs: lockBudget })
975
+ // `ok` means "the plan was executed", not "every item succeeded".
976
+ // A partial batch carries `results` + `failures`; marking the whole
977
+ // response as an error hid those results and made the Client show
978
+ // "The Host refused the request" while rows had already been deleted.
979
+ sendJson(res, 200, { ok: true, partial: result.failures.length > 0, ...result })
980
+ return
981
+ }
982
+ if (path === '/trash/restore') {
983
+ const result = await serialize(() => restoreEntry(body?.trashId))
984
+ sendJson(res, 200, { ok: true, ...result })
985
+ return
986
+ }
987
+ if (path === '/trash/purge') {
988
+ const result = await serialize(() => purgeEntry(body?.trashId, body?.confirmPhrase))
989
+ sendJson(res, 200, { ok: true, ...result })
990
+ return
991
+ }
992
+ if (path === '/trash/discard') {
993
+ const result = await serialize(() => discardEntry(body?.trashId, body?.confirmPhrase))
994
+ sendJson(res, 200, { ok: true, ...result })
995
+ return
996
+ }
892
997
  throw new DeleteError('E_NOT_FOUND', 'Unknown endpoint.', { path: path.slice(0, 120) })
893
998
  } catch (error) {
894
999
  const normalized = asDeleteError(error)
@@ -279,7 +279,7 @@ export function renderFeedbackMessage(submission, { at = Date.now() } = {}) {
279
279
  '---- 自动附带 / attached automatically ----',
280
280
  ` plugin: dsh-conversation-recycle-bin ${PLUGIN_VERSION}`,
281
281
  ...contextLines,
282
- ` from / 发件人: ${submission.email ?? '(anonymous)'}`,
282
+ ` visitor / 访客邮箱: ${submission.email ?? '(anonymous)'}`,
283
283
  ` reply-to: ${submission.email ?? '(none)'}`,
284
284
  ` at: ${new Date(at).toISOString()}`,
285
285
  ]
@@ -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) {
@@ -331,7 +340,7 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
331
340
  const headers = { 'content-type': 'application/json' }
332
341
  if (settings.apiToken !== '') headers[settings.authHeader.toLowerCase()] = `Bearer ${settings.apiToken}`
333
342
  const body = {
334
- from: settings.from === '' ? undefined : settings.from,
343
+ from: (settings.visitorAsFrom === true && submission.email) ? submission.email : (settings.from === '' ? undefined : settings.from),
335
344
  to: [settings.to],
336
345
  subject: message.subject,
337
346
  text: message.text,
@@ -404,13 +413,16 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
404
413
  reason: settings.reason,
405
414
  })
406
415
  }
407
- takeSlot()
416
+ const slot = takeSlot()
408
417
  const message = renderFeedbackMessage(submission, { at: now() })
409
418
  const startedAt = now()
410
419
  try {
411
420
  if (settings.transport === 'http') await deliverHttp(message, submission)
412
421
  else await deliverSmtp(message, submission)
413
422
  } catch (error) {
423
+ // A failed delivery refunds the slot: a temporary provider outage must
424
+ // not consume the hourly cap and lock the visitor out for an hour.
425
+ slot.release()
414
426
  const normalized = error instanceof DeleteError
415
427
  ? error
416
428
  : 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.1'
8
+ export const PLUGIN_VERSION = '0.3.3'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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,