dsh-conversation-recycle-bin 0.3.5 → 0.3.6

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,25 @@
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.6] — 2026-10-24
7
+
8
+ 对应 `AUDIT-2026-10-24-desktop-web-rc3.md` §7 的四条「建议动作」(A2 / A3 / A5 / A6)。同一节的 A1、A4、A7、A8 复核后判定为产品取舍(不申报未直接消费的 client 依赖、不导出 `Config` 以免设置页脱离非表单语义、归档读保留双源缓存、`missingSessionPaths` 保持成员判定),本版不改。
9
+
10
+ ### Fixed
11
+
12
+ - **A3 `sessionController` 缺失时抛出的不是插件错误**:`lib/host/lifecycle.js` 新增 `peek()`,把 `ctx.get?.(name) ?? ctx[name]` 的整条退化读取留在 `try` 内。`??` 右侧的属性读会命中 cordis 的 Proxy trap(`cannot get property "sessionController" without inject`),异常类型不是 `DeleteError`,会绕过统一错误映射并让删除在归档前失败。现在 `sessionController` 明确视为可选服务:缺失时跳过 `cancel`,归档与 drain 检查照常执行。新增用例以抛异常的 getter 模拟裁剪组合,断言归档仍然发生。
13
+ - **A6 运行中判定永远落到 `inspect()`**:`lib/host/gateway.js` 的 `probeRunning()` 改为主探 `agents.get(id)?.status === 'running'`(与宿主 `dsh-api-session-controller` 列表行的判据一致),旧的 `Session.running/active/busy` 字段探测降为兜底。删除前置的「是否运行中」现在真正走廉价的实时探测,不必为每次判定重读会话。
14
+ - **A2 事件数列恒为 `—`**:`stat()` 从不返回 `eventCount`,旧代码读的正是这个不存在的字段。活跃会话改用 `Session.seq`(会话存储将其定义为日志长度,精确、纯数值读,不触发已废弃的 `snapshotEvents()`);已落盘的非活跃会话没有便宜的精确来源,继续返回 `null`、由 UI 显示 `—`,不拿投影游标冒充精确值。新增两个用例(活跃 = 精确值、非活跃 = `null`),并把测试夹具的 `stat()` 改成 0.2.1 的真实形状(去掉 `eventCount`),避免此前的假绿。
15
+
16
+ ### Added
17
+
18
+ - **A5 `engines.dsh`**:`package.json` 声明 `"dsh": ">=0.2.1-rc.2"`(本插件的实测下限)。该键在安装/声明时由版本闸门读取,运行时不强制;不设上界,因为更晚的版本尚未验证。
19
+
20
+ ### Maintenance
21
+
22
+ - 测试 138 → **141**(api 41 → 44:活跃/非活跃事件数、无 `sessionController` 的裁剪组合、agents 三态运行判定)。
23
+ - `lib/version.js` 与 `package.json` 同步到 0.3.6(`manifest` 用例守门)。
24
+
6
25
  ## [0.3.5] — 2026-10-22
7
26
 
8
27
  对应 `AUDIT-2026-10-22-desktop-web-compat.md`:把 0.3.4 之后一直只存在于工作树的修复正式发布,并落地该报告 §9 的全部建议。
@@ -186,6 +186,21 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
186
186
  * @returns {boolean|undefined}
187
187
  */
188
188
  function probeRunning(sessionId) {
189
+ // Authoritative cheap probe first: the Host computes a list row's `running`
190
+ // flag from exactly this check (`api-session-controller`: `agents.get(id)
191
+ // ?.status === "running"`). The `Session` object handed out by `sessions.get`
192
+ // carries no `running`/`active`/`busy` field, so the legacy probe below can
193
+ // only ever answer `undefined` on current Hosts.
194
+ const agents = service('agents')
195
+ if (agents !== undefined && typeof agents.get === 'function') {
196
+ try {
197
+ const agent = agents.get(sessionId)
198
+ if (agent === undefined || agent === null) return false
199
+ if (typeof agent.status === 'string') return agent.status === 'running'
200
+ } catch {
201
+ // Fall through to the legacy field probe.
202
+ }
203
+ }
189
204
  const sessions = service('sessions')
190
205
  if (sessions === undefined || typeof sessions.get !== 'function') return undefined
191
206
  let current
@@ -202,6 +217,31 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
202
217
  return undefined
203
218
  }
204
219
 
220
+ /**
221
+ * Event count when it is cheaply knowable, `null` otherwise.
222
+ *
223
+ * `sessionPersistence.stat()` reports `header`/`revision`/`sizeBytes` (and no
224
+ * `eventCount` on 0.2.1), so the old `snapshot.eventCount` read was dead. A
225
+ * live session does expose the count exactly: the session store defines `seq`
226
+ * as "always the log length" (`dsh-session` `get seq()`), and it is a plain
227
+ * number read, not a materialized `snapshotEvents()` (that API is deprecated).
228
+ * For a stored, non-live session there is no equivalent that avoids reading
229
+ * and parsing the whole log, so the field stays `null` instead of presenting
230
+ * a projection cursor as an exact count.
231
+ * @returns {number|null}
232
+ */
233
+ function eventCountFor(sessionId, snapshot) {
234
+ if (typeof snapshot?.eventCount === 'number') return snapshot.eventCount
235
+ const sessions = service('sessions')
236
+ if (sessions === undefined || typeof sessions.get !== 'function') return null
237
+ try {
238
+ const seq = sessions.get(sessionId)?.seq
239
+ return typeof seq === 'number' && Number.isFinite(seq) && seq >= 0 ? seq : null
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+
205
245
  async function workspaceFor(session) {
206
246
  const workspaces = await workspaceEntities()
207
247
  const byId = workspaces.find((workspace) => workspace.id === session.workspaceId)
@@ -251,7 +291,7 @@ export function createSessionGateway({ ctx, paths, now = Date.now }) {
251
291
  pinned: fromList?.pinned ?? false,
252
292
  createdAt: snapshot?.header?.createdAt ?? null,
253
293
  updatedAt: fromList?.updatedAt ?? null,
254
- events: typeof snapshot?.eventCount === 'number' ? snapshot.eventCount : null,
294
+ events: eventCountFor(sessionId, snapshot),
255
295
  sizeBytes: typeof snapshot?.sizeBytes === 'number' ? snapshot.sizeBytes : null,
256
296
  workspace: workspace === null ? null : { id: workspace.id, title: workspace.title, path: workspace.path, index: entity },
257
297
  workspaceId: workspace?.id ?? null,
@@ -20,13 +20,26 @@ import { DeleteError } from '../core/errors.js'
20
20
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
21
21
 
22
22
  export function createLifecycleOps({ ctx, gateway, config, now = Date.now, delay = sleep }) {
23
- function service(name) {
24
- let value
23
+ /**
24
+ * Optional-service probe.
25
+ *
26
+ * The property read has to stay inside the `try`: `ctx[name]` goes through
27
+ * cordis' Proxy trap, which throws `cannot get property "x" without inject`
28
+ * for a service this composition never provided. `ctx.get?.(name)` alone
29
+ * resolves to `undefined` in that case, and the `??` fallback would then
30
+ * throw a foreign error type out of the delete cascade (why a failed `get`
31
+ * must not be repaired by reading the property outside the guard).
32
+ */
33
+ function peek(name) {
25
34
  try {
26
- value = ctx.get?.(name) ?? ctx[name]
35
+ return ctx.get?.(name) ?? ctx[name]
27
36
  } catch {
28
- value = undefined
37
+ return undefined
29
38
  }
39
+ }
40
+
41
+ function service(name) {
42
+ const value = peek(name)
30
43
  if (value === undefined) {
31
44
  throw new DeleteError('E_INTERNAL', `The ${name} service is unavailable.`, {})
32
45
  }
@@ -55,7 +68,11 @@ export function createLifecycleOps({ ctx, gateway, config, now = Date.now, delay
55
68
  */
56
69
  async quiesce(session) {
57
70
  const registry = service('workspaceRegistry')
58
- const controller = ctx.get?.('sessionController') ?? ctx.sessionController
71
+ // Optional on purpose: a composition without the controller still deletes
72
+ // the log, it just cannot cancel an in-flight turn first (the archive gate
73
+ // and the drain check below still hold). `peek` keeps that path from
74
+ // throwing a cordis error instead of reaching the archive step.
75
+ const controller = peek('sessionController')
59
76
  const wasArchived = session.archived === true
60
77
  const drainMs = Math.max(0, Number(config.liveDrainMs) || 0)
61
78
 
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.5'
8
+ export const PLUGIN_VERSION = '0.3.6'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
@@ -33,7 +33,8 @@
33
33
  "LICENSE"
34
34
  ],
35
35
  "engines": {
36
- "node": ">=20"
36
+ "node": ">=20",
37
+ "dsh": ">=0.2.1-rc.2"
37
38
  },
38
39
  "optionalDependencies": {
39
40
  "nodemailer": "^10.1.2"