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 +46 -0
- package/README.md +6 -6
- package/client.js +8 -6
- package/lib/core/audit.js +51 -15
- package/lib/core/file-lock.js +4 -2
- package/lib/core/ids.js +7 -0
- package/lib/core/rate-limit.js +17 -3
- package/lib/core/trash.js +39 -0
- package/lib/host/api.js +209 -98
- package/lib/host/feedback.js +22 -3
- package/lib/host/gateway.js +27 -1
- package/lib/host/lifecycle.js +6 -0
- package/lib/version.js +1 -1
- package/package.json +2 -2
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/ #
|
|
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(
|
|
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
|
|
262
|
-
| `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization
|
|
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 #
|
|
291
|
-
npm test # 全量
|
|
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': '
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
}
|
package/lib/core/file-lock.js
CHANGED
|
@@ -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 >=
|
|
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
|
package/lib/core/ids.js
ADDED
|
@@ -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}$/
|
package/lib/core/rate-limit.js
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
696
|
-
if (entry === null)
|
|
697
|
-
|
|
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
|
-
|
|
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.
|
|
731
|
-
// the
|
|
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:
|
|
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
|
-
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
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:
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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)
|
package/lib/host/feedback.js
CHANGED
|
@@ -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
|
-
|
|
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 !== '')
|
|
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.', {
|
package/lib/host/gateway.js
CHANGED
|
@@ -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' ||
|
|
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') {
|
package/lib/host/lifecycle.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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
|
|
60
|
+
"check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs"
|
|
61
61
|
},
|
|
62
62
|
"dsh": {
|
|
63
63
|
"manifestVersion": 1,
|