dsh-conversation-recycle-bin 0.3.19 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,57 @@
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.21] — 2026-11-09
7
+
8
+ CI 第一次真正跑起来就抓到一条**只在中文机器上通过**的用例。0.3.20 发布后推送仓库,四个矩阵任务(ubuntu/windows × Node 22.15/24)全部在 `npm test` 阶段失败,失败点相同。
9
+
10
+ ### Fixed
11
+
12
+ - **`client-module.test.mjs` 把中文写进了断言**(`the background-sweep notice carries how long ago it ran`):`__formatAgo` 的「刚刚」被硬编码成字面量。`client.js` 的 `currentLocaleId()` 先读宿主的 `locale` 服务、读不到才回退 `navigator.language`,而这个用例没有安装 locale 服务,于是断言跟着**运行机器**的语言走——作者的中文 Windows 通过,所有英文 locale 的 CI 与贡献者机器都失败(`'just now' !== '刚刚'`)。这正是 0.3.17 修过的同一类缺陷:**测试没有锁定 locale,产品行为其实是对的**。
13
+ - 现在这条用例对 `en` 与 `zh-CN` **各跑一遍**,每次通过新加的 `loadClientWithLocale()` 显式安装 locale 服务,并逐段断言「刚刚 / 30 分钟前 / 3 小时前 / 2 天前」与英文文案,顺手把两套字典的相对时间文案一起钉住。
14
+
15
+ ### Added
16
+
17
+ - **CI 把失败摘要发布成 workflow annotation**:失败任务的原始日志需要仓库凭据才能读,只看到 `exit code 1` 无法区分「locale 相关的断言断错」和「夹具缺失」。`test` 步骤现在捕获输出,失败时把失败用例的名称、`expected`/`actual` 与所在文件行号以 `::error::` 注释发出,无需凭据即可读(本条缺陷就是这样定位的)。
18
+
19
+ ### Tests
20
+
21
+ - 用例总数不变(221),`client-module.test.mjs` 32/32。
22
+
23
+ ## [0.3.20] — 2026-11-09
24
+
25
+ 一次只改工程效率、不改删除语义的整理:把测试墙钟从 **60.5s 压到 4.6s**,堵住「跑过测试再打包」会把本机测试残留塞进 npm 包的路径,删掉三处零引用导出,并把两个确认前的**读**循环改成有界并发。生产路径的锁预算、超时与策略判定全部不变。
26
+
27
+ ### Changed
28
+
29
+ - **`/delete` 的锁预算改为可注入、且与 `preview` 共用一份计算**(`PLAN_LOCK_BUDGET` + `planLockBudgetMs()`):这两处过去是同一算式抄了两份(`preview` 发布 `limits.planTimeoutMs`,`/delete` 用它当跨进程锁超时),一处调参另一处很容易漏。生产默认值不变(15s 基数 + 每会话 16s + 每个 live 会话一个 drain 窗口,上限 900s);测试可传 `planBudget` 把预算降到毫秒级。
30
+ - **`preview` / `deletePlan` 的预检改为有界并发(8)**:`inspect()` 只读(persistence stat + 投影 + 缓存列表),50 项批量过去要串行付 50 次往返。**执行**循环仍是严格串行——只有读并发。
31
+ - **`gateway.loadList()` 加单飞 + 代际号**:预检并发后,冷启动时多个 `inspect()` 会同时触发列表构建;现在只有第一个真正构建,其余复用同一个 Promise。`invalidate()` 会递增代际并丢弃在飞的构建结果,保证「1.5s 缓存窗口内 pin 后删除」的既有修复不因并发回退。
32
+ - **`/sessions` 去掉第二趟投影读**:`enrichTitles()` 会对「标题仍为空」的行再读一遍 `buildList()` 刚读过的同一批投影文件,且只补前 80 行。`createdAt` 现在由 `buildList()` 在唯一那次读里补齐,`enrichTitles()` 整个删除。
33
+ - **`files` 收紧为 `test/*.mjs`**:过去是整个 `test/` 目录,而 npm 的 `files` 白名单优先于 `.gitignore`,于是 `npm test` 之后 `npm pack` 会把 `test/.tmp/` 里的会话日志、记忆索引和 `mutation-busy-*.lock` 打进包——同一个 commit 打两次包 shasum 不同。新增 `scripts/clean-tmp.mjs`(`prepack` 钩子)作为第二道防线,失败只告警不阻断发布。
34
+ - **`config.schema.json` / 语义不变**:本版没有新增或改动任何配置键。
35
+
36
+ ### Removed
37
+
38
+ | 符号 | 位置 | 状态 |
39
+ |---|---|---|
40
+ | `ok()` | `lib/core/errors.js` | 零引用(`toWireError` 才是失败态唯一出口) |
41
+ | `defaultConfig()` | `lib/core/config.js` | 零引用;连带删除只被它使用的 `clone()` |
42
+ | `writeJsonAtomic()` | `lib/host/paths.js` | 零引用;连带从 import 里去掉 `writeFile` |
43
+ | `enrichTitles()` | `lib/host/gateway.js` | 功能已并入 `buildList()`,见上 |
44
+
45
+ ### Added
46
+
47
+ - **`.github/workflows/ci.yml`**:此前仓库没有任何 CI,所有审计都是手工 `npm test`。现在 push/PR 会在 ubuntu + windows × Node 22.15/24 上跑 `npm run check`、`npm test`,并额外断言打包产物不含 `test/.tmp`。包无任何依赖,因此没有安装步骤。
48
+
49
+ ### Tests
50
+
51
+ - 用例总数不变(**221**,10 个文件),`api.test.mjs` 70/70 全过。
52
+ - **全量墙钟 60.5s → 4.6s;`api.test.mjs` 59.3s → 3.5s**,来自两处固定等待被消除:
53
+ - 「busy cross-process lock」用例过去被 `/delete` 的 33s 预算卡住(用例自己注入的 60ms `timeoutMs` 被路由参数覆盖)→ 现在注入 `planBudget` + `liveDrainMs: 0`,压到毫秒级;
54
+ - fixture 默认 `foreignWriterGraceMs: 0`,9 个 hard-delete 用例不再各付 1.5s 固定宽限。真正验证该窗口的两条用例仍直接构造 `createSessionLogOps({ foreignWriterGraceMs: 120/40, delay })`,语义未被稀释。
55
+ - README 的三处用例数(188 / 200 / 200)统一为实际的 **221**。
56
+
6
57
  ## [0.3.19] — 2026-11-05
7
58
 
8
59
  平台审计(架构 / 安全 / 验收三方独立复审 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.19`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
5
+ > 状态:`0.3.21`,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/ # 188 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
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 个文件、200 个用例);任一失败仍会跑完全部再汇总
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 # 全量 200 个用例
365
+ npm test # 全量 221 个用例
366
366
 
367
367
  # 端到端:用本机收件桩观察真实投递路径,不外发邮件
368
368
  $env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
@@ -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
- }
@@ -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
- for (const id of sessionIds) {
735
- const session = await gateway.inspect(id)
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 = Math.min(900_000, 15_000 + sessionIds.length * 16_000 + liveCount * config.liveDrainMs)
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
- for (const id of sessionIds) {
853
- const session = await gateway.inspect(id)
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
- const lockBudget = Math.min(900_000, 15_000 + batchCount * 16_000 + batchCount * config.liveDrainMs)
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),
@@ -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
- listCache = items.map((item) => {
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
- const untitled = listCache.filter((row) => row.title === '')
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
- listCachedAt = now()
202
- return listCache
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
- * Fill missing titles from the per-session projection checkpoints for the
378
- * first `limit` rows. The bulk list is read-only UI data, so this is done
379
- * lazily here rather than on every inspect; a missing checkpoint simply
380
- * leaves the id/cwd fallback the Client renders.
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, writeFile } from 'node:fs/promises'
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
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.3.19'
8
+ export const PLUGIN_VERSION = '0.3.21'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
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 30s because a
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 {
@@ -81,6 +81,32 @@ async function loadClient({ withReactDom = false } = {}) {
81
81
  return { exported, registrations, style, moduleId, body, portalCalls }
82
82
  }
83
83
 
84
+ /**
85
+ * Load the Client with an explicit active locale.
86
+ *
87
+ * `client.js` resolves the locale from the host's `locale` service and only
88
+ * then falls back to `navigator.language`, so a suite that just calls
89
+ * `loadClient()` silently follows whatever language the MACHINE is set to.
90
+ * That is how a Chinese literal in an assertion passed on a zh-CN laptop and
91
+ * failed on every English-locale CI runner. Anything that asserts user-visible
92
+ * text must pin the locale here instead.
93
+ */
94
+ async function loadClientWithLocale(localeId) {
95
+ const { exported } = await loadClient()
96
+ exported.apply({
97
+ slots: {
98
+ inject(key, callback) { return callback() ?? (() => {}) },
99
+ register() { return () => {} },
100
+ },
101
+ effect(fn) { fn() },
102
+ get(name) {
103
+ if (name === 'locale') return { getSnapshot: () => ({ active: localeId }) }
104
+ return undefined
105
+ },
106
+ })
107
+ return exported
108
+ }
109
+
84
110
  test('client: module id equals the package name the boot graph keys rows by', async () => {
85
111
  const { moduleId } = await loadClient()
86
112
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
@@ -1092,11 +1118,22 @@ test('client module: an incomplete entry offers purge when the Host allows it',
1092
1118
  })
1093
1119
 
1094
1120
  test('client module: the background-sweep notice carries how long ago it ran', async () => {
1095
- const { exported } = await loadClient()
1096
1121
  const now = Date.now()
1097
- assert.equal(exported.__formatAgo(now - 5_000, now), '刚刚')
1098
- assert.match(exported.__formatAgo(now - 30 * 60_000, now), /分钟前|min ago/)
1099
- assert.match(exported.__formatAgo(now - 3 * 3_600_000, now), /小时前|h ago/)
1100
- assert.match(exported.__formatAgo(now - 2 * 86_400_000, now), /天前|d ago/)
1101
- assert.equal(exported.__formatAgo(undefined, now), '')
1122
+ // Both languages, each with an explicitly installed locale service: a single
1123
+ // Chinese literal here (`'刚刚'`) made this assertion depend on the machine's
1124
+ // `navigator.language`, so it failed on every English-locale runner while
1125
+ // passing on the author's zh-CN laptop. Pinning both tables also proves the
1126
+ // relative-time wording exists on both sides.
1127
+ const cases = [
1128
+ ['en', { justNow: 'just now', minutes: /^30 min ago$/, hours: /^3 h ago$/, days: /^2 d ago$/ }],
1129
+ ['zh-CN', { justNow: '刚刚', minutes: /^30 分钟前$/, hours: /^3 小时前$/, days: /^2 天前$/ }],
1130
+ ]
1131
+ for (const [localeId, expected] of cases) {
1132
+ const client = await loadClientWithLocale(localeId)
1133
+ assert.equal(client.__formatAgo(undefined, now), '', `${localeId}: no timestamp renders nothing`)
1134
+ assert.equal(client.__formatAgo(now - 5_000, now), expected.justNow, `${localeId}: seconds`)
1135
+ assert.match(client.__formatAgo(now - 30 * 60_000, now), expected.minutes, `${localeId}: minutes`)
1136
+ assert.match(client.__formatAgo(now - 3 * 3_600_000, now), expected.hours, `${localeId}: hours`)
1137
+ assert.match(client.__formatAgo(now - 2 * 86_400_000, now), expected.days, `${localeId}: days`)
1138
+ }
1102
1139
  })