dsh-conversation-recycle-bin 0.3.19 → 0.3.20
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 +34 -0
- package/README.md +4 -4
- package/lib/core/config.js +0 -9
- package/lib/core/errors.js +0 -5
- package/lib/host/api.js +58 -8
- package/lib/host/gateway.js +49 -20
- package/lib/host/paths.js +1 -15
- package/lib/version.js +1 -1
- package/package.json +4 -3
- package/scripts/clean-tmp.mjs +42 -0
- package/test/api.test.mjs +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,40 @@
|
|
|
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.20] — 2026-11-09
|
|
7
|
+
|
|
8
|
+
一次只改工程效率、不改删除语义的整理:把测试墙钟从 **60.5s 压到 4.6s**,堵住「跑过测试再打包」会把本机测试残留塞进 npm 包的路径,删掉三处零引用导出,并把两个确认前的**读**循环改成有界并发。生产路径的锁预算、超时与策略判定全部不变。
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **`/delete` 的锁预算改为可注入、且与 `preview` 共用一份计算**(`PLAN_LOCK_BUDGET` + `planLockBudgetMs()`):这两处过去是同一算式抄了两份(`preview` 发布 `limits.planTimeoutMs`,`/delete` 用它当跨进程锁超时),一处调参另一处很容易漏。生产默认值不变(15s 基数 + 每会话 16s + 每个 live 会话一个 drain 窗口,上限 900s);测试可传 `planBudget` 把预算降到毫秒级。
|
|
13
|
+
- **`preview` / `deletePlan` 的预检改为有界并发(8)**:`inspect()` 只读(persistence stat + 投影 + 缓存列表),50 项批量过去要串行付 50 次往返。**执行**循环仍是严格串行——只有读并发。
|
|
14
|
+
- **`gateway.loadList()` 加单飞 + 代际号**:预检并发后,冷启动时多个 `inspect()` 会同时触发列表构建;现在只有第一个真正构建,其余复用同一个 Promise。`invalidate()` 会递增代际并丢弃在飞的构建结果,保证「1.5s 缓存窗口内 pin 后删除」的既有修复不因并发回退。
|
|
15
|
+
- **`/sessions` 去掉第二趟投影读**:`enrichTitles()` 会对「标题仍为空」的行再读一遍 `buildList()` 刚读过的同一批投影文件,且只补前 80 行。`createdAt` 现在由 `buildList()` 在唯一那次读里补齐,`enrichTitles()` 整个删除。
|
|
16
|
+
- **`files` 收紧为 `test/*.mjs`**:过去是整个 `test/` 目录,而 npm 的 `files` 白名单优先于 `.gitignore`,于是 `npm test` 之后 `npm pack` 会把 `test/.tmp/` 里的会话日志、记忆索引和 `mutation-busy-*.lock` 打进包——同一个 commit 打两次包 shasum 不同。新增 `scripts/clean-tmp.mjs`(`prepack` 钩子)作为第二道防线,失败只告警不阻断发布。
|
|
17
|
+
- **`config.schema.json` / 语义不变**:本版没有新增或改动任何配置键。
|
|
18
|
+
|
|
19
|
+
### Removed
|
|
20
|
+
|
|
21
|
+
| 符号 | 位置 | 状态 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `ok()` | `lib/core/errors.js` | 零引用(`toWireError` 才是失败态唯一出口) |
|
|
24
|
+
| `defaultConfig()` | `lib/core/config.js` | 零引用;连带删除只被它使用的 `clone()` |
|
|
25
|
+
| `writeJsonAtomic()` | `lib/host/paths.js` | 零引用;连带从 import 里去掉 `writeFile` |
|
|
26
|
+
| `enrichTitles()` | `lib/host/gateway.js` | 功能已并入 `buildList()`,见上 |
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **`.github/workflows/ci.yml`**:此前仓库没有任何 CI,所有审计都是手工 `npm test`。现在 push/PR 会在 ubuntu + windows × Node 22.15/24 上跑 `npm run check`、`npm test`,并额外断言打包产物不含 `test/.tmp`。包无任何依赖,因此没有安装步骤。
|
|
31
|
+
|
|
32
|
+
### Tests
|
|
33
|
+
|
|
34
|
+
- 用例总数不变(**221**,10 个文件),`api.test.mjs` 70/70 全过。
|
|
35
|
+
- **全量墙钟 60.5s → 4.6s;`api.test.mjs` 59.3s → 3.5s**,来自两处固定等待被消除:
|
|
36
|
+
- 「busy cross-process lock」用例过去被 `/delete` 的 33s 预算卡住(用例自己注入的 60ms `timeoutMs` 被路由参数覆盖)→ 现在注入 `planBudget` + `liveDrainMs: 0`,压到毫秒级;
|
|
37
|
+
- fixture 默认 `foreignWriterGraceMs: 0`,9 个 hard-delete 用例不再各付 1.5s 固定宽限。真正验证该窗口的两条用例仍直接构造 `createSessionLogOps({ foreignWriterGraceMs: 120/40, delay })`,语义未被稀释。
|
|
38
|
+
- README 的三处用例数(188 / 200 / 200)统一为实际的 **221**。
|
|
39
|
+
|
|
6
40
|
## [0.3.19] — 2026-11-05
|
|
7
41
|
|
|
8
42
|
平台审计(架构 / 安全 / 验收三方独立复审 v0.3.18)之后的修复。三方从不同角度撞到同一处:**记忆残留的条目在 UI/API 上没有任何出口**;另有一条把瞬时读失败当永久不可恢复。
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
|
|
4
4
|
|
|
5
|
-
> 状态:`0.3.
|
|
5
|
+
> 状态:`0.3.20`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
|
|
6
6
|
> 运行要求:**Node.js ≥ 22.15**(Host 侧用到 `readdir(..., { recursive: true })`、`AbortSignal` 等 22.x 行为),DSH `>=0.2.1-rc.2 <0.3.0-0`(`package.json` 的 `engines.dsh` 已如实声明,DSH 安装校验会据此拒绝范围外版本;上界是刻意的:0.2.2 与 0.3.x 之间没有承诺兼容)。
|
|
7
7
|
> 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
|
|
8
8
|
> 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
|
|
@@ -236,7 +236,7 @@ dsh-conversation-recycle-bin/
|
|
|
236
236
|
│ ├─ memory.js # 适配器装配
|
|
237
237
|
│ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
|
|
238
238
|
│ └─ api.js # 同源 HTTP API、确认 token、限流
|
|
239
|
-
└─ test/ #
|
|
239
|
+
└─ test/ # 221 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
|
|
240
240
|
```
|
|
241
241
|
|
|
242
242
|
---
|
|
@@ -245,7 +245,7 @@ dsh-conversation-recycle-bin/
|
|
|
245
245
|
|
|
246
246
|
```powershell
|
|
247
247
|
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
248
|
-
npm test # 依次跑 test/*.test.mjs(10 个文件、
|
|
248
|
+
npm test # 依次跑 test/*.test.mjs(10 个文件、221 个用例);任一失败仍会跑完全部再汇总
|
|
249
249
|
npm run check # 语法 + manifest
|
|
250
250
|
node scripts/verify-host-contract.mjs http://127.0.0.1:3082 --token=<token> # 宿主已启动时的运行时探针(boot 图 + /api/state)
|
|
251
251
|
```
|
|
@@ -362,7 +362,7 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
|
|
|
362
362
|
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
363
363
|
npm run check # 语法 + manifest(含 feedback.js)
|
|
364
364
|
node test/feedback.test.mjs # 20 个反馈用例
|
|
365
|
-
npm test # 全量
|
|
365
|
+
npm test # 全量 221 个用例
|
|
366
366
|
|
|
367
367
|
# 端到端:用本机收件桩观察真实投递路径,不外发邮件
|
|
368
368
|
$env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
|
package/lib/core/config.js
CHANGED
|
@@ -100,10 +100,6 @@ function isPlainObject(value) {
|
|
|
100
100
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function clone(value) {
|
|
104
|
-
return JSON.parse(JSON.stringify(value))
|
|
105
|
-
}
|
|
106
|
-
|
|
107
103
|
function clampInt(raw, fallback, min, max, field, warnings) {
|
|
108
104
|
if (raw === undefined) return fallback
|
|
109
105
|
const number = typeof raw === 'number' ? raw : Number.NaN
|
|
@@ -345,8 +341,3 @@ export function resolveJsonPointer(document, pointer) {
|
|
|
345
341
|
}
|
|
346
342
|
return cursor
|
|
347
343
|
}
|
|
348
|
-
|
|
349
|
-
/** Deep clone of the defaults; exported for tests and documentation. */
|
|
350
|
-
export function defaultConfig() {
|
|
351
|
-
return clone(DEFAULTS)
|
|
352
|
-
}
|
package/lib/core/errors.js
CHANGED
|
@@ -91,8 +91,3 @@ export function toWireError(error) {
|
|
|
91
91
|
const normalized = asDeleteError(error)
|
|
92
92
|
return { ok: false, error: redactDetails(normalized.toJSON()) }
|
|
93
93
|
}
|
|
94
|
-
|
|
95
|
-
/** Wire shape for a successful call. */
|
|
96
|
-
export function ok(value) {
|
|
97
|
-
return { ok: true, ...value }
|
|
98
|
-
}
|
package/lib/host/api.js
CHANGED
|
@@ -73,6 +73,13 @@ import { SESSION_ID_PATTERN } from '../core/ids.js'
|
|
|
73
73
|
export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
|
|
74
74
|
export const REQUIRED_HEADER = 'x-conversation-recycle-bin'
|
|
75
75
|
const MAX_BODY_BYTES = 256 * 1024
|
|
76
|
+
/**
|
|
77
|
+
* How many sessions a plan preflight may inspect at once. `inspect()` is a
|
|
78
|
+
* short read (persistence stat + one projection file), so this bounds open
|
|
79
|
+
* descriptors and CPU while still collapsing a 50-item batch's serial latency.
|
|
80
|
+
* The mutation loop itself stays strictly serial — only reads fan out.
|
|
81
|
+
*/
|
|
82
|
+
const PREFLIGHT_CONCURRENCY = 8
|
|
76
83
|
/**
|
|
77
84
|
* Lock wait budget for the single-entry trash mutations.
|
|
78
85
|
*
|
|
@@ -104,6 +111,36 @@ export function queueBudgetFor(lockBudgetMs) {
|
|
|
104
111
|
return (Number.isFinite(budget) && budget > 0 ? budget : 30_000) + QUEUE_GRACE_MS
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Shape of the lock wait a delete plan is allowed, per unit of work.
|
|
116
|
+
*
|
|
117
|
+
* The number has two consumers that MUST agree: `preview` publishes it as
|
|
118
|
+
* `limits.planTimeoutMs` (so the Client waits long enough) and `/delete` uses
|
|
119
|
+
* the very same computation as its cross-process lock timeout. They used to be
|
|
120
|
+
* two copies of the same expression, which is exactly the kind of pair that
|
|
121
|
+
* drifts when one side is tuned.
|
|
122
|
+
*
|
|
123
|
+
* `createDeleteApi` accepts an override so a test can shrink the budget without
|
|
124
|
+
* waiting the production 31 s to observe `E_LOCK_BUSY`. Production defaults are
|
|
125
|
+
* unchanged: 15 s base + 16 s per session + one drain window per live session.
|
|
126
|
+
*/
|
|
127
|
+
export const PLAN_LOCK_BUDGET = Object.freeze({ baseMs: 15_000, perItemMs: 16_000, capMs: 900_000 })
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Lock budget for a plan of `itemCount` sessions, `liveCount` of them live.
|
|
131
|
+
* @param {number} itemCount sessions in the plan
|
|
132
|
+
* @param {number} liveCount how many of them must be drained first
|
|
133
|
+
* @param {number} liveDrainMs configured `liveDrainMs`
|
|
134
|
+
* @param {{ baseMs: number, perItemMs: number, capMs: number }} [budget]
|
|
135
|
+
*/
|
|
136
|
+
export function planLockBudgetMs(itemCount, liveCount, liveDrainMs, budget = PLAN_LOCK_BUDGET) {
|
|
137
|
+
const items = Number.isFinite(itemCount) && itemCount > 0 ? itemCount : 0
|
|
138
|
+
const live = Number.isFinite(liveCount) && liveCount > 0 ? liveCount : 0
|
|
139
|
+
const drain = Number.isFinite(liveDrainMs) && liveDrainMs > 0 ? liveDrainMs : 0
|
|
140
|
+
const cap = Number.isFinite(budget?.capMs) && budget.capMs > 0 ? budget.capMs : PLAN_LOCK_BUDGET.capMs
|
|
141
|
+
return Math.min(cap, budget.baseMs + items * budget.perItemMs + live * drain)
|
|
142
|
+
}
|
|
143
|
+
|
|
107
144
|
function isLoopbackHostname(hostname) {
|
|
108
145
|
const value = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
109
146
|
return value === 'localhost' || value === '::1' || value === '0:0:0:0:0:0:0:1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)
|
|
@@ -370,7 +407,7 @@ function sendJson(res, status, payload) {
|
|
|
370
407
|
* E_LOCK_BUSY path with a short wait budget; production uses 30s because a
|
|
371
408
|
* deletion transaction legitimately takes a while and waiting beats failing.
|
|
372
409
|
*/
|
|
373
|
-
export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = console, mutationLock = null }) {
|
|
410
|
+
export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = console, mutationLock = null, planBudget = PLAN_LOCK_BUDGET }) {
|
|
374
411
|
const gateway = createSessionGateway({ ctx, paths, now, logger })
|
|
375
412
|
const memoryService = createMemoryService({
|
|
376
413
|
adapters: createMemoryAdapters({ config, dshHome: paths.dshHome, now }),
|
|
@@ -731,8 +768,13 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
731
768
|
}
|
|
732
769
|
const sessions = []
|
|
733
770
|
const blocked = []
|
|
734
|
-
|
|
735
|
-
|
|
771
|
+
// `inspect()` only reads (persistence stat + projection + cached list), so
|
|
772
|
+
// the per-session preflight fans out. A 50-item batch used to pay 50 serial
|
|
773
|
+
// round trips before the dialog could render the plan.
|
|
774
|
+
const inspected = await mapLimit(sessionIds, PREFLIGHT_CONCURRENCY, (id) => gateway.inspect(id))
|
|
775
|
+
for (let index = 0; index < sessionIds.length; index += 1) {
|
|
776
|
+
const id = sessionIds[index]
|
|
777
|
+
const session = inspected[index]
|
|
736
778
|
if (session === null) {
|
|
737
779
|
blocked.push({ sessionId: id, policy: { ok: false, code: 'E_NOT_FOUND', message: 'The conversation was not found.' } })
|
|
738
780
|
continue
|
|
@@ -770,7 +812,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
770
812
|
// instead of its own estimate, otherwise it aborts while the Host is still
|
|
771
813
|
// deleting and retries into an in-flight transaction.
|
|
772
814
|
const liveCount = sessions.filter((item) => item.live === true).length
|
|
773
|
-
const planTimeoutMs =
|
|
815
|
+
const planTimeoutMs = planLockBudgetMs(sessionIds.length, liveCount, config.liveDrainMs, planBudget)
|
|
774
816
|
|
|
775
817
|
return {
|
|
776
818
|
sessions,
|
|
@@ -849,8 +891,13 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
849
891
|
// of reasons, never half-executed because the last id was forbidden.
|
|
850
892
|
const sessions = []
|
|
851
893
|
const refusals = []
|
|
852
|
-
|
|
853
|
-
|
|
894
|
+
// Read-only preflight, same bounded fan-out as `preview`: nothing mutates
|
|
895
|
+
// here, so the order of the reads is irrelevant — only the order of the
|
|
896
|
+
// resulting `sessions`/`refusals` lists is, and that is rebuilt below.
|
|
897
|
+
const preflighted = await mapLimit(sessionIds, PREFLIGHT_CONCURRENCY, (id) => gateway.inspect(id))
|
|
898
|
+
for (let index = 0; index < sessionIds.length; index += 1) {
|
|
899
|
+
const id = sessionIds[index]
|
|
900
|
+
const session = preflighted[index]
|
|
854
901
|
if (session === null) {
|
|
855
902
|
refusals.push({ sessionId: id, code: 'E_NOT_FOUND', message: 'The conversation was not found.' })
|
|
856
903
|
continue
|
|
@@ -1061,8 +1108,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1061
1108
|
}
|
|
1062
1109
|
|
|
1063
1110
|
async function sessionsForUi() {
|
|
1111
|
+
// `gateway.list()` fills titles and `createdAt` from the per-session
|
|
1112
|
+
// projections for every row, so there is no second enrichment pass here.
|
|
1064
1113
|
const sessions = await gateway.list()
|
|
1065
|
-
await gateway.enrichTitles(sessions)
|
|
1066
1114
|
// Every row carries its own policy verdict: "select all" used to include
|
|
1067
1115
|
// subagent/orphan/out-of-root conversations that `/preview` then refuses
|
|
1068
1116
|
// as a whole batch, leaving the user with a disabled button and no hint
|
|
@@ -1494,7 +1542,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1494
1542
|
? [body.sessionId]
|
|
1495
1543
|
: []
|
|
1496
1544
|
const batchCount = Math.max(1, rawIds.length)
|
|
1497
|
-
|
|
1545
|
+
// Same computation `preview` publishes, assuming the worst case (every
|
|
1546
|
+
// item live) so the lock wait can never be shorter than the work.
|
|
1547
|
+
const lockBudget = planLockBudgetMs(batchCount, batchCount, config.liveDrainMs, planBudget)
|
|
1498
1548
|
const result = await serialize(() => deletePlan(body), {
|
|
1499
1549
|
lockTimeoutMs: lockBudget,
|
|
1500
1550
|
shouldAbort: () => clientGone(req, res),
|
package/lib/host/gateway.js
CHANGED
|
@@ -24,6 +24,13 @@ const STATE_TTL_MS = 1_500
|
|
|
24
24
|
export function createSessionGateway({ ctx, paths, now = Date.now, logger = console }) {
|
|
25
25
|
let listCache = null
|
|
26
26
|
let listCachedAt = -Infinity
|
|
27
|
+
// Single-flight for the list build. `inspect()` calls `loadList()` and a plan
|
|
28
|
+
// preflight now inspects several sessions concurrently, so without this every
|
|
29
|
+
// worker that arrives before the first build finishes would start its own.
|
|
30
|
+
let listInFlight = null
|
|
31
|
+
// Bumped by `invalidate()`. A build that started before an invalidation must
|
|
32
|
+
// not write its (possibly pre-mutation) snapshot into the cache afterwards.
|
|
33
|
+
let listGeneration = 0
|
|
27
34
|
let stateCache = null
|
|
28
35
|
let stateCachedAt = -Infinity
|
|
29
36
|
|
|
@@ -115,6 +122,18 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
|
|
|
115
122
|
|
|
116
123
|
async function loadList() {
|
|
117
124
|
if (listCache !== null && now() - listCachedAt < LIST_TTL_MS) return listCache
|
|
125
|
+
if (listInFlight !== null) return listInFlight
|
|
126
|
+
const generation = listGeneration
|
|
127
|
+
const pending = buildList(generation)
|
|
128
|
+
listInFlight = pending
|
|
129
|
+
try {
|
|
130
|
+
return await pending
|
|
131
|
+
} finally {
|
|
132
|
+
if (listInFlight === pending) listInFlight = null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function buildList(generation) {
|
|
118
137
|
const controller = service('sessionController')
|
|
119
138
|
const persistence = service('sessionPersistence')
|
|
120
139
|
let items = []
|
|
@@ -160,7 +179,7 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
|
|
|
160
179
|
}
|
|
161
180
|
const titles = await readTitles()
|
|
162
181
|
const state = await readWorkspaceState()
|
|
163
|
-
|
|
182
|
+
const rows = items.map((item) => {
|
|
164
183
|
const member = workspaceBySession.get(item.sessionId) ?? null
|
|
165
184
|
const byCwd = member === null ? workspaceByCwd(item.cwd) : null
|
|
166
185
|
const owned = member ?? (byCwd === null ? null : { workspace: byCwd, index: -1 })
|
|
@@ -189,17 +208,29 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
|
|
|
189
208
|
// above is usually empty and every row would render untitled. Fill the gaps
|
|
190
209
|
// from the authoritative per-session projection, in small batches so a long
|
|
191
210
|
// history does not open hundreds of descriptors at once.
|
|
192
|
-
|
|
211
|
+
//
|
|
212
|
+
// This is also the only place `createdAt` can come from: the legacy bulk
|
|
213
|
+
// table does not carry it. `enrichTitles()` used to run a second pass over
|
|
214
|
+
// exactly these rows (re-reading the same projection files) purely to fill
|
|
215
|
+
// that field, and was capped at the first 80 rows; doing it here makes the
|
|
216
|
+
// pass complete and removes the duplicate reads.
|
|
217
|
+
const untitled = rows.filter((row) => row.title === '')
|
|
193
218
|
for (let index = 0; index < untitled.length; index += 16) {
|
|
194
219
|
await Promise.all(untitled.slice(index, index + 16).map(async (row) => {
|
|
195
220
|
const projection = await readSessionProjection(row.id)
|
|
196
221
|
if (projection === null) return
|
|
197
222
|
if (projection.title !== '') row.title = projection.title
|
|
198
223
|
row.blank = row.blank || projection.blank
|
|
224
|
+
if (row.createdAt === null) row.createdAt = projection.createdAt ?? null
|
|
199
225
|
}))
|
|
200
226
|
}
|
|
201
|
-
|
|
202
|
-
|
|
227
|
+
// A build that started before an `invalidate()` describes state the caller
|
|
228
|
+
// deliberately discarded, so it must not install itself as the cache.
|
|
229
|
+
if (generation === listGeneration) {
|
|
230
|
+
listCache = rows
|
|
231
|
+
listCachedAt = now()
|
|
232
|
+
}
|
|
233
|
+
return rows
|
|
203
234
|
}
|
|
204
235
|
|
|
205
236
|
function isLive(sessionId) {
|
|
@@ -374,25 +405,23 @@ export function createSessionGateway({ ctx, paths, now = Date.now, logger = cons
|
|
|
374
405
|
},
|
|
375
406
|
|
|
376
407
|
/**
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
408
|
+
* NOTE: `enrichTitles()` used to live here. It re-read the same projection
|
|
409
|
+
* files `buildList()` had just read — but only for rows still untitled, and
|
|
410
|
+
* only for the first 80 rows — purely to fill a title/`createdAt` that
|
|
411
|
+
* `buildList()` now fills for every row. `/sessions` paid for that second,
|
|
412
|
+
* partial pass on every call. Titles and `createdAt` are complete after
|
|
413
|
+
* `list()`; see `buildList()`.
|
|
381
414
|
*/
|
|
382
|
-
async enrichTitles(sessions, limit = 80) {
|
|
383
|
-
const slice = sessions.slice(0, Math.max(1, limit))
|
|
384
|
-
await Promise.all(slice.map(async (session) => {
|
|
385
|
-
if (typeof session.title === 'string' && session.title !== '') return
|
|
386
|
-
const projection = await readSessionProjection(session.id)
|
|
387
|
-
if (projection === null) return
|
|
388
|
-
if (typeof projection.title === 'string' && projection.title !== '') session.title = projection.title
|
|
389
|
-
if (projection.blank === true) session.blank = true
|
|
390
|
-
if (session.createdAt === null) session.createdAt = projection.createdAt ?? null
|
|
391
|
-
}))
|
|
392
|
-
return sessions
|
|
393
|
-
},
|
|
394
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Drop every cached read. Also retires an in-flight list build: it started
|
|
418
|
+
* from state the caller has just been told is stale (a pin or archive made
|
|
419
|
+
* inside the 1.5 s window), so its result must neither be installed nor be
|
|
420
|
+
* what the next `inspect()` awaits.
|
|
421
|
+
*/
|
|
395
422
|
invalidate() {
|
|
423
|
+
listGeneration += 1
|
|
424
|
+
listInFlight = null
|
|
396
425
|
listCache = null
|
|
397
426
|
listCachedAt = -Infinity
|
|
398
427
|
stateCache = null
|
package/lib/host/paths.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* its original cwd and there is no directory left to scan.
|
|
15
15
|
*/
|
|
16
16
|
import { createReadStream } from 'node:fs'
|
|
17
|
-
import { cp, mkdir, open, readdir, readFile, rename, rm, stat
|
|
17
|
+
import { cp, mkdir, open, readdir, readFile, rename, rm, stat } from 'node:fs/promises'
|
|
18
18
|
import { homedir } from 'node:os'
|
|
19
19
|
import { dirname, join, resolve } from 'node:path'
|
|
20
20
|
|
|
@@ -297,17 +297,3 @@ export async function readJsonFile(filePath) {
|
|
|
297
297
|
throw new Error(`malformed JSON at ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
|
-
|
|
301
|
-
/** Write JSON through a sibling temp file + rename (never a torn target). */
|
|
302
|
-
export async function writeJsonAtomic(filePath, value) {
|
|
303
|
-
await mkdir(dirname(filePath), { recursive: true })
|
|
304
|
-
const temporary = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
|
305
|
-
const payload = `${JSON.stringify(value, null, 2)}\n`
|
|
306
|
-
await writeFile(temporary, payload, 'utf8')
|
|
307
|
-
try {
|
|
308
|
-
await rename(temporary, filePath)
|
|
309
|
-
} catch (error) {
|
|
310
|
-
await rm(temporary, { force: true }).catch(() => {})
|
|
311
|
-
throw error
|
|
312
|
-
}
|
|
313
|
-
}
|
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.20",
|
|
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"LICENSE",
|
|
33
33
|
"AUDIT-*.md",
|
|
34
34
|
"scripts/",
|
|
35
|
-
"test
|
|
35
|
+
"test/*.mjs"
|
|
36
36
|
],
|
|
37
37
|
"engines": {
|
|
38
38
|
"node": ">=22.15",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"test": "node scripts/run-tests.mjs",
|
|
63
|
-
"check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs"
|
|
63
|
+
"check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs",
|
|
64
|
+
"prepack": "node scripts/clean-tmp.mjs"
|
|
64
65
|
},
|
|
65
66
|
"dsh": {
|
|
66
67
|
"manifestVersion": 1,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Remove `test/.tmp` before a tarball is built.
|
|
4
|
+
*
|
|
5
|
+
* The suites create their fixtures under `test/.tmp/<random>` and clean up
|
|
6
|
+
* afterwards, but on Windows a lingering handle (antivirus, a still-open log
|
|
7
|
+
* file, a lock file whose owner process is slow to exit) makes `rm` fail
|
|
8
|
+
* silently. `files` in package.json used to whitelist the whole `test/`
|
|
9
|
+
* directory, and npm's `files` whitelist wins over `.gitignore`, so a
|
|
10
|
+
* `npm pack` after `npm test` shipped those leftovers: a real session log, a
|
|
11
|
+
* memory index and a `mutation-busy-*.lock`. The tarball then differed between
|
|
12
|
+
* two runs of the same commit.
|
|
13
|
+
*
|
|
14
|
+
* `files` now names `test/*.mjs` explicitly, which already excludes the
|
|
15
|
+
* directory; this script is the second line of defence (and keeps `npm pack`
|
|
16
|
+
* output identical whether or not the tests ran in the same checkout).
|
|
17
|
+
*
|
|
18
|
+
* Best effort on purpose: a leftover fixture must never block a release. The
|
|
19
|
+
* failure is printed so it is visible instead of silent.
|
|
20
|
+
*/
|
|
21
|
+
import { readdirSync, rmSync } from 'node:fs'
|
|
22
|
+
import { dirname, resolve } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
const tmpDir = resolve(here, '..', 'test', '.tmp')
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
readdirSync(tmpDir)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code === 'ENOENT') process.exit(0)
|
|
32
|
+
console.warn(`[pack] could not read ${tmpDir}: ${error?.code ?? error}`)
|
|
33
|
+
process.exit(0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
38
|
+
console.log(`[pack] removed leftover test fixtures at ${tmpDir}`)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.warn(`[pack] could not remove ${tmpDir} (${error?.code ?? error}); check "files" in package.json`)
|
|
41
|
+
}
|
|
42
|
+
process.exit(0)
|
package/test/api.test.mjs
CHANGED
|
@@ -49,6 +49,7 @@ async function makeFixture({
|
|
|
49
49
|
mutationLock = null,
|
|
50
50
|
connection = null,
|
|
51
51
|
clock = null,
|
|
52
|
+
planBudget = null,
|
|
52
53
|
} = {}) {
|
|
53
54
|
const home = await makeTempHome()
|
|
54
55
|
const workspaceRegistry = makeWorkspaceRegistry([
|
|
@@ -135,6 +136,12 @@ async function makeFixture({
|
|
|
135
136
|
// is covered by `connection: a Host without a connection service refuses
|
|
136
137
|
// by default`.
|
|
137
138
|
requireConnection: connection !== null,
|
|
139
|
+
// The foreign-writer grace window is a fixed sleep inside every hard
|
|
140
|
+
// delete, so paying the production 1.5 s in ~10 unrelated tests bought
|
|
141
|
+
// nothing but wall time. The two tests that actually exercise the window
|
|
142
|
+
// build `createSessionLogOps` directly with their own value (and a stub
|
|
143
|
+
// `delay`), so they are unaffected by this default.
|
|
144
|
+
foreignWriterGraceMs: 0,
|
|
138
145
|
...configOverrides,
|
|
139
146
|
}).value
|
|
140
147
|
const paths = resolveDataPaths(home.root)
|
|
@@ -146,6 +153,7 @@ async function makeFixture({
|
|
|
146
153
|
...(clock === null ? {} : { now: clock }),
|
|
147
154
|
logger: { error: (...args) => logs.push(args.join(' ')), warn() {}, info() {} },
|
|
148
155
|
mutationLock,
|
|
156
|
+
...(planBudget === null ? {} : { planBudget }),
|
|
149
157
|
})
|
|
150
158
|
return { home, api, config, paths, services, workspaceRegistry, seeded, memoryRoot, logs, items }
|
|
151
159
|
}
|
|
@@ -927,9 +935,15 @@ test('mutation lock: a busy cross-process lock is E_LOCK_BUSY, not a corrupted m
|
|
|
927
935
|
const lockPath = join(process.cwd(), 'test', '.tmp', `mutation-busy-${randomBytes(6).toString('hex')}.lock`)
|
|
928
936
|
const holder = createFileLock({ lockPath, timeoutMs: 2_000, staleMs: 60_000, retryMs: 10 })
|
|
929
937
|
const fixture = await makeFixture({
|
|
930
|
-
// A short wait budget keeps the test fast; production waits
|
|
938
|
+
// A short wait budget keeps the test fast; production waits 31s because a
|
|
931
939
|
// deletion transaction legitimately takes a while.
|
|
932
940
|
mutationLock: createFileLock({ lockPath, timeoutMs: 60, staleMs: 60_000, retryMs: 10 }),
|
|
941
|
+
// `/delete` used to derive its own lock budget (15s + 16s/item + drain) and
|
|
942
|
+
// hand it to `serialize()`, overriding the lock's own timeout — so this
|
|
943
|
+
// test sat on the whole 33s budget just to observe E_LOCK_BUSY. The budget
|
|
944
|
+
// is now injectable, and `liveDrainMs: 0` removes the last fixed slice.
|
|
945
|
+
planBudget: { baseMs: 40, perItemMs: 20, capMs: 200 },
|
|
946
|
+
configOverrides: { liveDrainMs: 0 },
|
|
933
947
|
})
|
|
934
948
|
const release = await holder.acquire()
|
|
935
949
|
try {
|