dsh-conversation-recycle-bin 0.3.2 → 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 +30 -0
- package/client.js +6 -4
- package/lib/core/audit.js +29 -5
- 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 +203 -98
- package/lib/host/feedback.js +14 -2
- 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,36 @@
|
|
|
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
|
+
|
|
6
36
|
## [0.3.2] — 2026-10-18
|
|
7
37
|
|
|
8
38
|
### Fixed
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(-
|
|
121
|
+
return { records: records.slice(-maxRecords), malformed }
|
|
98
122
|
},
|
|
99
123
|
}
|
|
100
124
|
}
|
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,25 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
608
631
|
})
|
|
609
632
|
}
|
|
610
633
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
696
|
-
if (entry === null)
|
|
697
|
-
|
|
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
|
-
|
|
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.
|
|
731
|
-
// the
|
|
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:
|
|
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
|
-
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
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) {
|
|
@@ -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.', {
|
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.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
|
|
60
|
+
"check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs"
|
|
61
61
|
},
|
|
62
62
|
"dsh": {
|
|
63
63
|
"manifestVersion": 1,
|