Skip to content

Releases

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 10 Oct 19:20

Full Changelog: v0.4.9...v0.5.0

v0.4.9

Choose a tag to compare

@github-actions github-actions released this 01 Oct 16:00

Full Changelog: v0.4.8...v0.4.9

v0.4.8

Choose a tag to compare

@github-actions github-actions released this 01 Oct 03:53

Full Changelog: v0.4.7...v0.4.8

v0.4.7

Choose a tag to compare

@github-actions github-actions released this 30 Sep 18:12

Full Changelog: v0.4.6...v0.4.7

v0.4.6

Choose a tag to compare

@github-actions github-actions released this 30 Sep 15:05

Full Changelog: v0.4.5...v0.4.6

v0.4.5

Choose a tag to compare

@github-actions github-actions released this 30 Sep 09:33

Full Changelog: v0.4.4...v0.4.5

v0.4.4

Choose a tag to compare

@github-actions github-actions released this 30 Sep 08:37

Full Changelog: v0.4.3...v0.4.4

v0.4.3

Choose a tag to compare

@SunshineR04 SunshineR04 released this 30 Sep 02:44

修复:对话框生命周期、故障必定可见、一次请求只读一遍语料(P2 收口)

复审 0.4.2 之后的 P2 清单落地。每一条都有承重测试:九条负向对照逐条把修复改回去,
确认对应测试确实失败,再把文件按哈希还原(client.js 4B4FFB5F…、index.js 9235D629…)。

对话框生命周期

  • 一次性守卫可能被永久卡死:confirmOpen 是模块级状态,只有 finish() 会释放它。于是
    挂载遮罩时的一次同步抛错会——(a) 让一个 promise 被 reject,而设置页两处调用点都在
    try/catch 之外 await 它(未处理 rejection,用户看不到任何反馈);(b) 把守卫留在置位
    状态,之后每一次确认都直接按「取消」处理:删除按钮点了没反应、什么都没删、也没有报错。
    现在挂载被包在 try/catch 里,走与「渲染退化」检查同一条「取消」通道,并给出本地化文案。
  • 插件卸载时没有人关闭已打开的对话框:重载/重挂插件会留下孤立的遮罩、它的 document
    捕获期 keydown 监听器,以及置位的守卫。apply 的注入 DOM 清理现在会调用
    closeOpenConfirm,同时移除注入的 <style> 与仍在屏上的纯 DOM toast(含定时器)。

反馈通道

  • loadPending 把失败吞了:pendingIds 为空时,排队(墓碑)中的会话会以普通归档行
    渲染——那个「恢复」按钮会被宿主以 session/pending 拒绝——而整块待删横幅消失,全程无声。
  • error 是后写覆盖前写:一次部分成功删除的宿主 warnings 会被随后的刷新失败覆盖
    (批量删除的汇总也会覆盖 warnings)。现在 pushError 累加,每次操作开始时清空。
  • 「宿主不可达」的提示根本不可达(!rpcAvailable 永远为真:inject 面总会提供闭包)。
    现在由 ping 的应答结果驱动——那是唯一可用的证据;另外三处裸英文传输错误信息也已入词典。

无障碍(a11y)

  • 对话框用后果文本描述自己(aria-describedby):一次不可逆删除,光有标题不足以让
    辅助技术用户知道自己在确认什么。
  • role="status" 活区在第一个子节点之前就已挂载(与内容同时创建的活区是经典的「播报
    丢失」写法);失败落进 role="alert" 横幅;多个「取消删除」按钮现在有各自的可访问名,
    不再是一串同名按钮。
  • 选中行不再把 border 简写与 borderColor 混用(React 此前每次重渲染都会告警)。

宿主:一次操作只读一遍语料

sessionPersistence.list() 会解压每个已存 header,而 deferred/list 原本逐个排队 id
从头解析——而且是两遍(sweep 一遍、recoverable 拆分一遍),全程还持着操作锁。现在
sweep、产物解析与 recoverable 拆分共享同一份索引(artifactIndex);不传索引时保持
单 id 行为。索引同时接受符号链接的项目目录(目录项的 isDirectory() 对链接返回 false,
Windows junction 也是如此,而叶子检查用的是 stat):只按 isDirectory() 过滤会漏掉产物、
清掉墓碑,并在下次启动让被删会话复活。

工具与文档

  • 两个破坏性 e2e 脚本不再依赖操作者自觉:scripts/e2e-guard.mjs 要求 e2e-seed.mjs
    写入的标记文件,并点名拒绝真实 ~/.dsh——只凭一个 URL 无法证明它背后是哪个 home。
    因此 e2e-residue.mjs 现在要求显式的 --home。
  • 渲染测试的 harness 现在按 label 捕获 ctx.effect 的清理函数(runDisposer(label)):
    原先的 effect: (fn) => fn() 丢弃了所有 disposer,使每条 teardown 路径都无法测试。
  • AGENTS.md / 两个 README 同步新不变量、e2e 守卫、harness 说明与测试名册;
    编辑器残留文件 .zcodeignore 已加入 gitignore。

验证

  • pnpm test 83/83(宿主 31 + 客户端 43 + 契约 9)。
  • 九条负向对照(各以 --test-name-pattern 单独复跑,每条恰好 1 个测试、恰好 1 个失败):
    移除遮罩挂载的 try/catch、移除 teardown 关框、把 pushError 改回 setError(warning 与
    刷新失败各一条)、把 loadPending 改回吞错、把不可达提示改回死条件、移除
    aria-describedby、移除取消按钮的可访问名、把 deferred/list 改回逐 id 解析、把项目目录
    过滤改回只看 isDirectory()——九条全部如期失败,随后按哈希还原并通过全量。
  • 隔离守卫四态实测:缺 --home/指向真实 home/未播种的临时 home 均被拒绝,带标记的
    播种 home 通过。
English

Fix: dialog lifecycle, always-visible failures, one corpus read per request (the P2 items)

Dialog lifecycle. confirmOpen is module scope and only finish() released it, so a
synchronous throw while attaching the overlay (a) rejected a promise both settings call sites await
OUTSIDE try/catch — an unhandled rejection with no user feedback — and (b) left the guard set, which
made EVERY later confirm resolve null: a delete button that opens nothing, deletes nothing and
reports nothing. The attach is now wrapped, resolving through the same "cancelled" channel as the
existing degenerate-render check. Nothing closed an open dialog on teardown either; apply's
injected-DOM cleanup now runs closeOpenConfirm and also removes the injected <style> and any
plain-DOM toast still on screen (with its timer).

Feedback channels. loadPending swallowed its failure: with pendingIds empty a queued
(tombstoned) session renders as an ordinary archived row — with a Restore the host refuses as
session/pending — and the whole banner disappears, silently. error was last-write-wins, so a
partial delete's host warnings were replaced by its refresh failure; pushError now accumulates per
operation. The unreachable-host guidance was unreachable (!rpcAvailable can never be true) and is
now driven by the ping ANSWER; the three raw English transport messages moved into the dictionaries.

Accessibility. The dialog describes itself by its consequence text; the role="status" live
region is mounted before its first child; failures land in a role="alert" banner; two
pending-cancel buttons are distinguishable by accessible name; a selected row no longer mixes the
border shorthand with borderColor.

Host. deferred/list resolved every queued id from scratch — twice (the sweep and the
recoverable split) — while holding the operation lock, and sessionPersistence.list() decompresses
every stored header. One shared artifactIndex now serves the sweep, the artifact resolution and the
split. It also accepts symlinked project directories: a dirent for a link reports
isDirectory() === false (Windows junctions too) while the leaf check uses stat, so the old filter
could hide an artifact, clear its tombstone and resurrect the session at the next boot.

Tooling/docs. The two DESTRUCTIVE e2e scripts now enforce isolation: scripts/e2e-guard.mjs
requires the marker e2e-seed.mjs writes and refuses the real ~/.dsh by name, because a URL alone
cannot prove which home an instance serves (e2e-residue.mjs takes a required --home). The render
harness captures ctx.effect cleanups by label — the old stub dropped every disposer, which made
every teardown path untestable. AGENTS.md and both READMEs carry the new invariants.

Verification. pnpm test: 83/83 (31 host + 43 client + 9 contract). Nine negative controls, each
re-run in isolation with --test-name-pattern (exactly one test, exactly one failure) and each
reverted with the file hash verified back. The isolation guard was exercised in all four states:
missing --home, the real home, an unseeded home and a seeded one.

v0.4.2

Choose a tag to compare

@SunshineR04 SunshineR04 released this 29 Sep 17:03

修复:待删队列不再静默丢失,客户端只在残留守卫下回拉列表

0.4.1 之后一次完整复审的落地产出:四个缺陷,当时的测试套件一个都看不见。

1. 高危 —— 队列文件读不出来时被当成「空队列」,写入者随即把空视图写回

待删队列是删除收尾的唯一凭据,而每个写入者都会把「刚读到的那份快照」写回文件。一次读取失败(Windows EBUSY、非原子回退写造成的撕裂文件、手工编辑)被当成空队列之后,下一次写入会抹掉全部标记;这些 id 的墓碑于是永远留在归档集里 —— listArchived 会把它们显示出来,而 restoreSession 永远恢复不了(文件已删):永久卡死、既无法恢复也无法清除的行。

现在 readPendingSnapshot() 区分两种情况:文件不存在=正常的空队列;读取失败或 JSON 损坏=拒绝执行。_addPending、_removePending、sweepPending、listArchived、restoreLocked 统一返回 session-manager/internal 并在宿主日志留痕,队列文件保持原样。冷会话删除不依赖队列,因此不受影响。

cancelPending 的顺序也随之修正:它原本在清掉墓碑之后才读队列,读失败会留下「无墓碑 + 仍在队列」,下次启动就会执行用户刚刚取消的删除;现在先读队列,失败则什么都没动。

2. 中危 —— 残留修复的「武装」来自 ping,而不是队列读

pendingSeeded 在队列读的结果未知前就被置位,而 pendingDeleteIds 与 store 观察只由成功的读取安装。于是一次瞬时的 deferred/list 失败会让整个残留修复在该页面失效:事件驱动的回拉把墓碑重新灌回 store,宿主重播钩子永远不会被请求 —— 正是 0.4.1 要修的那个现场 bug,而且全程静默。现在置位由 observePending 完成,失败的种子读按 ping 同样的 1s/3s 退避重试,放弃时打一条 console.warn。

3. 中危 —— 持久层列表的返回形状读错,文档承诺的「第二条 seam」一直空转

sessionPersistence.list() 返回的是 { header, revision, sizeBytes } 包装对象,不是裸 header(对照已安装的 dsh-session-persistence-jsonl 与两个官方消费方,后者都 .map((s) => s.header))。把包装当 header 读会让匹配永远失败(String(undefined) !== sessionId),中间那条产物解析 seam 形同虚设;而在没有 session controller 的组合里,列表回退会把每个会话折叠到 'undefined' 键、字段全空。

两处读取改为解包 .header(缺 header 的条目跳过并告警一次),宿主测试的 fake 也改为真实形状 —— 正是它一直返回扁平 header,才让这个缺陷藏了四个版本。新增契约断言用三份内联样本把包装形状钉在已安装的后端上。

4. 中危 —— 组件的列表回拉绕过了排队 id 守卫

「刷新」按钮、refreshUntilGone、取消删除、批量删除都直接调 sessions.refresh()。现在所有显式回拉都走注入的 pullSessions,回拉后立刻复查残留(store 可观察时由订阅覆盖;list.subscribe 缺失或抛错时,这一步是唯一的触发点)。批量删除只要含任意一个打开会话就跳过整批回拉(deleted > 0 && openDeleted === 0),因为这些 id 已由 api-session/removed 从 store 摘除。

验证

  • pnpm test 74/74(宿主 29 + 客户端 36 + 契约 9),新增 6 条测试。
  • 负向对照(逐条还原并校验文件哈希):宽容队列读 → 「an unreadable pending queue is refused…」失败;旧种子置位 → 「a failed first queue read does not disarm…」失败;包装读错 → 两条持久层测试失败;旧批量条件 → 「a mixed bulk run does not re-pull…」失败;去掉回拉后的残留复查 → 「an explicit list pull re-checks the residue even when the store cannot be watched」失败;把快照断言改成永不匹配 → 样本测试与已安装后端测试同时失败(证明守卫没有静默跳过)。
  • 探针复核(临时脚本,跑完即删):修复前「种子读失败 → 事件到达」会回拉墓碑且从不请求修复;修复后同一场景 pulls 0,且重试落地后自动修复一次、不轮询。

已知残留

种子读失败后的约 1 秒重试窗口内到达的 api-session/removed 仍会触发一次多余回拉;重试落地后 store 观察即武装,宿主重播会把该行修掉(自愈,不是卡死)。

English

Fix: the pending queue can no longer be lost, and list pulls go through the residue guard

Four defects from a full review of 0.4.1, none of them visible to the suite as it stood.

1. HIGH — an unreadable queue was reported as an EMPTY one. Every writer persists the snapshot it just read, so the next write-back dropped every live marker. Their tombstones then stayed in the archive set with no queue entry left to sweep them while their files were already gone: permanently stuck rows that listArchived un-hides and restoreSession can never restore. readPendingSnapshot() now separates ENOENT (the normal empty queue) from a read failure or unparseable JSON (a torn file counts — the cross-device fallback write is not atomic), and _addPending / _removePending / sweepPending / listArchived / restoreLocked refuse with session-manager/internal, logging where the error is created. A cold delete reads no queue and still runs. cancelPending had to change order: it read the queue after clearing the tombstone, so a failed read left "no tombstone + still queued" and the next boot would finish the deletion the user had just cancelled.

2. MEDIUM — the residue block was armed by the PING, not by the queue read. One transient deferred/list failure left it disarmed for the page's lifetime: the event-driven pull re-materialised every husk and the host repair was never asked — the 0.4.1 field bug, silently. The flag is now set by observePending, and a failed seed is retried with the ping's own 1s/3s backoff before one console.warn.

3. MEDIUM — the persistence listing's shape was read wrong, so the advertised middle artifact-resolution seam was a permanent no-op and the controller-less listing fallback blanked every field. list() yields { header, revision, sizeBytes } wrappers (verified against the shipped backend and both official consumers). Both read sites unwrap .header, the host fake now encodes the real shape, and a new contract predicate pins it against the installed backend.

4. MEDIUM — component list pulls bypassed the queued-id guard. Every explicit pull now goes through the injected pullSessions, which re-checks the residue right after pulling, and a bulk run containing any open delete skips its whole-run pull.

Verification. pnpm test: 74/74 (29 host + 36 client + 9 contract), six new tests. Six negative controls, each reverted with the file hash verified back — including an unsatisfiable snapshot predicate, which fails BOTH the fixture test and the installed-backend test, proving that guard is not skipping. A throwaway probe reproduces the pre-fix pull and shows the fix returning 0 pulls with one self-healing repair.

Known residual. A removal event arriving inside the ~1s seed-retry window still costs one redundant pull; the watch arms when the retry lands and the host re-announces, so the row self-heals rather than sticking.

v0.4.1

Choose a tag to compare

@SunshineR04 SunshineR04 released this 29 Sep 17:04

修复:被彻底删除的打开会话不再回到视图;确认框改为不透明

现场报告:对打开中的会话执行「彻底删除」后,该行仍出现在侧边栏「未分组」里。

根因

dsh 没有公开的「关闭会话」API。删除一个打开中的会话只能:立即删除磁盘产物 + 摘除工作区记账,并在归档集里留一个墓碑(官方归档过滤器据此在默认视图隐藏它),id 进入待删队列等待下次启动收尾。

于是残留只在一个条件下可见:侧边栏处于 视图选项 → 全部对话(显示已归档)/仅显示已归档。此时「未被任何工作区记账 + 已归档」的墓碑会被渲染进「未分组」——看起来就是「删掉的会话又回来了」。

排除项(见仓库内 docs/analysis-deleted-open-session-still-listed.md §3):dsh 0.1.7-rc.2 → 0.2.0-rc.2 在 session/list、sessionVisible、归档过滤器、SQLite live 行这些环节语义逐行相同,因此这不是更新引入的回归。

被否决的方案

把活会话从 sessions store 里 detach() 看起来能根治,实测会引入 4 类新问题:半关闭生命周期(agent 仍存活到进程结束)、sessions.flush() 对该 id 必然抛错(多个内建模块会命中)、waitForDrainingConfiguredIdentity 挂起、session/disposed 监听把产物写回。完整证据见分析文档 §8.1。

本次改动

  • 客户端不再自我复活:排队 id 由 rpc 包装器统一喂入待删集合;api-session/removed 触发的去抖回拉对排队 id 在调度时与触发时各校验一次(删除响应与事件走不同通道,事件通常先到)。
  • 宿主按需重播移除事件(reannouncePendingRemovals,默认开启):每次读取删除队列时,为仍存活的排队 id 重播官方 api-session/removed,于是「显示已归档」视图也能保持干净。客户端按「一次残留一次修复」触发,不会轮询。
  • 确认框改为官方不透明模态:卡片原先用 --dsw-specific-menu(约 94% 透明度的浮层填充),看起来是半透明的;现在对齐官方 Modal/Button:--dsw-alias-bg-layer-2 + --dsw-radius-panel(28px) + --dsw-elevation-prominent,遮罩用 --dsw-alias-bg-mask-1 + --dsw-mask-blur,按钮用 md 规格(36px / radius-md)。

验证

  • pnpm test 66/66;三条负向对照逐条确认(去掉触发时复校验、换回菜单 token、把修复钩子改回默认关闭,各自都会让对应测试失败)。
  • 隔离实例 + 真实 Chrome:菜单删除一个打开会话后,默认视图与「显示已归档」视图都没有该行;对话框 8 项计算样式与官方实例逐项一致。
  • 已知残留:仅做客户端修复(或把修复钩子设为 false)时,页面重载/另一个客户端会从宿主列表重新学到该 id —— 默认视图仍隐藏,归档视图会显示到下次重启。彻底根治需要上游提供「关闭会话」能力。
English

Fix: a permanently-deleted open session no longer comes back; the confirm dialog is opaque

Field report: after "Delete permanently" on an open session the row still appeared in the sidebar's ungrouped bucket.

Root cause. dsh has no public "close session" API, so deleting an open session can only remove its files and workspace accounting immediately and leave an archive tombstone (which the official archive filter hides in the default view) plus a pending-queue entry for the next-boot finish-up. The residue is therefore visible in exactly one condition: a sidebar set to 视图选项 → 全部对话(显示已归档)/仅显示已归档, where an archived session accounted by no workspace is rendered inside 未分组 — i.e. "the deleted session came back".

Ruled out: dsh 0.1.7-rc.2 → 0.2.0-rc.2 is semantically identical in every module involved (session/list, sessionVisible, the archive filter, the SQLite live rows), so this was not an update regression. See docs/analysis-deleted-open-session-still-listed.md §3.

Rejected fix: detaching the live session from the sessions store would look like a cure but introduces four problems (half-disposed lifecycle, sessions.flush() throwing for that id, waitForDrainingConfiguredIdentity stalling, session/disposed listeners writing artifacts back). Evidence in the analysis doc §8.1.

Changes. (1) The client never pulls the list for a queued id — checked both when the debounced refresh is scheduled and when it fires (the delete response and the event travel on different channels, and the event usually wins). (2) New host config reannouncePendingRemovals (default on): every read of the pending queue re-announces the official api-session/removed for queued ids whose session is still live, so the archived-rows view stays clean too — one repair per residue episode, never a poll loop. (3) The confirm dialog now mirrors the official modal (opaque layer-2 surface, panel radius, prominent elevation, shared mask, Button md geometry) instead of the ~94%-alpha menu fill that read as see-through.

Verification. pnpm test: 66/66, with three negative controls (each reverted). Isolated instance + real Chrome: after a menu delete of an open session the id renders in neither view; the dialog's 8 computed-style checks match the live official dialog.

Known residual. With the client half alone (or reannouncePendingRemovals: false), a page reload or another client re-learns the id from the host's own list: the default view still hides it, and the archived view shows it until the next restart. A complete cure needs an upstream session-close API.